import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react"; import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react"; import { api, ApiError } from "../api"; import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types"; import { publicModelDisplayName } from "../model-display"; import { isPublicGenerationError, presentGenerationError } from "../generation-error"; import type { Notice, Page } from "./route-config"; import { stageOrder, statusPill } from "./stage-config"; import { MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays"; import { ModelLibrary } from "../components/model-library"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; import { allowedStructures, clampDuration, coercePresentationFormat, coerceVideoStructure, DURATION_OPTIONS, durationWarning, isForbidden, PRESENTATION_FORMATS, PRESENTATION_HINT, PRESENTATION_KEYS, recommendDuration, recommendSetup, SEGMENT_DURATION_MAX, STRUCTURE_HINT, TOTAL_DURATION_MIN, VIDEO_STRUCTURES, type PresentationFormat, type VideoStructure, } from "../script-setup"; import { isLocalLife } from "../product-business"; // 真实资产缩略图注入:与全站一致用 --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 文案 // 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。 const SOURCE_LABEL: Record = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" }; // 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 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_PERSONAS) // 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts const WIZ_PERSONA_LABEL: Record = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" }; const PERSONA_KEY_BY_LABEL: Record = { ...Object.fromEntries(Object.entries(WIZ_PERSONA_LABEL).map(([key, label]) => [label, key])), 专业测评: "reviewer", }; function coercePersona(value: unknown, fallback = "urban"): string { if (typeof value !== "string" || !value) return fallback; if (WIZ_PERSONA_LABEL[value]) return value; return PERSONA_KEY_BY_LABEL[value] || fallback; } // 视频片段状态 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 "待生成"; } function generationTaskErrorText(error: unknown, fallback: string): string { if (!isPublicGenerationError(error)) return fallback; const presentation = presentGenerationError(error); return `${presentation.title}:${presentation.description}`; } // API 时间统一以 UTC ISO 字符串返回;项目工作台固定按中国时区展示,避免直接截取 UTC 的 HH:mm。 function formatShanghaiClock(iso?: string): string { if (!iso) return "--:--"; const date = new Date(iso); if (Number.isNaN(date.getTime())) return "--:--"; return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).format(date); } // 毫秒 → 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 PIPELINE_RAIL = [ { n: "01", title: "选择商品", desc: "确定本次内容生产的商品主体" }, { n: "02", title: "脚本创建", desc: "生成并确认镜头脚本" }, { n: "03", title: "资产选择", desc: "准备商品、角色与场景资产" }, { n: "04", title: "故事板", desc: "生成并确认视频分镜画面" }, { n: "05", title: "视频生成", desc: "按故事板生成视频片段" }, ]; const PIPELINE_HEAD: Record = { 1: { title: "脚本创建", desc: "围绕商品卖点组织镜头脚本,确认后进入下一步内容生产", status: "镜头脚本" }, 2: { title: "资产选择", desc: "准备故事板所需的商品、角色与场景资产,确保后续画面保持一致", status: "资产选择" }, 3: { title: "故事板", desc: "检查每个镜头的画面、台词、运镜和节奏,确认后进入视频生成。", status: "故事板" }, 4: { title: "视频生成", desc: "系统已按故事板场次自动开始生成,各场视频会并行完成。", status: "视频生成" }, 5: { title: "视频生成", desc: "系统已按故事板场次自动开始生成,各场视频会并行完成。", status: "视频生成" }, }; // 行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 || "")} /> ); } // 分镜「旁白 / 画面」可编辑框:非受控 textarea,落库走 onBlur。 // 聚焦中绝不覆盖,避免提交后父组件用旧值把正在输入的字冲掉。 function ShotArea({ value, placeholder, ariaLabel, onCommit, rows = 2 }: { value: string; placeholder: string; ariaLabel: string; onCommit: (text: string) => void; rows?: number; }) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el || document.activeElement === el) return; if (el.value !== value) el.value = value; }, [value]); return ( void onPickScriptFile(event)} /> void onPickVideoFile(event)} />
{/* 1.11 · 聊天已经开起来之后仍能提炼参考视频(入口菜单只在空态出现) */} {/* 模型选择小按钮(输入框下方,对齐 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); } }}> {publicModelDisplayName(m)}
))}
) : null} {streamBusy ? ( // ⑤ 生成中 → 停止键(中间方块 + 外圈转圈),点它掐断本次生成 ) : ( )}
脚本生成预计消耗 {pts(10)} 积分 / 次,失败不扣除
{/* ============= 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; // 商品主图:优先用序列化器内嵌的 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); const personEntitiesHead = buildEntities("person"); const sceneEntitiesHead = buildEntities("scene"); const pendingPersonN = castTags.filter((t) => !personEntitiesHead.some((e) => e.label === t)).length; const pendingSceneN = sceneTags.filter((t) => !sceneEntitiesHead.some((e) => e.label === t)).length; const personTotal = personEntitiesHead.length + pendingPersonN; const sceneTotal = sceneEntitiesHead.length + pendingSceneN + sceneDrafts.length; const personReadyN = personEntitiesHead.filter((e) => e.group.adopted_asset).length; const sceneReadyN = sceneEntitiesHead.filter((e) => e.group.adopted_asset).length; const prodReady = isLocalProduct || !!adoptedTriAsset; const assetDone = (prodReady ? 1 : 0) + personReadyN + sceneReadyN; const assetTotal = 1 + personTotal + sceneTotal; const prodStatus = isLocalProduct ? "本地生活无需三视图" : (triGenerating || triUploading) ? "三视图生成中" : adoptedTriAsset ? "主图已就绪 · 三视图已确认" : hasTriView ? "主图已就绪 · 三视图待采用" : productAssetUrl ? "主图已就绪 · 三视图待确认" : "商品主图待确认"; const triUrl = candUrl(productGroup, previewTriAsset); return (
{gateVisible && (
{extractState === "running" ? (
{extractMsg || "正在提取角色 / 场景"}
) : ( <>
先从剧本认出角色 / 场景
AI 认出角色 / 场景并出提示词,稍后你再逐个生成
{pts(10)} 积分/次 · 失败不扣
{extractErr &&
{extractErr}
} )}
)}

基础资产

商品、角色和场景集中在同一页面确认,减少来回切换。

{(entitiesExtracted || hasAnyAsset) ? ( ) : null}
资产完成度{assetDone} / {assetTotal}
01

商品

{productName}

{isLocalProduct ? "本地生活无需三视图,主图直接带入后续生成。" : "主图自动带入,三视图作为另一张完整参考图管理。"}

{prodStatus}
{!isLocalProduct && !hasTriView && ( 缺三视图 MISSING TRI-VIEW 该商品还未生成 正 / 侧 / 背 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。 建议:点右侧 AI 生成三视图 先补齐三视图,再发起后续生成。 )} {productAssetUrl ? 商品主图 : null}
{!isLocalProduct ? (
{(triGenerating || triUploading) && !triUrl ? ( {triUploading ? "上传中" : "生成中"} ) : triUrl ? (
) : null}

三视图

{isLocalProduct ? (

本地生活无需商品三视图,可直接进入下一步。

) : (
)}
{!isLocalProduct && previewTriAsset ? (
{adoptedTriAsset && ( submitAssetReview(adoptedTriAsset)} /> )} {pts(20)} 积分 / 次
) : null} {!isLocalProduct ? (
历史版本 · {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}
); })}
) : null} { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} />
{(["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}`; const kindReady = entities.filter((e) => e.group.adopted_asset).length; const kindTotal = entities.length + pendingTags.length + (kind === "scene" ? sceneDrafts.length : 0); const kindStatus = kindTotal ? `${kindReady} / ${kindTotal} 已确认` : "尚未确认"; return (
{kind === "person" ? "02" : "03"}

{KIND_LABEL[kind]}

{kind === "person" ? "仅展示当前使用的角色图,可从模特库替换或 AI 生成。" : "仅展示当前使用的场景图,可从场景库替换或 AI 生成。"}

0 && kindReady === kindTotal ? " ready" : ""}`}>{kindStatus}
{kind === "scene" && sceneDrafts.map((draft) => { const dk = `scene-draft:${draft.id}`; const busy = isBusy(dk); return (
{busy ? 生成中 : null}
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))} />
); })} {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 (
{seedDelArmed === seedKey ? ( ) : ( )}
openSeedDetail(kind, tag, promptValue)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSeedDetail(kind, tag, promptValue); } }} > {busy ? 生成中 : null}

openSeedDetail(kind, tag, promptValue)}>{tag || (kind === "person" ? "角色图待生成" : "场景图待生成")}

来自脚本
setAssetPromptDraft((m) => ({ ...m, [seedKey]: v }))} />
); })} {entities.map((entity) => { const grp = entity.group; const mainUrl = groupMainUrl(grp); const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? ""; const entBK = `ent:${kind}:${entity.key}`; const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || (!mainUrl && pendingHas(kind, entity.name)); const rs = (kind === "person" || kind === "scene") && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : ""; const previewState = busy ? " generating" : mainUrl ? " ready" : " pending"; return (
{grp.id && (delArmed === grp.id ? ( ) : ( ))}
openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }} > {busy ? 生成中… : null}

openAssetDetail(kind, entity)}>{entity.name}

{(kind === "person" || kind === "scene") && grp.adopted_asset && ( submitAssetReview(grp.adopted_asset!)} /> )} {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]}」
)}
); })}
确认后将使用以上资产创建故事板,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣
); })()} {/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */} {viewStage === 3 && (() => { // 每场时间区间(累加脚本镜时长 → 「0~5s」) let cum = 0; const sceneTimes = shots.map((s) => { const st = cum; cum += shotSeconds(s); 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 && ( )} {pts(20)} 积分/镜
本场历史版本({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 ? "采用" : "历史"}
{formatShanghaiClock(ver.created_at)}
); }) : 本场暂无历史}
{/* 预览的是非采用版 → 显式「采用此版本」(对标视频详情弹窗的采用按钮);采用不影响在制重跑 */} {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 segSeconds = segments.map((s) => s.target_duration_seconds).filter((n) => n > 0); const segMin = segSeconds.length ? Math.min(...segSeconds) : 0; const segMax = segSeconds.length ? Math.max(...segSeconds) : 0; const segTotal = segSeconds.reduce((sum, n) => sum + n, 0); const paceText = !segSeconds.length ? "每场按脚本时长出片" : segMin === segMax ? `每场 Seedance ${segMin} 秒 · 全片 ${segTotal} 秒` : `每场 Seedance ${segMin}–${segMax} 秒 · 全片 ${segTotal} 秒`; const statusText = !segments.length ? "暂无片段" : segDone === segments.length ? "已完成所有场次" : activeVideoCount > 0 ? `生成中 · ${activeVideoCount} 段进行中(自动刷新)` : "待生成"; // 合成成片(ffmpeg 按场次顺序拼成一条):全部场次出片才能合成。 // 命名避开本作用域已有的 exporting/exportErr(那是「片段打包 zip 下载」)。 const allSegDone = segments.length > 0 && segDone === segments.length; const mergedUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : ""; const merging = exportResult?.status === "queued" || exportResult?.status === "running"; const mergeProgress = exportResult?.progress ?? 0; return (
视频生成 · {segDone} / {segments.length} 完成
{paceText} · {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"} · 按时长计量{seg.error_message ? ` · ${seg.error_message}` : ""}
{/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */} {verCount > 1 && ( )} {/* AI 生成片段不需单卡上传(自定义替换走 queue-bar 全局上传);移除多余「上传」按钮 */} {url ? 下载 : }
); })}
) : (
暂无视频片段 · 先在故事板确认后生成
)}
[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ] {merging && 合成中 {mergeProgress}%} {exportResult?.status === "failed" && ( 合成失败:{exportResult.error_message || "请重试"} )}
{/* 合成成片:各场视频按顺序拼成一条完整视频(合成完才有下面的播放/下载) */} {mergedUrl && ( <> 下载成片 )}
); })()} {/* ============= 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}