533 lines
24 KiB
HTML
533 lines
24 KiB
HTML
<!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>
|
||
|
||
<!-- Suppress noisy in-browser Babel warning (dev-only). -->
|
||
<script>
|
||
(function () {
|
||
const ow = console.warn && console.warn.bind ? console.warn.bind(console) : console.warn;
|
||
if (!ow) return;
|
||
console.warn = function () {
|
||
const first = arguments && arguments.length ? String(arguments[0] || "") : "";
|
||
if (first.includes("in-browser Babel transformer")) return;
|
||
return ow.apply(console, arguments);
|
||
};
|
||
})();
|
||
</script>
|
||
|
||
<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 [llmProvider, setLlmProvider] = useState("mock");
|
||
const [imageProvider, setImageProvider] = useState("mock");
|
||
const [imageFallbackProvider, setImageFallbackProvider] = useState("mock");
|
||
const [scenes, setScenes] = useState([]);
|
||
const [stageLogs, setStageLogs] = useState({ Script: [], Refine: [], Render: [] });
|
||
const [stageState, setStageState] = useState({
|
||
Script: "pending",
|
||
Refine: "pending",
|
||
Render: "pending",
|
||
});
|
||
const [activeLogStage, setActiveLogStage] = useState("Script");
|
||
const [renderProgress, setRenderProgress] = useState(0);
|
||
const [canRender, setCanRender] = useState(false);
|
||
const [finalVideoUrl, setFinalVideoUrl] = useState("");
|
||
const [taskId, setTaskId] = useState("");
|
||
const [toast, setToast] = useState("");
|
||
const [scriptRunning, setScriptRunning] = useState(false);
|
||
|
||
const esRef = useRef(null);
|
||
const logRef = useRef(null);
|
||
const autoTimerRef = useRef(null);
|
||
|
||
const appendStageLog = (stage, line) => {
|
||
setStageLogs((prev) => ({ ...prev, [stage]: [...(prev[stage] || []), String(line)] }));
|
||
};
|
||
|
||
const showToast = (msg) => {
|
||
setToast(String(msg || "发生错误"));
|
||
setTimeout(() => setToast(""), 6000);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!logRef.current) return;
|
||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||
}, [stageLogs]);
|
||
|
||
const normalizeScene = (raw, idx) => {
|
||
const s = raw || {};
|
||
return {
|
||
index: Number(s.index || idx + 1),
|
||
image_prompt: String(s.image_prompt || ""),
|
||
video_motion: String(s.video_motion || ""),
|
||
narration: String(s.narration || ""),
|
||
preview_url: String(s.preview_url || ""),
|
||
motion_camera: String(s.motion_camera || ""),
|
||
motion_direction: String(s.motion_direction || ""),
|
||
motion_speed: String(s.motion_speed || "normal"),
|
||
};
|
||
};
|
||
|
||
const parseStageUpdate = (raw) => {
|
||
if (!raw || typeof raw !== "object") return null;
|
||
if (Number(raw.schema_version) !== 1) return null;
|
||
const stage = String(raw.stage || "");
|
||
if (!["Script", "Refine", "Render"].includes(stage)) return null;
|
||
return raw;
|
||
};
|
||
|
||
const applyStageUpdate = (u) => {
|
||
const stage = String(u.stage || "Script");
|
||
setStageState((prev) => ({ ...prev, [stage]: prev[stage] === "done" ? "done" : "running" }));
|
||
if (typeof u.progress === "number" && stage === "Render") {
|
||
setRenderProgress(Math.max(0, Math.min(1, u.progress)));
|
||
}
|
||
if (u.scene_json) {
|
||
const scene = u.scene_json;
|
||
const idx = Math.max(0, Number(scene.index || 1) - 1);
|
||
setScenes((prev) => {
|
||
const next = [...prev];
|
||
next[idx] = normalizeScene(scene, idx);
|
||
return next;
|
||
});
|
||
setCanRender(true);
|
||
}
|
||
if (u.message) appendStageLog(stage, u.message);
|
||
if (u.shot_id && u.shot_status) appendStageLog(stage, `${u.shot_id} -> ${u.shot_status}`);
|
||
};
|
||
|
||
const stopScript = () => {
|
||
if (esRef.current) {
|
||
esRef.current.close();
|
||
esRef.current = null;
|
||
}
|
||
setScriptRunning(false);
|
||
};
|
||
|
||
const startScript = () => {
|
||
const p = prompt.trim();
|
||
if (!p) return;
|
||
stopScript();
|
||
setScriptRunning(true);
|
||
setCanRender(false);
|
||
setFinalVideoUrl("");
|
||
setRenderProgress(0);
|
||
setScenes([]);
|
||
setStageLogs({ Script: [], Refine: [], Render: [] });
|
||
setStageState({ Script: "running", Refine: "pending", Render: "pending" });
|
||
setActiveLogStage("Script");
|
||
|
||
const q = new URLSearchParams({
|
||
prompt: p,
|
||
mock: mock ? "1" : "0",
|
||
global_style: globalStyle,
|
||
character: characterPreset,
|
||
llm_provider: llmProvider,
|
||
image_provider: imageProvider,
|
||
image_fallback_provider: imageFallbackProvider,
|
||
});
|
||
const es = new EventSource(`/api/script?${q.toString()}`);
|
||
esRef.current = es;
|
||
|
||
es.addEventListener("task", (e) => {
|
||
try { setTaskId(JSON.parse(e.data).task_id || ""); } catch (err) { }
|
||
});
|
||
es.addEventListener("stage_update", (e) => {
|
||
try {
|
||
const parsed = parseStageUpdate(JSON.parse(e.data));
|
||
if (!parsed) return appendStageLog("Script", "[schema_error] invalid stage_update payload");
|
||
applyStageUpdate(parsed);
|
||
} catch (err) { appendStageLog("Script", "[parse_error] " + err); }
|
||
});
|
||
es.addEventListener("line", (e) => appendStageLog("Script", e.data));
|
||
es.addEventListener("error", (e) => {
|
||
setStageState((prev) => ({ ...prev, Script: "failed" }));
|
||
showToast((e && e.data) ? e.data : "script连接错误");
|
||
});
|
||
es.addEventListener("done", () => {
|
||
stopScript();
|
||
setCanRender(true);
|
||
setStageState((prev) => ({ ...prev, Script: "done" }));
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
|
||
autoTimerRef.current = setTimeout(() => {
|
||
if (!prompt.trim()) return;
|
||
startScript();
|
||
}, 700);
|
||
return () => {
|
||
if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
|
||
};
|
||
}, [prompt, globalStyle, characterPreset, llmProvider, imageProvider, imageFallbackProvider, mock]);
|
||
|
||
const updateMotionField = (idx, field, value) => {
|
||
setScenes((prev) => {
|
||
const next = [...prev];
|
||
const cur = normalizeScene(next[idx], idx);
|
||
const merged = { ...cur, [field]: value };
|
||
merged.video_motion = [merged.motion_camera, merged.motion_direction, merged.motion_speed].filter(Boolean).join(" | ");
|
||
next[idx] = merged;
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const onEdit = (idx, field, value) => {
|
||
setScenes((prev) => {
|
||
const next = [...prev];
|
||
const cur = normalizeScene(next[idx], idx);
|
||
next[idx] = { ...cur, [field]: value };
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const refineOne = async (sceneIndex) => {
|
||
setStageState((prev) => ({ ...prev, Refine: "running" }));
|
||
appendStageLog("Refine", `scene ${sceneIndex} refining...`);
|
||
const s0 = scenes[sceneIndex - 1] || {};
|
||
const resp = await fetch("/api/refine", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
prompt,
|
||
scene: {
|
||
image_prompt: s0.image_prompt || "",
|
||
video_motion: s0.video_motion || "",
|
||
narration: s0.narration || "",
|
||
},
|
||
scene_index: sceneIndex,
|
||
mock,
|
||
global_style: globalStyle,
|
||
character: characterPreset,
|
||
task_id: taskId,
|
||
llm_provider: llmProvider,
|
||
image_provider: imageProvider,
|
||
image_fallback_provider: imageFallbackProvider,
|
||
}),
|
||
});
|
||
if (!resp.ok) {
|
||
setStageState((prev) => ({ ...prev, Refine: "failed" }));
|
||
showToast("润色请求失败(HTTP " + resp.status + ")");
|
||
return;
|
||
}
|
||
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 (err) { }
|
||
} else if (event === "stage_update") {
|
||
try {
|
||
const parsed = parseStageUpdate(JSON.parse(data));
|
||
if (!parsed) return appendStageLog("Refine", "[schema_error] invalid stage_update payload");
|
||
applyStageUpdate(parsed);
|
||
} catch (err) { appendStageLog("Refine", "[parse_error] " + err); }
|
||
} else if (event === "error") {
|
||
appendStageLog("Refine", "[ERROR] " + data);
|
||
setStageState((prev) => ({ ...prev, Refine: "failed" }));
|
||
showToast(data);
|
||
} else if (event === "done") {
|
||
appendStageLog("Refine", `scene ${sceneIndex} done`);
|
||
setStageState((prev) => ({ ...prev, Refine: "done" }));
|
||
} else if (event === "line") {
|
||
appendStageLog("Refine", data);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const renderVideo = async () => {
|
||
setStageState((prev) => ({ ...prev, Render: "running" }));
|
||
setRenderProgress(0);
|
||
appendStageLog("Render", "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,
|
||
llm_provider: llmProvider,
|
||
image_provider: imageProvider,
|
||
image_fallback_provider: imageFallbackProvider,
|
||
}),
|
||
});
|
||
if (!resp.ok) {
|
||
setStageState((prev) => ({ ...prev, Render: "failed" }));
|
||
showToast("渲染请求失败(HTTP " + resp.status + ")");
|
||
return;
|
||
}
|
||
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 (err) { }
|
||
} else if (event === "stage_update") {
|
||
try {
|
||
const parsed = parseStageUpdate(JSON.parse(data));
|
||
if (!parsed) return appendStageLog("Render", "[schema_error] invalid stage_update payload");
|
||
applyStageUpdate(parsed);
|
||
} catch (err) { appendStageLog("Render", "[parse_error] " + err); }
|
||
} else if (event === "error") {
|
||
appendStageLog("Render", "[ERROR] " + data);
|
||
setStageState((prev) => ({ ...prev, Render: "failed" }));
|
||
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 || "");
|
||
if (tid) setFinalVideoUrl(`/api/static/${encodeURIComponent(tid)}/${encodeURIComponent(file)}?t=${Date.now()}`);
|
||
setRenderProgress(1);
|
||
setStageState((prev) => ({ ...prev, Render: "done" }));
|
||
} catch (e) {
|
||
setStageState((prev) => ({ ...prev, Render: "failed" }));
|
||
showToast("渲染完成消息解析失败");
|
||
}
|
||
} else if (event === "line") {
|
||
appendStageLog("Render", data);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const activeLogs = useMemo(() => (stageLogs[activeLogStage] || []).join("\n"), [stageLogs, activeLogStage]);
|
||
const stageColor = (s) => {
|
||
if (s === "done") return "#16a34a";
|
||
if (s === "running") return "#2563eb";
|
||
if (s === "failed") return "#dc2626";
|
||
return "#9ca3af";
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<h2>AiVideo POC:多后端模型平台</h2>
|
||
<p className="muted">自动拆分 Prompt,SSE 实时反馈,Render 全链路可视化。</p>
|
||
|
||
<div className="row">
|
||
<input type="text" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||
</div>
|
||
|
||
<div className="row">
|
||
<label>LLM Provider:
|
||
<select value={llmProvider} onChange={(e) => setLlmProvider(e.target.value)} style={{ marginLeft: 6, padding: 8 }}>
|
||
<option value="mock">mock</option>
|
||
<option value="openai">openai</option>
|
||
</select>
|
||
</label>
|
||
<label>Image Provider:
|
||
<select value={imageProvider} onChange={(e) => setImageProvider(e.target.value)} style={{ marginLeft: 6, padding: 8 }}>
|
||
<option value="mock">mock</option>
|
||
<option value="comfy">comfy</option>
|
||
<option value="replicate">replicate</option>
|
||
<option value="openai">openai</option>
|
||
</select>
|
||
</label>
|
||
<label>Fallback:
|
||
<select value={imageFallbackProvider} onChange={(e) => setImageFallbackProvider(e.target.value)} style={{ marginLeft: 6, padding: 8 }}>
|
||
<option value="mock">mock</option>
|
||
<option value="comfy">comfy</option>
|
||
</select>
|
||
</label>
|
||
<label className="row" style={{ gap: 6 }}>
|
||
<input type="checkbox" checked={mock} onChange={(e) => setMock(e.target.checked)} />
|
||
mock flag
|
||
</label>
|
||
</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)} style={{ width: "min(520px, 100%)", padding: "10px 12px", fontSize: 14 }} />
|
||
</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="row" style={{ marginTop: 10, alignItems: "stretch" }}>
|
||
{["Script", "Refine", "Render"].map((s) => (
|
||
<button
|
||
key={s}
|
||
onClick={() => setActiveLogStage(s)}
|
||
style={{
|
||
flex: 1,
|
||
border: `1px solid ${stageColor(stageState[s])}`,
|
||
borderRadius: 10,
|
||
padding: 10,
|
||
textAlign: "left",
|
||
background: activeLogStage === s ? "#eef2ff" : "#fff",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: 700 }}>{s}</div>
|
||
<div className="muted" style={{ color: stageColor(stageState[s]) }}>{stageState[s]}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ marginTop: 10 }}>
|
||
<div className="muted">Render Progress: {(renderProgress * 100).toFixed(0)}%</div>
|
||
<div style={{ width: "100%", height: 10, background: "#e5e7eb", borderRadius: 8, overflow: "hidden" }}>
|
||
<div style={{ width: `${Math.max(0, Math.min(100, renderProgress * 100))}%`, height: "100%", background: "#2563eb" }} />
|
||
</div>
|
||
</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>
|
||
{s && s.preview_url ? (
|
||
<div style={{ marginTop: 8 }}>
|
||
<img src={s.preview_url + `?t=${Date.now()}`} style={{ width: "100%", borderRadius: 8 }} />
|
||
</div>
|
||
) : null}
|
||
<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">motion</div>
|
||
<div className="row">
|
||
<input type="text" placeholder="camera(如 push-in)" value={(s && s.motion_camera) || ""} onChange={(e) => updateMotionField(idx, "motion_camera", e.target.value)} style={{ padding: 8 }} />
|
||
<input type="text" placeholder="direction(如 left->right)" value={(s && s.motion_direction) || ""} onChange={(e) => updateMotionField(idx, "motion_direction", e.target.value)} style={{ padding: 8 }} />
|
||
<select value={(s && s.motion_speed) || "normal"} onChange={(e) => updateMotionField(idx, "motion_speed", e.target.value)} style={{ padding: 8 }}>
|
||
<option value="slow">slow</option>
|
||
<option value="normal">normal</option>
|
||
<option value="fast">fast</option>
|
||
</select>
|
||
</div>
|
||
<div className="k">video_motion (raw)</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>下载 final.mp4</button></a>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="muted">尚未渲染完成。</div>
|
||
)}
|
||
</div>
|
||
|
||
<h3>分阶段日志:{activeLogStage}</h3>
|
||
<pre ref={logRef}>{activeLogs}</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>
|
||
|