feat(core): add loop and data-playback-rate for media elements

- data-playback-rate: per-element slow-mo/fast-forward (0.1-5x range)
  Multiplied with global transport rate. Affects timeline duration
  calculation when source duration is used as fallback.

- loop: native HTML loop attribute now works correctly in the runtime.
  Wraps media playback from mediaStart when source reaches end.
  Enables looping short clips over longer durations.

Both follow the existing data-media-start/data-volume pattern.
This commit is contained in:
Miguel Ángel
2026-03-24 17:27:41 -04:00
parent 1ea55d8f3a
commit 46e9f8d248
3 changed files with 134 additions and 12 deletions
+2 -1
View File
@@ -406,7 +406,8 @@ export function lintHyperframeHtml(
code: "base64_media_prohibited",
severity: "error",
message: `Inline base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB)${isSuspicious ? " — likely fabricated data" : ""}. Base64 media is prohibited — it bloats file size and breaks rendering.`,
fixHint: "Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
fixHint:
"Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
});
}
+104 -3
View File
@@ -96,6 +96,52 @@ describe("refreshRuntimeMediaCache", () => {
const result = refreshRuntimeMediaCache();
expect(result.mediaClips[0].duration).toBe(8);
});
it("reads defaultPlaybackRate from element", () => {
const el = createVideo({ "data-start": "0", "data-duration": "10" });
Object.defineProperty(el, "defaultPlaybackRate", { value: 0.5, writable: true });
const result = refreshRuntimeMediaCache();
expect(result.mediaClips[0].playbackRate).toBe(0.5);
});
it("defaults playback rate to 1", () => {
createVideo({ "data-start": "0", "data-duration": "5" });
const result = refreshRuntimeMediaCache();
expect(result.mediaClips[0].playbackRate).toBe(1);
});
it("clamps playback rate to [0.1, 5]", () => {
const el1 = createVideo({ "data-start": "0", "data-duration": "5" });
Object.defineProperty(el1, "defaultPlaybackRate", { value: 0.01, writable: true });
const r1 = refreshRuntimeMediaCache();
expect(r1.mediaClips[0].playbackRate).toBe(0.1);
document.body.innerHTML = "";
const el2 = createVideo({ "data-start": "0", "data-duration": "5" });
Object.defineProperty(el2, "defaultPlaybackRate", { value: 10, writable: true });
const r2 = refreshRuntimeMediaCache();
expect(r2.mediaClips[0].playbackRate).toBe(5);
});
it("adjusts fallback duration by playback rate", () => {
const el = createVideo({ "data-start": "0" });
Object.defineProperty(el, "defaultPlaybackRate", { value: 0.5, writable: true });
Object.defineProperty(el, "duration", { value: 10, writable: true });
const result = refreshRuntimeMediaCache();
// 10s source at 0.5x = 20s on timeline
expect(result.mediaClips[0].duration).toBe(20);
});
it("reads native loop attribute", () => {
createVideo({ "data-start": "0", "data-duration": "15", loop: "" });
const result = refreshRuntimeMediaCache();
expect(result.mediaClips[0].loop).toBe(true);
});
it("defaults loop to false", () => {
createVideo({ "data-start": "0", "data-duration": "5" });
const result = refreshRuntimeMediaCache();
expect(result.mediaClips[0].loop).toBe(false);
});
});
describe("syncRuntimeMedia", () => {
@@ -114,6 +160,9 @@ describe("syncRuntimeMedia", () => {
duration: 10,
end: 10,
volume: null,
playbackRate: 1,
loop: false,
sourceDuration: null,
...overrides,
};
}
@@ -163,9 +212,61 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.currentTime).toBe(original);
});
it("sets playbackRate", () => {
const clip = createMockClip({ start: 0, end: 10 });
it("sets per-element playbackRate × global rate", () => {
const clip = createMockClip({ start: 0, end: 10, playbackRate: 0.5 });
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 2 });
expect(clip.el.playbackRate).toBe(2);
expect(clip.el.playbackRate).toBe(1); // 0.5 × 2 = 1
});
it("computes relTime with per-element playback rate", () => {
const clip = createMockClip({ start: 0, end: 20, playbackRate: 0.5, mediaStart: 0 });
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 10, playing: false, playbackRate: 1 });
// At timeline t=10, with 0.5x rate: relTime = 10 * 0.5 + 0 = 5s into the media
expect(clip.el.currentTime).toBe(5);
});
it("wraps relTime when loop is true and media has ended", () => {
// 3s source at 1x, looped over 10s clip
const clip = createMockClip({
start: 0,
end: 10,
mediaStart: 0,
loop: true,
sourceDuration: 3,
});
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
// At t=7, relTime = 7, wraps to 7 % 3 = 1
syncRuntimeMedia({ clips: [clip], timeSeconds: 7, playing: false, playbackRate: 1 });
expect(clip.el.currentTime).toBe(1);
});
it("wraps loop with mediaStart offset", () => {
// Source is 10s, mediaStart=5, so loop length is 5s (5-10)
const clip = createMockClip({
start: 0,
end: 15,
mediaStart: 5,
loop: true,
sourceDuration: 10,
});
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
// At t=7: relTime = 7*1 + 5 = 12, wraps: 5 + ((12-5) % 5) = 5 + (7%5) = 5+2 = 7
syncRuntimeMedia({ clips: [clip], timeSeconds: 7, playing: false, playbackRate: 1 });
expect(clip.el.currentTime).toBe(7);
});
it("does not loop when loop is false", () => {
const clip = createMockClip({
start: 0,
end: 10,
mediaStart: 0,
loop: false,
sourceDuration: 3,
});
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
// At t=7, relTime = 7 (no wrapping, even though > sourceDuration)
syncRuntimeMedia({ clips: [clip], timeSeconds: 7, playing: false, playbackRate: 1 });
expect(clip.el.currentTime).toBe(7);
});
});
+28 -8
View File
@@ -5,6 +5,10 @@ export type RuntimeMediaClip = {
duration: number;
end: number;
volume: number | null;
playbackRate: number;
loop: boolean;
/** Source media duration in seconds (from el.duration). Used for loop wrapping. */
sourceDuration: number | null;
};
export function refreshRuntimeMediaCache(params?: {
@@ -28,13 +32,18 @@ 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 loop = el.loop;
const sourceDuration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null;
let duration = Number.parseFloat(el.dataset.duration ?? "");
if (
(!Number.isFinite(duration) || duration <= 0) &&
Number.isFinite(el.duration) &&
el.duration > 0
) {
duration = Math.max(0, el.duration - mediaStart);
if ((!Number.isFinite(duration) || duration <= 0) && sourceDuration != null) {
// Effective duration accounts for playback rate:
// at 0.5x, a 10s source plays for 20s on the timeline
duration = Math.max(0, (sourceDuration - mediaStart) / playbackRate);
}
const end =
Number.isFinite(duration) && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
@@ -46,6 +55,9 @@ export function refreshRuntimeMediaCache(params?: {
duration: Number.isFinite(duration) && duration > 0 ? duration : Number.POSITIVE_INFINITY,
end,
volume: Number.isFinite(volumeRaw) ? volumeRaw : null,
playbackRate,
loop,
sourceDuration,
};
mediaClips.push(clip);
if (el.tagName === "VIDEO") videoClips.push(clip);
@@ -63,13 +75,21 @@ export function syncRuntimeMedia(params: {
for (const clip of params.clips) {
const { el } = clip;
if (!el.isConnected) continue;
const relTime = params.timeSeconds - clip.start + clip.mediaStart;
let relTime = (params.timeSeconds - clip.start) * clip.playbackRate + clip.mediaStart;
const isActive =
params.timeSeconds >= clip.start && params.timeSeconds < clip.end && relTime >= 0;
if (isActive) {
// Loop wrapping: when media reaches end, restart from mediaStart
if (clip.loop && clip.sourceDuration != null && clip.sourceDuration > 0) {
const loopLength = clip.sourceDuration - clip.mediaStart;
if (loopLength > 0 && relTime >= clip.sourceDuration) {
relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength);
}
}
if (clip.volume != null) el.volume = clip.volume;
try {
el.playbackRate = params.playbackRate;
// Per-element rate × global transport rate
el.playbackRate = clip.playbackRate * params.playbackRate;
} catch {
// ignore unsupported playbackRate
}