fix: 优化内容
This commit is contained in:
262
server/index.js
262
server/index.js
@@ -2,14 +2,34 @@ import express from "express";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const outputsDir = path.join(repoRoot, "outputs");
|
||||
fs.mkdirSync(outputsDir, { recursive: true });
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
app.use(
|
||||
"/api/static",
|
||||
express.static(outputsDir, {
|
||||
fallthrough: true,
|
||||
setHeaders: (res) => {
|
||||
// Important: avoid stale video preview.
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
function sseHeaders(res) {
|
||||
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
@@ -25,9 +45,46 @@ function sseSend(res, event, data) {
|
||||
res.write("\n");
|
||||
}
|
||||
|
||||
app.get("/api/run", (req, res) => {
|
||||
function newTaskId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function taskDir(taskId) {
|
||||
return path.join(outputsDir, taskId);
|
||||
}
|
||||
|
||||
function ensureTaskDir(taskId) {
|
||||
const dir = taskDir(taskId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function spawnPythonStep({ step, prompt, configPath, mock, globalStyle, character, taskId, sceneIndex }) {
|
||||
const py = process.env.PYTHON_BIN || "python3.10";
|
||||
const args = [
|
||||
"-m",
|
||||
"engine.main",
|
||||
"--prompt",
|
||||
prompt,
|
||||
"--config",
|
||||
configPath,
|
||||
"--step",
|
||||
step,
|
||||
"--task-id",
|
||||
taskId,
|
||||
];
|
||||
if (sceneIndex) args.push("--scene-index", String(sceneIndex));
|
||||
if (globalStyle) args.push("--global-style", globalStyle);
|
||||
if (character) args.push("--character", character);
|
||||
if (mock) args.push("--mock");
|
||||
return spawn(py, args, { cwd: repoRoot, env: process.env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
app.get("/api/script", (req, res) => {
|
||||
const prompt = String(req.query.prompt || "").trim();
|
||||
const mock = String(req.query.mock || "1") === "1";
|
||||
const globalStyle = String(req.query.global_style || "").trim();
|
||||
const character = String(req.query.character || "").trim();
|
||||
const configPath = String(req.query.config || "./configs/config.yaml");
|
||||
|
||||
if (!prompt) {
|
||||
@@ -35,25 +92,21 @@ app.get("/api/run", (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = newTaskId();
|
||||
ensureTaskDir(taskId);
|
||||
|
||||
sseHeaders(res);
|
||||
sseSend(res, "task", JSON.stringify({ task_id: taskId }));
|
||||
sseSend(res, "status", "starting");
|
||||
|
||||
// Unified in-container execution: Node spawns python directly.
|
||||
const py = process.env.PYTHON_BIN || "python";
|
||||
const args = [
|
||||
path.join(repoRoot, "main.py"),
|
||||
"--prompt",
|
||||
const child = spawnPythonStep({
|
||||
step: "script",
|
||||
prompt,
|
||||
"--config",
|
||||
configPath,
|
||||
"--script-only",
|
||||
];
|
||||
if (mock) args.push("--mock");
|
||||
|
||||
const child = spawn(py, args, {
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
mock,
|
||||
globalStyle,
|
||||
character,
|
||||
taskId,
|
||||
});
|
||||
|
||||
let buf = "";
|
||||
@@ -64,14 +117,15 @@ app.get("/api/run", (req, res) => {
|
||||
buf = parts.pop() || "";
|
||||
for (const line of parts) {
|
||||
if (!line) continue;
|
||||
// Forward raw lines. Frontend will parse SCENE_JSON.
|
||||
sseSend(res, "line", line);
|
||||
if (line.startsWith("SCENE_JSON ")) sseSend(res, "scene", line.slice("SCENE_JSON ".length));
|
||||
else if (line.startsWith("PROG ")) sseSend(res, "prog", line.slice("PROG ".length));
|
||||
else sseSend(res, "line", line);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => {
|
||||
sseSend(res, "stderr", chunk);
|
||||
sseSend(res, "error", chunk);
|
||||
});
|
||||
|
||||
req.on("close", () => {
|
||||
@@ -80,13 +134,177 @@ app.get("/api/run", (req, res) => {
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (buf.trim()) sseSend(res, "line", buf.trim());
|
||||
sseSend(res, "done", String(code ?? 0));
|
||||
sseSend(res, "done", String(code != null ? code : 0));
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
app.listen(port, () => {
|
||||
console.log(`[server] http://127.0.0.1:${port}`);
|
||||
app.post("/api/refine", (req, res) => {
|
||||
const prompt = String((req.body && req.body.prompt) || "").trim();
|
||||
const sceneIndex = Number((req.body && req.body.scene_index) || 1);
|
||||
const scenes = req.body && req.body.scenes;
|
||||
const scene = req.body && req.body.scene;
|
||||
const mock = Boolean((req.body && req.body.mock) != null ? req.body.mock : true);
|
||||
const globalStyle = String((req.body && req.body.global_style) || "").trim();
|
||||
const character = String((req.body && req.body.character) || "").trim();
|
||||
const configPath = String((req.body && req.body.config) || "./configs/config.yaml");
|
||||
const taskId = String((req.body && req.body.task_id) || "").trim() || newTaskId();
|
||||
|
||||
if (!prompt) return res.status(400).json({ error: "missing prompt" });
|
||||
if (!Number.isFinite(sceneIndex) || sceneIndex < 1) return res.status(400).json({ error: "bad scene_index" });
|
||||
if (!Array.isArray(scenes) && (!scene || typeof scene !== "object")) {
|
||||
return res.status(400).json({ error: "missing scene or scenes[]" });
|
||||
}
|
||||
ensureTaskDir(taskId);
|
||||
|
||||
const child = spawnPythonStep({
|
||||
step: "refine",
|
||||
prompt,
|
||||
configPath,
|
||||
mock,
|
||||
globalStyle,
|
||||
character,
|
||||
taskId,
|
||||
sceneIndex,
|
||||
});
|
||||
if (Array.isArray(scenes)) {
|
||||
child.stdin.end(JSON.stringify({ scenes }));
|
||||
} else {
|
||||
child.stdin.end(JSON.stringify({ scene }));
|
||||
}
|
||||
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (c) => (out += c));
|
||||
child.stderr.on("data", (c) => (err += c));
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) return res.status(500).json({ error: "python failed", stderr: err, stdout: out });
|
||||
const line = out
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.find((s) => s.startsWith("SCENE_JSON "));
|
||||
if (!line) return res.status(500).json({ error: "no SCENE_JSON", stderr: err, stdout: out });
|
||||
const payload = JSON.parse(line.slice("SCENE_JSON ".length));
|
||||
return res.json({ task_id: taskId, scene: payload, stderr: err });
|
||||
});
|
||||
});
|
||||
|
||||
let isBusy = false;
|
||||
|
||||
app.post("/api/render", (req, res) => {
|
||||
const prompt = String((req.body && req.body.prompt) || "").trim();
|
||||
const scenes = req.body && req.body.scenes;
|
||||
const mock = Boolean((req.body && req.body.mock) != null ? req.body.mock : false);
|
||||
const globalStyle = String((req.body && req.body.global_style) || "").trim();
|
||||
const character = String((req.body && req.body.character) || "").trim();
|
||||
const configPath = String((req.body && req.body.config) || "./configs/config.yaml");
|
||||
const taskId = String((req.body && req.body.task_id) || "").trim() || newTaskId();
|
||||
|
||||
if (!prompt) return res.status(400).json({ error: "missing prompt" });
|
||||
if (!Array.isArray(scenes)) return res.status(400).json({ error: "missing scenes[]" });
|
||||
ensureTaskDir(taskId);
|
||||
|
||||
if (isBusy) {
|
||||
return res.status(429).json({ error: "busy", msg: "GPU is busy, try later" });
|
||||
}
|
||||
isBusy = true;
|
||||
|
||||
sseHeaders(res);
|
||||
sseSend(res, "task", JSON.stringify({ task_id: taskId }));
|
||||
sseSend(res, "status", "render_start");
|
||||
|
||||
const child = spawnPythonStep({
|
||||
step: "render",
|
||||
prompt,
|
||||
configPath,
|
||||
mock,
|
||||
globalStyle,
|
||||
character,
|
||||
taskId,
|
||||
});
|
||||
child.stdin.end(JSON.stringify({ scenes }));
|
||||
|
||||
let buf = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buf += chunk;
|
||||
const parts = buf.split(/\r?\n/);
|
||||
buf = parts.pop() || "";
|
||||
for (const line of parts) {
|
||||
if (!line) continue;
|
||||
if (line.startsWith("PROG ")) sseSend(res, "prog", line.slice("PROG ".length));
|
||||
else if (line.startsWith("RENDER_DONE ")) sseSend(res, "done", line.slice("RENDER_DONE ".length));
|
||||
else sseSend(res, "line", line);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
sseSend(res, "error", chunk);
|
||||
});
|
||||
|
||||
req.on("close", () => {
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
isBusy = false;
|
||||
if (buf.trim()) sseSend(res, "line", buf.trim());
|
||||
if (code !== 0) sseSend(res, "error", `[ERROR] python exit_code=${code}`);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
async function runSelfCheck() {
|
||||
const py = process.env.PYTHON_BIN || "python3.10";
|
||||
const checks = [
|
||||
{ name: "check_comfy", args: ["scripts/check_comfy.py"] },
|
||||
{ name: "inspect_comfy_node", args: ["scripts/inspect_comfy_node.py"] },
|
||||
];
|
||||
for (const c of checks) {
|
||||
const deadline = Date.now() + 90_000;
|
||||
let lastErr = "";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(py, c.args, { cwd: repoRoot, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) return resolve(true);
|
||||
reject(new Error(`${c.name} failed (code=${code})\n${err || out}`));
|
||||
});
|
||||
});
|
||||
lastErr = "";
|
||||
break;
|
||||
} catch (e) {
|
||||
lastErr = String(e);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
if (lastErr) {
|
||||
throw new Error(lastErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
(async () => {
|
||||
try {
|
||||
await runSelfCheck();
|
||||
app.listen(port, () => {
|
||||
console.log(`[server] http://127.0.0.1:${port}`);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user