diff --git a/.memory/worklog.json b/.memory/worklog.json
index 7f0d72c..4eb7b72 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,122 +1,5 @@
{
"entries": [
- {
- "files_changed": 1,
- "hash": "e9e2acc",
- "message": "auto-save 2026-05-15 09:05 (~1)",
- "ts": "2026-05-15T09:06:31+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:05 (~1)",
- "ts": "2026-05-15T01:11:06Z",
- "type": "session-heartbeat"
- },
- {
- "files_changed": 1,
- "hash": "d808666",
- "message": "auto-save 2026-05-15 09:11 (~1)",
- "ts": "2026-05-15T09:12:04+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:11 (~1)",
- "ts": "2026-05-15T01:13:35Z",
- "type": "session-heartbeat"
- },
- {
- "files_changed": 1,
- "hash": "3c93195",
- "message": "auto-save 2026-05-15 09:17 (~1)",
- "ts": "2026-05-15T09:17:35+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:17 (~1)",
- "ts": "2026-05-15T01:21:07Z",
- "type": "session-heartbeat"
- },
- {
- "files_changed": 1,
- "hash": "fccc272",
- "message": "auto-save 2026-05-15 09:22 (~1)",
- "ts": "2026-05-15T09:23:08+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:22 (~1)",
- "ts": "2026-05-15T01:23:35Z",
- "type": "session-heartbeat"
- },
- {
- "files_changed": 1,
- "hash": "cd700f1",
- "message": "auto-save 2026-05-15 09:28 (~1)",
- "ts": "2026-05-15T09:28:41+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:28 (~1)",
- "ts": "2026-05-15T01:31:07Z",
- "type": "session-heartbeat"
- },
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:28 (~1)",
- "ts": "2026-05-15T01:33:35Z",
- "type": "session-heartbeat"
- },
- {
- "files_changed": 1,
- "hash": "055b9d8",
- "message": "auto-save 2026-05-15 09:34 (~1)",
- "ts": "2026-05-15T09:34:14+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "hash": "cec7e66",
- "message": "auto-save 2026-05-15 09:39 (~1)",
- "ts": "2026-05-15T09:39:48+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:39 (~1)",
- "ts": "2026-05-15T01:41:07Z",
- "type": "session-heartbeat"
- },
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:39 (~1)",
- "ts": "2026-05-15T01:43:35Z",
- "type": "session-heartbeat"
- },
- {
- "files_changed": 1,
- "hash": "0759520",
- "message": "auto-save 2026-05-15 09:45 (~1)",
- "ts": "2026-05-15T09:45:26+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "hash": "ec7eaef",
- "message": "auto-save 2026-05-15 09:50 (~1)",
- "ts": "2026-05-15T09:50:59+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:50 (~1)",
- "ts": "2026-05-15T01:51:07Z",
- "type": "session-heartbeat"
- },
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 09:50 (~1)",
@@ -3272,6 +3155,123 @@
"message": "refactor: place audio results side by side",
"hash": "78d47b8",
"files_changed": 3
+ },
+ {
+ "ts": "2026-05-17T14:38:19+08:00",
+ "type": "commit",
+ "message": "refactor: unify audio result panel",
+ "hash": "27a6ef0",
+ "files_changed": 3
+ },
+ {
+ "ts": "2026-05-17T06:38:25Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:refactor: unify audio result panel",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-17T14:44:33+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-17 14:44 (~2)",
+ "hash": "c6eb3ae",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-17T06:48:25Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:auto-save 2026-05-17 14:44 (~2)",
+ "files_changed": 3
+ },
+ {
+ "ts": "2026-05-17T14:49:55+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-17 14:49 (~3)",
+ "hash": "38ed5bb",
+ "files_changed": 3
+ },
+ {
+ "ts": "2026-05-17T14:55:16+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-17 14:55 (~2)",
+ "hash": "fbfbd59",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-17T14:58:12+08:00",
+ "type": "commit",
+ "message": "feat: add synced video waveform timeline",
+ "hash": "120dacf",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-17T06:58:25Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add synced video waveform timeline",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-17T15:05:10+08:00",
+ "type": "commit",
+ "message": "fix: show real audio pitch waveform",
+ "hash": "365053a",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-17T07:08:25Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: show real audio pitch waveform",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-17T07:18:25Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:fix: show real audio pitch waveform",
+ "files_changed": 3
+ },
+ {
+ "ts": "2026-05-17T15:21:09+08:00",
+ "type": "commit",
+ "message": "fix: render continuous audio waveform",
+ "hash": "9a95a53",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-17T15:27:06+08:00",
+ "type": "commit",
+ "message": "fix: smooth waveform playback cursor",
+ "hash": "68e7599",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-17T07:28:26Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: smooth waveform playback cursor",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-17T07:38:26Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: smooth waveform playback cursor",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-17T15:48:14+08:00",
+ "type": "commit",
+ "message": "feat: add audio storyboard planning table",
+ "hash": "cd135ae",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-17T07:48:26Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add audio storyboard planning table",
+ "files_changed": 1
+ },
+ {
+ "ts": "2026-05-17T07:58:26Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:feat: add audio storyboard planning table",
+ "files_changed": 3
}
]
}
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 97ce276..1d7199a 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -569,12 +569,13 @@
业务管线
- 当前产品方向已收窄为“信息流广告快速复刻第一步”:主界面左侧是素材输入列,右侧是音频解析工作表。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。抽帧、分镜规划、产品融入、元素 6 视图和视频合成暂作为后续能力保留,不在当前开始流程里自动触发。
+ 当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧先完成音频解析,再进入信息流复刻分镜工作台。用户粘贴 TK 链接或上传视频后点击“开始”,系统自动下载源视频;下载完成后优先提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效。分镜规划按逐句时间轴生成,抽帧和视频生成由用户按单条分镜触发,不在当前开始流程里自动全量运行。
1
导入素材 粘贴 TK / 信息流视频链接或上传本地视频;“开始”只把任务放入第一步队列。
2
下载源视频 后端用 yt-dlp 或本地上传文件落 source.mp4,记录时长、尺寸和视频只读地址。
3
解析音频 从 source.mp4 提取 audio.wav,ASR 提取原文案,翻译成中文,并写入逐句时间轴。
4
声音分析 用音频模型分析讲话人、口播节奏、停顿、背景音乐/环境声/音效;不默认改写配音或生成视频。
+
5
分镜生成 按逐句时间轴生成竖向分镜行,单行内从左到右承接原内容、新口播、画面规划、参考帧和候选视频。
@@ -588,7 +589,7 @@
web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。
web/app/globals.css全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。
web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 音频解析工作表;“开始”编排状态只负责在下载完成后自动触发 triggerTranscribe,不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。
- web/components/ad-recreation-board.tsx信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方新增 SKG 分镜规划表:按逐句时间轴生成结构作用、SKG 新文案、画面规划、产品融入,并支持逐行定向抽参考帧。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。
+ web/components/ad-recreation-board.tsx信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和生成视频;单条生成会先把该行规划保存为对应关键帧分镜,再复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。
web/app/login/page.tsx生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。
web/app/login/layout.tsx登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 /login 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。
web/components/login/oasis-canvas.tsx登录页全屏动态视觉层:用 iframe 直接承载下载包 web/public/oasis-source/index.html 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 postMessage 转发给 iframe,避免登录面板或输入框遮挡时草地失去鼠标响应。
@@ -626,7 +627,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
@@ -652,9 +653,9 @@ api/main.py
适合怎么描述 “原视频播放、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。
-
你看到的区域 SKG 分镜规划表
-
主要源码 AudioStoryboardPlanPanel、buildAudioStoryboardRows in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob。
-
适合怎么描述 “按音频逐句生成产品分镜、每行怎样改写 SKG 文案、如何定向抽帧和进入元素提取”。
+
你看到的区域 信息流复刻分镜工作台
+
主要源码 AudioStoryboardPlanPanel、buildAudioStoryboardRows、buildStoryboardSceneFromAudioRow in web/components/ad-recreation-board.tsx;逐行定向抽帧复用 onAddManualFrameForJob,单条生成复用 onGenerateVideo 和 PUT /frames/{idx}/storyboard。
+
适合怎么描述 “按音频逐句生成产品分镜、每行怎样改写口播、如何抽参考帧、生成的视频应该回显到哪一行”。
你看到的区域 旧深度素材面板(当前不作为主路径)
@@ -949,14 +950,14 @@ SubjectAsset {
- 2026-05-17 · 新增 SKG 分镜规划表
+ 2026-05-17 · 新增信息流复刻分镜工作台
UI
Workflow
-
问题: 信息流复刻不应该先全量抽帧和提元素,也不能空写分镜;主线应先按音频内容规划产品分镜,再按每条分镜定向抽参考帧。
-
改动: web/components/ad-recreation-board.tsx 新增 AudioStoryboardPlanPanel:从 job.transcript 生成逐行 SKG 分镜规划表,包含时间段、原内容、结构作用、SKG 新文案、画面规划、产品融入和参考帧状态。每行“抽参考帧”调用现有手动加帧接口,在对应时间段中点抽取原视频参考帧。
-
影响: web/components/ad-recreation-board.tsx、docs/source-analysis.html。当前第一版先做前端规划和定向抽帧入口,暂不新增后端持久化字段;后续可在此表基础上接入模型改写、元素提取和单条视频生成。
+
问题: 信息流复刻不应该先全量抽帧和提元素,也不能空写分镜;主线应先按音频内容规划产品分镜,再按每条分镜定向抽参考帧,并把生成出的视频回挂到对应分镜。
+
改动: web/components/ad-recreation-board.tsx 新增 AudioStoryboardPlanPanel:从 job.transcript 生成纵向分镜行,每行内部从左到右展示时间/结构、原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和生成视频。每行“抽参考帧”调用现有手动加帧接口,在对应时间段中点抽取原视频参考帧;“生成本条”会把该行规划保存到对应关键帧分镜,并复用现有 onGenerateVideo 提交 Seedance 候选,候选视频按 frame_idx 回显在该行右侧。
+
影响: web/components/ad-recreation-board.tsx、docs/source-analysis.html。当前不新增后端字段,继续复用 KeyFrame.storyboard 和 GeneratedVideo.frame_idx;后续模型改写、关键元素 6 视图和完整合成可以在该分镜行内继续接入。
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index cdbbcfd..3fea8b9 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -287,6 +287,19 @@ function buildAudioStoryboardRows(job: Job | null): AudioStoryboardRow[] {
})
}
+function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFrame, nextFrame?: KeyFrame | null): StoryboardScene {
+ return {
+ duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
+ first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${row.index + 1} 参考帧` },
+ last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${row.index + 1} 尾帧` } : null,
+ subject: row.keyElements,
+ scene: `${row.visualPlan}\n原音频依据:${row.source}`,
+ product: row.productIntegration,
+ action: row.skgCopy,
+ reference_ids: [],
+ }
+}
+
export function AdRecreationBoard({
data,
onGenerateVideo,
@@ -547,6 +560,8 @@ export function AdRecreationBoard({
job={job}
onAddFrame={data.onAddManualFrameForJob}
onOpenFrame={data.onOpenFramePanel}
+ onJobUpdate={data.onJobUpdate}
+ onGenerateVideo={onGenerateVideo}
/>
@@ -885,18 +900,28 @@ function AudioStoryboardPlanPanel({
job,
onAddFrame,
onOpenFrame,
+ onJobUpdate,
+ onGenerateVideo,
}: {
job: Job | null
onAddFrame?: (jobId: string, t: number) => Promise
| void
onOpenFrame?: (idx: number) => void
+ onJobUpdate?: (job: Job) => void
+ onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise | void
}) {
const [busyRow, setBusyRow] = useState(null)
+ const [videoBusyRow, setVideoBusyRow] = useState(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 videosForRow = (refs: KeyFrame[]) => {
+ const refIndices = new Set(refs.map((frame) => frame.index))
+ return (job?.generated_videos ?? []).filter((video) => refIndices.has(video.frame_idx))
+ }
+
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))
@@ -908,101 +933,176 @@ function AudioStoryboardPlanPanel({
}
}
+ const generateRowVideo = async (row: AudioStoryboardRow, refs: KeyFrame[]) => {
+ if (!job || !refs.length || !onGenerateVideo) return
+ const frame = refs[0]
+ const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
+ const scene = buildStoryboardSceneFromAudioRow(row, frame, nextFrame)
+ setVideoBusyRow(row.index)
+ try {
+ const updated = await updateStoryboard(job.id, frame.index, scene)
+ onJobUpdate?.(updated)
+ await onGenerateVideo(frame.index, scene, "seedance")
+ } catch (e) {
+ toast.error("生成本条视频失败:" + (e instanceof Error ? e.message : String(e)))
+ } finally {
+ setVideoBusyRow(null)
+ }
+ }
+
if (!job) return null
return (
-
} title="SKG 分镜规划表" />
-
先按音频内容规划产品分镜,再按每条分镜定向抽参考帧和关键元素。
+
} title="信息流复刻分镜工作台" />
+
每条分镜纵向排列;行内从左到右完成原内容、新文案、画面/产品、参考帧和生成视频。
0} detail={rows.length ? `${rows.length} 条` : "待音频"} />
0} detail={orderedFrames.length ? `${orderedFrames.length} 张` : "待抽帧"} />
- 0} detail="先规划" />
+ 0} detail={`${job.generated_videos?.length ?? 0} 条`} />
{rows.length ? (
-
-
-
时间
-
结构
-
原内容
-
SKG 新文案
-
画面规划 / 产品融入
-
参考帧
-
下一步
-
-
- {rows.map((row) => {
- const refs = framesForRow(row)
- const busy = busyRow === row.index
- return (
-
+
+ {rows.map((row) => {
+ const refs = framesForRow(row)
+ const rowVideos = videosForRow(refs)
+ const busy = busyRow === row.index
+ const generating = videoBusyRow === row.index
+ return (
+
+
{row.start.toFixed(1)}-{row.end.toFixed(1)}s
-
-
{row.role}
+
+ {row.role}
-
{row.source}
-
{row.skgCopy}
-
-
{row.visualPlan}
-
-
- {row.productIntegration}
+
+
+
+ {row.source}
+
+
+
+ {row.skgCopy}
+
+
+
+ {row.visualPlan}
+
+
+ {row.productIntegration}
+
+
+
+
+ {refs.length ? (
+
+ {refs.map((frame) => (
+
onOpenFrame?.(frame.index)}
+ className="h-16 w-10 shrink-0 overflow-hidden rounded border border-white/10 bg-black/45 transition hover:border-cyan-300/40"
+ title={`参考帧 ${frame.timestamp.toFixed(1)}s`}
+ >
+
+
+ ))}
+ ) : (
+ {row.referencePlan}
+ )}
+
+
+ {row.keyElements}
-
- {refs.length ? (
-
- {refs.map((frame) => (
-
onOpenFrame?.(frame.index)}
- className="h-12 w-8 overflow-hidden rounded border border-white/10 bg-black/45"
- title={`参考帧 ${frame.timestamp.toFixed(1)}s`}
- >
-
-
- ))}
-
- ) : (
-
{row.referencePlan}
- )}
-
-
-
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 ? : }
- 抽参考帧
-
-
-
- {refs.length ? "下一步提元素" : "先抽帧"}
+
addReferenceFrame(row)}
+ disabled={!onAddFrame || busy}
+ className="mt-2 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 ? : }
+ {refs.length ? "补抽参考帧" : "抽参考帧"}
+
+
+
+
+ {rowVideos.length > 0 ? (
+
+ {rowVideos.map((video) => (
+
+ ))}
-
-
- )
- })}
-
+ ) : (
+
+ {refs.length ? "等待生成" : "先抽参考帧"}
+
+ )}
+
generateRowVideo(row, refs)}
+ disabled={!refs.length || !onGenerateVideo || generating}
+ className="inline-flex h-8 w-full items-center justify-center gap-1 rounded-md bg-white px-2 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
+ >
+ {generating ? : }
+ 生成本条
+
+
+
+ )
+ })}
) : (
-
+
)}
)
}
+function StoryboardPlanCell({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) {
+ return (
+
+ )
+}
+
+function StoryboardVideoPreview({ job, video }: { job: Job; video: GeneratedVideo }) {
+ const src = videoSrc(video)
+ const poster = videoPoster(job, video)
+ const running = video.status === "queued" || video.status === "in_progress"
+ return (
+
+ {src && video.status === "completed" ? (
+
+ ) : poster ? (
+
+ ) : (
+
+ )}
+
+ {running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}
+
+ {running && }
+
+ )
+}
+
function AudioWaveform({
features,
status,