feat(storyboard): 故事板改分镜制(单场独立重跑/历史)+ 视频过审闸 + 审核前端如实化

三块改动在 services/views/pipeline/App/api 等文件按行交织、且过审闸后端依赖故事板新
模型,无法拆成各自独立可编译的提交,故合并为一次:

· 故事板分镜制:新增 StoryboardShot/StoryboardShotVersion(对标 VideoSegment/Version)
  + 数据迁移(采用版每帧转一 shot+已采用版本)。每镜一个 shot,各自历史版本,可单场
  重跑、逐场切换/采用,新增「开始生成故事板/单场重跑/采用某版」端点。重跑非阻塞(后台
  静默轮询出图,不锁其它场按钮);历史「查看」与「采用」分离(切版本不丢在制重跑)。
  视频取分镜图/删分镜清理/过审闸全部改读 storyboard_shots。
· 视频生成前过审闸:点生成校验含真人脸的人物立绘/故事板分镜是否过审,未过审弹窗列出
  哪一镜/哪个,可一键送审,挡住不生成(collect_video_review_blockers + precheck 端点)。
· 审核前端如实化:不再 `|| "processing"` 伪造审核中;送审失败经 onNotify 如实提示;
  审核轮询扩到视频阶段,送审后能看着盾变绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-24 17:11:55 +08:00
co-authored by Claude Opus 4.8
parent d6cb280018
commit 658aaa222f
12 changed files with 936 additions and 243 deletions
+197 -80
View File
@@ -2,7 +2,7 @@ import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useR
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, Team, TimelineSavePayload, User } from "../types";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
@@ -457,6 +457,7 @@ export function PipelinePage(props: {
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>;
@@ -482,6 +483,10 @@ export function PipelinePage(props: {
// 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产
onRenameActor?: (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>;
@@ -495,9 +500,9 @@ export function PipelinePage(props: {
onSubmitExport: (payload?: TimelineSavePayload) => void;
}) {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
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, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
@@ -772,6 +777,24 @@ export function PipelinePage(props: {
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 {
@@ -834,20 +857,47 @@ export function PipelinePage(props: {
}, [adDetail]);
// 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。
// ── Stage 3:默认展示已采用(is_adopted)版本;点历史缩略图可切换查看任一版本 ──
const storyboards = project.storyboard_versions ?? [];
const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null;
const [sbViewId, setSbViewId] = useState<string | null>(null);
const displayedStoryboard = storyboards.find((s) => s.id === sbViewId) || adoptedStoryboard;
const sbFrames = [...(displayedStoryboard?.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order);
// ── Stage 3:分镜制(对标视频段)—— 每镜一个 StoryboardShot,各自采用版图 + 历史版本,可单场重跑/切版 ──
const sbShots = [...(project.storyboard_shots ?? [])].sort((a, b) => a.sort_order - b.sort_order);
const [sbSelected, setSbSelected] = useState(0);
const sbActiveFrame = sbFrames[Math.min(sbSelected, Math.max(0, sbFrames.length - 1))] || null;
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(样式同基础资产趴)
// 整张/全部生成中:不全屏遮挡,只在每张分镜占位上转个 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(() => {
@@ -942,14 +992,19 @@ export function PipelinePage(props: {
setReviewBusyId(id);
try {
const r = await api.submitAssetReview(id);
setReviews((m) => ({ ...m, [id]: r.review_status || "processing" }));
} catch { /* 失败保持灰盾可再点 */ } finally {
// 用后端真态,别再 `|| "processing"` 伪造审核中 —— 没真送出去就该回灰盾,而不是骗一个刷新即消失的「审核中」
setReviews((m) => ({ ...m, [id]: r.review_status || "" }));
} catch (e) {
// 送审没成(503=审核服务不可用等):如实提示,灰盾保持可重试,不再静默吞掉
onNotify?.("error", e instanceof Error && e.message ? e.message : "审核服务暂不可用,请稍后重试");
} finally {
setReviewBusyId(null);
}
}
// 在基础资产趴(stage 2)/ 故事板趴(stage 3)定时轮询审核状态,刷新徽章(processing → active绿 / failed红)
// 在基础资产趴(stage 2)/ 故事板趴(stage 3)/ 视频趴(stage 4)定时轮询审核状态,刷新徽章
// (processing → active绿 / failed红)。stage4 也轮询:过审闸里「一键送审」后,要在视频阶段就能看到盾变绿再生成。
useEffect(() => {
if (viewStage !== 2 && viewStage !== 3) return;
if (viewStage !== 2 && viewStage !== 3 && viewStage !== 4) return;
let alive = true;
let sawProcessing = false;
let timer = 0;
@@ -969,6 +1024,14 @@ export function PipelinePage(props: {
timer = window.setInterval(tick, 8000);
return () => { alive = false; window.clearInterval(timer); };
}, [viewStage, project.id]);
// 故事板有在制场(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() {
@@ -1359,12 +1422,9 @@ export function PipelinePage(props: {
};
}, [gutterDragging]);
const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成可直接指导视频的分镜图";
const [storyboardPrompt, setStoryboardPrompt] = useState(SB_PROMPT_DEFAULT);
// 切换查看的故事板版本时,提示词编辑框重新种子为该版本的提示词
useEffect(() => {
setStoryboardPrompt(displayedStoryboard?.prompt || SB_PROMPT_DEFAULT);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedStoryboard?.id]);
// 整张风格提示词:项目级(原 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));
@@ -2970,30 +3030,41 @@ export function PipelinePage(props: {
);
})()}
{/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */}
{viewStage === 3 && (
{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">
{sbFrames.length ? (() => {
// 每场显示时间区间(累加脚本镜时长 → 「0~5s」),对齐 HTML;无脚本时回退序号
let cum = 0;
const times = shots.map((s) => { const st = cum; cum += s.duration_seconds || 15; return `${st}~${cum}s`; });
return sbFrames.map((frame, idx) => {
const url = frameUrl(frame);
return (
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}>
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
<span className="ph-frame"> {idx + 1}</span>
{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{frame.asset && <span className="sb-frame-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[frame.asset] || frame.review_status || "") as ReviewStatus} error={frame.review_error} onSubmit={() => void submitAssetReview(frame.asset)} busy={reviewBusyId === frame.asset} /></span>}
</div>
<div className="nm"> {idx + 1}</div>
<div className="sub">{times[idx] || `#${frame.sort_order + 1}`}</div>
{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>}
{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>
);
});
})() : sbExpectedShots ? (
<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)}>
@@ -3005,12 +3076,13 @@ export function PipelinePage(props: {
) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
</div>
{(() => {
const url = frameUrl(sbActiveFrame);
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">{sbActiveFrame ? `${sbSelected + 1}` : sbExpectedShots ? `${sbSelected + 1} · 待生成` : "// 故事板未生成"}</span>
{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{sbActiveFrame?.asset && <span className="sb-main-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[sbActiveFrame.asset] || sbActiveFrame.review_status || "") as ReviewStatus} error={sbActiveFrame.review_error} onSubmit={() => void submitAssetReview(sbActiveFrame.asset)} busy={reviewBusyId === sbActiveFrame.asset} /></span>}
<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>
);
})()}
@@ -3019,65 +3091,70 @@ export function PipelinePage(props: {
<div className="sb-side">
<div className="pane" style={{ padding: "18px" }}>
<div className="hstack" style={{ marginBottom: "10px" }}>
<strong style={{ fontSize: "14px" }}> · <span id="sb-side-scene">{sbActiveFrame ? `${sbSelected + 1}` : "—"}</span></strong>
<strong style={{ fontSize: "14px" }}> · <span id="sb-side-scene">{sbShots.length ? `${sbSelected + 1}` : "—"}</span></strong>
<span className="spacer"></span>
{displayedStoryboard
? (displayedStoryboard.is_adopted
? <span className="pill ok"><span className="dot"></span></span>
: <span className="pill neutral"><span className="dot"></span></span>)
{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>
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}> · image-2 (/)</div>
<div className="sb-rerun-note">
<span className="warn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4M12 17h.01" /></svg>
</span>
<div className="note-copy"><strong></strong> · , <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 </a> ,</div>
</div>
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 整张提示词(重跑时生效,可编辑)</div>
{/* key 跟版本走:切版本重新种子;onChange 实时回写 state → 整张重跑用的就是你编辑后的文本 */}
<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
key={displayedStoryboard?.id || "no-version"}
className="prompt-edit"
id="sb-prompt-edit"
stop={false}
value={displayedStoryboard?.prompt || SB_PROMPT_DEFAULT}
value={storyboardPrompt}
onChange={(v) => setStoryboardPrompt(v.trim())}
/>
<div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => guardGen(shots, () => { setSbGenerating(true); void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false)); })}>
<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>
{adoptedStoryboard ? "整张重跑" : "生成故事板"}
{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" }}>~¥0.45/</span>
</div>
<div className="sb-history">
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">{storyboards.length}</span>)</div>
<div className="sb-history-h">// 本场历史版本(<span id="sb-history-ct">{activeVers.length}</span>)· 点击预览</div>
<div className="sb-history-row" id="sb-history-row">
{storyboards.length ? storyboards.map((ver) => {
const cover = frameUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]);
{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${ver.id === displayedStoryboard?.id ? " current" : ""}`} key={ver.id} data-vi={ver.id} role="button" tabIndex={0} title="点击查看该版本" onClick={() => { setSbViewId(ver.id); setSbSelected(0); }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewId(ver.id); setSbSelected(0); } }}>
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{ver.is_adopted ? "采用" : "历史"}</span></div>
<div className={`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>}
}) : <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">
{(() => {
// 与 Stage 2 同源:用 metadata.label 取名(不依赖全局 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>;
@@ -3088,26 +3165,26 @@ export function PipelinePage(props: {
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ image-2 · {sbFrames.length ? `${sbFrames.length}` : sbExpectedShots ? `待生成 ${sbExpectedShots}` : "0 场"} · , ]</span></div>
<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>
<span className="pop-body"> <b></b>,</span>
</div>
)}
<button className="btn btn-primary btn-lg" type="button" disabled={loading} onClick={() => { if (!adoptedStoryboard) { 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>
<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;
@@ -3128,7 +3205,7 @@ export function PipelinePage(props: {
</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={() => guardGen(shots, submitAllVideosOptimistic)}>{loading ? <><span className="spinner btn-spin" aria-hidden="true" /></> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<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>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
@@ -3169,7 +3246,7 @@ export function PipelinePage(props: {
</div>
<div className="video-meta">{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · ~¥0.45{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={() => guardGen(shots.filter((s) => s.sort_order === seg.sort_order), () => submitVideoOptimistic(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`))}>{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "重跑"}</button>
<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>
@@ -3641,7 +3718,7 @@ export function PipelinePage(props: {
<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={() => guardGen(shots.filter((s) => s.sort_order === vdSeg.sort_order), () => submitVideoOptimistic(vdSeg.id, rerunPrompt))}>
<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>
@@ -3673,6 +3750,46 @@ export function PipelinePage(props: {
</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)));
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);