🎬 Z Storyboard v2 · 静态化分镜

剧本 → AI 场景切分 → 静态画面分镜 Excel · Cloudflare 代理 · 断点续传
⏸ 检测到上次未完成的任务

🤖 模型设置 0 个服务

🔐 默认走 Cloudflare 服务端代理 (/api/chat),密钥由服务端持有,前端无需填写 API Key。
场景切分推荐 GPT 5.2 / Grok 4.2 BETA(理解力强);分镜细化推荐 Grok 4.0(创作性强、速度快)。
想用自己的 API?新增服务时把 URL 改成完整地址(如 https://openrouter.ai/api/v1/chat/completions)即可,此时需填 Key。
⚙️
请点击「+ 新增服务」添加在线 API
支持 GPTProto / OpenRouter / 任何 OpenAI 兼容接口

📜 Prompt 模板 0 个

模板将作为 system prompt 用于 Stage 2 分镜细化。点击「✏ 编辑」直接修改内容。
加载中...
支持 .txt / .docx / .xlsx

📄 选择剧本文件

点击或拖拽文件到此 支持 .txt / .docx / .xlsx

⚙️ 转换参数

}`); storyText = await extractText(opts.file); if (!storyText.trim()) throw new Error('剧本内容为空'); logMsg(`剧本字数: ${storyText.length}`); setPhase('Stage 1 · 场景切分'); setProg(5); scenes = await runStage1(svcStage1, storyText, logMsg, jsonRetries); sceneCuts = {}; sceneErrors = {}; // 保存断点 RESUME.save({ fileName, storyText, scenes, sceneCuts, sceneErrors, promptName, promptText, ts: Date.now(), }); } setStat(scenes.length, Object.keys(sceneCuts).length, 0, Object.keys(sceneErrors).length); renderSceneStatus(scenes, sceneCuts, sceneErrors); setProg(15); // ---- Stage 2: 并发跑每个场景 ---- setPhase('Stage 2 · 分镜细化'); const remaining = scenes.filter(s => !sceneCuts[s.scene_id]); logMsg(`Stage 2 待处理: ${remaining.length} / ${scenes.length} 场景, 并发=${concurrency}`); const tasks = remaining.map(scene => async () => { if (cancelFlag) return null; logMsg(` → 处理场景 #${scene.scene_id} (${scene.location})`); const r = await runStage2OneScene(svcStage2, promptText, scene, temperature, aiPromptLang, jsonRetries); if (r.ok) { sceneCuts[r.scene_id] = r.cuts; logMsg(` ✔ 场景 #${r.scene_id} → ${r.cuts.length} cuts`); } else { sceneErrors[r.scene_id] = r.error; logMsg(` ✘ 场景 #${r.scene_id} 失败: ${r.error}`); } // 增量保存断点 RESUME.save({ fileName, storyText, scenes, sceneCuts, sceneErrors, promptName, promptText, ts: Date.now(), }); // 更新 UI const doneCount = Object.keys(sceneCuts).length; const failedCount = Object.keys(sceneErrors).length; const totalCuts = Object.values(sceneCuts).reduce((a, b) => a + b.length, 0); setStat(scenes.length, doneCount, totalCuts, failedCount); renderSceneStatus(scenes, sceneCuts, sceneErrors); setProg(15 + Math.floor((doneCount + failedCount) / scenes.length * 80)); return r; }); await runWithConcurrency(tasks, concurrency, null, () => cancelFlag); if (cancelFlag) { setStatus('paused', 'PAUSED'); setPhase('已暂停 (可重新点击「开始」继续)'); logMsg('⏸ 已暂停'); $('submitBtn').style.display = ''; $('cancelBtn').style.display = 'none'; return; } // ---- 输出 Excel ---- setPhase('Stage 3 · 生成 Excel'); setProg(96); logMsg('生成 Excel...'); const blob = buildXlsx({ promptText, scenes, sceneCuts, archivePrompt, }); const baseName = fileName.replace(/\.[^.]+$/, ''); const outName = `storyboard_${baseName}_${nowTs()}.xlsx`; setProg(100); const url = URL.createObjectURL(blob); const dlBtn = document.createElement('button'); dlBtn.textContent = '⬇ 下载 ' + outName; dlBtn.onclick = () => { const a = document.createElement('a'); a.href = url; a.download = outName; document.body.appendChild(a); a.click(); a.remove(); }; $('downloadArea').appendChild(dlBtn); const failedCount = Object.keys(sceneErrors).length; if (failedCount === 0) { setStatus('done', 'DONE'); setPhase('全部完成'); logMsg(`✔ 全部完成 → ${outName}`); RESUME.clear(); refreshResumeBanner(); } else { setStatus('failed', `${failedCount} FAILED`); setPhase(`完成 (${failedCount} 个场景失败,可重新运行重试失败项)`); logMsg(`⚠ 完成,但有 ${failedCount} 个场景失败。状态已保留,可点击「继续」重试`); } $('submitBtn').style.display = ''; $('cancelBtn').style.display = 'none'; } // ============================================================================ // 断点续传 UI // ============================================================================ function refreshResumeBanner() { const st = RESUME.load(); const banner = $('resumeBanner'); if (!st) { banner.classList.remove('show'); return; } const total = (st.scenes || []).length; const done = Object.keys(st.sceneCuts || {}).length; const failed = Object.keys(st.sceneErrors || {}).length; $('resumeBannerInfo').innerHTML = `文件: ${escapeHtml(st.fileName || '?')} · ` + `场景: ${done} 完成 / ${failed} 失败 / 共 ${total} · ` + `时间: ${new Date(st.ts || 0).toLocaleString()}`; banner.classList.add('show'); } $('resumeBtn').addEventListener('click', async () => { const st = RESUME.load(); if (!st) return; // 重试失败的场景:把 sceneErrors 中的 scene_id 从 sceneCuts 中移除(虽然它们已经不在), // 然后再次跑 runMain 即可(remaining = scenes 中没有 sceneCuts 的) // 默认: 失败场景视为未完成,会被重试 st.sceneErrors = {}; RESUME.save(st); try { await runMain({ resume: st }); } catch(e) { setStatus('failed', 'FAILED'); logMsg('[ERROR] ' + e.message); $('submitBtn').style.display = ''; $('cancelBtn').style.display = 'none'; } }); $('discardResumeBtn').addEventListener('click', () => { if (!confirm('确定丢弃保存的进度?此操作不可恢复。')) return; RESUME.clear(); refreshResumeBanner(); }); // ============================================================================ // 主操作按钮 // ============================================================================ $('submitBtn').addEventListener('click', async () => { try { if (!checkLibs()) return; if (!selectedFile) { // 如果有断点状态,提示用户 const st = RESUME.load(); if (st) { if (confirm('未选择文件。是否继续上次未完成的任务?')) { await runMain({ resume: st }); return; } } alert('请先选择剧本文件'); return; } await runMain({ file: selectedFile }); } catch(e) { setStatus('failed', 'FAILED'); logMsg('[ERROR] ' + e.message); console.error(e); alert('错误: ' + e.message); $('submitBtn').style.display = ''; $('cancelBtn').style.display = 'none'; } }); $('cancelBtn').addEventListener('click', () => { cancelFlag = true; logMsg('⏹ 用户请求停止...'); }); // ============================================================================ // 初始化 // ============================================================================ function init() { if (!checkLibs()) return; ensurePresets(); renderServiceSelect(); showActiveService(); renderPrompts(); loadParamPrefs(); refreshResumeBanner(); } init(); }`); storyText = await extractText(opts.file); if (!storyText.trim()) throw new Error('剧本内容为空'); logMsg(`剧本字数: ${storyText.length}`); setPhase('Stage 1 · 场景切分'); setProg(5); scenes = await runStage1(svcStage1, storyText, logMsg, jsonRetries); sceneCuts = {}; sceneErrors = {}; // 立即写入断点 RESUME.save({ ts: Date.now(), fileName, storyText, scenes, sceneCuts, sceneErrors, promptName, }); refreshResumeBanner(); } setStat(scenes.length, Object.keys(sceneCuts).length, 0, Object.keys(sceneErrors).length); renderSceneStatus(scenes, sceneCuts, sceneErrors); setPhase('Stage 2 · 分镜细化'); setProg(10); // 仅生成尚未完成的场景任务 const pendingScenes = scenes.filter(s => !sceneCuts[s.scene_id]); logMsg(`Stage 2 · 待生成场景: ${pendingScenes.length} / 共 ${scenes.length}`); let totalCuts = Object.values(sceneCuts).reduce((a, arr) => a + (arr ? arr.length : 0), 0); const tasks = pendingScenes.map(scene => async () => { if (cancelFlag) return null; const r = await runStage2OneScene(svcStage2, promptText, scene, temperature, aiPromptLang, jsonRetries); if (r.ok) { sceneCuts[scene.scene_id] = r.cuts; totalCuts += r.cuts.length; delete sceneErrors[scene.scene_id]; logMsg(`✔ Scene #${scene.scene_id} 完成(${r.cuts.length} cuts)`); } else { sceneErrors[scene.scene_id] = r.error || '未知错误'; sceneCuts[scene.scene_id] = sceneCuts[scene.scene_id] || []; logMsg(`✘ Scene #${scene.scene_id} 失败: ${r.error}`); } // 增量保存 RESUME.save({ ts: Date.now(), fileName, storyText, scenes, sceneCuts, sceneErrors, promptName, }); setStat(scenes.length, Object.keys(sceneCuts).length, totalCuts, Object.keys(sceneErrors).length); renderSceneStatus(scenes, sceneCuts, sceneErrors); return r; }); await runWithConcurrency( tasks, concurrency, (done, total) => setProg(10 + Math.round((done / Math.max(total, 1)) * 80)), () => cancelFlag, ); if (cancelFlag) { setStatus('failed', 'CANCELLED'); setPhase('已取消'); logMsg('⏹ 任务已取消,进度已保存到断点'); $('submitBtn').style.display = ''; $('cancelBtn').style.display = 'none'; refreshResumeBanner(); return; } setPhase('Stage 3 · 导出 Excel'); setProg(95); logMsg('生成 Excel 文件...'); const blob = buildXlsx({ promptText, scenes, sceneCuts, archivePrompt, }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const baseName = fileName.replace(/\.[^.]+$/, ''); const dlName = `${baseName}_storyboard_${nowTs()}.xlsx`; a.href = url; a.download = dlName; a.className = 'download-link'; a.textContent = `⬇ 下载 ${dlName}`; $('downloadArea').appendChild(a); setProg(100); setPhase('完成'); const failedCount = Object.keys(sceneErrors).length; if (failedCount === 0) { setStatus('done', 'DONE'); logMsg(`✅ 全部完成,共 ${scenes.length} 场景,${totalCuts} cuts`); // 成功后清理断点 RESUME.clear(); refreshResumeBanner(); } else { setStatus('failed', 'PARTIAL'); logMsg(`⚠ 完成,但 ${failedCount} 个场景失败。已保存断点,可点击"继续"重试。`); refreshResumeBanner(); } $('submitBtn').style.display = ''; $('cancelBtn').style.display = 'none'; } function refreshResumeBanner() { const st = RESUME.load(); const banner = $('resumeBanner'); if (!st) { banner.style.display = 'none'; return; } banner.style.display = ''; const total = (st.scenes || []).length; const done = Object.keys(st.sceneCuts || {}).length; const failed = Object.keys(st.sceneErrors || {}).length; const ts = new Date(st.ts || Date.now()); $('resumeBannerInfo').innerHTML = `文件:${escapeHtml(st.fileName || '?')} · ` + `场景 ${done}/${total} 已完成 · ` + `失败 ${failed} · ` + `保存时间:${ts.toLocaleString()}`; } $('resumeBtn').addEventListener('click', async () => { const st = RESUME.load(); if (!st) return; try { await runMain({ resume: st }); } catch (e) { setStatus('failed', 'ERROR'); logMsg('✘ 错误: ' + (e.message || e)); $('submitBtn').style.display = ''; $('cancelBtn').style.display = 'none'; } }); $('discardResumeBtn').addEventListener('click', () => { if (!confirm('确定丢弃断点?已生成但未导出的内容将丢失。')) return; RESUME.clear(); refreshResumeBanner(); }); $('submitBtn').addEventListener('click', async () => { if (!selectedFile) { alert('请先选择剧本文件'); return; } try { await runMain({ file: selectedFile }); } catch (e) { setStatus('failed', 'ERROR'); logMsg('✘ 错误: ' + (e.message || e)); $('submitBtn').style.display = ''; $('cancelBtn').style.display = 'none'; } }); $('cancelBtn').addEventListener('click', () => { if (!confirm('确定停止?已完成场景将保留为断点,可稍后继续。')) return; cancelFlag = true; logMsg('⏹ 收到停止信号,等待当前任务完成...'); }); // ============================================================================ // 初始化 // ============================================================================ function init() { checkLibs(); ensurePresets(); ensureDefaultPrompt(); renderServiceSelect(); renderStageSelectors(); showActiveService(); renderPrompts(); loadParamPrefs(); refreshResumeBanner(); // 默认折叠服务管理面板 const panel = $('svcPanel'); if (panel) panel.style.display = 'none'; } init();