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().
This commit is contained in:
Miguel Ángel
2026-06-08 21:29:04 -04:00
committed by GitHub
parent 48711ab135
commit 908b455e5e
5 changed files with 45 additions and 15 deletions
+2 -4
View File
@@ -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 &&
+33 -4
View File
@@ -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<string, string>): HTMLVideoElement {
@@ -23,6 +23,37 @@ function createAudio(attrs: Record<string, string>): 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;
+6 -5
View File
@@ -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 <script> tag.
const rawRate = el.defaultPlaybackRate;
const playbackRate =
Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
const playbackRate = readElementPlaybackRate(el);
const loop = el.loop;
const sourceDuration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null;
let duration =
+2 -1
View File
@@ -1,5 +1,6 @@
import type { RuntimeTimelineLike } from "./types";
import { swallow } from "./diagnostics";
import { readElementPlaybackRate } from "./media";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
@@ -106,7 +107,7 @@ export function createRuntimeStartTimeResolver(params: {
parseNumeric(element.getAttribute("data-media-start")) ??
0;
if (Number.isFinite(element.duration) && element.duration > playbackStart) {
resolved = element.duration - playbackStart;
resolved = (element.duration - playbackStart) / readElementPlaybackRate(element);
}
}
if (resolved == null || resolved <= 0) {
+2 -1
View File
@@ -5,6 +5,7 @@ import type {
RuntimeTimelineLike,
} from "./types";
import { swallow } from "./diagnostics";
import { readElementPlaybackRate } from "./media";
import { createRuntimeStartTimeResolver } from "./startResolver";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
@@ -208,7 +209,7 @@ export function collectRuntimeTimelinePayload(params: {
parseNum(mediaEl.getAttribute("data-media-start")) ??
0;
if (Number.isFinite(mediaEl.duration) && mediaEl.duration > playbackStart) {
return Math.max(0, mediaEl.duration - playbackStart);
return Math.max(0, (mediaEl.duration - playbackStart) / readElementPlaybackRate(mediaEl));
}
return null;
};