import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react"; import { Play } from "lucide-react"; import { api, ApiError } from "../api"; import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types"; import type { Notice, Page } from "./route-config"; import { money, stageOrder, statusPill } from "./stage-config"; import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell"; import { MediaLightbox, useBodyScrollLock } from "../components/overlays"; import { ActorLibrary } from "../components/actor-library"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; import { IconKitSvg } from "../components/IconKitSvg"; // 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角) const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties); // 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象) const KIND_LABEL: Record = { product: "商品", person: "角色", scene: "场景" }; // 脚本来源 → 「来源」brief pill 文案 const SOURCE_LABEL: Record = { ai: "AI 全生", theme: "一句话主题", manual: "自带脚本" }; // 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐) const VO_VOICES = [ { key: "BV700_streaming", label: "灿灿 · 活力女声" }, { key: "BV034_streaming", label: "知性姐姐 · 沉稳女声" }, { key: "BV001_streaming", label: "通用女声" }, { key: "BV056_streaming", label: "阳光男声" }, { key: "BV102_streaming", label: "儒雅青年 · 解说男声" }, { key: "BV002_streaming", label: "通用男声" }, ]; // 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_STYLES/WIZ_PERSONAS) // 脚本风格预设(顺序对齐设计稿设定卡下拉:真实测评 / 痛点种草 / 小红书种草 / 开箱测评 / 对比展示) const WIZ_STYLE_LABEL: Record = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" }; const WIZ_PERSONA_LABEL: Record = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" }; // 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral) function statusLabel(status: string): string { if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成"; if (["failed", "error"].includes(status)) return "失败"; if (["running", "queued", "polling"].includes(status)) return "生成中"; if (status === "needs_review") return "待确认"; return "待生成"; } // 毫秒 → mm:ss(时间轴 / 字幕展示) function fmtMs(ms: number): string { const total = Math.max(0, Math.round(ms / 1000)); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; } // 字幕切分:整段旁白 → 短句 cue,按字数比例分配片段时长(短视频字幕节奏)。 // 硬标点(。!?;…)必切;长句(≥12 字)在逗号处再切;过短碎句(<5 字)并入前句;去尾部逗号句号。 // 与后端 export._split_subtitle_text 同规则,预览即所得。 function splitSubtitleCues(text: string, durMs: number, voiceDurMs = 0): Array<{ offsetMs: number; durMs: number; text: string }> { const clean = (text || "").replace(/\s+/g, " ").trim(); if (!clean || durMs <= 0) return []; // 有配音时字幕跟语音走:逐句窗口摊在真实语音时长内(语速是事实源),否则字幕铺满15s而人声4秒念完,后半段全对不上 const spanMs = voiceDurMs > 0 ? Math.min(voiceDurMs, durMs) : durMs; const HARD = "\u3002\uFF01\uFF1F!?\uFF1B;\u2026"; // 。!?!?;;… 全角+半角(字面量易被编辑器归一化,用转义) const SOFT = "\uFF0C,\u3001"; // ,,、 const parts: string[] = []; let cur = ""; for (const ch of clean) { cur += ch; if (HARD.includes(ch) || (SOFT.includes(ch) && cur.length >= 12)) { parts.push(cur); cur = ""; } } if (cur.trim()) parts.push(cur); const merged: string[] = []; for (const raw of parts) { const p = raw.trim(); if (!p) continue; const core = [...p].filter((c) => !HARD.includes(c) && !SOFT.includes(c)).length; if (merged.length && core < 5) merged[merged.length - 1] += p; else merged.push(p); } const display = merged.map((p) => p.replace(/[\uFF0C\u3002\uFF1B,;\u3001]+$/u, "")).filter(Boolean); const totalChars = display.reduce((sum, p) => sum + p.length, 0) || 1; let acc = 0; return display.map((p) => { const start = (acc / totalChars) * spanMs; acc += p.length; return { offsetMs: start, durMs: (acc / totalChars) * spanMs - start, text: p }; }); } // ── 时间轴真实缩略图 / 波形提取(借鉴 video-flow/web-core 工作台:按资产 memoize,只抽一次)── // TOS 未配 CORS:经 /api/assets/{id}/raw/ 同源代理拿 blob,canvas 抽帧才不会被 taint。 const assetBlobUrlCache = new Map>(); function getAssetBlobUrl(assetId: string): Promise { if (!assetBlobUrlCache.has(assetId)) { assetBlobUrlCache.set(assetId, api.fetchAssetBlob(assetId).then((blob) => URL.createObjectURL(blob))); } return assetBlobUrlCache.get(assetId)!; } // 视频 → N 张缩略图(离屏 video 逐点 seek + canvas 抽帧,dataURL 列表) const videoThumbsCache = new Map>(); function extractVideoThumbs(assetId: string, count = 6, width = 96): Promise { const key = `${assetId}_${count}`; if (!videoThumbsCache.has(key)) { videoThumbsCache.set(key, (async () => { const blobUrl = await getAssetBlobUrl(assetId); const video = document.createElement("video"); video.muted = true; video.preload = "auto"; video.src = blobUrl; await new Promise((resolve, reject) => { video.onloadedmetadata = () => resolve(); video.onerror = () => reject(new Error("video load failed")); }); const ratio = video.videoHeight / (video.videoWidth || 1) || 16 / 9; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = Math.max(1, Math.round(width * ratio)); const ctx = canvas.getContext("2d")!; const out: string[] = []; for (let i = 0; i < count; i += 1) { const t = ((i + 0.5) / count) * (video.duration || 0); await new Promise((resolve) => { video.onseeked = () => resolve(); video.currentTime = t; }); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); out.push(canvas.toDataURL("image/jpeg", 0.55)); } video.removeAttribute("src"); video.load(); return out; })().catch(() => [] as string[])); } return videoThumbsCache.get(key)!; } // ── 配音句音频:预解码进 AudioBuffer + Web Audio 精确调度(WebAV/web-core 式)── // HTMLAudio 逐句换 src 每句要现场加载+解码(几百毫秒),声音必然晚于字幕、句尾被切; // 预解码后用 source.start(when, offset) 排程,起声零延迟、时长用真实音频时长(API 报的时长不可信) const voiceBufferLoading = new Map>(); const voiceBufferReady = new Map(); let sharedVoiceCtx: AudioContext | null = null; function getVoiceCtx(): AudioContext { if (!sharedVoiceCtx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; sharedVoiceCtx = new AC(); } return sharedVoiceCtx; } function loadVoiceBuffer(assetId: string): Promise { if (!voiceBufferLoading.has(assetId)) { voiceBufferLoading.set(assetId, (async () => { const blob = await api.fetchAssetBlob(assetId); const buf = await blob.arrayBuffer(); const audio = await getVoiceCtx().decodeAudioData(buf); voiceBufferReady.set(assetId, audio); return audio; })().catch(() => null)); } return voiceBufferLoading.get(assetId)!; } // 音频 → 振幅峰值数组(decodeAudioData 后分桶取峰值,渲染真波形) const wavePeaksCache = new Map>(); function extractWavePeaks(assetId: string, samples = 150): Promise { const key = `${assetId}_${samples}`; if (!wavePeaksCache.has(key)) { wavePeaksCache.set(key, (async () => { const blob = await api.fetchAssetBlob(assetId); const buf = await blob.arrayBuffer(); const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; const ac = new AC(); try { const audio = await ac.decodeAudioData(buf); const data = audio.getChannelData(0); const bucket = Math.max(1, Math.floor(data.length / samples)); const peaks: number[] = []; for (let i = 0; i < samples; i += 1) { let peak = 0; const start = i * bucket; const end = Math.min(data.length, start + bucket); for (let j = start; j < end; j += 32) { const v = Math.abs(data[j]); if (v > peak) peak = v; } peaks.push(peak); } const max = Math.max(...peaks, 0.01); return peaks.map((p) => p / max); } finally { void ac.close(); } })().catch(() => [] as number[])); } return wavePeaksCache.get(key)!; } // 时间轴片段缩略图条(memo:thumbs/frameCount 不变就跳过重渲染——24 张 dataURL img 每次 reconcile 不便宜) const ClipFrames = memo(function ClipFrames({ thumbs, frameCount }: { thumbs?: string[]; frameCount: number }) { return ( {thumbs?.length ? thumbs.map((thumb, i) => ) : Array.from({ length: frameCount + 1 }).map((_, i) => )} ); }); // BGM 波形(memo:peaks 不变就跳过 150 个 rect 的 reconcile) const BgmWave = memo(function BgmWave({ peaks }: { peaks: number[] | null }) { return ( {peaks?.length ? peaks.map((p, i) => { const h = Math.max(2, p * 18); return ; }) : ED_WAVE.map(([y, h], i) => )} ); }); // 时间轴 clip 真实定位:start_ms / duration_ms 相对轨道总长(timeline.duration_seconds)算百分比 function clipLayout(startMs: number, durationMs: number, totalMs: number): { leftPct: number; widthPct: number } { const total = totalMs > 0 ? totalMs : 1; return { leftPct: Math.max(0, (startMs / total) * 100), widthPct: Math.max(0, Math.min(100, (durationMs / total) * 100)) }; } // 标尺刻度:按轨道总秒数生成每秒一刻,偶数秒为主刻度带秒标 function buildRuler(totalSec: number): Array<{ leftPct: number; major: boolean; t?: string }> { const secs = Math.max(1, Math.round(totalSec)); const ticks: Array<{ leftPct: number; major: boolean; t?: string }> = []; for (let s = 0; s <= secs; s += 1) { const major = s % 2 === 0; ticks.push({ leftPct: (s / secs) * 100, major, t: major ? `${s}s` : undefined }); } return ticks; } // 装饰用伪波形(BGM 轨铺底,纯视觉,无真实波形数据时使用) const ED_WAVE: Array<[number, number]> = [[8,4],[6,8],[3,14],[7,6],[4,12],[2,16],[6,8],[8,4],[5,10],[3,14],[7,6],[4,12],[6,8],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8]]; const STAGE_STEPS: Array<{ n: number; label: string }> = [ { n: 1, label: "脚本" }, { n: 2, label: "基础资产" }, { n: 3, label: "故事板" }, { n: 4, label: "视频" } // V1 雪藏「拼接导出」(代码保留,V2 恢复):{ n: 5, label: "拼接导出" } ]; // 行34 · 行内「添加标签」:点 + 展开输入框,回车 / 失焦提交(空则收起) function AddTagInline({ onAdd, placeholder, ariaLabel }: { onAdd?: (value: string) => void; placeholder?: string; ariaLabel?: string }) { const [editing, setEditing] = useState(false); const [value, setValue] = useState(""); const inputRef = useRef(null); useEffect(() => { if (editing) inputRef.current?.focus(); }, [editing]); const commit = () => { const v = value.trim(); if (v) onAdd?.(v); setValue(""); setEditing(false); }; if (!editing) { return ; } return ( setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "Escape") { setValue(""); setEditing(false); } }} onBlur={commit} /> ); } // 可编辑提示词框(基础资产卡用):**非受控** contentEditable —— 初值只在挂载时灌一次,之后由浏览器维护 // DOM,React 不再 reconcile 其子节点。受控写法(把 state 当 children 回灌)在输入触发换行时,浏览器会 // 往 contentEditable 里插
/
,React 拿单个文本子节点去对账就会 removeChild 崩溃 → 整页白屏。 // 读取走 onInput 写回上层 state(供「生成/重跑」带上),与渲染解耦,既能编辑又不崩。 function PromptBox({ value, onChange, className = "prompt-box", id, ariaLabel, stop = true }: { value: string; onChange: (v: string) => void; className?: string; id?: string; ariaLabel?: string; stop?: boolean }) { const ref = useRef(null); // 只挂载时灌初值;后续用户编辑由 DOM 自己维护,不受 value 变化驱动(避免重渲染打断输入/白屏)。 // 切换实体/草稿/版本时上层用 key 强制重挂载,会重新走这次初值灌入,所以不需要把 value 进依赖。 useEffect(() => { if (ref.current && ref.current.textContent !== value) ref.current.textContent = value; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
onChange(e.currentTarget.textContent || "")} /> ); } // 分镜「旁白 / 画面」可编辑行:同样**非受控** contentEditable,落库走 onBlur(回车=失焦提交)。 // 受控写法(把 narration 当 children)在提交后父组件用旧值重渲染时,React 会把 DOM 文本重置回旧值 → // 「按回车后变成空白」。这里不放受控子节点:初值/外部改动(切版本、AI 改稿)由 effect 同步进 DOM, // 但**正在编辑(聚焦中)时绝不覆盖**,避免打断输入。 function EditableShotField({ value, placeholder, onCommit }: { value: string; placeholder: string; onCommit: (text: string) => void }) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el || document.activeElement === el) return; // 聚焦中=用户在打字,别覆盖 if (el.textContent !== value) el.textContent = value; }, [value]); return (
{ e.currentTarget.removeAttribute("data-empty"); }} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); e.currentTarget.blur(); } }} onBlur={(e) => { const text = (e.currentTarget.textContent || "").trim(); if (!text) e.currentTarget.setAttribute("data-empty", "true"); if (text !== value) onCommit(text); }} /> ); } // 行34 · 「添加分镜」插入的本地可编辑空白卡片:输入旁白/画面,提交后落库 function DraftShotCard({ draft, onCommit, onCancel }: { draft: { id: string; afterId: string | null; narration: string; visual: string }; onCommit?: (draft: { id: string; afterId: string | null; narration: string; visual: string }) => void; onCancel?: (id: string) => void; }) { const [narration, setNarration] = useState(draft.narration); const [visual, setVisual] = useState(draft.visual); const naRef = useRef(null); useEffect(() => { naRef.current?.focus(); }, []); const commit = () => onCommit?.({ ...draft, narration: narration.trim(), visual: visual.trim() }); return (
+
// 新分镜 · 待填写
旁白
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */} {textModels && textModels.length > 0 ? (
{textModels.map((m) => (
{ setScriptModelId(m.id); setModelMenuOpen(false); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setScriptModelId(m.id); setModelMenuOpen(false); } }}> {m.display_name || m.name}
))}
) : null} {streamBusy ? ( // ⑤ 生成中 → 停止键(中间方块 + 外圈转圈),点它掐断本次生成 ) : ( )}
[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */} {viewStage === 2 && (() => { // 流程步骤4 · 每次生成三视图 = 一个新 product group;聚合成「版本列表」,可切换 + 重跑 + 采用 // 行39 · 商品三视图:一个商品组,candidate_assets = 各版本,adopted_asset = 采用版 const productGroup = groupsByKind("product").find((g) => !isTriview(g)) || null; const productVersions = productGroup?.candidate_assets ?? []; const triGenerating = isBusy("tri:product") || pendingHas("product", ""); const adoptedTriAsset = productGroup?.adopted_asset || ""; const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset; const hasTriView = productVersions.length > 0; const triPanelShow = triPanelOpen || hasTriView || triGenerating; // 商品主图:优先用序列化器内嵌的 preview_url(全局 assets 已不再全量取,assetUrl 反查会落空), // 依次 封面 → 主图 → 首图,最后才回退旧的全局反查。 const productAssetUrl = productRecord?.cover_preview_url || productRecord?.images?.find((img) => img.is_primary)?.preview_url || productRecord?.images?.[0]?.preview_url || assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板 const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`; const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); }; // 实体提取闸门:没走过正式提取步(entities_extracted)且没生成任何基础资产 → 盖蒙版 + 三按钮,不自动花钱。 // 用 entities_extracted 标记而非 script_entities 存在性:脚本生成期也可能吐过不稳的 entities,那不算正式提取。 const entitiesExtracted = project.metadata?.entities_extracted === true; const hasAnyAsset = KIND_ORDER.some((k) => groupsByKind(k).length > 0); const gateVisible = extractState === "running" || (!entitiesExtracted && !hasAnyAsset); return (
{gateVisible && (
{extractState === "running" ? (
{extractMsg || "正在提取角色 / 场景"}
) : ( <>
先从剧本认出角色 / 场景
// AI 认出角色 / 场景并出提示词,稍后你再逐个生成
{extractErr &&
{extractErr}
} )}
)}
{KIND_ORDER.map((kind) => { // 计数口径要和下方分区「· N 个」一致:人物/场景按「实体」(buildEntities 已排除自动配套的 // 三视图组、并按同名归并),否则人物的三视图组会被算进去 → 显示 5 而真人只有 2。 // 商品是「单实体多版本」模型,仍按组计(与商品区一致)。 const count = kind === "product" ? groupsByKind(kind).length : buildEntities(kind).length; const adopted = kind === "product" ? groupsByKind(kind).filter((g) => g.adopted_asset).length : buildEntities(kind).filter((e) => e.group.adopted_asset).length; return (
jumpAssetSection(kind)}> {KIND_LABEL[kind]}{count ? `${adopted}/${count}` : "0"}
); })}
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。

// 人物 +¥0.20/张 // 场景 +¥0.15/张 商品图无成本(直接复用商品库) {(entitiesExtracted || hasAnyAsset) && ( )}

商品 · {productName}

{!hasTriView && ( 缺三视图 MISSING TRI-VIEW 该商品还未生成 正 / 侧 / 背 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。 建议:点右下 AI 生成三视图 先补齐三视图,再发起后续生成。 )} {productName} · 主图
{productName}
{products.find((item) => item.id === project.product)?.category || "未分类"}
{(project.created_at || "").slice(0, 10)} 创建
{/* 与人物/场景卡页脚一致:hstack 左「使用已有素材」(ghost) + spacer + 右「AI 生成」(primary) */}
{/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */}
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
// 三视图预览 · {triGenerating ? "生成中 · 约 12s" : previewTriAsset ? (previewTriAsset === adoptedTriAsset ? "已采用,不满意可重跑" : "预览中(未采用)") : "待生成"}
{(() => { const u = candUrl(productGroup, previewTriAsset); if (triGenerating && !u) { return
生成中
; } return (
setPreview({ src: u, kind: "image", name: `三视图` }) : undefined} onKeyDown={u ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } } : undefined}> {!u && // 暂无三视图}
); })()} {previewTriAsset && (
~¥0.30 / 次
)}
// 历史版本 · {productVersions.length} 版
{productVersions.map((assetId, i) => { const isAdopted = assetId === adoptedTriAsset; const isPreview = assetId === previewTriAsset; const u = candUrl(productGroup, assetId); return (
setTriPreviewId(assetId)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setTriPreviewId(assetId); } }}> 已采用 v{i + 1}
); })}
{(["person", "scene"] as const).map((kind) => { // 流程步骤4 · 把同一脚本标签的多个 group 合成「一个角色 + 多版本立绘」实体 const entities = buildEntities(kind); // 流程步骤4 · 人物←脚本提取的人物标签,场景←场景标签;提示词用脚本提取时 AI 生成的(metadata.*_prompts) const tags = kind === "person" ? castTags : sceneTags; const promptMap = (kind === "person" ? project.metadata?.cast_prompts : project.metadata?.scene_prompts) || {}; const fallbackPrompt = (tag: string) => kind === "person" ? `${tag},真人模特出镜,自然光,${productName} 上身展示,9:16 竖屏` : `${tag},使用场景,氛围统一,干净构图,9:16 竖屏`; const tagPrompt = (tag: string) => (promptMap[tag]?.trim() || fallbackPrompt(tag)); // 已生成的标签(实体 label),其余脚本标签显示为「待生成」seed 卡(自动生成会逐个补齐) const generatedLabels = new Set(entities.map((e) => e.label).filter(Boolean)); const pendingTags = tags.filter((t) => !generatedLabels.has(t)); const genPrompt = kind === "person" ? `${productName} 真人模特出镜,自然光,商品上身展示,9:16 竖屏` : `${productName} 使用场景,氛围统一,干净构图,9:16 竖屏`; const customBusy = `custom:${kind}`; return (

{KIND_LABEL[kind]} · {entities.length} 个

{/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */}
{/* 新增场景:本地占位草稿卡(可改标题 + 提示词),编辑完点「AI 生成」才出图;出图成功后移除草稿 */} {kind === "scene" && sceneDrafts.map((draft) => { const dk = `scene-draft:${draft.id}`; const busy = isBusy(dk); return (
{busy ?
生成中 · 约 8s
: {(draft.title.trim() || "新场景")} · 待生成}
setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, title: e.target.value } : d))} /> 新增
setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, prompt: v } : d))} />
); })} {/* 脚本提取出但还没生成的人物/场景:seed 卡(可改提示词后生成,进入页面会自动补齐) */} {pendingTags.map((tag) => { const seedKey = `seed:${kind}:${tag}`; const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag); const busy = isBusy(seedKey) || isBusy(`${seedKey}:tri`) || pendingHas(kind, tag); return (
{/* 待生成 seed 卡也可删除(还没出参考图也有垃圾桶):两步确认,直接从脚本标签里移除(ZWQ#9) */} {seedDelArmed === seedKey ? ( ) : ( )}
openSeedDetail(kind, tag, promptValue)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSeedDetail(kind, tag, promptValue); } }}> {busy ?
生成中 · 约 8s
: {tag} · 待生成}
openSeedDetail(kind, tag, promptValue)}>{tag}来自脚本
setAssetPromptDraft((m) => ({ ...m, [seedKey]: v }))} />
); })} {/* 已生成实体卡:点缩略/标题进详情;prompt-box 可改;重跑/替换 */} {entities.map((entity) => { const grp = entity.group; const mainUrl = groupMainUrl(grp); const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? ""; const entBK = `ent:${kind}:${entity.key}`; // 已生成卡(有 mainUrl)只在本次主动重跑(本地 isBusy)时转圈; // 不再被轮询到的在途任务(可能是残留/重复/三视图同名任务)误盖成「生成中」—— // 否则刷新后已出图的卡会无故转圈。仅当还没出图(mainUrl 空)才用 pendingHas 兜底。 const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || (!mainUrl && pendingHas(kind, entity.name)); // 审核态:优先本地轮询的实时态(processing→终态),回退项目详情持久下发的 adopted_asset_review // (刷新后轮询 map 为空也能回显;不再依赖 byId,因为页面没喂全量 assets) const rs = kind === "person" && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : ""; return (
{/* D:hover 删除(角色/场景卡;商品主卡不给删)·二次确认 */} {grp.id && (delArmed === grp.id ? ( ) : ( ))}
openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}> {busy ?
生成中…
: !mainUrl && {entity.name}}
openAssetDetail(kind, entity)}>{entity.name} {kind === "person" && grp.adopted_asset && ( submitAssetReview(grp.adopted_asset!)} /> )} {/* C:采用/未采用 toggle —— 下游只取已采用;脚本里没有的默认未采用,可点采用。需已有真实组 */} {grp.id && grp.adopted_asset && (() => { const st = adoptStateOf(grp, entity.name); return ( ); })()}
setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
); })} {/* 添加人物入口统一走右上「+ 新增人物」按钮(原末尾常驻卡与之重复,已移除) */} {entities.length === 0 && pendingTags.length === 0 && (kind !== "scene" || sceneDrafts.length === 0) && (
// 暂无{KIND_LABEL[kind]}资产 · 点右上「新增{KIND_LABEL[kind]}」
)}
); })}
[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]
); })()} {/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */} {viewStage === 3 && (() => { // 每场时间区间(累加脚本镜时长 → 「0~5s」) let cum = 0; const sceneTimes = shots.map((s) => { const st = cum; cum += s.duration_seconds || 15; return `${st}~${cum}s`; }); const shotImg = (s?: StoryboardShot | null) => frameUrl(s ? { asset: s.adopted_asset ?? "", asset_url: s.adopted_asset_url } : null); const shotBusy = (s: StoryboardShot) => sbGenerating || sbBusyShots.has(s.id) || ["queued", "running"].includes(s.status); const cardCount = sbShots.length || sbExpectedShots; const activeBusy = sbActiveShot ? shotBusy(sbActiveShot) : sbGenerating; const activeVers = [...(sbActiveShot?.versions ?? [])]; // 当前「查看」的版本(纯预览,默认=采用版);切换它只动本地预览,不动后端、不丢重跑 const sbViewedVer = activeVers.find((v) => v.id === sbViewVerId) || activeVers.find((v) => v.is_adopted || v.id === sbActiveShot?.adopted_version) || null; const sbViewedIsAdopted = !sbViewedVer || sbViewedVer.is_adopted || sbViewedVer.id === sbActiveShot?.adopted_version; const mainUrl = sbViewedVer ? (sbViewedVer.asset_url || assetUrl(sbViewedVer.asset)) : shotImg(sbActiveShot); const mainAid = sbViewedVer?.asset || sbActiveShot?.adopted_asset || ""; return (
{sbShots.length ? sbShots.map((shot, idx) => { const url = shotImg(shot); const aid = shot.adopted_asset || ""; return (
setSbSelected(idx)}>
场 {idx + 1} {shotBusy(shot) &&
场 {idx + 1}
{sceneTimes[idx] || `#${shot.sort_order + 1}`}
); }) : sbExpectedShots ? ( /* 还没出图:按采用版脚本镜头数铺等量空占位,标「待生成」,让用户看出本该有几张 */ Array.from({ length: sbExpectedShots }, (_, idx) => (
setSbSelected(idx)}>
场 {idx + 1}{sbGenerating &&
场 {idx + 1}
// 待生成
)) ) :
// 暂无
}
{(() => { const url = mainUrl; const aid = mainAid; return (
setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }); } } : undefined}> {url ? `场 ${sbSelected + 1}${sbViewedIsAdopted ? "" : " · 预览历史版"}` : sbExpectedShots ? `场 ${sbSelected + 1} · 待生成` : "// 故事板未生成"} {activeBusy &&
); })()}
故事板 · {sbShots.length ? `场 ${sbSelected + 1}` : "—"} {sbActiveShot ? (activeBusy ? 生成中 : sbActiveShot.status === "failed" ? 生成失败 : sbActiveShot.adopted_version ? 已出片 : 待生成) : 未生成}
{/* 失败原因·友好提示(后端已把 yunqi 真因翻成中文,如内容审核拦截 → 提示改措辞) */} {sbActiveShot?.status === "failed" && !activeBusy && sbActiveShot.error_message && (
{sbActiveShot.error_message}
)}
每个场一张分镜图 · 可单独「重跑本场」只重出这一张,每场各自留历史版本,互不影响。
// 整张风格提示词(重跑时生效,可编辑)
setStoryboardPrompt(v.trim())} />
{sbActiveShot && ( )} ~¥0.45/场
// 本场历史版本({activeVers.length})· 点击预览
{activeVers.length ? activeVers.map((ver) => { const cover = frameUrl({ asset: ver.asset ?? "", asset_url: ver.asset_url }); const isAdopted = ver.is_adopted || ver.id === sbActiveShot?.adopted_version; const isViewed = sbViewedVer?.id === ver.id; // 点击只切「预览」(本地态),不动后端 → 重跑在制时切版本不丢任务。采用要点下方按钮。 return (
setSbViewVerId(ver.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewVerId(ver.id); } }}>
{isAdopted ? "采用" : "历史"}
{(ver.created_at || "").slice(11, 16) || "--:--"}
); }) : // 本场暂无历史}
{/* 预览的是非采用版 → 显式「采用此版本」(对标视频详情弹窗的采用按钮);采用不影响在制重跑 */} {sbViewedVer && !sbViewedIsAdopted && sbActiveShot && ( )}
// 绑定的资产
{(() => { const bound = [ ...groupsByKind("product").filter((g) => g.adopted_asset).map((g) => ({ id: g.id, name: (g.metadata?.label || "").trim() || KIND_LABEL.product, kind: "product" as const, url: groupMainUrl(g) })), ...buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "person" as const, url: groupMainUrl(e.group) })), ...buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "scene" as const, url: groupMainUrl(e.group) })), ]; return bound.length ? bound.map((b) => ( {b.url ? : }{b.name}({KIND_LABEL[b.kind]}) )) : // 暂无绑定资产; })()}
[ image-2 逐场输出 · {cardCount ? `${cardCount} 场` : "0 场"} · 单场可重跑,失败不扣 ]
{sbConfirmHint && (
故事板还没出齐 请先点上方 开始生成故事板,每场都出片后再确认进入视频生成。
)}
); })()} {/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */} {viewStage === 4 && (() => { const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0; const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status)); const statusText = !segments.length ? "暂无片段" : segDone === segments.length ? "已完成所有场次" : activeVideoCount > 0 ? `生成中 · ${activeVideoCount} 段进行中(自动刷新)` : "待生成"; return (
视频生成 · {segDone} / {segments.length} 完成
// 每场 Seedance 约 15 秒 · {statusText}
{pct}% {/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */}
{segments.length ? (
{segments.map((seg) => { const url = segUrl(seg); const tone = statusPill(seg.status); const busy = ["running", "queued"].includes(seg.status); const pending = rerunPending.has(seg.id); const showBusy = busy || pending; const verCount = seg.versions?.length ?? 0; return (
openVideoDetail(seg.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openVideoDetail(seg.id); } }}> {url ?
{/* 标题带本镜画面要点(对齐设计稿「场 1 · 深夜办公桌」),来自绑定的脚本镜 */} 场 {seg.sort_order + 1}{(() => { const hint = (shots[seg.sort_order]?.visual_prompt || shots[seg.sort_order]?.narration || "").trim(); return hint ? ` · ${hint.slice(0, 12)}` : ""; })()} {statusLabel(seg.status)}
{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · ~¥0.45{seg.error_message ? ` · ${seg.error_message}` : ""}
{/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */} {verCount > 1 && ( )} {/* AI 生成片段不需单卡上传(自定义替换走 queue-bar 全局上传);移除多余「上传」按钮 */} {url ? 下载 : }
); })}
) : (
// 暂无视频片段 · 先在故事板确认后生成
)}
[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]
{/* V1 雪藏拼接导出:隐藏「进入拼接」入口(代码保留,V2 恢复 false→true) */} {false && ( )}
); })()} {/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */} {viewStage === 5 && (() => { const previewUrl = edCur?.url || assetUrl(tlClips[0]?.asset) || segUrl(segments.find((s) => s.adopted_asset)); const aspect = timeline?.aspect_ratio || "9:16"; const resolution = timeline?.resolution || "1080×1920"; const bgm = bgmTracks[0] || null; const bgmName = assetName(bgm?.asset) || (bgm ? "背景音乐" : ""); const showVideo = !!(edCur?.isVideo && edCur.url); // 拼接成片:导出成功后用整片预览/下载;导出中显示进度;previewFinal=false 时回到片段编辑预览 const finalUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : ""; const showFinal = Boolean(finalUrl) && previewFinal; const exporting = exportResult?.status === "queued" || exportResult?.status === "running"; const exportFailed = exportResult?.status === "failed"; // ── 编辑器派生(随本地编辑实时变化的时间轴)── const STYLE_SWATCHES = [ { key: "plain", demo: "", nm: "朴素白底" }, { key: "cinema", demo: "b", nm: "影视黑底" }, { key: "handwrite", demo: "c", nm: "手写描边" }, { key: "variety", demo: "d", nm: "综艺暖黄" } ]; const TRANSITIONS = [ { key: "none", nm: "无转场" }, { key: "fade", nm: "淡入淡出" }, { key: "dissolve", nm: "溶解" }, { key: "slideleft", nm: "左滑" }, { key: "wiperight", nm: "擦除" } ]; const edRulerMs = edTotalMs; const edRuler = buildRuler(edRulerMs / 1000); const serverBgm = (project.timeline?.bgm_tracks ?? [])[0] || null; const serverBgmUrl = serverBgm?.asset_url || ""; const serverBgmName = serverBgm?.asset_name || (serverBgm ? "背景音乐" : ""); const subVisible = edState.subtitleEnabled; return (
{/* BGM 预览音轨(隐藏):编辑预览播放时跟随播放头出声;成片态不播(成片已混音) */} {bgmPreviewUrl &&
{/* 看成片时按任一播放控制先切回编辑预览,避免操作一个没挂载的隐藏播放器 */} {paintRef.current.label} / {fmtMs(edTotalMs)} {finalUrl && ( )}
setPropsTab("subtitle")}>字幕
setPropsTab("transition")}>转场
setPropsTab("bgm")}>BGM
{propsTab === "subtitle" && ( <>
烧入字幕
// 字幕样式(导出烧入)
{STYLE_SWATCHES.map((sw) => (
commitEdit({ ...edState, subtitleStyle: sw.key, subtitleEnabled: true })}>
真实分享
{sw.nm}
))}
// 字幕文本(默认取脚本旁白,可逐段改)
{edState.clips.map((c, idx) => (
{idx + 1}