feat: add audio storyboard planning table

This commit is contained in:
2026-05-17 15:48:14 +08:00
parent 68e75990b8
commit cd135aeed9
2 changed files with 228 additions and 1 deletions

View File

@@ -66,6 +66,19 @@ type AudioFeature = {
type AudioFeatureStatus = "idle" | "loading" | "ready" | "failed"
type AudioStoryboardRow = {
index: number
start: number
end: number
source: string
role: string
skgCopy: string
visualPlan: string
referencePlan: string
keyElements: string
productIntegration: string
}
const controlClass =
"h-10 rounded-md border border-white/10 bg-black/55 px-3 text-[12px] text-white outline-none transition focus:border-cyan-300/60 disabled:cursor-not-allowed disabled:opacity-40"
@@ -205,6 +218,75 @@ function buildFallbackScene(job: Job, frame: KeyFrame, order: number): Storyboar
}
}
function classifyAudioRole(text: string, index: number, total: number) {
const lower = text.toLowerCase()
if (index === 0) return "开场钩子"
if (index >= total - 2 || /discount|code|shipping|link|limited|sold out|grab|recommend|tiktok/.test(lower)) return "转化收口"
if (/can't|dont|don't|if |when |tired|stress|pain|crave|bloated|puffy|ready/.test(lower)) return "痛点推进"
if (/help|can |reduce|improve|relax|lower|stabilize|clear|less/.test(lower)) return "利益证明"
if (/use|try|apple|product|bottle|one month/.test(lower)) return "方案过渡"
return "节奏承接"
}
function buildSkgCopy(role: string, index: number) {
const variants: Record<string, string[]> = {
"开场钩子": [
"如果你也经常低头刷手机、久坐办公,肩颈紧绷可能已经在悄悄影响状态。",
"每天盯屏几个小时,脖子和肩膀的疲惫会比你想得更早出现。",
],
"痛点推进": [
"脖子发紧、肩膀沉、抬头不舒服,不一定要等到很难受才处理。",
"通勤、办公、带娃、刷手机叠在一起,肩颈很容易一直处在紧绷状态。",
],
"利益证明": [
"SKG 颈部按摩仪贴合后颈和肩颈两侧,把热敷感和揉按感带到真正紧的位置。",
"戴上后不用占手,工作间隙、居家放松、睡前都能快速进入舒缓节奏。",
],
"方案过渡": [
"这一镜把原片的讲解节奏换成 SKG 使用步骤:拿起、佩戴、贴合、放松。",
"让产品自然进入画面,重点不是硬推,而是把肩颈紧绷到放松的变化拍清楚。",
],
"转化收口": [
"如果你也想把肩颈放松变成日常习惯,可以先从这台 SKG 开始。",
"最后用清晰产品特写和轻松状态收住,让用户知道现在就可以入手。",
],
"节奏承接": [
"延续原片短句快节奏,把每一句都落到一个具体肩颈场景或产品动作。",
"这一句作为过渡,画面从痛点切到产品,让节奏继续往下走。",
],
}
const list = variants[role] ?? variants["节奏承接"]
return list[index % list.length]
}
function buildVisualPlan(role: string) {
if (role === "开场钩子") return "竖屏近景口播开场,人物轻揉脖子或转动肩颈,直接建立疲惫感。"
if (role === "痛点推进") return "沿用原视频的表情、手势和节奏,画面强调低头、久坐、肩颈紧绷。"
if (role === "利益证明") return "产品进入画面并佩戴到后颈,切到肩颈贴合、按键、热敷/揉按感的细节。"
if (role === "转化收口") return "产品清晰特写 + 人物放松表情收尾,保留信息流广告的快速行动感。"
return "保持原片同类构图和运镜,把画面内容替换成 SKG 肩颈放松场景。"
}
function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] {
if (!job?.transcript.length) return []
return job.transcript.map((segment, index) => {
const source = segment.zh?.trim() || segment.en?.trim() || "原音频文案待补充"
const role = classifyAudioRole(`${segment.en} ${segment.zh}`, index, job.transcript.length)
return {
index: segment.index,
start: segment.start,
end: segment.end,
source,
role,
skgCopy: buildSkgCopy(role, index),
visualPlan: buildVisualPlan(role),
referencePlan: `从原视频 ${segment.start.toFixed(1)}-${segment.end.toFixed(1)}s 定向抽 1-2 张参考帧。`,
keyElements: role === "利益证明" ? "佩戴动作、产品位置、手部按键、放松表情" : "口播构图、人物动作、表情节奏、场景光线",
productIntegration: "把原片产品/道具语境替换为 SKG 白色 U 形颈部按摩仪,产品必须外置佩戴在肩颈位置。",
}
})
}
export function AdRecreationBoard({
data,
onGenerateVideo,
@@ -461,6 +543,11 @@ export function AdRecreationBoard({
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<AudioIntakePanel job={job} />
<AudioStoryboardPlanPanel
job={job}
onAddFrame={data.onAddManualFrameForJob}
onOpenFrame={data.onOpenFramePanel}
/>
</div>
</section>
</div>
@@ -794,6 +881,128 @@ function AudioIntakePanel({ job }: { job: Job | null }) {
)
}
function AudioStoryboardPlanPanel({
job,
onAddFrame,
onOpenFrame,
}: {
job: Job | null
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
onOpenFrame?: (idx: number) => void
}) {
const [busyRow, setBusyRow] = useState<number | null>(null)
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
const framesForRow = (row: AudioStoryboardRow) =>
orderedFrames.filter((frame) => frame.timestamp >= row.start - 0.2 && frame.timestamp <= row.end + 0.2).slice(0, 3)
const addReferenceFrame = async (row: AudioStoryboardRow) => {
if (!job || !onAddFrame) return
const t = clampNumber((row.start + row.end) / 2, 0, Math.max(job.duration || row.end, row.end))
setBusyRow(row.index)
try {
await onAddFrame(job.id, t)
} finally {
setBusyRow(null)
}
}
if (!job) return null
return (
<section className="mt-3 rounded-lg border border-white/10 bg-black/28 p-2.5">
<div className="mb-2 flex items-start justify-between gap-3">
<div>
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="SKG 分镜规划表" />
<p className="mt-1 text-[11px] leading-snug text-white/42"></p>
</div>
<div className="grid shrink-0 grid-cols-3 gap-2 text-[11px] text-white/45">
<Requirement label="分镜" ready={rows.length > 0} detail={rows.length ? `${rows.length}` : "待音频"} />
<Requirement label="参考帧" ready={orderedFrames.length > 0} detail={orderedFrames.length ? `${orderedFrames.length}` : "待抽帧"} />
<Requirement label="主线" ready={rows.length > 0} detail="先规划" />
</div>
</div>
{rows.length ? (
<div className="overflow-hidden rounded-md border border-white/10">
<div className="grid grid-cols-[78px_86px_minmax(150px,0.9fr)_minmax(180px,1fr)_minmax(170px,1fr)_140px_112px] border-b border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] font-semibold text-white/50">
<div></div>
<div></div>
<div></div>
<div>SKG </div>
<div> / </div>
<div></div>
<div></div>
</div>
<div className="max-h-[420px] overflow-y-auto">
{rows.map((row) => {
const refs = framesForRow(row)
const busy = busyRow === row.index
return (
<div
key={row.index}
className="grid grid-cols-[78px_86px_minmax(150px,0.9fr)_minmax(180px,1fr)_minmax(170px,1fr)_140px_112px] gap-3 border-b border-white/8 px-3 py-2 text-[11.5px] leading-snug text-white/64 last:border-b-0"
>
<div className="font-mono text-[11px] text-white/40">{row.start.toFixed(1)}-{row.end.toFixed(1)}s</div>
<div>
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-emerald-100/75">{row.role}</span>
</div>
<div className="line-clamp-3" title={row.source}>{row.source}</div>
<div className="line-clamp-3 text-white/78" title={row.skgCopy}>{row.skgCopy}</div>
<div className="space-y-1">
<div className="line-clamp-2" title={row.visualPlan}>{row.visualPlan}</div>
<div className="line-clamp-2 text-white/42" title={row.productIntegration}>
<Package className="mr-1 inline h-3 w-3 text-rose-200/70" />
{row.productIntegration}
</div>
</div>
<div>
{refs.length ? (
<div className="flex items-center gap-1.5">
{refs.map((frame) => (
<button
key={frame.index}
type="button"
onClick={() => onOpenFrame?.(frame.index)}
className="h-12 w-8 overflow-hidden rounded border border-white/10 bg-black/45"
title={`参考帧 ${frame.timestamp.toFixed(1)}s`}
>
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
</button>
))}
</div>
) : (
<span className="text-white/32">{row.referencePlan}</span>
)}
</div>
<div className="space-y-1.5">
<button
type="button"
onClick={() => addReferenceFrame(row)}
disabled={!onAddFrame || busy}
className="inline-flex h-8 w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[11px] text-white/70 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-45"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
</button>
<div className="flex items-center gap-1 text-[10px] text-white/35">
<ImageIcon className="h-3 w-3" />
{refs.length ? "下一步提元素" : "先抽帧"}
</div>
</div>
</div>
)
})}
</div>
</div>
) : (
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成 SKG 分镜规划表。先看结构,再按分镜定向抽参考帧。" />
)}
</section>
)
}
function AudioWaveform({
features,
status,