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