449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
|
||
"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>
|
||
);
|
||
}
|