Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions web/src/components/preview/previewEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
activeAudioClips,
activeVisualClips,
advancePlayhead,
clipVolume,
clipVolumeAt,
frameForSourceTime,
isExternalSeekWhilePlaying,
shouldUseRustEngine,
Expand Down Expand Up @@ -327,10 +327,16 @@ export function useTimelinePlaybackEngine(): void {
const key = previewElementKey(m);
const el = previewElements.get(key);
if (!el) continue; // images carry no media element
const vol = clipVolume(m.track, m.clip);
// Frame-aware gain: static volume x dB keyframe automation x fade ramp
// (Clip::volume_at). The true gain can exceed 1 (boosted keyframes / a
// >1 static volume) but HTMLMediaElement.volume is capped to [0,1] and
// throws a RangeError above 1, so the clamp lives here at the
// assignment, not inside the pure helper.
// TODO(>0dB): route through a Web Audio GainNode to make >0 dB boosts audible.
const gain = clipVolumeAt(m.track, m.clip, r);
const isVisualVideo = visuals.some((visual) => visual.clip.id === m.clip.id);
el.muted = vol <= 0 || (isVisualVideo && duplicatedVisualAudioRefs.has(m.clip.mediaRef));
el.volume = vol;
el.muted = gain <= 0 || (isVisualVideo && duplicatedVisualAudioRefs.has(m.clip.mediaRef));
el.volume = Math.min(1, gain);
const desired = sourceTimeSec(m.clip, f, fps);
const previousClipId = lastClipByKey.get(key) ?? null;
lastClipByKey.set(key, m.clip.id);
Expand Down
61 changes: 61 additions & 0 deletions web/src/components/preview/timelinePlayback.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { linearFromDb } from "../../lib/clip";
import type { Clip, ClipType, Timeline, Track } from "../../lib/types";
import {
activeAudioClips,
Expand All @@ -8,6 +9,7 @@ import {
clipCoversFrame,
clipOpacity,
clipVolume,
clipVolumeAt,
frameForSourceTime,
isExternalSeekWhilePlaying,
playbackFrameFromActiveFrame,
Expand Down Expand Up @@ -222,6 +224,65 @@ describe("clipVolume / clipOpacity", () => {
});
});

describe("clipVolumeAt (frame-aware envelope + fade, T2-7)", () => {
it("zeroes gain on a muted track regardless of the clip's envelope", () => {
const t = track({ id: "a", type: "audio", muted: true, clips: [] });
const c = clip({ id: "c", mediaType: "audio", volume: 1 });
expect(clipVolumeAt(t, c, 50)).toBe(0);
});

it("passes through the static volume when there is no keyframe track or fade", () => {
const t = track({ id: "a", type: "audio", clips: [] });
const c = clip({ id: "c", mediaType: "audio", volume: 0.5, startFrame: 0, durationFrames: 100 });
expect(clipVolumeAt(t, c, 50)).toBeCloseTo(0.5);
});

it("follows a dB volume keyframe via linearFromDb at a sampled frame", () => {
const t = track({ id: "a", type: "audio", clips: [] });
const c = clip({
id: "c",
mediaType: "audio",
volume: 1,
startFrame: 0,
durationFrames: 100,
volumeTrack: {
keyframes: [{ frame: 0, value: -6, interpolationOut: "hold" }],
},
});
// hold interpolation -> flat -6dB across the clip; gain = 1 * linearFromDb(-6).
expect(clipVolumeAt(t, c, 50)).toBeCloseTo(linearFromDb(-6));
expect(clipVolumeAt(t, c, 50)).toBeLessThan(1);
});

it("ramps 0 -> full gain across fadeInFrames", () => {
const t = track({ id: "a", type: "audio", clips: [] });
const c = clip({
id: "c",
mediaType: "audio",
volume: 1,
startFrame: 0,
durationFrames: 100,
fadeInFrames: 10,
fadeInInterpolation: "linear",
});
expect(clipVolumeAt(t, c, 0)).toBeCloseTo(0);
expect(clipVolumeAt(t, c, 5)).toBeCloseTo(0.5);
expect(clipVolumeAt(t, c, 10)).toBeCloseTo(1);
});

it("preserves a >1 static-volume boost pre-clamp (the [0,1] clamp is the caller's job)", () => {
const t = track({ id: "a", type: "audio", clips: [] });
const c = clip({ id: "c", mediaType: "audio", volume: 4, startFrame: 0, durationFrames: 100 });
expect(clipVolumeAt(t, c, 50)).toBeCloseTo(4);
});

it("falls back to 1 for a non-finite gain", () => {
const t = track({ id: "a", type: "audio", clips: [] });
const c = clip({ id: "c", mediaType: "audio", volume: NaN, startFrame: 0, durationFrames: 100 });
expect(clipVolumeAt(t, c, 50)).toBe(1);
});
});

describe("visualAudioIsDuplicated", () => {
it("flags a video whose source is also on an audio track", () => {
const visual = { clip: clip({ id: "v", mediaType: "video", mediaRef: "m1" }), track: track({ id: "v1", type: "video", clips: [] }), trackIndex: 0 };
Expand Down
18 changes: 18 additions & 0 deletions web/src/components/preview/timelinePlayback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* frame instead of switching to a separate ffmpeg/PNG render path.
*/

import { volumeAt } from "../../lib/clip";
import type { Clip, Timeline, Track } from "../../lib/types";

/** A clip selected for playback at a frame, with its track context. */
Expand Down Expand Up @@ -111,6 +112,23 @@ export function clipVolume(track: Track, clip: Clip): number {
return Math.max(0, Math.min(1, Number.isFinite(v) ? v : 1));
}

/**
* Frame-aware playback gain for a clip: 0 when the track is muted, otherwise
* the true `volumeAt` envelope (static volume × dB keyframe automation ×
* fade-in/out ramp — `Clip::volume_at` / `Timeline.swift:189`). Unlike
* {@link clipVolume} this is NOT clamped to 1 — callers that assign to
* `HTMLMediaElement.volume` must clamp at the assignment site, since that
* setter throws a RangeError above 1. Floors at 0 (upstream never emits a
* negative gain, but this guards a still-authored fade edge) and falls back
* to 1 for a non-finite result (matches `clipVolume`'s NaN guard).
*/
export function clipVolumeAt(track: Track, clip: Clip, frame: number): number {
if (track.muted) return 0;
const gain = volumeAt(clip, frame);
if (!Number.isFinite(gain)) return 1;
return Math.max(0, gain);
}

/** Effective 0–1 opacity for a clip (clamped; defaults to 1). */
export function clipOpacity(clip: Clip): number {
const o = clip.opacity;
Expand Down
Loading