Files
yingqing/core/frontend/src/routes/pipeline.tsx
T

4345 lines
300 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, ApiError } from "../api";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
import { publicModelDisplayName } from "../model-display";
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { IconKitSvg } from "../components/IconKitSvg";
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
// 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象)
const KIND_LABEL: Record<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: "学生党" };
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
function statusLabel(status: string): string {
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
if (["failed", "error"].includes(status)) return "失败";
if (["running", "queued", "polling"].includes(status)) return "生成中";
if (status === "needs_review") return "待确认";
return "待生成";
}
function generationTaskErrorText(error: unknown, fallback: string): string {
if (!isPublicGenerationError(error)) return fallback;
const presentation = presentGenerationError(error);
return `${presentation.title}${presentation.description}`;
}
// API 时间统一以 UTC ISO 字符串返回;项目工作台固定按中国时区展示,避免直接截取 UTC 的 HH:mm。
function formatShanghaiClock(iso?: string): string {
if (!iso) return "--:--";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "--:--";
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(date);
}
// 毫秒 → mm:ss(时间轴 / 字幕展示)
function fmtMs(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
// 字幕切分:整段旁白 → 短句 cue,按字数比例分配片段时长(短视频字幕节奏)。
// 硬标点(。!?;…)必切;长句(≥12 字)在逗号处再切;过短碎句(<5 字)并入前句;去尾部逗号句号。
// 与后端 export._split_subtitle_text 同规则,预览即所得。
function splitSubtitleCues(text: string, durMs: number, voiceDurMs = 0): Array<{ offsetMs: number; durMs: number; text: string }> {
const clean = (text || "").replace(/\s+/g, " ").trim();
if (!clean || durMs <= 0) return [];
// 有配音时字幕跟语音走:逐句窗口摊在真实语音时长内(语速是事实源),否则字幕铺满15s而人声4秒念完,后半段全对不上
const spanMs = voiceDurMs > 0 ? Math.min(voiceDurMs, durMs) : durMs;
const HARD = "\u3002\uFF01\uFF1F!?\uFF1B;\u2026"; // 。!?!?;;… 全角+半角(字面量易被编辑器归一化,用转义)
const SOFT = "\uFF0C,\u3001"; // ,、
const parts: string[] = [];
let cur = "";
for (const ch of clean) {
cur += ch;
if (HARD.includes(ch) || (SOFT.includes(ch) && cur.length >= 12)) { parts.push(cur); cur = ""; }
}
if (cur.trim()) parts.push(cur);
const merged: string[] = [];
for (const raw of parts) {
const p = raw.trim();
if (!p) continue;
const core = [...p].filter((c) => !HARD.includes(c) && !SOFT.includes(c)).length;
if (merged.length && core < 5) merged[merged.length - 1] += p;
else merged.push(p);
}
const display = merged.map((p) => p.replace(/[\uFF0C\u3002\uFF1B,;\u3001]+$/u, "")).filter(Boolean);
const totalChars = display.reduce((sum, p) => sum + p.length, 0) || 1;
let acc = 0;
return display.map((p) => {
const start = (acc / totalChars) * spanMs;
acc += p.length;
return { offsetMs: start, durMs: (acc / totalChars) * spanMs - start, text: p };
});
}
// ── 时间轴真实缩略图 / 波形提取(借鉴 video-flow/web-core 工作台:按资产 memoize,只抽一次)──
// TOS 未配 CORS:经 /api/assets/{id}/raw/ 同源代理拿 blob,canvas 抽帧才不会被 taint。
const assetBlobUrlCache = new Map<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: "视频" }
// V1 雪藏「拼接导出」(代码保留,V2 恢复):{ n: 5, label: "拼接导出" }
];
// 行34 · 行内「添加标签」:点 + 展开输入框,回车 / 失焦提交(空则收起)
function AddTagInline({ onAdd, placeholder, ariaLabel }: { onAdd?: (value: string) => void; placeholder?: string; ariaLabel?: string }) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState("");
const inputRef = useRef<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}
/>
);
}
// 可编辑提示词框(基础资产卡用):**非受控** contentEditable —— 初值只在挂载时灌一次,之后由浏览器维护
// DOM,React 不再 reconcile 其子节点。受控写法(把 state 当 children 回灌)在输入触发换行时,浏览器会
// 往 contentEditable 里插 <div>/<br>,React 拿单个文本子节点去对账就会 removeChild 崩溃 → 整页白屏。
// 读取走 onInput 写回上层 state(供「生成/重跑」带上),与渲染解耦,既能编辑又不崩。
function PromptBox({ value, onChange, className = "prompt-box", id, ariaLabel, stop = true }: { value: string; onChange: (v: string) => void; className?: string; id?: string; ariaLabel?: string; stop?: boolean }) {
const ref = useRef<HTMLDivElement | null>(null);
// 只挂载时灌初值;后续用户编辑由 DOM 自己维护,不受 value 变化驱动(避免重渲染打断输入/白屏)。
// 切换实体/草稿/版本时上层用 key 强制重挂载,会重新走这次初值灌入,所以不需要把 value 进依赖。
useEffect(() => {
if (ref.current && ref.current.textContent !== value) ref.current.textContent = value;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
ref={ref}
className={className}
id={id}
role={ariaLabel ? "textbox" : undefined}
aria-label={ariaLabel}
contentEditable
suppressContentEditableWarning
spellCheck={false}
{...(stop ? { "data-stop": true } : {})}
onInput={(e) => onChange(e.currentTarget.textContent || "")}
/>
);
}
// 分镜「旁白 / 画面」可编辑行:同样**非受控** contentEditable,落库走 onBlur(回车=失焦提交)。
// 受控写法(把 narration 当 children)在提交后父组件用旧值重渲染时,React 会把 DOM 文本重置回旧值 →
// 「按回车后变成空白」。这里不放受控子节点:初值/外部改动(切版本、AI 改稿)由 effect 同步进 DOM,
// 但**正在编辑(聚焦中)时绝不覆盖**,避免打断输入。
function EditableShotField({ value, placeholder, onCommit }: { value: string; placeholder: string; onCommit: (text: string) => void }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el || document.activeElement === el) return; // 聚焦中=用户在打字,别覆盖
if (el.textContent !== value) el.textContent = value;
}, [value]);
return (
<div
ref={ref}
className="shot-v"
contentEditable
suppressContentEditableWarning
spellCheck={false}
data-placeholder={placeholder}
data-empty={value ? undefined : "true"}
onFocus={(e) => { e.currentTarget.removeAttribute("data-empty"); }}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); e.currentTarget.blur(); } }}
onBlur={(e) => {
const text = (e.currentTarget.textContent || "").trim();
if (!text) e.currentTarget.setAttribute("data-empty", "true");
if (text !== value) onCommit(text);
}}
/>
);
}
// 行34 · 「添加分镜」插入的本地可编辑空白卡片:输入旁白/画面,提交后落库
function DraftShotCard({ draft, onCommit, onCancel }: {
draft: { id: string; afterId: string | null; narration: string; visual: string };
onCommit?: (draft: { id: string; afterId: string | null; narration: string; visual: string }) => void;
onCancel?: (id: string) => void;
}) {
const [narration, setNarration] = useState(draft.narration);
const [visual, setVisual] = useState(draft.visual);
const naRef = useRef<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 · 进度时间轴节点:每步一个点,竖线串起;模型调用那步(id=generate)会进入「思考」态。
type StepNode = { id: string; label: string; done?: boolean; think?: boolean };
// 行33 · 进度时间轴(参考主流 AI 应用的流式+推理体感):
// 竖线串起每步;当前步状态字「光扫」+ 秒数;generate 步思考态用模型实时思考最新一句做状态字,
// 右侧 › 折叠/展开思考全文(默认收起);完成后收成「已完成思考 · 用时 Xs」。非推理模型无思考节点,照常跑步骤。
function ProgressTimeline({ steps, reasoning, stream, done, startedAt, thinkingElapsedSeconds, thinkingDurationReady }: { steps?: StepNode[]; reasoning?: string; stream?: string; done?: boolean; startedAt?: number; thinkingElapsedSeconds?: number; thinkingDurationReady?: boolean }) {
const [openThink, setOpenThink] = useState(false);
const [now, setNow] = useState(() => Date.now());
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (done) return;
const t = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(t);
}, [done]);
useEffect(() => { if (openThink && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [reasoning, openThink]);
const list = steps ?? [];
const liveElapsed = startedAt ? Math.max(0, Math.round((now - startedAt) / 1000)) : 0;
// “已完成思考”只展示生成分镜阶段冻结下来的时长;历史记录重进页面时不能以当前时间重算。
const elapsed = thinkingDurationReady && thinkingElapsedSeconds != null ? thinkingElapsedSeconds : liveElapsed;
const activeId = !done ? ([...list].reverse().find((s) => !s.done)?.id ?? null) : null;
// 思考流最新一句(按句末标点/换行切)= 动态状态字,不写死
const latestThought = (() => {
const parts = (reasoning ?? "").split(/[\n。.!?!?;]/).map((s) => s.trim()).filter(Boolean);
return parts.length ? parts[parts.length - 1] : "";
})();
const preamble = (stream ?? "").trim();
return (
<div className={`progress-timeline${done ? " done" : ""}`}>
{list.map((s) => {
const rowDone = !!s.done || !!done;
const active = s.id === activeId;
const hasThink = s.id === "generate" && (reasoning ?? "").length > 0;
let text = s.label;
if (s.id === "generate") {
if (rowDone) text = hasThink
? (thinkingDurationReady && thinkingElapsedSeconds != null ? `已完成思考 · 用时 ${elapsed}s` : "已完成思考")
: (preamble || s.label);
else if (s.think) text = latestThought || "思考";
else text = preamble || s.label;
}
return (
<div className={`pt-row${rowDone ? " done" : ""}${active ? " active" : ""}`} key={s.id}>
<span className="pt-rail" aria-hidden="true"><span className="pt-dot"></span></span>
<div className="pt-main">
<div
className={`pt-line${hasThink ? " pt-line-click" : ""}`}
role={hasThink ? "button" : undefined}
tabIndex={hasThink ? 0 : undefined}
aria-label={hasThink ? (openThink ? "收起思考" : "展开思考") : undefined}
onClick={hasThink ? () => setOpenThink((v) => !v) : undefined}
onKeyDown={hasThink ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenThink((v) => !v); } } : undefined}
>
<span className={`pt-text${active ? " shimmer" : ""}`}>{text}</span>
{active && startedAt ? <span className="pt-sec">{elapsed}s</span> : null}
{hasThink ? <span className={`pt-caret${openThink ? " open" : ""}`} aria-hidden="true"></span> : null}
</div>
{hasThink && openThink ? <div className="pt-think" ref={bodyRef}>{reasoning}</div> : null}
</div>
</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;
onNotify?: (type: "success" | "error", text: string) => void;
scriptModelName: string;
textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: 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, referenceAssetId?: string) => void | Promise<unknown>;
onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise<unknown>;
onSetAdoptState: (groupId: string, state: "adopted" | "unadopted") => void | Promise<unknown>;
onDeleteBaseAsset: (groupId: string) => void | Promise<unknown>;
// 流程步骤4 · 模特库:用现有模特替换角色参考图 / AI 生成模特 / 本地上传模特
// target:已生成卡传 {group_id};seed 占位卡还没 group 传 {kind,label}(后端按 label 命中/建组)
onAttachBaseAsset: (target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string }, assetId: string) => void | Promise<unknown>;
onGenerateModel: (prompt: string) => Promise<{ assets: Asset[] } | null>;
// 本地上传模特:返回形象 Asset,供添加模特工作台保存。
onUploadModel: (file: File) => Promise<Asset | null>;
// 流程步骤4 · 据某一版立绘生成它配套的三视图(三视图与立绘 1:1 绑定)
onGenerateTriview: (portraitGroupId: string) => Promise<{ id: string } | null>;
// 流程步骤4 · 添加模特工作台命名:同步写回形象资产名称。
onRenameModel?: (assetId: string, name: string) => Promise<unknown>;
onGenerateStoryboard: (prompt: string) => void | Promise<unknown>;
// 单场重跑(只重出该 shot 的分镜图)/ 采用某场某历史版本
onRerunStoryboardShot?: (shotId: string, prompt?: string) => Promise<unknown>;
onAdoptStoryboardShotVersion?: (shotId: string, versionId: string) => void | Promise<unknown>;
onPollStoryboardQuiet?: () => void | Promise<void>;
onSkipStoryboard: () => Promise<unknown>;
onSubmitVideo: (segmentId: string, prompt: string) => void | Promise<unknown>;
onSubmitAllVideos: (prompt: string) => void | Promise<unknown>;
onPollVideosQuiet: () => void | Promise<void>;
exportResult: ExportPoll | null;
onRefreshExport: () => void;
onRefreshProject: () => Promise<void> | void;
onRefreshBilling: () => 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, onNotify,
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
// ── 团队价格系数(差异化调价):页面各处「N 积分/次」文案按团队系数动态显示,拉不到按标准价 1 ──
const [priceMultiplier, setPriceMultiplier] = useState(1);
useEffect(() => {
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
}, []);
// 与后端 apply_team_price 逐字对齐:挂牌整数积分 × 系数 → HALF_UP 最低 1(toFixed(6) 只吸浮点噪声)
const pts = (base: number) => (priceMultiplier === 1 ? base : Math.max(1, Math.round(Number((base * priceMultiplier).toFixed(6)))));
// ── 资产解析:把各阶段引用的 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);
// ④ 持久删除队列:点删 → 本地秒删进队列(localStorage,扛刷新)+ 后台重试到删成才出队,绝不回滚
const [pendingDeletes, setPendingDeletes] = useState<Set<string>>(() => {
try { return new Set<string>(JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]")); } catch { return new Set(); }
});
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).filter((s) => !pendingDeletes.has(s.id));
// 对白 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}`;
// 直接删除一个「待生成」seed 标签(还没出参考图的角色/场景):立即从 cast/scenes 移除并落库,
// 不走「整体重写」的待删队列——给 seed 卡一个能用的删除入口(ZWQ#9)。assetKind: person↔cast / scene↔scene。
function deleteSeedTag(assetKind: "person" | "scene", tag: string) {
if (assetKind === "person") {
const next = castTags.filter((t) => t !== tag);
setCastTags(next);
void onSaveProjectMeta?.({ cast: next });
} else {
const next = sceneTags.filter((t) => t !== tag);
setSceneTags(next);
void onSaveProjectMeta?.({ scenes: next });
}
}
const [seedDelArmed, setSeedDelArmed] = useState<string | null>(null);
// 行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>>({});
// 新增场景:先落「占位草稿卡」(标题 + 可编辑提示词),用户编辑完点「AI 生成」才真正出图;
// 出图成功后从草稿列表移除(生成结果会作为实体卡出现在下方)。不直接生成,避免一点就出一张默认图。
// 持久化到 localStorage(按项目隔离):新增场景没点 AI 生成就刷新页面也不会丢(ZWQ#10)。
const sceneDraftsKey = `airshelf:scenedrafts:${project.id}`;
type SceneDraft = { id: string; title: string; prompt: string; generating?: boolean };
const [sceneDrafts, setSceneDrafts] = useState<SceneDraft[]>(() => {
try { const raw = localStorage.getItem(sceneDraftsKey); return raw ? (JSON.parse(raw) as SceneDraft[]).map((d) => ({ ...d, generating: false })) : []; } catch { return []; }
});
useEffect(() => {
// 只持久化「还没点 AI 生成」的草稿;正在生成的草稿不落盘——否则生成中刷新页面后,
// 这张草稿会和生成完成后新出现的实体卡并存成两个窗口(ZWQ#17)。
try { localStorage.setItem(sceneDraftsKey, JSON.stringify(sceneDrafts.filter((d) => !d.generating))); } catch { /* localStorage 不可用时静默降级 */ }
}, [sceneDrafts, sceneDraftsKey]);
// 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗)
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
const [triPanelOpen, setTriPanelOpen] = useState(false);
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
// 通用「选已有素材替换」选图层。商品三视图(取 product_image、按商品过滤)与场景(取 scene、团队全量)共用一套。
// 角色不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的。
// 旧 bug:场景卡「替换」误用了模特选择器 → 列的是角色数据。现在场景改走本选图层。
type AssetPick = {
title: string;
category: "product_image" | "scene";
product?: string; // 传则按商品过滤(商品三视图);不传则团队全量(场景)
target: { group_id?: string; kind?: "product" | "person" | "scene"; label?: string };
uploadCategory: string;
uploadName: string;
};
const [assetPick, setAssetPick] = useState<AssetPick | null>(null);
const [assetPickBusy, setAssetPickBusy] = useState(false);
const [assetPickList, setAssetPickList] = useState<Asset[]>([]);
const [assetPickLoading, setAssetPickLoading] = useState(false);
const [assetPickPage, setAssetPickPage] = useState(1);
const ASSET_PICK_PAGE_SIZE = 20; // 商品/场景选图:20 个一页
const pickFileRef = useRef<HTMLInputElement | null>(null);
const pickPreview = (a: Asset): string => a.files?.find((f) => f.is_primary)?.preview_url || a.files?.[0]?.preview_url || "";
useBodyScrollLock(Boolean(assetPick));
useEffect(() => {
if (!assetPick) return;
setAssetPickLoading(true);
setAssetPickList([]);
setAssetPickPage(1);
// 后端 ?product= 取三路并集(独立生图 metadata.product_id + 项目内生成 origin_task→project→product + 上传图);
// 不传 product 则团队全量(场景常跨商品复用)。category 限定只列该类素材,排除噪音。多取一点供前端分页。
api.assetsPage({ ...(assetPick.product ? { product: assetPick.product } : {}), asset_type: "image", category: assetPick.category, pageSize: 200, ordering: "-created_at" })
.then((res) => setAssetPickList(res.results || []))
.catch(() => setAssetPickList([]))
.finally(() => setAssetPickLoading(false));
}, [assetPick]);
// 场景里混进了故事板帧(也被存成 category=scene,只能靠名字区分;metadata 全空)→ 过滤掉,只留真场景图。
const assetPickFiltered = useMemo(() => {
if (!assetPick) return [];
return assetPick.category === "scene"
? assetPickList.filter((a) => !/storyboard|story-board|分镜/i.test(a.name || ""))
: assetPickList;
}, [assetPick, assetPickList]);
const assetPickPageCount = Math.max(1, Math.ceil(assetPickFiltered.length / ASSET_PICK_PAGE_SIZE));
useEffect(() => { setAssetPickPage((p) => Math.min(p, assetPickPageCount)); }, [assetPickPageCount]);
const assetPickPageList = assetPickFiltered.slice((assetPickPage - 1) * ASSET_PICK_PAGE_SIZE, assetPickPage * ASSET_PICK_PAGE_SIZE);
async function attachPickedAsset(assetId: string) {
if (!assetPick) return;
setAssetPickBusy(true);
try {
await onAttachBaseAsset(assetPick.target, assetId); // 后端 attach-base-asset 按 group_id / kind+label 命中或建组并采用
setAssetPick(null);
} finally {
setAssetPickBusy(false);
}
}
async function uploadPickedFromFile(file: File) {
if (!assetPick) return;
setAssetPickBusy(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("asset_type", "image");
fd.append("category", assetPick.uploadCategory);
fd.append("name", assetPick.uploadName);
const asset = await api.uploadAsset(fd); // 落 TOS + 建 team 资产,返回带 id
await onAttachBaseAsset(assetPick.target, asset.id);
setAssetPick(null);
} catch (e) {
onNotify?.("error", e instanceof Error && e.message ? e.message : "上传失败,请重试");
} finally {
setAssetPickBusy(false);
}
}
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[] };
// 采用态:metadata.adopt 显性优先;否则按"实体名是否在脚本里"推导(在脚本→已采用,孤儿→未采用)
const scriptEntityNames = useMemo(
() => new Set(((project.metadata?.script_entities as Array<{ name?: string }>) ?? []).map((e) => (e?.name || "").trim()).filter(Boolean)),
[project.metadata?.script_entities]
);
function adoptStateOf(group: BaseGroup, name: string): "adopted" | "unadopted" {
const a = (group?.metadata as { adopt?: string } | undefined)?.adopt;
if (a === "adopted" || a === "unadopted") return a;
return scriptEntityNames.has((name || "").trim()) ? "adopted" : "unadopted";
}
// 删除二次确认:点删除先 arm(显"确认删除"),再点才真删;3s 自动撤销
const [delArmed, setDelArmed] = useState<string | null>(null);
// 一个 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 ?? [] };
});
}
// 轮询用:onRefreshProject 每次 App 渲染都换新引用,若直接进 effect 依赖会让轮询每渲染重订阅+立刻重发请求
// (基础资产页「一直在轮询」的根因之一)。用 ref 稳住,effect 不再因它重跑。
const onRefreshProjectRef = useRef(onRefreshProject);
useEffect(() => { onRefreshProjectRef.current = onRefreshProject; });
const onRefreshBillingRef = useRef(onRefreshBilling);
useEffect(() => { onRefreshBillingRef.current = onRefreshBilling; });
// 审核轮询的「重启信号」:手动送审/一键送审后 +1,让已停的 poll-reviews 重新开跑去盯新提交的审核。
const [reviewWake, setReviewWake] = useState(0);
// 行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 pendingGenKey = useMemo(() => pendingGen.map((p) => `${p.kind}:${p.label}:${p.is_triview}`).join("|"), [pendingGen]);
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, referenceAssetId?: string): Promise<GenResult> {
if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发)
addBusy(busyKey);
try {
return (await onGenerateBaseAsset(kind, prompt, label, referenceAssetId)) 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 · 生成人物立绘;三视图只在角色详情里手动生成。
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string) {
return await genBaseAsset("person", prompt, label, busyKey);
}
// ── 流程步骤4 · 实体提取闸门(进资产趴入口):不自动花钱,用户点按钮才提取/生成 ──
// idle=露三按钮 / running=露 loading;提取后 metadata.script_entities 落库、或已有资产 → 闸门由渲染层自动隐藏
const [extractState, setExtractState] = useState<"idle" | "running">("idle");
const [extractMsg, setExtractMsg] = useState("");
const [extractErr, setExtractErr] = useState("");
const extractPollRef = useRef(0); // 提取轮询定时器句柄
const extractStartedRef = useRef(false); // 是否已有一条轮询在跑(防认领与手点双轮询)
type ExtractEntity = { id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number };
// 提取完成后(mode≠only)按返回实体循环出参考图(立绘/场景图;三视图手动生成)
async function runGenForEntities(entities: ExtractEntity[], mode: "gen" | "full") {
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
for (const e of entities) {
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
if (e.type === "character") {
if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk); // 立绘;三视图手动生成
else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
} else {
await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
}
}
await onRefreshProject();
}
// 轮询提取进度直到结束:成功→刷新(可选继续出图)→idle;失败→显错→idle。
// mode=null 表示「刷新后认领」(只恢复 loading,不自动出图 —— 出图态由 pending-assets 单独认领)。
function pollExtractUntilDone(mode: "only" | "gen" | "full" | null) {
window.clearInterval(extractPollRef.current);
extractStartedRef.current = true;
const finish = () => { window.clearInterval(extractPollRef.current); extractStartedRef.current = false; };
const tick = async () => {
try {
const s = await api.extractStatus(project.id);
if (s.running) return; // 还在跑,下一轮再看
finish();
if (s.status === "succeeded") {
await onRefreshProject(); // 拉回 metadata(cast/scenes/script_entities)+ 回填的每镜 refs
await onRefreshBilling(); // 实体提取成功已产生真实扣费,同步右上角现有余额
if ((mode === "gen" || mode === "full") && (s.entities?.length ?? 0) > 0) {
await runGenForEntities(s.entities, mode);
}
setExtractState("idle"); setExtractMsg(""); // 闸门据 entities_extracted 已落库 → 渲染层自动隐藏
} else if (s.status === "failed") {
setExtractState("idle"); setExtractMsg("");
setExtractErr(generationTaskErrorText(s.error, s.error_message || "提取失败,请重试"));
} else {
setExtractState("idle"); setExtractMsg(""); // 既不在途也无成败(理论不至于):收尾,别把 loading 永久挂住
}
} catch {
/* 网络抖动:忽略,下一轮再试 */
}
};
extractPollRef.current = window.setInterval(() => { void tick(); }, 3000);
void tick();
}
// only=只提取关键词(不出图) / gen=提取+生成角色场景(立绘+场景图) / full=提取+生成角色场景(三视图手动生成)
async function runExtract(mode: "only" | "gen" | "full") {
if (extractState === "running") return;
setExtractErr("");
setExtractState("running");
setExtractMsg("正在从剧本认出角色 / 场景…");
try {
await api.extractEntities(project.id); // 异步提交(已有在途则后端复用,不重复扣费),慢活在 worker 跑
pollExtractUntilDone(mode); // 轮询直到 worker 跑完
} catch (err) {
setExtractState("idle");
setExtractMsg("");
setExtractErr(err instanceof Error ? err.message : "提取失败,请重试");
}
}
// ── 流程步骤4/5 · 兜底弹窗拦截:生成故事板/视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
// 缺了就弹窗(去补齐 / 仍要生成),不静默退化文生图、不让用户花冤枉钱。商品永远有主图、不算缺。
// reason:"noref" = 还没参考图(立绘/场景图);"notri" = 角色有立绘但缺三视图(故事板合成需多角度参考)。
type RefMiss = { name: string; type: string; reason: "noref" | "notri" };
const [refGate, setRefGate] = useState<{ missing: RefMiss[]; proceed: () => void } | null>(null);
useBodyScrollLock(Boolean(refGate));
// 某角色(按名字)已采用的立绘有没有配套三视图:取该角色代表组的 adopted_asset → 查它的三视图组。
// 用与详情弹窗同款判定(triview 组有候选 / 资产 metadata 标记 / 模特库正面图)。
function personHasTriview(name: string): boolean {
const ent = buildEntities("person").find((e) => (e.name || "").trim() === (name || "").trim());
const portrait = ent?.group.adopted_asset;
if (!portrait) return false;
const tri = triGroupForAsset(portrait);
if (tri && (tri.adopted_asset || (tri.candidate_assets?.length ?? 0) > 0)) return true;
const m: Record<string, unknown> = byId.get(portrait)?.metadata || {};
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
if (has === true || (Array.isArray(has) && has.length > 0) || (typeof has === "string" && (has as string).trim())) return true;
if (m.view === "frontal") return true; // 模特库正面图与三视图同批生成
return false;
}
function missingRefsFor(segs: Array<{ entity_refs?: string[] }>): RefMiss[] {
const ents = project.metadata?.script_entities;
if (!Array.isArray(ents) || !ents.length) return []; // 没提取过实体就不拦(交给提取闸门)
const entById = new Map(ents.map((e) => [e.id, e]));
const adopted = {
person: new Set(buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
scene: new Set(buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => (e.name || "").trim())),
};
const miss = new Map<string, RefMiss>();
for (const seg of segs) {
for (const rid of seg.entity_refs || []) {
const ent = entById.get(rid);
if (!ent || ent.type === "product") continue; // 商品永远有主图,不算缺
const kind = ent.type === "character" ? "person" : "scene";
const name = (ent.name || "").trim();
if (!name) continue;
if (!adopted[kind].has(name)) { miss.set(name, { name, type: ent.type, reason: "noref" }); continue; }
// 已有立绘/场景图 → 角色再查三视图:故事板 @图 合成需正/侧/背多角度参考,缺则拦(ZWQ#3 防呆)
if (kind === "person" && !personHasTriview(name)) miss.set(name, { name, type: ent.type, reason: "notri" });
}
}
return [...miss.values()];
}
// 拦截包装:参考图齐 → 直接生成;不齐 → 弹窗让用户去补 / 仍要生成(随他便)
function guardGen(segs: Array<{ entity_refs?: string[] }>, action: () => void) {
const missing = missingRefsFor(segs);
if (missing.length) setRefGate({ missing, proceed: action });
else action();
}
// ── 流程步骤5 · 生成视频前的「过审闸」:含真人脸的人物立绘/故事板分镜必须先过审(火山要素材库已过审引用),
// 否则火山直接报 InputImageSensitiveContentDetected。未过审 → 弹窗指明哪一镜/哪个人物/哪张分镜,挡住不生成。
type ReviewBlocker = { video_segment_id: string; sort_order: number; scene_no: number; kind: "person" | "storyboard"; name: string; asset_id: string; review_status: string };
const [reviewGate, setReviewGate] = useState<{ blockers: ReviewBlocker[]; submitting: boolean } | null>(null);
useBodyScrollLock(Boolean(reviewGate));
// 过审闸:有未过审项 → 弹窗(去送审/等过审),不放行;全过审 → 执行生成。segmentId 给定=只校验该段。
async function guardReview(segmentId: string | null, action: () => void) {
const r = await api.videoReviewPrecheck(project.id, segmentId || undefined).catch(() => null);
const blockers = r?.blockers ?? [];
if (blockers.length) setReviewGate({ blockers, submitting: false });
else action();
}
// 视频生成统一闸:先查参考图齐不齐(refGate),齐了再查过审(reviewGate),都过才真生成。
function guardVideoGen(segs: Array<{ entity_refs?: string[] }>, segmentId: string | null, action: () => void) {
const missing = missingRefsFor(segs);
if (missing.length) { setRefGate({ missing, proceed: () => void guardReview(segmentId, action) }); return; }
void guardReview(segmentId, action);
}
// 资产图片下载(经同源代理取 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("");
// 角色立绘升级为模特的状态按「形象资产 id」记录;后端精确查询,不能用全量列表猜测。
const [enrolledModelAssets, setEnrolledModelAssets] = useState<Set<string>>(() => new Set());
const [modelEnrollmentLoading, setModelEnrollmentLoading] = useState(false);
const [modelEnrollmentBusy, setModelEnrollmentBusy] = useState(false);
async function loadModelEnrollment(assetId: string | null | undefined) {
if (!assetId) return;
setModelEnrollmentLoading(true);
try {
const result = await api.listModels({ portraitAsset: assetId, pageSize: 1 });
if (result.results.length) setEnrolledModelAssets((known) => new Set(known).add(assetId));
} catch {
// 查询失败时保留可升级入口;提交接口本身仍是幂等的,不会重复建模特。
} finally {
setModelEnrollmentLoading(false);
}
}
useBodyScrollLock(Boolean(adDetail));
function openAssetDetail(kind: "person" | "scene", entity: AssetEntity) {
setAdDetail({ kind, key: entity.key });
const portraitAssetId = entity.group.adopted_asset ?? entity.versions.at(-1) ?? null;
setAdPortraitId(portraitAssetId);
setAdTriId(null); // 跟随当前立绘的最新三视图
setAdPrompt(entity.group.prompt ?? "");
if (kind === "person") void loadModelEnrollment(portraitAssetId);
}
// seed(未生成)卡也能点进详情:还没组,用脚本名(=key)+ 当前提示词开一个「空壳」详情。
// 进去先生成立绘;三视图按既有逻辑(无立绘则禁用)天然挡住。生成完刷新 → 同 key(=名字)自动切到真实体。
function openSeedDetail(kind: "person" | "scene", tag: string, promptValue: string) {
setAdDetail({ kind, key: tag });
setAdPortraitId(null);
setAdTriId(null);
setAdPrompt(promptValue);
}
// 模特库覆盖层:replace 带替换目标;browse + studio 直接进“添加模特”。
const [modelLib, setModelLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string; addNew?: boolean } | null>(null);
function openModelReplace(entity: AssetEntity) {
setModelLib({ mode: "replace", groupId: entity.group.id });
}
// seed 占位卡“替换”:还没组,选模特后由后端按 label 命中/建组再挂。
function openModelReplaceSeed(kind: "person" | "scene", tag: string) {
setModelLib({ mode: "replace", seedKind: kind, seedLabel: tag });
}
// 场景“替换”走通用选图层(取团队场景图),不使用模特库。
function openSceneReplaceSeed(tag: string) {
setAssetPick({ title: "选择场景素材", category: "scene", target: { kind: "scene", label: tag }, uploadCategory: "scene", uploadName: `${tag || "场景"}·场景图` });
}
function openSceneReplaceEntity(groupId: string, label?: string) {
setAssetPick({ title: "选择场景素材", category: "scene", target: { group_id: groupId }, uploadCategory: "scene", uploadName: `${label || "场景"}·场景图` });
}
async function pickModel(assetId: string, assetName?: string) {
if (modelLib?.mode === "replace") {
if (modelLib.groupId) await onAttachBaseAsset({ group_id: modelLib.groupId }, assetId);
// 新增角色(无既有 group / 无脚本标签):用所选模特名建立角色组并挂上形象资产。
else if (modelLib.addNew && modelLib.seedKind) await onAttachBaseAsset({ kind: modelLib.seedKind, label: (assetName || "").trim() }, assetId);
else if (modelLib.seedKind) await onAttachBaseAsset({ kind: modelLib.seedKind, label: modelLib.seedLabel }, assetId);
}
setModelLib(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]);
// 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。
// ── Stage 3:分镜制(对标视频段)—— 每镜一个 StoryboardShot,各自采用版图 + 历史版本,可单场重跑/切版 ──
const sbShots = [...(project.storyboard_shots ?? [])].sort((a, b) => a.sort_order - b.sort_order);
const [sbSelected, setSbSelected] = useState(0);
const sbActiveShot = sbShots[Math.min(sbSelected, Math.max(0, sbShots.length - 1))] || null;
// 「查看的版本」与「采用的版本」分离(对标视频详情弹窗):点历史缩略图只切预览(纯本地态),
// 绝不动后端状态 → 重跑在制时切版本不会把 RUNNING 状态冲掉、不丢任务;采用要点显式「采用此版本」。
const [sbViewVerId, setSbViewVerId] = useState<string | null>(null);
// 切换场 / 切项目 / 采用版变化(重跑落地或手动采用)时复位预览到「采用版」,这样重跑出片后自动看到新图
useEffect(() => { setSbViewVerId(null); }, [sbActiveShot?.id, sbActiveShot?.adopted_version]);
const sbAnyImage = sbShots.some((s) => Boolean(s.adopted_asset_url || s.adopted_asset));
// 全部场都出片(有采用版)= 故事板完成,可进视频;在制中(queued/running)用于转圈veil
const sbAllDone = sbShots.length > 0 && sbShots.every((s) => Boolean(s.adopted_version));
const sbAnyGenerating = sbShots.some((s) => ["queued", "running"].includes(s.status));
// 故事板按「采用版脚本」逐镜出图 → 还没出图时,先按采用版镜头数铺等量空占位(场1…场N),
// 让用户一眼看出「这里本该有几张」,出图后逐个填真图。无采用版脚本则为 0(显示「暂无」)。
const sbAdoptedScript = scripts.find((s) => s.is_adopted) || null;
const sbExpectedShots = sbAdoptedScript ? (sbAdoptedScript.segments?.length ?? 0) : 0;
// 整张/全部生成中:不全屏遮挡,只在每张分镜占位上转个 spinner(样式同基础资产趴)
const [sbGenerating, setSbGenerating] = useState(false);
// 单场重跑的乐观态:点下立刻给该场转圈(撑到状态回传 queued/running 接管)
const [sbBusyShots, setSbBusyShots] = useState<Set<string>>(() => new Set());
const clearSbBusy = (id: string) => setSbBusyShots((s) => { if (!s.has(id)) return s; const n = new Set(s); n.delete(id); return n; });
// 单场重跑:乐观置忙 + 调后端 → 由 poll 出图;状态进 queued/running 后由状态接管,失败/超时兜底解禁
function rerunStoryboardShotOptimistic(shotId: string) {
setSbBusyShots((s) => new Set(s).add(shotId));
Promise.resolve(onRerunStoryboardShot?.(shotId, storyboardPrompt || SB_PROMPT_DEFAULT))
.then((res) => { if (res == null) clearSbBusy(shotId); })
.catch(() => clearSbBusy(shotId));
window.setTimeout(() => clearSbBusy(shotId), 90000);
}
// 进入 succeeded/failed 终态的场,从乐观忙集合摘除(此后由真实状态驱动)
const sbStatusKey = sbShots.map((s) => `${s.id}:${s.status}`).join("|");
useEffect(() => {
setSbBusyShots((prev) => {
if (prev.size === 0) return prev;
const next = new Set(prev);
for (const s of sbShots) if (next.has(s.id) && ["succeeded", "failed"].includes(s.status)) next.delete(s.id);
return next.size === prev.size ? prev : next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sbStatusKey]);
// 没生成故事板时点「确认故事板」→ 弹气泡提示先去生成(3.5s 自动收起)
const [sbConfirmHint, setSbConfirmHint] = useState(false);
useEffect(() => {
if (!sbConfirmHint) return;
const t = window.setTimeout(() => setSbConfirmHint(false), 3500);
return () => window.clearTimeout(t);
}, [sbConfirmHint]);
// ── 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("");
}
// 重跑「提交中」乐观态:点下立刻禁用+转圈,撑到该段真的进 running(busy 接管),堵掉空窗连点 → 防双倍扣费
const [rerunPending, setRerunPending] = useState<Set<string>>(() => new Set());
const clearRerunPending = (segId: string) =>
setRerunPending((s) => { if (!s.has(segId)) return s; const n = new Set(s); n.delete(segId); return n; });
function submitVideoOptimistic(segId: string, prompt: string) {
setRerunPending((s) => new Set(s).add(segId));
Promise.resolve(onSubmitVideo(segId, prompt))
.then((res) => { if (res == null) clearRerunPending(segId); }) // 失败(action 返回 null)→ 立即解禁可重试;成功交给下方 effect
.catch(() => clearRerunPending(segId));
window.setTimeout(() => clearRerunPending(segId), 20000); // 兜底:异常下也别永久禁用
}
// 「全部重跑」乐观态:把将被提交的段(非在途)全部立刻标 pending → 每张卡马上转圈,不必等状态回传
function submitAllVideosOptimistic() {
const ids = segments.filter((s) => !["running", "queued"].includes(s.status)).map((s) => s.id);
if (ids.length === 0) return;
setRerunPending((s) => { const n = new Set(s); ids.forEach((id) => n.add(id)); return n; });
Promise.resolve(onSubmitAllVideos(videoPrompt))
.then((res) => { if (res == null) ids.forEach(clearRerunPending); }) // 失败 → 解禁;成功交给上面的 effect 逐个摘除
.catch(() => ids.forEach(clearRerunPending));
ids.forEach((id) => window.setTimeout(() => clearRerunPending(id), 20000));
}
// 段状态变化时,把已真正进入 running/queued 的段从 pending 摘掉(此后由 busy 驱动 UI)
const segStatusKey = segments.map((s) => `${s.id}:${s.status}`).join("|");
useEffect(() => {
setRerunPending((prev) => {
if (prev.size === 0) return prev;
const next = new Set(prev);
for (const seg of segments) if (next.has(seg.id) && ["running", "queued"].includes(seg.status)) next.delete(seg.id);
return next.size === prev.size ? prev : next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segStatusKey]);
// 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)。
// V1 雪藏拼接导出:已完成项目落到第 4 阶段(视频),不落到被藏的第 5(V2 恢复改回 5)
const projectStage = project.status === "completed" ? 4 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
// 进入项目默认落到「项目当前进行到的阶段」(视频在生成/已生成 → 直接到第4阶段),而不是恒显第1阶段(ZWQ#16)。
// 仍尊重地址栏 #stage-N(分享某阶段链接 / 浏览器前进后退)。
const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : projectStage);
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 || "") : "");
// 手动兜底:灰盾点击 → 提交审核,乐观置 processing(下次轮询回真态)
const [reviewBusyId, setReviewBusyId] = useState<string | null>(null);
async function submitAssetReview(id: string) {
setReviewBusyId(id);
try {
const r = await api.submitAssetReview(id);
// 用后端真态,别再 `|| "processing"` 伪造审核中 —— 没真送出去就该回灰盾,而不是骗一个刷新即消失的「审核中」
setReviews((m) => ({ ...m, [id]: r.review_status || "" }));
if ((r.review_status || "") === "processing") setReviewWake((n) => n + 1); // 唤醒审核轮询去盯这条
} catch (e) {
// 送审没成(503=审核服务不可用等):如实提示,灰盾保持可重试,不再静默吞掉
onNotify?.("error", e instanceof Error && e.message ? e.message : "审核服务暂不可用,请稍后重试");
} finally {
setReviewBusyId(null);
}
}
// 在基础资产趴(stage 2)/ 故事板趴(stage 3)/ 视频趴(stage 4)定时轮询审核状态,刷新徽章
// (processing → active绿 / failed红)。stage4 也轮询:过审闸里「一键送审」后,要在视频阶段就能看到盾变绿再生成。
useEffect(() => {
if (viewStage !== 2 && viewStage !== 3 && viewStage !== 4) return;
let alive = true;
let timer = 0;
const tick = async () => {
if (document.hidden) return; // 标签页切走时不空跑审核轮询(纯读,回前台自然恢复)
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) {
setReviews((m) => ({ ...m, ...map })); // 含终态(active绿/failed红):终态在这一轮就下发了
} else {
// 后端已无 processing 资产 → 没什么可等,停轮询(旧实现没见过 processing 时会 8s 空跑到永远)。
// 新生成的真人会自动送审、手动送审会 +reviewWake,届时本 effect 重订阅再开跑。
window.clearInterval(timer);
}
};
void tick();
timer = window.setInterval(tick, 8000);
return () => { alive = false; window.clearInterval(timer); };
}, [viewStage, project.id, genBusy, reviewWake, pendingGenKey]);
// 故事板有在制场(queued/running)时,后台每 5s 静默轮询驱动出图 + 刷新(对标视频段轮询)。
// 不占全局 loading → 单场重跑不会锁住其它场的按钮,可并行重跑多场。
useEffect(() => {
if (viewStage !== 3 || !sbAnyGenerating) return;
const timer = window.setInterval(() => { void onPollStoryboardQuiet?.(); }, 5000);
void onPollStoryboardQuiet?.();
return () => window.clearInterval(timer);
}, [viewStage, sbAnyGenerating, onPollStoryboardQuiet]);
// 外部 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");
// 向导选的成片时长档("0-30"→30 秒)传给脚本 agent —— 脚本按它定镜数,别再写死 60(否则选 30s 也按 60s 出)
const wizardTotalDuration = (() => {
const raw = project.metadata?.wizard?.duration;
const m = /(\d+)\s*$/.exec(typeof raw === "string" ? raw : "");
return m ? Number(m[1]) : 60;
})();
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?: StepNode[]; stream?: string; reasoning?: string; done?: boolean; startedAt?: number; thinkingElapsedSeconds?: number; thinkingDurationReady?: boolean; auto?: boolean };
const nowHm = () => new Date().toTimeString().slice(0, 5);
const elapsedSinceStart = (startedAt?: number) => startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0;
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.map((msg) => (
msg.kind === "progress" && msg.done && msg.steps?.some((step) => step.id === "generate") && !msg.thinkingDurationReady
? { ...msg, thinkingDurationReady: false }
: msg
));
}
}
} 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 || "";
// 模型选择小按钮(输入框下方)· 自建 restraint 下拉(幽灵触发 + popover 菜单),不再用原生 select + inline
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const activeModel = textModels?.find((m) => m.id === activeScriptModelId);
const activeModelName = publicModelDisplayName(activeModel);
useEffect(() => {
if (!modelMenuOpen) return;
const close = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".chat-model-pick")) setModelMenuOpen(false); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [modelMenuOpen]);
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
const [armedDelete, setArmedDelete] = useState<string | null>(null);
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
const [busyShot, setBusyShot] = useState<string | null>(null);
// 整体生成中:给整个镜头脚本区盖罩子转圈
const [scriptBusy, setScriptBusy] = useState(false);
// 单镜「改写中」:只在重跑/改稿那一镜盖罩子。与 busyShot 区分——删除也占 busyShot,但删除不该显「改写」罩子
const [rewriteShot, setRewriteShot] = useState<string | null>(null);
// ⑤ 停止按钮:流式生成可中断(AbortController);streamBusy=true 时发送键变停止键
const genAbortRef = useRef<AbortController | null>(null);
const [streamBusy, setStreamBusy] = useState(false);
// 行35 · 单条分镜重跑:调后端 rerun-script-segment 只重写该镜(instruction 带本镜要点微调),给即时反馈
async function rerunShot(shotId: string, _index: number, hint: string) {
if (busyShot) return;
setBusyShot(shotId);
setRewriteShot(shotId); // 重跑 = 改写 → 那一镜盖「改写中」罩子
try {
await onRerunShot?.(shotId, hint || undefined);
} finally {
setBusyShot(null);
setRewriteShot(null);
}
}
// ④ 持久删除队列:本地秒删 + 后台重试,绝不回滚;网络不好就一直扛着重试,直到后端确实删掉
const delRetryingRef = useRef<Set<string>>(new Set());
function persistPending(next: Set<string>) {
try { localStorage.setItem(`airshelf:pending-del:${project.id}`, JSON.stringify([...next])); } catch { /* localStorage 不可用就算了 */ }
}
function stopDeleteRetry(id: string) {
delRetryingRef.current.delete(id);
}
function dropPending(id: string) {
stopDeleteRetry(id);
setPendingDeletes((prev) => { if (!prev.has(id)) return prev; const next = new Set(prev); next.delete(id); persistPending(next); return next; });
}
async function runDeleteRetry(shotId: string, attempt: number) {
try {
await api.deleteScriptSegment(project.id, { segment_id: shotId });
stopDeleteRetry(shotId);
void onRefreshProject(); // 后端已删 → 拉回最新(re-sort 后的镜号)
} catch (e) {
const status = e instanceof ApiError ? e.status : 0;
if (status === 404) { stopDeleteRetry(shotId); void onRefreshProject(); return; } // 本来就没了 = 删成
if (status >= 400 && status < 500) { console.warn("删除被拒(非网络),停重试但按要求不回滚:", status, shotId); stopDeleteRetry(shotId); return; }
// 网络/5xx → 退避重试(最长 30s),扛到删成
const delay = Math.min(30000, 1000 * 2 ** Math.min(attempt, 5));
window.setTimeout(() => { void runDeleteRetry(shotId, attempt + 1); }, delay);
}
}
function ensureDeleteRetry(shotId: string) {
if (delRetryingRef.current.has(shotId)) return;
delRetryingRef.current.add(shotId);
void runDeleteRetry(shotId, 0);
}
// 单条分镜删除:本地秒删(进持久队列,那一镜立刻消失)→ 后台重试。绝不回滚。
function deleteShot(shotId: string) {
setArmedDelete(null);
setPendingDeletes((prev) => { const next = new Set(prev).add(shotId); persistPending(next); return next; });
ensureDeleteRetry(shotId);
}
// 扛刷新/换项目:把 localStorage 里还没删成的继续重试,期间那些镜保持隐藏
useEffect(() => {
let ids: string[] = [];
try { ids = JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]"); } catch { ids = []; }
setPendingDeletes(new Set(ids));
ids.forEach((id) => ensureDeleteRetry(id));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.id]);
useEffect(() => {
if (!currentScript || pendingDeletes.size === 0) return;
const liveIds = new Set((currentScript.segments ?? []).map((segment) => segment.id));
setPendingDeletes((prev) => {
let changed = false;
const next = new Set(prev);
prev.forEach((id) => {
if (!liveIds.has(id)) {
stopDeleteRetry(id);
next.delete(id);
changed = true;
}
});
if (changed) persistPending(next);
return changed ? next : prev;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentScript, pendingDeletes]);
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: "", reasoning: "", done: false, startedAt: Date.now(), time: nowHm() }]);
// 指定镜号 = 精准改一镜,强制走 revise(后端读全脚本上下文、只动那一镜)
const agentMode = targetIndex != null ? "revise" : (mode ?? mapSourceToMode(source ?? chatMode));
const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined;
// 生成时给左侧上罩子:指定镜号 → 只盖那一镜(busyShot);否则 → 盖整个脚本区(scriptBusy)
const targetShotId = targetIndex != null ? (shots[targetIndex]?.id ?? null) : null;
if (targetShotId) { setBusyShot(targetShotId); setRewriteShot(targetShotId); } else setScriptBusy(true);
const ac = new AbortController();
genAbortRef.current = ac;
setStreamBusy(true);
let ok = false;
let receivedEvent = false;
let summaryText = "";
try {
await api.agentScriptStream(
project.id,
{
mode: agentMode,
prompt,
model_config_id: activeScriptModelId || undefined,
base_version_id: baseVersionId,
aspect_ratio: "9:16",
total_duration: wizardTotalDuration,
target_index: targetIndex
},
(evt) => {
receivedEvent = true;
if (evt.type === "tool") {
// 工具卡 → 时间轴节点:running 入列/置活动,done 标完成(extract/check 只发 done 也入列)
const sid = String((evt as { id?: unknown }).id || "");
if (!sid) return;
const label = typeof evt.label === "string" ? evt.label : undefined;
const running = evt.status === "running";
setChatMsgs((list) => list.map((m) => {
if (m.id !== progressId) return m;
const steps = [...(m.steps ?? [])];
const idx = steps.findIndex((s) => s.id === sid);
if (idx === -1) steps.push({ id: sid, label: label ?? sid, done: !running });
else steps[idx] = { ...steps[idx], ...(label ? { label } : {}), ...(running ? {} : { done: true }) };
// “生成分镜”结束即表示思考结束;实体提取、自检、保存都不再计入该文案的用时。
const thinkingElapsedSeconds = sid === "generate" && !running && m.thinkingElapsedSeconds == null
? elapsedSinceStart(m.startedAt)
: m.thinkingElapsedSeconds;
const thinkingDurationReady = sid === "generate" && !running ? true : m.thinkingDurationReady;
return { ...m, steps, ...(thinkingElapsedSeconds != null ? { thinkingElapsedSeconds } : {}), ...(thinkingDurationReady != null ? { thinkingDurationReady } : {}) };
}));
} else if (evt.type === "reasoning" && typeof evt.text === "string") {
// 推理模型思考流:逐字累积,并把 generate 步置「思考态」(状态字 = 思考最新一句)
const piece = evt.text;
setChatMsgs((list) => list.map((m) => {
if (m.id !== progressId) return m;
const steps = (m.steps ?? []).map((s) => (s.id === "generate" ? { ...s, think: true } : s));
return { ...m, reasoning: (m.reasoning ?? "") + piece, steps };
}));
} else if (evt.type === "delta" && typeof evt.text === "string") {
// 出正文 = 思考结束:generate 步退出思考态,状态字转成模型那句真前言
const piece = evt.text;
setChatMsgs((list) => list.map((m) => {
if (m.id !== progressId) return m;
const steps = (m.steps ?? []).map((s) => (s.id === "generate" ? { ...s, think: false } : s));
return { ...m, stream: (m.stream ?? "") + piece, steps };
}));
} else if (evt.type === "summary" && typeof evt.text === "string") {
// 模型自己写的收尾交付语 → 当 AI 回复气泡(替掉写死的「已生成」)
summaryText = evt.text;
} else if (evt.type === "saved") {
ok = true;
} else if (evt.type === "error") {
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true, thinkingElapsedSeconds: m.thinkingElapsedSeconds ?? elapsedSinceStart(m.startedAt), thinkingDurationReady: m.thinkingDurationReady ?? false } : m)));
const publicError = isPublicGenerationError(evt.error) ? evt.error : null;
if (publicError) {
const presentation = presentGenerationError(publicError);
pushMsg("ai", `${presentation.title}${presentation.description}`);
} else {
// 兼容尚未接入统一错误对象的安全本地校验帧。
pushMsg("ai", typeof evt.detail === "string" ? evt.detail : "脚本遇到问题,请稍后重试。");
}
}
},
ac.signal
);
} catch {
if (ok) {
// 已收到 saved:流其实成功了(只是收尾抖动),绝不回退重发,避免重复生成+重复扣费
} else if (ac.signal.aborted) {
// 用户点了停止键:正常中断,不当报错(后端检测断连已释放预扣额度)
pushMsg("ai", "已停止生成。");
} else if (!receivedEvent) {
// 流式没跑起来:明确报错,不再静默退回旧散文路(旧路不产 script_entities,会让下游故事板/视频参考图断线)
pushMsg("ai", "脚本生成没能启动(流式连接失败),请重试。");
} else {
// 流中途断了(后端已通过 finally 释放预扣额度):不重试,提示用户
pushMsg("ai", "生成中断了,请重试。");
}
} finally {
genAbortRef.current = null;
setStreamBusy(false);
if (targetShotId) { setBusyShot(null); setRewriteShot(null); } else setScriptBusy(false);
}
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true, thinkingElapsedSeconds: m.thinkingElapsedSeconds ?? elapsedSinceStart(m.startedAt), thinkingDurationReady: m.thinkingDurationReady ?? false } : m)));
if (ok) {
await onRefreshProject();
await onRefreshBilling(); // SSE 的 saved 事件在后端完成脚本扣费后才发出
// 模型写了收尾就用它(真 agent 感);没写则兜底默认句
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
}
}
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
async function runScriptWithSetup() {
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
// 行28 · 持久化所选风格/人物到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回三个菜单。
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
setSetupOpen(false);
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
if (setupSource === "theme") {
// 确定 = 确认风格/人物;主题交给模型来问 → 用户输入后由 submitChat 的 theme 分支带风格/人物生成
setChatMode("theme");
pushMsg("ai", `好,${styleLabel} · ${personaLabel} 定了。用一句话说说这条视频的主题(5-30字),例如「熬夜党的早八续命面膜」,发我就生成。`);
chatTextareaRef.current?.focus();
return;
}
if (setupSource === "manual") {
const base = chatText.trim();
if (!base) {
chatTextareaRef.current?.focus();
return;
}
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;
// 一句话主题:确定后模型已问主题,这次输入就是主题 → 带上已确认的风格/人物生成(仅首次出稿前)
if (chatMode === "theme" && !currentScript && text) {
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
setChatText("");
setChatAttachments([]);
void runScriptGeneration(`一句话主题:${text}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${text}`, "theme");
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);
// 一句话主题:开卡时不再 push 提示(否则 chatMsgs 非空,点返回就回不到三选项);
// 主题改成在「确定」(确认风格/人物)之后由模型来问,更像对话流。
if (source === "manual") {
setChatText("");
setChatAttachments([]);
window.setTimeout(() => chatTextareaRef.current?.focus(), 0);
}
// 文件上传入口先保留,当前「自带脚本」改为粘贴文本。
// 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);
}
// 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 = "统一商品、人物、场景风格,生成可直接指导视频的分镜图";
// 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效
const sbSavedPrompt = (project.metadata as Record<string, unknown> | undefined)?.storyboard_prompt as string | undefined;
const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT);
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);
// 提交一个新编辑态(进 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 文件上传
// 导出全部:把本项目所有已采用视频片段打成一个 zip 下载
const [exporting, setExporting] = useState(false);
const [exportErr, setExportErr] = useState("");
async function exportAllVideos() {
if (exporting) return;
setExporting(true);
setExportErr("");
try {
const blob = await api.exportProjectClips(project.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${project.name}-视频素材.zip`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
setExportErr(err instanceof ApiError ? err.message : "导出失败,请重试");
window.setTimeout(() => setExportErr(""), 3000);
} finally {
setExporting(false);
}
}
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;
// 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonPortrait/genBaseAsset 出图、直接扣用户费,
// 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套),
// 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。
// 流程步骤4 · 在基础资产趴轮询后端在途出图任务,重建「生成中」占位卡 loading(刷新后内存态丢失也能恢复);
// 在途数减少=有任务出图完成 → 刷新项目把占位卡换成真卡。离开该趴即停。
const prevPendingIdsRef = useRef<string[]>([]);
useEffect(() => {
if (activeDot !== 2 && viewStage !== 2) return;
let stopped = false;
let timer = 0;
const tick = async () => {
if (document.hidden) { if (!stopped) timer = window.setTimeout(tick, 4000); return; } // 后台不空跑(纯读),保留心跳回前台即恢复
try {
const res = await api.pendingAssets(project.id);
if (stopped) return;
const list = res.pending || [];
const completedIds = prevPendingIdsRef.current.filter((id) => !list.some((pending) => pending.id === id));
if (completedIds.length > 0) {
void onRefreshProjectRef.current();
// 刷新后认领的在途出图:先确认终态成功,失败/退款不刷新右上角余额。
void api.generateImageStatus(completedIds).then((result) => {
if (result.tasks.some((task) => task.status === "succeeded")) void onRefreshBillingRef.current();
}).catch(() => undefined);
}
prevPendingIdsRef.current = list.map((pending) => pending.id);
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview })));
// 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。
if (list.length === 0 && genBusy.size === 0) { stopped = true; return; }
} catch {
/* 忽略,下一轮再试 */
}
if (!stopped) timer = window.setTimeout(tick, 4000);
};
void tick();
return () => { stopped = true; window.clearTimeout(timer); };
// genBusy 入依赖:开始/结束一次生成都会重订阅 → 空闲即停、开工即起。onRefreshProject 走 ref,不进依赖(否则每渲染重发)。
}, [activeDot, viewStage, project.id, genBusy]);
// 流程步骤4 · 刷新后认领在途「提取」:内存态 extractState 一刷新就丢,据后端 extract-status 重建「提取中」loading
// (和上面出图占位卡同思路)。已有轮询在跑则不重复起。进资产趴时检查一次。
useEffect(() => {
if (activeDot !== 2 && viewStage !== 2) return;
let cancelled = false;
(async () => {
try {
const s = await api.extractStatus(project.id);
if (cancelled || !s.running || extractStartedRef.current) return;
setExtractState("running");
setExtractMsg("正在从剧本认出角色 / 场景…");
pollExtractUntilDone(null); // 认领:mode 未知,只恢复 loading,完成后刷新即见卡片
} catch {
/* 忽略,无在途就不重建 */
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDot, viewStage, project.id]);
// 卸载/离开页面:停掉提取轮询,别泄漏定时器
useEffect(() => () => window.clearInterval(extractPollRef.current), []);
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="账户(双击退出)">
{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : <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">
{scriptBusy ? (
<div className="script-gen-veil" aria-hidden="true">
<div className="sgv-card">
<span className="sgv-ico"><span className="spinner"></span></span>
<div className="sgv-body">
<div className="sgv-title">正在生成镜头脚本<span className="sgv-dots"><i></i><i></i><i></i></span></div>
<div className="sgv-bar"></div>
</div>
</div>
</div>
) : null}
{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">
{rewriteShot === shot.id ? (
<div className="shot-gen-veil" aria-hidden="true"><span className="spinner"></span><span className="mono">// 改这一镜…</span></div>
) : null}
<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 || busyShot === shot.id}
onClick={() => {
if (armedDelete === shot.id) {
void deleteShot(shot.id);
} else {
setArmedDelete(shot.id);
}
}}
>{armedDelete === shot.id ? "确认删除?" : "×"}</button>
</div>
</div>
{/* 这一镜有对白时,旁白=对白拼接(同一句),只显对白、不再重复显旁白 */}
{!(shot.dialogue && shot.dialogue.length > 0) ? (
<div className="shot-row">
<span className="shot-k">旁白</span>
<EditableShotField
value={narration}
placeholder="(旁白)点击编辑"
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, narration: text }); }}
/>
</div>
) : null}
<div className="shot-row">
<span className="shot-k">画面</span>
<EditableShotField
value={visualShown}
placeholder="(画面描述)点击编辑"
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, visual_prompt: text }); }}
/>
</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, { narration: d.narration, visual_prompt: d.visual }); }}
onCancel={(id) => setDraftShots((list) => list.filter((x) => x.id !== id))} />
))}
<div className={`shot-insert-gap${index === shots.length - 1 ? " shot-insert-gap--bottom" : ""}`}>
<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-11" style={{ marginLeft: "8px" }}>· {activeModelName}</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"
? <>
<ProgressTimeline steps={msg.steps} reasoning={msg.reasoning} stream={msg.stream} done={msg.done} startedAt={msg.startedAt} thinkingElapsedSeconds={msg.thinkingElapsedSeconds} thinkingDurationReady={msg.thinkingDurationReady} />
</>
: <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">
{/* ← 返回:收起设定卡,回到「AI全生/一句话主题/自带脚本」三选项页 */}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}> 返回</button>
<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 || (setupSource === "manual" && !chatText.trim())} 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)· 复用 restraint chip 下拉,向上展开 */}
{textModels && textModels.length > 0 ? (
<div className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
<button type="button" className="chip" title="选择脚本生成模型" onClick={() => setModelMenuOpen((v) => !v)}>
{activeModelName}
<svg className="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
</button>
<div className="chip-menu align-up">
{textModels.map((m) => (
<div key={m.id} className={`mi${m.id === activeScriptModelId ? " selected" : ""}`} role="menuitemradio" aria-checked={m.id === activeScriptModelId} tabIndex={0}
onClick={() => { setScriptModelId(m.id); setModelMenuOpen(false); }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setScriptModelId(m.id); setModelMenuOpen(false); } }}>
{publicModelDisplayName(m)}
<svg className="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
</div>
))}
</div>
</div>
) : null}
<span className="spacer"></span>
{streamBusy ? (
// ⑤ 生成中 → 停止键(中间方块 + 外圈转圈),点它掐断本次生成
<button className="chat-send-btn chat-stop-btn" id="chat-send-btn" type="button" title="停止生成" aria-label="停止生成" onClick={() => genAbortRef.current?.abort()}>
<span className="chat-stop-ring" aria-hidden="true"><span className="chat-stop-square"></span></span>
</button>
) : (
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || scriptBusy || !!rewriteShot || (!setupOpen && !chatText.trim() && pendingTagEdits.length === 0)} onClick={submitChat}>
{scriptBusy || rewriteShot
? <span className="spinner spinner-on-accent" aria-hidden="true"></span>
: <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">[ 脚本生成 {pts(10)} 积分/ · 失败不扣 ]</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;
// 商品主图:优先用序列化器内嵌的 preview_url(全局 assets 已不再全量取,assetUrl 反查会落空),
// 依次 封面 → 主图 → 首图,最后才回退旧的全局反查。
const productAssetUrl =
productRecord?.cover_preview_url
|| productRecord?.images?.find((img) => img.is_primary)?.preview_url
|| productRecord?.images?.[0]?.preview_url
|| assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); };
// 实体提取闸门:没走过正式提取步(entities_extracted)且没生成任何基础资产 → 盖蒙版 + 三按钮,不自动花钱。
// 用 entities_extracted 标记而非 script_entities 存在性:脚本生成期也可能吐过不稳的 entities,那不算正式提取。
const entitiesExtracted = project.metadata?.entities_extracted === true;
const hasAnyAsset = KIND_ORDER.some((k) => groupsByKind(k).length > 0);
const gateVisible = extractState === "running" || (!entitiesExtracted && !hasAnyAsset);
return (
<section className="stage active" data-stage-pane="2">
{gateVisible && (
<div className="extract-gate">
<div className="extract-gate-panel">
{extractState === "running" ? (
<div className="sgv-card">
<span className="sgv-ico"><span className="spinner"></span></span>
<div className="sgv-body">
<div className="sgv-title">{extractMsg || "正在提取角色 / 场景"}<span className="sgv-dots"><i></i><i></i><i></i></span></div>
<div className="sgv-bar"></div>
</div>
</div>
) : (
<>
<div className="eg-title">先从剧本认出角色 / 场景</div>
<div className="mono eg-sub">// AI 认出角色 / 场景并出提示词,稍后你再逐个生成</div>
<button type="button" className="btn btn-primary btn-lg eg-extract-btn" onClick={() => void runExtract("only")}>提取角色 / 场景</button>
<div className="mono eg-cost-hint">// {pts(10)} 积分/次 · 失败不扣</div>
{extractErr && <div className="eg-err">{extractErr}</div>}
</>
)}
</div>
</div>
)}
<div className="stage-assets">
<div className="asset-side">
{KIND_ORDER.map((kind) => {
// 计数口径要和下方分区「· N 个」一致:人物/场景按「实体」(buildEntities 已排除自动配套的
// 三视图组、并按同名归并),否则人物的三视图组会被算进去 → 显示 5 而真人只有 2。
// 商品是「单实体多版本」模型,仍按组计(与商品区一致)。
const count = kind === "product" ? groupsByKind(kind).length : buildEntities(kind).length;
const adopted = kind === "product"
? groupsByKind(kind).filter((g) => g.adopted_asset).length
: buildEntities(kind).filter((e) => e.group.adopted_asset).length;
return (
<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">{count ? `${adopted}/${count}` : "0"}</span>
</div>
);
})}
<div className="info">
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
<br /><br />
<strong className="mono">// 提取角色 / 场景 +{pts(10)} 积分/次</strong>
<strong className="mono">// 人物 +{pts(20)} 积分/张</strong>
<strong className="mono">// 场景 +{pts(20)} 积分/张</strong>
<span style={{ color: "var(--black-alpha-48)" }}>商品图无成本(直接复用商品库)</span>
{(entitiesExtracted || hasAnyAsset) && (
<button type="button" className="btn btn-sm eg-reextract" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}>
{extractState === "running" ? "提取中…" : "重新提取角色 / 场景"}
</button>
)}
</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">
{/* 与人物/场景卡页脚一致:hstack 左「使用已有素材」(ghost) + spacer + 右「AI 生成」(primary) */}
<div className="hstack">
{/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */}
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || assetPickBusy} onClick={() => setAssetPick({ title: "使用已有素材作三视图", category: "product_image", product: project.product, target: { kind: "product" }, uploadCategory: "tri_view", uploadName: `${productName || "商品"}·三视图` })}>替换</button>
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
</div>
</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">
{/* 商品图也挂审核盾:商品图可能出现模特,送审后视频路才能换 asset:// 引用(与角色/场景同款) */}
{adoptedTriAsset && (
<ReviewBadge
compact
status={(reviews[adoptedTriAsset] || productGroup?.adopted_asset_review || "") as ReviewStatus}
error={productGroup?.adopted_asset_review_error || byId.get(adoptedTriAsset)?.review_error}
busy={reviewBusyId === adoptedTriAsset}
onSubmit={() => submitAssetReview(adoptedTriAsset)}
/>
)}
<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 }}>{pts(20)} 积分 / </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") setModelLib({ mode: "replace", seedKind: "person", addNew: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
</div>
<div className="asset-grid-2">
{/* 新增场景:本地占位草稿卡(可改标题 + 提示词),编辑完点「AI 生成」才出图;出图成功后移除草稿 */}
{kind === "scene" && sceneDrafts.map((draft) => {
const dk = `scene-draft:${draft.id}`;
const busy = isBusy(dk);
return (
<div className="asset-card-2 asset-seed" data-asset-kind="scene" key={dk}>
<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">{(draft.title.trim() || "新场景")} · 待生成</span>}
</div>
<div className="body-2">
<div className="hstack">
<input type="text" value={draft.title} placeholder="场景名(可选)" aria-label="场景名" disabled={busy} data-stop
style={{ flex: 1, minWidth: 0, fontSize: "13.5px", fontWeight: 600, color: "var(--accent-black)", background: "transparent", border: "none", outline: "none", padding: 0 }}
onChange={(e) => setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, title: e.target.value } : d))} />
<span className="spacer"></span>
<span className="pill neutral"><span className="dot"></span>新增</span>
</div>
<PromptBox key={dk} value={draft.prompt} onChange={(v) => setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, prompt: v } : d))} />
<div className="hstack" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => setSceneDrafts((list) => list.filter((d) => d.id !== draft.id))}>移除</button>
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" data-stop disabled={busy} onClick={async () => {
const p = draft.prompt.trim() || genPrompt;
// 一启动就标 generating(此后不再落盘):生成中刷新页面不会留下这张草稿 → 不会和新实体卡并存(ZWQ#17)
setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, generating: true } : d));
const res = await genBaseAsset("scene", p, draft.title.trim() || undefined, dk);
if (res && (res.id || res.adopted_asset)) setSceneDrafts((list) => list.filter((d) => d.id !== draft.id)); // 成功:结果已成实体卡,移除草稿
else setSceneDrafts((list) => list.map((d) => d.id === draft.id ? { ...d, generating: false } : d)); // 失败:恢复草稿可重试
}}>{busy ? "生成中…" : "AI 生成"}</button>
</div>
</div>
</div>
);
})}
{/* 脚本提取出但还没生成的人物/场景: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}>
{/* 待生成 seed 卡也可删除(还没出参考图也有垃圾桶):两步确认,直接从脚本标签里移除(ZWQ#9) */}
{seedDelArmed === seedKey ? (
<span className="asset-card-del-confirm" data-stop>
<button type="button" className="del-yes" onClick={(e) => { e.stopPropagation(); setSeedDelArmed(null); deleteSeedTag(kind, tag); }}>确认删除</button>
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setSeedDelArmed(null); }}>取消</button>
</span>
) : (
<button type="button" className="asset-card-del" data-stop title="删除该角色/场景" onClick={(e) => { e.stopPropagation(); setSeedDelArmed(seedKey); window.setTimeout(() => setSeedDelArmed((v) => (v === seedKey ? null : v)), 3000); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder thumb-2" role="button" tabIndex={0} style={{ cursor: "pointer" }} title="点击进详情(先生成立绘,再生成三视图)"
onClick={() => openSeedDetail(kind, tag, promptValue)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSeedDetail(kind, tag, promptValue); } }}>
{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", cursor: "pointer" }} onClick={() => openSeedDetail(kind, tag, promptValue)}>{tag}</strong><span className="spacer"></span><span className="pill neutral"><span className="dot"></span>来自脚本</span></div>
<PromptBox key={seedKey} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: v }))} />
<div className="hstack" style={{ marginTop: 10 }}>
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={busy} onClick={() => kind === "scene" ? openSceneReplaceSeed(tag) : openModelReplaceSeed(kind, tag)}>替换</button>
<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); 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}`;
// 已生成卡(有 mainUrl)只在本次主动重跑(本地 isBusy)时转圈;
// 不再被轮询到的在途任务(可能是残留/重复/三视图同名任务)误盖成「生成中」——
// 否则刷新后已出图的卡会无故转圈。仅当还没出图(mainUrl 空)才用 pendingHas 兜底。
const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || (!mainUrl && pendingHas(kind, entity.name));
// 审核态:优先本地轮询的实时态(processing→终态),回退项目详情持久下发的 adopted_asset_review
// (刷新后轮询 map 为空也能回显;不再依赖 byId,因为页面没喂全量 assets)
// 角色 + 场景卡都挂审核盾:场景图也可能出现真人,送审后视频路才能换 asset:// 引用
const rs = (kind === "person" || kind === "scene") && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
return (
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
{/* D:hover 删除(角色/场景卡;商品主卡不给删)·二次确认 */}
{grp.id && (delArmed === grp.id ? (
<span className="asset-card-del-confirm" data-stop>
<button type="button" className="del-yes" onClick={(e) => { e.stopPropagation(); setDelArmed(null); void onDeleteBaseAsset(grp.id); }}>确认删除</button>
<button type="button" className="del-no" onClick={(e) => { e.stopPropagation(); setDelArmed(null); }}>取消</button>
</span>
) : (
<button type="button" className="asset-card-del" data-stop title="删除该角色/场景" onClick={(e) => { e.stopPropagation(); setDelArmed(grp.id); window.setTimeout(() => setDelArmed((v) => (v === grp.id ? null : v)), 3000); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
))}
<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>
{(kind === "person" || kind === "scene") && grp.adopted_asset && (
<ReviewBadge
status={rs as ReviewStatus}
error={grp.adopted_asset_review_error || byId.get(grp.adopted_asset)?.review_error}
busy={reviewBusyId === grp.adopted_asset}
onSubmit={() => submitAssetReview(grp.adopted_asset!)}
/>
)}
{/* C:采用/未采用 toggle —— 下游只取已采用;脚本里没有的默认未采用,可点采用。需已有真实组 */}
{grp.id && grp.adopted_asset && (() => { const st = adoptStateOf(grp, entity.name); return (
<button className={`adopt-toggle ${st}`} type="button" data-stop title={st === "adopted" ? "已采用 · 点击改为未采用" : "未采用 · 点击采用(下游只取已采用的)"} onClick={(e) => { e.stopPropagation(); void onSetAdoptState(grp.id, st === "adopted" ? "unadopted" : "adopted"); }}>{st === "adopted" ? "已采用" : "未采用"}</button>
); })()}
</div>
<PromptBox key={entity.key} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
<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; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void genBaseAsset(kind, p, entity.name, entBK, ref); }}>{busy ? "生成中…" : "重跑"}</button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-stop onClick={() => kind === "scene" ? openSceneReplaceEntity(entity.group.id, entity.name) : openModelReplace(entity)}>替换</button>
</div>
</div>
</div>
);
})}
{/* 添加人物入口统一走右上「+ 新增人物」按钮(原末尾常驻卡与之重复,已移除) */}
{entities.length === 0 && pendingTags.length === 0 && (kind !== "scene" || sceneDrafts.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={() => guardGen(shots, () => 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 && (() => {
// 每场时间区间(累加脚本镜时长 → 「0~5s」)
let cum = 0;
const sceneTimes = shots.map((s) => { const st = cum; cum += s.duration_seconds || 15; return `${st}~${cum}s`; });
const shotImg = (s?: StoryboardShot | null) => frameUrl(s ? { asset: s.adopted_asset ?? "", asset_url: s.adopted_asset_url } : null);
const shotBusy = (s: StoryboardShot) => sbGenerating || sbBusyShots.has(s.id) || ["queued", "running"].includes(s.status);
const cardCount = sbShots.length || sbExpectedShots;
const activeBusy = sbActiveShot ? shotBusy(sbActiveShot) : sbGenerating;
const activeVers = [...(sbActiveShot?.versions ?? [])];
// 当前「查看」的版本(纯预览,默认=采用版);切换它只动本地预览,不动后端、不丢重跑
const sbViewedVer = activeVers.find((v) => v.id === sbViewVerId)
|| activeVers.find((v) => v.is_adopted || v.id === sbActiveShot?.adopted_version) || null;
const sbViewedIsAdopted = !sbViewedVer || sbViewedVer.is_adopted || sbViewedVer.id === sbActiveShot?.adopted_version;
const mainUrl = sbViewedVer ? (sbViewedVer.asset_url || assetUrl(sbViewedVer.asset)) : shotImg(sbActiveShot);
const mainAid = sbViewedVer?.asset || sbActiveShot?.adopted_asset || "";
return (
<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">
{sbShots.length ? sbShots.map((shot, idx) => {
const url = shotImg(shot);
const aid = shot.adopted_asset || "";
return (
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={shot.id} data-sid={shot.id} onClick={() => setSbSelected(idx)}>
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
<span className="ph-frame"> {idx + 1}</span>
{shotBusy(shot) && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{/* 失败帧:角标红字「失败」,hover 看友好提示;不再灰着没反应(点该场到右侧看完整原因+重跑) */}
{!shotBusy(shot) && shot.status === "failed" && (
<span title={shot.error_message || "生成失败,点开本场查看原因并重跑"} style={{ position: "absolute", left: 6, top: 6, display: "inline-flex", alignItems: "center", gap: 3, padding: "2px 6px", borderRadius: 6, background: "var(--err, #d33)", color: "#fff", fontSize: 11, fontWeight: 600, zIndex: 2 }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" 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>失败
</span>
)}
{aid && <span className="sb-frame-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[aid] || shot.review_status || "") as ReviewStatus} error={shot.review_error} onSubmit={() => void submitAssetReview(aid)} busy={reviewBusyId === aid} /></span>}
</div>
<div className="nm"> {idx + 1}</div>
<div className="sub">{sceneTimes[idx] || `#${shot.sort_order + 1}`}</div>
</div>
);
}) : sbExpectedShots ? (
/* 还没出图:按采用版脚本镜头数铺等量空占位,标「待生成」,让用户看出本该有几张 */
Array.from({ length: sbExpectedShots }, (_, idx) => (
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={`ph-${idx}`} onClick={() => setSbSelected(idx)}>
<div className="placeholder"><span className="ph-frame"> {idx + 1}</span>{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}</div>
<div className="nm"> {idx + 1}</div>
<div className="sub">// 待生成</div>
</div>
))
) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
</div>
{(() => {
const url = mainUrl;
const aid = mainAid;
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">{url ? `场 ${sbSelected + 1}${sbViewedIsAdopted ? "" : " · 预览历史版"}` : sbExpectedShots ? `场 ${sbSelected + 1} · 待生成` : "// 故事板未生成"}</span>
{activeBusy && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{aid && <span className="sb-main-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[aid] || sbViewedVer?.review_status || sbActiveShot?.review_status || "") as ReviewStatus} error={sbViewedVer?.review_error || sbActiveShot?.review_error} onSubmit={() => void submitAssetReview(aid)} busy={reviewBusyId === aid} /></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">{sbShots.length ? `场 ${sbSelected + 1}` : "—"}</span></strong>
<span className="spacer"></span>
{sbActiveShot
? (activeBusy
? <span className="pill neutral"><span className="dot"></span>生成中</span>
: sbActiveShot.status === "failed"
? <span className="pill bad"><span className="dot"></span>生成失败</span>
: sbActiveShot.adopted_version
? <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>
{/* 失败原因·友好提示(后端已把 yunqi 真因翻成中文,如内容审核拦截 → 提示改措辞) */}
{sbActiveShot?.status === "failed" && !activeBusy && sbActiveShot.error_message && (
<div style={{ display: "flex", gap: 6, alignItems: "flex-start", color: "var(--err, #d33)", fontSize: "12.5px", lineHeight: 1.55, margin: "2px 0 10px", padding: "8px 10px", background: "rgba(211,51,51,.07)", borderRadius: 8 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, marginTop: 1 }}><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>
<span>{sbActiveShot.error_message}</span>
</div>
)}
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>每个场一张分镜图 · 可单独「重跑本场」只重出这一张,每场各自留历史版本,互不影响。</div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 整张风格提示词(重跑时生效,可编辑)</div>
<PromptBox
className="prompt-edit"
id="sb-prompt-edit"
stop={false}
value={storyboardPrompt}
onChange={(v) => setStoryboardPrompt(v.trim())}
/>
<div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={sbAnyGenerating} onClick={() => guardGen(shots, () => { setSbGenerating(true); void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false)); })}>
<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>
{sbAnyImage ? "全部重跑" : "开始生成故事板"}
</button>
{sbActiveShot && (
<button className="btn btn-sm" type="button" disabled={activeBusy} title="只重出当前这一场(新增一条该场历史版本)" onClick={() => guardGen(shots.filter((s) => s.sort_order === sbActiveShot.sort_order), () => rerunStoryboardShotOptimistic(sbActiveShot.id))}>
{activeBusy ? <><span className="spinner btn-spin" aria-hidden="true" />生成中…</> : `↻ 重跑本场`}
</button>
)}
<span className="spacer"></span>
<span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>{pts(20)} 积分/</span>
</div>
<div className="sb-history">
<div className="sb-history-h">// 本场历史版本(<span id="sb-history-ct">{activeVers.length}</span>)· 点击预览</div>
<div className="sb-history-row" id="sb-history-row">
{activeVers.length ? activeVers.map((ver) => {
const cover = frameUrl({ asset: ver.asset ?? "", asset_url: ver.asset_url });
const isAdopted = ver.is_adopted || ver.id === sbActiveShot?.adopted_version;
const isViewed = sbViewedVer?.id === ver.id;
// 点击只切「预览」(本地态),不动后端 → 重跑在制时切版本不丢任务。采用要点下方按钮。
return (
<div className={`sb-history-thumb${isViewed ? " current" : ""}`} key={ver.id} data-vi={ver.id} role="button" tabIndex={0} title={isAdopted ? "当前采用版(点击预览)" : "点击预览此版本"} onClick={() => setSbViewVerId(ver.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewVerId(ver.id); } }}>
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{isAdopted ? "采用" : "历史"}</span></div>
<div className="ts">{formatShanghaiClock(ver.created_at)}</div>
</div>
);
}) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>// 本场暂无历史</span>}
</div>
{/* 预览的是非采用版 → 显式「采用此版本」(对标视频详情弹窗的采用按钮);采用不影响在制重跑 */}
{sbViewedVer && !sbViewedIsAdopted && sbActiveShot && (
<button className="btn btn-sm btn-primary" type="button" style={{ marginTop: "8px" }} onClick={() => void onAdoptStoryboardShotVersion?.(sbActiveShot.id, sbViewedVer.id)}>采用此版本</button>
)}
</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">
{(() => {
const bound = [
...groupsByKind("product").filter((g) => g.adopted_asset).map((g) => ({ id: g.id, name: (g.metadata?.label || "").trim() || KIND_LABEL.product, kind: "product" as const, url: groupMainUrl(g) })),
...buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "person" as const, url: groupMainUrl(e.group) })),
...buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "scene" as const, url: groupMainUrl(e.group) })),
];
return bound.length ? bound.map((b) => (
<span className="asset-tag" key={b.id}>{b.url ? <span className="asset-thumb" style={{ backgroundImage: `url(${b.url})` }} /> : <span className="dotc"></span>}{b.name}({KIND_LABEL[b.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 逐场输出 · {cardCount ? `${cardCount} 场` : "0 场"} · 单场可重跑,失败不扣 ]</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>
<div className="sb-confirm-wrap">
{sbConfirmHint && (
<div className="sb-confirm-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>
故事板还没出齐
</span>
<span className="pop-body">请先点上方 <b>开始生成故事板</b>,每场都出片后再确认进入视频生成。</span>
</div>
)}
<button className="btn btn-primary btn-lg" type="button" disabled={loading} onClick={() => { if (!sbAllDone) { setSbConfirmHint(true); return; } 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>
</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 约 15 秒 · {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={() => guardVideoGen(shots, null, submitAllVideosOptimistic)}>{loading ? <><span className="spinner btn-spin" aria-hidden="true" />提交中…</> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
{/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */}
<button className="btn btn-sm" type="button" disabled={exporting || segDone === 0} title={exportErr || "把所有已完成视频片段打包下载"} onClick={() => void exportAllVideos()}>
<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="M7 10l5 5 5-5" /><path d="M12 15V3" /></svg>
{exporting ? "导出中…" : exportErr ? "导出失败" : "导出全部"}
</button>
</div>
{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);
const pending = rerunPending.has(seg.id);
const showBusy = busy || pending;
const verCount = seg.versions?.length ?? 0;
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}#t=0.1`} 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 && !showBusy && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
{showBusy && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
</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 · {timeline?.resolution || "1080×1920"} · 按时长计量{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || showBusy} onClick={() => guardVideoGen(shots.filter((s) => s.sort_order === seg.sort_order), seg.id, () => submitVideoOptimistic(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds} 秒`))}>{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "重跑"}</button>
{/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */}
{verCount > 1 && (
<button className="video-ver-badge" type="button" data-vstop title="查看历史版本" onClick={() => openVideoDetail(seg.id)}> {verCount} </button>
)}
{/* AI 生成片段不需单卡上传(自定义替换走 queue-bar 全局上传);移除多余「上传」按钮 */}
<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>
{/* V1 雪藏拼接导出:隐藏「进入拼接」入口(代码保留,V2 恢复 false→true) */}
{false && (
<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 ? "重新生成配音" : `生成配音 · ${pts(10)} 积分/500字`}</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={50} max={200} 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 showBusy = busy || rerunPending.has(vdSeg.id);
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}#t=0.1`} 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>
<PromptBox
key={vdSeg.id}
className="vd-prompt-edit"
ariaLabel="视频提示词"
stop={false}
value={`${videoPrompt}${vdSeg.sort_order + 1} 段,时长 ${vdSeg.target_duration_seconds} 秒`}
onChange={(v) => setVdPrompt(v.trim())}
/>
</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 || showBusy} onClick={() => guardVideoGen(shots.filter((s) => s.sort_order === vdSeg.sort_order), vdSeg.id, () => submitVideoOptimistic(vdSeg.id, rerunPrompt))}>
{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "↻ 重跑本场"}
</button>
</div>
</div>
</div>
</div>
);
})()}
{/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
{refGate && (() => {
// 拆两类:noref=连参考图都没有;notri=有立绘但缺三视图。文案分别告诉用户「去哪里点什么」。
const noRef = refGate.missing.filter((m) => m.reason === "noref");
const noTri = refGate.missing.filter((m) => m.reason === "notri");
return (
<div className="ref-gate-mask" onClick={() => setRefGate(null)}>
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="rg-title">资产还没齐,先补一下?</div>
{noRef.length > 0 && (
<>
<div className="rg-body">
下面这些角色 / 场景<strong>还没生成参考图</strong>。请回「基础资产」页,点对应卡片进详情后<strong>「生成立绘 / 场景图」</strong>并采用,否则故事板会变成纯文生图、跟你的设定对不上(白花钱):
</div>
<div className="rg-list">
{noRef.map((m) => (
<span className="rg-chip" key={`noref:${m.type}:${m.name}`}>
<span className={`rg-kind rg-kind-${m.type === "character" ? "person" : "scene"}`}>{m.type === "character" ? "角色" : "场景"}</span>
{m.name}
</span>
))}
</div>
</>
)}
{noTri.length > 0 && (
<>
<div className="rg-body" style={{ marginTop: noRef.length > 0 ? 14 : 0 }}>
下面这些角色已有立绘,<strong>还没生成三视图</strong>。故事板 @图 合成要靠正 / / 背多角度锁人物,缺三视图角色容易跑形。请回「基础资产」页,点角色卡进详情后点<strong>AI 生成三视图」</strong>:
</div>
<div className="rg-list">
{noTri.map((m) => (
<span className="rg-chip" key={`notri:${m.type}:${m.name}`}>
<span className="rg-kind rg-kind-person">角色</span>
{m.name} <span className="rg-warn">缺三视图</span>
</span>
))}
</div>
</>
)}
<div className="rg-actions">
<button type="button" className="btn btn-primary" onClick={() => { setRefGate(null); goStage(2); }}>去基础资产页补齐</button>
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>仍要继续 <span className="rg-warn">可能跟角色对不上</span></button>
</div>
</div>
</div>
);
})()}
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
{reviewGate && (
<div className="ref-gate-mask" onClick={() => setReviewGate(null)}>
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="rg-title">这些素材还没过审,先过审再生成视频</div>
<div className="rg-body">
含真人脸的<strong>人物形象</strong><strong>故事板分镜</strong>必须先通过火山合规审核(<span style={{ color: "var(--ok, #16a34a)" }}>绿盾·已过审</span>),
否则视频生成会被火山以「疑似真人」拒绝。下面这些还没过审:
</div>
<div className="rg-list">
{reviewGate.blockers.map((b, i) => {
const st = b.review_status === "processing" ? "审核中" : b.review_status === "failed" ? "未过审(被驳回)" : "未送审";
return (
<span className="rg-chip" key={`${b.video_segment_id}:${b.kind}:${b.asset_id}:${i}`}>
<span className={`rg-kind rg-kind-${b.kind === "person" ? "person" : "scene"}`}>{b.scene_no} · {b.kind === "person" ? "人物" : "分镜"}</span>
{b.name} <span className="rg-warn">{st}</span>
</span>
);
})}
</div>
<div className="rg-actions">
<button
type="button"
className="btn btn-primary"
disabled={reviewGate.submitting}
onClick={async () => {
// 一键送审:对「未送审/被驳回」的资产提交审核(审核中的已在素材库、只需等待)。送审后留在视频阶段,
// 由本阶段轮询把盾刷绿(见下方 poll effect 已含 stage4),变绿后再点生成即可。
setReviewGate((g) => (g ? { ...g, submitting: true } : g));
const ids = [...new Set(reviewGate.blockers.filter((b) => b.review_status !== "processing" && b.asset_id).map((b) => b.asset_id))];
await Promise.all(ids.map((id) => api.submitAssetReview(id).then((r) => setReviews((m) => ({ ...m, [id]: r.review_status || "processing" }))).catch(() => undefined)));
if (ids.length) setReviewWake((n) => n + 1); // 唤醒审核轮询去盯刚送审的这些
setReviewGate(null);
onNotify?.("success", ids.length ? "已提交审核,请稍候待变绿(已过审)后再生成视频" : "素材正在审核中,请稍候待变绿后再生成");
}}
>{reviewGate.submitting ? "提交中…" : "一键送审"}</button>
<button type="button" className="btn btn-ghost" onClick={() => setReviewGate(null)}>知道了</button>
</div>
</div>
</div>
)}
{/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */}
{adDetail && (() => {
const entities = buildEntities(adDetail.kind);
const realEntity = entities.find((e) => e.key === adDetail.key);
// seed(还没生成组)也能开详情:用脚本名(=key)+ 当前提示词兜个「空壳」实体,进去先生成立绘。
// 生成完刷新 → buildEntities 按同 key(label=名字)收到真实组,自动切回真实体。
const entity: AssetEntity = realEntity ?? {
key: adDetail.key, label: adDetail.key, name: adDetail.key, kind: adDetail.kind,
group: { id: "", kind: adDetail.kind, prompt: adPrompt, adopted_asset: null, candidate_assets: [] },
versions: [],
};
const isSeed = !realEntity; // 空壳态:还没立绘
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);
// 已有三视图判定(ZWQ-row24):优先看 BaseGroup(流水线内生成的三视图组);其次看资产 metadata
// 沿用 library.tsx 同款判定:tri_view/triview/has_triview/three_view/tri_views 任一为真即已有;
// 模特库正面图(metadata.view==="frontal")本身与三视图同批生成,直接算已就绪。
const assetAlreadyHasTri: boolean = (() => {
if (triUrl) return true;
const m: Record<string, unknown> = (viewPortraitAsset ? byId.get(viewPortraitAsset)?.metadata : null) || {};
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
if (has === true || (Array.isArray(has) && has.length > 0) || (typeof has === "string" && (has as string).trim())) return true;
if (m.view === "frontal") return true; // 模特库正面图:generate_model 同批出了三视图
return false;
})();
const pBK = `addet-portrait:${entity.key}`;
// 没立绘时(尤其 seed 首次生成),立绘在 worker 跑 ~30s,本地 isBusy 早已落回 → 用 pendingHas 兜底转圈
const busyPortrait = isBusy(pBK) || (!portraitUrl && pendingHas(adDetail.kind, entity.name));
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
async function regenPortrait() {
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
// 重跑立绘 → 只追加新立绘候选并采用;三视图改手动(用「生成三视图」按钮),不再链式自动出。
// 角色重跑:若该角色已有当前立绘,传它作参考图 → 后端走 image_edit 参考立绘+提示词,保持同一人物一致(不重抽随机人)。
const ref = isPerson && viewPortraitAsset ? viewPortraitAsset : undefined;
await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK, ref);
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">
{/* 同三视图:大图随数据(portraitUrl)走,出图完成即显示,不被在途 busyPortrait 卡转圈 */}
<div className={`placeholder ad-lead-img${portraitUrl ? " has-mock-media" : ""}`} style={portraitUrl ? mediaStyle(portraitUrl) : undefined}>
{!portraitUrl && (busyPortrait
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">立绘生成中…</span></div>
: <span className="ph-frame">立绘</span>)}
</div>
{portraitUrl && <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);
const selectPortrait = () => { setAdPortraitId(assetId); setAdTriId(null); if (isPerson) void loadModelEnrollment(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={selectPortrait} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectPortrait(); } }} />;
})}
</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">
{/* 大图随数据(triUrl)走,不被在途 poll 的 busyTri 挡住:出图完成(缩略图已有 url)即显示,
避免「缩略图已出、大图还在转圈」——busyTri 只在「还没任何结果」时显示生成中占位 */}
<div className={`placeholder${triUrl ? " has-mock-media" : ""}`} style={triUrl ? mediaStyle(triUrl) : undefined}>
{!triUrl && (busyTri
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">三视图生成中…</span></div>
: <span className="ph-frame"> / / · 三视图</span>)}
</div>
{triUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
</div>
</div>
{!assetAlreadyHasTri && !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>{viewPortraitAsset ? "三视图据这版立绘生成,保证正/侧/背多角度一致。点下方按钮生成。" : "请先生成左侧「立绘」,有了立绘才能据它生成三视图。"}</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, justifyContent: "flex-end" }}>
<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 ? "生成中…" : (portraitVersions.length === 0 ? (isPerson ? "生成立绘" : "生成场景图") : (isPerson ? "重跑立绘" : "重跑"))}
</button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => {
if (adDetail.kind === "scene") { isSeed ? openSceneReplaceSeed(entity.name) : openSceneReplaceEntity(entity.group.id, entity.name); }
else { isSeed ? openModelReplaceSeed(adDetail.kind, entity.name) : openModelReplace(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>
{isPerson && <button
className="btn"
type="button"
disabled={loading || modelEnrollmentLoading || modelEnrollmentBusy || !viewPortraitAsset || enrolledModelAssets.has(viewPortraitAsset)}
title={enrolledModelAssets.has(viewPortraitAsset) ? "无需重复升级" : undefined}
onClick={async () => {
if (!viewPortraitAsset) return;
setModelEnrollmentBusy(true);
try {
await api.enrollModelFromAsset(viewPortraitAsset, entity.name);
setEnrolledModelAssets((known) => new Set(known).add(viewPortraitAsset));
onNotify?.("success", "已升级为模特,可在模特库中复用");
} catch (error) {
onNotify?.("error", error instanceof Error && error.message ? error.message : "升级为模特失败,请重试");
} finally {
setModelEnrollmentBusy(false);
}
}}
>{modelEnrollmentBusy ? "升级中…" : modelEnrollmentLoading ? "检查中…" : enrolledModelAssets.has(viewPortraitAsset) ? "该角色形象已在模特库" : "升级为模特"}</button>}
<button className="btn btn-primary" type="button" disabled={loading || !viewPortraitAsset} onClick={async () => {
// 应用当前角色 = 采用 + 自动送审:不再要用户应用完还得手动点灰盾送审(ZWQ#7)。
// 立绘(person)/三视图(tri_view)都属送审类;送审异步(processing→绿盾),失败由 submitAssetReview 自行提示。
if (viewPortraitAsset) {
await onAdoptBaseAsset(grp.id, viewPortraitAsset);
if (assetReview(viewPortraitAsset) !== "active") void submitAssetReview(viewPortraitAsset);
}
if (triGroup && viewTriAsset) {
await onAdoptBaseAsset(triGroup.id, viewTriAsset);
if (assetReview(viewTriAsset) !== "active") void submitAssetReview(viewTriAsset);
}
setAdDetail(null);
}}>应用当前角色</button>
</div>
</div>
</div>
);
})()}
{/* 问题5(a):用已有素材作三视图——选商品已有图 / 上传本地图,attach 成商品采用版(不走平台 AI 生成、不计费) */}
{assetPick && (
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label={assetPick.title} onClick={(e) => { if (e.target === e.currentTarget && !assetPickBusy) setAssetPick(null); }}>
<div className="asset-modal">
<div className="asset-modal-h">
<h2>{assetPick.title}</h2>
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>// 选已有图或上传本地图,点一下直接采用</span>
<button className="x" type="button" aria-label="关闭" disabled={assetPickBusy} onClick={() => setAssetPick(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">
<input ref={pickFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadPickedFromFile(f); e.target.value = ""; }} />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: "12px" }}>
{/* 上传本地图 tile */}
<div className="placeholder" role="button" tabIndex={0} aria-disabled={assetPickBusy} title="上传本地图片" style={{ aspectRatio: "16/9", display: "grid", placeItems: "center", cursor: assetPickBusy ? "default" : "pointer" }} onClick={() => { if (!assetPickBusy) pickFileRef.current?.click(); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !assetPickBusy) { e.preventDefault(); pickFileRef.current?.click(); } }}>
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-48)" }}>{assetPickBusy ? "处理中…" : "+ 上传本地图"}</span>
</div>
{/* 已有素材:当前页(点击直接采用) */}
{assetPickPageList.map((a) => {
const u = pickPreview(a);
return (
<div key={a.id} className={`placeholder${u ? " has-mock-media is-zoomable" : ""}`} role="button" tabIndex={0} title={a.name || "点击采用"} aria-disabled={assetPickBusy} style={{ aspectRatio: "16/9", cursor: assetPickBusy ? "default" : "pointer", ...(u ? mediaStyle(u) : {}) }} onClick={() => { if (!assetPickBusy) void attachPickedAsset(a.id); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !assetPickBusy) { e.preventDefault(); void attachPickedAsset(a.id); } }}>
{!u && <span className="mono" style={{ fontSize: "11px", color: "var(--black-alpha-48)" }}>// 无预览</span>}
</div>
);
})}
</div>
{assetPickLoading
? <div className="mono" style={{ marginTop: "10px", fontSize: "12px", color: "var(--black-alpha-48)" }}>// 加载素材…</div>
: assetPickFiltered.length === 0 && (
<div className="mono" style={{ marginTop: "10px", fontSize: "12px", color: "var(--black-alpha-48)" }}>// 暂无可选素材,可直接上传本地图</div>
)}
{/* 分页:与模特素材库一致(上一页 / X·N 共 / 下一页) */}
{assetPickPageCount > 1 && (
<div className="hstack" style={{ marginTop: 14, justifyContent: "center", gap: 12 }}>
<button className="btn btn-ghost btn-sm" type="button" disabled={assetPickPage <= 1} onClick={() => setAssetPickPage((p) => Math.max(1, p - 1))}>上一页</button>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{assetPickPage} / {assetPickPageCount} · {assetPickFiltered.length}</span>
<button className="btn btn-ghost btn-sm" type="button" disabled={assetPickPage >= assetPickPageCount} onClick={() => setAssetPickPage((p) => Math.min(assetPickPageCount, p + 1))}>下一页</button>
</div>
)}
</div>
</div>
</div>
)}
{/* 流程步骤4 · 模特库:浏览 / 添加模特(AI 生成·本地上传)/ 替换回填 */}
<ModelLibrary
open={Boolean(modelLib)}
mode={modelLib?.mode || "browse"}
initialStudio={modelLib?.studio}
onClose={() => setModelLib(null)}
onPick={pickModel}
onGenerate={onGenerateModel}
onUpload={onUploadModel}
onRename={onRenameModel}
onRefresh={() => void onRefreshProject()}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
);
}