feat: streamline storyboard video rows

This commit is contained in:
2026-05-19 15:11:08 +08:00
parent 980d252815
commit 64a9673fa1
3 changed files with 278 additions and 115 deletions

View File

@@ -436,6 +436,20 @@ function containsCjk(text: string) {
return /[\u3400-\u9fff]/.test(text)
}
type CompactStoryboardFieldKind = "copy" | "scene" | "action"
const STORYBOARD_VIDEO_COUNT_OPTIONS = [1, 2, 4, 6, 8, 12]
function storyboardFieldLabel(kind: CompactStoryboardFieldKind) {
if (kind === "copy") return "文案"
if (kind === "scene") return "场景一句话"
return "人物 + 产品 + 动作"
}
function clampVideoCount(value: number) {
return Math.round(clampNumber(Number.isFinite(value) ? value : 4, 1, 12))
}
async function ensureEnglishForModel(text: string) {
const trimmed = text.trim()
if (!trimmed || !containsCjk(trimmed)) return trimmed
@@ -3405,6 +3419,10 @@ function AudioStoryboardPlanPanel({
const [refinePreview, setRefinePreview] = useState<RefineStoryboardResult["items"] | null>(null)
const [panelOpen, setPanelOpen] = useState<Record<"product" | "batch", boolean>>({ product: true, batch: true })
const [rowSectionOpen, setRowSectionOpen] = useState<Record<string, boolean>>({})
const [rowVideoCounts, setRowVideoCounts] = useState<Record<number, number>>({})
const [batchVideoCount, setBatchVideoCount] = useState(4)
const [autoOptimizingField, setAutoOptimizingField] = useState<string | null>(null)
const [autoOptimizedChinese, setAutoOptimizedChinese] = useState<Record<string, string>>({})
const productFileRef = useRef<HTMLInputElement | null>(null)
const productPersistSeq = useRef(0)
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
@@ -3442,6 +3460,10 @@ function AudioStoryboardPlanPanel({
setRefinePreview(null)
setPanelOpen({ product: true, batch: true })
setRowSectionOpen({})
setRowVideoCounts({})
setBatchVideoCount(4)
setAutoOptimizingField(null)
setAutoOptimizedChinese({})
}, [job?.id])
const persistProductItems = async (items: ProductRefItem[]) => {
@@ -3475,6 +3497,12 @@ function AudioStoryboardPlanPanel({
setPlanOverrides((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] ?? {}), ...patch } }))
}
const videoCountForRow = (rowIndex: number) => clampVideoCount(rowVideoCounts[rowIndex] ?? batchVideoCount)
const patchRowVideoCount = (rowIndex: number, value: number) => {
setRowVideoCounts((prev) => ({ ...prev, [rowIndex]: clampVideoCount(value) }))
}
const savePromptToLibrary = async (
category: "scene_desc" | "video_desc" | "subject_desc" | "skg_script" | "product_angle",
name: string,
@@ -3996,17 +4024,18 @@ function AudioStoryboardPlanPanel({
const batchDrawAllRows = async () => {
if (!job || !rows.length) return
const count = clampVideoCount(batchVideoCount)
setBatchCardBusy(true)
try {
await saveAllStoryboardDrafts(true)
const updated = await batchGenerateAll(job.id, {
count_per_row: 4,
concurrency: 4,
count_per_row: count,
concurrency: 1,
model: "seedance",
size: "720x1280",
})
onJobUpdate?.(updated)
toast.success(`整片视频候选生成已启动:${rows.length} 条分镜 × 每条 4 个候选`)
toast.success(`整片视频候选生成已启动:${rows.length} 条分镜 × 每条 ${count} 个候选`)
} catch (e) {
toast.error("整片视频候选生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -4033,6 +4062,84 @@ function AudioStoryboardPlanPanel({
})
}
const applyFieldRefineItems = (rowIndex: number, field: CompactStoryboardFieldKind, items: RefineStoryboardResult["items"]) => {
if (field === "copy") {
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 })
return
}
if (field === "scene") {
patchRowPlan(rowIndex, {
sceneOneLine: items.scene_one_line_en,
sceneOneLineZh: items.scene_one_line_zh,
visualPlan: items.scene_one_line_en,
visualPlanZh: items.scene_one_line_zh,
})
return
}
patchRowPlan(rowIndex, {
actionOneLine: items.action_one_line_en,
actionOneLineZh: items.action_one_line_zh,
subjectDescription: items.action_one_line_en,
subjectDescriptionZh: items.action_one_line_zh,
})
}
const patchTranslatedFieldFallback = (rowIndex: number, field: CompactStoryboardFieldKind, zhValue: string, english: string) => {
if (field === "copy") {
setCopyOverrides((prev) => ({ ...prev, [rowIndex]: english }))
setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: zhValue }))
patchRowPlan(rowIndex, { skgCopy: english, skgCopyZh: zhValue })
return
}
if (field === "scene") {
patchRowPlan(rowIndex, { sceneOneLine: english, sceneOneLineZh: zhValue, visualPlan: english, visualPlanZh: zhValue })
return
}
patchRowPlan(rowIndex, { actionOneLine: english, actionOneLineZh: zhValue, subjectDescription: english, subjectDescriptionZh: zhValue })
}
const optimizeEnglishFromChinese = async (
row: AudioStoryboardRow,
frame: KeyFrame | null,
field: CompactStoryboardFieldKind,
zhValue: string,
) => {
if (!job || !frame) return
const trimmedZh = zhValue.trim()
if (!trimmedZh || !containsCjk(trimmedZh)) return
const cacheKey = `${row.index}:${field}`
if (autoOptimizedChinese[cacheKey] === trimmedZh) return
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
const currentPlan = quickInputForRow(plannedRow, frame)
if (field === "copy") currentPlan.skg_copy_zh = trimmedZh
if (field === "scene") currentPlan.scene_one_line_zh = trimmedZh
if (field === "action") currentPlan.action_one_line_zh = trimmedZh
setAutoOptimizingField(cacheKey)
try {
const result = await refineStoryboard(job.id, frame.index, {
current_plan: currentPlan,
user_feedback: `用户刚修改了「${storyboardFieldLabel(field)}」的中文字段。请把这个中文作为该字段的最新意思,优化对应英文主字段,让它适合英文视频生成 prompt 和短视频口播/动作描述。其他两个字段只做必要的轻微润色,不要改变语义。`,
})
applyFieldRefineItems(row.index, field, result.items)
setAutoOptimizedChinese((prev) => ({ ...prev, [cacheKey]: trimmedZh }))
if (result.error) toast.warning(`中文已更新,英文使用兜底优化:${result.error}`)
else toast.success(`${storyboardFieldLabel(field)}已按中文优化英文`)
} catch (e) {
try {
const english = await translateText(trimmedZh, "en")
patchTranslatedFieldFallback(row.index, field, trimmedZh, english)
setAutoOptimizedChinese((prev) => ({ ...prev, [cacheKey]: trimmedZh }))
toast.success(`${storyboardFieldLabel(field)}已翻译成英文`)
} catch {
toast.error("中文优化英文失败:" + (e instanceof Error ? e.message : String(e)))
}
} finally {
setAutoOptimizingField(null)
}
}
const submitRefine = async () => {
if (!job || !refineDialog) return
const row = rows.find((item) => item.index === refineDialog.rowIndex)
@@ -4238,6 +4345,19 @@ function AudioStoryboardPlanPanel({
{scriptRewriteBusy === "all" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
<label className="inline-flex h-9 items-center gap-1 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] font-semibold text-white/52">
<select
value={batchVideoCount}
onChange={(event) => setBatchVideoCount(clampVideoCount(Number(event.target.value)))}
className="h-6 rounded border border-white/10 bg-black/45 px-1 text-center font-mono text-[11px] text-white/78 outline-none focus:border-cyan-300/45"
>
{STORYBOARD_VIDEO_COUNT_OPTIONS.map((count) => (
<option key={count} value={count}>{count}</option>
))}
</select>
</label>
<button
type="button"
onClick={() => void batchDrawAllRows()}
@@ -4245,7 +4365,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"
>
{batchCardBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{rows.length}×4
{rows.length}×{batchVideoCount}
</button>
<button
type="button"
@@ -4283,6 +4403,7 @@ function AudioStoryboardPlanPanel({
const endpointSubjectBrief = plannedRow.needsSubject ? subjectBriefForEndpoint(plannedRow, subjectRefs) : ""
const fieldsOpen = isRowSectionOpen(row.index, "fields", true)
const videosOpen = isRowSectionOpen(row.index, "videos", false)
const rowVideoCount = videoCountForRow(row.index)
return (
<article
key={row.index}
@@ -4317,28 +4438,42 @@ function AudioStoryboardPlanPanel({
</button>
<button
type="button"
onClick={() => toggleRowSection(row.index, "fields", true)}
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"
onClick={() => patchRowVideoCount(row.index, Math.max(1, rowVideoCount - 1))}
disabled={rowVideoCount <= 1}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.045] text-white/58 transition hover:border-white/25 hover:text-white/82 disabled:cursor-not-allowed disabled:opacity-30"
aria-label="减少本行生成数量"
>
<ChevronDown className={`h-3.5 w-3.5 transition ${fieldsOpen ? "rotate-180" : ""}`} />
-
</button>
<label className="inline-flex h-8 items-center gap-1 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] font-semibold text-white/52">
<input
type="number"
min={1}
max={12}
value={rowVideoCount}
onChange={(event) => patchRowVideoCount(row.index, Number(event.target.value) || 1)}
className="h-5 w-10 rounded border border-white/10 bg-black/45 text-center font-mono text-[11px] text-white/82 outline-none focus:border-cyan-300/45"
/>
</label>
<button
type="button"
onClick={() => patchRowVideoCount(row.index, Math.min(12, rowVideoCount + 1))}
disabled={rowVideoCount >= 12}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.045] text-white/58 transition hover:border-white/25 hover:text-white/82 disabled:cursor-not-allowed disabled:opacity-30"
aria-label="增加本行生成数量"
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => toggleRowSection(row.index, "videos", false)}
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"
>
{rowVideos.length ? rowVideos.length : ""}
<ChevronDown className={`h-3.5 w-3.5 transition ${videosOpen ? "rotate-180" : ""}`} />
</button>
<button
type="button"
onClick={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
onClick={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
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
{rowVideoCount}
</button>
<button
type="button"
@@ -4353,60 +4488,73 @@ function AudioStoryboardPlanPanel({
<ChevronDown className={`h-3.5 w-3.5 transition ${advancedRows.has(row.index) ? "rotate-180" : ""}`} />
</button>
<button
type="button"
onClick={() => toggleRowSection(row.index, "fields", true)}
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"
>
{fieldsOpen ? "收起" : "展开"}
<ChevronDown className={`h-3.5 w-3.5 transition ${fieldsOpen ? "rotate-180" : ""}`} />
</button>
</div>
</div>
{fieldsOpen ? (
<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>
) : null}
{!advancedRows.has(row.index) && (videosOpen || rowVideos.length || quickVideoBusyRow === row.index) ? (
<div className="mt-2 grid gap-3 xl:grid-cols-[minmax(280px,0.82fr)_minmax(420px,1.18fr)]">
<div className="grid min-w-0 gap-1.5">
<CompactStoryboardField
label="文案"
value={copyText}
zhValue={copyZhText}
showChinese={showChineseMirror}
optimizing={autoOptimizingField === `${row.index}:copy`}
onChange={(value) => patchRowCopy(row.index, value)}
onChangeZh={(value) => patchRowCopyZh(row.index, value)}
onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "copy", value)}
onSave={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 文案`, copyText, copyZhText)}
onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")}
/>
<CompactStoryboardField
label="场景一句话"
value={plannedRow.sceneOneLine}
zhValue={plannedRow.sceneOneLineZh}
showChinese={showChineseMirror}
optimizing={autoOptimizingField === `${row.index}:scene`}
onChange={(value) => patchRowPlan(row.index, { sceneOneLine: value, visualPlan: value })}
onChangeZh={(value) => patchRowPlan(row.index, { sceneOneLineZh: value, visualPlanZh: value })}
onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "scene", 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}
optimizing={autoOptimizingField === `${row.index}:action`}
onChange={(value) => patchRowPlan(row.index, { actionOneLine: value, subjectDescription: value })}
onChangeZh={(value) => patchRowPlan(row.index, { actionOneLineZh: value, subjectDescriptionZh: value })}
onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "action", value)}
onSave={() => void savePromptToLibrary("video_desc", `分镜 ${row.index + 1} 人物产品动作`, plannedRow.actionOneLine, plannedRow.actionOneLineZh)}
onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")}
/>
</div>
<StoryboardVideoSlots
job={job}
videos={rowVideos}
enabled={!!referenceFrame}
expanded={videosOpen}
selectedVideoId={referenceFrame?.storyboard?.selected_video_id ?? ""}
busy={quickVideoBusyRow === row.index}
onToggleExpanded={() => toggleRowSection(row.index, "videos", false)}
onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
onDeleteVideo={onDeleteVideo}
/>
</div>
) : null}
</div>
@@ -4626,9 +4774,11 @@ function AudioStoryboardPlanPanel({
expanded={videosOpen}
selectedVideoId={referenceFrame?.storyboard?.selected_video_id ?? ""}
busy={quickVideoBusyRow === row.index}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
onToggleExpanded={() => toggleRowSection(row.index, "videos", false)}
onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, 4)}
onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
@@ -4922,8 +5072,10 @@ function CompactStoryboardField({
showChinese,
onChange,
onChangeZh,
onChineseCommit,
onSave,
onPick,
optimizing = false,
}: {
label: string
value: string
@@ -4931,14 +5083,17 @@ function CompactStoryboardField({
showChinese: boolean
onChange: (value: string) => void
onChangeZh?: (value: string) => void
onChineseCommit?: (value: string) => void
onSave?: () => void
onPick?: () => void
optimizing?: boolean
}) {
return (
<div className="min-w-0 rounded-md border border-white/10 bg-black/28 p-2">
<div className="min-w-0 rounded-md border border-white/10 bg-black/28 p-1.5">
<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">
{optimizing ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100/80" /> : null}
<button
type="button"
onClick={onSave}
@@ -4963,14 +5118,15 @@ function CompactStoryboardField({
<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"
className="min-h-[40px] 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"
onBlur={(event) => onChineseCommit?.(event.currentTarget.value)}
placeholder="改中文后自动优化英文"
className="mt-1 min-h-[28px] w-full resize-none rounded border border-white/8 bg-black/22 px-2 py-1 text-[10px] leading-snug text-white/46 outline-none placeholder:text-white/22 focus:border-cyan-300/35"
/>
) : null}
</div>
@@ -4981,10 +5137,10 @@ function StoryboardVideoSlots({
job,
videos,
enabled,
expanded = false,
selectedVideoId = "",
busy = false,
onToggleExpanded,
count = 4,
onCountChange,
onDraw,
onReroll,
onRegenerate,
@@ -4998,6 +5154,8 @@ function StoryboardVideoSlots({
expanded?: boolean
selectedVideoId?: string
busy?: boolean
count?: number
onCountChange?: (count: number) => void
onToggleExpanded?: () => void
onDraw?: () => void
onReroll?: () => void
@@ -5007,29 +5165,36 @@ function StoryboardVideoSlots({
onDeleteVideo?: (videoId: string) => void
}) {
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)
const runningCount = videos.filter((video) => video.status === "queued" || video.status === "in_progress").length
const selectedVideo = selectedVideoId ? videos.find((video) => video.id === selectedVideoId) : null
const miniVideos = videos.slice(0, 10)
const targetCount = clampVideoCount(count)
const emptyCount = visible.length ? 0 : Math.max(1, targetCount)
return (
<div className="mt-2 rounded-md border border-white/10 bg-black/24 p-1.5">
<div className="min-w-0 rounded-md border border-white/10 bg-black/24 p-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<button
type="button"
onClick={onToggleExpanded}
className="flex min-w-0 flex-1 items-center gap-2 rounded px-1.5 py-1 text-left transition hover:bg-white/[0.045]"
aria-expanded={expanded}
>
<div className="flex min-w-0 flex-1 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>
<span className="text-[11px] font-semibold text-white/66"></span>
<span className="shrink-0 text-[10px] text-white/34">
{videos.length ? `${videos.length}${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待生成" : "待抽帧"}
</span>
{selectedVideo ? <span className="rounded border border-emerald-300/20 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] text-emerald-100/72"> {shortId(selectedVideo.id)}</span> : null}
<ChevronDown className={`ml-auto h-3.5 w-3.5 shrink-0 text-white/42 transition ${expanded ? "rotate-180" : ""}`} />
</button>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<label className="inline-flex h-7 items-center gap-1 rounded-md border border-white/10 bg-black/36 px-1.5 text-[10px] font-semibold text-white/48">
<select
value={targetCount}
onChange={(event) => onCountChange?.(Number(event.target.value))}
disabled={!onCountChange}
className="h-5 rounded border border-white/10 bg-black/45 px-1 text-center font-mono text-[10.5px] text-white/78 outline-none disabled:opacity-45"
>
{STORYBOARD_VIDEO_COUNT_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</label>
<button
type="button"
onClick={videos.length ? onReroll : onDraw}
@@ -5037,7 +5202,7 @@ function StoryboardVideoSlots({
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 条"}
{videos.length ? `再生成 ${targetCount}` : `生成 ${targetCount}`}
</button>
<button
type="button"
@@ -5050,49 +5215,34 @@ function StoryboardVideoSlots({
</button>
</div>
</div>
{!expanded ? (
miniVideos.length ? (
<div className="mt-1 flex min-h-10 items-center gap-1 overflow-x-auto px-1 pb-0.5">
{miniVideos.map((video) => {
const thumb = videoPoster(job, video) || videoSrc(video)
const running = video.status === "queued" || video.status === "in_progress"
return (
<button
key={video.id}
type="button"
onClick={() => onSelect?.(video.id)}
className={`relative h-10 w-7 shrink-0 overflow-hidden rounded border transition ${selectedVideoId === video.id ? "border-emerald-300/75" : "border-white/12 hover:border-cyan-300/45"}`}
title={`${shortId(video.id)} · ${video.status}`}
>
{thumb ? <img src={thumb} alt="" className="h-full w-full object-cover" /> : <span className="flex h-full w-full items-center justify-center bg-black text-[8px] text-white/30"></span>}
{running ? <span className="absolute inset-0 flex items-center justify-center bg-black/55"><Loader2 className="h-3 w-3 animate-spin text-cyan-100" /></span> : null}
{selectedVideoId === video.id ? <span className="absolute right-0 top-0 rounded-bl bg-emerald-400 p-0.5 text-black"><Check className="h-2.5 w-2.5" /></span> : null}
</button>
)
})}
</div>
) : null
) : (
<div className="mt-1 grid max-h-[300px] grid-cols-2 gap-1.5 overflow-y-auto pr-1 sm:grid-cols-4">
<div className="mt-2 flex min-h-[170px] items-stretch gap-2 overflow-x-auto pb-1">
{visible.map((video) => (
<StoryboardVideoPreview
key={video.id}
job={job}
video={video}
selected={selectedVideoId === video.id}
className="aspect-[9/16] min-h-[92px] w-full"
className="h-[168px] w-[94px]"
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-[92px] 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 h-[168px] w-[94px] shrink-0 items-center justify-center rounded-md border border-dashed border-white/12 bg-black/25 px-2 text-center text-[9.5px] leading-tight text-white/26">
{enabled ? `待生成 ${index + 1}` : "待抽帧"}
</div>
))}
<button
type="button"
onClick={videos.length ? onReroll : onDraw}
disabled={!enabled || busy}
className="flex h-[168px] w-[94px] shrink-0 flex-col items-center justify-center gap-2 rounded-md border border-cyan-300/18 bg-cyan-300/[0.055] px-2 text-center text-[10.5px] font-semibold text-cyan-100/72 transition hover:border-cyan-300/48 hover:text-cyan-50 disabled:cursor-not-allowed disabled:opacity-35"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{videos.length ? `追加 ${targetCount}` : `生成 ${targetCount}`}
</button>
</div>
)}
</div>
)
}