4301 lines
296 KiB
TypeScript
4301 lines
296 KiB
TypeScript
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}`;
|
||
}
|
||
// 毫秒 → 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 }: { steps?: StepNode[]; reasoning?: string; stream?: string; done?: boolean; startedAt?: number }) {
|
||
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 elapsed = startedAt ? Math.max(0, Math.round((now - startedAt) / 1000)) : 0;
|
||
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 ? `已完成思考 · 用时 ${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;
|
||
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,
|
||
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; });
|
||
// 审核轮询的「重启信号」:手动送审/一键送审后 +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
|
||
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; auto?: boolean };
|
||
const nowHm = () => new Date().toTimeString().slice(0, 5);
|
||
const msgIdRef = useRef(1);
|
||
const nextMsgId = () => msgIdRef.current++;
|
||
// 脚本助手记录按项目持久化:退出项目→重进仍能看到历史对话(行36)
|
||
const chatKey = `airshelf:pipeline:chat:${project.id}`;
|
||
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>(() => {
|
||
try {
|
||
const raw = localStorage.getItem(chatKey);
|
||
if (raw) {
|
||
const saved = JSON.parse(raw) as ChatMsg[];
|
||
if (Array.isArray(saved) && saved.length) {
|
||
msgIdRef.current = Math.max(...saved.map((m) => m.id || 0)) + 1;
|
||
return saved;
|
||
}
|
||
}
|
||
} catch { /* 解析失败则回退默认 */ }
|
||
return currentScript
|
||
? [{ id: nextMsgId(), role: "ai", auto: true, text: `当前已有 ${currentScript.segments?.length ?? 0} 镜脚本(${currentScript.is_adopted ? "已采用" : "待采用"})。直接输入修改意见可整体重写。`, time: nowHm() }]
|
||
: [];
|
||
});
|
||
// 落盘:auto 派生提示(「已有N镜」)不落盘——它跟当前脚本状态绑定,持久化会在切项目时串台;
|
||
// 未完成的 progress 流也不落盘(临时态);只保留最近 60 条真实对话
|
||
useEffect(() => {
|
||
try {
|
||
const slim = chatMsgs.filter((m) => !m.auto && (m.kind !== "progress" || m.done)).slice(-60);
|
||
localStorage.setItem(chatKey, JSON.stringify(slim));
|
||
} catch { /* localStorage 不可用则忽略 */ }
|
||
}, [chatKey, chatMsgs]);
|
||
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
|
||
// 脚本模型下拉:用户可选 豆包/GPT-5.5/Gemini(空 = 用后端默认文本模型)
|
||
const [scriptModelId, setScriptModelId] = useState<string>("");
|
||
const activeScriptModelId = scriptModelId || textModels?.[0]?.id || "";
|
||
// 模型选择小按钮(输入框下方)· 自建 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 }) };
|
||
return { ...m, steps };
|
||
}));
|
||
} 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 } : 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 } : m)));
|
||
if (ok) {
|
||
await onRefreshProject();
|
||
// 模型写了收尾就用它(真 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 prevPendingCountRef = useRef(0);
|
||
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 || [];
|
||
if (list.length < prevPendingCountRef.current) void onRefreshProjectRef.current();
|
||
prevPendingCountRef.current = list.length;
|
||
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} />
|
||
</>
|
||
: <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>
|
||
{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(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">{(ver.created_at || "").slice(11, 16) || "--:--"}</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>
|
||
);
|
||
}
|