Skip to content

Commit 908b455

Browse files
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().
1 parent 48711ab commit 908b455

5 files changed

Lines changed: 45 additions & 15 deletions

File tree

packages/core/src/runtime/init.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { createThreeAdapter } from "./adapters/three";
99
import { createTypegpuAdapter } from "./adapters/typegpu";
1010
import { patchVideoTextureCompat } from "./adapters/video-texture-compat";
1111
import { createWaapiAdapter } from "./adapters/waapi";
12-
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
12+
import { readElementPlaybackRate, refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
1313
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
1414
import { createPickerModule } from "./picker";
1515
import { createRuntimePlayer } from "./player";
@@ -1356,9 +1356,7 @@ export function initSandboxRuntimeModular(): void {
13561356
const mediaStart =
13571357
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
13581358
0;
1359-
const rawRate = element.defaultPlaybackRate;
1360-
const playbackRate =
1361-
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
1359+
const playbackRate = readElementPlaybackRate(element);
13621360
const hostRemaining =
13631361
context.inheritedStart != null &&
13641362
context.inheritedDuration != null &&

packages/core/src/runtime/media.test.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, afterEach } from "vitest";
2-
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
2+
import { readElementPlaybackRate, refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
33
import type { RuntimeMediaClip } from "./media";
44

55
function createVideo(attrs: Record<string, string>): HTMLVideoElement {
@@ -23,6 +23,37 @@ function createAudio(attrs: Record<string, string>): HTMLAudioElement {
2323
return el;
2424
}
2525

26+
describe("readElementPlaybackRate", () => {
27+
it("reads defaultPlaybackRate from element", () => {
28+
const el = document.createElement("video");
29+
Object.defineProperty(el, "defaultPlaybackRate", { value: 0.5, writable: true });
30+
expect(readElementPlaybackRate(el)).toBe(0.5);
31+
});
32+
33+
it("defaults to 1 when not set", () => {
34+
const el = document.createElement("video");
35+
expect(readElementPlaybackRate(el)).toBe(1);
36+
});
37+
38+
it("clamps to [0.1, 5]", () => {
39+
const el = document.createElement("video");
40+
Object.defineProperty(el, "defaultPlaybackRate", { value: 0.01, writable: true });
41+
expect(readElementPlaybackRate(el)).toBe(0.1);
42+
Object.defineProperty(el, "defaultPlaybackRate", { value: 10, writable: true });
43+
expect(readElementPlaybackRate(el)).toBe(5);
44+
});
45+
46+
it("defaults to 1 for NaN/negative/zero", () => {
47+
const el = document.createElement("video");
48+
Object.defineProperty(el, "defaultPlaybackRate", { value: NaN, writable: true });
49+
expect(readElementPlaybackRate(el)).toBe(1);
50+
Object.defineProperty(el, "defaultPlaybackRate", { value: -1, writable: true });
51+
expect(readElementPlaybackRate(el)).toBe(1);
52+
Object.defineProperty(el, "defaultPlaybackRate", { value: 0, writable: true });
53+
expect(readElementPlaybackRate(el)).toBe(1);
54+
});
55+
});
56+
2657
describe("refreshRuntimeMediaCache", () => {
2758
afterEach(() => {
2859
document.body.innerHTML = "";
@@ -140,9 +171,7 @@ describe("refreshRuntimeMediaCache", () => {
140171
const mediaStart =
141172
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
142173
0;
143-
const rawRate = element.defaultPlaybackRate;
144-
const playbackRate =
145-
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
174+
const playbackRate = readElementPlaybackRate(element);
146175
return Number.isFinite(element.duration) && element.duration > mediaStart
147176
? Math.max(0, (element.duration - mediaStart) / playbackRate)
148177
: null;

packages/core/src/runtime/media.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { swallow } from "./diagnostics";
22
import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
33

4+
export function readElementPlaybackRate(el: HTMLMediaElement): number {
5+
const raw = el.defaultPlaybackRate;
6+
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
7+
}
8+
49
export type RuntimeMediaClip = {
510
el: HTMLVideoElement | HTMLAudioElement;
611
start: number;
@@ -47,11 +52,7 @@ export function refreshRuntimeMediaCache(params?: {
4752
if (!Number.isFinite(start)) continue;
4853
const mediaStart =
4954
Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
50-
// Read per-element rate from the native defaultPlaybackRate property.
51-
// LLMs set this via el.defaultPlaybackRate = 0.5 in a <script> tag.
52-
const rawRate = el.defaultPlaybackRate;
53-
const playbackRate =
54-
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
55+
const playbackRate = readElementPlaybackRate(el);
5556
const loop = el.loop;
5657
const sourceDuration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null;
5758
let duration =

packages/core/src/runtime/startResolver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { RuntimeTimelineLike } from "./types";
22
import { swallow } from "./diagnostics";
3+
import { readElementPlaybackRate } from "./media";
34

45
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
56
const AUTHORED_END_ATTR = "data-hf-authored-end";
@@ -106,7 +107,7 @@ export function createRuntimeStartTimeResolver(params: {
106107
parseNumeric(element.getAttribute("data-media-start")) ??
107108
0;
108109
if (Number.isFinite(element.duration) && element.duration > playbackStart) {
109-
resolved = element.duration - playbackStart;
110+
resolved = (element.duration - playbackStart) / readElementPlaybackRate(element);
110111
}
111112
}
112113
if (resolved == null || resolved <= 0) {

packages/core/src/runtime/timeline.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
RuntimeTimelineLike,
66
} from "./types";
77
import { swallow } from "./diagnostics";
8+
import { readElementPlaybackRate } from "./media";
89
import { createRuntimeStartTimeResolver } from "./startResolver";
910

1011
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
@@ -208,7 +209,7 @@ export function collectRuntimeTimelinePayload(params: {
208209
parseNum(mediaEl.getAttribute("data-media-start")) ??
209210
0;
210211
if (Number.isFinite(mediaEl.duration) && mediaEl.duration > playbackStart) {
211-
return Math.max(0, mediaEl.duration - playbackStart);
212+
return Math.max(0, (mediaEl.duration - playbackStart) / readElementPlaybackRate(mediaEl));
212213
}
213214
return null;
214215
};

0 commit comments

Comments
 (0)