feat: simplify storyboard video card flow

This commit is contained in:
2026-05-19 11:05:57 +08:00
parent ff7bf00f6d
commit 3462758585
4 changed files with 1133 additions and 80 deletions

View File

@@ -22,6 +22,8 @@ import {
type KeyFrame,
type ProductViewAnalysisItem,
type ProductRefStateItem,
type QuickStoryboardPlanInput,
type RefineStoryboardResult,
type RuntimeModels,
type StoryboardScriptRewriteSegment,
type StoryboardScene,
@@ -32,6 +34,7 @@ import {
analyzeJob,
analyzeProductViews,
apiAssetUrl,
batchGenerateAll,
characterLibraryImageUrl,
createAssetLibraryItem,
createPromptLibraryItem,
@@ -41,6 +44,7 @@ import {
formatJobError,
generateSceneAsset,
generateProductAngleAsset,
generateStoryboardVideo,
generateSubjectAssets,
generatedImageUrl,
getJob,
@@ -50,6 +54,8 @@ import {
listSubjectTemplates,
representativeCutoutUrl,
resolveImageRefUrl,
refineStoryboard,
quickPlanStoryboard,
rewriteStoryboardScript,
saveSubjectTemplate,
saveProductRefs,
@@ -120,6 +126,10 @@ type AudioStoryboardRow = {
subjectDescriptionZh: string
skgCopy: string
skgCopyZh: string
sceneOneLine: string
sceneOneLineZh: string
actionOneLine: string
actionOneLineZh: string
visualPlan: string
visualPlanZh: string
firstFramePlan: string
@@ -153,7 +163,7 @@ type ResolvedSubjectProfile = {
payload: SubjectProfilePreference
}
type StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "subjectDescriptionZh" | "visualPlan" | "visualPlanZh" | "firstFramePlan" | "firstFramePlanZh" | "lastFramePlan" | "lastFramePlanZh" | "productIntegration" | "productIntegrationZh" | "productPlacement" | "productPlacementZh">>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "subjectDescriptionZh" | "skgCopy" | "skgCopyZh" | "sceneOneLine" | "sceneOneLineZh" | "actionOneLine" | "actionOneLineZh" | "visualPlan" | "visualPlanZh" | "firstFramePlan" | "firstFramePlanZh" | "lastFramePlan" | "lastFramePlanZh" | "productIntegration" | "productIntegrationZh" | "productPlacement" | "productPlacementZh">>
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
type WorkflowStepStatus = "blocked" | "pending" | "running" | "ready" | "paused"
type WorkflowStep = {
@@ -395,6 +405,13 @@ const fieldClass =
const emptyScene = (): StoryboardScene => ({
duration: 5,
skg_copy_en: "",
skg_copy_zh: "",
scene_one_line_en: "",
scene_one_line_zh: "",
action_one_line_en: "",
action_one_line_zh: "",
selected_video_id: "",
subject: "",
product: "",
scene: "",
@@ -686,18 +703,18 @@ function buildWorkflowSteps({
{
id: "scene",
no: "08",
title: "画面首尾帧",
detail: endpointTargetCount ? `${endpointFramePairCount}/${endpointTargetCount} 组首尾帧` : "待分镜",
judge: "每条分镜先确定场景+人+产品+动作,再生成 asset 类型首帧/尾帧keyframe 不算通过。",
status: stepStatus({ ready: endpointTargetCount > 0 && endpointFramePairCount >= endpointTargetCount, blocked: !storyboardReady }),
title: "三字段规划",
detail: storyboardReady ? `${transcriptCount} 条紧凑分镜` : "待分镜",
judge: "客户默认只看文案、场景一句话、人物+产品+动作;首尾帧藏在高级模式和后端内部。",
status: stepStatus({ ready: storyboardReady, blocked: !storyboardReady }),
},
{
id: "video",
no: "09",
title: "视频候选",
detail: generatedVideoCount ? `${generatedVideoCount}历史` : "生成入口暂停",
judge: "当前不直接调视频模型;首尾帧审核后才开放单条或批量提交。",
status: generatedVideoCount > 0 ? "ready" : "paused",
detail: generatedVideoCount ? `${generatedVideoCount}候选` : "可抽 4 张",
judge: "单条默认抽 4 张候选;整片一键抽卡后台提交,失败行可单独重试。",
status: generatedVideoCount > 0 ? "ready" : stepStatus({ ready: false, blocked: !storyboardReady }),
},
]
}
@@ -1150,6 +1167,10 @@ function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] {
subjectDescriptionZh: buildSubjectDescriptionZh(role, visualMode),
skgCopy: buildSkgCopy(role, index),
skgCopyZh: buildSkgCopyZh(role, index),
sceneOneLine: buildVisualPlan(role),
sceneOneLineZh: buildVisualPlanZh(role),
actionOneLine: `${buildSubjectDescription(role, visualMode) || "Product-forward SKG short-video beat."} ${defaults.productPlacement}`,
actionOneLineZh: `${buildSubjectDescriptionZh(role, visualMode) || "以 SKG 产品为主的短视频镜头。"}${defaultsZh.productPlacement ? ` ${defaultsZh.productPlacement}` : ""}`,
visualPlan: buildVisualPlan(role),
visualPlanZh: buildVisualPlanZh(role),
firstFramePlan: buildFirstFramePlan(role),
@@ -1369,6 +1390,12 @@ function savedScenePatch(scene?: StoryboardScene | null): RowPlanPatch {
visualMode: scene.visual_mode,
needsProduct: scene.needs_product,
needsSubject: scene.needs_subject,
skgCopy: scene.skg_copy_en,
skgCopyZh: scene.skg_copy_zh,
sceneOneLine: scene.scene_one_line_en,
sceneOneLineZh: scene.scene_one_line_zh,
actionOneLine: scene.action_one_line_en,
actionOneLineZh: scene.action_one_line_zh,
subjectDescription: scene.subject?.split("\n").find((line) => line.trim() && !line.startsWith("Subject source") && !line.startsWith("No main subject") && !line.startsWith("主体真源") && !line.startsWith("本条不需要"))?.trim(),
visualPlan: scene.scene?.split("\n").find((line) => line.trim() && !line.startsWith("Visual mode") && !line.startsWith("First-frame plan") && !line.startsWith("Last-frame plan") && !line.startsWith("Source audio reference") && !line.startsWith("镜头类型") && !line.startsWith("首帧规划") && !line.startsWith("尾帧规划") && !line.startsWith("原音频依据"))?.trim(),
firstFramePlan: scene.first_frame_plan,
@@ -1385,6 +1412,12 @@ function applyPlanPatch(row: AudioStoryboardRow, patch?: RowPlanPatch): AudioSto
visualMode: patch.visualMode ?? row.visualMode,
needsProduct: patch.needsProduct ?? row.needsProduct,
needsSubject: patch.needsSubject ?? row.needsSubject,
skgCopy: patch.skgCopy ?? row.skgCopy,
skgCopyZh: patch.skgCopyZh ?? row.skgCopyZh,
sceneOneLine: patch.sceneOneLine ?? row.sceneOneLine,
sceneOneLineZh: patch.sceneOneLineZh ?? row.sceneOneLineZh,
actionOneLine: patch.actionOneLine ?? row.actionOneLine,
actionOneLineZh: patch.actionOneLineZh ?? row.actionOneLineZh,
subjectDescription: patch.subjectDescription ?? row.subjectDescription,
subjectDescriptionZh: patch.subjectDescriptionZh ?? row.subjectDescriptionZh,
visualPlan: patch.visualPlan ?? row.visualPlan,
@@ -1677,6 +1710,13 @@ function buildStoryboardSceneFromAudioRow(
needs_product: row.needsProduct,
needs_subject: row.needsSubject,
subject_brief: row.needsSubject ? subjectBrief : "",
skg_copy_en: row.skgCopy,
skg_copy_zh: row.skgCopyZh,
scene_one_line_en: row.sceneOneLine,
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 ?? "",
first_frame_plan: row.firstFramePlan,
last_frame_plan: row.lastFramePlan,
product_placement: row.productPlacement,
@@ -1742,7 +1782,7 @@ export function AdRecreationBoard({
})
const workflow = workflowStepMap(workflowSteps)
const statusMessage = job?.message?.startsWith("视频生成已提交")
? "历史候选视频已保留;当前已暂停直接提交视频,先逐条生成并审核首尾帧。"
? "视频候选已提交;当前默认按紧凑三字段抽卡,首尾帧细节自动处理。"
: job?.message
useEffect(() => {
@@ -3353,6 +3393,13 @@ function AudioStoryboardPlanPanel({
const [authorIntent, setAuthorIntent] = useState("")
const [showChineseMirror, setShowChineseMirror] = useState(true)
const [scriptRewriteBusy, setScriptRewriteBusy] = useState<"all" | number | null>(null)
const [quickVideoBusyRow, setQuickVideoBusyRow] = useState<number | null>(null)
const [batchCardBusy, setBatchCardBusy] = useState(false)
const [advancedRows, setAdvancedRows] = useState<Set<number>>(new Set())
const [refineDialog, setRefineDialog] = useState<{ rowIndex: number; frameIndex: number | null } | null>(null)
const [refineFeedback, setRefineFeedback] = useState("")
const [refineBusy, setRefineBusy] = useState(false)
const [refinePreview, setRefinePreview] = useState<RefineStoryboardResult["items"] | null>(null)
const productFileRef = useRef<HTMLInputElement | null>(null)
const productPersistSeq = useRef(0)
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
@@ -3374,6 +3421,12 @@ function AudioStoryboardPlanPanel({
setPlanOverrides({})
setAuthorIntent("")
setScriptRewriteBusy(null)
setQuickVideoBusyRow(null)
setBatchCardBusy(false)
setAdvancedRows(new Set())
setRefineDialog(null)
setRefineFeedback("")
setRefinePreview(null)
}, [job?.id])
const persistProductItems = async (items: ProductRefItem[]) => {
@@ -3399,6 +3452,10 @@ function AudioStoryboardPlanPanel({
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: value }))
}
const patchRowCopyZh = (rowIndex: number, value: string) => {
setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: value }))
}
const patchRowPlan = (rowIndex: number, patch: RowPlanPatch) => {
setPlanOverrides((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] ?? {}), ...patch } }))
}
@@ -3464,6 +3521,125 @@ function AudioStoryboardPlanPanel({
return (job?.generated_videos ?? []).filter((video) => video.frame_idx === frame.index)
}
const quickInputForRow = (row: AudioStoryboardRow, frame: KeyFrame | null): QuickStoryboardPlanInput => ({
skg_copy_en: row.skgCopy,
skg_copy_zh: row.skgCopyZh,
scene_one_line_en: row.sceneOneLine,
scene_one_line_zh: row.sceneOneLineZh,
action_one_line_en: row.actionOneLine,
action_one_line_zh: row.actionOneLineZh,
subject_brief: row.needsSubject ? subjectBriefForEndpoint(row, subjectRefs) : "",
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || frame?.storyboard?.duration || 4.5)).toFixed(1)),
visual_mode: row.visualMode,
needs_product: row.needsProduct,
needs_subject: row.needsSubject,
})
const buildSceneForPlannedRow = (
row: AudioStoryboardRow,
frame: KeyFrame,
quickPlan?: StoryboardScene | null,
selectedVideoId?: string,
): StoryboardScene => {
const selectedSubjectRefs = row.needsSubject ? selectSubjectRefsForRow(row, subjectRefs) : []
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"),
})
if (!quickPlan) {
return { ...base, selected_video_id: selectedVideoId ?? frame.storyboard?.selected_video_id ?? base.selected_video_id ?? "" }
}
return {
...base,
duration: quickPlan.duration || base.duration,
visual_mode: quickPlan.visual_mode ?? base.visual_mode,
needs_product: quickPlan.needs_product ?? base.needs_product,
needs_subject: quickPlan.needs_subject ?? base.needs_subject,
subject_brief: quickPlan.subject_brief || base.subject_brief,
skg_copy_en: quickPlan.skg_copy_en || base.skg_copy_en,
skg_copy_zh: quickPlan.skg_copy_zh || base.skg_copy_zh,
scene_one_line_en: quickPlan.scene_one_line_en || base.scene_one_line_en,
scene_one_line_zh: quickPlan.scene_one_line_zh || base.scene_one_line_zh,
action_one_line_en: quickPlan.action_one_line_en || base.action_one_line_en,
action_one_line_zh: quickPlan.action_one_line_zh || base.action_one_line_zh,
first_frame_plan: quickPlan.first_frame_plan || base.first_frame_plan,
last_frame_plan: quickPlan.last_frame_plan || base.last_frame_plan,
product_placement: quickPlan.product_placement || base.product_placement,
subject: quickPlan.subject || base.subject,
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 ?? "",
}
}
const promptForStoryboardScene = (scene: StoryboardScene) => [
"Create one vertical 9:16 short-form SKG ad video clip.",
`English voice-over line: ${scene.skg_copy_en || scene.action || ""}`,
`Scene: ${scene.scene_one_line_en || scene.scene || ""}`,
`Subject + product + action: ${scene.action_one_line_en || scene.action || ""}`,
`First frame intent: ${scene.first_frame_plan || ""}`,
`Last frame intent: ${scene.last_frame_plan || ""}`,
`Product placement: ${scene.product_placement || scene.product || ""}`,
`Subject brief: ${scene.subject_brief || scene.subject || ""}`,
"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) => {
if (!job || !frame) {
toast.warning("这条分镜还没有参考帧,先完成抽帧。")
return
}
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
setQuickVideoBusyRow(row.index)
try {
const expandedPlan = await quickPlanStoryboard(job.id, frame.index, quickInputForRow(plannedRow, frame))
const scene = buildSceneForPlannedRow(plannedRow, frame, expandedPlan)
const saved = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate?.(saved)
const updated = await generateStoryboardVideo(job.id, frame.index, {
prompt: promptForStoryboardScene(scene),
duration: scene.duration || 4,
count,
first_image: scene.first_image ?? null,
last_image: scene.last_image ?? null,
product_images: scene.product_images ?? [],
subject_images: scene.subject_images ?? [],
subject_image: scene.subject_image ?? null,
scene_image: scene.scene_image ?? null,
product_image: scene.product_image ?? null,
action_image: scene.action_image ?? null,
model: "seedance",
size: "720x1280",
})
onJobUpdate?.(updated)
toast.success(`分镜 ${row.index + 1} 已提交 ${count} 张视频候选`)
} catch (e) {
toast.error("视频抽卡失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setQuickVideoBusyRow(null)
}
}
const selectVideoForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, videoId: string) => {
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 updated = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate?.(updated)
toast.success(`分镜 ${row.index + 1} 已选用该视频`)
} catch (e) {
toast.error("选用视频失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const clearVideosForRow = (videos: GeneratedVideo[]) => {
if (!videos.length) return
for (const video of videos) onDeleteVideo?.(video.id)
toast.success(`已清空 ${videos.length} 个候选`)
}
const itemSourceForRef = (ref: ImageRef) => productItems.find((item) => sameImageRef(item.ref, ref))?.source ?? "upload"
const buildAnalyzedProductItems = (refs: ImageRef[], analysisItems: ProductViewAnalysisItem[] = [], startIndex = 0) => refs.map((ref, index) => {
@@ -3686,12 +3862,8 @@ function AudioStoryboardPlanPanel({
const saveRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame) => {
if (!job) return
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
const selectedSubjectRefs = plannedRow.needsSubject ? selectSubjectRefsForRow(plannedRow, subjectRefs) : []
const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, selectedSubjectRefs, {
firstImage: endpointAssetRef(frame, "first_frame"),
lastImage: endpointAssetRef(frame, "last_frame"),
})
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
const scene = buildSceneForPlannedRow(plannedRow, frame, frame.storyboard)
const updated = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate?.(updated)
}
@@ -3765,7 +3937,7 @@ function AudioStoryboardPlanPanel({
setStoryboardSaveBusyRow(row.index)
try {
await saveRowStoryboardDraft(row, frame)
toast.success("已保存本条分镜规划;视频生成入口已暂停,等待首尾帧资产")
toast.success("已保存本条三字段规划")
} catch (e) {
toast.error("保存本条规划失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -3773,14 +3945,14 @@ function AudioStoryboardPlanPanel({
}
}
const saveAllStoryboardDrafts = async () => {
if (!job || !rows.length) return
const saveAllStoryboardDrafts = async (quiet = false) => {
if (!job || !rows.length) return { ok: 0, failed: 0 }
const jobsToSubmit = rows
.map((row) => ({ row: planForRow(row, referenceFrameForRow(row)), frame: referenceFrameForRow(row) }))
.filter((item): item is { row: AudioStoryboardRow; frame: KeyFrame } => !!item.frame)
if (!jobsToSubmit.length) {
toast.warning("先完成前置抽帧,让每条分镜有可保存的承载位置")
return
if (!quiet) toast.warning("先完成前置抽帧,让每条分镜有可保存的承载位置")
return { ok: 0, failed: rows.length }
}
setBatchStoryboardSaveBusy(true)
let ok = 0
@@ -3796,12 +3968,98 @@ function AudioStoryboardPlanPanel({
console.warn("批量保存分镜规划失败", item.row.index, e)
}
}
if (failed) toast.warning(`已保存 ${ok} 条规划,${failed} 条失败`)
else toast.success(`已保存全部 ${ok}分镜规划;视频生成入口已暂停`)
if (!quiet) {
if (failed) toast.warning(`已保存 ${ok}规划,${failed} 条失败`)
else toast.success(`已保存全部 ${ok} 条分镜规划`)
}
} finally {
setStoryboardSaveBusyRow(null)
setBatchStoryboardSaveBusy(false)
}
return { ok, failed }
}
const batchDrawAllRows = async () => {
if (!job || !rows.length) return
setBatchCardBusy(true)
try {
await saveAllStoryboardDrafts(true)
const updated = await batchGenerateAll(job.id, {
count_per_row: 4,
concurrency: 4,
model: "seedance",
size: "720x1280",
})
onJobUpdate?.(updated)
toast.success(`整片一键抽卡已启动:${rows.length}× 4 张`)
} catch (e) {
toast.error("整片一键抽卡失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setBatchCardBusy(false)
}
}
const openRefineForRow = (row: AudioStoryboardRow, frame: KeyFrame | null) => {
setRefineDialog({ rowIndex: row.index, frameIndex: frame?.index ?? null })
setRefineFeedback("")
setRefinePreview(null)
}
const applyRefineItems = (rowIndex: number, items: RefineStoryboardResult["items"]) => {
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_en }))
setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_zh }))
patchRowPlan(rowIndex, {
skgCopy: items.skg_copy_en,
skgCopyZh: items.skg_copy_zh,
sceneOneLine: items.scene_one_line_en,
sceneOneLineZh: items.scene_one_line_zh,
actionOneLine: items.action_one_line_en,
actionOneLineZh: items.action_one_line_zh,
})
}
const submitRefine = async () => {
if (!job || !refineDialog) return
const row = rows.find((item) => item.index === refineDialog.rowIndex)
const frame = refineDialog.frameIndex == null ? null : job.frames.find((item) => item.index === refineDialog.frameIndex) ?? null
if (!row || !frame) return
const feedback = refineFeedback.trim()
if (!feedback) {
toast.warning("先写一句你想怎么改。")
return
}
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
const currentPlan = refinePreview
? {
...quickInputForRow(plannedRow, frame),
skg_copy_en: refinePreview.skg_copy_en,
skg_copy_zh: refinePreview.skg_copy_zh,
scene_one_line_en: refinePreview.scene_one_line_en,
scene_one_line_zh: refinePreview.scene_one_line_zh,
action_one_line_en: refinePreview.action_one_line_en,
action_one_line_zh: refinePreview.action_one_line_zh,
}
: quickInputForRow(plannedRow, frame)
setRefineBusy(true)
try {
const result = await refineStoryboard(job.id, frame.index, {
current_plan: currentPlan,
user_feedback: feedback,
})
setRefinePreview(result.items)
if (result.error) toast.warning(`AI 改写已回退:${result.error}`)
} catch (e) {
toast.error("AI 改写失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setRefineBusy(false)
}
}
const closeRefineDialog = () => {
setRefineDialog(null)
setRefineFeedback("")
setRefinePreview(null)
setRefineBusy(false)
}
if (!job) return null
@@ -3935,6 +4193,15 @@ function AudioStoryboardPlanPanel({
{scriptRewriteBusy === "all" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => void batchDrawAllRows()}
disabled={batchCardBusy || batchStoryboardSaveBusy || !rows.length || !orderedFrames.length}
className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{batchCardBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{rows.length}×4
</button>
<button
type="button"
onClick={() => {
@@ -3953,7 +4220,7 @@ function AudioStoryboardPlanPanel({
className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{batchStoryboardSaveBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</button>
</div>
</div>
@@ -3970,8 +4237,110 @@ function AudioStoryboardPlanPanel({
return (
<article
key={row.index}
className="grid overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64 xl:grid-cols-[54px_120px_minmax(170px,0.48fr)_minmax(420px,1.2fr)_360px] 2xl:grid-cols-[56px_140px_280px_minmax(560px,1fr)_420px]"
className="overflow-hidden rounded-md border border-white/10 bg-black/24 text-[11px] leading-snug text-white/64"
>
<div className="border-b border-white/8 p-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-white/82"> {row.index + 1}</span>
<span className="font-mono text-[10.5px] text-white/42">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</span>
<span className="rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] leading-tight text-emerald-100/80">
{ROLE_LABELS_ZH[row.role]}
</span>
{referenceFrame ? (
<span className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] text-white/38"> {referenceFrame.index + 1}</span>
) : (
<span className="rounded-md border border-amber-300/18 bg-amber-300/[0.07] px-1.5 py-0.5 text-[10px] text-amber-100/70"></span>
)}
</div>
<p className="mt-1 line-clamp-1 text-[10.5px] text-white/32" title={row.source}>{row.source}</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<button
type="button"
onClick={() => openRefineForRow(plannedRow, referenceFrame)}
disabled={!referenceFrame}
className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-violet-300/18 bg-violet-300/[0.07] px-2 text-[10.5px] font-semibold text-violet-100/75 transition hover:border-violet-300/45 hover:text-violet-50 disabled:cursor-not-allowed disabled:opacity-35"
>
<Wand2 className="h-3.5 w-3.5" />
AI
</button>
<button
type="button"
onClick={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
disabled={!referenceFrame || quickVideoBusyRow !== null}
className="skg-primary-action inline-flex h-8 items-center justify-center gap-1 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{quickVideoBusyRow === row.index ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
4
</button>
<button
type="button"
onClick={() => setAdvancedRows((prev) => {
const next = new Set(prev)
if (next.has(row.index)) next.delete(row.index)
else next.add(row.index)
return next
})}
className="inline-flex h-8 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10.5px] font-semibold text-white/58 transition hover:border-white/25 hover:text-white/82"
>
<ChevronDown className={`h-3.5 w-3.5 transition ${advancedRows.has(row.index) ? "rotate-180" : ""}`} />
</button>
</div>
</div>
<div className="mt-2 grid gap-2 lg:grid-cols-3">
<CompactStoryboardField
label="文案"
value={copyText}
zhValue={copyZhText}
showChinese={showChineseMirror}
onChange={(value) => patchRowCopy(row.index, value)}
onChangeZh={(value) => patchRowCopyZh(row.index, value)}
onSave={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 文案`, copyText, copyZhText)}
onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")}
/>
<CompactStoryboardField
label="场景一句话"
value={plannedRow.sceneOneLine}
zhValue={plannedRow.sceneOneLineZh}
showChinese={showChineseMirror}
onChange={(value) => patchRowPlan(row.index, { sceneOneLine: value, visualPlan: value })}
onChangeZh={(value) => patchRowPlan(row.index, { sceneOneLineZh: value, visualPlanZh: value })}
onSave={() => void savePromptToLibrary("scene_desc", `分镜 ${row.index + 1} 场景一句话`, plannedRow.sceneOneLine, plannedRow.sceneOneLineZh)}
onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")}
/>
<CompactStoryboardField
label="人物 + 产品 + 动作"
value={plannedRow.actionOneLine}
zhValue={plannedRow.actionOneLineZh}
showChinese={showChineseMirror}
onChange={(value) => patchRowPlan(row.index, { actionOneLine: value, subjectDescription: value })}
onChangeZh={(value) => patchRowPlan(row.index, { actionOneLineZh: value, subjectDescriptionZh: value })}
onSave={() => void savePromptToLibrary("video_desc", `分镜 ${row.index + 1} 人物产品动作`, plannedRow.actionOneLine, plannedRow.actionOneLineZh)}
onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")}
/>
</div>
<StoryboardVideoSlots
job={job}
videos={rowVideos}
enabled={!!referenceFrame}
selectedVideoId={referenceFrame?.storyboard?.selected_video_id ?? ""}
busy={quickVideoBusyRow === row.index}
onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
onDeleteVideo={onDeleteVideo}
/>
</div>
{advancedRows.has(row.index) ? (
<div className="grid xl:grid-cols-[54px_120px_minmax(170px,0.48fr)_minmax(420px,1.2fr)_360px] 2xl:grid-cols-[56px_140px_280px_minmax(560px,1fr)_420px]">
<StoryboardPlanCell label="分镜">
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
<div className="mt-1.5 inline-flex max-w-full rounded-md border border-emerald-300/15 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] leading-tight text-emerald-100/80">
@@ -4182,20 +4551,22 @@ function AudioStoryboardPlanPanel({
<StoryboardVideoSlots
job={job}
videos={rowVideos}
enabled={!!endpointAssetRef(referenceFrame, "first_frame") && !!endpointAssetRef(referenceFrame, "last_frame")}
enabled={!!referenceFrame}
selectedVideoId={referenceFrame?.storyboard?.selected_video_id ?? ""}
busy={quickVideoBusyRow === row.index}
onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
onDeleteVideo={onDeleteVideo}
/>
<div className="mt-1 truncate text-[10px] text-white/34" title="视频生成已暂停,首尾帧确认后再开放单条提交">
{endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame")
? "首尾帧已就绪 · 待开放单条视频提交"
: "先生成并确认首帧 / 尾帧"}
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="text-[10px] text-white/34"></span>
<span className="rounded border border-amber-300/18 bg-amber-300/[0.07] px-1.5 py-0.5 text-[10px] text-amber-100/70"></span>
<span className="text-[10px] text-white/34"></span>
<span className="rounded border border-emerald-300/18 bg-emerald-300/[0.07] px-1.5 py-0.5 text-[10px] text-emerald-100/70"></span>
</div>
<div className="mt-1 rounded border border-amber-300/12 bg-amber-300/[0.045] px-2 py-1 text-[10px] leading-snug text-amber-100/62">
SKG /
<div className="mt-1 rounded border border-cyan-300/12 bg-cyan-300/[0.045] px-2 py-1 text-[10px] leading-snug text-cyan-100/62">
/ prompt
</div>
<button
type="button"
@@ -4207,13 +4578,112 @@ function AudioStoryboardPlanPanel({
</button>
</StoryboardPlanCell>
</div>
) : null}
</article>
)
})}
</div>
{refineDialog ? (() => {
const dialogRow = rows.find((item) => item.index === refineDialog.rowIndex)
const dialogFrame = refineDialog.frameIndex == null ? null : job.frames.find((item) => item.index === refineDialog.frameIndex) ?? null
const plannedDialogRow = dialogRow && dialogFrame ? { ...planForRow(dialogRow, dialogFrame), skgCopy: copyForRow(dialogRow), skgCopyZh: copyZhForRow(dialogRow) } : null
if (!dialogRow || !dialogFrame || !plannedDialogRow) return null
const quickButtons = ["更兴奋", "更舒缓", "产品再露", "主体更近", "镜头更动", "背景更暗", "节奏更快"]
return (
<div className="fixed inset-0 z-[9000] flex items-center justify-center bg-black/72 p-4">
<div className="w-full max-w-3xl rounded-lg border border-white/14 bg-[#080b0f] p-3 shadow-[0_26px_90px_rgba(0,0,0,0.72)]">
<div className="mb-2 flex items-center justify-between gap-3">
<div>
<div className="text-[14px] font-semibold text-white"> AI </div>
<div className="mt-0.5 text-[11px] text-white/42"> {dialogRow.index + 1} · </div>
</div>
<button type="button" onClick={closeRefineDialog} className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/48 transition hover:text-white">
</button>
</div>
<textarea
value={refineFeedback}
onChange={(event) => setRefineFeedback(event.target.value)}
placeholder="你想怎么改?例如:让这条更兴奋一点,但产品露出更自然。"
className="min-h-[72px] w-full resize-y rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[12px] leading-relaxed text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/>
<div className="mt-2 flex flex-wrap gap-1.5">
{quickButtons.map((item) => (
<button
key={item}
type="button"
onClick={() => setRefineFeedback((prev) => prev ? `${prev}${item}` : item)}
className="rounded-md border border-white/10 bg-white/[0.045] px-2 py-1 text-[10.5px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100"
>
{item}
</button>
))}
</div>
{refinePreview ? (
<div className="mt-3 grid gap-2 md:grid-cols-2">
<div className="rounded-md border border-white/10 bg-black/24 p-2">
<div className="mb-1 text-[11px] font-semibold text-white/58"></div>
<p className="text-[11px] leading-snug text-white/60">{plannedDialogRow.skgCopy}</p>
<p className="mt-1 text-[11px] leading-snug text-white/44">{plannedDialogRow.sceneOneLine}</p>
<p className="mt-1 text-[11px] leading-snug text-white/44">{plannedDialogRow.actionOneLine}</p>
</div>
<div className="rounded-md border border-emerald-300/18 bg-emerald-300/[0.06] p-2">
<div className="mb-1 text-[11px] font-semibold text-emerald-100/72"></div>
<p className="text-[11px] leading-snug text-white/76">{refinePreview.skg_copy_en}</p>
<p className="mt-1 text-[11px] leading-snug text-white/56">{refinePreview.scene_one_line_en}</p>
<p className="mt-1 text-[11px] leading-snug text-white/56">{refinePreview.action_one_line_en}</p>
</div>
</div>
) : null}
<div className="mt-3 flex items-center justify-end gap-2">
<button type="button" onClick={closeRefineDialog} className="skg-secondary-action inline-flex h-8 items-center px-3 text-[11px] font-semibold">
</button>
{refinePreview ? (
<>
<button
type="button"
onClick={() => void submitRefine()}
disabled={refineBusy}
className="skg-secondary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold disabled:cursor-not-allowed disabled:opacity-40"
>
{refineBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => {
applyRefineItems(dialogRow.index, refinePreview)
closeRefineDialog()
}}
className="skg-primary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold"
>
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
<button
type="button"
onClick={() => void submitRefine()}
disabled={refineBusy}
className="skg-primary-action inline-flex h-8 items-center gap-1 px-3 text-[11px] font-semibold disabled:cursor-not-allowed disabled:opacity-40"
>
{refineBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
AI
</button>
)}
</div>
</div>
</div>
)
})() : null}
</>
) : (
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成信息流复刻分镜工作台。先抽帧并生成相似主体,再逐条规划首尾帧。" />
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成三字段分镜,并支持单条或整片一键抽 4 张视频候选。" />
)}
</section>
)
@@ -4372,40 +4842,144 @@ function StoryboardPlanCell({ label, children, className = "" }: { label: string
)
}
function CompactStoryboardField({
label,
value,
zhValue,
showChinese,
onChange,
onChangeZh,
onSave,
onPick,
}: {
label: string
value: string
zhValue?: string
showChinese: boolean
onChange: (value: string) => void
onChangeZh?: (value: string) => void
onSave?: () => void
onPick?: () => void
}) {
return (
<div className="min-w-0 rounded-md border border-white/10 bg-black/28 p-2">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-white/68">{label}</span>
<span className="flex items-center gap-1">
<button
type="button"
onClick={onSave}
disabled={!value.trim()}
className="inline-flex h-6 w-6 items-center justify-center rounded border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] text-[#f1d78e]/70 transition hover:border-[#d6b36a]/45 hover:text-[#f1d78e] disabled:cursor-not-allowed disabled:opacity-30"
title="保存到提示词库"
aria-label="保存到提示词库"
>
<BookOpen className="h-3 w-3" />
</button>
<button
type="button"
onClick={onPick}
className="inline-flex h-6 w-6 items-center justify-center rounded border border-white/10 bg-white/[0.045] text-white/45 transition hover:border-cyan-300/35 hover:text-cyan-100"
title="从提示词库选用"
aria-label="从提示词库选用"
>
<PanelRight className="h-3 w-3" />
</button>
</span>
</div>
<textarea
value={value}
onChange={(event) => onChange(event.target.value)}
className="min-h-[48px] w-full resize-y rounded border border-white/10 bg-black/34 px-2 py-1.5 text-[11px] leading-snug text-white/82 outline-none placeholder:text-white/25 focus:border-cyan-300/50"
/>
{showChinese ? (
<textarea
value={zhValue ?? ""}
onChange={(event) => onChangeZh?.(event.target.value)}
placeholder="中文镜像"
className="mt-1 min-h-[30px] w-full resize-none rounded border border-white/8 bg-black/22 px-2 py-1 text-[10px] leading-snug text-white/42 outline-none placeholder:text-white/22 focus:border-cyan-300/35"
/>
) : null}
</div>
)
}
function StoryboardVideoSlots({
job,
videos,
enabled,
selectedVideoId = "",
busy = false,
onDraw,
onReroll,
onRegenerate,
onClear,
onSelect,
onDeleteVideo,
}: {
job: Job
videos: GeneratedVideo[]
enabled: boolean
selectedVideoId?: string
busy?: boolean
onDraw?: () => void
onReroll?: () => void
onRegenerate?: () => void
onClear?: () => void
onSelect?: (videoId: string) => void
onDeleteVideo?: (videoId: string) => void
}) {
const visible = videos.slice(0, 6)
const emptyCount = Math.max(0, 6 - visible.length)
const visible = videos
const slotCount = Math.max(4, Math.ceil(Math.max(visible.length, 1) / 4) * 4)
const emptyCount = Math.max(0, slotCount - visible.length)
return (
<div>
<div className="grid grid-cols-6 gap-1.5">
<div className="mt-2 rounded-md border border-white/10 bg-black/24 p-2">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Film className="h-3.5 w-3.5 text-cyan-100/65" />
<span className="text-[11px] font-semibold text-white/66">4 </span>
{videos.length ? <span className="text-[10px] text-white/34">{videos.length} </span> : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={videos.length ? onReroll : onDraw}
disabled={!enabled || busy}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.07] px-2 text-[10px] font-semibold text-cyan-100/70 transition hover:border-cyan-300/45 hover:text-cyan-50 disabled:cursor-not-allowed disabled:opacity-35"
>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
{videos.length ? "再抽 4 张" : "抽 4 张"}
</button>
<button
type="button"
onClick={onClear}
disabled={!videos.length}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.04] px-2 text-[10px] font-semibold text-white/46 transition hover:border-rose-300/35 hover:text-rose-100 disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
{visible.map((video) => (
<StoryboardVideoPreview
key={video.id}
job={job}
video={video}
className="aspect-[9/16] min-h-[86px] w-full"
selected={selectedVideoId === video.id}
className="aspect-[9/16] min-h-[118px] w-full"
onSelect={onSelect ? () => onSelect(video.id) : undefined}
onRegenerate={onRegenerate}
onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
/>
))}
{Array.from({ length: emptyCount }).map((_, index) => (
<div key={`empty-video-${index}`} className="flex aspect-[9/16] min-h-[86px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[9.5px] leading-tight text-white/26">
{enabled ? `候选 ${visible.length + index + 1}` : "待首尾帧"}
<div key={`empty-video-${index}`} className="flex aspect-[9/16] min-h-[118px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[9.5px] leading-tight text-white/26">
{enabled ? `候选 ${visible.length + index + 1}` : "待帧"}
</div>
))}
</div>
{videos.length > 6 && (
<div className="mt-1 text-[10px] text-white/34"> {videos.length - 6} </div>
)}
</div>
)
}
@@ -4503,11 +5077,17 @@ function StoryboardVideoPreview({
job,
video,
className = "h-20 w-12",
selected = false,
onSelect,
onRegenerate,
onDelete,
}: {
job: Job
video: GeneratedVideo
className?: string
selected?: boolean
onSelect?: () => void
onRegenerate?: () => void
onDelete?: () => void
}) {
const src = videoSrc(video)
@@ -4518,15 +5098,19 @@ function StoryboardVideoPreview({
kind="video"
src={src && video.status === "completed" ? src : undefined}
poster={poster}
href={src || undefined}
href={onSelect ? undefined : src || undefined}
alt={`片段 ${shortId(video.id)}`}
label={`${shortId(video.id)} · ${video.model}`}
meta={video.status}
className={`shrink-0 bg-black/45 ${className}`}
objectFit="cover"
selected={selected}
onClick={onSelect}
title={`${video.model} · ${video.status}`}
bottom={<span className="block truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}</span>}
topLeft={selected ? <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-400 text-black"><Check className="h-3 w-3" /></span> : undefined}
topRight={running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : undefined}
actions={onRegenerate ? [{ key: "regen", label: "重生一个候选", icon: <RefreshCw className="h-3 w-3" />, onClick: onRegenerate, tone: "cyan" }] : []}
onDelete={onDelete}
deleteLabel="删除这个视频候选"
/>

View File

@@ -188,6 +188,13 @@ export interface StoryboardScene {
needs_product?: boolean
needs_subject?: boolean
subject_brief?: string
skg_copy_en?: string
skg_copy_zh?: string
scene_one_line_en?: string
scene_one_line_zh?: string
action_one_line_en?: string
action_one_line_zh?: string
selected_video_id?: string
first_frame_plan?: string
last_frame_plan?: string
product_placement?: string
@@ -203,6 +210,33 @@ export interface StoryboardScene {
reference_ids?: string[]
}
export interface QuickStoryboardPlanInput {
skg_copy_en?: string
skg_copy_zh?: string
scene_one_line_en?: string
scene_one_line_zh?: string
action_one_line_en?: string
action_one_line_zh?: string
subject_brief?: string
duration?: number
visual_mode?: StoryboardScene["visual_mode"]
needs_product?: boolean
needs_subject?: boolean
}
export interface RefineStoryboardResult {
items: {
skg_copy_en: string
skg_copy_zh: string
scene_one_line_en: string
scene_one_line_zh: string
action_one_line_en: string
action_one_line_zh: string
}
model: string
error?: string
}
export interface GeneratedVideo {
id: string
provider_id?: string
@@ -1180,12 +1214,64 @@ export async function updateStoryboard(
return res.json()
}
export async function quickPlanStoryboard(
jobId: string,
frameIdx: number,
body: QuickStoryboardPlanInput,
): Promise<StoryboardScene> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/quick-plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`quickPlanStoryboard ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function refineStoryboard(
jobId: string,
frameIdx: number,
body: { current_plan: QuickStoryboardPlanInput; user_feedback: string },
): Promise<RefineStoryboardResult> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard/refine`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`refineStoryboard ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function batchGenerateAll(
jobId: string,
body: { count_per_row?: number; concurrency?: number; model?: string; size?: string },
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard/batch-generate-all`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`batchGenerateAll ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function generateStoryboardVideo(
jobId: string,
frameIdx: number,
body: {
prompt: string
duration?: number
count?: number
seed?: number | null
first_image?: ImageRef | null
last_image?: ImageRef | null
product_images?: ImageRef[]