feat: simplify storyboard video card flow
This commit is contained in:
@@ -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="删除这个视频候选"
|
||||
/>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user