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:
+30
-13
@@ -357,6 +357,25 @@ export function App() {
|
||||
}
|
||||
}, [activeProjectId]);
|
||||
|
||||
// 静默轮询故事板分镜生成(对标 pollVideosQuiet):有 queued/running 的场就驱动后端出图并刷新,不占全局 loading、不弹 toast。
|
||||
// 这样单场重跑不会把别的场按钮也锁住——生成在后台跑,UI 只按每场自身状态转圈。
|
||||
const pollStoryboardQuiet = useCallback(async () => {
|
||||
if (!activeProjectId) return;
|
||||
let detail = projectDetailRef.current;
|
||||
if (!detail || detail.id !== activeProjectId) {
|
||||
detail = await api.project(activeProjectId).catch(() => null);
|
||||
if (!detail) return;
|
||||
setProjectDetail(detail);
|
||||
}
|
||||
const active = (detail.storyboard_shots ?? []).filter((s) => ["queued", "running"].includes(s.status));
|
||||
if (active.length === 0) return;
|
||||
await api.pollStoryboard(activeProjectId).catch(() => undefined);
|
||||
const next = await api.project(activeProjectId).catch(() => null);
|
||||
if (next && next.id === activeProjectIdRef.current) {
|
||||
setProjectDetail((prev) => (prev && JSON.stringify(prev) === JSON.stringify(next) ? prev : next));
|
||||
}
|
||||
}, [activeProjectId]);
|
||||
|
||||
// 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。
|
||||
const refreshExport = useCallback(async () => {
|
||||
if (!activeProjectId) return;
|
||||
@@ -866,7 +885,9 @@ export function App() {
|
||||
notice={notice}
|
||||
unreadCount={unreadCount}
|
||||
avatarChar={avatarChar}
|
||||
logout={logout} onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
|
||||
logout={logout}
|
||||
onNotify={(type, text) => setNotice({ type, text })}
|
||||
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
|
||||
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新", { liteRefresh: true })}
|
||||
onAddShot={(afterSegmentId, content) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId, ...content }), "分镜已添加", { liteRefresh: true })}
|
||||
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除", { liteRefresh: true })}
|
||||
@@ -883,19 +904,15 @@ export function App() {
|
||||
return assetId ? { adopted_asset: assetId } : null;
|
||||
}}
|
||||
onGenerateStoryboard={(prompt) =>
|
||||
action(async () => {
|
||||
// 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待
|
||||
await api.generateStoryboard(pipelineProject.id, { prompt });
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
const res = await api.pollStoryboard(pipelineProject.id);
|
||||
if (res.status === "succeeded") return true;
|
||||
if (res.status === "failed") throw new Error(res.error || "故事板生成失败,请重试");
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
}
|
||||
// 轮询窗口耗尽仍未完成:如实报超时,不能让 action 弹「已生成」的假成功 toast
|
||||
throw new Error("故事板生成超时,请稍后刷新查看或重试");
|
||||
}, "故事板已生成")
|
||||
// 只「提交」(秒回);出图由后台 pollStoryboardQuiet 驱动 —— 不占全局 loading,不锁其它场按钮(对标视频「开始生成」)
|
||||
action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已开始生成")
|
||||
}
|
||||
onRerunStoryboardShot={(shotId, prompt) =>
|
||||
// 单场重跑也只「提交」(秒回);后台轮询出图。不阻塞 → 可同时重跑别的场
|
||||
action(() => api.rerunStoryboardShot(pipelineProject.id, shotId, prompt), "本场已开始重跑")
|
||||
}
|
||||
onAdoptStoryboardShotVersion={(shotId, versionId) => action(() => api.adoptStoryboardShotVersion(pipelineProject.id, shotId, versionId), "已采用该版本")}
|
||||
onPollStoryboardQuiet={pollStoryboardQuiet}
|
||||
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
|
||||
onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")}
|
||||
onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")}
|
||||
|
||||
@@ -405,9 +405,24 @@ export const api = {
|
||||
skipStoryboard(projectId: string) {
|
||||
return request<Project>(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" });
|
||||
},
|
||||
// 单场重跑:只重出该 shot 的分镜图(新增一条历史版本并采用)
|
||||
rerunStoryboardShot(projectId: string, shotId: string, prompt?: string) {
|
||||
return request(`/api/projects/${projectId}/rerun-storyboard-shot/`, { method: "POST", body: JSON.stringify({ shot_id: shotId, prompt: prompt || "" }) });
|
||||
},
|
||||
// 采用某场的某个历史版本(切换该场分镜图)
|
||||
adoptStoryboardShotVersion(projectId: string, shotId: string, versionId: string) {
|
||||
return request<Project>(`/api/projects/${projectId}/adopt-storyboard-shot-version/`, { method: "POST", body: JSON.stringify({ shot_id: shotId, version_id: versionId }) });
|
||||
},
|
||||
submitVideo(projectId: string, payload: { video_segment_id: string; prompt: string }) {
|
||||
return request<Project>(`/api/projects/${projectId}/submit-video-segment/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
// 点生成视频前的过审闸:列出未过审的人物立绘/故事板分镜。video_segment_id 给定=只校验该段,不给=全部未出片段。
|
||||
videoReviewPrecheck(projectId: string, videoSegmentId?: string) {
|
||||
return request<{ blockers: Array<{ video_segment_id: string; sort_order: number; scene_no: number; kind: "person" | "storyboard"; name: string; asset_id: string; review_status: string }> }>(
|
||||
`/api/projects/${projectId}/video-review-precheck/`,
|
||||
{ method: "POST", body: JSON.stringify(videoSegmentId ? { video_segment_id: videoSegmentId } : {}) },
|
||||
);
|
||||
},
|
||||
pollVideo(projectId: string, video_segment_id: string) {
|
||||
return request(`/api/projects/${projectId}/poll-video-segment/`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -336,6 +336,33 @@ export type StoryboardVersion = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
// 故事板分镜制(对标 VideoSegment/Version):每镜一个 shot,各自历史版本 + 采用版
|
||||
export type StoryboardShotVersion = {
|
||||
id: string;
|
||||
asset: string | null;
|
||||
asset_url: string;
|
||||
prompt: string;
|
||||
is_adopted: boolean;
|
||||
review_status?: string;
|
||||
review_error?: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type StoryboardShot = {
|
||||
id: string;
|
||||
script_segment: string | null;
|
||||
sort_order: number;
|
||||
status: string;
|
||||
error_message: string;
|
||||
prompt: string;
|
||||
adopted_version: string | null;
|
||||
adopted_asset?: string | null;
|
||||
adopted_asset_url?: string;
|
||||
review_status?: string;
|
||||
review_error?: string;
|
||||
versions?: StoryboardShotVersion[];
|
||||
};
|
||||
|
||||
export type ExportPoll = {
|
||||
status: string;
|
||||
progress: number;
|
||||
@@ -414,6 +441,7 @@ export type Project = {
|
||||
created_at?: string;
|
||||
}>;
|
||||
storyboard_versions: StoryboardVersion[];
|
||||
storyboard_shots: StoryboardShot[];
|
||||
timeline: Timeline | null;
|
||||
metadata?: {
|
||||
wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] };
|
||||
|
||||
Reference in New Issue
Block a user