feat(core): add support for playback-rate and loop for media elements (#41)

## Adds playback rate and loop support for media elements

Introduces per-element playback rate control and looping functionality for `<video>` and `<audio>` elements in the runtime media system.

### Playback Rate Control
- Reads `defaultPlaybackRate` property from media elements (set via JavaScript)
- Clamped to [0.1, 5] range for safety
- Multiplied with global transport rate during playback
- Adjusts timeline duration calculation: 10s source at 0.5x rate = 20s on timeline
- Defaults to 1.0 when not specified

### Loop Functionality  
- Reads native `loop` attribute from media elements
- When enabled, wraps `relTime` using modulo of source duration
- Restarts from `mediaStart` offset when source reaches end
- Works correctly with partial media clips (respects `data-media-start`)
- Defaults to false when not specified

### Implementation Details
- Extends `RuntimeMediaClip` type with `playbackRate`, `loop`, and `sourceDuration` fields
- Updates `refreshRuntimeMediaCache` to parse new properties from DOM elements
- Modifies `syncRuntimeMedia` to apply per-element rates and handle loop wrapping
- Maintains backward compatibility with existing media clips

### Test Coverage
Adds 8 new unit tests covering:
- Playback rate parsing from DOM elements
- Rate clamping to valid ranges  
- Duration adjustment calculations
- Combined per-element and global rate application
- Loop wrapping with and without `mediaStart` offsets
- Non-looping behavior verification
This commit is contained in:
Miguel Ángel
2026-03-24 23:24:51 +01:00
committed by GitHub
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
}