From 120dacf8b664ba3b4cc34650d3792663f67bb588 Mon Sep 17 00:00:00 2001 From: kang Date: Sun, 17 May 2026 14:58:12 +0800 Subject: [PATCH] feat: add synced video waveform timeline --- docs/source-analysis.html | 20 +- web/components/ad-recreation-board.tsx | 277 ++++++++++++++++++++----- 2 files changed, 238 insertions(+), 59 deletions(-) diff --git a/docs/source-analysis.html b/docs/source-analysis.html index c002cdc..159a059 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -588,7 +588,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信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下面是逐句时间轴。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + web/components/ad-recreation-board.tsx信息流广告音频解析工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用于观察切割点和爆点节奏。视频播放会同步高亮并滚动当前句,点击波形或字幕行会跳转原视频时间。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 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,避免登录面板或输入框遮挡时草地失去鼠标响应。 @@ -625,7 +625,7 @@ web/app/page.tsx -> 音频解析工作表:web/components/ad-recreation-board.tsx -> 开始:创建/激活 job → 下载完成后自动触发音频处理 - -> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,逐句时间轴在下) + -> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部波形联动) -> 底部音频条:不再渲染,音频结果集中到右侧工作表 -> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口) -> API 契约:web/lib/api.ts @@ -648,7 +648,7 @@ api/main.py
你看到的区域音频解析结果表
主要源码AudioIntakePanel / AudioIntakeStatus in web/components/ad-recreation-board.tsx;复用 triggerTranscribeAudioScript
-
适合怎么描述“逐句时间轴、讲话人、节奏、背景音还需要哪些字段;全文文案依据是否需要展开查看”。
+
适合怎么描述“原视频播放、音频波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。
你看到的区域旧深度素材面板(当前不作为主路径)
@@ -819,7 +819,7 @@ SubjectAsset { 删除输入视频DELETE /jobs/{id}deleteJob从任务队列、URL 和磁盘 jobs/<id> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。 解析视频POST /jobs/{id}/analyze?frames=&target=&mode=&quality=analyzeJob后续阶段保留的抽帧能力。默认 frames=12target 支持透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前第一步主流程不自动调用该接口。 音频文案轨POST /jobs/{id}/transcribetriggerTranscribe若尚未拆轨,先从 source.mp4 提取 audio.wav 并回填 source_audio_url;随后用 ASR 提取原始文案,翻译成中文,写入 audio_script.source_textsource_zh 和逐句 transcript。远端 ASR_MODEL 失败后先走本机 LOCAL_ASR_BIN/LOCAL_ASR_MODEL(默认 mlx_whisper),再尝试 ASR_FALLBACK_MODEL。后端会拒绝重复文本、逐秒假字幕或覆盖率过低的结果,不再把不可听的多模态输出写进时间轴。再用 ASR_FALLBACK_MODEL 多模态音频分析讲话人、语速节奏、停顿、背景音乐/环境声/音效,写入 speaker_profilerhythm_profilebackground_audio_profile。当前第一步不默认生成 SKG 新口播和 MiniMax 配音。 - 原始音频文件GET /jobs/{id}/audio.wavsourceAudioUrl返回拆轨得到的 wav;当前主界面不再渲染底部音频条,右侧音频解析工作表直接使用 transcriptaudio_script 展示文字与声音分析结果。 + 原始音频文件GET /jobs/{id}/audio.wavsourceAudioUrl返回拆轨得到的 wav;当前主界面不再渲染底部吸附音频条,右侧音频解析工作表会读取该文件生成横向波形,并和原视频、逐句时间轴联动。 改写配音文件GET /jobs/{id}/audio-script.mp3apiAssetUrl(job.audio_script.voice_url)后续新配音阶段保留的 MiniMax T2A 产物。当前第一步不默认生成该文件。 手动加帧POST /jobs/{id}/frames?t=addManualFrame按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。 Vision 识别POST /frames/{idx}/describedescribeFrame写入 frame.description,后续可从 objects 加候选元素。 @@ -941,6 +941,18 @@ SubjectAsset {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-17 · 增加原视频与波形联动审片

+ UI + Workflow +
+
+

问题:只有逐句时间轴难以判断原视频节奏、停顿和爆点位置;用户需要边看原版视频边看字幕,并通过音频波形快速识别结构切割点。

+

改动:web/components/ad-recreation-board.tsx 在音频解析结果面板中加入原视频播放器和横向音频波形,布局为左侧原视频、右侧逐句时间轴、底部波形。视频播放时根据 currentTime 高亮并自动滚动当前字幕行;点击波形或字幕行会跳转原视频时间。波形从 audio.wav 解码生成,失败时用本地 fallback peaks 保持布局可用。

+

影响:web/components/ad-recreation-board.tsxdocs/source-analysis.html。后续如果要做爆点标记,应基于当前波形和字幕时间轴继续加 marker,而不是恢复底部音频条。

+
+

2026-05-17 · 收紧音频解析第一步版面

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index c2873a3..07380dd 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -1,6 +1,6 @@ "use client" -import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react" +import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react" import { AlertTriangle, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Link2, Loader2, Mic, Package, PanelRight, Play, Plus, Scissors, Sparkles, Trash2, Upload, Wand2, @@ -24,6 +24,7 @@ import { generatedImageUrl, hasCutout, representativeCutoutUrl, + sourceAudioUrl, updateStoryboard, videoUrl, } from "@/lib/api" @@ -92,6 +93,47 @@ function formatSeconds(raw?: number) { return `${raw.toFixed(1)}s` } +function clampNumber(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function fallbackPeaks(count: number, seedText: string) { + let seed = 0 + for (let i = 0; i < seedText.length; i++) seed = (seed * 31 + seedText.charCodeAt(i)) % 9973 + return Array.from({ length: count }, (_, i) => { + const wave = Math.sin((i + seed) * 0.39) * 0.35 + Math.sin((i + seed) * 0.12) * 0.24 + const pulse = ((i + seed) % 11) / 24 + return clampNumber(0.18 + Math.abs(wave) + pulse, 0.12, 1) + }) +} + +async function decodeWaveform(url: string, targetPeaks = 180) { + const res = await fetch(url) + if (!res.ok) throw new Error(`audio ${res.status}`) + const arrayBuffer = await res.arrayBuffer() + const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (!AudioContextClass) throw new Error("AudioContext unavailable") + const ctx = new AudioContextClass() + try { + const buffer = await ctx.decodeAudioData(arrayBuffer.slice(0)) + const data = buffer.getChannelData(0) + const bucket = Math.max(1, Math.floor(data.length / targetPeaks)) + let maxPeak = 0.01 + const raw: number[] = [] + for (let i = 0; i < targetPeaks; i++) { + const start = i * bucket + const end = Math.min(data.length, start + bucket) + let peak = 0 + for (let j = start; j < end; j++) peak = Math.max(peak, Math.abs(data[j] || 0)) + raw.push(peak) + maxPeak = Math.max(maxPeak, peak) + } + return raw.map((p) => clampNumber(p / maxPeak, 0.08, 1)) + } finally { + void ctx.close().catch(() => {}) + } +} + function frameLabel(frame: KeyFrame, order: number) { return `S${String(order + 1).padStart(2, "0")} · ${frame.timestamp.toFixed(1)}s` } @@ -409,10 +451,6 @@ export function AdRecreationBoard({
- -
- -
@@ -540,17 +578,62 @@ function AudioIntakeStatus({ job, audioReady }: { job: Job | null; audioReady: b } function AudioIntakePanel({ job }: { job: Job | null }) { - if (!job) { - return - } - - const script = job.audio_script + const [currentTime, setCurrentTime] = useState(0) + const [mediaDuration, setMediaDuration] = useState(0) + const [peaks, setPeaks] = useState(() => fallbackPeaks(180, "initial-waveform")) + const videoRef = useRef(null) + const rowRefs = useRef>({}) + const script = job?.audio_script + const audioSrcUrl = job ? apiAssetUrl(job.source_audio_url) || sourceAudioUrl(job.id) : "" const profiles = [ { label: "讲话人", value: script?.speaker_profile }, { label: "节奏", value: script?.rhythm_profile }, { label: "背景音", value: script?.background_audio_profile }, ] - const processing = job.status === "transcribing" || script?.status === "rewriting" + const processing = !!job && (job.status === "transcribing" || script?.status === "rewriting") + const timelineDuration = useMemo(() => { + if (!job) return 1 + const lastTranscriptEnd = job.transcript.reduce((max, segment) => Math.max(max, segment.end || 0), 0) + return Math.max( + mediaDuration, + job.duration ?? 0, + lastTranscriptEnd, + 1, + ) + }, [job, mediaDuration]) + const activeSegment = job?.transcript.find((segment) => currentTime >= segment.start && currentTime <= Math.max(segment.end, segment.start + 0.2)) + + useEffect(() => { + if (!job?.id || !audioSrcUrl) return + setCurrentTime(0) + setMediaDuration(0) + setPeaks(fallbackPeaks(180, `${job.id}-loading`)) + let cancelled = false + decodeWaveform(audioSrcUrl) + .then((next) => { + if (!cancelled) setPeaks(next) + }) + .catch(() => { + if (!cancelled) setPeaks(fallbackPeaks(180, job.id)) + }) + return () => { cancelled = true } + }, [audioSrcUrl, job?.id]) + + useEffect(() => { + if (activeSegment) rowRefs.current[activeSegment.index]?.scrollIntoView({ block: "nearest" }) + }, [activeSegment?.index]) + + const seekTo = (time: number) => { + const next = clampNumber(time, 0, timelineDuration) + if (videoRef.current) videoRef.current.currentTime = next + setCurrentTime(next) + } + + if (!job) { + return + } + + const videoSrcUrl = apiAssetUrl(job.video_url) || videoUrl(job.id) return (
@@ -568,34 +651,141 @@ function AudioIntakePanel({ job }: { job: Job | null }) { ))} -
- } title="逐句时间轴" /> - {job.transcript.length} 段 -
- {job.transcript.length ? ( -
-
-
时间
-
原文
-
中文
+
+
+
+ 音频波形 / 切点参考 + {currentTime.toFixed(1)}s +
+ +
+ +
+
+
+ } title="原版视频" /> + {currentTime.toFixed(1)}s / {formatSeconds(timelineDuration)}
-
- {job.transcript.map((segment) => ( -
-
{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s
-
{segment.en || -}
-
{segment.zh || 翻译中}
-
- ))} +
+ {job.video_url ? ( +
- ) : ( - - )} + +
+
+ } title="逐句时间轴" /> + {job.transcript.length} 段 +
+ {job.transcript.length ? ( +
+
+
时间
+
原文
+
中文
+
+
+ {job.transcript.map((segment) => { + const active = activeSegment?.index === segment.index + return ( +
{ rowRefs.current[segment.index] = node }} + onClick={() => seekTo(segment.start)} + className={`grid cursor-pointer grid-cols-[82px_minmax(0,1fr)_minmax(0,1fr)] gap-3 border-b px-3 py-1.5 text-[11.5px] leading-snug transition last:border-b-0 ${ + active + ? "border-emerald-300/18 bg-emerald-300/[0.12] text-white" + : "border-white/8 text-white/64 hover:bg-white/[0.045]" + }`} + > +
{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s
+
{segment.en || -}
+
{segment.zh || 翻译中}
+
+ ) + })} +
+
+ ) : ( + + )} +
+
+
) } +function AudioWaveform({ + peaks, + currentTime, + duration, + segments, + onSeek, +}: { + peaks: number[] + currentTime: number + duration: number + segments: Array<{ start: number; end: number }> + onSeek: (time: number) => void +}) { + const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100) + return ( +
{ + const rect = event.currentTarget.getBoundingClientRect() + onSeek(((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration) + }} + > +
+ {peaks.map((peak, index) => ( +
+ ))} +
+ {segments.map((segment, index) => ( +
+ ))} +
+
+ ) +} + function ProfileTile({ label, value, running }: { label: string; value?: string; running?: boolean }) { return (
@@ -1039,29 +1229,6 @@ function SegmentBand({ icon, title, children }: { icon: ReactNode; title: string ) } -function AudioStepSummary({ job, audioReady }: { job: Job | null; audioReady: boolean }) { - const downloading = !!job && ["created", "downloading"].includes(job.status) - const audioRunning = !!job && (job.status === "transcribing" || job.audio_script?.status === "rewriting") - return ( -
-
- -
-
第一步:音频解析
-
- {job?.message || "等待素材输入;完成后再进入分镜规划和素材生成。"} -
-
-
-
- - - -
-
- ) -} - function ComposeSummary({ audioReady, selectedVideoCount,