From 7b6c87fa88d8faac2cf37a36cd35e617c88b549d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 8 Jun 2026 21:17:41 -0400 Subject: [PATCH] fix(core): account for playbackRate in media duration resolution (#1287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): account for playbackRate in media duration resolution resolveDurationSeconds computed sourceDuration as (element.duration - mediaStart) without dividing by playbackRate. A 5s source at 0.5x should span 10s on the timeline, but was capped at 5s — causing the video to go black once the raw source was exhausted. Read defaultPlaybackRate from the element (same clamping as refreshRuntimeMediaCache) and divide sourceDuration by it so the effective timeline window matches the actual playback speed. * test(core): add regression test for playbackRate in resolveDurationSeconds Pins the fix: a 5s source at 0.5x playbackRate must resolve to 10s effective duration when resolveDurationSeconds is provided (mirroring the init.ts callback pattern). Without the rate division, this would return 5s and clip early. --- packages/core/src/runtime/init.ts | 5 ++++- packages/core/src/runtime/media.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 90b7eca08..cc814b62a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1356,6 +1356,9 @@ export function initSandboxRuntimeModular(): void { const mediaStart = Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") || 0; + const rawRate = element.defaultPlaybackRate; + const playbackRate = + Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1; const hostRemaining = context.inheritedStart != null && context.inheritedDuration != null && @@ -1364,7 +1367,7 @@ export function initSandboxRuntimeModular(): void { : null; const sourceDuration = Number.isFinite(element.duration) && element.duration > mediaStart - ? Math.max(0, element.duration - mediaStart) + ? Math.max(0, (element.duration - mediaStart) / playbackRate) : null; if (sourceDuration != null && hostRemaining != null) { return Math.min(sourceDuration, hostRemaining); diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index 77eae50e2..48c75342e 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -131,6 +131,28 @@ describe("refreshRuntimeMediaCache", () => { expect(result.mediaClips[0].duration).toBe(20); }); + it("resolveDurationSeconds must account for playbackRate (regression: clip clipped early)", () => { + const el = createVideo({ "data-start": "0", "data-duration": "10" }); + Object.defineProperty(el, "defaultPlaybackRate", { value: 0.5, writable: true }); + Object.defineProperty(el, "duration", { value: 5, writable: true }); + const result = refreshRuntimeMediaCache({ + resolveDurationSeconds: (element) => { + const mediaStart = + Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") || + 0; + const rawRate = element.defaultPlaybackRate; + const playbackRate = + Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1; + return Number.isFinite(element.duration) && element.duration > mediaStart + ? Math.max(0, (element.duration - mediaStart) / playbackRate) + : null; + }, + }); + // 5s source at 0.5x = 10s effective; should NOT be capped to 5s + expect(result.mediaClips[0].duration).toBe(10); + expect(result.mediaClips[0].end).toBe(10); + }); + it("reads native loop attribute", () => { createVideo({ "data-start": "0", "data-duration": "15", loop: "" }); const result = refreshRuntimeMediaCache();