fix: isolate storyboard videos by row

This commit is contained in:
2026-05-19 15:24:30 +08:00
parent 64a9673fa1
commit e03c5db3fd
4 changed files with 100 additions and 34 deletions

View File

@@ -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)}

View File

@@ -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[]