feat: add audio storyboard planning table
This commit is contained in:
@@ -588,7 +588,7 @@
|
||||
<tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr>
|
||||
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。</td></tr>
|
||||
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 音频解析工作表;“开始”编排状态只负责在下载完成后自动触发 <code>triggerTranscribe</code>,不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。</td></tr>
|
||||
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
|
||||
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方新增 SKG 分镜规划表:按逐句时间轴生成结构作用、SKG 新文案、画面规划、产品融入,并支持逐行定向抽参考帧。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
|
||||
<tr><td><code>web/app/login/page.tsx</code></td><td>生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。</td></tr>
|
||||
<tr><td><code>web/app/login/layout.tsx</code></td><td>登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 <code>/login</code> 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。</td></tr>
|
||||
<tr><td><code>web/components/login/oasis-canvas.tsx</code></td><td>登录页全屏动态视觉层:用 iframe 直接承载下载包 <code>web/public/oasis-source/index.html</code> 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 <code>postMessage</code> 转发给 iframe,避免登录面板或输入框遮挡时草地失去鼠标响应。</td></tr>
|
||||
@@ -626,6 +626,7 @@ web/app/page.tsx
|
||||
-> 音频解析工作表:web/components/ad-recreation-board.tsx
|
||||
-> 开始:创建/激活 job → 下载完成后自动触发音频处理
|
||||
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动)
|
||||
-> SKG 分镜规划表:逐句时间轴 → 结构作用 / SKG 新文案 / 画面规划 / 产品融入 → 定向抽参考帧
|
||||
-> 底部音频条:不再渲染,音频结果集中到右侧工作表
|
||||
-> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口)
|
||||
-> API 契约:web/lib/api.ts
|
||||
@@ -650,6 +651,11 @@ api/main.py
|
||||
<div><strong>主要源码</strong><span><code>AudioIntakePanel</code> / <code>AudioIntakeStatus</code> in <code>web/components/ad-recreation-board.tsx</code>;复用 <code>triggerTranscribe</code> 和 <code>AudioScript</code>。</span></div>
|
||||
<div><strong>适合怎么描述</strong><span>“原视频播放、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。</span></div>
|
||||
</div>
|
||||
<div class="flow-row">
|
||||
<div><strong>你看到的区域</strong><span>SKG 分镜规划表</span></div>
|
||||
<div><strong>主要源码</strong><span><code>AudioStoryboardPlanPanel</code>、<code>buildAudioStoryboardRows</code> in <code>web/components/ad-recreation-board.tsx</code>;逐行定向抽帧复用 <code>onAddManualFrameForJob</code>。</span></div>
|
||||
<div><strong>适合怎么描述</strong><span>“按音频逐句生成产品分镜、每行怎样改写 SKG 文案、如何定向抽帧和进入元素提取”。</span></div>
|
||||
</div>
|
||||
<div class="flow-row">
|
||||
<div><strong>你看到的区域</strong><span>旧深度素材面板(当前不作为主路径)</span></div>
|
||||
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 <code>applyCleanedFrame</code>,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:顶部选择 5 个内置透明骨架人角色之一,并常驻显示桌面四张真实 SKG 产品角度图;每行只显示已预填场景/产品使用/享受描述、秒数、生成按钮和可横向追加的视频历史结果,产品图和结果视频都支持鼠标停留放大预览。描述词内置 36 条镜头语言模板,按“建立出场、产品入画、佩戴贴合、使用感受、生活延展、收尾记忆”排列,并且会按角色自动改写场景气质、使用动作和享受状态。每行还内置角色参考图调度:例如正面/半身用于出场,侧面/背部特写用于佩戴贴合,半身/背部特写用于收尾产品记忆点。点击“换一组”只刷新 6 行描述词。四张桌面 SKG 产品图是真实产品真源,所选角色 7 张参考图是人物身份参考,生成时分别通过 <code>copyProductLibraryAsset</code> 与 <code>copyCharacterLibraryAssets</code> 自动写入当前 job;视频 prompt 要求产品作为外置刚性实物合成到后颈外侧,禁止穿模、融进透明身体或重绘产品。不再暴露产品角度槽、产品融合辅助栏、产品图库选择器或首尾帧槽。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页保留给旧流程/单独生图,不再是产品融合必填步骤。相关接口包括 <code>cleanupFrame</code>、<code>applyCleanedFrame</code>、<code>addElement</code>、<code>generateSubjectAssets</code>、<code>generateSceneAsset</code>、<code>copyProductLibraryAsset</code> 和 <code>copyCharacterLibraryAssets</code>。</span></div>
|
||||
@@ -941,6 +947,18 @@ SubjectAsset {
|
||||
<h2>变更记录</h2>
|
||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||
<div class="changelog">
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-17 · 新增 SKG 分镜规划表</h3>
|
||||
<span class="tag rose">UI</span>
|
||||
<span class="tag cyan">Workflow</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>信息流复刻不应该先全量抽帧和提元素,也不能空写分镜;主线应先按音频内容规划产品分镜,再按每条分镜定向抽参考帧。</p>
|
||||
<p><strong>改动:</strong><code>web/components/ad-recreation-board.tsx</code> 新增 <code>AudioStoryboardPlanPanel</code>:从 <code>job.transcript</code> 生成逐行 SKG 分镜规划表,包含时间段、原内容、结构作用、SKG 新文案、画面规划、产品融入和参考帧状态。每行“抽参考帧”调用现有手动加帧接口,在对应时间段中点抽取原视频参考帧。</p>
|
||||
<p><strong>影响:</strong><code>web/components/ad-recreation-board.tsx</code>、<code>docs/source-analysis.html</code>。当前第一版先做前端规划和定向抽帧入口,暂不新增后端持久化字段;后续可在此表基础上接入模型改写、元素提取和单条视频生成。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-17 · 平滑音频波形播放线</h3>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user