From 908b455e5eed94f6c6d1e6a27b2a6c99a8032394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 8 Jun 2026 21:29:04 -0400 Subject: [PATCH] fix(core): apply playbackRate to all media duration resolution sites (#1288) Extract readElementPlaybackRate() to eliminate clamping duplication across media.ts, init.ts, startResolver.ts, and timeline.ts. Apply the rate division to the two remaining sites that were missed: - startResolver.ts: visibility loop used raw source duration, hiding slowed-down videos mid-playback when no data-duration was set - timeline.ts: resolveMediaElementDurationSeconds underreported the end window sent to the renderer, affecting preview parity Also adds direct tests for readElementPlaybackRate(). --- packages/core/src/runtime/init.ts | 6 ++-- packages/core/src/runtime/media.test.ts | 37 +++++++++++++++++++--- packages/core/src/runtime/media.ts | 11 ++++--- packages/core/src/runtime/startResolver.ts | 3 +- packages/core/src/runtime/timeline.ts | 3 +- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cc814b62a..aaeb7dfa2 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -9,7 +9,7 @@ import { createThreeAdapter } from "./adapters/three"; import { createTypegpuAdapter } from "./adapters/typegpu"; import { patchVideoTextureCompat } from "./adapters/video-texture-compat"; import { createWaapiAdapter } from "./adapters/waapi"; -import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media"; +import { readElementPlaybackRate, refreshRuntimeMediaCache, syncRuntimeMedia } from "./media"; import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js"; import { createPickerModule } from "./picker"; import { createRuntimePlayer } from "./player"; @@ -1356,9 +1356,7 @@ 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 playbackRate = readElementPlaybackRate(element); const hostRemaining = context.inheritedStart != null && context.inheritedDuration != null && diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index 48c75342e..97862c2c2 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media"; +import { readElementPlaybackRate, refreshRuntimeMediaCache, syncRuntimeMedia } from "./media"; import type { RuntimeMediaClip } from "./media"; function createVideo(attrs: Record): HTMLVideoElement { @@ -23,6 +23,37 @@ function createAudio(attrs: Record): HTMLAudioElement { return el; } +describe("readElementPlaybackRate", () => { + it("reads defaultPlaybackRate from element", () => { + const el = document.createElement("video"); + Object.defineProperty(el, "defaultPlaybackRate", { value: 0.5, writable: true }); + expect(readElementPlaybackRate(el)).toBe(0.5); + }); + + it("defaults to 1 when not set", () => { + const el = document.createElement("video"); + expect(readElementPlaybackRate(el)).toBe(1); + }); + + it("clamps to [0.1, 5]", () => { + const el = document.createElement("video"); + Object.defineProperty(el, "defaultPlaybackRate", { value: 0.01, writable: true }); + expect(readElementPlaybackRate(el)).toBe(0.1); + Object.defineProperty(el, "defaultPlaybackRate", { value: 10, writable: true }); + expect(readElementPlaybackRate(el)).toBe(5); + }); + + it("defaults to 1 for NaN/negative/zero", () => { + const el = document.createElement("video"); + Object.defineProperty(el, "defaultPlaybackRate", { value: NaN, writable: true }); + expect(readElementPlaybackRate(el)).toBe(1); + Object.defineProperty(el, "defaultPlaybackRate", { value: -1, writable: true }); + expect(readElementPlaybackRate(el)).toBe(1); + Object.defineProperty(el, "defaultPlaybackRate", { value: 0, writable: true }); + expect(readElementPlaybackRate(el)).toBe(1); + }); +}); + describe("refreshRuntimeMediaCache", () => { afterEach(() => { document.body.innerHTML = ""; @@ -140,9 +171,7 @@ describe("refreshRuntimeMediaCache", () => { 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 playbackRate = readElementPlaybackRate(element); return Number.isFinite(element.duration) && element.duration > mediaStart ? Math.max(0, (element.duration - mediaStart) / playbackRate) : null; diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 0e65c28eb..af84cf3d4 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -1,6 +1,11 @@ import { swallow } from "./diagnostics"; import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js"; +export function readElementPlaybackRate(el: HTMLMediaElement): number { + const raw = el.defaultPlaybackRate; + return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1; +} + export type RuntimeMediaClip = { el: HTMLVideoElement | HTMLAudioElement; start: number; @@ -47,11 +52,7 @@ export function refreshRuntimeMediaCache(params?: { if (!Number.isFinite(start)) continue; const mediaStart = Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0; - // Read per-element rate from the native defaultPlaybackRate property. - // LLMs set this via el.defaultPlaybackRate = 0.5 in a