Skip to content
Open
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
24 changes: 20 additions & 4 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1469,10 +1469,18 @@ export function initSandboxRuntimeModular(): void {
Number.isFinite(element.duration) && element.duration > mediaStart
? Math.max(0, element.duration - mediaStart)
: null;
if (sourceDuration != null && hostRemaining != null) {
return Math.min(sourceDuration, hostRemaining);
}
return sourceDuration ?? hostRemaining;
// The element's own data-duration is an explicit clip-length trim
// (the studio writes it when you drag the clip edge). It must bound
// playback so a trimmed track stops at its edge instead of running on
// to the source-file or host-composition end. Absent → no cap (an
// untrimmed clip plays its natural source length).
const ownDuration = Number.parseFloat(element.dataset.duration ?? "");
const explicitDuration =
Number.isFinite(ownDuration) && ownDuration > 0 ? ownDuration : null;
const candidates = [sourceDuration, hostRemaining, explicitDuration].filter(
(value): value is number => value != null,
);
return candidates.length > 0 ? Math.min(...candidates) : null;
},
});
// Attach probed volume keyframes to clips so syncRuntimeMedia can use the
Expand Down Expand Up @@ -2168,6 +2176,13 @@ export function initSandboxRuntimeModular(): void {
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;
const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? "");
const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1;
// The clip's authored window bounds the WebAudio buffer so a trimmed
// clip stops at its edge instead of running to the source's end.
const durationAttr = Number.parseFloat(rawEl.dataset.duration ?? "");
const clipDuration =
Number.isFinite(durationAttr) && durationAttr > 0
? durationAttr
: Number.POSITIVE_INFINITY;
void webAudio.decodeAudioElement(rawEl).then((buffer) => {
if (!buffer || !clock.isPlaying()) return;
void webAudio.schedulePlayback(
Expand All @@ -2179,6 +2194,7 @@ export function initSandboxRuntimeModular(): void {
vol * state.bridgeVolume,
gen,
state.playbackRate,
clipDuration,
);
});
}
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/runtime/webAudioTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,43 @@ describe("WebAudioTransport", () => {
});
});

describe("clip duration bound (trim)", () => {
it("bounds an in-progress clip to its remaining authored window", async () => {
const { transport, mock, gen } = setupTransport(100);
// compStart=5, mediaStart=0, compTime=8 → elapsed=3; clipDuration=10 → 7 left
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1, 10);
expect(mock.startFn).toHaveBeenCalledWith(0, 3, 7);
});

it("bounds a future clip to its full authored window", async () => {
const { transport, mock, gen } = setupTransport(100);
// compStart=10, mediaStart=1.5, compTime=2 → elapsed=-8 → delay 8; clipDuration=4
await transport.schedulePlayback(mockEl, mockBuffer, 10, 1.5, 2, 1, gen, 1, 4);
expect(mock.startFn).toHaveBeenCalledWith(108, 1.5, 4);
});

it("does not schedule a clip whose window has already elapsed", async () => {
const { transport, mock, gen } = setupTransport(100);
// elapsed=15 > clipDuration=10 → nothing to play
const result = await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 20, 1, gen, 1, 10);
expect(result).toBeNull();
expect(mock.startFn).not.toHaveBeenCalled();
});

it("scales the bound by playback rate (buffer seconds)", async () => {
const { transport, mock, gen } = setupTransport(100);
// rate=2, clipDuration=10 → clipSourceLen=20; elapsed=3 → 17 buffer seconds left
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2, 10);
expect(mock.startFn).toHaveBeenCalledWith(0, 3, 17);
});

it("plays unbounded when clipDuration is omitted (legacy behavior)", async () => {
const { transport, mock, gen } = setupTransport(100);
await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen);
expect(mock.startFn).toHaveBeenCalledWith(0, 3);
});
});

describe("playback rate", () => {
it("sets sourceNode.playbackRate.value when rate is provided", async () => {
const { transport, mock, gen } = setupTransport(100);
Expand Down
55 changes: 50 additions & 5 deletions packages/core/src/runtime/webAudioTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,42 @@ function normalizeRate(rate: number): number {
return rate;
}

/**
* Start a buffer source, bounding it to the clip's authored window
* (`data-duration`) so a trimmed clip stops at its edge instead of running the
* buffer to the source file's natural end. `clipSourceLen` is the clip span in
* buffer seconds; the third `start()` arg is the portion to play from the
* offset. An infinite `clipDuration` plays unbounded (legacy behavior).
*
* Returns false when the playhead is already past the clip end (nothing to
* play); the caller should discard the source.
*/
function startBoundedSource(
node: AudioBufferSourceNode,
opts: {
elapsed: number;
mediaStart: number;
scheduledAt: number;
safeRate: number;
clipDuration: number;
},
): boolean {
const { elapsed, mediaStart, scheduledAt, safeRate, clipDuration } = opts;
const hasBound = Number.isFinite(clipDuration) && clipDuration > 0;
const clipSourceLen = clipDuration * safeRate;
if (elapsed >= 0) {
const remaining = clipSourceLen - elapsed;
if (hasBound && remaining <= 0) return false;
if (hasBound) node.start(0, elapsed + mediaStart, remaining);
else node.start(0, elapsed + mediaStart);
return true;
}
const delay = -elapsed / safeRate;
if (hasBound) node.start(scheduledAt + delay, mediaStart, clipSourceLen);
else node.start(scheduledAt + delay, mediaStart);
return true;
}

export type ScheduledSource = {
el: HTMLMediaElement;
sourceNode: AudioBufferSourceNode;
Expand Down Expand Up @@ -92,6 +128,7 @@ export class WebAudioTransport {
volume: number,
generation: number,
rate = 1,
clipDuration = Number.POSITIVE_INFINITY,
): Promise<ScheduledSource | null> {
if (!this._ctx || !this._masterGain) return null;
if (generation !== this._playGeneration) return null;
Expand Down Expand Up @@ -119,11 +156,19 @@ export class WebAudioTransport {
this._rateAnchorCtx = scheduledAt;
this._rateAnchorComp = compositionTime;

if (elapsed >= 0) {
sourceNode.start(0, elapsed + mediaStart);
} else {
const delay = -elapsed / safeRate;
sourceNode.start(scheduledAt + delay, mediaStart);
if (
!startBoundedSource(sourceNode, {
elapsed,
mediaStart,
scheduledAt,
safeRate,
clipDuration,
})
) {
// Playhead already past the clip end — discard the nodes we built.
sourceNode.disconnect();
gainNode.disconnect();
return null;
}

const priorMuted = el.muted;
Expand Down
49 changes: 49 additions & 0 deletions packages/player/src/parent-media.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { describe, it, expect } from "vitest";
import { ParentMediaManager, type ProxyEntry } from "./parent-media";

// A fake media element whose paused state is driven by play()/pause() stubs.
function makeFakeAudio(initiallyPaused: boolean): HTMLMediaElement {
const el = new Audio();
let paused = initiallyPaused;
Object.defineProperty(el, "paused", { get: () => paused });
el.pause = () => {
paused = true;
};
el.play = () => {
paused = false;
return Promise.resolve();
};
el.src = "https://example.test/music.mp3";
return el;
}

function makeManager(overrides: Partial<{ isPaused: boolean; owner: "runtime" | "parent" }> = {}) {
const mgr = new ParentMediaManager({
dispatchEvent: () => {},
Expand Down Expand Up @@ -73,6 +89,39 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
expect(mgr.entries).toHaveLength(0);
});

it("pauses a proxy once the playhead passes the clip end (trimmed clip)", () => {
const mgr = makeManager({ owner: "parent", isPaused: false });
const el = makeFakeAudio(false); // already playing within the clip
mgr.entries.push({ el, start: 0, duration: 5, driftSamples: 0 });

mgr.mirrorTime(3); // inside [0, 5) — stays playing
expect(el.paused).toBe(false);

mgr.mirrorTime(6); // past the trimmed end — must pause
expect(el.paused).toBe(true);
});

it("re-reads the source element's live data-duration so trims bound the proxy", () => {
const mgr = makeManager({ owner: "parent", isPaused: false });
const source = new Audio();
source.setAttribute("data-start", "0");
source.setAttribute("data-duration", "30");
// jsdom reports isConnected=false unless attached; attach it.
document.body.appendChild(source);

const el = makeFakeAudio(false);
mgr.entries.push({ el, start: 0, duration: 30, driftSamples: 0, source });

mgr.mirrorTime(20); // within 30 → playing
expect(el.paused).toBe(false);

// User trims the clip to 10s; the proxy must pick it up and pause at 20s.
source.setAttribute("data-duration", "10");
mgr.mirrorTime(20);
expect(el.paused).toBe(true);
source.remove();
});

it("does not duplicate or hijack a clip the composition already owns", () => {
const mgr = makeManager();
// The composition already adopted a clip with this URL.
Expand Down
51 changes: 38 additions & 13 deletions packages/player/src/parent-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export interface ProxyEntry {
el: HTMLMediaElement;
start: number;
duration: number;
/**
* The iframe media element this proxy mirrors, when adopted from the DOM.
* Its `data-start`/`data-duration` are re-read each tick so live timeline
* edits (trim/move) bound the proxy correctly. Null for URL-driven proxies.
*/
source?: HTMLMediaElement | null;
/**
* Count of consecutive steady-state samples in which the proxy's
* `currentTime` was found drifted beyond `MIRROR_DRIFT_THRESHOLD_SECONDS`.
Expand Down Expand Up @@ -118,11 +124,33 @@ export class ParentMediaManager {
for (const m of this._entries) m.el.playbackRate = rate;
}

playAll(): void {
for (const m of this._entries) {
if (!m.el.src) continue;
m.el.play().catch((err: unknown) => this._reportPlaybackError(err));
private _playEntry(m: ProxyEntry): void {
if (!m.el.src) return;
m.el.play().catch((err: unknown) => this._reportPlaybackError(err));
}

// Re-read the source clip's live timing so trims/moves bound the proxy
// (adopt-time values go stale when the timeline is edited).
private _refreshEntryBounds(m: ProxyEntry): void {
if (!m.source?.isConnected) return;
m.start = parseFloat(m.source.getAttribute("data-start") || "0");
m.duration = parseFloat(m.source.getAttribute("data-duration") || "Infinity");
}

// Pause the proxy outside its clip window; resume it on re-entry during
// parent-owned playback. Returns whether the proxy is within the window.
private _gateEntryPlayback(m: ProxyEntry, relTime: number): boolean {
if (relTime < 0 || relTime >= m.duration) {
if (!m.el.paused) m.el.pause();
m.driftSamples = 0;
return false;
}
if (this._audioOwner === "parent" && !this._isPaused() && m.el.paused) this._playEntry(m);
return true;
}

playAll(): void {
for (const m of this._entries) this._playEntry(m);
}

pauseAll(): void {
Expand All @@ -145,11 +173,9 @@ export class ParentMediaManager {
mirrorTime(timelineSeconds: number, options?: { force?: boolean }): void {
const force = options?.force === true;
for (const m of this._entries) {
this._refreshEntryBounds(m);
const relTime = timelineSeconds - m.start;
if (relTime < 0 || relTime >= m.duration) {
m.driftSamples = 0;
continue;
}
if (!this._gateEntryPlayback(m, relTime)) continue;
if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
m.driftSamples += 1;
if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) {
Expand Down Expand Up @@ -275,6 +301,7 @@ export class ParentMediaManager {
tag: "audio" | "video",
start: number,
duration: number,
source?: HTMLMediaElement | null,
): ProxyEntry | null {
if (this._entries.some((m) => m.el.src === src)) return null;

Expand All @@ -287,7 +314,7 @@ export class ParentMediaManager {
const rate = this._getPlaybackRate();
if (rate !== 1) el.playbackRate = rate;

const entry: ProxyEntry = { el, start, duration, driftSamples: 0 };
const entry: ProxyEntry = { el, start, duration, driftSamples: 0, source };
this._entries.push(entry);
return entry;
}
Expand All @@ -312,15 +339,13 @@ export class ParentMediaManager {
const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity");
const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const);

const created = this._createEntry(src, tag, start, duration);
const created = this._createEntry(src, tag, start, duration, iframeEl);

// If already under parent ownership and playing, the new proxy must catch
// up immediately — bypass the jitter-coalescing gate.
if (created && this._audioOwner === "parent") {
this.mirrorTime(this._getCurrentTime(), { force: true });
if (!this._isPaused() && created.el.src) {
created.el.play().catch((err: unknown) => this._reportPlaybackError(err));
}
if (!this._isPaused()) this._playEntry(created);
}
}

Expand Down
Loading
Loading