mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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