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
+30 -13
View File
@@ -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), "已删除")}