import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import type { ChangeEvent, CSSProperties } from "react"; import { Play } from "lucide-react"; import type { Asset, BillingSummary, ExportPoll, Product, Project, 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 } from "../components/overlays"; 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 → 中文区块名(对齐 design.md 商品/人物/场景三类) const KIND_LABEL: Record = { product: "商品", person: "人物", scene: "场景" }; // 视频片段状态 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")}`; } // 时间轴 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: "视频" }, { n: 5, label: "拼接导出" } ]; export function PipelinePage(props: { project: Project; loading: boolean; navigate: (page: Page, options?: { projectId?: string; productId?: string }) => void; user: User; team: Team; products: Product[]; projects: Project[]; assets: Asset[]; billing: BillingSummary | null; notice: Notice | null; unreadCount: number; avatarChar: string; logout: () => void; onRefresh: () => void; onGenerateScript: (prompt: string) => void; onAdoptScript: (scriptId: string) => void | Promise; onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void; onGenerateStoryboard: (prompt: string) => void; onSkipStoryboard: () => void; onSubmitVideo: (segmentId: string, prompt: string) => void; onPollVideo: (segmentId: string) => void; onSubmitAllVideos: (prompt: string) => void; onPollVideosQuiet: () => void | Promise; onPollAllVideos: () => void; exportResult: ExportPoll | null; onRefreshExport: () => void; onUploadVideoSegment: (segmentId: string, file: File) => void; onUploadBgm: (file: File, volume: number) => void; onSaveTimeline: (payload: TimelineSavePayload) => void; onSubmitExport: (payload?: TimelineSavePayload) => void; }) { const { project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout, onGenerateScript, onAdoptScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard, onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport } = props; // ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)── const byId = new Map(assets.map((a) => [a.id, a] as const)); const assetUrl = (id: string | null | undefined): string => { if (!id) return ""; const a = byId.get(id); return a?.files?.find((f) => f.is_primary)?.preview_url || a?.files?.[0]?.preview_url || ""; }; const assetName = (id: string | null | undefined): string => (id ? byId.get(id)?.name || "" : ""); // 缩略图解析:优先用后端内嵌的 preview_url(不受团队 assets 分页 20 条影响),回退到 assets 列表解析 type GroupLike = { adopted_asset: string | null; adopted_asset_url?: string; candidate_assets?: string[]; candidate_asset_urls?: Record }; const groupMainUrl = (g?: GroupLike | null): string => g?.adopted_asset_url || assetUrl(g?.adopted_asset) || (g?.candidate_assets?.[0] ? (g?.candidate_asset_urls?.[g.candidate_assets[0]] || assetUrl(g.candidate_assets[0])) : ""); const candUrl = (g: GroupLike | null | undefined, id: string): string => g?.candidate_asset_urls?.[id] || assetUrl(id); const frameUrl = (f?: { asset: string; asset_url?: string } | null): string => f?.asset_url || assetUrl(f?.asset); const segUrl = (s?: { adopted_asset?: string | null; adopted_asset_url?: string } | null): string => s?.adopted_asset_url || assetUrl(s?.adopted_asset); // ── Stage 1:脚本(采用版优先,否则最近一版)+ 镜头列表 ── const scripts = [...(project.script_versions ?? [])]; const currentScript = scripts.find((s) => s.is_adopted) || [...scripts].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))[0] || null; const scriptAdopted = Boolean(currentScript?.is_adopted); const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order); // ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ── const groups = project.base_asset_groups ?? []; const groupsByKind = (kind: string) => groups.filter((g) => g.kind === kind); const KIND_ORDER: Array<"product" | "person" | "scene"> = ["product", "person", "scene"]; const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product"); function jumpAssetSection(kind: "product" | "person" | "scene") { setAssetTab(kind); if (typeof document !== "undefined") { document.getElementById(`asset-sec-${kind}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); } } // ── Stage 3:取已采用(is_adopted)的故事板版本,无则取第一版 ── const storyboards = project.storyboard_versions ?? []; const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null; const sbFrames = [...(adoptedStoryboard?.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order); const [sbSelected, setSbSelected] = useState(0); const sbActiveFrame = sbFrames[Math.min(sbSelected, Math.max(0, sbFrames.length - 1))] || null; // ── Stage 4:视频片段(adopted_asset 缩略图 + 状态 pill + 时长)── const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order); const segDone = segments.filter((s) => ["succeeded", "completed", "done"].includes(s.status)).length; const segTotalSec = segments.reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0); // ── Stage 5:时间轴 / 字幕 / BGM(真实定位,轨道总长用 timeline.duration_seconds)── const timeline = project.timeline; const tlTotalMs = (timeline?.duration_seconds || 0) * 1000; const tlClips = [...(timeline?.clips ?? [])].sort((a, b) => a.sort_order - b.sort_order); const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || 15000; const ruler = buildRuler(tlRulerMs / 1000); const subtitleTrack = (timeline?.subtitle_tracks ?? []).find((t) => t.enabled) || (timeline?.subtitle_tracks ?? [])[0] || null; const subtitleCues = [...(subtitleTrack?.content ?? [])].sort((a, b) => a.start_ms - b.start_ms); const bgmTracks = timeline?.bgm_tracks ?? []; // 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段; // 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。 const projectStage = project.status === "completed" ? 5 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1); const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null; const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : 1); const [navigated, setNavigated] = useState(Boolean(initHash)); const activeDot = navigated ? viewStage : projectStage; const completed = Math.max(projectStage - 1, activeDot - 1); const [chatText, setChatText] = useState(""); // 媒体预览灯箱(视频片段播放 / 故事板分镜放大) const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null); const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai"); const [chatAttachments, setChatAttachments] = useState>([]); const chatTextareaRef = useRef(null); const chatFileRef = useRef(null); function clearChat() { setChatText(""); setChatAttachments([]); setChatMode("ai"); } function pickScriptMode() { setChatMode("manual"); chatFileRef.current?.click(); } function onPickScriptFile(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const reader = new FileReader(); reader.onload = () => { const text = String(reader.result || "").trim(); setChatText((prev) => (prev ? `${prev}\n${text}` : text)); setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]); setChatMode("manual"); chatTextareaRef.current?.focus(); }; reader.readAsText(file); } function focusThemeMode() { setChatMode("theme"); chatTextareaRef.current?.focus(); } const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图"); const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感"); const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version)); // ── Stage 5 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ── const isVideoAsset = (id: string | null | undefined): boolean => { const a = id ? byId.get(id) : null; if (!a) return false; if (a.asset_type === "video") return true; const f = a.files?.find((x) => x.is_primary) || a.files?.[0]; return !!f && /video\//.test(f.content_type || ""); }; // ── Stage 5 · 编辑器状态(可改片段/字幕文本/转场/BGM音量,本地编辑,保存草稿/导出时落盘)── type EdClipState = { key: string; asset: string; url: string; isVideo: boolean; durMs: number; trimStartMs: number; trimEndMs: number | null; subtitle: string }; type EditorState = { clips: EdClipState[]; subtitleEnabled: boolean; subtitleStyle: string; transition: string; bgmVolume: number }; const buildInitialEditor = useCallback((): EditorState => { const tl = project.timeline; const sub = (tl?.subtitle_tracks ?? [])[0]; const savedTexts: string[] = (sub?.content ?? []).map((c) => String(c?.text || "")); const scriptScript = (project.script_versions ?? []).find((s) => s.is_adopted) || (project.script_versions ?? [])[0]; const scriptTexts = [...(scriptScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).map((s) => (s.narration || "").trim()); const subFor = (i: number) => savedTexts[i] || scriptTexts[i] || ""; const baseClips: EdClipState[] = tl?.clips?.length ? [...tl.clips].sort((a, b) => a.sort_order - b.sort_order).map((c, i) => ({ key: `c${i}-${c.id}`, asset: c.asset, url: c.asset_url || assetUrl(c.asset), isVideo: c.asset_is_video ?? isVideoAsset(c.asset), durMs: c.duration_ms || 0, trimStartMs: c.trim_start_ms || 0, trimEndMs: c.trim_end_ms ?? null, subtitle: subFor(i) })) : segments.filter((s) => s.adopted_asset).map((s, i) => ({ key: `s${i}-${s.id}`, asset: s.adopted_asset as string, url: segUrl(s), isVideo: true, durMs: (s.target_duration_seconds || 0) * 1000, trimStartMs: 0, trimEndMs: null, subtitle: subFor(i) })); const bgm = (tl?.bgm_tracks ?? [])[0]; return { clips: baseClips, subtitleEnabled: sub ? sub.enabled : true, subtitleStyle: (sub?.style?.key as string) || "plain", transition: tl?.metadata?.transition?.type || "none", bgmVolume: bgm?.volume ?? 60 }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [project.timeline, project.script_versions, segments]); const [edState, setEdState] = useState(buildInitialEditor); const [edHistory, setEdHistory] = useState([]); const [edFuture, setEdFuture] = useState([]); const [selectedClip, setSelectedClip] = useState(0); const [propsTab, setPropsTab] = useState<"subtitle" | "transition" | "bgm">("subtitle"); const edHydratedRef = useRef(false); const bgmFileRef = useRef(null); const videoUploadRef = useRef(null); const [uploadTargetSeg, setUploadTargetSeg] = useState(null); // 提交一个新编辑态(进 undo 栈) const commitEdit = useCallback((next: EditorState) => { setEdHistory((h) => [...h.slice(-49), edState]); setEdFuture([]); setEdState(next); }, [edState]); const edClips = edState.clips.map((c) => ({ id: c.key, assetId: c.asset, url: c.url, isVideo: c.isVideo, durMs: c.durMs })); const edTotalMs = edClips.reduce((sum, c) => sum + c.durMs, 0) || tlRulerMs; const edOffsetMs = (idx: number) => edClips.slice(0, idx).reduce((sum, c) => sum + c.durMs, 0); const videoRef = useRef(null); const [edIdx, setEdIdx] = useState(0); const [edPlaying, setEdPlaying] = useState(false); const [edClipMs, setEdClipMs] = useState(0); const edCur = edClips[Math.min(edIdx, Math.max(0, edClips.length - 1))] || null; const edGlobalMs = edOffsetMs(edIdx) + edClipMs; const gotoClip = useCallback((idx: number, atEnd = false) => { if (idx < 0 || idx >= edClips.length) return; setEdIdx(idx); setEdClipMs(atEnd ? Math.max(0, (edClips[idx]?.durMs || 0) - 200) : 0); }, [edClips]); // 在时间轴上跳转到全局毫秒位置(点击标尺 seek) const seekToMs = useCallback((globalMs: number) => { let acc = 0; for (let i = 0; i < edClips.length; i += 1) { const dur = edClips[i].durMs || 0; if (globalMs <= acc + dur || i === edClips.length - 1) { const within = Math.max(0, Math.min(dur, globalMs - acc)); setEdIdx(i); setEdClipMs(within); if (edClips[i].isVideo && videoRef.current) videoRef.current.currentTime = within / 1000; return; } acc += dur; } }, [edClips]); const togglePlay = useCallback(() => { setEdPlaying((p) => { const next = !p; const v = videoRef.current; if (edCur?.isVideo && v) { if (next) v.play().catch(() => undefined); else v.pause(); } return next; }); }, [edCur]); // 上一帧 / 下一帧:视频按 ±1/25s 逐帧 seek,越界切相邻段;静态图切相邻片段 const stepFrame = useCallback((dir: 1 | -1) => { const v = videoRef.current; if (edCur?.isVideo && v && v.duration) { const t = v.currentTime + dir * (1 / 25); if (t < 0) { gotoClip(edIdx - 1, true); return; } if (t > v.duration) { gotoClip(edIdx + 1, false); return; } v.currentTime = t; setEdClipMs(t * 1000); } else { gotoClip(edIdx + dir, false); } }, [edCur, edIdx, gotoClip]); // 静态图(无视频文件)播放:定时推进虚拟播放头,到段尾自动进下一段 useEffect(() => { if (!edPlaying || edCur?.isVideo || edClips.length === 0) return; const tick = window.setInterval(() => { setEdClipMs((ms) => { const dur = edCur?.durMs || 2000; if (ms + 120 >= dur) { if (edIdx + 1 < edClips.length) { setEdIdx(edIdx + 1); return 0; } setEdPlaying(false); return dur; } return ms + 120; }); }, 120); return () => window.clearInterval(tick); }, [edPlaying, edCur, edIdx, edClips.length]); // 切换片段时同步 video 进度,正在播放则续播 useEffect(() => { const v = videoRef.current; if (!v || !edCur?.isVideo) return; v.currentTime = edClipMs / 1000; if (edPlaying) v.play().catch(() => undefined); // eslint-disable-next-line react-hooks/exhaustive-deps }, [edIdx]); // 键盘:仅 stage5 生效(空格播放/暂停,←/→ 逐帧) useEffect(() => { if (viewStage !== 5) return; function onKey(e: KeyboardEvent) { const tag = (e.target as HTMLElement)?.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; if (e.code === "Space") { e.preventDefault(); togglePlay(); } else if (e.code === "ArrowLeft") { e.preventDefault(); stepFrame(-1); } else if (e.code === "ArrowRight") { e.preventDefault(); stepFrame(1); } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [viewStage, togglePlay, stepFrame]); // Stage 4:有运行中的视频段时静默轮询推进(本机无 Celery worker,前端驱动 poll-video-segment),进度条/缩略图实时刷新 const activeVideoCount = segments.filter((s) => ["running", "queued"].includes(s.status)).length; useEffect(() => { if (viewStage !== 4 || activeVideoCount === 0) return; const timer = window.setInterval(() => { void onPollVideosQuiet(); }, 5000); return () => window.clearInterval(timer); }, [viewStage, activeVideoCount, onPollVideosQuiet]); // Stage 5:进入拼接页时回填已有导出成片(若此前导过) useEffect(() => { if (viewStage !== 5) return; onRefreshExport(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewStage]); // 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。 async function confirmScript() { if (currentScript && !scriptAdopted) { await onAdoptScript(currentScript.id); } goStage(2); } // ── Stage 5 编辑器:水合 + 片段操作 + 撤销/重做 + 保存负载 ── const [edZoom, setEdZoom] = useState(100); useEffect(() => { if (viewStage !== 5) { edHydratedRef.current = false; return; } if (edHydratedRef.current) return; const hasData = Boolean(project.timeline?.clips?.length) || segments.some((s) => s.adopted_asset); if (!hasData) return; edHydratedRef.current = true; setEdState(buildInitialEditor()); setEdHistory([]); setEdFuture([]); setSelectedClip(0); }, [viewStage, project, segments, buildInitialEditor]); const edUndo = useCallback(() => { setEdHistory((h) => { if (!h.length) return h; setEdFuture((f) => [edState, ...f].slice(0, 50)); setEdState(h[h.length - 1]); return h.slice(0, -1); }); }, [edState]); const edRedo = useCallback(() => { setEdFuture((f) => { if (!f.length) return f; setEdHistory((h) => [...h, edState].slice(-50)); setEdState(f[0]); return f.slice(1); }); }, [edState]); function edDeleteClip(idx: number) { if (idx < 0 || idx >= edState.clips.length || edState.clips.length <= 1) return; commitEdit({ ...edState, clips: edState.clips.filter((_, i) => i !== idx) }); setSelectedClip((s) => Math.max(0, Math.min(s, edState.clips.length - 2))); } function edCopyClip(idx: number) { const c = edState.clips[idx]; if (!c) return; const dup = { ...c, key: `${c.key}-copy-${Date.now()}` }; commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx + 1), dup, ...edState.clips.slice(idx + 1)] }); } function edSplitAtPlayhead() { const idx = edIdx; const c = edState.clips[idx]; if (!c || c.durMs <= 400) return; const offset = Math.max(200, Math.min(c.durMs - 200, edClipMs)); const a: EdClipState = { ...c, key: `${c.key}-a-${Date.now()}`, durMs: offset, trimEndMs: c.trimStartMs + offset }; const b: EdClipState = { ...c, key: `${c.key}-b-${Date.now()}`, durMs: c.durMs - offset, trimStartMs: c.trimStartMs + offset, trimEndMs: c.trimEndMs }; commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx), a, b, ...edState.clips.slice(idx + 1)] }); } const buildSavePayload = useCallback((): TimelineSavePayload => { let acc = 0; const content = edState.clips.map((c) => { const start = acc; acc += c.durMs; return { start_ms: Math.round(start), text: c.subtitle || "" }; }); return { clips: edState.clips.map((c) => ({ asset: c.asset, duration_ms: Math.round(c.durMs), trim_start_ms: Math.round(c.trimStartMs), trim_end_ms: c.trimEndMs == null ? null : Math.round(c.trimEndMs) })), subtitle: { enabled: edState.subtitleEnabled, style_key: edState.subtitleStyle, content }, bgm: { volume: edState.bgmVolume }, transition: { type: edState.transition } }; }, [edState]); // 字幕文本编辑(打字不进 undo 栈,避免逐字刷历史) function setClipSubtitle(idx: number, text: string) { setEdState((s) => ({ ...s, clips: s.clips.map((c, i) => (i === idx ? { ...c, subtitle: text } : c)) })); } // 片段拖拽重排 const [dragIdx, setDragIdx] = useState(null); function reorderClip(from: number, to: number) { if (from === to || from < 0 || to < 0 || from >= edState.clips.length || to >= edState.clips.length) return; const clips = [...edState.clips]; const [moved] = clips.splice(from, 1); clips.splice(to, 0, moved); commitEdit({ ...edState, clips }); setSelectedClip(to); } // 转场实时预览:切片段时若有转场,画面做一次淡场(导出才是真 xfade,这里给即时视觉提示) const [fadeKey, setFadeKey] = useState(0); const prevEdIdxRef = useRef(edIdx); useEffect(() => { if (prevEdIdxRef.current !== edIdx) { prevEdIdxRef.current = edIdx; if (edState.transition !== "none") setFadeKey((k) => k + 1); } }, [edIdx, edState.transition]); // Stage 4 / 5 文件上传 function onPickVideoFile(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (file && uploadTargetSeg) onUploadVideoSegment(uploadTargetSeg, file); setUploadTargetSeg(null); } function triggerVideoUpload(segmentId: string) { setUploadTargetSeg(segmentId); videoUploadRef.current?.click(); } function onPickBgmFile(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; if (file) onUploadBgm(file, edState.bgmVolume); } // 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面) const productRecord = products.find((item) => item.id === project.product); const productName = productRecord?.title || "透真补水面膜"; const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null; function goStage(n: number) { setViewStage(n); setNavigated(true); if (typeof location !== "undefined") location.hash = `stage-${n}`; } function dotCls(n: number) { if (n === activeDot) return "sp-dot active"; if (n <= completed) return "sp-dot done"; return "sp-dot"; } return (
navigate("account")}> 余额 {money(billing?.account.balance)}
{avatarChar}
{STAGE_STEPS.map((step, index) => ( { event.preventDefault(); goStage(step.n); }}> {step.label} {index < STAGE_STEPS.length - 1 && } ))}
{notice && } {/* ============= STAGE 1 · 脚本 ============= */}
镜头脚本 {shots.length ? `· ${shots.length} 镜 · ${scriptAdopted ? "已采用" : "待采用"}` : "· 空 · 待生成"}
来源未选择 风格待确认 人物待确认
// 人物
// 场景
{shots.length ? shots.map((shot) => (
{shot.sort_order + 1}
// 场 {shot.sort_order + 1} · {shot.duration_seconds || 15}s
{shot.narration?.trim() || "(本镜暂无文案,可在右侧重写脚本)"}
)) : (
还没有镜头脚本
// 跟右侧脚本助手对话
选择一种方式生成你的第一稿
)}
AI
脚本助手 · GPT-4o
选择一种生成方式开始
// 三种,由「最省事」到「最保真原意」
[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */} {viewStage === 2 && (() => { const productGroup = groupsByKind("product")[0] || null; const productAssetUrl = groupMainUrl(productGroup) || assetUrl(productCover); const productCandidates = (productGroup?.candidate_assets ?? []).filter((id) => id !== productGroup?.adopted_asset); return (
{KIND_ORDER.map((kind) => { const list = groupsByKind(kind); const adopted = list.filter((g) => g.adopted_asset).length; return (
jumpAssetSection(kind)}> {KIND_LABEL[kind]}{list.length ? `${adopted}/${list.length}` : "0"}
); })}
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。

// 人物 +¥0.20/张 // 场景 +¥0.15/张 商品图无成本(直接复用商品库)

商品 · {productName}

{!productGroup?.adopted_asset && ( 缺三视图 MISSING TRI-VIEW 该商品还未生成 正 / 侧 / 背 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。 建议:点右下 AI 生成三视图 先补齐三视图,再发起后续生成。 )} {productName} · 主图
{productName}
{products.find((item) => item.id === project.product)?.category || "未分类"}
{(project.created_at || "").slice(0, 10)} 创建
// 候选三视图 · {productCandidates.length} 张
候选 #1
{productCandidates.slice(0, 4).map((id) => (
))}
{(["person", "scene"] as const).map((kind) => { const list = groupsByKind(kind); const genPrompt = kind === "person" ? `${productName} 真人模特出镜,自然光,商品上身展示,9:16 竖屏` : `${productName} 使用场景,氛围统一,干净构图,9:16 竖屏`; return (

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

{list.length ? (
{list.map((group, gi) => { const mainUrl = groupMainUrl(group); const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4); return (
{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}
{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`} {group.adopted_asset ? 已采用 : 待采用}
{group.prompt || "(暂无提示词)"}
{cands.length > 0 && (
{cands.map((id) => (
))}
)}
); })}
) : (
// 暂无{KIND_LABEL[kind]}资产 · 点上方「AI 生成{KIND_LABEL[kind]}」生成
)}
); })}
[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]
); })()} {/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */} {viewStage === 3 && (
{sbFrames.length ? sbFrames.map((frame, idx) => { const url = frameUrl(frame); return (
setSbSelected(idx)}>
场 {idx + 1}
场 {idx + 1}
#{frame.sort_order + 1}
); }) :
// 暂无
}
{(() => { const url = frameUrl(sbActiveFrame); 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}> {sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}
); })()}
故事板 · {sbActiveFrame ? `场 ${sbSelected + 1}` : "—"} {adoptedStoryboard ? 已生成 : 未生成}
整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。
仅支持整张重跑 · 不能局部改某一镜。如需调单镜,先在 { event.preventDefault(); goStage(1); }}>Stage 1 脚本 改镜头描述,再回此处整张重跑。
// 本场提示词
{sbActiveFrame?.prompt || adoptedStoryboard?.prompt || "(暂无提示词)"}
~¥0.45/场
// 历史版本({storyboards.length})
{storyboards.length ? storyboards.map((ver) => { const cover = frameUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]); return (
{ver.is_adopted ? "采用" : "历史"}
{(ver.created_at || "").slice(11, 16) || "--:--"}
); }) : // 暂无历史}
// 绑定的资产
{groups.filter((g) => g.adopted_asset).length ? groups.filter((g) => g.adopted_asset).map((g) => ( {assetName(g.adopted_asset) || KIND_LABEL[g.kind] || g.kind}({KIND_LABEL[g.kind] || g.kind}) )) : // 暂无绑定资产}
[ image-2 整张输出 · {sbFrames.length} 场 · 整张重跑,失败不扣 ]
)} {/* ============= 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 生成 · {statusText}
{pct}%
{segments.length ? (
{segments.map((seg) => { const url = segUrl(seg); const tone = statusPill(seg.status); const busy = ["running", "queued"].includes(seg.status); return (
setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "video", name: `场 ${seg.sort_order + 1}` }); } } : undefined}> {url ?
场 {seg.sort_order + 1} {statusLabel(seg.status)}
{seg.target_duration_seconds}s{seg.error_message ? ` · ${seg.error_message}` : ""}
{url ? 下载 : }
); })}
) : (
// 暂无视频片段 · 先在故事板确认后生成
)}
[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]
); })()} {/* ============= 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); // 拼接成片:导出成功后用整片预览/下载;导出中显示进度 const finalUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : ""; 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 edOffsets = edClips.map((_, i) => edClips.slice(0, i).reduce((sum, c) => sum + c.durMs, 0)); const serverBgm = (project.timeline?.bgm_tracks ?? [])[0] || null; const serverBgmUrl = serverBgm?.asset_url || ""; const serverBgmName = serverBgm?.asset_name || (serverBgm ? "背景音乐" : ""); const subVisible = edState.subtitleEnabled; return (
{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}