diff --git a/api/main.py b/api/main.py
index 76f7481..d907a9f 100644
--- a/api/main.py
+++ b/api/main.py
@@ -347,6 +347,7 @@ class GeneratedVideo(BaseModel):
id: str
provider_id: str = ""
frame_idx: int
+ storyboard_row_idx: int | None = None
prompt: str
model: str = ""
status: Literal["queued", "in_progress", "completed", "failed"] = "queued"
@@ -376,6 +377,7 @@ class StoryboardScene(BaseModel):
visual_mode: Literal["person_only", "person_product", "product_only", "environment"] = "person_product"
needs_product: bool = True
needs_subject: bool = True
+ storyboard_row_idx: int | None = None
subject_brief: str = ""
skg_copy_en: str = ""
skg_copy_zh: str = ""
@@ -5609,6 +5611,7 @@ class UpdateStoryboardReq(BaseModel):
visual_mode: Literal["person_only", "person_product", "product_only", "environment"] = "person_product"
needs_product: bool = True
needs_subject: bool = True
+ storyboard_row_idx: int | None = None
subject_brief: str = ""
skg_copy_en: str = ""
skg_copy_zh: str = ""
@@ -5637,6 +5640,7 @@ class GenerateStoryboardVideoReq(BaseModel):
duration: float = 4
count: int = 1
seed: int | None = None
+ storyboard_row_idx: int | None = None
first_image: dict | None = None
last_image: dict | None = None
product_images: list[dict] = Field(default_factory=list)
@@ -6203,6 +6207,7 @@ def _enqueue_storyboard_videos(job: Job, frame: KeyFrame, req: GenerateStoryboar
id=local_id,
provider_id="",
frame_idx=frame.index,
+ storyboard_row_idx=req.storyboard_row_idx,
prompt=variant_prompt,
model=model,
status="queued",
@@ -7275,6 +7280,7 @@ def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
visual_mode=req.visual_mode,
needs_product=bool(req.needs_product),
needs_subject=bool(req.needs_subject),
+ storyboard_row_idx=req.storyboard_row_idx,
subject_brief=req.subject_brief.strip(),
skg_copy_en=req.skg_copy_en.strip(),
skg_copy_zh=req.skg_copy_zh.strip(),
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index a8156db..fe3d46c 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -997,11 +997,11 @@ ProductRefStateItem {
| 角色图入库到 job | POST /jobs/{id}/assets/character-library | copyCharacterLibraryAssets | 把所选角色的 7 张参考图复制为当前 job asset,返回 subject_images,产品融合生成视频时作为人物身份参考图提交。 |
| 产品融合引导图 | POST /jobs/{id}/product-fusion/guide | createProductFusionGuide | 旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。 |
| 产品融合描述词 | POST /jobs/{id}/product-fusion/descriptions | generateProductFusionDescriptions | 兼容接口:可生成产品融合动作描述库。当前前端默认直接用本地 36 条镜头语言模板预填 6 行镜头,并通过“换一组”按钮按 6 条一组轮换。 |
- | 分镜保存 | PUT /frames/{idx}/storyboard | updateStoryboard | 保存三字段中英镜像、选中视频 ID、4 图槽、时长、改造说明,以及高级抽屉里的镜头类型、人物描述、人物/产品开关、首帧规划、尾帧规划和产品出现方式。 |
+ | 分镜保存 | PUT /frames/{idx}/storyboard | updateStoryboard | 保存三字段中英镜像、选中视频 ID、4 图槽、时长、改造说明,以及高级抽屉里的镜头类型、人物描述、人物/产品开关、首帧规划、尾帧规划和产品出现方式。当前音频分镜行会额外写 storyboard_row_idx,避免多条分镜共用同一参考帧时互相覆盖。 |
| 三字段自动展开 | POST /jobs/{job_id}/frames/{idx}/storyboard/quick-plan | quickPlanStoryboard | 输入 skg_copy_*、scene_one_line_*、action_one_line_* 和 subject_brief,用 REWRITE_MODEL 展开为完整 StoryboardScene,只作为视频 prompt 来源,不直接持久化。 |
| AI 改文案 | POST /jobs/{job_id}/frames/{idx}/storyboard/refine | refineStoryboard | 输入当前三字段和中文反馈,返回新的三字段中英镜像。前端必须先弹改前/改后预览,用户点应用后才写入行状态。 |
- | 单条视频候选生成 | POST /jobs/{job_id}/frames/{idx}/storyboard/video | generateStoryboardVideo | 新增 count 和 seed,默认一次创建 4 个 GeneratedVideo 任务并立即返回 job;每个候选独立排队、生成、失败或成功。前端提交 prompt 前用 quick-plan 展开,高级首尾帧存在时继续带上,不存在时后端用参考帧/主体图/产品图透明兜底。 |
- | 整片一键生成候选 | POST /jobs/{job_id}/storyboard/batch-generate-all | batchGenerateAll | 输入 count_per_row=4、concurrency=4,后台遍历分镜并为每行提交 4 个视频候选;job message 用轮询展示进度。单行失败只写 job error,不阻断其他行。 |
+ | 单条视频候选生成 | POST /jobs/{job_id}/frames/{idx}/storyboard/video | generateStoryboardVideo | 新增 count、seed 和 storyboard_row_idx,默认一次创建 4 个 GeneratedVideo 任务并立即返回 job;每个候选独立排队、生成、失败或成功。前端提交 prompt 前用 quick-plan 展开,高级首尾帧存在时继续带上,不存在时后端用参考帧/主体图/产品图透明兜底。视频候选显示必须优先按 storyboard_row_idx 归属到音频分镜行,而不是只按 frame_idx。 |
+ | 整片一键生成候选 | POST /jobs/{job_id}/storyboard/batch-generate-all | 当前主路径改为逐行调用 generateStoryboardVideo | 用户选择“每行 N 条”后,前端按音频分镜逐行提交,确保每个候选都带 storyboard_row_idx。后端批量接口保留为兼容能力,默认 concurrency=1,但当前 UI 不再用它做主路径。 |
| 生图 | POST /frames/{idx}/generate | generateImage | 基于关键帧或已选生成图做 image-to-image,目前可用。 |
@@ -1108,6 +1108,19 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-19 · 视频候选按音频分镜行隔离
+ API
+ Video
+ Storyboard
+
+
+
问题:多条音频分镜会映射到同一张参考帧,旧视频候选只按 frame_idx 过滤,导致第一行生成的视频也出现在后面共用参考帧的分镜行里。
+
改动:StoryboardScene、GeneratedVideo 和 /storyboard/video 请求增加 storyboard_row_idx;前端显示候选和读取已保存分镜时优先按该行号隔离,旧无行号候选只归到同参考帧的第一条兼容行。
+
影响:点击某一行生成视频后,只会在该音频分镜行右侧出现候选;整片生成也改为逐行提交,避免候选跨行串位。
+
+
2026-05-19 · 分镜行改成左文字右视频轨
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index 847910e..a366991 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -34,7 +34,6 @@ import {
analyzeJob,
analyzeProductViews,
apiAssetUrl,
- batchGenerateAll,
characterLibraryImageUrl,
createAssetLibraryItem,
createPromptLibraryItem,
@@ -1422,6 +1421,12 @@ function savedScenePatch(scene?: StoryboardScene | null): RowPlanPatch {
}
}
+function storyboardSceneBelongsToRow(scene: StoryboardScene | null | undefined, rowIndex: number, legacyRowIndex?: number | null) {
+ if (!scene) return false
+ if (typeof scene.storyboard_row_idx === "number") return scene.storyboard_row_idx === rowIndex
+ return legacyRowIndex != null && legacyRowIndex === rowIndex
+}
+
function applyPlanPatch(row: AudioStoryboardRow, patch?: RowPlanPatch): AudioStoryboardRow {
if (!patch) return row
return {
@@ -1726,6 +1731,7 @@ function buildStoryboardSceneFromAudioRow(
visual_mode: row.visualMode,
needs_product: row.needsProduct,
needs_subject: row.needsSubject,
+ storyboard_row_idx: row.index,
subject_brief: row.needsSubject ? subjectBrief : "",
skg_copy_en: row.skgCopy,
skg_copy_zh: row.skgCopyZh,
@@ -1733,7 +1739,7 @@ function buildStoryboardSceneFromAudioRow(
scene_one_line_zh: row.sceneOneLineZh,
action_one_line_en: row.actionOneLine,
action_one_line_zh: row.actionOneLineZh,
- selected_video_id: frame.storyboard?.selected_video_id ?? "",
+ selected_video_id: frame.storyboard?.storyboard_row_idx === row.index ? frame.storyboard?.selected_video_id ?? "" : "",
first_frame_plan: row.firstFramePlan,
last_frame_plan: row.lastFramePlan,
product_placement: row.productPlacement,
@@ -3544,8 +3550,23 @@ function AudioStoryboardPlanPanel({
})
}
- const planForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) =>
- applyPlanPatch(applyPlanPatch(row, savedScenePatch(frame?.storyboard)), planOverrides[row.index])
+ const referenceFrameForRow = (row: AudioStoryboardRow) =>
+ closestFrameForTime(rowReferencePool, clampNumber((row.start + row.end) / 2, 0, Math.max(job?.duration || row.end, row.end)))
+
+ const legacyRowIndexForFrame = (frameIndex: number) => {
+ for (const item of rows) {
+ if (referenceFrameForRow(item)?.index === frameIndex) return item.index
+ }
+ return null
+ }
+
+ const planForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
+ const legacyRowIndex = frame ? legacyRowIndexForFrame(frame.index) : null
+ const savedPatch = storyboardSceneBelongsToRow(frame?.storyboard, row.index, legacyRowIndex)
+ ? savedScenePatch(frame?.storyboard)
+ : {}
+ return applyPlanPatch(applyPlanPatch(row, savedPatch), planOverrides[row.index])
+ }
const rewriteSegmentForRow = (row: AudioStoryboardRow): StoryboardScriptRewriteSegment => ({
index: row.index,
@@ -3556,12 +3577,22 @@ function AudioStoryboardPlanPanel({
current_text: copyForRow(row),
})
- const referenceFrameForRow = (row: AudioStoryboardRow) =>
- closestFrameForTime(rowReferencePool, clampNumber((row.start + row.end) / 2, 0, Math.max(job?.duration || row.end, row.end)))
-
- const videosForFrame = (frame: KeyFrame | null) => {
+ const videosForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
if (!frame) return []
- return (job?.generated_videos ?? []).filter((video) => video.frame_idx === frame.index)
+ const legacyRowIndex = legacyRowIndexForFrame(frame.index)
+ return (job?.generated_videos ?? []).filter((video) => {
+ if (video.frame_idx !== frame.index) return false
+ if (typeof video.storyboard_row_idx === "number") return video.storyboard_row_idx === row.index
+ return legacyRowIndex === row.index
+ })
+ }
+
+ const selectedVideoIdForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
+ if (!frame?.storyboard) return ""
+ const legacyRowIndex = legacyRowIndexForFrame(frame.index)
+ return storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
+ ? frame.storyboard.selected_video_id ?? ""
+ : ""
}
const quickInputForRow = (row: AudioStoryboardRow, frame: KeyFrame | null): QuickStoryboardPlanInput => ({
@@ -3585,12 +3616,17 @@ function AudioStoryboardPlanPanel({
selectedVideoId?: string,
): StoryboardScene => {
const selectedSubjectRefs = row.needsSubject ? selectSubjectRefsForRow(row, subjectRefs) : []
+ const legacyRowIndex = legacyRowIndexForFrame(frame.index)
+ const savedSceneForRow = storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
+ ? frame.storyboard
+ : null
const base = buildStoryboardSceneFromAudioRow(row, frame, productItems, selectedSubjectRefs, {
- firstImage: frame.storyboard?.first_image ?? endpointAssetRef(frame, "first_frame"),
- lastImage: frame.storyboard?.last_image ?? endpointAssetRef(frame, "last_frame"),
+ firstImage: savedSceneForRow?.first_image ?? endpointAssetRef(frame, "first_frame"),
+ lastImage: savedSceneForRow?.last_image ?? endpointAssetRef(frame, "last_frame"),
})
+ const savedSelectedVideoId = selectedVideoIdForRow(row, frame)
if (!quickPlan) {
- return { ...base, selected_video_id: selectedVideoId ?? frame.storyboard?.selected_video_id ?? base.selected_video_id ?? "" }
+ return { ...base, selected_video_id: selectedVideoId ?? savedSelectedVideoId ?? base.selected_video_id ?? "" }
}
return {
...base,
@@ -3612,7 +3648,7 @@ function AudioStoryboardPlanPanel({
scene: quickPlan.scene || base.scene,
product: quickPlan.product || base.product,
action: quickPlan.action || base.action,
- selected_video_id: selectedVideoId ?? frame.storyboard?.selected_video_id ?? base.selected_video_id ?? "",
+ selected_video_id: selectedVideoId ?? savedSelectedVideoId ?? base.selected_video_id ?? "",
}
}
@@ -3628,10 +3664,10 @@ function AudioStoryboardPlanPanel({
"Keep motion natural, creator-ad style, premium wellness lighting, no subtitles, no platform UI, no watermark, no medical treatment claims.",
].filter((line) => line.trim()).join("\n")
- const drawVideosForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, count = 4) => {
+ const drawVideosForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, count = 4, quiet = false) => {
if (!job || !frame) {
- toast.warning("这条分镜还没有参考帧,先完成抽帧。")
- return
+ if (!quiet) toast.warning("这条分镜还没有参考帧,先完成抽帧。")
+ return false
}
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
setQuickVideoBusyRow(row.index)
@@ -3644,6 +3680,7 @@ function AudioStoryboardPlanPanel({
prompt: promptForStoryboardScene(scene),
duration: scene.duration || 4,
count,
+ storyboard_row_idx: row.index,
first_image: scene.first_image ?? null,
last_image: scene.last_image ?? null,
product_images: scene.product_images ?? [],
@@ -3656,9 +3693,11 @@ function AudioStoryboardPlanPanel({
size: "720x1280",
})
onJobUpdate?.(updated)
- toast.success(`分镜 ${row.index + 1} 已提交 ${count} 条视频候选`)
+ if (!quiet) toast.success(`分镜 ${row.index + 1} 已提交 ${count} 条视频候选`)
+ return true
} catch (e) {
- toast.error("视频候选生成失败:" + (e instanceof Error ? e.message : String(e)))
+ if (!quiet) toast.error("视频候选生成失败:" + (e instanceof Error ? e.message : String(e)))
+ return false
} finally {
setQuickVideoBusyRow(null)
}
@@ -3668,7 +3707,11 @@ function AudioStoryboardPlanPanel({
if (!job || !frame) return
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
try {
- const scene = buildSceneForPlannedRow(plannedRow, frame, frame.storyboard, videoId)
+ const legacyRowIndex = legacyRowIndexForFrame(frame.index)
+ const savedSceneForRow = storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
+ ? frame.storyboard
+ : null
+ const scene = buildSceneForPlannedRow(plannedRow, frame, savedSceneForRow, videoId)
const updated = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate?.(updated)
toast.success(`分镜 ${row.index + 1} 已选用该视频`)
@@ -4026,16 +4069,17 @@ function AudioStoryboardPlanPanel({
if (!job || !rows.length) return
const count = clampVideoCount(batchVideoCount)
setBatchCardBusy(true)
+ let submitted = 0
+ let failed = 0
try {
- await saveAllStoryboardDrafts(true)
- const updated = await batchGenerateAll(job.id, {
- count_per_row: count,
- concurrency: 1,
- model: "seedance",
- size: "720x1280",
- })
- onJobUpdate?.(updated)
- toast.success(`整片视频候选生成已启动:${rows.length} 条分镜 × 每条 ${count} 个候选`)
+ for (const row of rows) {
+ const frame = referenceFrameForRow(row)
+ const ok = await drawVideosForRow(row, frame, count, true)
+ if (ok) submitted += 1
+ else failed += 1
+ }
+ if (failed) toast.warning(`整片已排队 ${submitted} 条分镜,${failed} 条失败或缺少参考帧`)
+ else toast.success(`整片视频候选已按行排队:${submitted} 条分镜 × 每条 ${count} 个候选`)
} catch (e) {
toast.error("整片视频候选生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -4395,7 +4439,7 @@ function AudioStoryboardPlanPanel({
{rows.map((row) => {
const referenceFrame = referenceFrameForRow(row)
const plannedRow = planForRow(row, referenceFrame)
- const rowVideos = videosForFrame(referenceFrame)
+ const rowVideos = videosForRow(row, referenceFrame)
const savingStoryboard = storyboardSaveBusyRow === row.index
const copyText = copyForRow(row)
const copyZhText = copyZhForRow(row)
@@ -4543,7 +4587,7 @@ function AudioStoryboardPlanPanel({
job={job}
videos={rowVideos}
enabled={!!referenceFrame}
- selectedVideoId={referenceFrame?.storyboard?.selected_video_id ?? ""}
+ selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
busy={quickVideoBusyRow === row.index}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
@@ -4772,7 +4816,7 @@ function AudioStoryboardPlanPanel({
videos={rowVideos}
enabled={!!referenceFrame}
expanded={videosOpen}
- selectedVideoId={referenceFrame?.storyboard?.selected_video_id ?? ""}
+ selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
busy={quickVideoBusyRow === row.index}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 6b9bc81..535fd15 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -187,6 +187,7 @@ export interface StoryboardScene {
visual_mode?: "person_only" | "person_product" | "product_only" | "environment"
needs_product?: boolean
needs_subject?: boolean
+ storyboard_row_idx?: number | null
subject_brief?: string
skg_copy_en?: string
skg_copy_zh?: string
@@ -241,6 +242,7 @@ export interface GeneratedVideo {
id: string
provider_id?: string
frame_idx: number
+ storyboard_row_idx?: number | null
prompt: string
model: string
status: "queued" | "in_progress" | "completed" | "failed"
@@ -1278,6 +1280,7 @@ export async function generateStoryboardVideo(
duration?: number
count?: number
seed?: number | null
+ storyboard_row_idx?: number | null
first_image?: ImageRef | null
last_image?: ImageRef | null
product_images?: ImageRef[]