Files
yingqing/core/frontend/src/routes/pipeline.tsx
T
zycandClaude Opus 4.8 0d1fce1cc1 fix(core): 基础资产「生成中」占位卡刷新后从后端认领恢复(出图本在 worker 跑,只是前端 loading 丢了)
问题:立绘/商品图出图是异步(worker),但 loading 占位只活在前端内存 genBusy + 当前页轮询 Promise,
刷新即丢 → 占位卡退回「待生成」,虽 worker 仍在跑、跑完手动刷新才见。
修复:
- 后端 GET /api/projects/{id}/pending-assets/ 返回在途出图任务(id/kind/label/status,读 request_payload)
- 前端进基础资产趴轮询该端点,据 (kind,label) 用 pendingHas 把对应占位卡置「生成中」;
  在途数减少=有任务完成 → onRefreshProject 把占位卡换成真卡。离开该趴停止轮询。
tsc + py_compile + 21 测试通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:36:30 +08:00

3243 lines
217 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from "../api";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, 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, useBodyScrollLock } from "../components/overlays";
import { ActorLibrary } from "../components/actor-library";
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<string, string> = { product: "商品", person: "人物", scene: "场景" };
// 脚本来源 → 「来源」brief pill 文案
const SOURCE_LABEL: Record<string, string> = { 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<string, string> = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" };
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
const THEME_TIP = "好,用一句话描述主题(5-30 字),例如「熬夜党的早八续命面膜」,输入后点发送。";
// 视频片段状态 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<string, Promise<string>>();
function getAssetBlobUrl(assetId: string): Promise<string> {
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<string, Promise<string[]>>();
function extractVideoThumbs(assetId: string, count = 6, width = 96): Promise<string[]> {
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<void>((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<void>((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<string, Promise<AudioBuffer | null>>();
const voiceBufferReady = new Map<string, AudioBuffer>();
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<AudioBuffer | null> {
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<string, Promise<number[]>>();
function extractWavePeaks(assetId: string, samples = 150): Promise<number[]> {
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 (
<span className="frames">
{thumbs?.length
? thumbs.map((thumb, i) => <img key={i} src={thumb} alt="" draggable={false} style={{ height: "100%", flex: "1 1 0", minWidth: 0, objectFit: "cover" }} />)
: Array.from({ length: frameCount + 1 }).map((_, i) => <span className="fr" key={i}></span>)}
</span>
);
});
// BGM 波形(memo:peaks 不变就跳过 150 个 rect 的 reconcile)
const BgmWave = memo(function BgmWave({ peaks }: { peaks: number[] | null }) {
return (
<svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">
{peaks?.length
? peaks.map((p, i) => { const h = Math.max(2, p * 18); return <rect key={i} x={i * 4} y={(20 - h) / 2} width="2" height={h} />; })
: ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}
</svg>
);
});
// 时间轴 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: "拼接导出" }
];
// 行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<HTMLInputElement | null>(null);
useEffect(() => { if (editing) inputRef.current?.focus(); }, [editing]);
const commit = () => {
const v = value.trim();
if (v) onAdd?.(v);
setValue("");
setEditing(false);
};
if (!editing) {
return <button className="tag-add" type="button" aria-label={ariaLabel} onClick={() => setEditing(true)}>+</button>;
}
return (
<input
ref={inputRef}
className="tag-add-input"
type="text"
value={value}
placeholder={placeholder}
aria-label={ariaLabel}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "Escape") { setValue(""); setEditing(false); } }}
onBlur={commit}
/>
);
}
// 行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<HTMLTextAreaElement | null>(null);
useEffect(() => { naRef.current?.focus(); }, []);
const commit = () => onCommit?.({ ...draft, narration: narration.trim(), visual: visual.trim() });
return (
<div className="shot-card draft-shot-card">
<div className="shot-n"></div>
<div className="shot-main">
<div className="shot-meta-row">
<div className="shot-meta">// 新分镜 · 待填写</div>
<div className="shot-actions">
<button className="icon-mini-btn" type="button" title="确认新增" onClick={commit}></button>
<button className="icon-mini-btn" type="button" title="取消" onClick={() => onCancel?.(draft.id)}>×</button>
</div>
</div>
<div className="shot-row">
<span className="shot-k">旁白</span>
<textarea ref={naRef} className="draft-shot-input" rows={1} placeholder="输入这一镜的旁白…" value={narration}
onChange={(e) => setNarration(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); commit(); } else if (e.key === "Escape") { onCancel?.(draft.id); } }} />
</div>
<div className="shot-row">
<span className="shot-k">画面</span>
<textarea className="draft-shot-input" rows={1} placeholder="输入这一镜的画面描述…" value={visual}
onChange={(e) => setVisual(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); commit(); } else if (e.key === "Escape") { onCancel?.(draft.id); } }} />
</div>
</div>
</div>
);
}
// 行33 · 进度提示流气泡:steps 逐条滚动;done 后折叠成一行「已完成」结果
function ProgressStream({ steps, done }: { steps?: string[]; done?: boolean }) {
const list = steps ?? [];
if (done) {
return (
<div className="progress-stream done">
<span className="ps-check" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
</span>
已完成 {list.length} 步思考 · 镜头脚本已生成
</div>
);
}
return (
<div className="progress-stream">
{list.map((step, i) => {
const isLast = i === list.length - 1;
return (
<div className={`ps-row${isLast ? " active" : " past"}`} key={i}>
<span className="ps-dot" aria-hidden="true"></span>
<span className="ps-text">{step}</span>
</div>
);
})}
</div>
);
}
// 行32 · 长文本折叠:超过 maxLines 行先折叠,点「查看更多」展开
function CollapsibleText({ text, maxLines = 10 }: { text: string; maxLines?: number }) {
const [expanded, setExpanded] = useState(false);
const lineCount = (text.match(/\n/g)?.length ?? 0) + 1;
// 长文本不一定有换行(整段粘贴),按字符量也判一次溢出
const overflow = lineCount > maxLines || text.length > maxLines * 40;
if (!overflow) return <>{text}</>;
return (
<>
<div className={expanded ? "" : "clamp-lines"} style={expanded ? undefined : ({ ["--clamp-lines"]: String(maxLines) } as CSSProperties)}>{text}</div>
<button type="button" className="clamp-toggle" onClick={() => setExpanded((v) => !v)}>{expanded ? "收起" : "查看更多"}</button>
</>
);
}
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;
scriptModelName: string;
textModels?: ModelConfig[];
onGenerateScript: (prompt: string, source?: string) => Promise<unknown>;
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
onAddShot: (afterSegmentId: string) => Promise<unknown>;
onDeleteShot: (segmentId: string) => Promise<unknown>;
// 行35 · 单条分镜重跑(后端只重写该 segment);行28/34 · 把设定/标签合并进 project.metadata 持久化
onRerunShot?: (segmentId: string, instruction?: string) => Promise<unknown>;
onSaveProjectMeta?: (meta: Record<string, unknown>) => Promise<unknown>;
onAdoptVideoVersion: (segmentId: string, versionId: string) => Promise<unknown>;
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void | Promise<unknown>;
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
// 流程步骤4 · 演员库:用现有资产替换基础资产卡 / AI 生成演员 / 本地上传演员
onAttachBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
onGenerateActor: (prompt: string) => Promise<{ assets: Asset[] } | null>;
onUploadActor: (file: File) => Promise<unknown>;
// 流程步骤4 · 据某一版立绘生成它配套的三视图(三视图与立绘 1:1 绑定)
onGenerateTriview: (portraitGroupId: string) => Promise<{ id: string } | null>;
onGenerateStoryboard: (prompt: string) => void;
onSkipStoryboard: () => Promise<unknown>;
onSubmitVideo: (segmentId: string, prompt: string) => void;
onSubmitAllVideos: (prompt: string) => void;
onPollVideosQuiet: () => void | Promise<void>;
exportResult: ExportPoll | null;
onRefreshExport: () => void;
onRefreshProject: () => Promise<void> | 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,
scriptModelName, textModels, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
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<string, string> };
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:脚本(显示最近一版,采用状态用 badge 标)+ 镜头列表 ──
// 取最近一版而非「采用版优先」:右侧 agent 重新生成后(新版未采用),左侧要立刻显示新稿供 review,
// 否则会一直停在旧的已采用版,看着像「没更新」。下游故事板/视频仍按后端 is_adopted 取,互不影响。
const scripts = [...(project.script_versions ?? [])];
const currentScript =
[...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);
// 对白 speaker(entity id)→ 角色名;null = 旁白。entities 在 ScriptVersion.metadata
const scriptEntities = ((currentScript?.metadata as { entities?: Array<{ id: string; name: string }> } | undefined)?.entities) ?? [];
const entityName = (id: string | null | undefined) => (id ? (scriptEntities.find((e) => e.id === id)?.name || id) : "旁白");
// 行34 · 脚本里的「人物 / 场景」标签:持久化进 project.metadata.cast / .scenes。
// 初值从 metadata 读,刷新/重进后仍在;增删后 onSaveProjectMeta 合并落库。
const [castTags, setCastTags] = useState<string[]>(() => project.metadata?.cast ?? []);
const [sceneTags, setSceneTags] = useState<string[]>(() => project.metadata?.scenes ?? []);
// 后台刷新(action 后 refreshProjectDetail)回填的 metadata 同步到本地态,保证多端/重进一致
useEffect(() => { setCastTags(project.metadata?.cast ?? []); }, [project.metadata?.cast]);
useEffect(() => { setSceneTags(project.metadata?.scenes ?? []); }, [project.metadata?.scenes]);
// 行34/流程步骤3 · 标签「待确认修改」队列:点 chip × 或加新标签都不立即落库,而是排进这里,
// 在脚本助手输入框上方显示为可撤销胶囊;点发送才把这些增删拼成指令整体重写脚本(AI 重写后会重新提取标签)。
type TagEdit = { id: number; op: "add" | "remove"; kind: "cast" | "scene"; value: string };
const [pendingTagEdits, setPendingTagEdits] = useState<TagEdit[]>([]);
const tagEditId = () => Date.now() + Math.floor(Math.random() * 1000);
const removingSet = (kind: "cast" | "scene") =>
new Set(pendingTagEdits.filter((e) => e.kind === kind && e.op === "remove").map((e) => e.value));
const addingList = (kind: "cast" | "scene") =>
pendingTagEdits.filter((e) => e.kind === kind && e.op === "add").map((e) => e.value);
// 删除某标签:若它本是「待加」则直接撤掉那条 add(等于没加过);否则排一条 remove
function queueRemoveTag(kind: "cast" | "scene", value: string) {
setPendingTagEdits((list) => {
const addEntry = list.find((e) => e.kind === kind && e.op === "add" && e.value === value);
if (addEntry) return list.filter((e) => e !== addEntry);
if (list.some((e) => e.kind === kind && e.op === "remove" && e.value === value)) return list;
return [...list, { id: tagEditId(), op: "remove", kind, value }];
});
}
// 添加某标签:若它本是「待删」则撤掉删除(等于恢复);否则(且原本不存在)排一条 add
function queueAddTag(kind: "cast" | "scene", raw: string) {
const value = raw.trim();
if (!value) return;
setPendingTagEdits((list) => {
const rmEntry = list.find((e) => e.kind === kind && e.op === "remove" && e.value === value);
if (rmEntry) return list.filter((e) => e !== rmEntry);
const base = kind === "cast" ? castTags : sceneTags;
if (base.includes(value) || list.some((e) => e.kind === kind && e.op === "add" && e.value === value)) return list;
return [...list, { id: tagEditId(), op: "add", kind, value }];
});
}
const undoTagEdit = (id: number) => setPendingTagEdits((list) => list.filter((e) => e.id !== id));
// 已落库标签的 × :已排队待删 → 撤销恢复;否则排一条待删
function toggleRemoveTag(kind: "cast" | "scene", value: string) {
const rm = pendingTagEdits.find((e) => e.kind === kind && e.op === "remove" && e.value === value);
if (rm) undoTagEdit(rm.id); else queueRemoveTag(kind, value);
}
const tagEditVerb = (e: TagEdit) => `${e.op === "add" ? "添加" : "删除"}${e.kind === "cast" ? "人物" : "场景"}${e.value}`;
// 行34 · 「添加分镜」插入的本地空白可编辑卡(尚未落库;失焦有内容才真正 onAddShot)
type DraftShot = { id: string; afterId: string | null; narration: string; visual: string };
const [draftShots, setDraftShots] = useState<DraftShot[]>([]);
// ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ──
// 后端 BaseAssetGroup 无 Meta.ordering(UUID 主键顺序≈随机),必须按 created_at 排稳,
// 否则「当前商品组取 [0]」可能取到旧组,重新生成的三视图永远不显示。
const groups = [...(project.base_asset_groups ?? [])].sort((a, b) => (a.created_at || "").localeCompare(b.created_at || ""));
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");
// 行38 · 资产卡可编辑提示词(本地草稿,按 group id 覆盖原 prompt;重跑/替换时带上)
const [assetPromptDraft, setAssetPromptDraft] = useState<Record<string, string>>({});
// 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗)
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
const [triPanelOpen, setTriPanelOpen] = useState(false);
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
function jumpAssetSection(kind: "product" | "person" | "scene") {
setAssetTab(kind);
if (typeof document !== "undefined") {
document.getElementById(`asset-sec-${kind}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
// ── 流程步骤4 · 正确模型:一个实体 = 一个 BaseAssetGroup,candidate_assets = 各版本,adopted_asset = 采用/当前 ──
// 人物立绘版本 = 人物组的 candidate_assets;商品三视图版本 = 商品组的 candidate_assets。
// 人物「某一版立绘的三视图」= 另一个 person 组,metadata.triview_of = <该立绘 asset id>,其 candidate_assets = 三视图版本。
type BaseGroup = (typeof groups)[number];
const triviewOf = (g?: BaseGroup | null): string => (g?.metadata?.triview_of as string) || "";
const isTriview = (g: BaseGroup) => Boolean(triviewOf(g));
// 某一版立绘资产(assetId)配套的三视图组(同一立绘只一组,内含多版本候选)
const triGroupForAsset = (assetId?: string | null): BaseGroup | null =>
(assetId ? groupsByKind("person").find((g) => triviewOf(g) === assetId) || null : null);
type AssetEntity = { key: string; label: string; name: string; kind: "person" | "scene"; group: BaseGroup; versions: string[] };
// 一个 kind 下的实体列表:按 label 归并(同名取最新一组为代表),三视图组不计入
function buildEntities(kind: "person" | "scene"): AssetEntity[] {
const portraits = groupsByKind(kind).filter((g) => !isTriview(g));
const byLabel = new Map<string, BaseGroup>();
for (const g of portraits) byLabel.set((g.metadata?.label || "").trim() || g.id, g); // 升序遍历,后者(更新)覆盖
return [...byLabel.entries()].map(([key, g], i) => {
const label = (g.metadata?.label || "").trim();
const name = label || assetName(g.adopted_asset) || `${KIND_LABEL[kind]} ${i + 1}`;
return { key, label, name, kind, group: g, versions: g.candidate_assets ?? [] };
});
}
// 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物)
const [genBusy, setGenBusy] = useState<Set<string>>(new Set());
// 刷新后从后端「认领」在途出图任务:出图在 worker 跑、内存态 genBusy 丢了,据此重建占位卡 loading
const [pendingGen, setPendingGen] = useState<Array<{ kind: string; label: string; is_triview: boolean }>>([]);
const pendingHas = (kind: string, label: string) =>
pendingGen.some((p) => p.kind === kind && !p.is_triview && (p.label || "") === (label || ""));
const isBusy = (k: string) => genBusy.has(k);
const addBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.add(k); return n; });
const delBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.delete(k); return n; });
type GenResult = { id?: string; adopted_asset?: string | null } | null;
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string): Promise<GenResult> {
if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发)
addBusy(busyKey);
try {
return (await onGenerateBaseAsset(kind, prompt, label)) as GenResult;
} finally {
delBusy(busyKey);
}
}
// 据某一版立绘资产生成它配套的三视图(loading 用 busyKey)
async function genTriview(portraitAssetId: string, busyKey: string): Promise<{ id: string } | null> {
if (genBusy.has(busyKey)) return null;
addBusy(busyKey);
try {
return await onGenerateTriview(portraitAssetId);
} finally {
delBusy(busyKey);
}
}
// 流程步骤4 · 生成立绘后链式配套三视图(人物专用):新立绘(返回组的 adopted_asset)→ 据它生成新三视图
async function genPersonWithTri(prompt: string, label: string | undefined, busyKey: string) {
const g = await genBaseAsset("person", prompt, label, busyKey);
const newAsset = g?.adopted_asset;
if (newAsset) await genTriview(newAsset, `${busyKey}:tri`);
return g;
}
// 资产图片下载(经同源代理取 blob,避开 TOS 跨域 + 强制下载而非新开标签)
async function downloadAssetImage(assetId: string, name: string) {
try {
const blob = await api.fetchAssetBlob(assetId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name || "asset"}.png`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
} catch { /* 取流失败忽略 */ }
}
// ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ──
const [adDetail, setAdDetail] = useState<{ kind: "person" | "scene"; key: string } | null>(null);
const [adPortraitId, setAdPortraitId] = useState<string | null>(null); // 当前查看的立绘 asset id(组内候选)
const [adTriId, setAdTriId] = useState<string | null>(null); // 当前查看的三视图 asset id
const [adPrompt, setAdPrompt] = useState("");
useBodyScrollLock(Boolean(adDetail));
function openAssetDetail(kind: "person" | "scene", entity: AssetEntity) {
setAdDetail({ kind, key: entity.key });
setAdPortraitId(entity.group.adopted_asset ?? entity.versions.at(-1) ?? null);
setAdTriId(null); // 跟随当前立绘的最新三视图
setAdPrompt(entity.group.prompt ?? "");
}
// 流程步骤4 · 演员库覆盖层:replace 模式带要替换的实体组(选演员后挂为候选并采用)
const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; groupId?: string } | null>(null);
function openActorReplace(entity: AssetEntity) {
setActorLib({ mode: "replace", groupId: entity.group.id });
}
async function pickActor(assetId: string) {
if (actorLib?.mode === "replace" && actorLib.groupId) await onAttachBaseAsset(actorLib.groupId, assetId);
setActorLib(null);
}
useEffect(() => {
if (!adDetail) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setAdDetail(null); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [adDetail]);
// 流程步骤4 · 人物三视图「据当前查看的那一版立绘自动生成」:该立绘资产还没三视图就自动据它生成一版(每立绘 asset 仅一次)
const triAutoRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!adDetail || adDetail.kind !== "person") return;
const ent = buildEntities("person").find((e) => e.key === adDetail.key);
if (!ent || isBusy(`addet-tri:${ent.key}`)) return;
const portraitAsset = adPortraitId || ent.group.adopted_asset;
if (!portraitAsset || triGroupForAsset(portraitAsset)) return;
if (triAutoRef.current.has(portraitAsset)) return;
triAutoRef.current.add(portraitAsset);
void genTriview(portraitAsset, `addet-tri:${ent.key}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adDetail, adPortraitId]);
// 进入基础资产自动生成的「已触发」哨兵(实际副作用在 productName/viewStage 定义后挂载)
const autoGenRef = useRef(false);
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ──
const storyboards = project.storyboard_versions ?? [];
const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null;
const [sbViewId, setSbViewId] = useState<string | null>(null);
const displayedStoryboard = storyboards.find((s) => s.id === sbViewId) || adoptedStoryboard;
const sbFrames = [...(displayedStoryboard?.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 4 · 视频详情弹窗:选中段 + 查看的版本 + 可编辑的重跑提示词
const [vdSegId, setVdSegId] = useState<string | null>(null);
const [vdVerId, setVdVerId] = useState<string | null>(null);
const [vdPrompt, setVdPrompt] = useState("");
// 弹窗打开时锁页面滚动,滚轮只作用于弹窗内部
useBodyScrollLock(Boolean(vdSegId));
const vdSeg = segments.find((s) => s.id === vdSegId) || null;
const vdVersions = vdSeg?.versions ?? [];
const vdVer = vdVersions.find((v) => v.id === vdVerId) || vdVersions.find((v) => v.is_adopted) || vdVersions[0] || null;
function openVideoDetail(segId: string) {
setVdSegId(segId);
setVdVerId(null);
setVdPrompt("");
}
// Esc 关闭详情弹窗
useEffect(() => {
if (!vdSegId) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setVdSegId(null);
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [vdSegId]);
// ── 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));
// 火山人像审核状态(真人资产绿/红盾):本地覆盖 map(轮询刷新),回退 asset.review_status
const [reviews, setReviews] = useState<Record<string, string>>({});
const assetReview = (id?: string | null) => (id ? (reviews[id] || byId.get(id)?.review_status || "") : "");
// 在基础资产趴(stage 2)定时轮询审核状态,刷新徽章(processing → active绿 / failed红)
useEffect(() => {
if (viewStage !== 2) return;
let alive = true;
let sawProcessing = false;
let timer = 0;
const tick = async () => {
const r = await api.pollReviews(project.id).catch(() => null);
if (!alive) return;
const map = (r?.reviews || {}) as Record<string, string>;
if (Object.keys(map).length) {
sawProcessing = true;
setReviews((m) => ({ ...m, ...map }));
} else if (sawProcessing) {
window.clearInterval(timer); // 审核都终态了,停轮询省空跑(后端无 processing 资产时返回 {})
}
};
void tick();
timer = window.setInterval(tick, 8000);
return () => { alive = false; window.clearInterval(timer); };
}, [viewStage, project.id]);
// 外部 hash 变化(浏览器前进/后退、地址栏改 #stage-N)也要切阶段——镜像有 hashchange 监听,这里补齐
useEffect(() => {
function onHashChange() {
const matched = location.hash.match(/#stage-(\d)/);
if (!matched) return;
const n = Number(matched[1]);
if (n >= 1 && n <= 5) {
setViewStage(n);
setNavigated(true);
}
}
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
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<Array<{ name: string; chars: number }>>([]);
// ── Stage 1 · 生成前「来源 & 风格 & 人物」设定(行28/行30):向导那边已删,这里补上 ──
// setupOpen:三选项之一被选中后,展示风格/人物下拉 + 确认/重新推荐;确认后才真正发起生成。
const SETUP_STYLE_KEYS = Object.keys(WIZ_STYLE_LABEL);
const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL);
const [setupOpen, setSetupOpen] = useState(false);
const [setupSource, setSetupSource] = useState<"ai" | "theme" | "manual">("ai");
const [setupStyle, setSetupStyle] = useState<string>(project.metadata?.wizard?.script_style || SETUP_STYLE_KEYS[0] || "pain");
const [setupPersona, setSetupPersona] = useState<string>(project.metadata?.wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const chatFileRef = useRef<HTMLInputElement | null>(null);
const chatBodyRef = useRef<HTMLDivElement | null>(null);
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; stream?: string; done?: boolean; auto?: boolean };
const nowHm = () => new Date().toTimeString().slice(0, 5);
const msgIdRef = useRef(1);
const nextMsgId = () => msgIdRef.current++;
// 脚本助手记录按项目持久化:退出项目→重进仍能看到历史对话(行36)
const chatKey = `airshelf:pipeline:chat:${project.id}`;
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>(() => {
try {
const raw = localStorage.getItem(chatKey);
if (raw) {
const saved = JSON.parse(raw) as ChatMsg[];
if (Array.isArray(saved) && saved.length) {
msgIdRef.current = Math.max(...saved.map((m) => m.id || 0)) + 1;
return saved;
}
}
} catch { /* 解析失败则回退默认 */ }
return currentScript
? [{ id: nextMsgId(), role: "ai", auto: true, text: `当前已有 ${currentScript.segments?.length ?? 0} 镜脚本(${currentScript.is_adopted ? "已采用" : "待采用"})。直接输入修改意见可整体重写。`, time: nowHm() }]
: [];
});
// 落盘:auto 派生提示(「已有N镜」)不落盘——它跟当前脚本状态绑定,持久化会在切项目时串台;
// 未完成的 progress 流也不落盘(临时态);只保留最近 60 条真实对话
useEffect(() => {
try {
const slim = chatMsgs.filter((m) => !m.auto && (m.kind !== "progress" || m.done)).slice(-60);
localStorage.setItem(chatKey, JSON.stringify(slim));
} catch { /* localStorage 不可用则忽略 */ }
}, [chatKey, chatMsgs]);
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
// 脚本模型下拉:用户可选 豆包/GPT-5.5/Gemini(空 = 用后端默认文本模型)
const [scriptModelId, setScriptModelId] = useState<string>("");
const activeScriptModelId = scriptModelId || textModels?.[0]?.id || "";
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
const [armedDelete, setArmedDelete] = useState<string | null>(null);
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
const [busyShot, setBusyShot] = useState<string | null>(null);
// 行35 · 单条分镜重跑:调后端 rerun-script-segment 只重写该镜(instruction 带本镜要点微调),给即时反馈
async function rerunShot(shotId: string, _index: number, hint: string) {
if (busyShot) return;
setBusyShot(shotId);
try {
await onRerunShot?.(shotId, hint || undefined);
} finally {
setBusyShot(null);
}
}
// 单条分镜删除:立即反馈(置 busy)再落库
async function deleteShot(shotId: string) {
if (busyShot) return;
setBusyShot(shotId);
setArmedDelete(null);
try {
await onDeleteShot(shotId);
} finally {
setBusyShot(null);
}
}
useEffect(() => {
if (!armedDelete) return;
const timer = window.setTimeout(() => setArmedDelete(null), 3000);
return () => window.clearTimeout(timer);
}, [armedDelete]);
useEffect(() => {
const el = chatBodyRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [chatMsgs]);
// 行33 · 进度流:由后端 SSE 真事件驱动 —— 工具卡(tool:加载skill/分析商品/生成分镜/提取实体/自检)
// + 思考前言(delta 逐字)。真 agent 体感,不再前端假模拟。流式不可用时兜底退回旧同步端点。
function mapSourceToMode(src?: string): "auto" | "theme" | "revise" {
if (src === "theme") return "theme";
if (src === "revise") return "revise";
return "auto"; // ai / manual / 默认
}
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string, mode?: "auto" | "theme" | "revise", targetIndex?: number) {
pushMsg("user", userLabel || prompt);
const progressId = nextMsgId();
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [], stream: "", done: false, time: nowHm() }]);
// 指定镜号 = 精准改一镜,强制走 revise(后端读全脚本上下文、只动那一镜)
const agentMode = targetIndex != null ? "revise" : (mode ?? mapSourceToMode(source ?? chatMode));
const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined;
let ok = false;
let receivedEvent = false;
try {
await api.agentScriptStream(
project.id,
{
mode: agentMode,
prompt,
model_config_id: activeScriptModelId || undefined,
base_version_id: baseVersionId,
aspect_ratio: "9:16",
total_duration: 60,
target_index: targetIndex
},
(evt) => {
receivedEvent = true;
if (evt.type === "tool") {
// 工具卡:running 时把 label 滚进进度流(done/error 暂只用于结束态)
if (evt.status === "running" && typeof evt.label === "string") {
const label = evt.label;
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, steps: [...(m.steps ?? []), label] } : m)));
}
} else if (evt.type === "delta" && typeof evt.text === "string") {
const piece = evt.text;
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, stream: (m.stream ?? "") + piece } : m)));
} else if (evt.type === "saved") {
ok = true;
} else if (evt.type === "error") {
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
pushMsg("ai", `生成失败:${typeof evt.detail === "string" ? evt.detail : "请稍后重试"}`);
}
}
);
} catch {
if (ok) {
// 已收到 saved:流其实成功了(只是收尾抖动),绝不回退重发,避免重复生成+重复扣费
} else if (!receivedEvent) {
// 一个事件都没收到 = 流式根本没跑起来(网关不支持 SSE)→ 退回旧同步端点
const res = await onGenerateScript(prompt, source ?? chatMode).catch(() => null);
ok = !!res;
} else {
// 流中途断了(后端已通过 finally 释放预扣额度):不重试,提示用户
pushMsg("ai", "生成中断了,请重试。");
}
}
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
if (ok) {
await onRefreshProject();
pushMsg("ai", "镜头脚本已生成,左侧已刷新。可继续输入修改意见(会基于当前脚本改稿),或点「确认脚本」进入下一步。");
}
}
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
async function runScriptWithSetup() {
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
setSetupOpen(false);
// 行28 · 持久化所选风格/人物到 metadata.wizard(合并现有 wizard,不冲掉 duration/selling_point_ids);
// 顶部「风格/人物」brief pill 读 metadata.wizard,刷新后即显已确认值。
// 先 await 落库再发起生成:action 有 in-flight 互斥锁,并发会被「操作进行中」挡掉
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
if (setupSource === "theme") {
const theme = chatText.trim();
if (!theme) { focusThemeMode(); return; }
setChatText("");
await runScriptGeneration(`一句话主题:${theme}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${theme} · ${styleLabel} · ${personaLabel}`, "theme");
return;
}
if (setupSource === "manual") {
const base = chatText.trim();
setChatText("");
await runScriptGeneration(`${base ? `${base}\n` : ""}据此整理成镜头脚本。风格:${styleLabel},目标人群:${personaLabel}`, `自带脚本 · ${styleLabel} · ${personaLabel}`, "manual");
return;
}
await runScriptGeneration(`AI 全生 · 风格:${styleLabel},目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`, `${sourceLabel}:${styleLabel} · ${personaLabel}`, "ai");
}
// 流程步骤3 · 把待确认的标签增删拼成给 AI 的重写指令 + 用户消息标签
function tagEditsInstruction() {
if (!pendingTagEdits.length) return "";
const parts = pendingTagEdits.map((e) => `${e.op === "add" ? "添加" : "删除"}${e.kind === "cast" ? "人物" : "场景"}${e.value}」`);
return `请据此调整并整体重写脚本:${parts.join("、")}。其余分镜尽量保持不变。`;
}
// 发送框统一入口:设定卡开着 → 走 setup 生成;否则把「待确认标签增删 + 输入文字」合成一条整体重写
function submitChat() {
if (loading) return;
if (setupOpen) { void runScriptWithSetup(); return; }
const text = chatText.trim();
const tagInstr = tagEditsInstruction();
if (!text && !tagInstr) return;
const prompt = [tagInstr, text].filter(Boolean).join("\n");
const label = [pendingTagEdits.map(tagEditVerb).join(" · "), text].filter(Boolean).join(" · ");
setChatText("");
setChatAttachments([]);
setPendingTagEdits([]);
// 检测「第N镜 / 场N / 第N个 / 第N段」→ 精准只改那一镜(有当前脚本时);否则整版改稿/出稿
let targetIndex: number | undefined;
if (currentScript && shots.length) {
// 只认「第N镜 / 第N场」;「个/段」太宽(会撞「第3个卖点」「第2段文案」等整体改稿诉求)故不触发
const m = text.match(/第\s*([0-9]{1,2}|[一二两三四五六七八九十])\s*(?:镜|场)/);
if (m) {
const cn: Record<string, number> = { : 1, : 2, : 2, : 3, : 4, : 5, : 6, : 7, : 8, : 9, : 10 };
const n = /^[0-9]+$/.test(m[1]) ? parseInt(m[1], 10) : (cn[m[1]] ?? 0);
if (n >= 1 && n <= shots.length) targetIndex = n - 1;
}
}
// 已有脚本 → 追问走「改稿」(指定镜号则只改那镜);否则全自动出稿
void runScriptGeneration(prompt, label || undefined, undefined, currentScript ? "revise" : "auto", targetIndex);
}
function clearChat() {
setPendingTagEdits([]);
setChatText("");
setChatAttachments([]);
setChatMode("ai");
setSetupOpen(false);
setChatMsgs([]);
}
// 行30 · 三选项指引:选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成)
function openSetup(source: "ai" | "theme" | "manual") {
setSetupSource(source);
setChatMode(source);
setSetupOpen(true);
if (source === "theme") { focusThemeMode(); }
else if (source === "manual") { pickScriptMode(); }
}
function pickScriptMode() {
setChatMode("manual");
chatFileRef.current?.click();
}
function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
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");
pushMsg("user", `已上传脚本附件《${file.name}》(${text.length} 字)`);
pushMsg("ai", "已读入你的脚本。可在输入框补充修改意见,点发送据此生成镜头脚本。");
chatTextareaRef.current?.focus();
};
reader.readAsText(file);
}
function focusThemeMode() {
setChatMode("theme");
if (chatMsgs[chatMsgs.length - 1]?.text !== THEME_TIP) pushMsg("ai", THEME_TIP);
chatTextareaRef.current?.focus();
}
// Stage 1 · 脚本助手宽度拖拽(对齐设计稿:clamp [380, 680],写 --chat-w)
const scriptGridRef = useRef<HTMLDivElement | null>(null);
const [gutterDragging, setGutterDragging] = useState(false);
useEffect(() => {
if (!gutterDragging) return;
function onMove(e: MouseEvent) {
const grid = scriptGridRef.current;
if (!grid) return;
const rect = grid.getBoundingClientRect();
const width = Math.max(380, Math.min(680, rect.right - e.clientX));
grid.style.setProperty("--chat-w", `${width}px`);
}
function onUp() {
setGutterDragging(false);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [gutterDragging]);
const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成可直接指导视频的分镜图";
const [storyboardPrompt, setStoryboardPrompt] = useState(SB_PROMPT_DEFAULT);
// 切换查看的故事板版本时,提示词编辑框重新种子为该版本的提示词
useEffect(() => {
setStoryboardPrompt(displayedStoryboard?.prompt || SB_PROMPT_DEFAULT);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedStoryboard?.id]);
const videoPrompt = "竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感";
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 };
// voOffsets:配音句的句内起点覆盖(asset id → ms)。拖动字幕块改这里,进 undo 栈,保存草稿/导出时落盘
type EditorState = { clips: EdClipState[]; subtitleEnabled: boolean; subtitleStyle: string; transition: string; bgmVolume: number; voOffsets: Record<string, number> };
const buildInitialEditor = useCallback((): EditorState => {
const tl = project.timeline;
const sub = (tl?.subtitle_tracks ?? [])[0];
// 保存的字幕是逐句 cue(每句带 start_ms),不能再按「content[i]=片段 i」取——
// 必须按时间窗把句子归回所属片段并拼回整段文本(旧格式「每片段一条」时间窗归组同样成立)
const savedCues = (sub?.content ?? [])
.map((c) => ({ start: Number(c?.start_ms || 0), text: String(c?.text || "").trim() }))
.filter((c) => c.text)
.sort((a, b) => a.start - b.start);
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 adoptedSwap = new Map<string, { asset: string; url: string }>();
segments.forEach((s) => {
if (!s.adopted_asset) return;
(s.versions ?? []).forEach((v) => {
if (v.asset && v.asset !== s.adopted_asset) adoptedSwap.set(v.asset, { asset: s.adopted_asset as string, url: segUrl(s) });
});
});
const baseClips: EdClipState[] = tl?.clips?.length
? [...tl.clips].sort((a, b) => a.sort_order - b.sort_order).map((c, i) => {
const swap = adoptedSwap.get(c.asset);
return {
key: `c${i}-${c.id}`, asset: swap?.asset ?? c.asset, url: swap ? swap.url : (c.asset_url || assetUrl(c.asset)),
isVideo: swap ? true : (c.asset_is_video ?? isVideoAsset(c.asset)), durMs: c.duration_ms || 0,
trimStartMs: swap ? 0 : (c.trim_start_ms || 0), trimEndMs: swap ? null : (c.trim_end_ms ?? null), subtitle: ""
};
})
: 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: ""
}));
// 句子拼回:句尾被保存时去掉的逗号句号补一个「。」,保证重新逐句切分时能在原位置断句
let accMs = 0;
baseClips.forEach((clip, i) => {
const start = accMs;
const end = accMs + clip.durMs;
accMs = end;
const inRange = savedCues.filter((cue) => cue.start >= start && cue.start < end);
clip.subtitle = inRange.length
? inRange.map((cue) => (/[。!?!?;…,,、]$/u.test(cue.text) ? cue.text : `${cue.text}。`)).join("")
: (scriptTexts[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,
voOffsets: {}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.timeline, project.script_versions, segments]);
const [edState, setEdState] = useState<EditorState>(buildInitialEditor);
const [edHistory, setEdHistory] = useState<EditorState[]>([]);
const [edFuture, setEdFuture] = useState<EditorState[]>([]);
const [selectedClip, setSelectedClip] = useState(0);
const [propsTab, setPropsTab] = useState<"subtitle" | "transition" | "bgm">("subtitle");
const edHydratedRef = useRef(false);
const bgmFileRef = useRef<HTMLInputElement | null>(null);
const videoUploadRef = useRef<HTMLInputElement | null>(null);
const [uploadTargetSeg, setUploadTargetSeg] = useState<string | null>(null);
// 提交一个新编辑态(进 undo 栈)
const commitEdit = useCallback((next: EditorState) => {
setEdHistory((h) => [...h.slice(-49), edState]);
setEdFuture([]);
setEdState(next);
}, [edState]);
// memo 稳定 edClips 身份:它是几乎所有播放回调/Effect 的依赖,每渲染重建会让缓存全部失效
const edClips = useMemo(
() => edState.clips.map((c) => ({ id: c.key, assetId: c.asset, url: c.url, isVideo: c.isVideo, durMs: c.durMs, trimStartMs: c.trimStartMs || 0 })),
[edState.clips]
);
const edTotalMs = useMemo(() => edClips.reduce((sum, c) => sum + c.durMs, 0) || tlRulerMs, [edClips, tlRulerMs]);
const edOffsets = useMemo(() => edClips.map((_, i) => edClips.slice(0, i).reduce((sum, c) => sum + c.durMs, 0)), [edClips]);
const edOffsetMs = (idx: number) => edOffsets[idx] ?? 0;
// ── 旁白配音(逐句):每句一段语音,句内起点 offset_ms 可被拖动调整——字幕块=语音段,拖到哪声音就在哪 ──
const voInfo = timeline?.voiceover || null;
const voEnabled = Boolean(voInfo?.enabled && voInfo.items.length);
// 预解码所有句音频(进模块级缓存),并用**真实音频时长**接管句窗时长(TTS 接口报的时长有偏差,
// 用错时长会导致字幕窗口与实际发声错位/句尾被切)
const [voRealDurs, setVoRealDurs] = useState<Record<string, number>>({});
useEffect(() => {
if (!voEnabled || !voInfo) return;
let cancelled = false;
for (const it of voInfo.items) {
if (!it.asset) continue;
void loadVoiceBuffer(it.asset).then((buf) => {
if (cancelled || !buf) return;
const ms = Math.round(buf.duration * 1000);
setVoRealDurs((m) => (Math.abs((m[it.asset] || 0) - ms) < 2 ? m : { ...m, [it.asset]: ms }));
});
}
return () => { cancelled = true; };
}, [voEnabled, voInfo]);
type VoCue = { asset: string; url: string; text: string; offsetMs: number; durMs: number };
const voCuesPerClip = useMemo<Record<number, VoCue[]>>(() => {
const map: Record<number, VoCue[]> = {};
if (!voEnabled || !voInfo) return map;
const grouped: Record<number, typeof voInfo.items> = {};
for (const it of voInfo.items) (grouped[it.index] ||= []).push(it);
for (const key of Object.keys(grouped)) {
const idx = Number(key);
const list = [...grouped[idx]].sort((a, b) => (a.cue ?? 0) - (b.cue ?? 0));
let acc = 0; // 无显式 offset 的旧数据按自然语速顺排
map[idx] = list.map((it) => {
const dur = voRealDurs[it.asset] ?? it.duration_ms ?? 0;
const base = it.offset_ms ?? acc;
acc = base + dur;
return { asset: it.asset, url: it.asset_url, text: it.text, offsetMs: edState.voOffsets[it.asset] ?? base, durMs: dur };
});
}
return map;
}, [voEnabled, voInfo, edState.voOffsets, voRealDurs]);
// 字幕逐句窗口:有配音时直接用语音段的窗口(字幕=语音的可视化,天然同步);无配音回退按字数摊满片段。
// 用 deferred 值——字幕 textarea 打字是高频紧急更新,切分/轨道块渲染降级为可中断的后台更新(React 18 并发特性)
const deferredClips = useDeferredValue(edState.clips);
const cuesPerClip = useMemo(
() => deferredClips.map((c, idx) => {
const voCues = voCuesPerClip[idx];
if (voCues?.length) return voCues.map((q) => ({ offsetMs: q.offsetMs, durMs: q.durMs, text: q.text, asset: q.asset }));
return splitSubtitleCues(c.subtitle, c.durMs).map((q) => ({ ...q, asset: "" }));
}),
[deferredClips, voCuesPerClip]
);
const videoRef = useRef<HTMLVideoElement | null>(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;
// ── 瞬态传输面板(transient transport):播放/拖动期间的播放头、指示线、时间文本
// 直接写 DOM,不进 React 状态——否则 timeupdate(每秒 4-15 次)/pointermove(每秒 60+ 次)
// 都会全量重渲染整个管线组件,剪辑操作必卡。借鉴 web-core 细粒度更新思路的 React 版。
const edClipMsRef = useRef(0);
const paintRef = useRef({ pct: 0, label: "0:00" });
// ── BGM 预览音轨:剪辑预览播放时背景音乐跟随播放头(成片已混音,成片态不播)──
// 片段视频是无声生成的(generate_audio=False),不接这条预览播放就全程静音
const bgmRef = useRef<HTMLAudioElement | null>(null);
const bgmPreviewUrl = bgmTracks[0]?.asset_url || "";
// ── 旁白配音(TTS)预览:Web Audio 排程(引擎在下方 scheduleVoices/syncVoice)──
const [voVoicePick, setVoVoicePick] = useState("");
// 配音是按生成那一刻的字幕文本逐句合成的:任何一段文本改了就提示重新生成(预览/导出都会用旧音频)
const voStale = useMemo(() => {
if (!voInfo) return false;
const byClip: Record<number, typeof voInfo.items> = {};
voInfo.items.forEach((it) => (byClip[it.index] ||= []).push(it));
return Object.entries(byClip).some(([key, list]) => {
const clip = edState.clips[Number(key)];
if (!clip) return true;
if (list.length === 1 && list[0].cue == null) return (clip.subtitle || "").trim() !== (list[0].text || "").trim();
const want = splitSubtitleCues(clip.subtitle, Math.max(1, clip.durMs)).map((c) => c.text).join("¦");
const have = [...list].sort((a, b) => (a.cue ?? 0) - (b.cue ?? 0)).map((it) => (it.text || "").trim()).join("¦");
return want !== have;
});
}, [voInfo, edState.clips]);
const syncBgm = useCallback((globalMs: number) => {
const a = bgmRef.current;
if (!a || !Number.isFinite(a.duration) || a.duration <= 0) return;
const want = (globalMs / 1000) % a.duration; // 导出时 BGM 循环铺满全片,预览同口径
if (Math.abs(a.currentTime - want) > 0.35) a.currentTime = want;
}, []);
// 人声播放引擎(Web Audio 排程):播放时把当前片段的所有句子按精确时间点排进 AudioContext
// (source.start(when, offset)),起声零延迟;每帧只做时钟漂移校验,漂移 >250ms 才整体重排。
// 全部走 ref(播放中每帧调用,不进 React 状态)
const edIdxRef = useRef(0);
const edOffsetsRef = useRef<number[]>([]);
const voCuesRef = useRef<Record<number, VoCue[]>>({});
const edPlayingRef = useRef(false);
const edMutedRef = useRef(false);
const voActiveRef = useRef(false); // 上次是否在「配音发声态」(边沿检测,防依赖变化误停)
const voGainRef = useRef<GainNode | null>(null);
// 已排程的句:asset → { src, ctxStart(该句在 AudioContext 时间轴上的起点秒) }。
// 用 Map 做增量排程——重排时位置基本没变的「正在发声句」直接保留,不掐断重启(否则每次微漂移都爆音)
const voSourcesRef = useRef<Map<string, { src: AudioBufferSourceNode; ctxStart: number }>>(new Map());
const voClockRef = useRef<{ baseGlobalMs: number; ctxTime: number } | null>(null);
const stopVoices = useCallback(() => {
for (const { src } of voSourcesRef.current.values()) { try { src.stop(); } catch { /* 已停 */ } }
voSourcesRef.current.clear();
voClockRef.current = null;
}, []);
// 增量排程(借鉴 WebAV:AudioContext 时钟权威 + 排了就交给时钟、绝不回头掐断正在发声的句)。
// hard=false(温和:解码晚到/拖句/换段)→ 正在发声的句无条件保留,只动还没开始的未来句;
// hard=true(真 seek/大漂移)→ 正在发声且位置变了的句才停掉重排(用户跳转,预期内)。
const scheduleVoices = useCallback((globalMs: number, hard = false) => {
if (!edPlayingRef.current) { stopVoices(); return; }
const ctx = getVoiceCtx();
if (ctx.state === "suspended") void ctx.resume();
if (!voGainRef.current) {
voGainRef.current = ctx.createGain();
voGainRef.current.connect(ctx.destination);
}
voGainRef.current.gain.value = edMutedRef.current ? 0 : 1;
const gain = voGainRef.current;
const idx = edIdxRef.current;
const within = globalMs - (edOffsetsRef.current[idx] ?? 0);
const cues = voCuesRef.current[idx] || [];
const now = ctx.currentTime + 0.03; // 30ms 起播余量
const live = voSourcesRef.current;
const wanted = new Set<string>();
for (const q of cues) {
const buf = voiceBufferReady.get(q.asset);
if (!buf) continue; // 未解码完的句子先跳过(解码完 voCuesPerClip 变 → 意图 effect 补排)
const startIn = (q.offsetMs - within) / 1000;
if (startIn + buf.duration <= 0) continue; // 这句已经过去
wanted.add(q.asset);
const desiredCtxStart = now + startIn; // 这句应当开始的绝对 ctx 时间(可能为过去=正在发声)
const existing = live.get(q.asset);
if (existing) {
const playing = existing.ctxStart <= ctx.currentTime; // 这句已经在发声
// 温和重排:正在发声的句一律保留(WebAV 式不掐断);未来句漂移 <120ms 也保留
if (!hard && playing) continue;
if (Math.abs(existing.ctxStart - desiredCtxStart) < 0.12) continue;
try { existing.src.stop(); } catch { /* 已停 */ }
}
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(gain);
if (startIn >= 0) src.start(now + startIn);
else src.start(now, -startIn); // 播放头在句中:从句内偏移处续播
const asset = q.asset;
src.onended = () => { if (live.get(asset)?.src === src) live.delete(asset); };
live.set(asset, { src, ctxStart: desiredCtxStart });
}
// 不再属于当前片段/已挪走的句:停掉。但正在发声的句在温和重排里不掐——
// 换段边界有一帧 edIdx 与播放头不同步,会把刚起声的下一句误判「不属于本片段」,
// 放它播完(onended 自清)即可,杜绝边界顿挫(WebAV:从不掐断正在发声的句)
for (const [asset, entry] of live) {
if (wanted.has(asset)) continue;
const playing = entry.ctxStart <= ctx.currentTime;
if (playing && !hard) continue;
try { entry.src.stop(); } catch { /* 已停 */ }
live.delete(asset);
}
voClockRef.current = { baseGlobalMs: globalMs, ctxTime: now };
}, [stopVoices]);
const syncVoice = useCallback((globalMs: number) => {
if (!edPlayingRef.current) return;
const clock = voClockRef.current;
if (!clock) { scheduleVoices(globalMs); return; }
// syncVoice 的重排永远是「卡顿/累积漂移对齐」,不是用户 seek(seek 走 gotoClip/scrub 落点)→
// 一律温和(hard=false):正在发声的句绝不掐断,只对齐还没起声的未来句(WebAV 铁律)
const engineGlobal = clock.baseGlobalMs + (getVoiceCtx().currentTime - clock.ctxTime) * 1000;
if (Math.abs(engineGlobal - globalMs) > 600) scheduleVoices(globalMs);
}, [scheduleVoices]);
useEffect(() => { edIdxRef.current = edIdx; }, [edIdx]);
useEffect(() => { edOffsetsRef.current = edOffsets; }, [edOffsets]);
useEffect(() => { voCuesRef.current = voCuesPerClip; }, [voCuesPerClip]);
useEffect(() => () => stopVoices(), [stopVoices]); // 卸载时停掉所有已排程的句子
const paintTransport = useCallback((globalMs: number) => {
const pct = Math.min(100, (globalMs / (edTotalMs || 1)) * 100);
paintRef.current = { pct, label: fmtMs(globalMs) };
const ph = document.getElementById("ed-playhead");
if (ph) ph.style.left = `${pct}%`;
const lineRuler = document.getElementById("ed-line-ruler");
if (lineRuler) lineRuler.style.left = `${pct}%`;
const lineVideo = document.getElementById("ed-line-video");
if (lineVideo) lineVideo.style.left = `${pct}%`;
const cur = document.getElementById("ed-cur-time");
if (cur) cur.textContent = fmtMs(globalMs);
syncBgm(globalMs); // 播放推进/拖动/落位都经过这里,BGM/人声跟着播放头校准
syncVoice(globalMs);
}, [edTotalMs, syncBgm, syncVoice]);
// 状态提交(seek 落点/换段/暂停)时同步 ref 并重绘
useEffect(() => {
edClipMsRef.current = edClipMs;
paintTransport(edOffsetMs(edIdx) + edClipMs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edClipMs, edIdx, paintTransport]);
// 暂停时把瞬态进度提交回状态(分割等操作读状态才是准的)
useEffect(() => {
if (!edPlaying) setEdClipMs(edClipMsRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edPlaying]);
// 字幕烧入预览的当前句:只有文本真变了才 setState(播放中每 3-5 秒一次,而非每帧)
const [overlayCue, setOverlayCue] = useState("");
const overlayCueRef = useRef("");
const syncOverlayCue = useCallback((idx: number, withinMs: number) => {
const cues = cuesPerClip[idx] || [];
// 按完整时间窗匹配:句子窗口结束就收字幕(配音念完后画面继续时不能挂着最后一句)
const cue = cues.find((q) => withinMs >= q.offsetMs && withinMs < q.offsetMs + q.durMs);
const text = cue?.text || "";
if (text !== overlayCueRef.current) {
overlayCueRef.current = text;
setOverlayCue(text);
}
}, [cuesPerClip]);
useEffect(() => { syncOverlayCue(edIdx, edClipMs); }, [edIdx, edClipMs, syncOverlayCue]);
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]);
// 在时间轴上跳转到全局毫秒位置(标尺点击/拖拽 scrub)。
// 边界用严格小于:正好落在片段交界(如 30s)归下一段开头,而不是停在上一段文件末帧。
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));
setEdClipMs(within);
// 同段内 seek 不触发 [edIdx] effect,直接调元素(片内时间 + trim 起点);跨段交给 effect 换源后再 seek
if (i === edIdx) {
if (edClips[i].isVideo && videoRef.current) videoRef.current.currentTime = (edClips[i].trimStartMs + within) / 1000;
} else {
setEdIdx(i);
}
return;
}
acc += dur;
}
}, [edClips, edIdx]);
// 播放/暂停 = 纯意图切换;真正驱动 <video> 的是下面的「意图 effect」。
// 旧实现把 play()/pause() 塞在 setState updater 里,且靠元素 onPause 回写状态——
// 片段边界切 src 时浏览器自动 pause 会把意图打回 false,播放器在交界处卡死(黑屏冻结)。
const togglePlay = useCallback(() => {
setEdPlaying((p) => !p);
}, []);
// 上一帧 / 下一帧:视频按 ±1/25s 逐帧 seek,越界切相邻段;静态图切相邻片段。
// 全部按「片内时间」算(trim 感知),分割出的片段在自己的窗口内步进,不会串到相邻半段。
const stepFrame = useCallback((dir: 1 | -1) => {
const v = videoRef.current;
if (edCur?.isVideo && v && v.duration) {
const trim = edCur.trimStartMs || 0;
const within = v.currentTime * 1000 - trim + dir * 40;
if (within < 0) { gotoClip(edIdx - 1, true); return; }
if (edCur.durMs > 0 && within > edCur.durMs) { gotoClip(edIdx + 1, false); return; }
v.currentTime = (trim + within) / 1000;
setEdClipMs(Math.max(0, within));
} 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 进度(片内时间 + trim 起点)。播放续播交给下面的意图 effect。
useEffect(() => {
const v = videoRef.current;
if (!v || !edCur?.isVideo) return;
v.currentTime = ((edCur.trimStartMs || 0) + edClipMs) / 1000;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edIdx]);
// 成片 ↔ 编辑预览切换:导出成功默认看成片,但仍可切回片段编辑预览(此前成片会永久霸占预览区)
const [previewFinal, setPreviewFinal] = useState(true);
// 用户一按播放 = 视图选择已定(编辑预览),立刻落锁——
// 否则进页后异步查到「旧成片存在」会把视图整帧切成成片,正在播的三轨(视频/BGM/配音)被连根掀翻
useEffect(() => {
if (edPlaying) setPreviewFinal(false);
}, [edPlaying]);
// 只有「刚导出完成」(运行中→成功)才自动切到成片(用户在等成片);旧成片靠默认值在进页时展示
const prevExportStatusRef = useRef<string | undefined>(undefined);
useEffect(() => {
const cur = exportResult?.status;
const prev = prevExportStatusRef.current;
if (cur === "succeeded" && (prev === "running" || prev === "queued")) setPreviewFinal(true);
prevExportStatusRef.current = cur;
}, [exportResult?.status]);
const showingFinal = viewStage === 5 && previewFinal && exportResult?.status === "succeeded" && Boolean(exportResult?.output_url);
// ── 播放意图 effect:edPlaying 是唯一事实源,元素跟着意图走。
// 边界换 src 浏览器自动 pause 也不会丢意图——effect 在新片段上接着 play,跨段连续播放。
const [edMuted, setEdMuted] = useState(false);
useEffect(() => {
const v = videoRef.current;
if (!v || !edCur?.isVideo || showingFinal) return;
if (edPlaying) v.play().catch(() => setEdPlaying(false));
else if (!v.paused) v.pause();
}, [edPlaying, edIdx, showingFinal, edCur?.isVideo]);
// BGM 跟随播放意图:音量滑杆/静音键实时生效;静态图片段也照样出声
useEffect(() => {
const a = bgmRef.current;
if (!a) return;
a.volume = Math.max(0, Math.min(1, edState.bgmVolume / 100));
a.muted = edMuted;
}, [edState.bgmVolume, edMuted, bgmPreviewUrl]);
useEffect(() => {
const a = bgmRef.current;
if (!a) return;
if (edPlaying && !showingFinal && edCur) {
// 元素可能在音量 effect 跑过之后才挂载(进 stage5 才渲染),起播前再应用一次音量/静音
a.volume = Math.max(0, Math.min(1, edState.bgmVolume / 100));
a.muted = edMuted;
syncBgm(edOffsetMs(edIdx) + edClipMsRef.current);
a.play().catch(() => {});
} else if (!a.paused) {
a.pause();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edPlaying, edIdx, showingFinal, bgmPreviewUrl]);
// 旁白配音跟随播放意图:播放/换段/拖动句子(voCuesPerClip 变)都温和重排;暂停才停。
// 边沿检测:只在「发声态 → 停」真翻转时 stopVoices——否则解码晚到让 voCuesPerClip 变、
// effect 重跑时会误走 else 把正在念的句掐掉(0:17 边界顿挫的真凶)
useEffect(() => {
edPlayingRef.current = edPlaying && !showingFinal;
edMutedRef.current = edMuted;
if (voGainRef.current) voGainRef.current.gain.value = edMuted ? 0 : 1;
const active = edPlayingRef.current && voEnabled;
if (active) scheduleVoices(edOffsetMs(edIdx) + edClipMsRef.current);
else if (voActiveRef.current) stopVoices(); // 仅播放→暂停/切成片/关配音 时停一次
voActiveRef.current = active;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edPlaying, edIdx, showingFinal, edMuted, voEnabled, voCuesPerClip]);
// ── 逐帧播放头(对齐 designcombo RVE 的 frameupdate / react-timeline-editor 的 rAF 引擎)──
// timeupdate 每秒只来 ~4 次,播放头每 250ms 跳一格=视觉卡顿;
// 这里用 requestVideoFrameCallback(精确到视频帧,Chrome)/rAF 兜底,逐帧直绘。
useEffect(() => {
if (!edPlaying || showingFinal || !edCur?.isVideo) return;
const v = videoRef.current;
if (!v) return;
let stopped = false;
const paintFrom = (mediaTimeSec: number) => {
if (scrubActiveRef.current || v.seeking) return;
const within = Math.max(0, mediaTimeSec * 1000 - (edCur.trimStartMs || 0));
if (edCur.durMs > 0 && within > edCur.durMs) return; // 边界推进交给 timeupdate
edClipMsRef.current = within;
paintTransport(edOffsetMs(edIdx) + within);
};
type VideoWithVFC = HTMLVideoElement & {
requestVideoFrameCallback?: (cb: (now: number, meta: { mediaTime: number }) => void) => number;
cancelVideoFrameCallback?: (handle: number) => void;
};
const vv = v as VideoWithVFC;
if (typeof vv.requestVideoFrameCallback === "function") {
let handle = 0;
const onFrame = (_now: number, meta: { mediaTime: number }) => {
if (stopped) return;
paintFrom(meta.mediaTime);
handle = vv.requestVideoFrameCallback!(onFrame);
};
handle = vv.requestVideoFrameCallback(onFrame);
return () => { stopped = true; vv.cancelVideoFrameCallback?.(handle); };
}
let rafId = 0;
const onRaf = () => {
if (stopped) return;
if (v.readyState >= 2) paintFrom(v.currentTime);
rafId = requestAnimationFrame(onRaf);
};
rafId = requestAnimationFrame(onRaf);
return () => { stopped = true; cancelAnimationFrame(rafId); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edPlaying, edIdx, showingFinal, edCur, paintTransport]);
// ── 播放头 / 标尺按住拖动 scrub ──
// 拖动期间不换源:跨段拖动只直绘播放头/时间(不进 React 状态),画面保持当前帧——
// 否则拖过每个边界都整段重载视频,加载空窗 + 转场黑闪叠加,拖动全程黑屏。
// 目标落在当前片段内时直接 seek 元素给实时画面;松手才一次性落位到目标段。
const scrubWasPlayingRef = useRef(false);
const scrubActiveRef = useRef(false);
const beginScrub = useCallback((event: { clientX: number; preventDefault: () => void }, trackEl: HTMLElement | null) => {
if (!edClips.length || !trackEl) return;
event.preventDefault();
setPreviewFinal(false); // 看成片时拖时间轴 = 想回编辑预览定位
const rect = trackEl.getBoundingClientRect();
const idxAtDown = edIdx;
scrubWasPlayingRef.current = edPlaying;
scrubActiveRef.current = true;
setEdPlaying(false);
let lastMs = 0;
// 元素 seek 走 rAF 节流:pointermove 每秒可达 60-120 次,逐次设 currentTime 解码排队也会顿
let rafId = 0;
let pendingFileSec = -1;
const applySeek = () => {
rafId = 0;
if (pendingFileSec >= 0 && videoRef.current) videoRef.current.currentTime = pendingFileSec;
pendingFileSec = -1;
};
const seekAt = (clientX: number) => {
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
lastMs = frac * edTotalMs;
// 拖动期间只直绘传输面板,不进 React 状态(否则每次 move 全量重渲染=拖动卡顿)
paintTransport(lastMs);
let acc = 0;
for (let i = 0; i < edClips.length; i += 1) {
const dur = edClips[i].durMs || 0;
if (lastMs < acc + dur || i === edClips.length - 1) {
const within = Math.max(0, Math.min(dur, lastMs - acc));
syncOverlayCue(i, within);
// 在按下时所在的片段范围内拖动 → seek 元素给实时画面(无需换源)
if (i === idxAtDown && edClips[i].isVideo) {
pendingFileSec = (edClips[i].trimStartMs + within) / 1000;
if (!rafId) rafId = requestAnimationFrame(applySeek);
}
break;
}
acc += dur;
}
};
seekAt(event.clientX);
const onMove = (ev: PointerEvent) => seekAt(ev.clientX);
const onUp = () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.removeEventListener("pointercancel", onUp);
if (rafId) cancelAnimationFrame(rafId);
scrubActiveRef.current = false;
// 按拖放位置落位状态 + 元素(不走 seekToMs——目标 idx 可能等于当前值,setState 短路会跳过元素 seek)
let acc = 0;
for (let i = 0; i < edClips.length; i += 1) {
const dur = edClips[i].durMs || 0;
if (lastMs < acc + dur || i === edClips.length - 1) {
const within = Math.max(0, Math.min(dur, lastMs - acc));
setEdIdx(i);
setEdClipMs(within);
if (i === idxAtDown && edClips[i].isVideo && videoRef.current) videoRef.current.currentTime = (edClips[i].trimStartMs + within) / 1000;
break;
}
acc += dur;
}
if (scrubWasPlayingRef.current) setEdPlaying(true);
scrubWasPlayingRef.current = false;
};
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
document.addEventListener("pointercancel", onUp);
}, [edClips, edIdx, edPlaying, edTotalMs, paintTransport, syncOverlayCue]);
// ── 片段视频加载失败(TOS 签名 URL 过期等):画布给出提示 + 一键刷新重取链接 ──
const [mediaError, setMediaError] = useState(false);
useEffect(() => { setMediaError(false); }, [edCur?.url, showingFinal]);
async function reloadMediaLinks() {
setMediaError(false);
edHydratedRef.current = false; // 刷新到货后允许编辑器用新 URL 重新水合
await onRefreshProject();
}
// ── 双缓冲预加载(借鉴 video-flow/web-core「每片段一 sprite 常驻」思路的 DOM 版)──
// 两个 <video> 槽位轮换:下一片段在隐藏槽提前加载并 seek 到入点;
// 播放推进到边界时只交换可见槽位、不当场换 src——跨段零黑屏;
// transition≠none 时两槽 opacity 过渡 0.35s = 真转场预览(替代旧的黑闪提示层)。
const slotARef = useRef<HTMLVideoElement | null>(null);
const slotBRef = useRef<HTMLVideoElement | null>(null);
const [activeSlot, setActiveSlot] = useState<0 | 1>(0);
const standbySlot: 0 | 1 = activeSlot === 0 ? 1 : 0;
const slotRefOf = (slot: 0 | 1) => (slot === 0 ? slotARef : slotBRef);
const nextEdClip = edClips[edIdx + 1] || null;
// 每槽独立 src:交换后旧画面要留在淡出层,等转场结束才给它装下一段(否则换 src 当场丢帧)
const [slotSrcs, setSlotSrcs] = useState<[string, string]>(["", ""]);
useEffect(() => {
const url = edCur?.isVideo ? edCur.url : "";
setSlotSrcs((prev) => (prev[activeSlot] === url ? prev : (activeSlot === 0 ? [url, prev[1]] : [prev[0], url])));
}, [edCur?.url, edCur?.isVideo, activeSlot]);
useEffect(() => {
const url = nextEdClip?.isVideo ? nextEdClip.url : "";
const delay = edState.transition !== "none" ? 420 : 60;
const timer = window.setTimeout(() => {
setSlotSrcs((prev) => (prev[standbySlot] === url ? prev : (standbySlot === 0 ? [url, prev[1]] : [prev[0], url])));
}, delay);
return () => window.clearTimeout(timer);
}, [nextEdClip?.url, nextEdClip?.isVideo, standbySlot, edState.transition]);
// 槽位回调 ref:commit 阶段先于所有 effect 执行,保证 videoRef 永远指向激活槽
const bindSlot = (slot: 0 | 1) => (el: HTMLVideoElement | null) => {
slotRefOf(slot).current = el;
if (slot === activeSlot) videoRef.current = el;
};
// 边界推进:备用槽已就绪(同 url 且解出帧)→ 仅交换槽位;否则退回原地换 src(老路径)
const advanceClip = useCallback(() => {
if (edIdx + 1 >= edClips.length) return;
const next = edClips[edIdx + 1];
const sv = slotRefOf(standbySlot).current;
if (next?.isVideo && sv && sv.getAttribute("src") === next.url && sv.readyState >= 2) {
setActiveSlot(standbySlot);
}
setEdIdx(edIdx + 1);
setEdClipMs(0);
}, [edIdx, edClips, standbySlot]);
// ── 时间轴真实缩略图 + BGM 真波形(异步抽取,memoize,占位兜底)──
const [thumbsMap, setThumbsMap] = useState<Record<string, string[]>>({});
const [bgmPeaks, setBgmPeaks] = useState<number[] | null>(null);
const videoAssetKey = edClips.filter((c) => c.isVideo && c.assetId).map((c) => c.assetId).join(",");
useEffect(() => {
if (viewStage !== 5 || !videoAssetKey) return;
let cancelled = false;
(async () => {
for (const id of Array.from(new Set(videoAssetKey.split(",")))) {
const thumbs = await extractVideoThumbs(id);
if (cancelled) return;
if (thumbs.length) setThumbsMap((m) => (m[id] ? m : { ...m, [id]: thumbs }));
}
})();
return () => { cancelled = true; };
}, [viewStage, videoAssetKey]);
const bgmAssetId = (project.timeline?.bgm_tracks ?? [])[0]?.asset || "";
useEffect(() => {
if (viewStage !== 5 || !bgmAssetId) { setBgmPeaks(null); return; }
let cancelled = false;
void extractWavePeaks(bgmAssetId).then((peaks) => { if (!cancelled && peaks.length) setBgmPeaks(peaks); });
return () => { cancelled = true; };
}, [viewStage, bgmAssetId]);
// 删除/撤销使片段数变少时,播放位置回收到最后一段,防止播放头/时间显示越界
useEffect(() => {
if (edClips.length > 0 && edIdx >= edClips.length) {
setEdIdx(edClips.length - 1);
setEdClipMs(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edClips.length, edIdx]);
// 从成片切回编辑预览时,video 元素是新挂载的,要把进度 seek 回当前片内位置(含 trim)
useEffect(() => {
if (showingFinal) return;
const v = videoRef.current;
if (v && edCur?.isVideo) v.currentTime = ((edCur.trimStartMs || 0) + edClipMs) / 1000;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showingFinal]);
// 键盘:仅 stage5 编辑预览态生效(空格播放/暂停,←/→ 逐帧);看成片时交给原生 <video controls>
useEffect(() => {
if (viewStage !== 5 || showingFinal) 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, showingFinal]);
// 有运行中的视频段就静默轮询推进(本机无 Celery worker,前端驱动 poll-video-segment)。
// 不再限定停留在阶段4:此前切去别的阶段轮询就停了,生成被白白挂起——只要管线页开着就继续推进。
const activeVideoCount = segments.filter((s) => ["running", "queued"].includes(s.status)).length;
useEffect(() => {
if (activeVideoCount === 0) return;
const timer = window.setInterval(() => { void onPollVideosQuiet(); }, 5000);
return () => window.clearInterval(timer);
}, [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, edClipMsRef.current));
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 => {
// 字幕逐句落盘:每条 cue 带 start_ms/end_ms。有配音时窗口=语音段窗口(含拖动后的句内偏移),
// 导出烧入与人声严格同窗;无配音回退按字数摊满片段
let acc = 0;
const content: Array<{ start_ms: number; end_ms: number; text: string }> = [];
edState.clips.forEach((c, idx) => {
const voCues = voCuesPerClip[idx];
const cues = voCues?.length ? voCues : splitSubtitleCues(c.subtitle, c.durMs);
for (const cue of cues) {
content.push({ start_ms: Math.round(acc + cue.offsetMs), end_ms: Math.round(acc + cue.offsetMs + cue.durMs), text: cue.text });
}
acc += c.durMs;
});
const payload: TimelineSavePayload = {
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 }
};
// 拖动过的句内偏移回写到配音映射(导出的人声放置位置以它为准)
const voItems = Object.values(voCuesPerClip).flat();
if (voItems.length) payload.voiceover = { items: voItems.map((q) => ({ asset: q.asset, offset_ms: Math.round(q.offsetMs) })) };
return payload;
}, [edState, voCuesPerClip]);
// 字幕文本编辑(打字不进 undo 栈,避免逐字刷历史)
function setClipSubtitle(idx: number, text: string) {
setEdState((s) => ({ ...s, clips: s.clips.map((c, i) => (i === idx ? { ...c, subtitle: text } : c)) }));
}
// 字幕块水平拖动:改这句的句内起点(字幕和它的语音段一起移动,保持同步);
// 拖动期间直写 style.left(瞬态),松手才 commitEdit 进 undo 栈并待保存落盘
function beginCueDrag(event: ReactPointerEvent<HTMLDivElement>, clipIdx: number, asset: string, startOffsetMs: number, cueDurMs: number) {
event.preventDefault();
event.stopPropagation();
const lane = document.getElementById("ed-lane-subtitle");
if (!lane || !asset) return;
const laneW = lane.getBoundingClientRect().width || 1;
const startX = event.clientX;
const clipDur = edState.clips[clipIdx]?.durMs || 0;
const maxOffset = Math.max(0, clipDur - cueDurMs);
const el = event.currentTarget;
el.style.cursor = "grabbing";
let next = startOffsetMs;
const onMove = (ev: PointerEvent) => {
const deltaMs = ((ev.clientX - startX) / laneW) * (edTotalMs || 1);
next = Math.max(0, Math.min(maxOffset, startOffsetMs + deltaMs));
el.style.left = `${(((edOffsets[clipIdx] ?? 0) + next) / (edTotalMs || 1)) * 100}%`;
};
const onUp = () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.removeEventListener("pointercancel", onUp);
el.style.cursor = "";
if (Math.abs(next - startOffsetMs) > 10) {
commitEdit({ ...edState, voOffsets: { ...edState.voOffsets, [asset]: Math.round(next) } });
}
};
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
document.addEventListener("pointercancel", onUp);
}
// 片段拖拽重排
const [dragIdx, setDragIdx] = useState<number | null>(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);
}
// (旧的转场黑闪提示层已删:双缓冲槽位的 opacity 过渡就是真转场预览)
// Stage 4 / 5 文件上传
function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
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<HTMLInputElement>) {
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;
// 流程步骤4 · 进入基础资产自动生成脚本里提取出、但还没生成的人物/场景占位卡(每项目仅一次)
useEffect(() => {
if (activeDot !== 2 && viewStage !== 2) return;
if (autoGenRef.current) return;
const flagKey = `airshelf:autogen:${project.id}`;
try { if (localStorage.getItem(flagKey)) { autoGenRef.current = true; return; } } catch { /* ignore */ }
const pending: Array<{ kind: "person" | "scene"; tag: string; prompt: string }> = [];
(["person", "scene"] as const).forEach((kind) => {
const tags = kind === "person" ? (project.metadata?.cast ?? []) : (project.metadata?.scenes ?? []);
const generated = new Set(groupsByKind(kind).map((g) => (g.metadata?.label || "").trim()).filter(Boolean));
const promptMap = (kind === "person" ? project.metadata?.cast_prompts : project.metadata?.scene_prompts) || {};
tags.filter((t) => !generated.has(t)).forEach((tag) => {
const fallback = kind === "person"
? `${tag},真人模特出镜,自然光,${productName} 上身展示,9:16 竖屏`
: `${tag},使用场景,氛围统一,干净构图,9:16 竖屏`;
pending.push({ kind, tag, prompt: promptMap[tag]?.trim() || fallback });
});
});
if (!pending.length) return;
autoGenRef.current = true;
try { localStorage.setItem(flagKey, "1"); } catch { /* ignore */ }
void (async () => {
for (const item of pending) {
const bk = `seed:${item.kind}:${item.tag}`;
if (item.kind === "person") await genPersonWithTri(item.prompt, item.tag, bk);
else await genBaseAsset(item.kind, item.prompt, item.tag, bk);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDot, viewStage, project.id]);
// 流程步骤4 · 在基础资产趴轮询后端在途出图任务,重建「生成中」占位卡 loading(刷新后内存态丢失也能恢复);
// 在途数减少=有任务出图完成 → 刷新项目把占位卡换成真卡。离开该趴即停。
const prevPendingCountRef = useRef(0);
useEffect(() => {
if (activeDot !== 2 && viewStage !== 2) return;
let stopped = false;
let timer = 0;
const tick = async () => {
try {
const res = await api.pendingAssets(project.id);
if (stopped) return;
const list = res.pending || [];
if (list.length < prevPendingCountRef.current) void onRefreshProject();
prevPendingCountRef.current = list.length;
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview })));
} catch {
/* 忽略,下一轮再试 */
}
if (!stopped) timer = window.setTimeout(tick, 4000);
};
void tick();
return () => { stopped = true; window.clearTimeout(timer); };
}, [activeDot, viewStage, project.id, onRefreshProject]);
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 (
<div className="app pipeline-page">
<Sidebar page="pipeline" navigate={navigate} user={user} team={team} products={products} projects={projects} />
<main>
<Decorations />
<header className="topbar">
<div className="pipeline-topbar-left">
<a className="btn btn-ghost pipeline-back" href="/projects" onClick={(event) => { event.preventDefault(); navigate("projects"); }} aria-label="返回视频项目">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M19 12H5" /><path d="M12 19l-7-7 7-7" /></svg>
返回视频项目
</a>
<div className="pipeline-topbar-title" title={project.name}>
{project.name}<span className="mono">// PIPELINE</span>
</div>
</div>
<div className="right">
<span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" />
余额 <strong>{money(billing?.account.balance)}</strong>
</span>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
<IconKitSvg name="bell" />
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
</button>
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
<span>{avatarChar}</span>
</div>
</div>
<div className="stage-pill" id="stage-pill">
{STAGE_STEPS.map((step, index) => (
<Fragment key={step.n}>
<a className={dotCls(step.n)} data-stage={step.n} href={`#stage-${step.n}`} onClick={(event) => { event.preventDefault(); goStage(step.n); }}>
<span className="d"></span><span className="l">{step.label}</span>
</a>
{index < STAGE_STEPS.length - 1 && <span className={`sp-line${step.n <= completed ? " done" : ""}`}></span>}
</Fragment>
))}
</div>
</header>
<div className="content content--fh content--fh-flat" id="page-content">
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{/* ============= STAGE 1 · 脚本 ============= */}
<section className={`stage${viewStage === 1 ? " active" : ""}`} data-stage-pane="1">
<div className="stage-script" ref={scriptGridRef}>
<div className="pane shot-list">
<div className="pane-h">
<div className="shot-headline">
<strong>镜头脚本</strong>
<span className="muted-2 mono" id="shots-meta" style={{ fontSize: "12px" }}>
{shots.length ? ${shots.length} 镜 · ${scriptAdopted ? "已采用" : "待采用"}` : "· 空 · 待生成"}
</span>
</div>
<div className="script-brief-summary" aria-label="当前创作方向">
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),风格/人物=新建向导里选的(metadata.wizard) */}
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "AI 全生") : chatMode !== "ai" ? SOURCE_LABEL[chatMode] : "未选择"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k">风格</span><span className="v" id="brief-style">{(() => { const k = project.metadata?.wizard?.script_style; return k ? (WIZ_STYLE_LABEL[k] || k) : "待确认"; })()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{(() => { const k = project.metadata?.wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
</div>
{/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列,
点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */}
<div className="script-tags" id="script-tags">
{(["cast", "scene"] as const).map((kind) => {
const baseTags = kind === "cast" ? castTags : sceneTags;
const removing = removingSet(kind);
const adds = addingList(kind);
const label = kind === "cast" ? "人物" : "场景";
return (
<div className="tag-group" data-kind={kind === "cast" ? "char" : "scene"} key={kind}>
<span className="tg-lbl">// {label}</span>
{baseTags.map((tag, i) => {
const pendingRemove = removing.has(tag);
return (
<span className={`script-chip${pendingRemove ? " pending-remove" : ""}`} key={`${kind}-${i}-${tag}`}>
{tag}
<button type="button" className="chip-x" aria-label={`${pendingRemove ? "恢复" : "移除"}${label} ${tag}`} title={pendingRemove ? "待删除 · 点恢复" : undefined} onClick={() => toggleRemoveTag(kind, tag)}>{pendingRemove ? "↺" : "×"}</button>
</span>
);
})}
{adds.map((tag) => (
<span className="script-chip pending-add" key={`${kind}-add-${tag}`} title="待添加 · 点发送生效">
{tag}
<button type="button" className="chip-x" aria-label={`撤销添加${label} ${tag}`} onClick={() => queueRemoveTag(kind, tag)}>×</button>
</span>
))}
<AddTagInline placeholder={`${label}名`} onAdd={(v) => queueAddTag(kind, v)} ariaLabel={`添加${label}`} />
</div>
);
})}
</div>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑,适合短视频投放", "整体重写")}> 整体重写</button>
</div>
<div className="shots-body" id="shots-body">
{shots.length ? (() => {
let cum = 0;
return shots.map((shot, index) => {
const start = cum;
cum += shot.duration_seconds || 15;
const narration = (shot.narration || "").trim();
const visual = (shot.visual_prompt || "").trim();
// 旁白/画面历史数据可能同文(结构化解析前生成),画面框置空避免重复编辑同一段
const visualShown = visual === narration ? "" : visual;
return (
<Fragment key={shot.id}>
<div className="shot-card">
<div className="shot-n">{index + 1}</div>
<div className="shot-main">
<div className="shot-meta-row">
<div className="shot-meta">// 场 {index + 1} · {start}-{cum}s</div>
<div className="shot-actions">
{/* 行35 · 单条重跑:即时反馈(转「…」并禁用) */}
<button className="icon-mini-btn" type="button" title="重跑本场(只重写这一镜)" disabled={loading || busyShot === shot.id} onClick={() => void rerunShot(shot.id, index, (visualShown || narration))}>{busyShot === shot.id ? "…" : "↻"}</button>
<button
className={`icon-mini-btn${armedDelete === shot.id ? " armed" : ""}`}
type="button"
title={armedDelete === shot.id ? "再点一次确认删除" : "删除本场"}
disabled={loading || shots.length <= 1 || busyShot === shot.id}
onClick={() => {
if (armedDelete === shot.id) {
void deleteShot(shot.id);
} else {
setArmedDelete(shot.id);
}
}}
>{armedDelete === shot.id ? "确认删除?" : "×"}</button>
</div>
</div>
<div className="shot-row">
<span className="shot-k">旁白</span>
<div
className="shot-v"
contentEditable
suppressContentEditableWarning
spellCheck={false}
data-placeholder="(旁白)点击编辑"
data-empty={narration ? undefined : "true"}
onFocus={(event) => { event.currentTarget.removeAttribute("data-empty"); }}
onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.blur(); } }}
onBlur={(event) => {
const text = (event.currentTarget.textContent || "").trim();
if (!text) event.currentTarget.setAttribute("data-empty", "true");
if (text !== narration) void onUpdateShot({ segment_id: shot.id, narration: text });
}}
>{narration}</div>
</div>
<div className="shot-row">
<span className="shot-k">画面</span>
<div
className="shot-v"
contentEditable
suppressContentEditableWarning
spellCheck={false}
data-placeholder="(画面描述)点击编辑"
data-empty={visualShown ? undefined : "true"}
onFocus={(event) => { event.currentTarget.removeAttribute("data-empty"); }}
onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.blur(); } }}
onBlur={(event) => {
const text = (event.currentTarget.textContent || "").trim();
if (!text) event.currentTarget.setAttribute("data-empty", "true");
if (text !== visualShown) void onUpdateShot({ segment_id: shot.id, visual_prompt: text });
}}
>{visualShown}</div>
</div>
{/* ScriptDraft 结构化字段:镜型(钩子/痛点/卖点/CTA)+ 商品露出方式(只读展示) */}
{(shot.role || shot.product_exposure) ? (
<div className="shot-row">
<span className="shot-k">标记</span>
<div className="shot-v" style={{ opacity: 0.8 }}>
{shot.role ? `镜型·${shot.role}` : ""}
{shot.role && shot.product_exposure ? " " : ""}
{shot.product_exposure ? `露出·${shot.product_exposure}` : ""}
</div>
</div>
) : null}
{/* 角色对白(剧情向):speaker→角色名 / 旁白;只读展示 */}
{shot.dialogue && shot.dialogue.length > 0 ? (
<div className="shot-row">
<span className="shot-k">对白</span>
<div className="shot-v" style={{ opacity: 0.9 }}>
{shot.dialogue.map((d, di) => (
<div key={di}><b>{entityName(d.speaker)}</b>{d.line}</div>
))}
</div>
</div>
) : null}
</div>
</div>
{/* 行34 · 该卡之后挂着的本地草稿分镜(点「添加分镜」插入,可编辑,有内容失焦才落库) */}
{draftShots.filter((d) => d.afterId === shot.id).map((draft) => (
<DraftShotCard key={draft.id} draft={draft}
onCommit={(d) => { setDraftShots((list) => list.filter((x) => x.id !== d.id)); if ((d.narration || d.visual).trim()) void onAddShot(d.afterId || shot.id); }}
onCancel={(id) => setDraftShots((list) => list.filter((x) => x.id !== id))} />
))}
<div className="shot-insert-gap">
<button className="add-shot-btn" type="button" disabled={loading} onClick={() => setDraftShots((list) => [...list, { id: `draft-${Date.now()}`, afterId: shot.id, narration: "", visual: "" }])}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>添加分镜
</button>
</div>
</Fragment>
);
});
})() : (
<div className="shots-empty">
<div className="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 10h18M9 5v14" /></svg></div>
<div className="empty-title">还没有镜头脚本</div>
<div className="empty-hint">// 跟右侧脚本助手对话<br />选择一种方式生成你的第一稿</div>
</div>
)}
</div>
</div>
<div className={`stage-script-gutter${gutterDragging ? " dragging" : ""}`} id="stage-script-gutter" role="separator" aria-orientation="vertical" aria-label="拖动调整脚本助手宽度" onMouseDown={(event) => { event.preventDefault(); setGutterDragging(true); }}></div>
<div className="pane chat-pane">
<div className="pane-h">
<div className="ai-avatar">AI</div>
<strong>脚本助手</strong>
<span className="muted-2 mono" style={{ fontSize: "12px" }}>· {scriptModelName}</span>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0 && chatMsgs.length === 0} onClick={clearChat}>清空对话</button>
</div>
<div className="chat-body" id="chat-body" ref={chatBodyRef}>
{chatMsgs.length || setupOpen ? (
<>
{chatMsgs.map((msg) => (
<div className={`msg ${msg.role}`} key={msg.id}>
<div className="bubble">
{msg.kind === "progress"
? <>
<ProgressStream steps={msg.steps} done={msg.done} />
{msg.stream ? <div style={{ marginTop: 6, opacity: 0.85, whiteSpace: "pre-wrap" }}>{msg.stream}</div> : null}
</>
: <CollapsibleText text={msg.text} maxLines={10} />}
</div>
<div className="time">{msg.time}</div>
</div>
))}
{/* 行28/行30 · 选定生成方式后的「来源 & 风格 & 人物」设定卡(确认后再生成) */}
{setupOpen && (
<div className="msg ai">
<div className="bubble setup-card">
<div className="setup-lead">{setupSource === "manual" ? "已选「自带脚本」。" : setupSource === "theme" ? "已选「一句话主题」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
<label className="setup-field">
<span className="sf-k">风格</span>
<select className="setup-select" value={setupStyle} onChange={(e) => setSetupStyle(e.target.value)}>
{SETUP_STYLE_KEYS.map((k) => <option key={k} value={k}>{WIZ_STYLE_LABEL[k]}</option>)}
</select>
</label>
<div className="setup-rec">根据商品信息推荐</div>
<label className="setup-field">
<span className="sf-k">人物</span>
<select className="setup-select" value={setupPersona} onChange={(e) => setSetupPersona(e.target.value)}>
{SETUP_PERSONA_KEYS.map((k) => <option key={k} value={k}>{WIZ_PERSONA_LABEL[k]}</option>)}
</select>
</label>
<div className="setup-rec">根据商品目标人群推荐</div>
<div className="setup-foot">
<button type="button" className="btn btn-ghost btn-sm" onClick={() => {
// 重新推荐:随机换一组,模拟「再推荐」(纯前端,后端会从 prompt 推断)
setSetupStyle(SETUP_STYLE_KEYS[Math.floor(Math.random() * SETUP_STYLE_KEYS.length)] || setupStyle);
setSetupPersona(SETUP_PERSONA_KEYS[Math.floor(Math.random() * SETUP_PERSONA_KEYS.length)] || setupPersona);
}}>重新推荐</button>
<button type="button" className="btn btn-primary btn-sm" disabled={loading} onClick={() => void runScriptWithSetup()}>确定</button>
</div>
</div>
<div className="time">{nowHm()}</div>
</div>
)}
</>
) : (
<div className="chat-empty">
<div className="ce-title">选择一种生成方式开始</div>
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
<div className="chat-modes">
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => openSetup("ai")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI 全生</button>
<button className={`chat-mode${chatMode === "theme" ? " primary" : ""}`} type="button" data-mode="theme" onClick={() => openSetup("theme")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg>一句话主题</button>
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" onClick={() => openSetup("manual")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>自带脚本</button>
</div>
</div>
)}
</div>
<div className="chat-input">
<div className="chat-input-card">
{/* 流程步骤3 · 待确认的标签增删:悬浮显示「添加/删除人物/场景:xxx」,× 撤销,点发送整体重写 */}
<div className="chat-tagedit-row" id="chat-tagedit-row" hidden={pendingTagEdits.length === 0}>
{pendingTagEdits.map((edit) => (
<span className={`chip tagedit-chip ${edit.op}`} key={edit.id} title={`${tagEditVerb(edit)}(点发送确定更改)`}>
{tagEditVerb(edit)}
<button type="button" aria-label={`撤销 ${tagEditVerb(edit)}`} style={{ marginLeft: 4, background: "none", border: 0, cursor: "pointer", color: "inherit" }} onClick={() => undoTagEdit(edit.id)}>×</button>
</span>
))}
</div>
<div className="chat-attach-row" id="chat-attach-row" hidden={chatAttachments.length === 0}>
{chatAttachments.map((att, index) => (
<span className="chip" key={`${att.name}-${index}`} style={{ marginRight: 6 }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
{att.name} · {att.chars}
<button type="button" aria-label="移除附件" style={{ marginLeft: 4, background: "none", border: 0, cursor: "pointer", color: "inherit" }} onClick={() => setChatAttachments((list) => list.filter((_, i) => i !== index))}>×</button>
</span>
))}
</div>
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜 ·(Enter 发送 / Shift+Enter 换行)" : chatMode === "manual" ? "粘贴或上传你的脚本,AI 将据此生成镜头脚本 ·(Enter 发送)" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党 ·(Enter 发送)"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}
onKeyDown={(event) => {
// 行31 · Enter 发送,Shift+Enter 换行(输入法组合期不触发)
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
submitChat();
}
}}></textarea>
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
<div className="chat-input-foot">
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件" onClick={() => chatFileRef.current?.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
{/* 模型选择小按钮(放输入框下方,对齐 ChatGPT/Lovart) */}
{textModels && textModels.length > 0 ? (
<select
className="setup-select"
style={{ height: 26, fontSize: 12, padding: "0 6px", maxWidth: 150, marginLeft: 6 }}
value={activeScriptModelId}
onChange={(event) => setScriptModelId(event.target.value)}
title="选择脚本生成模型(豆包 / GPT / Gemini)"
>
{textModels.map((m) => <option key={m.id} value={m.id}>{m.display_name || m.name}</option>)}
</select>
) : null}
<span className="spacer"></span>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || (!setupOpen && !chatText.trim() && pendingTagEdits.length === 0)} onClick={submitChat}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]</span></div>
<div className="hstack">
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> 重新生成全部</button>
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
{/* ============= 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;
const productAssetUrl = assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); };
return (
<section className="stage active" data-stage-pane="2">
<div className="stage-assets">
<div className="asset-side">
{KIND_ORDER.map((kind) => {
const list = groupsByKind(kind);
const adopted = list.filter((g) => g.adopted_asset).length;
return (
<div className={`ttab${kind === assetTab ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => jumpAssetSection(kind)}>
<span>{KIND_LABEL[kind]}</span><span className="num">{list.length ? `${adopted}/${list.length}` : "0"}</span>
</div>
);
})}
<div className="info">
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
<br /><br />
<strong className="mono">// 人物 +¥0.20/张</strong>
<strong className="mono">// 场景 +¥0.15/张</strong>
<span style={{ color: "var(--black-alpha-48)" }}>商品图无成本(直接复用商品库)</span>
</div>
</div>
<div className="asset-main">
<section className="asset-sec" id="asset-sec-product">
<div className="sec-h"><h3>商品 · <span id="asset-prod-name">{productName}</span></h3><span className="spacer"></span></div>
<div className="prod-row">
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={adoptedTriAsset || "prod-main"} id="asset-prod-card">
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
{!hasTriView && (
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
<span className="ico" aria-hidden="true"></span>
<span className="lbl-mono">缺三视图</span>
<span className="tri-missing-pop" role="tooltip">
<span className="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
MISSING TRI-VIEW
</span>
<span className="pop-body">该商品还未生成 <b> / / </b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>
<span className="pop-tip">建议:点右下 <b>AI 生成三视图</b> 先补齐三视图,再发起后续生成。</span>
</span>
</span>
)}
<span className="ph-frame" id="asset-prod-thumb-label">{productName} · 主图</span>
</div>
<div className="prod-body">
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
<div className="prod-cat">{products.find((item) => item.id === project.product)?.category || "未分类"}</div>
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
</div>
<div className="prod-action" id="asset-prod-action">
{/* 行39 · 点击展开右侧三视图预览面板并生成一版(对齐原版 prod-preview 交互) */}
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating} onClick={runProductTri}>
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}
</button>
</div>
</div>
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
<div className={`prod-preview${triPanelShow ? " show" : ""}`} id="asset-prod-preview">
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">{triGenerating ? "生成中 · 约 12s" : previewTriAsset ? (previewTriAsset === adoptedTriAsset ? "已采用,不满意可重跑" : "预览中(未采用)") : "待生成"}</span></div>
{(() => {
const u = candUrl(productGroup, previewTriAsset);
if (triGenerating && !u) {
return <div className="placeholder prod-preview-img"><span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">生成中</span></span></div>;
}
return (
<div className={`placeholder prod-preview-img${u ? " has-mock-media is-zoomable" : ""}`} id="prod-preview-img" role={u ? "button" : undefined} tabIndex={u ? 0 : undefined} title={u ? "点击放大查看" : undefined} style={u ? mediaStyle(u) : undefined} onClick={u ? () => 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 && <span className="ph-frame">// 暂无三视图</span>}
</div>
);
})()}
{previewTriAsset && (
<div className="prod-preview-foot" id="prod-preview-foot">
<button className="btn btn-ghost btn-sm" type="button" disabled={triGenerating} onClick={runProductTri}> 重跑</button>
<button className="btn btn-sm prod-preview-adopt" type="button" disabled={previewTriAsset === adoptedTriAsset || loading} title={previewTriAsset === adoptedTriAsset ? "此版本已采用" : "将此版本设为商品采用版本"} onClick={() => { if (productGroup && previewTriAsset !== adoptedTriAsset) void onAdoptBaseAsset(productGroup.id, previewTriAsset); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M4 12l5 5L20 6" /></svg>
{previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
</button>
<span className="spacer"></span>
<span className="muted-2 mono" style={{ fontSize: 12 }}>~¥0.30 / </span>
</div>
)}
<div className={`prod-preview-history${productVersions.length ? " show" : ""}`} id="prod-preview-history">
<div className="h-lbl">// 历史版本 · <span className="ct">{productVersions.length}</span> 版</div>
<div className="h-row">
{productVersions.map((assetId, i) => {
const isAdopted = assetId === adoptedTriAsset;
const isPreview = assetId === previewTriAsset;
const u = candUrl(productGroup, assetId);
return (
<div className={`h-thumb${isAdopted ? " adopted" : ""}${isPreview && !isAdopted ? " previewing" : ""}${u ? " has-mock-media" : ""}`} key={assetId} data-idx={i} title={`v${i + 1}${isAdopted ? " · 已采用" : isPreview ? " · 预览中" : ""}`} role="button" tabIndex={0} style={u ? mediaStyle(u) : undefined} onClick={() => setTriPreviewId(assetId)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setTriPreviewId(assetId); } }}>
<span className="badge">已采用</span>
<span className="v">v{i + 1}</span>
</div>
);
})}
</div>
</div>
</div>
</div>
</section>
{(["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 (
<section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
<div className="sec-h">
<h3>{KIND_LABEL[kind]} · {entities.length} </h3>
<span className="spacer"></span>
{/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */}
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "browse" }); else void genBaseAsset("scene", genPrompt, undefined, customBusy); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
</div>
<div className="asset-grid-2">
{/* 脚本提取出但还没生成的人物/场景: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 (
<div className="asset-card-2 asset-seed" data-asset-kind={kind} data-seed-tag={tag} key={seedKey}>
<div className="placeholder thumb-2">
{busy
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">生成中 · 8s</span></div>
: <span className="ph-frame">{tag} · 待生成</span>}
</div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>{tag}</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>来自脚本</span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop key={seedKey} onInput={(e) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.currentTarget.textContent || "" }))}>{promptValue}</div>
<div className="hstack" style={{ marginTop: 10 }}>
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); if (kind === "person") void genPersonWithTri(p, tag, seedKey); else void genBaseAsset(kind, p, tag, seedKey); }}>{busy ? "生成中…" : "AI 生成"}</button>
</div>
</div>
</div>
);
})}
{/* 已生成实体卡:点缩略/标题进详情;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}`;
const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || pendingHas(kind, entity.name);
const rs = kind === "person" && grp.adopted_asset ? assetReview(grp.adopted_asset) : "";
return (
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
<div className={`placeholder thumb-2${mainUrl && !busy ? " has-mock-media" : ""}`} style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
role="button" tabIndex={0} title="点击查看详情(立绘 / 三视图 / 版本)"
onClick={() => openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}>
{busy
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">生成中…</span></div>
: !mainUrl && <span className="ph-frame">{entity.name}</span>}
</div>
<div className="body-2">
<div className="hstack">
<strong style={{ fontSize: "13.5px", cursor: "pointer" }} onClick={() => openAssetDetail(kind, entity)}>{entity.name}</strong>
<span className="spacer"></span>
{rs === "active" && <span className="pill ok" title="火山真人审核已通过"><span className="dot"></span>审核✓</span>}
{rs === "failed" && <span className="pill err" title={`审核未过:${byId.get(grp.adopted_asset!)?.review_error || "建议改提示词重新生成"}`}><span className="dot"></span>审核✗</span>}
{rs === "processing" && <span className="pill neutral"><span className="dot"></span>审核中</span>}
</div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop key={entity.key} onInput={(e) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: e.currentTarget.textContent || "" }))}>{promptValue}</div>
<div className="hstack" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; if (kind === "person") void genPersonWithTri(p, entity.name, entBK); else void genBaseAsset(kind, p, entity.name, entBK); }}>{busy ? "生成中…" : "重跑"}</button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => openActorReplace(entity)}>替换</button>
</div>
</div>
</div>
);
})}
{entities.length === 0 && pendingTags.length === 0 && (
<div className="placeholder" style={{ gridColumn: "1 / -1", minHeight: "120px", flexDirection: "column", gap: "10px" }}>
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点右上「新增{KIND_LABEL[kind]}」</span>
</div>
)}
</div>
</section>
);
})}
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(1)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回脚本</button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>确认资产,进入故事板 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
);
})()}
{/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */}
{viewStage === 3 && (
<section className="stage active" data-stage-pane="3">
<div className="stage-storyboard">
<div className="sb-canvas">
<div className="sb-scenes-col" id="sb-scenes-row">
{sbFrames.length ? sbFrames.map((frame, idx) => {
const url = frameUrl(frame);
return (
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}>
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}><span className="ph-frame"> {idx + 1}</span></div>
<div className="nm"> {idx + 1}</div>
<div className="sub">#{frame.sort_order + 1}</div>
</div>
);
}) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
</div>
{(() => {
const url = frameUrl(sbActiveFrame);
return (
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => 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}>
<span className="ph-frame">{sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"}</span>
</div>
);
})()}
</div>
<div className="sb-side">
<div className="pane" style={{ padding: "18px" }}>
<div className="hstack" style={{ marginBottom: "10px" }}>
<strong style={{ fontSize: "14px" }}>故事板 · <span id="sb-side-scene">{sbActiveFrame ? `场 ${sbSelected + 1}` : "—"}</span></strong>
<span className="spacer"></span>
{displayedStoryboard
? (displayedStoryboard.is_adopted
? <span className="pill ok"><span className="dot"></span>已采用</span>
: <span className="pill neutral"><span className="dot"></span>历史版本</span>)
: <span className="pill neutral"><span className="dot"></span>未生成</span>}
</div>
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。</div>
<div className="sb-rerun-note">
<span className="warn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4M12 17h.01" /></svg>
</span>
<div className="note-copy"><strong>仅支持整张重跑</strong> · 不能局部改某一镜。如需调单镜,先在 <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 脚本</a> 改镜头描述,再回此处整张重跑。</div>
</div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 整张提示词(重跑时生效,可编辑)</div>
{/* key 跟版本走:切版本重新种子;onInput 实时回写 state → 整张重跑用的就是你编辑后的文本 */}
<div
className="prompt-edit"
contentEditable
suppressContentEditableWarning
spellCheck={false}
id="sb-prompt-edit"
key={displayedStoryboard?.id || "no-version"}
onInput={(event) => setStoryboardPrompt((event.currentTarget.textContent || "").trim())}
>{displayedStoryboard?.prompt || SB_PROMPT_DEFAULT}</div>
<div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
{adoptedStoryboard ? "整张重跑" : "生成故事板"}
</button>
<span className="spacer"></span>
<span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>~¥0.45/</span>
</div>
<div className="sb-history">
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">{storyboards.length}</span>)</div>
<div className="sb-history-row" id="sb-history-row">
{storyboards.length ? storyboards.map((ver) => {
const cover = frameUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]);
return (
<div className={`sb-history-thumb${ver.id === displayedStoryboard?.id ? " current" : ""}`} key={ver.id} data-vi={ver.id} role="button" tabIndex={0} title="点击查看该版本" onClick={() => { setSbViewId(ver.id); setSbSelected(0); }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewId(ver.id); setSbSelected(0); } }}>
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{ver.is_adopted ? "采用" : "历史"}</span></div>
<div className="ts">{(ver.created_at || "").slice(11, 16) || "--:--"}</div>
</div>
);
}) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 暂无历史</span>}
</div>
</div>
<div className="divider" style={{ marginTop: "16px" }}></div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
{groups.filter((g) => g.adopted_asset).length ? groups.filter((g) => g.adopted_asset).map((g) => (
<span className="asset-tag" key={g.id}><span className="dotc"></span>{assetName(g.adopted_asset) || KIND_LABEL[g.kind] || g.kind}({KIND_LABEL[g.kind] || g.kind})</span>
)) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 暂无绑定资产</span>}
</div>
</div>
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ image-2 整张输出 · {sbFrames.length} · 整张重跑,失败不扣 ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
{adoptedStoryboard ? (
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(4)}>确认故事板,开始生成视频 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
) : (
/* 没有故事板时不再假装「确认故事板」:走真实 skip-storyboard,把项目阶段如实推进到视频 */
<button className="btn btn-primary btn-lg" type="button" disabled={loading} onClick={async () => { await onSkipStoryboard(); goStage(4); }}>跳过故事板,直接生成视频 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
)}
</div>
</div>
</section>
)}
{/* ============= 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 (
<section className="stage active" data-stage-pane="4">
<div className="queue-bar">
<div>
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
<div className="muted-2 mono" style={{ fontSize: "12px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {statusText}</div>
</div>
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => onSubmitAllVideos(videoPrompt)}>{anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
上传视频
</button>
</div>
<input ref={videoUploadRef} type="file" accept="video/*" style={{ display: "none" }} onChange={onPickVideoFile} />
{segments.length ? (
<div className="video-grid" id="video-grid">
{segments.map((seg) => {
const url = segUrl(seg);
const tone = statusPill(seg.status);
const busy = ["running", "queued"].includes(seg.status);
return (
<div className="video-card" key={seg.id} data-video-id={seg.id}>
<div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }} role="button" tabIndex={0} title="点击查看详情(历史版本/提示词/重跑)" onClick={() => openVideoDetail(seg.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openVideoDetail(seg.id); } }}>
{url
? <video src={url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
: <span className="ph-frame"> {seg.sort_order + 1}</span>}
{url && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
</div>
<div className="body">
<div className="video-card-head">
{/* 标题带本镜画面要点(对齐设计稿「场 1 · 深夜办公桌」),来自绑定的脚本镜 */}
<strong className="video-card-title" title={(shots[seg.sort_order]?.visual_prompt || shots[seg.sort_order]?.narration || "").trim() || undefined}>
{seg.sort_order + 1}{(() => { const hint = (shots[seg.sort_order]?.visual_prompt || shots[seg.sort_order]?.narration || "").trim(); return hint ? ` · ${hint.slice(0, 12)}` : ""; })()}
</strong>
<span className={`pill ${tone}`}><span className="dot"></span>{statusLabel(seg.status)}</span>
</div>
<div className="video-meta">{seg.target_duration_seconds}s{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || busy} onClick={() => onSubmitVideo(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds} 秒`)}>{busy ? "生成中…" : "重跑"}</button>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => triggerVideoUpload(seg.id)}>上传</button>
<span className="spacer"></span>
{url
? <a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop>下载</a>
: <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled>下载</button>}
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="placeholder" style={{ minHeight: "200px", margin: "18px 28px" }}><span className="ph-frame">// 暂无视频片段 · 先在故事板确认后生成</span></div>
)}
<div className="stage-foot">
<div className="info"><span className="mono">[ 已完成 {segDone} · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回故事板</button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(5)}>确认视频,进入拼接 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
);
})()}
{/* ============= 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 (
<section className="stage active" data-stage-pane="5">
<div className="editor">
<div className="editor-preview">
<div className={`canvas${!showVideo && !showFinal && previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={!showVideo && !showFinal && previewUrl ? mediaStyle(previewUrl) : undefined}>
{/* BGM 预览音轨(隐藏):编辑预览播放时跟随播放头出声;成片态不播(成片已混音) */}
{bgmPreviewUrl && <audio ref={bgmRef} src={bgmPreviewUrl} loop preload="auto" style={{ display: "none" }} />}
{/* 旁白配音预览音轨(隐藏):当前片段的人声段,与片段同起点播放 */}
{showFinal ? (
<>
<video
src={finalUrl}
controls
playsInline
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain", background: "#000", borderRadius: "inherit" }}
/>
<span className="pill ok" style={{ position: "absolute", top: 10, left: 10, zIndex: 4 }}><span className="dot"></span>成片</span>
</>
) : showVideo ? (
<>
{/* 双缓冲槽位:激活槽显示当前片段,备用槽预加载下一片段;边界只换可见性不换 src */}
{([0, 1] as const).map((slot) => {
const isActive = slot === activeSlot;
return (
<video
key={`ed-slot-${slot}`}
ref={bindSlot(slot)}
src={slotSrcs[slot] || undefined}
playsInline
preload="auto"
muted={isActive ? edMuted : true}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit", opacity: isActive ? 1 : 0, zIndex: isActive ? 2 : 1, transition: edState.transition !== "none" ? "opacity .35s ease" : undefined }}
onLoadedMetadata={(e) => {
if (isActive) {
// 激活槽换源完成后按已提交进度落位:跨段手动定位时 [edIdx] effect 的 seek
// 发生在槽位换源提交之前,会被随后的 src 变更冲掉,这里兜底(同源跳转走 effect)
const cur = edClips[edIdx];
if (cur?.isVideo) e.currentTarget.currentTime = (cur.trimStartMs + edClipMsRef.current) / 1000;
} else {
// 备用槽 metadata 一到就预 seek 到下一片段入点,交换时画面已就绪
const next = edClips[edIdx + 1];
if (next?.isVideo) e.currentTarget.currentTime = (next.trimStartMs || 0) / 1000;
}
}}
onTimeUpdate={!isActive ? undefined : (e) => {
// 只认当前激活元素的事件(交换瞬间旧元素可能还有排队事件);
// 拖动 scrub 期间状态归拖动所有;换源/seek 中的 timeupdate 是旧时间噪声,一律忽略
if (e.currentTarget !== videoRef.current) return;
if (scrubActiveRef.current || e.currentTarget.seeking || e.currentTarget.readyState < 2) return;
// 文件时间 → 片内时间(减 trim 起点);播到本片段窗口尾就推进下一段,
// 否则分割出的前半段会一直播到文件物理结尾,split 预览失真
const trim = edCur?.trimStartMs || 0;
const within = e.currentTarget.currentTime * 1000 - trim;
if (edCur && edCur.durMs > 0 && within >= edCur.durMs) {
if (edIdx + 1 < edClips.length) { advanceClip(); return; }
setEdPlaying(false);
setEdClipMs(edCur.durMs);
return;
}
// 播放推进是瞬态:直绘播放头/时间 + 同步 ref,不进 React 状态
// (每秒 4-15 次 timeupdate 全量重渲染整个管线组件 = 播放期间持续卡顿)
const clamped = Math.max(0, within);
edClipMsRef.current = clamped;
paintTransport(edOffsetMs(edIdx) + clamped);
syncOverlayCue(edIdx, clamped);
}}
onEnded={!isActive ? undefined : () => { if (edIdx + 1 < edClips.length) advanceClip(); else setEdPlaying(false); }}
onError={!isActive ? undefined : () => setMediaError(true)}
/>
);
})}
{/* 字幕烧入预览:当前句由 syncOverlayCue 维护(只有换句才重渲染,而非每帧) */}
{subVisible && overlayCue && (
<div aria-hidden="true" style={{ position: "absolute", left: "50%", bottom: "9%", transform: "translateX(-50%)", maxWidth: "86%", zIndex: 3, pointerEvents: "none", textAlign: "center", fontSize: "14px", fontWeight: 600, lineHeight: 1.45, padding: edState.subtitleStyle === "cinema" ? "4px 12px" : 0, borderRadius: 8,
background: edState.subtitleStyle === "cinema" ? "rgba(0,0,0,.65)" : "transparent",
color: edState.subtitleStyle === "variety" ? "rgb(255,220,60)" : "#fff",
textShadow: edState.subtitleStyle === "handwrite"
? "0 0 3px var(--heat), 0 0 3px var(--heat), 0 0 3px var(--heat)"
: edState.subtitleStyle === "cinema" ? "none" : "0 0 3px rgba(0,0,0,.9), 0 1px 2px rgba(0,0,0,.9)" }}>
{overlayCue}
</div>
)}
{/* 片段加载失败(常见:TOS 签名链接过期)→ 给出口,不再黑屏干瞪眼 */}
{mediaError && (
<div style={{ position: "absolute", inset: 0, zIndex: 4, display: "grid", placeItems: "center", background: "rgba(0,0,0,.55)", borderRadius: "inherit" }}>
<div style={{ textAlign: "center" }}>
<div className="mono" style={{ color: "#fff", fontSize: "12px", marginBottom: 10 }}>// 片段视频加载失败 · 链接可能已过期</div>
<button className="btn btn-sm" type="button" onClick={() => void reloadMediaLinks()}>刷新片段链接</button>
</div>
</div>
)}
</>
) : (
<span id="ed-canvas-label">{exporting ? `拼接中… ${exportResult?.progress ?? 0}%` : `${aspect} 预览 · ${resolution}${edClips.length ? ` · 片段 ${Math.min(edIdx + 1, edClips.length)}/${edClips.length}` : ""}`}</span>
)}
</div>
<div className="controls">
{/* 看成片时按任一播放控制先切回编辑预览,避免操作一个没挂载的隐藏播放器 */}
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)" onClick={() => { if (showFinal) { setPreviewFinal(false); return; } stepFrame(-1); }} disabled={edClips.length === 0}><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)" onClick={() => { if (showFinal) { setPreviewFinal(false); return; } togglePlay(); }} disabled={edClips.length === 0}>
{edPlaying
? <svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M4 3h3v10H4zM9 3h3v10H9z" fill="currentColor" /></svg>
: <svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg>}
</button>
<button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)" onClick={() => { if (showFinal) { setPreviewFinal(false); return; } stepFrame(1); }} disabled={edClips.length === 0}><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-mute-btn" title={edMuted ? "取消静音" : "静音"} onClick={() => setEdMuted((m) => !m)} disabled={edClips.length === 0}>
{edMuted
? <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" stroke="none" /><line x1="23" y1="9" x2="17" y2="15" /><line x1="17" y1="9" x2="23" y2="15" /></svg>
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" stroke="none" /><path d="M15.54 8.46a5 5 0 0 1 0 7.07" /><path d="M19.07 4.93a10 10 0 0 1 0 14.14" /></svg>}
</button>
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">{paintRef.current.label}</span> / <span id="ed-total-time">{fmtMs(edTotalMs)}</span></span>
{finalUrl && (
<button className="btn btn-ghost btn-sm" type="button" style={{ marginLeft: "auto" }} onClick={() => setPreviewFinal((p) => !p)}>{showFinal ? "编辑预览" : "查看成片"}</button>
)}
</div>
</div>
<div className="editor-props">
<div className="props-tabs">
<div className={propsTab === "subtitle" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("subtitle")}>字幕</div>
<div className={propsTab === "transition" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("transition")}>转场</div>
<div className={propsTab === "bgm" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("bgm")}>BGM</div>
</div>
{propsTab === "subtitle" && (
<>
<div className="props-row" style={{ marginBottom: 8 }}>
<span className="k">烧入字幕</span>
<button className={`btn btn-sm ${edState.subtitleEnabled ? "btn-primary" : "btn-ghost"}`} type="button" onClick={() => commitEdit({ ...edState, subtitleEnabled: !edState.subtitleEnabled })}>{edState.subtitleEnabled ? "已开启" : "已关闭"}</button>
</div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 字幕样式(导出烧入)</div>
<div className="style-swatch">
{STYLE_SWATCHES.map((sw) => (
<div className={`swatch-card${edState.subtitleStyle === sw.key ? " selected" : ""}`} key={sw.key} role="button" tabIndex={0} style={{ cursor: "pointer", opacity: edState.subtitleEnabled ? 1 : 0.5 }} onClick={() => commitEdit({ ...edState, subtitleStyle: sw.key, subtitleEnabled: true })}>
<div className={`demo${sw.demo ? ` ${sw.demo}` : ""}`}>真实分享</div><div className="nm">{sw.nm}</div>
</div>
))}
</div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, margin: "12px 0 6px", letterSpacing: ".04em" }}>// 字幕文本(默认取脚本旁白,可逐段改)</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", maxHeight: "186px", overflowY: "auto" }}>
{edState.clips.map((c, idx) => (
<div key={c.key} style={{ display: "flex", gap: "6px", alignItems: "flex-start" }}>
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)", marginTop: "7px", flex: "0 0 auto" }}>{idx + 1}</span>
<textarea value={c.subtitle} onChange={(e) => setClipSubtitle(idx, e.target.value)} rows={1} disabled={!edState.subtitleEnabled} placeholder={`第 ${idx + 1} 段字幕`} style={{ flex: 1, minWidth: 0, resize: "vertical", fontSize: "12px", lineHeight: 1.4, padding: "4px 6px", border: "1px solid var(--border-faint)", borderRadius: "6px", background: "var(--surface)", color: "var(--accent-black)", fontFamily: "inherit" }} />
</div>
))}
</div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, margin: "14px 0 6px", letterSpacing: ".04em" }}>// 旁白配音(TTS · 导出混在 BGM 之上)</div>
{voInfo ? (
<div className="props-row" style={{ marginBottom: 6 }}>
<span style={{ fontSize: "12px", flex: 1 }}>已生成 {voInfo.items.length} · {VO_VOICES.find((v) => v.key === voInfo.voice_type)?.label || voInfo.voice_type}</span>
<button className={`btn btn-sm ${voInfo.enabled ? "btn-primary" : "btn-ghost"}`} type="button" disabled={loading} onClick={() => onSaveTimeline({ voiceover: { enabled: !voInfo.enabled } })}>{voInfo.enabled ? "已开启" : "已关闭"}</button>
</div>
) : (
<div className="muted" style={{ fontSize: "12px", marginBottom: 6 }}>未生成配音 · 将按上方字幕文本逐段合成人声</div>
)}
{voStale && <div style={{ fontSize: "12px", color: "#B45309", marginBottom: 6 }}>字幕文本已修改,与现有配音不一致,建议重新生成</div>}
<div className="props-row" style={{ marginBottom: 6 }}>
<span className="k">音色</span>
<select value={voVoicePick || voInfo?.voice_type || VO_VOICES[0].key} onChange={(e) => setVoVoicePick(e.target.value)} style={{ flex: 1, fontSize: "12px", padding: "4px 6px", border: "1px solid var(--border-faint)", borderRadius: "6px", background: "var(--surface)", color: "var(--accent-black)" }}>
{VO_VOICES.map((v) => <option key={v.key} value={v.key}>{v.label}</option>)}
</select>
</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-sm" type="button" disabled={loading || !edState.clips.some((c) => c.subtitle.trim())} onClick={() => onGenerateVoiceover({ items: edState.clips.map((c, idx) => ({ index: idx, text: c.subtitle.trim() })).filter((item) => item.text), voice_type: voVoicePick || voInfo?.voice_type || VO_VOICES[0].key })}>{voInfo ? "重新生成配音" : "生成配音 · ~¥1"}</button>
{voInfo && <button className="btn btn-sm btn-ghost" type="button" disabled={loading} onClick={() => onSaveTimeline({ voiceover: { clear: true } })}>移除配音</button>}
</div>
</>
)}
{propsTab === "transition" && (
<>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 片段间转场(导出 xfade 烧入)</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{TRANSITIONS.map((tr) => (
<button className={`btn btn-sm ${edState.transition === tr.key ? "btn-primary" : "btn-ghost"}`} key={tr.key} type="button" style={{ justifyContent: "flex-start" }} onClick={() => commitEdit({ ...edState, transition: tr.key })}>{tr.nm}</button>
))}
</div>
</>
)}
{propsTab === "bgm" && (
<>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 背景音乐(导出混音)</div>
<div className="props-row"><span style={{ fontSize: "12px", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{serverBgm ? serverBgmName : "未设置 BGM"}</span></div>
{serverBgmUrl && <audio src={serverBgmUrl} controls style={{ width: "100%", height: 30, marginBottom: 8 }} />}
<div className="props-row"><span className="k">音量 {edState.bgmVolume}</span>
<input type="range" min={0} max={100} value={edState.bgmVolume} onChange={(e) => setEdState((s) => ({ ...s, bgmVolume: Number(e.target.value) }))} style={{ flex: 1 }} />
</div>
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
<button className="btn btn-sm" type="button" disabled={loading} onClick={() => bgmFileRef.current?.click()}>{serverBgm ? "替换 BGM" : "上传 BGM"}</button>
{serverBgm && <button className="btn btn-sm btn-ghost" type="button" disabled={loading} onClick={() => onSaveTimeline({ bgm: { clear: true } })}>移除 BGM</button>}
</div>
<input ref={bgmFileRef} type="file" accept="audio/*" style={{ display: "none" }} onChange={onPickBgmFile} />
</>
)}
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 时间轴(<span id="ed-inspect-name">{timeline?.name || "未命名"}</span>)</div>
<div className="props-row"><span className="k">总时长</span><input className="input-mini" value={fmtMs(edRulerMs)} readOnly /></div>
<div className="props-row"><span className="k">片段</span><input className="input-mini" value={`${edClips.length} 段`} readOnly /></div>
<div className="props-row"><span className="k">字幕</span><input className="input-mini" value={subVisible ? `${edClips.length} 条` : "关"} readOnly /></div>
<div className="props-row"><span className="k">转场</span><input className="input-mini" value={(TRANSITIONS.find((t) => t.key === edState.transition) || TRANSITIONS[0]).nm} readOnly /></div>
<div className="props-row"><span className="k">分辨率</span><span className="mono" style={{ fontSize: "12px" }}>{resolution}</span></div>
</div>
<div className="timeline" id="ed-timeline" style={{ overflowX: edZoom > 100 ? "auto" : "hidden" }}>
<div className="tl-toolbar">
<button className="tl-action" type="button" title="撤销" disabled={!edHistory.length} onClick={edUndo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></svg></button>
<button className="tl-action" type="button" title="重做" disabled={!edFuture.length} onClick={edRedo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></svg></button>
<span className="tl-sep"></span>
<button className="tl-action" type="button" title="在播放头处分割所在片段" disabled={!edClips.length} onClick={edSplitAtPlayhead}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4L8.12 15.88" /><path d="M14.47 14.48L20 20" /><path d="M8.12 8.12L12 12" /></svg>分割</button>
<button className="tl-action" type="button" title="复制选中片段" disabled={!edClips.length} onClick={() => edCopyClip(selectedClip)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>复制</button>
<button className="tl-action danger" type="button" title="删除选中片段" disabled={edClips.length <= 1} onClick={() => edDeleteClip(selectedClip)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6" /></svg>删除</button>
<span className="spacer"></span>
<div className="tl-zoom"><span className="lbl">// zoom {edZoom}%</span><input type="range" min={100} max={300} value={edZoom} onChange={(e) => setEdZoom(Number(e.target.value))} /></div>
</div>
<div style={{ width: `${edZoom}%`, minWidth: "100%" }}>
<div className="tl-ruler">
<div className="l">// time</div>
<div
className="rule-track"
id="ed-ruler"
style={{ cursor: edClips.length ? "pointer" : "default", touchAction: "none" }}
onPointerDown={(event) => beginScrub(event, event.currentTarget)}
>
{edRuler.map((tick, i) => (
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: `${tick.leftPct}%` }}>{tick.t && <span className="t">{tick.t}</span>}</span>
))}
{edClips.length > 0 && (
<span id="ed-line-ruler" style={{ position: "absolute", top: 0, bottom: 0, left: `${paintRef.current.pct}%`, width: "2px", background: "var(--heat)", zIndex: 5, pointerEvents: "none" }} />
)}
</div>
</div>
<div className="tl-track video-track">
<div className="label video"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" /><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5" /></svg></span>视频</div>
<div className="lane" id="ed-lane-video" data-track="video">
{edClips.length > 0 && (
<span id="ed-line-video" style={{ position: "absolute", top: 0, bottom: 0, left: `${paintRef.current.pct}%`, width: "2px", background: "var(--heat)", zIndex: 6, pointerEvents: "none" }} />
)}
{edClips.length ? edClips.map((c, idx) => {
const { leftPct, widthPct } = clipLayout(edOffsets[idx], c.durMs, edRulerMs);
const lbl = assetName(c.assetId) || `片段 ${idx + 1}`;
const frameCount = Math.max(1, Math.round(c.durMs / 1000));
return (
<div
className="clip video"
key={c.id}
data-track="video"
data-label={lbl}
draggable
onDragStart={() => setDragIdx(idx)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); if (dragIdx != null) reorderClip(dragIdx, idx); setDragIdx(null); }}
onDragEnd={() => setDragIdx(null)}
title="拖拽可重排片段"
style={{ left: `${leftPct}%`, width: `${widthPct}%`, cursor: "grab", opacity: dragIdx === idx ? 0.4 : 1, outline: idx === selectedClip ? "2px solid var(--heat)" : undefined, outlineOffset: "-2px" }}
onClick={() => { setSelectedClip(idx); gotoClip(idx, false); setPreviewFinal(false); }}
>
<ClipFrames thumbs={thumbsMap[c.assetId]} frameCount={frameCount} />
<span className="num">{idx + 1}</span><span className="lbl">{lbl}</span>
</div>
);
}) : <span className="muted-2 mono" style={{ position: "absolute", left: "8px", top: "50%", transform: "translateY(-50%)", fontSize: "12px" }}>// 暂无片段</span>}
</div>
</div>
<div className="tl-track subtitle-track">
<div className="label subtitle"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3" /><path d="M9 20h6" /><path d="M12 4v16" /></svg></span>字幕</div>
<div className="lane" id="ed-lane-subtitle" data-track="subtitle">
{subVisible && deferredClips.flatMap((c, idx) =>
(cuesPerClip[idx] || []).map((cue, j) => {
const { leftPct, widthPct } = clipLayout((edOffsets[idx] ?? 0) + cue.offsetMs, cue.durMs, edRulerMs);
const draggable = Boolean(cue.asset);
return (
<div
className="clip subtitle"
key={`${c.key}-s${j}`}
data-track="subtitle"
data-label={cue.text}
title={draggable ? `${cue.text}(拖动调整出现时机,语音跟随)` : cue.text}
style={{ left: `${leftPct}%`, width: `${widthPct}%`, cursor: draggable ? "grab" : undefined, touchAction: draggable ? "none" : undefined }}
onPointerDown={draggable ? (event) => beginCueDrag(event, idx, cue.asset, cue.offsetMs, cue.durMs) : undefined}
><span className="lbl">{cue.text}</span></div>
);
})
)}
<div
className="playhead"
id="ed-playhead"
title="拖动调整播放位置"
style={{ left: `${paintRef.current.pct}%` }}
onPointerDown={(event) => { event.stopPropagation(); beginScrub(event, event.currentTarget.parentElement); }}
><span className="ph-grab"></span></div>
</div>
</div>
{serverBgm && (
<div className="tl-track bgm-track">
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
<div className="lane">
<div className="clip bgm" data-track="bgm" data-label={serverBgmName} style={{ left: "0%", width: "100%" }}>
<span className="wave"><BgmWave peaks={bgmPeaks} /></span>
<span className="lbl">{serverBgmName} · 音量 {edState.bgmVolume}</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
<div className="stage-foot">
<div className="info">
<span className="mono">[ 时间轴 {fmtMs(edRulerMs)} · {edClips.length} · 拼接 / 导出全程 0 token ]</span>
{exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 拼接中 {exportResult?.progress ?? 0}%</span>}
{finalUrl && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 成片已就绪</span>}
{exportFailed && <span className="mono" style={{ marginLeft: 10, color: "var(--err, #d33)" }}>// 导出失败:{exportResult?.error_message || "请重试"}</span>}
{!canExport && !finalUrl && !exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--black-alpha-48)" }}>// 待全部视频片段生成完成后可导出</span>}
</div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(4)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回片段</button>
<button className="btn" type="button" disabled={loading} onClick={() => onSaveTimeline(buildSavePayload())}>保存草稿</button>
{finalUrl && (
<a className="btn" href={finalUrl} target="_blank" rel="noreferrer" download>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg> 下载成片
</a>
)}
<button className="btn btn-primary btn-lg" type="button" disabled={!canExport || loading || exporting} onClick={() => onSubmitExport(buildSavePayload())}>
{exporting ? "拼接中…" : finalUrl ? "重新导出" : "导出 MP4"} · {resolution.includes("1080") || resolution.includes("1920") ? "1080P" : resolution} {aspect}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg>
</button>
</div>
</div>
</section>
);
})()}
</div>
</main>
{/* ── Stage 4 · 视频详情弹窗:大预览 + 历史版本(可切/可采用) + 本场提示词 + 重跑 ── */}
{vdSeg && (() => {
const busy = ["running", "queued"].includes(vdSeg.status);
const hint = (shots[vdSeg.sort_order]?.visual_prompt || shots[vdSeg.sort_order]?.narration || "").trim();
const rerunPrompt = vdPrompt.trim() || `${videoPrompt}${vdSeg.sort_order + 1} 段,时长 ${vdSeg.target_duration_seconds} 秒`;
return (
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label="视频详情" onClick={(event) => { if (event.target === event.currentTarget) setVdSegId(null); }}>
<div className="asset-modal">
<div className="asset-modal-h">
<h2>视频详情</h2>
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 场 {vdSeg.sort_order + 1} · {vdSeg.target_duration_seconds}s</span>
<button className="x" type="button" aria-label="关闭" onClick={() => setVdSegId(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="asset-modal-body">
<div className="vd-main-wrap">
<div className="vd-main">
{vdVer?.asset_url
? <video key={vdVer.id} src={vdVer.asset_url} controls playsInline />
: <span className="mono" style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", color: "var(--black-alpha-48)", fontSize: "12px" }}>// 暂无版本 · 点右下重跑生成</span>}
</div>
<div className="vd-info">
<div className="vd-section-h">// 基础信息</div>
<div className="vd-kv">
<span className="k">场次</span><span className="v"> {vdSeg.sort_order + 1}{hint ? ` · ${hint.slice(0, 18)}` : ""}</span>
<span className="k">状态</span><span className="v">{statusLabel(vdSeg.status)}{vdSeg.error_message ? ` · ${vdSeg.error_message}` : ""}</span>
<span className="k">时长</span><span className="v">{vdSeg.target_duration_seconds}s</span>
<span className="k">版本数</span><span className="v">{vdVersions.length}</span>
{vdVer && <><span className="k">当前查看</span><span className="v">{(vdVer.created_at || "").slice(0, 16).replace("T", " ") || "—"}{vdVer.is_adopted ? " · 已采用" : ""}</span></>}
</div>
<div style={{ marginTop: "18px" }}>
<div className="vd-history-h">// 历史版本 · {vdVersions.length} 版</div>
<div className="vd-history-row">
{vdVersions.length ? vdVersions.map((v) => (
<div className={`vd-history-thumb${v.id === vdVer?.id ? " current" : ""}${v.is_adopted ? " adopted" : ""}`} key={v.id} role="button" tabIndex={0} title={v.is_adopted ? "已采用版本" : "点击查看该版本"} onClick={() => setVdVerId(v.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setVdVerId(v.id); } }}>
<div className="placeholder" style={{ position: "relative", overflow: "hidden" }}>
{v.asset_url && <video src={v.asset_url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} />}
</div>
<div className="ts">{(v.created_at || "").slice(11, 16) || "--:--"}</div>
</div>
)) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 暂无版本</span>}
</div>
</div>
<div className="vd-prompt-field">
<div className="vd-prompt-head">
<span className="label">// 本场提示词(重跑生效,可编辑)</span>
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>系统会自动织入本镜旁白与参考图</span>
</div>
<div
className="vd-prompt-edit"
contentEditable
suppressContentEditableWarning
spellCheck={false}
role="textbox"
aria-label="视频提示词"
key={vdSeg.id}
onInput={(event) => setVdPrompt((event.currentTarget.textContent || "").trim())}
>{`${videoPrompt}${vdSeg.sort_order + 1} 段,时长 ${vdSeg.target_duration_seconds} 秒`}</div>
</div>
</div>
</div>
</div>
<div className="asset-modal-f">
{vdVer && !vdVer.is_adopted && (
<button className="btn" type="button" disabled={loading} onClick={() => void onAdoptVideoVersion(vdSeg.id, vdVer.id)}>采用此版本</button>
)}
<div className="vd-modal-actions">
{vdVer?.asset_url && <a className="btn btn-ghost" href={vdVer.asset_url} target="_blank" rel="noreferrer">下载</a>}
<button className="btn btn-ghost" type="button" onClick={() => setVdSegId(null)}>关闭</button>
<button className="btn btn-primary" type="button" disabled={loading || busy} onClick={() => onSubmitVideo(vdSeg.id, rerunPrompt)}>
{busy ? "生成中…" : "↻ 重跑本场"}
</button>
</div>
</div>
</div>
</div>
);
})()}
{/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */}
{adDetail && (() => {
const entities = buildEntities(adDetail.kind);
const entity = entities.find((e) => e.key === adDetail.key);
if (!entity) return null;
const isPerson = adDetail.kind === "person";
const grp = entity.group;
const portraitVersions = grp.candidate_assets ?? [];
// 当前查看的立绘 asset(组内候选);默认 = 采用版
const viewPortraitAsset = (adPortraitId && portraitVersions.includes(adPortraitId)) ? adPortraitId : (grp.adopted_asset || portraitVersions.at(-1) || "");
const portraitUrl = candUrl(grp, viewPortraitAsset);
// 三视图绑定到当前这版立绘 asset:切立绘 → 切三视图
const triGroup = triGroupForAsset(viewPortraitAsset);
const triVersions = triGroup?.candidate_assets ?? [];
const viewTriAsset = (adTriId && triVersions.includes(adTriId)) ? adTriId : (triGroup?.adopted_asset || triVersions.at(-1) || "");
const triUrl = candUrl(triGroup, viewTriAsset);
const pBK = `addet-portrait:${entity.key}`;
const busyPortrait = isBusy(pBK);
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
async function regenPortrait() {
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
// 重跑立绘 → 追加新候选并采用,人物链式据新立绘生成它的三视图;场景只重生立绘
if (isPerson) await genPersonWithTri(prompt, entity!.name, pBK);
else await genBaseAsset("scene", prompt, entity!.name, pBK);
setAdPortraitId(null); setAdTriId(null);
}
async function regenTri() {
if (!viewPortraitAsset) return;
await genTriview(viewPortraitAsset, `addet-tri:${entity!.key}`); // 据当前这版立绘再出一版三视图
setAdTriId(null);
}
const zoomSvg = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" /></svg>;
return (
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label={`${KIND_LABEL[adDetail.kind]}详情`} onClick={(event) => { if (event.target === event.currentTarget) setAdDetail(null); }}>
<div className="asset-modal">
<div className="asset-modal-h">
<h2>资产详情</h2>
<span className="ad-tag">/ {KIND_LABEL[adDetail.kind]} · {entity.name}</span>
<button className="x" type="button" aria-label="关闭" onClick={() => setAdDetail(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="asset-modal-body">
<div className="asset-detail-grid">
{/* 左:大立绘 + 版本缩略 */}
<div className="asset-detail-lead">
<div className="ad-lead-wrap">
<div className={`placeholder ad-lead-img${portraitUrl && !busyPortrait ? " has-mock-media" : ""}`} style={portraitUrl && !busyPortrait ? mediaStyle(portraitUrl) : undefined}>
{busyPortrait
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">立绘生成中…</span></div>
: !portraitUrl && <span className="ph-frame">立绘</span>}
</div>
{portraitUrl && !busyPortrait && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}>{zoomSvg}</button>}
</div>
{portraitVersions.length > 0 && (
<div className="ad-thumbs">
{portraitVersions.map((assetId) => {
const u = candUrl(grp, assetId);
return <div className={`thumb${assetId === viewPortraitAsset ? " active" : ""}${u ? " has-mock-media" : ""}`} key={assetId} role="button" tabIndex={0} title="切换立绘版本(三视图跟着切)" style={u ? mediaStyle(u) : undefined} onClick={() => { setAdPortraitId(assetId); setAdTriId(null); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setAdPortraitId(assetId); setAdTriId(null); } }} />;
})}
</div>
)}
</div>
{/* 右:三视图(人物)+ 提示词重跑 */}
<div className="asset-detail-right">
{isPerson && (
<div className="ad-section">
<div className="asset-detail-section-h">
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /></svg></span>
<span className="t">三视图</span>
<span className="ad-ratio-chip">16:9</span>
{triUrl && !busyTri && <>
<button className="ad-icon-btn" type="button" title="重跑三视图" onClick={() => void regenTri()}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg></button>
{viewTriAsset && <button className="ad-icon-btn ad-icon-gap" type="button" title="下载当前三视图" onClick={() => void downloadAssetImage(viewTriAsset, `${entity.name}-三视图`)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg></button>}
</>}
</div>
<div className="asset-detail-tri-row">
<div className="ad-tri-wrap">
<div className={`placeholder${triUrl && !busyTri ? " has-mock-media" : ""}`} style={triUrl && !busyTri ? mediaStyle(triUrl) : undefined}>
{busyTri
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">三视图生成中…</span></div>
: !triUrl && <span className="ph-frame"> / / · 三视图</span>}
</div>
{triUrl && !busyTri && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
</div>
</div>
{!triUrl && !busyTri && (
<div className="asset-detail-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
<span>三视图据立绘自动生成,以保证正//背多角度一致</span>
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>AI 生成三视图</button>
</div>
)}
{triVersions.length > 0 && (
<div className="md-view-versions">
{triVersions.map((assetId, i) => {
const u = candUrl(triGroup, assetId);
return <div className={`v-thumb${assetId === viewTriAsset ? " active" : ""}${u ? " has-mock-media" : ""}`} key={assetId} role="button" tabIndex={0} title={`三视图 v${i + 1}`} style={u ? mediaStyle(u) : undefined} onClick={() => setAdTriId(assetId)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setAdTriId(assetId); } }}><span className="v">v{i + 1}</span></div>;
})}
</div>
)}
</div>
)}
<div className="ad-section">
<div className="asset-detail-section-h">
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 6h16M4 12h16M4 18h10" /></svg></span>
<span className="t">提示词</span>
<span className="ad-ratio-chip">立绘 {portraitVersions.length} </span>
</div>
<textarea className="ad-detail-prompt" placeholder={isPerson ? "描述这个角色的立绘…" : "描述这个场景…"} value={adPrompt} onChange={(e) => setAdPrompt(e.target.value)} />
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<button className="btn btn-primary btn-sm" type="button" disabled={busyPortrait} onClick={() => void regenPortrait()}>
{busyPortrait ? <span className="asset-spinner sm" aria-hidden="true" style={{ marginRight: 4 }}></span> : <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>}
{busyPortrait ? "生成中…" : (isPerson ? "重跑立绘" : "重跑")}
</button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => openActorReplace(entity)}>替换</button>
</div>
</div>
</div>
</div>
</div>
<div className="asset-modal-f">
<div className="ad-foot-stats">
{portraitUrl && <button className="ad-stat-btn" type="button" onClick={() => void downloadAssetImage(viewPortraitAsset, `${entity.name}-立绘`)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>下载立绘</button>}
</div>
<button className="btn btn-primary" type="button" disabled={loading || !viewPortraitAsset} onClick={async () => {
if (viewPortraitAsset) await onAdoptBaseAsset(grp.id, viewPortraitAsset);
if (triGroup && viewTriAsset) await onAdoptBaseAsset(triGroup.id, viewTriAsset);
setAdDetail(null);
}}>使用该资产</button>
</div>
</div>
</div>
);
})()}
{/* 流程步骤4 · 演员库:浏览 / 添加演员(AI生成·本地上传)/ 替换回填 */}
<ActorLibrary
open={Boolean(actorLib)}
mode={actorLib?.mode || "browse"}
assets={assets}
onClose={() => setActorLib(null)}
onPick={pickActor}
onGenerate={onGenerateActor}
onUpload={onUploadActor}
onRefresh={() => void onRefreshProject()}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
);
}