fix: 优化内容

This commit is contained in:
Daniel
2026-03-25 13:33:48 +08:00
parent f99098ec58
commit 8991f2a2d7
14 changed files with 1417 additions and 277 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AiVideo POC - Script Stream Test</title>
<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; }
@@ -15,108 +15,327 @@
.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>
<h2>AiVideo POC实时分镜脚本流测试</h2>
<p class="muted">点击运行后,页面会通过 SSE 实时接收 Python stdout并把分镜渲染到下方。</p>
<div id="root"></div>
<div class="row">
<input id="prompt" type="text" value="写一个温暖的城市夜景故事" />
<label class="row" style="gap:6px;">
<input id="mock" type="checkbox" checked />
mock无 ComfyUI / 无 Key 也能跑)
</label>
<button id="run">运行</button>
<button id="stop">停止</button>
</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>
<div class="scenes" id="scenes"></div>
<script type="text/babel">
const { useEffect, useMemo, useRef, useState } = React;
<h3>原始日志stdout/stderr</h3>
<pre id="log"></pre>
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("");
<script>
const $ = (id) => document.getElementById(id);
const logEl = $("log");
const scenesEl = $("scenes");
let es = null;
const esRef = useRef(null);
const logRef = useRef(null);
function log(line) {
logEl.textContent += line + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
const appendLog = (line) => {
setLogs((prev) => prev + line + "\n");
};
function upsertScene(scene) {
const id = "scene-" + scene.index;
let card = document.getElementById(id);
if (!card) {
card = document.createElement("div");
card.className = "card";
card.id = id;
scenesEl.appendChild(card);
}
card.innerHTML = `
<div><strong>Scene ${scene.index}</strong></div>
<div class="k">image_prompt</div><div class="v">${escapeHtml(scene.image_prompt)}</div>
<div class="k">video_motion</div><div class="v">${escapeHtml(scene.video_motion || "")}</div>
<div class="k">narration</div><div class="v">${escapeHtml(scene.narration)}</div>
`;
}
const showToast = (msg) => {
setToast(String(msg || "发生错误"));
// auto hide
setTimeout(() => setToast(""), 6000);
};
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
useEffect(() => {
if (!logRef.current) return;
logRef.current.scrollTop = logRef.current.scrollHeight;
}, [logs]);
function start() {
stop();
logEl.textContent = "";
scenesEl.innerHTML = "";
const startScript = () => {
stopScript();
setLogs("");
setScenes([null, null, null]);
setCanRender(false);
setFinalVideoUrl("");
setTaskId("");
const prompt = $("prompt").value.trim();
const mock = $("mock").checked ? "1" : "0";
if (!prompt) return;
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;
const url = `/api/run?prompt=${encodeURIComponent(prompt)}&mock=${mock}`;
es = new EventSource(url);
es.addEventListener("status", (e) => log("[status] " + e.data));
es.addEventListener("stderr", (e) => log("[stderr] " + e.data));
es.addEventListener("done", (e) => {
log("[done] exit_code=" + e.data);
stop();
});
es.addEventListener("line", (e) => {
const line = e.data;
log(line);
if (line.startsWith("SCENE_JSON ")) {
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(line.slice("SCENE_JSON ".length));
upsertScene(obj);
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) {
log("[parse_error] " + 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);
}
}
}
});
es.onerror = () => {
log("[error] connection error");
};
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>
);
}
function stop() {
if (es) {
es.close();
es = null;
}
}
$("run").addEventListener("click", start);
$("stop").addEventListener("click", stop);
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
</script>
</body>
</html>