Files
ViGent/frontend/src/app/page.tsx
2026-01-19 18:22:18 +08:00

449 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
// 动态获取 API 地址:服务端使用 localhost客户端使用当前域名
const API_BASE = typeof window !== 'undefined'
? `http://${window.location.hostname}:8006`
: 'http://localhost:8006';
// 类型定义
interface Material {
id: string;
name: string;
scene: string;
size_mb: number;
path: string;
}
interface Task {
task_id: string;
status: string;
progress: number;
message: string;
download_url?: string;
}
export default function Home() {
const [materials, setMaterials] = useState<Material[]>([]);
const [selectedMaterial, setSelectedMaterial] = useState<string>("");
const [text, setText] = useState<string>(
"大家好,欢迎来到我的频道,今天给大家分享一些有趣的内容。"
);
const [voice, setVoice] = useState<string>("zh-CN-YunxiNeural");
const [isGenerating, setIsGenerating] = useState(false);
const [currentTask, setCurrentTask] = useState<Task | null>(null);
const [generatedVideo, setGeneratedVideo] = useState<string | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const [debugData, setDebugData] = useState<string>("");
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
// 可选音色
const voices = [
{ id: "zh-CN-YunxiNeural", name: "云溪 (男声-年轻)" },
{ id: "zh-CN-YunjianNeural", name: "云健 (男声-新闻)" },
{ id: "zh-CN-YunyangNeural", name: "云扬 (男声-专业)" },
{ id: "zh-CN-XiaoxiaoNeural", name: "晓晓 (女声-活泼)" },
{ id: "zh-CN-XiaoyiNeural", name: "晓伊 (女声-温柔)" },
];
// 加载素材列表
useEffect(() => {
fetchMaterials();
}, []);
const fetchMaterials = async () => {
try {
setFetchError(null);
setDebugData("Loading...");
// Add timestamp to prevent caching
const url = `${API_BASE}/api/materials/?t=${new Date().getTime()}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const text = await res.text(); // Get raw text first
setDebugData(text.substring(0, 200) + (text.length > 200 ? "..." : "")); // Show preview
const data = JSON.parse(text);
setMaterials(data.materials || []);
if (data.materials?.length > 0) {
if (!selectedMaterial) {
setSelectedMaterial(data.materials[0].id);
}
}
} catch (error) {
console.error("获取素材失败:", error);
setFetchError(String(error));
setDebugData(`Error: ${String(error)}`);
}
};
// 上传视频
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 验证文件类型
const validTypes = ['.mp4', '.mov', '.avi'];
const ext = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (!validTypes.includes(ext)) {
setUploadError('仅支持 MP4、MOV、AVI 格式');
return;
}
setIsUploading(true);
setUploadProgress(0);
setUploadError(null);
const formData = new FormData();
formData.append('file', file);
// 使用 XMLHttpRequest 以获取上传进度
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
setUploadProgress(progress);
}
};
xhr.onload = () => {
setIsUploading(false);
if (xhr.status >= 200 && xhr.status < 300) {
fetchMaterials(); // 刷新素材列表
setUploadProgress(100);
} else {
setUploadError(`上传失败: ${xhr.statusText}`);
}
};
xhr.onerror = () => {
setIsUploading(false);
setUploadError('网络错误,上传失败');
};
xhr.open('POST', `${API_BASE}/api/materials/`);
xhr.send(formData);
// 清空 input 以便可以再次选择同一文件
e.target.value = '';
};
// 生成视频
const handleGenerate = async () => {
if (!selectedMaterial || !text.trim()) {
alert("请选择素材并输入文案");
return;
}
setIsGenerating(true);
setGeneratedVideo(null);
try {
// 查找选中的素材对象以获取路径
const materialObj = materials.find(m => m.id === selectedMaterial);
if (!materialObj) {
alert("素材数据异常");
return;
}
// 创建生成任务
const res = await fetch(`${API_BASE}/api/videos/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
material_path: materialObj.path,
text: text,
voice: voice,
add_subtitle: true,
}),
});
const data = await res.json();
const taskId = data.task_id;
// 轮询任务状态
const pollTask = async () => {
const taskRes = await fetch(`${API_BASE}/api/videos/tasks/${taskId}`);
const taskData: Task = await taskRes.json();
setCurrentTask(taskData);
if (taskData.status === "completed") {
setGeneratedVideo(`${API_BASE}${taskData.download_url}`);
setIsGenerating(false);
} else if (taskData.status === "failed") {
alert("视频生成失败: " + taskData.message);
setIsGenerating(false);
} else {
setTimeout(pollTask, 1000);
}
};
pollTask();
} catch (error) {
console.error("生成失败:", error);
setIsGenerating(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
{/* Header */}
<header className="border-b border-white/10 bg-black/20 backdrop-blur-sm">
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<span className="text-3xl">🎬</span>
ViGent
</h1>
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧: 输入区域 */}
<div className="space-y-6">
{/* 素材选择 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
📹
</h2>
<div className="flex gap-2">
{/* 隐藏的文件输入 */}
<input
type="file"
id="video-upload"
accept=".mp4,.mov,.avi"
onChange={handleUpload}
className="hidden"
/>
<label
htmlFor="video-upload"
className={`px-3 py-1 text-xs rounded cursor-pointer transition-all ${isUploading
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
}`}
>
📤
</label>
<button
onClick={fetchMaterials}
className="px-3 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300"
>
🔄
</button>
</div>
</div>
{/* 上传进度条 */}
{isUploading && (
<div className="mb-4 p-4 bg-purple-500/10 rounded-xl border border-purple-500/30">
<div className="flex justify-between text-sm text-purple-300 mb-2">
<span>📤 ...</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 bg-black/30 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* 上传错误提示 */}
{uploadError && (
<div className="mb-4 p-4 bg-red-500/20 text-red-200 rounded-xl text-sm flex justify-between items-center">
<span> {uploadError}</span>
<button
onClick={() => setUploadError(null)}
className="text-red-300 hover:text-white"
>
</button>
</div>
)}
{fetchError ? (
<div className="p-4 bg-red-500/20 text-red-200 rounded-xl text-sm mb-4">
: {fetchError}
<br />
API: {API_BASE}/api/materials/
</div>
) : materials.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<div className="text-5xl mb-4">📁</div>
<p></p>
<p className="text-sm mt-2">
📤
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{materials.map((m) => (
<button
key={m.id}
onClick={() => setSelectedMaterial(m.id)}
className={`p-4 rounded-xl border-2 transition-all text-left ${selectedMaterial === m.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<div className="text-white font-medium truncate">
{m.scene || m.name}
</div>
<div className="text-gray-400 text-sm mt-1">
{m.size_mb.toFixed(1)} MB
</div>
</button>
))}
</div>
)}
</div>
{/* 文案输入 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
</h2>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="请输入你想说的话..."
className="w-full h-40 bg-black/30 border border-white/10 rounded-xl p-4 text-white placeholder-gray-500 resize-none focus:outline-none focus:border-purple-500 transition-colors"
/>
<div className="flex justify-between mt-2 text-sm text-gray-400">
<span>{text.length} </span>
<span>: ~{Math.ceil(text.length / 4)} </span>
</div>
</div>
{/* 音色选择 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
🎙
</h2>
<div className="grid grid-cols-2 gap-3">
{voices.map((v) => (
<button
key={v.id}
onClick={() => setVoice(v.id)}
className={`p-3 rounded-xl border-2 transition-all text-left ${voice === v.id
? "border-purple-500 bg-purple-500/20"
: "border-white/10 bg-white/5 hover:border-white/30"
}`}
>
<span className="text-white text-sm">{v.name}</span>
</button>
))}
</div>
</div>
{/* 生成按钮 */}
<button
onClick={handleGenerate}
disabled={isGenerating || !selectedMaterial}
className={`w-full py-4 rounded-xl font-bold text-lg transition-all ${isGenerating || !selectedMaterial
? "bg-gray-600 cursor-not-allowed text-gray-400"
: "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white shadow-lg hover:shadow-purple-500/25"
}`}
>
{isGenerating ? (
<span className="flex items-center justify-center gap-3">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
... {currentTask?.progress || 0}%
</span>
) : (
"🚀 生成视频"
)}
</button>
</div>
{/* 右侧: 预览区域 */}
<div className="space-y-6">
{/* 进度显示 */}
{currentTask && isGenerating && (
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4">
</h2>
<div className="space-y-3">
<div className="h-3 bg-black/30 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-300"
style={{ width: `${currentTask.progress}%` }}
/>
</div>
<p className="text-gray-300">{currentTask.message}</p>
</div>
</div>
)}
{/* 视频预览 */}
<div className="bg-white/5 rounded-2xl p-6 border border-white/10 backdrop-blur-sm">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
🎥
</h2>
<div className="aspect-video bg-black/50 rounded-xl overflow-hidden flex items-center justify-center">
{generatedVideo ? (
<video
src={generatedVideo}
controls
className="w-full h-full object-contain"
/>
) : (
<div className="text-gray-500 text-center">
<div className="text-5xl mb-4">📹</div>
<p></p>
</div>
)}
</div>
{generatedVideo && (
<a
href={generatedVideo}
download
className="mt-4 w-full py-3 rounded-xl bg-green-600 hover:bg-green-700 text-white font-medium flex items-center justify-center gap-2 transition-colors"
>
</a>
)}
</div>
</div>
</div>
</main>
{/* Footer */}
<footer className="border-t border-white/10 mt-12">
<div className="max-w-6xl mx-auto px-6 py-4 text-center text-gray-500 text-sm">
ViGent - MuseTalk + EdgeTTS
</div>
</footer>
</div>
);
}