<!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>

