Files
AiVideo/server/public/index.html
2026-03-25 13:33:48 +08:00

343 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AiVideo POC - Interactive</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; margin: 24px; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
input[type="text"] { width: min(900px, 100%); padding: 10px 12px; font-size: 16px; }
button { padding: 10px 14px; font-size: 16px; cursor: pointer; }
pre { background: #0b1020; color: #d7e1ff; padding: 14px; border-radius: 10px; overflow: auto; }
.scenes { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-top: 12px; }
.card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; }
.k { color: #6b7280; font-size: 12px; margin: 8px 0 2px; }
.v { white-space: pre-wrap; }
.muted { color: #6b7280; }
.videoBox { margin-top: 16px; border-top: 1px solid #e5e7eb; padding-top: 16px; }
video { width: min(980px, 100%); background: #000; border-radius: 10px; }
.toast {
position: fixed;
right: 18px;
bottom: 18px;
max-width: min(520px, calc(100vw - 36px));
background: rgba(20, 24, 33, 0.96);
color: #fff;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
padding: 12px 14px;
box-shadow: 0 10px 30px rgba(0,0,0,0.35);
z-index: 9999;
}
.toast .title { font-weight: 700; margin-bottom: 6px; }
.toast .msg { white-space: pre-wrap; font-size: 13px; opacity: 0.95; }
.toast .close { float: right; cursor: pointer; opacity: 0.8; }
</style>
</head>
<body>
<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script crossorigin src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script type="text/babel">
const { useEffect, useMemo, useRef, useState } = React;
function App() {
const [prompt, setPrompt] = useState("写一个温暖的城市夜景故事");
const [globalStyle, setGlobalStyle] = useState("电影感");
const [characterPreset, setCharacterPreset] = useState("");
const [mock, setMock] = useState(true);
const [logs, setLogs] = useState("");
const [scenes, setScenes] = useState([null, null, null]);
const [canRender, setCanRender] = useState(false);
const [finalVideoUrl, setFinalVideoUrl] = useState("");
const [taskId, setTaskId] = useState("");
const [toast, setToast] = useState("");
const esRef = useRef(null);
const logRef = useRef(null);
const appendLog = (line) => {
setLogs((prev) => prev + line + "\n");
};
const showToast = (msg) => {
setToast(String(msg || "发生错误"));
// auto hide
setTimeout(() => setToast(""), 6000);
};
useEffect(() => {
if (!logRef.current) return;
logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logs]);
const startScript = () => {
stopScript();
setLogs("");
setScenes([null, null, null]);
setCanRender(false);
setFinalVideoUrl("");
setTaskId("");
const url = `/api/script?prompt=${encodeURIComponent(prompt.trim())}&mock=${mock ? "1" : "0"}&global_style=${encodeURIComponent(globalStyle)}&character=${encodeURIComponent(characterPreset)}`;
const es = new EventSource(url);
esRef.current = es;
es.addEventListener("status", (e) => appendLog("[status] " + e.data));
es.addEventListener("error", (e) => {
const m = (e && e.data) ? e.data : "连接或后端错误";
appendLog("[ERROR] " + m);
showToast(m);
});
es.addEventListener("task", (e) => {
try { setTaskId(JSON.parse(e.data).task_id || ""); } catch { }
});
es.addEventListener("done", (e) => {
appendLog("[done] exit_code=" + e.data);
stopScript();
});
es.addEventListener("scene", (e) => {
try {
const obj = JSON.parse(e.data);
setScenes((prev) => {
const next = [...prev];
next[obj.index - 1] = {
index: obj.index,
image_prompt: obj.image_prompt || "",
video_motion: obj.video_motion || "",
narration: obj.narration || "",
};
return next;
});
} catch (err) {
appendLog("[parse_error] " + err);
}
});
es.addEventListener("line", (e) => {
appendLog(e.data);
if (e.data === "SCRIPT_END") setCanRender(true);
});
es.onerror = () => appendLog("[error] connection error");
};
const stopScript = () => {
if (esRef.current) {
esRef.current.close();
esRef.current = null;
}
};
const onEdit = (idx, field, value) => {
setScenes((prev) => {
const next = [...prev];
const cur = next[idx] || { index: idx + 1, image_prompt: "", video_motion: "", narration: "" };
next[idx] = { ...cur, [field]: value };
return next;
});
};
const refineOne = async (sceneIndex) => {
appendLog(`[refine] scene ${sceneIndex}...`);
const s0 = scenes[sceneIndex - 1] || {};
const payloadScene = {
image_prompt: s0.image_prompt || "",
video_motion: s0.video_motion || "",
narration: s0.narration || "",
};
const resp = await fetch("/api/refine", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, scene: payloadScene, scene_index: sceneIndex, mock, global_style: globalStyle, character: characterPreset, task_id: taskId }),
});
const data = await resp.json();
if (!resp.ok) {
appendLog("[refine_error] " + JSON.stringify(data));
showToast((data && (data.error || data.msg)) || "润色失败");
return;
}
const s = data.scene;
setScenes((prev) => {
const next = [...prev];
next[s.index - 1] = {
index: s.index,
image_prompt: s.image_prompt || "",
video_motion: s.video_motion || "",
narration: s.narration || "",
};
return next;
});
appendLog(`[refine] scene ${sceneIndex} done`);
};
const renderVideo = async () => {
appendLog("[render] start...");
const payloadScenes = scenes.map((s, i) => ({
image_prompt: (s && s.image_prompt) || "",
video_motion: (s && s.video_motion) || "",
narration: (s && s.narration) || "",
}));
const resp = await fetch("/api/render", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, scenes: payloadScenes, mock, global_style: globalStyle, character: characterPreset, task_id: taskId }),
});
if (!resp.ok) {
appendLog("[render_error] http " + resp.status);
showToast("渲染请求失败HTTP " + resp.status + "");
return;
}
// Parse SSE from fetch (POST)
const reader = resp.body.getReader();
const decoder = new TextDecoder("utf-8");
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const chunks = buf.split("\n\n");
buf = chunks.pop() || "";
for (const c of chunks) {
const lines = c.split("\n").filter(Boolean);
let event = "message";
const dataLines = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
}
const data = dataLines.join("\n");
if (event === "task") {
try { setTaskId(JSON.parse(data).task_id || ""); } catch { }
} else if (event === "prog") {
appendLog("[prog] " + data);
} else if (event === "error") {
appendLog("[ERROR] " + data);
showToast(data);
} else if (event === "done") {
try {
const obj = JSON.parse(data);
const file = String(obj.output || "").split("/").pop() || "final.mp4";
const tid = taskId || (obj.task_id || "");
appendLog("[render] done: " + file);
if (tid) setFinalVideoUrl(`/api/static/${encodeURIComponent(tid)}/${encodeURIComponent(file)}?t=${Date.now()}`);
} catch (e) {
appendLog("[render_done_parse_error] " + e);
showToast("渲染完成消息解析失败");
}
} else {
appendLog(data);
}
}
}
};
return (
<div>
<h2>AiVideo POC双向交互手搓平台</h2>
<p className="muted">分镜可编辑可单条润色渲染完成后可直接预览与下载</p>
<div className="row">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="row">
<label className="row" style={{ gap: 6 }}>
Global Style:
<select value={globalStyle} onChange={(e) => setGlobalStyle(e.target.value)} style={{ padding: "8px" }}>
<option value="电影感">电影感</option>
<option value="二次元">二次元</option>
<option value="写实">写实</option>
</select>
</label>
<label className="row" style={{ gap: 6 }}>
Character Preset:
<input
type="text"
value={characterPreset}
onChange={(e) => setCharacterPreset(e.target.value)}
placeholder="例如:黑发短发、穿风衣的年轻侦探、冷静目光"
style={{ width: "min(640px, 100%)", padding: "10px 12px", fontSize: 14 }}
/>
</label>
<label className="row" style={{ gap: 6 }}>
<input type="checkbox" checked={mock} onChange={(e) => setMock(e.target.checked)} />
mock ComfyUI / Key 也能跑
</label>
<button onClick={startScript}>生成分镜</button>
<button onClick={stopScript}>停止</button>
{canRender ? (
<button onClick={renderVideo}>确认并开始渲染视频</button>
) : null}
{taskId ? <span className="muted">task_id: {taskId}</span> : null}
</div>
<div className="scenes">
{scenes.map((s, idx) => (
<div className="card" key={idx}>
<div className="row" style={{ justifyContent: "space-between" }}>
<strong>Scene {idx + 1}</strong>
<button style={{ padding: "6px 10px" }} onClick={() => refineOne(idx + 1)}>🔄 重新润色</button>
</div>
<div className="k">image_prompt</div>
<textarea rows="3" style={{ width: "100%", padding: 8 }}
value={(s && s.image_prompt) || ""}
onChange={(e) => onEdit(idx, "image_prompt", e.target.value)}
/>
<div className="k">video_motion</div>
<textarea rows="2" style={{ width: "100%", padding: 8 }}
value={(s && s.video_motion) || ""}
onChange={(e) => onEdit(idx, "video_motion", e.target.value)}
/>
<div className="k">narration</div>
<textarea rows="2" style={{ width: "100%", padding: 8 }}
value={(s && s.narration) || ""}
onChange={(e) => onEdit(idx, "narration", e.target.value)}
/>
</div>
))}
</div>
<div className="videoBox">
<h3>视频预览</h3>
{finalVideoUrl ? (
<div>
<video controls src={finalVideoUrl}></video>
<div className="row" style={{ marginTop: 10 }}>
<a href={finalVideoUrl} download>
<button>下载视频</button>
</a>
</div>
<div className="muted">URL: {finalVideoUrl}</div>
</div>
) : (
<div className="muted">尚未渲染完成</div>
)}
</div>
<h3>原始日志stdout/stderr</h3>
<pre ref={logRef}>{logs}</pre>
{toast ? (
<div className="toast" role="alert">
<span className="close" onClick={() => setToast("")}></span>
<div className="title">发生错误</div>
<div className="msg">{toast}</div>
</div>
) : null}
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
</script>
</body>
</html>