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
+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
}