From b5b1d9db285434de86b9628a5a8def6c200e28c7 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 29 Sep 2026 23:36:53 -0700 Subject: [PATCH 1/9] feat(studio): draw a waveform strip under video clips that carry sound An audible video keeps its thumbnails and gains the audio waveform along the bottom 38% of the clip (also when thumbnails are off); muted and silent video are unchanged. The waveform route already decodes video audio; a test now proves it on an mp4. Co-Authored-By: Claude Opus 5.5 --- .../src/helpers/waveform.test.ts | 40 +++++++++++++- .../src/hooks/useRenderClipContent.test.ts | 52 +++++++++++++++++++ .../studio/src/hooks/useRenderClipContent.ts | 19 +++++-- .../components/AudibleVideoClipContent.tsx | 29 +++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 packages/studio/src/player/components/AudibleVideoClipContent.tsx diff --git a/packages/studio-server/src/helpers/waveform.test.ts b/packages/studio-server/src/helpers/waveform.test.ts index 16ba9d1e48..45478fc25c 100644 --- a/packages/studio-server/src/helpers/waveform.test.ts +++ b/packages/studio-server/src/helpers/waveform.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildWaveformCacheKey } from "./waveform.js"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findFfBinary } from "@hyperframes/parsers/ff-binaries"; +import { buildWaveformCacheKey, decodeAudioPeaks } from "./waveform.js"; describe("buildWaveformCacheKey", () => { it("is stable for the same file", () => { @@ -34,3 +39,36 @@ describe("buildWaveformCacheKey", () => { expect(buildWaveformCacheKey("a/b.m4a", fp)).toMatch(/\.json$/); }); }); + +describe("decodeAudioPeaks on a video file", () => { + const ffmpeg = findFfBinary("ffmpeg"); + it.skipIf(!ffmpeg)("reads the audio track of an .mp4", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-wave-video-")); + const file = join(dir, "talk.mp4"); + try { + execFileSync(ffmpeg ?? "ffmpeg", [ + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=c=black:s=32x32:d=1", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-shortest", + "-c:v", + "libx264", + "-c:a", + "aac", + file, + ]); + const peaks = await decodeAudioPeaks(file); + expect(peaks.length).toBeGreaterThan(0); + expect(Math.max(...peaks)).toBeCloseTo(1, 3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/studio/src/hooks/useRenderClipContent.test.ts b/packages/studio/src/hooks/useRenderClipContent.test.ts index 87a3305305..4013bf16ad 100644 --- a/packages/studio/src/hooks/useRenderClipContent.test.ts +++ b/packages/studio/src/hooks/useRenderClipContent.test.ts @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it } from "vitest"; import { CompositionThumbnail, VideoThumbnail } from "../player"; import { AudioWaveform } from "../player/components/AudioWaveform"; +import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent"; import type { TimelineClipRenderContext } from "../player/components/TimelineTypes"; import { usePlayerStore, type TimelineElement } from "../player/store/playerStore"; import { buildCompositionThumbnailUrl } from "../player/components/CompositionThumbnail"; @@ -176,6 +177,57 @@ describe("useRenderClipContent", () => { } }); + it("draws a waveform strip under the thumbnails of a video with sound", () => { + usePlayerStore.setState({ thumbnailMode: "adaptive" }); + const content = renderClipContent( + { + id: "talk", + tag: "video", + start: 0, + duration: 4, + track: 0, + src: "talk.mp4", + hasAudio: true, + }, + null, + ); + expect(isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)).toBe(true); + if (!isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)) return; + expect(content.type).toBe(AudibleVideoClipContent); + const { thumbnail, waveform } = content.props; + expect(isValidElement(thumbnail) && thumbnail.type).toBe(VideoThumbnail); + expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe( + "/api/projects/my-project/waveform/talk.mp4", + ); + }); + + it("keeps a muted or silent video to thumbnails only", () => { + usePlayerStore.setState({ thumbnailMode: "adaptive" }); + for (const extra of [{ hasAudio: true, muted: true }, { hasAudio: false }]) { + const content = renderClipContent( + { id: "b", tag: "video", start: 0, duration: 4, track: 0, src: "b.mp4", ...extra }, + null, + ); + expect(isValidElement(content) && content.type).toBe(VideoThumbnail); + } + }); + + it("still shows the sound strip when thumbnails are off", () => { + const content = renderClipContent( + { + id: "talk", + tag: "video", + start: 0, + duration: 4, + track: 0, + src: "talk.mp4", + hasAudio: true, + }, + null, + ); + expect(isValidElement(content) && content.type).toBe(AudibleVideoClipContent); + }); + it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => { usePlayerStore.setState({ thumbnailMode: "adaptive" }); diff --git a/packages/studio/src/hooks/useRenderClipContent.ts b/packages/studio/src/hooks/useRenderClipContent.ts index f0b4eac451..65625c7355 100644 --- a/packages/studio/src/hooks/useRenderClipContent.ts +++ b/packages/studio/src/hooks/useRenderClipContent.ts @@ -12,6 +12,8 @@ import type { TimelineClipRenderContext } from "../player/components/TimelineTyp import { audioPillFlags } from "../player/components/audioClipLink"; import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform"; import { ImageThumbnail } from "../player/components/ImageThumbnail"; +import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent"; +import { clipHasSound } from "../player/components/clipMenuNormalize"; import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils"; import { usePlayerStore } from "../player/store/playerStore"; import { thumbnailRevisionOf } from "../player/store/thumbnailSlice"; @@ -85,6 +87,15 @@ function renderAudioClip( }); } +function withSoundStrip( + el: TimelineElement, + thumbnail: ReactNode, + waveform: () => ReactNode, +): ReactNode { + if (el.tag !== "video" || !clipHasSound(el)) return thumbnail; + return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform() }); +} + export interface UseRenderClipContentOptions { projectIdRef: { current: string | null }; compIdToSrc: Map; @@ -117,10 +128,9 @@ export function useRenderClipContent({ // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar. + const waveform = () => renderAudioClip(el, pid, sessionEpoch, style.label, context, elements); if (effectiveMode === "hidden") { - return rendersWaveform(el) - ? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements) - : null; + return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform); } let compSrc = el.compositionSrc; @@ -205,7 +215,7 @@ export function useRenderClipContent({ rich: context.rich, }); } - return createElement(VideoThumbnail, { + const thumbnail = createElement(VideoThumbnail, { videoSrc: mediaSrc, label: "", labelColor: style.label, @@ -216,6 +226,7 @@ export function useRenderClipContent({ sessionEpoch, priority: context.priority, }); + return withSoundStrip(el, thumbnail, waveform); } if (htmlPreviewEligible) { diff --git a/packages/studio/src/player/components/AudibleVideoClipContent.tsx b/packages/studio/src/player/components/AudibleVideoClipContent.tsx new file mode 100644 index 0000000000..cbfcc59aa0 --- /dev/null +++ b/packages/studio/src/player/components/AudibleVideoClipContent.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; + +/** Share of the clip height the sound strip takes under a video's thumbnails. */ +const AUDIBLE_VIDEO_WAVE_SHARE = 0.38; + +/** A video that carries sound: thumbnails on top, its waveform strip along the bottom. */ +export function AudibleVideoClipContent({ + thumbnail, + waveform, +}: { + thumbnail: ReactNode; + waveform: ReactNode; +}) { + const waveHeight = `${AUDIBLE_VIDEO_WAVE_SHARE * 100}%`; + return ( +
+
+ {thumbnail} +
+
+ {waveform} +
+
+ ); +} From 7b8a6c887aa9908a92dad1b7a3a407787abcf925 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 29 Sep 2026 23:42:08 -0700 Subject: [PATCH 2/9] feat(studio): latch a CLIP light on the master meter at the export ceiling The light turns red when the preview master sample peak reaches -1 dBFS (the export true-peak ceiling) and stays lit until clicked. Mutation-tested threshold and latch. Co-Authored-By: Claude Opus 5.5 --- .../components/nle/AudioMeterStrip.test.tsx | 53 +++++++++++++++++ .../src/components/nle/AudioMeterStrip.tsx | 58 ++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx index bd5a387822..e453a81527 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.test.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.test.tsx @@ -12,6 +12,8 @@ import { SILENT_CHANNEL } from "../../utils/audioMeterMath"; import { AudioMeterStrip, type AudioMeterStripProps, + CLIP_LATCH_PEAK, + stepClipLatch, evictGoneMeterState, followMeterHook, stepAndPaintStrips, @@ -416,3 +418,54 @@ describe("stepAndPaintStrips", () => { expect(state.has("loud")).toBe(true); }); }); + +describe("CLIP light", () => { + const hookAt = (peak: number) => ({ + start: vi.fn(), + stop: vi.fn(), + read: vi.fn(() => ({ master: { l: peak, r: 0 }, groups: {} })), + }); + + it("latches at the -1 dBFS export ceiling, stays lit when the level drops, and resets on click", () => { + usePlayerStore.setState({ elements: [clip({})] }); + const hook = hookAt(1); + setHook(hook); + const { host } = mount(); + const light = host.querySelector("[data-testid=meter-clip-light]"); + expect(light?.dataset.lit).toBe("false"); + tick(); + expect(light?.dataset.lit).toBe("true"); + hook.read.mockReturnValue({ master: { l: 0.1, r: 0.1 }, groups: {} }); + tick(); + expect(light?.dataset.lit).toBe("true"); + act(() => light?.click()); + expect(light?.dataset.lit).toBe("false"); + tick(); + expect(light?.dataset.lit).toBe("false"); + }); + + it("stays dark in the red band just under the ceiling", () => { + usePlayerStore.setState({ elements: [clip({})] }); + setHook(hookAt(10 ** (-1.2 / 20))); + const { host } = mount(); + tick(); + expect(host.querySelector("[data-testid=meter-clip-light]")?.dataset.lit).toBe( + "false", + ); + }); +}); + +describe("stepClipLatch", () => { + it("lights exactly at -1 dBFS on either channel, not below", () => { + expect(CLIP_LATCH_PEAK).toBeCloseTo(0.891251, 6); + expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK, r: 0 })).toBe(true); + expect(stepClipLatch(false, { l: 0, r: CLIP_LATCH_PEAK })).toBe(true); + expect(stepClipLatch(false, { l: CLIP_LATCH_PEAK - 1e-6, r: 0 })).toBe(false); + expect(stepClipLatch(false, undefined)).toBe(false); + }); + + it("holds once lit, whatever comes next", () => { + expect(stepClipLatch(true, { l: 0, r: 0 })).toBe(true); + expect(stepClipLatch(true, undefined)).toBe(true); + }); +}); diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index dc23f46e37..6aefe57c9f 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -5,6 +5,7 @@ import { useMemo, useRef, type PointerEvent as ReactPointerEvent, + type ReactNode, type Ref, type RefObject, } from "react"; @@ -49,6 +50,21 @@ type StripBars = [Bars, Bars]; const MONITOR_LABEL = "Monitor"; +/** Sample peak at the export ceiling (−1 dBFS); the CLIP light latches here, stricter than RED_AT. */ +export const CLIP_LATCH_PEAK = 10 ** (-1 / 20); + +export function stepClipLatch(latched: boolean, master: StereoLevel | undefined): boolean { + if (latched || !master) return latched; + return Math.max(master.l, master.r) >= CLIP_LATCH_PEAK; +} + +function paintClipLight(el: HTMLElement | null, lit: boolean): void { + if (!el) return; + el.dataset.lit = String(lit); + el.style.setProperty("background-color", lit ? "rgb(239 68 68)" : "rgb(38 38 38)"); + el.style.setProperty("color", lit ? "#fff" : "rgb(115 115 115)"); +} + /** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */ const AMBER_AT = markFraction(-6); const RED_AT = markFraction(-3); @@ -178,10 +194,16 @@ export function stepAndPaintStrips( } /** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */ +interface ClipLatch { + lit: boolean; + light: HTMLElement | null; +} + function useMeterLoop( strips: Strip[], bars: RefObject>, iframeRef: RefObject | undefined, + clip: RefObject, ) { const shell = useStudioShellContextOptional(); const previewIframeRef = iframeRef ?? shell?.previewIframeRef; @@ -200,6 +222,11 @@ function useMeterLoop( last = now; evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id))); stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt); + const lit = stepClipLatch(clip.current.lit, levels?.master); + if (lit !== clip.current.lit) { + clip.current.lit = lit; + paintClipLight(clip.current.light, lit); + } }; raf = requestAnimationFrame(tick); return () => { @@ -210,7 +237,29 @@ function useMeterLoop( // Preview iframe already gone. } }; - }, [previewIframeRef, bars]); + }, [previewIframeRef, bars, clip]); +} + +function ClipLight({ clip }: { clip: RefObject }) { + return ( + + ); } function Bar({ maskRef, peakRef }: { maskRef: Ref; peakRef: Ref }) { @@ -341,11 +390,13 @@ function MeterStrip({ register, onLive, onCommit, + clipLight, }: { strip: Strip; register: (id: string | null, bars: StripBars | null) => void; onLive: (id: string | null, volume: number) => void; onCommit: (id: string | null, volume: number) => void; + clipLight?: ReactNode; }) { const refs = [ useRef(null), @@ -364,6 +415,7 @@ function MeterStrip({ }, [strip.id, register]); return (
+ {clipLight}
({ lit: false, light: null }); + useMeterLoop(strips, bars, previewIframeRef, clip); return (
: undefined} /> ))}
From f329afc34ae11f39ddf5798f11b04c1e71339d23 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 30 Sep 2026 16:24:16 -0700 Subject: [PATCH 3/9] fix(studio): paint the CLIP light from theme colour tokens Co-Authored-By: Claude Opus 5.5 --- packages/studio/src/components/nle/AudioMeterStrip.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index 6aefe57c9f..42ee238bfb 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -61,8 +61,11 @@ export function stepClipLatch(latched: boolean, master: StereoLevel | undefined) function paintClipLight(el: HTMLElement | null, lit: boolean): void { if (!el) return; el.dataset.lit = String(lit); - el.style.setProperty("background-color", lit ? "rgb(239 68 68)" : "rgb(38 38 38)"); - el.style.setProperty("color", lit ? "#fff" : "rgb(115 115 115)"); + el.style.setProperty( + "background-color", + lit ? "var(--color-red-500)" : "var(--color-neutral-800)", + ); + el.style.setProperty("color", lit ? "var(--color-white)" : "var(--color-neutral-500)"); } /** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */ From 1d7058b46f83a725c3c7e15f5a38f4b81902350c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 29 Sep 2026 23:50:03 -0700 Subject: [PATCH 4/9] feat(studio): mark where each clip redlines on its waveform studio-server computes an absolute per-50ms sample-peak map once per media file (native channels, cached by size+mtime next to the waveform cache); the timeline paints red marks where the source at the clip's own volume reaches -1 dBFS and names the loudest point. Co-Authored-By: Claude Opus 5.5 --- packages/studio-server/src/createStudioApi.ts | 2 + .../studio-server/src/helpers/peakMap.test.ts | 112 ++++++++++++++++ packages/studio-server/src/helpers/peakMap.ts | 124 ++++++++++++++++++ .../studio-server/src/routes/peaks.test.ts | 50 +++++++ packages/studio-server/src/routes/peaks.ts | 56 ++++++++ .../src/components/nle/AudioMeterStrip.tsx | 5 +- .../src/hooks/useRenderClipContent.test.ts | 52 +++++++- .../studio/src/hooks/useRenderClipContent.ts | 15 ++- .../player/components/ClipPeakMarks.test.tsx | 41 ++++++ .../src/player/components/ClipPeakMarks.tsx | 71 ++++++++++ .../player/components/clipPeakRuns.test.ts | 38 ++++++ .../src/player/components/clipPeakRuns.ts | 59 +++++++++ .../studio/src/utils/exportPeakCeiling.ts | 1 + 13 files changed, 622 insertions(+), 4 deletions(-) create mode 100644 packages/studio-server/src/helpers/peakMap.test.ts create mode 100644 packages/studio-server/src/helpers/peakMap.ts create mode 100644 packages/studio-server/src/routes/peaks.test.ts create mode 100644 packages/studio-server/src/routes/peaks.ts create mode 100644 packages/studio/src/player/components/ClipPeakMarks.test.tsx create mode 100644 packages/studio/src/player/components/ClipPeakMarks.tsx create mode 100644 packages/studio/src/player/components/clipPeakRuns.test.ts create mode 100644 packages/studio/src/player/components/clipPeakRuns.ts create mode 100644 packages/studio/src/utils/exportPeakCeiling.ts diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index d39576c446..3c1e3cde3e 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -10,6 +10,7 @@ import { registerThumbnailRoutes } from "./routes/thumbnail.js"; import { registerWaveformRoutes } from "./routes/waveform.js"; import { registerFreezeFrameRoutes } from "./routes/freezeFrame.js"; import { registerLoudnessRoutes } from "./routes/loudness.js"; +import { registerPeakRoutes } from "./routes/peaks.js"; import { registerFontRoutes } from "./routes/fonts.js"; import { registerRegistryRoutes } from "./routes/registry.js"; import { registerSelectionRoutes } from "./routes/selection.js"; @@ -65,6 +66,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { registerWaveformRoutes(api, adapter); registerFreezeFrameRoutes(api, adapter); registerLoudnessRoutes(api, adapter); + registerPeakRoutes(api, adapter); registerFontRoutes(api); registerRegistryRoutes(api, adapter); registerGlobalAssetRoutes(api); diff --git a/packages/studio-server/src/helpers/peakMap.test.ts b/packages/studio-server/src/helpers/peakMap.test.ts new file mode 100644 index 0000000000..069b6e327d --- /dev/null +++ b/packages/studio-server/src/helpers/peakMap.test.ts @@ -0,0 +1,112 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { findFfBinary } from "@hyperframes/parsers/ff-binaries"; +import { + accumulatePeaks, + buildPeakMapCacheKey, + createPeakAccumulator, + decodePeakMap, + finishPeaks, +} from "./peakMap.js"; + +function stereo(frames: Array<[number, number]>): Buffer { + const buf = Buffer.alloc(frames.length * 8); + frames.forEach(([l, r], i) => { + buf.writeFloatLE(l, i * 8); + buf.writeFloatLE(r, i * 8 + 4); + }); + return buf; +} + +describe("peak accumulation", () => { + it("keeps the absolute max of either channel per bin, un-normalized", () => { + const acc = createPeakAccumulator(4, 0.5, 2); + accumulatePeaks( + acc, + stereo([ + [0.1, -0.3], + [0.2, 0], + [0, 0.05], + [-0.04, 0], + ]), + ); + expect(finishPeaks(acc)).toEqual([0.3, 0.05]); + }); + + it("carries a frame split across chunks", () => { + const acc = createPeakAccumulator(2, 1, 2); + const whole = stereo([ + [0.5, 0], + [0, -0.9], + ]); + accumulatePeaks(acc, whole.subarray(0, 5)); + accumulatePeaks(acc, whole.subarray(5)); + expect(finishPeaks(acc)).toEqual([0.9]); + expect(acc.frame).toBe(2); + }); +}); + +describe("peak accumulation, mono", () => { + it("reads one channel per frame", () => { + const acc = createPeakAccumulator(2, 1, 1); + const buf = Buffer.alloc(8); + buf.writeFloatLE(-1, 0); + buf.writeFloatLE(0.5, 4); + accumulatePeaks(acc, buf); + expect(finishPeaks(acc)).toEqual([1]); + }); +}); + +describe("buildPeakMapCacheKey", () => { + it("changes when the file is replaced and never collides with a waveform entry", () => { + const a = buildPeakMapCacheKey("a/talk.mp4", { size: 10, mtimeMs: 1 }); + expect(a).toBe(buildPeakMapCacheKey("a/talk.mp4", { size: 10, mtimeMs: 1 })); + expect(a).not.toBe(buildPeakMapCacheKey("a/talk.mp4", { size: 11, mtimeMs: 1 })); + expect(a).not.toBe(buildPeakMapCacheKey("a/talk.mp4", { size: 10, mtimeMs: 2 })); + expect(a).toMatch(/^peaks-v1_a_talk\.mp4_10-1\.json$/); + }); +}); + +describe("decodePeakMap", () => { + const ffmpeg = findFfBinary("ffmpeg"); + it.skipIf(!ffmpeg)( + "reads a 0 dBFS video track near 1.0 and a -12 dBFS one near 0.25", + async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-peaks-")); + try { + const make = (name: string, volume: string) => { + const file = join(dir, name); + execFileSync(ffmpeg ?? "ffmpeg", [ + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=c=black:s=32x32:d=1", + "-f", + "lavfi", + "-i", + `sine=frequency=440:duration=1,volume=${volume}`, + "-shortest", + "-c:v", + "libx264", + "-c:a", + "pcm_s16le", + file, + ]); + return file; + }; + const loud = await decodePeakMap(make("loud.mov", "8")); + const quiet = await decodePeakMap(make("quiet.mov", "2")); + expect(Math.max(...loud)).toBeGreaterThan(0.95); + expect(Math.max(...quiet)).toBeLessThan(0.3); + expect(loud.length).toBeGreaterThanOrEqual(19); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/packages/studio-server/src/helpers/peakMap.ts b/packages/studio-server/src/helpers/peakMap.ts new file mode 100644 index 0000000000..659e9fd140 --- /dev/null +++ b/packages/studio-server/src/helpers/peakMap.ts @@ -0,0 +1,124 @@ +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { findFfBinary } from "@hyperframes/parsers/ff-binaries"; + +export const PEAK_BIN_SECONDS = 0.05; +const PEAK_SAMPLE_RATE = 48_000; +const execFileAsync = promisify(execFile); +const PEAK_MAP_CACHE_VERSION = "peaks-v1"; + +/** + * Running max-abs per bin over interleaved f32le at the source's own channel + * count. Native channels, not `-ac 2`: ffmpeg's mono upmix pans at -3 dB, + * which would hide a clip that really does reach 0 dBFS. + */ +export interface PeakAccumulator { + bins: number[]; + frame: number; + carry: Buffer; + framesPerBin: number; + channels: number; +} + +export function createPeakAccumulator( + sampleRate = PEAK_SAMPLE_RATE, + binSeconds = PEAK_BIN_SECONDS, + channels = 2, +): PeakAccumulator { + return { + bins: [], + frame: 0, + carry: Buffer.alloc(0), + framesPerBin: Math.max(1, Math.round(sampleRate * binSeconds)), + channels: Math.max(1, channels), + }; +} + +function framePeak(data: Buffer, offset: number, channels: number): number { + let peak = 0; + for (let ch = 0; ch < channels; ch++) { + peak = Math.max(peak, Math.abs(data.readFloatLE(offset + ch * 4))); + } + return peak; +} + +export function accumulatePeaks(acc: PeakAccumulator, chunk: Buffer): void { + const data = acc.carry.length > 0 ? Buffer.concat([acc.carry, chunk]) : chunk; + const frameBytes = 4 * acc.channels; + const whole = data.length - (data.length % frameBytes); + for (let offset = 0; offset < whole; offset += frameBytes) { + const peak = framePeak(data, offset, acc.channels); + const bin = Math.floor(acc.frame / acc.framesPerBin); + if (acc.bins.length <= bin) acc.bins.push(0); + if (peak > (acc.bins[bin] ?? 0)) acc.bins[bin] = peak; + acc.frame += 1; + } + acc.carry = Buffer.from(data.subarray(whole)); +} + +/** Linear sample peak per bin, NOT normalized — the level is the point. */ +export function finishPeaks(acc: PeakAccumulator): number[] { + return acc.bins.map((peak) => Number(peak.toFixed(4))); +} + +async function probeChannels(mediaPath: string): Promise { + const { stdout } = await execFileAsync( + findFfBinary("ffprobe") ?? "ffprobe", + [ + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "stream=channels", + "-of", + "csv=p=0", + mediaPath, + ], + { encoding: "utf8", timeout: 30_000, windowsHide: true }, + ); + const channels = Number.parseInt(stdout.trim(), 10); + if (!Number.isFinite(channels) || channels < 1) throw new Error("no audio stream"); + return channels; +} + +export async function decodePeakMap(mediaPath: string): Promise { + const channels = await probeChannels(mediaPath); + return new Promise((resolvePromise, reject) => { + const proc = spawn( + findFfBinary("ffmpeg") ?? "ffmpeg", + [ + "-v", + "error", + "-i", + mediaPath, + "-vn", + "-ar", + String(PEAK_SAMPLE_RATE), + "-f", + "f32le", + "pipe:1", + ], + { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }, + ); + const acc = createPeakAccumulator(PEAK_SAMPLE_RATE, PEAK_BIN_SECONDS, channels); + proc.stdout?.on("data", (chunk: Buffer) => accumulatePeaks(acc, chunk)); + proc.on("close", (code) => { + if (acc.frame === 0) { + reject(new Error(`ffmpeg produced no audio samples (exit ${code})`)); + return; + } + resolvePromise(finishPeaks(acc)); + }); + proc.on("error", reject); + }); +} + +/** Keyed on content (size + mtime) like the waveform cache, under its own version prefix. */ +export function buildPeakMapCacheKey( + assetPath: string, + fingerprint: { size: number; mtimeMs: number }, +): string { + const name = assetPath.replace(/[/\\]/g, "_"); + return `${PEAK_MAP_CACHE_VERSION}_${name}_${fingerprint.size}-${Math.round(fingerprint.mtimeMs)}.json`; +} diff --git a/packages/studio-server/src/routes/peaks.test.ts b/packages/studio-server/src/routes/peaks.test.ts new file mode 100644 index 0000000000..c7763535f8 --- /dev/null +++ b/packages/studio-server/src/routes/peaks.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerPeakRoutes } from "./peaks"; +import type { StudioApiAdapter } from "../types"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function setup(decode: (path: string) => Promise) { + const dir = mkdtempSync(join(tmpdir(), "hf-peaks-route-")); + dirs.push(dir); + writeFileSync(join(dir, "talk.mp4"), "video"); + const adapter: StudioApiAdapter = { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir }), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => "/tmp/renders", + startRender: () => ({ id: "j", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }), + }; + const app = new Hono(); + registerPeakRoutes(app, adapter, decode); + return app; +} + +describe("GET /projects/:id/peaks/*", () => { + it("answers absolute bins once per file and serves the cache after", async () => { + const decode = vi.fn(async () => [0.2, 0.99]); + const app = setup(decode); + for (let i = 0; i < 2; i++) { + const res = await app.request("http://localhost/projects/p/peaks/talk.mp4"); + expect(await res.json()).toEqual({ binSeconds: 0.05, bins: [0.2, 0.99] }); + } + expect(decode).toHaveBeenCalledTimes(1); + }); + + it("404s a missing file and a path outside the project", async () => { + const app = setup(async () => []); + expect((await app.request("http://localhost/projects/p/peaks/nope.mp4")).status).toBe(404); + expect( + (await app.request("http://localhost/projects/p/peaks/..%2F..%2Fetc%2Fpasswd")).status, + ).toBe(404); + }); +}); diff --git a/packages/studio-server/src/routes/peaks.ts b/packages/studio-server/src/routes/peaks.ts new file mode 100644 index 0000000000..8e8bb5349d --- /dev/null +++ b/packages/studio-server/src/routes/peaks.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution"; +import type { Hono } from "hono"; +import type { StudioApiAdapter } from "../types.js"; +import { isWaveformCacheDirectory, writeWaveformCache } from "../helpers/waveform.js"; +import { buildPeakMapCacheKey, decodePeakMap, PEAK_BIN_SECONDS } from "../helpers/peakMap.js"; +import { requestSubPath } from "../helpers/requestSubPath.js"; + +type DecodePeaks = (mediaPath: string) => Promise; + +function readCachedBins(cacheDir: string, cachePath: string): number[] | null { + try { + if (!isWaveformCacheDirectory(cacheDir) || !existsSync(cachePath)) return null; + const parsed: unknown = JSON.parse(readFileSync(cachePath, "utf-8")); + return Array.isArray(parsed) && parsed.every((n) => typeof n === "number") ? parsed : null; + } catch { + return null; + } +} + +/** Absolute per-bin sample peaks for one media file, so the timeline can mark where a clip redlines. */ +export function registerPeakRoutes( + api: Hono, + adapter: StudioApiAdapter, + decode: DecodePeaks = decodePeakMap, +): void { + api.get("/projects/:id/peaks/*", async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + const assetPath = requestSubPath(c.req.url, "projects/:id/peaks"); + const mediaPath = resolve(project.dir, assetPath); + if (!isWithinProjectRoot(project.dir, mediaPath)) + return c.json({ error: "file not found" }, 404); + const stats = statSync(mediaPath, { throwIfNoEntry: false }); + if (!stats?.isFile()) return c.json({ error: "file not found" }, 404); + + const cacheDir = join(project.dir, ".waveform-cache"); + const cachePath = join(cacheDir, buildPeakMapCacheKey(assetPath, stats)); + const cached = readCachedBins(cacheDir, cachePath); + if (cached) return c.json({ binSeconds: PEAK_BIN_SECONDS, bins: cached }); + + let bins: number[]; + try { + bins = await decode(mediaPath); + } catch { + return c.json({ error: "failed to decode audio" }, 500); + } + try { + writeWaveformCache(cachePath, bins); + } catch { + // A cache that cannot be written only costs the next request a decode. + } + return c.json({ binSeconds: PEAK_BIN_SECONDS, bins }); + }); +} diff --git a/packages/studio/src/components/nle/AudioMeterStrip.tsx b/packages/studio/src/components/nle/AudioMeterStrip.tsx index 42ee238bfb..5c24c56afa 100644 --- a/packages/studio/src/components/nle/AudioMeterStrip.tsx +++ b/packages/studio/src/components/nle/AudioMeterStrip.tsx @@ -21,6 +21,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap"; import { usePlayerStore } from "../../player"; import { clampNumber } from "../../utils/studioHelpers"; import { useAudioMetersVisible } from "../../utils/audioMeterVisibility"; +import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling"; import { useStudioShellContextOptional } from "../../contexts/StudioContext"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { @@ -50,8 +51,8 @@ type StripBars = [Bars, Bars]; const MONITOR_LABEL = "Monitor"; -/** Sample peak at the export ceiling (−1 dBFS); the CLIP light latches here, stricter than RED_AT. */ -export const CLIP_LATCH_PEAK = 10 ** (-1 / 20); +/** The CLIP light latches at the export ceiling, stricter than RED_AT. */ +export const CLIP_LATCH_PEAK = EXPORT_PEAK_CEILING; export function stepClipLatch(latched: boolean, master: StereoLevel | undefined): boolean { if (latched || !master) return latched; diff --git a/packages/studio/src/hooks/useRenderClipContent.test.ts b/packages/studio/src/hooks/useRenderClipContent.test.ts index 4013bf16ad..f03b43c52c 100644 --- a/packages/studio/src/hooks/useRenderClipContent.test.ts +++ b/packages/studio/src/hooks/useRenderClipContent.test.ts @@ -6,6 +6,13 @@ import { afterEach, describe, expect, it } from "vitest"; import { CompositionThumbnail, VideoThumbnail } from "../player"; import { AudioWaveform } from "../player/components/AudioWaveform"; import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent"; +import { ClipPeakMarks } from "../player/components/ClipPeakMarks"; + +function unwrapPeakMarks(node: ReactNode): ReactNode { + return isValidElement<{ children?: ReactNode }>(node) && node.type === ClipPeakMarks + ? node.props.children + : node; +} import type { TimelineClipRenderContext } from "../player/components/TimelineTypes"; import { usePlayerStore, type TimelineElement } from "../player/store/playerStore"; import { buildCompositionThumbnailUrl } from "../player/components/CompositionThumbnail"; @@ -94,9 +101,51 @@ describe("useRenderClipContent", () => { root.render(React.createElement(Harness)); }); act(() => root.unmount()); + return unwrapPeakMarks(content); + } + + function renderRaw(el: TimelineElement): ReactNode { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let content: ReactNode = null; + function Harness() { + const render = useRenderClipContent({ + projectIdRef: { current: "my-project" }, + compIdToSrc: new Map(), + activePreviewUrl: null, + effectiveTimelineDuration: 12, + }); + content = render(el, { clip: "#222", label: "#fff" }); + return null; + } + act(() => root.render(React.createElement(Harness))); + act(() => root.unmount()); return content; } + it("wraps a clip's waveform in peak marks at its own gain over its played source window", () => { + const content = renderRaw({ + id: "voiceover", + tag: "audio", + start: 1, + duration: 4, + track: 1, + src: "assets/voiceover.mp3", + playbackStart: 2, + playbackRate: 1.5, + volume: 2, + }); + expect(isValidElement(content) && content.type).toBe(ClipPeakMarks); + if (isValidElement>(content)) { + expect(content.props).toMatchObject({ + peaksUrl: "/api/projects/my-project/peaks/assets/voiceover.mp3", + sourceWindow: { mediaStart: 2, sourceSpan: 6 }, + gain: 2, + }); + } + }); + it("renders audio clips as waveforms even when a composition preview URL is active", () => { const content = renderClipContent({ id: "voiceover", @@ -194,7 +243,8 @@ describe("useRenderClipContent", () => { expect(isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)).toBe(true); if (!isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)) return; expect(content.type).toBe(AudibleVideoClipContent); - const { thumbnail, waveform } = content.props; + const { thumbnail } = content.props; + const waveform = unwrapPeakMarks(content.props.waveform); expect(isValidElement(thumbnail) && thumbnail.type).toBe(VideoThumbnail); expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe( "/api/projects/my-project/waveform/talk.mp4", diff --git a/packages/studio/src/hooks/useRenderClipContent.ts b/packages/studio/src/hooks/useRenderClipContent.ts index 65625c7355..a929e6ac3a 100644 --- a/packages/studio/src/hooks/useRenderClipContent.ts +++ b/packages/studio/src/hooks/useRenderClipContent.ts @@ -13,6 +13,7 @@ import { audioPillFlags } from "../player/components/audioClipLink"; import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform"; import { ImageThumbnail } from "../player/components/ImageThumbnail"; import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent"; +import { ClipPeakMarks } from "../player/components/ClipPeakMarks"; import { clipHasSound } from "../player/components/clipMenuNormalize"; import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils"; import { usePlayerStore } from "../player/store/playerStore"; @@ -73,7 +74,7 @@ function renderAudioClip( ? buildProjectApiPath(pid, `/waveform/${encodedRelative}`) : undefined; const { start, end } = trimFractions(el); - return createElement(AudioWaveform, { + const waveform = createElement(AudioWaveform, { audioUrl, waveformUrl, label: "", @@ -85,6 +86,18 @@ function renderAudioClip( priority: context.priority, ...audioPillFlags(el, elements), }); + return createElement( + ClipPeakMarks, + { + peaksUrl: encodedRelative ? buildProjectApiPath(pid, `/peaks/${encodedRelative}`) : undefined, + sourceWindow: { + mediaStart: el.playbackStart ?? 0, + sourceSpan: el.duration * (el.playbackRate ?? 1), + }, + gain: el.volume ?? 1, + }, + waveform, + ); } function withSoundStrip( diff --git a/packages/studio/src/player/components/ClipPeakMarks.test.tsx b/packages/studio/src/player/components/ClipPeakMarks.test.tsx new file mode 100644 index 0000000000..9c16f3b012 --- /dev/null +++ b/packages/studio/src/player/components/ClipPeakMarks.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createHappyDomRootHarness } from "./testRootHarness"; +import { ClipPeakMarks } from "./ClipPeakMarks"; + +const harness = createHappyDomRootHarness(); + +async function render(url: string, bins: number[], gain: number) { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ binSeconds: 1, bins })), + ); + const host = document.createElement("div"); + document.body.appendChild(host); + await act(async () => { + harness.mount(host).render( + + wave + , + ); + }); + await act(async () => {}); + vi.unstubAllGlobals(); + return host; +} + +describe("ClipPeakMarks", () => { + it("paints red marks and the peak on a clip that redlines", async () => { + const host = await render("/api/projects/p/peaks/loud.mp4", [0.2, 0.98], 1); + expect(host.textContent).toContain("wave"); + expect(host.querySelector("[data-testid=clip-peak-marks]")?.textContent).toContain( + "▲ peaks −0.2 dBFS", + ); + }); + + it("paints nothing on a quiet clip", async () => { + const host = await render("/api/projects/p/peaks/quiet.mp4", [0.25, 0.25], 1); + expect(host.querySelector("[data-testid=clip-peak-marks]")).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/ClipPeakMarks.tsx b/packages/studio/src/player/components/ClipPeakMarks.tsx new file mode 100644 index 0000000000..e8af8cc59c --- /dev/null +++ b/packages/studio/src/player/components/ClipPeakMarks.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { clipPeakRuns, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns"; + +const peakMapRequests = new Map>(); + +function loadPeakMap(url: string): Promise { + const pending = peakMapRequests.get(url); + if (pending) return pending; + const request = fetch(url) + .then((res) => (res.ok ? res.json() : null)) + .then((body: unknown) => (isPeakMap(body) ? body : null)) + .catch(() => null); + peakMapRequests.set(url, request); + return request; +} + +const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`; + +/** A clip's waveform with red marks where it, at its own volume, reaches the export ceiling. */ +export function ClipPeakMarks({ + peaksUrl, + sourceWindow, + gain, + children, +}: { + peaksUrl: string | undefined; + sourceWindow: ClipSourceWindow; + gain: number; + children?: ReactNode; +}) { + const [map, setMap] = useState(null); + useEffect(() => { + if (!peaksUrl) return; + let live = true; + void loadPeakMap(peaksUrl).then((loaded) => { + if (live) setMap(loaded); + }); + return () => { + live = false; + }; + }, [peaksUrl]); + const { runs, peakDbfs } = map + ? clipPeakRuns(map, sourceWindow, gain) + : { runs: [], peakDbfs: null }; + return ( +
+ {children} + {runs.length > 0 && peakDbfs !== null && ( +
+ {runs.map((run) => ( +
+ ))} + + ▲ peaks {dbText(peakDbfs)} + +
+ )} +
+ ); +} diff --git a/packages/studio/src/player/components/clipPeakRuns.test.ts b/packages/studio/src/player/components/clipPeakRuns.test.ts new file mode 100644 index 0000000000..5aed064218 --- /dev/null +++ b/packages/studio/src/player/components/clipPeakRuns.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { clipPeakRuns } from "./clipPeakRuns"; + +const map = (bins: number[]) => ({ binSeconds: 1, bins }); + +describe("clipPeakRuns", () => { + it("marks bins at or above -1 dBFS after the clip's gain and names the loudest", () => { + const { runs, peakDbfs } = clipPeakRuns( + map([0.1, 0.95, 0.99, 0.2, 0.9]), + { mediaStart: 0, sourceSpan: 5 }, + 1, + ); + expect(runs).toEqual([ + { from: 0.2, to: 0.6 }, + { from: 0.8, to: 1 }, + ]); + expect(peakDbfs).toBeCloseTo(20 * Math.log10(0.99), 6); + }); + + it("puts nothing on a -12 dBFS clip, and marks it once boosted by +12 dB", () => { + const quiet = map([0.25, 0.25]); + expect(clipPeakRuns(quiet, { mediaStart: 0, sourceSpan: 2 }, 1)).toEqual({ + runs: [], + peakDbfs: null, + }); + expect(clipPeakRuns(quiet, { mediaStart: 0, sourceSpan: 2 }, 4).runs).toHaveLength(1); + }); + + it("only reads the trimmed window, positioned within the clip", () => { + const { runs } = clipPeakRuns(map([1, 0, 0, 1, 0]), { mediaStart: 2, sourceSpan: 2 }, 1); + expect(runs).toEqual([{ from: 0.5, to: 1 }]); + }); + + it("is empty for a muted clip or an empty window", () => { + expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 1 }, 0).runs).toEqual([]); + expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 0 }, 1).runs).toEqual([]); + }); +}); diff --git a/packages/studio/src/player/components/clipPeakRuns.ts b/packages/studio/src/player/components/clipPeakRuns.ts new file mode 100644 index 0000000000..5478d231fc --- /dev/null +++ b/packages/studio/src/player/components/clipPeakRuns.ts @@ -0,0 +1,59 @@ +import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling"; + +export interface PeakMap { + binSeconds: number; + bins: number[]; +} + +export interface ClipSourceWindow { + mediaStart: number; + sourceSpan: number; +} + +export interface ClipPeakMarkRuns { + runs: Array<{ from: number; to: number }>; + peakDbfs: number | null; +} + +/** + * Where, as fractions of the clip, its source at the clip's own gain reaches the + * export ceiling, and the loudest point when it does. Adjacent hot bins merge. + */ +export function clipPeakRuns( + map: PeakMap, + window: ClipSourceWindow, + gain: number, +): ClipPeakMarkRuns { + const runs: ClipPeakMarkRuns["runs"] = []; + if (!(window.sourceSpan > 0) || !(map.binSeconds > 0) || !(gain > 0)) + return { runs, peakDbfs: null }; + const first = Math.max(0, Math.floor(window.mediaStart / map.binSeconds)); + const last = Math.min( + map.bins.length, + Math.ceil((window.mediaStart + window.sourceSpan) / map.binSeconds), + ); + const toFraction = (bin: number) => + Math.min(1, Math.max(0, (bin * map.binSeconds - window.mediaStart) / window.sourceSpan)); + let loudest = 0; + for (let bin = first; bin < last; bin++) { + const level = (map.bins[bin] ?? 0) * gain; + if (level < EXPORT_PEAK_CEILING) continue; + loudest = Math.max(loudest, level); + const previous = runs.at(-1); + const from = toFraction(bin); + const to = toFraction(bin + 1); + if (previous && previous.to >= from) previous.to = to; + else runs.push({ from, to }); + } + return { runs, peakDbfs: runs.length > 0 ? 20 * Math.log10(loudest) : null }; +} + +export function isPeakMap(value: unknown): value is PeakMap { + if (typeof value !== "object" || value === null) return false; + const bins: unknown = Reflect.get(value, "bins"); + return ( + typeof Reflect.get(value, "binSeconds") === "number" && + Array.isArray(bins) && + bins.every((bin) => typeof bin === "number") + ); +} diff --git a/packages/studio/src/utils/exportPeakCeiling.ts b/packages/studio/src/utils/exportPeakCeiling.ts new file mode 100644 index 0000000000..9cbbf97f36 --- /dev/null +++ b/packages/studio/src/utils/exportPeakCeiling.ts @@ -0,0 +1 @@ +export const EXPORT_PEAK_CEILING = 10 ** (-1 / 20); From 036aa22921bc253de703affccf4177ff5a998e8d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 29 Sep 2026 23:54:22 -0700 Subject: [PATCH 5/9] feat(studio): let a video with sound be the beat source Studio's isMusicSourceElement accepts an audible, unmuted video for the music track and the beat fallback (lane zoning unchanged); core findMusicAudioSrc accepts