mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
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:
@@ -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) + "..."),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user