diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 159a059..521466a 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;当前主界面不再渲染底部吸附音频条,右侧音频解析工作表会读取该文件生成横向波形,并和原视频、逐句时间轴联动。 + 原始音频文件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 加候选元素。 @@ -943,14 +943,14 @@ SubjectAsset {
-

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

+

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,而不是恢复底部音频条。

+

问题:只有逐句时间轴难以判断原视频节奏、停顿和爆点位置;普通柱状条也看不出高低音变化,无法辅助判断口播抬升、下沉和爆点。

+

改动:web/components/ad-recreation-board.tsx 在音频解析结果面板中加入原视频播放器和横向音频分析条,布局为左侧原视频、右侧逐句时间轴、底部音频走势。视频播放时根据 currentTime 高亮并自动滚动当前字幕行;点击音频分析条或字幕行会跳转原视频时间。音频分析条只从真实 audio.wav 解码生成:青色中线对称波形表示响度,琥珀曲线表示粗音高走势;解码前显示加载状态,失败时明确提示,不再用假波形兜底。

+

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

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 07380dd..c2875b5 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -60,6 +60,13 @@ type DraftSegment = { scene: StoryboardScene } +type AudioFeature = { + loudness: number + pitch: number | null +} + +type AudioFeatureStatus = "idle" | "loading" | "ready" | "failed" + 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" @@ -97,17 +104,7 @@ 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) { +async function decodeAudioFeatures(url: string, targetFrames = 180): Promise { const res = await fetch(url) if (!res.ok) throw new Error(`audio ${res.status}`) const arrayBuffer = await res.arrayBuffer() @@ -117,18 +114,40 @@ async function decodeWaveform(url: string, targetPeaks = 180) { 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 bucket = Math.max(1, Math.floor(data.length / targetFrames)) + let maxLoudness = 0.01 + const raw: Array<{ loudness: number; pitchHz: number | null }> = [] + for (let i = 0; i < targetFrames; 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) + let sumSq = 0 + let crossings = 0 + let prev = data[start] || 0 + for (let j = start; j < end; j++) { + const sample = data[j] || 0 + const abs = Math.abs(sample) + peak = Math.max(peak, abs) + sumSq += sample * sample + if (abs > 0.012 && Math.abs(prev) > 0.012 && Math.sign(sample) !== Math.sign(prev)) crossings += 1 + prev = sample + } + const size = Math.max(end - start, 1) + const rms = Math.sqrt(sumSq / size) + const loudness = Math.max(rms * 1.8, peak * 0.62) + const seconds = size / buffer.sampleRate + const pitchHz = rms > 0.006 && seconds > 0 ? crossings / (2 * seconds) : null + raw.push({ loudness, pitchHz }) + maxLoudness = Math.max(maxLoudness, loudness) } - return raw.map((p) => clampNumber(p / maxPeak, 0.08, 1)) + const minHz = Math.log2(80) + const maxHz = Math.log2(520) + return raw.map((item) => ({ + loudness: clampNumber(item.loudness / maxLoudness, 0.06, 1), + pitch: item.pitchHz && item.pitchHz >= 60 && item.pitchHz <= 720 + ? clampNumber((Math.log2(item.pitchHz) - minHz) / (maxHz - minHz), 0.02, 0.98) + : null, + })) } finally { void ctx.close().catch(() => {}) } @@ -580,7 +599,8 @@ function AudioIntakeStatus({ job, audioReady }: { job: Job | null; audioReady: b function AudioIntakePanel({ job }: { job: Job | null }) { const [currentTime, setCurrentTime] = useState(0) const [mediaDuration, setMediaDuration] = useState(0) - const [peaks, setPeaks] = useState(() => fallbackPeaks(180, "initial-waveform")) + const [audioFeatures, setAudioFeatures] = useState([]) + const [audioFeatureStatus, setAudioFeatureStatus] = useState("idle") const videoRef = useRef(null) const rowRefs = useRef>({}) const script = job?.audio_script @@ -604,17 +624,28 @@ function AudioIntakePanel({ job }: { job: Job | null }) { const activeSegment = job?.transcript.find((segment) => currentTime >= segment.start && currentTime <= Math.max(segment.end, segment.start + 0.2)) useEffect(() => { - if (!job?.id || !audioSrcUrl) return + if (!job?.id || !audioSrcUrl) { + setAudioFeatures([]) + setAudioFeatureStatus("idle") + return + } setCurrentTime(0) setMediaDuration(0) - setPeaks(fallbackPeaks(180, `${job.id}-loading`)) + setAudioFeatures([]) + setAudioFeatureStatus("loading") let cancelled = false - decodeWaveform(audioSrcUrl) + decodeAudioFeatures(audioSrcUrl) .then((next) => { - if (!cancelled) setPeaks(next) + if (!cancelled) { + setAudioFeatures(next) + setAudioFeatureStatus("ready") + } }) .catch(() => { - if (!cancelled) setPeaks(fallbackPeaks(180, job.id)) + if (!cancelled) { + setAudioFeatures([]) + setAudioFeatureStatus("failed") + } }) return () => { cancelled = true } }, [audioSrcUrl, job?.id]) @@ -654,11 +685,12 @@ function AudioIntakePanel({ job }: { job: Job | null }) {
- 音频波形 / 切点参考 + 音频波形 / 音高走势 {currentTime.toFixed(1)}s
onSeek: (time: number) => void }) { const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100) + const hasFeatures = features.length > 0 + const pitchPoints = features.map((feature, index) => { + const x = features.length <= 1 ? 0 : (index / (features.length - 1)) * 100 + const pitch = feature.pitch ?? 0.5 + return `${x.toFixed(2)},${(100 - pitch * 100).toFixed(2)}` + }).join(" ") return (
{ const rect = event.currentTarget.getBoundingClientRect() onSeek(((event.clientX - rect.left) / Math.max(rect.width, 1)) * duration) }} > -
- {peaks.map((peak, index) => ( -
- ))} +
{hasFeatures ? "高" : ""}
+
{hasFeatures ? "低" : ""}
+
+ {!hasFeatures && ( +
+ {status === "failed" ? "audio.wav 解码失败" : status === "loading" ? "正在解码 audio.wav" : "等待音频文件"} +
+ )} +
+
+
+ {hasFeatures && ( + <> +
+ {features.map((feature, index) => ( +
+
+
+ ))} +
+ + + + + )}
{segments.map((segment, index) => (