Files
hyperframes/packages/core/src/runtime/timeline.ts
T
Miguel Ángel afafca4b96 feat: make creator media edits render-safe (#3322)
* feat: make creator media edits render-safe

* fix: align media playback timing

* docs: add creator editing recipes

* docs: expand creator editing guidance

* fix: unify media source offsets

* fix: scale natural media duration

* fix: preserve natural media zero spans

* fix: align compiled natural media timing

* test: classify compiler media test as integration

* fix: drop inactive media windows

* fix: unify literal timing parsing

* fix: keep browser media parsing serializable

* fix: keep page timing readers strict

* fix: close remaining preview timing gaps

* fix(core): preserve Studio voice pitch at playback speed

* chore: keep creator contract source-neutral
2026-08-18 10:17:02 -04:00

640 lines
27 KiB
TypeScript

import type {
RuntimeTimelineClip,
RuntimeTimelineMessage,
RuntimeTimelineScene,
RuntimeTimelineLike,
} from "./types";
import { stableClipId } from "./clipTree";
import { swallow } from "./diagnostics";
import { readElementPlaybackRate, readElementPlaybackStart } from "./media";
import { parseStrictFiniteTimingNumber, resolveNaturalMediaTimelineDuration } from "./playbackRate";
import { resolveCssStackingContextId } from "./stackingContext";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { isSceneLikeCompositionId } from "../slideshow/index.js";
import { COMPOSITION_CONTRACT_VERSION } from "../compositionContract.js";
import { runtimeProtocolMetadata } from "./protocol.js";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
function parseNum(value: string | null | undefined): number | null {
return parseStrictFiniteTimingNumber(value);
}
function parseElementDurationAttr(element: Element): number | null {
return (
parseNum(element.getAttribute("data-duration")) ??
parseNum(element.getAttribute(AUTHORED_DURATION_ATTR))
);
}
function parseElementEndAttr(element: Element): number | null {
return (
parseNum(element.getAttribute("data-end")) ?? parseNum(element.getAttribute(AUTHORED_END_ATTR))
);
}
function readInlineZIndex(element: Element): number {
try {
const inline = (element as HTMLElement).style?.zIndex;
if (inline && inline !== "auto") {
const parsed = parseInt(inline, 10);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
} catch {
return 0;
}
}
function maxDefinedNumber(...values: Array<number | null>): number | null {
const finite = values.filter((value): value is number => Number.isFinite(value ?? null));
if (finite.length === 0) return null;
return Math.max(...finite);
}
/**
* Parse an authored track attribute, honoring 0 (a valid top-lane index).
* `parseInt(...) || fallback` silently replaced authored track 0 with the
* synthetic fallback, so track-0 clips drifted to the bottom of the timeline.
*/
function parseAuthoredTrack(el: Element, fallback: number): number {
const raw = el.getAttribute("data-track-index") ?? el.getAttribute("data-track");
if (raw == null) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
const raw = String(rawValue ?? "").trim();
if (!raw) return null;
const lowered = raw.toLowerCase();
if (lowered.startsWith("data:") || lowered.startsWith("javascript:")) return null;
try {
return new URL(raw, document.baseURI).toString();
} catch {
return raw;
}
}
function resolveNodeAssetUrl(node: Element): string | null {
const src = node.getAttribute("src") ?? node.getAttribute("data-src");
if (src) return toAbsoluteAssetUrl(src);
const compositionSrc = node.getAttribute("data-composition-src");
if (compositionSrc) return toAbsoluteAssetUrl(compositionSrc);
const mediaDescendant = node.querySelector("img[src], video[src], audio[src], source[src]");
if (!mediaDescendant) return null;
return toAbsoluteAssetUrl(mediaDescendant.getAttribute("src"));
}
function getFirstClassToken(node: Element): string | null {
const className = (node as HTMLElement).className;
if (typeof className !== "string") return null;
return (
className
.split(/\s+/)
.map((value) => value.trim())
.find((value) => value && value !== "clip" && !value.startsWith("__hf-")) ?? null
);
}
function filenameFromAssetUrl(url: string | null): string | null {
if (!url) return null;
try {
const parsed = new URL(url, document.baseURI);
return parsed.pathname.split("/").filter(Boolean).at(-1) ?? null;
} catch {
return url.split(/[\\/]/).filter(Boolean).at(-1) ?? null;
}
}
function textPreview(node: Element): string | null {
const text = node.textContent?.replace(/\s+/g, " ").trim();
if (!text) return null;
return text.length > 32 ? `${text.slice(0, 31)}...` : text;
}
function humanizeTimelineToken(value: string): string {
const normalized = value
.replace(/\.[^.]+$/i, "")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!normalized) return value;
return normalized.replace(/\b\w/g, (char) => char.toUpperCase());
}
function buildTimelineClipLabel(node: Element, kind: RuntimeTimelineClip["kind"], ordinal: number) {
const explicit =
node.getAttribute("data-timeline-label") ??
node.getAttribute("data-label") ??
node.getAttribute("aria-label") ??
null;
if (explicit?.trim()) return explicit.trim();
const compositionId = node.getAttribute("data-composition-id");
if (compositionId) return humanizeTimelineToken(compositionId);
const id = (node as HTMLElement).id;
if (id) return humanizeTimelineToken(id);
const classToken = getFirstClassToken(node);
if (classToken) return humanizeTimelineToken(classToken);
const assetName = filenameFromAssetUrl(resolveNodeAssetUrl(node));
if (assetName) return humanizeTimelineToken(assetName);
const text = textPreview(node);
if (text) return text;
return `${humanizeTimelineToken(kind)} ${ordinal + 1}`;
}
export function collectRuntimeTimelinePayload(params: {
canonicalFps: number;
}): RuntimeTimelineMessage {
const runtimeWindow = window as Window & {
__timelines?: Record<string, RuntimeTimelineLike | undefined>;
};
const timelineRegistry = runtimeWindow.__timelines ?? {};
const startResolver = createRuntimeStartTimeResolver({
timelineRegistry,
includeAuthoredTimingAttrs: true,
});
const resolveTimelineDurationSeconds = (compositionId: string | null): number | null => {
if (!compositionId) return null;
const timeline = timelineRegistry[compositionId] ?? null;
if (!timeline || typeof timeline.duration !== "function") return null;
try {
const duration = Number(timeline.duration());
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch {
return null;
}
};
const resolveMediaElementDurationSeconds = (
mediaEl: HTMLVideoElement | HTMLAudioElement,
): number | null => {
const declaredDuration = parseNum(mediaEl.getAttribute("data-duration"));
if (declaredDuration != null && declaredDuration > 0) {
return declaredDuration;
}
if (Number.isFinite(mediaEl.duration)) {
return resolveNaturalMediaTimelineDuration(mediaEl, mediaEl.duration);
}
return null;
};
const resolveMediaWindowEndSeconds = (): number | null => {
const mediaNodes = Array.from(
document.querySelectorAll("video[data-start], audio[data-start]"),
) as Array<HTMLVideoElement | HTMLAudioElement>;
if (mediaNodes.length === 0) return null;
let maxWindowEndSeconds = 0;
for (const mediaNode of mediaNodes) {
const start = !mediaNode.hasAttribute("data-hf-auto-start")
? Math.max(0, Number(mediaNode.getAttribute("data-start") ?? 0) || 0)
: startResolver.resolveStartForElement(mediaNode, 0);
if (!Number.isFinite(start)) continue;
const duration = resolveMediaElementDurationSeconds(mediaNode);
if (duration == null || duration <= 0) continue;
maxWindowEndSeconds = Math.max(maxWindowEndSeconds, Math.max(0, start) + duration);
}
return maxWindowEndSeconds > 0 ? maxWindowEndSeconds : null;
};
const resolveNearestCompositionContext = (
node: Element,
root: Element | null,
): {
parentCompositionId: string | null;
compositionAncestors: string[];
inheritedStart: number | null;
inheritedDuration: number | null;
} => {
const ancestors: string[] = [];
let inheritedStart: number | null = null;
let inheritedDuration: number | null = null;
let parentCompositionId: string | null = null;
let cursor = node.parentElement;
while (cursor) {
const compositionId = cursor.getAttribute("data-composition-id");
if (compositionId) {
ancestors.push(compositionId);
if (!parentCompositionId && cursor !== root) {
parentCompositionId = compositionId;
}
if (inheritedStart == null) {
inheritedStart = startResolver.resolveStartForElement(cursor, 0);
}
if (inheritedDuration == null) {
inheritedDuration =
parseNum(cursor.getAttribute("data-duration")) ??
resolveTimelineDurationSeconds(compositionId) ??
null;
}
}
cursor = cursor.parentElement;
}
return {
parentCompositionId,
compositionAncestors: ancestors.reverse(),
inheritedStart,
inheritedDuration,
};
};
const root = document.querySelector("[data-composition-id]") as Element | null;
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]"));
const rootCompositionId = root?.getAttribute("data-composition-id") ?? null;
const rootCompositionStart = root ? startResolver.resolveStartForElement(root, 0) : 0;
const mediaWindowEnd = resolveMediaWindowEndSeconds();
const mediaWindowDuration =
mediaWindowEnd != null ? Math.max(0, mediaWindowEnd - Math.max(0, rootCompositionStart)) : null;
const rootDurationFromTimeline = resolveTimelineDurationSeconds(rootCompositionId);
const rootDurationFromAttr = parseElementDurationAttr(root ?? document.body);
const compositionWindowEnd = maxDefinedNumber(
...compositionNodes
.filter((node) => node !== root)
.map((node) => {
const start = startResolver.resolveStartForElement(node, 0);
const duration =
startResolver.resolveDurationForElement(node) ??
resolveTimelineDurationSeconds(node.getAttribute("data-composition-id")) ??
null;
if (!Number.isFinite(start) || duration == null || duration <= 0) return null;
return Math.max(0, start) + duration;
}),
);
const compositionWindowDuration =
compositionWindowEnd != null
? Math.max(0, compositionWindowEnd - Math.max(0, rootCompositionStart))
: null;
const timelineDurationCandidate =
typeof rootDurationFromTimeline === "number" &&
Number.isFinite(rootDurationFromTimeline) &&
rootDurationFromTimeline > 0
? rootDurationFromTimeline
: null;
const attrDurationCandidate =
typeof rootDurationFromAttr === "number" &&
Number.isFinite(rootDurationFromAttr) &&
rootDurationFromAttr > 0
? rootDurationFromAttr
: null;
const mediaWindowDurationCandidate =
typeof mediaWindowDuration === "number" &&
Number.isFinite(mediaWindowDuration) &&
mediaWindowDuration > 0
? mediaWindowDuration
: null;
const compositionWindowDurationCandidate =
typeof compositionWindowDuration === "number" &&
Number.isFinite(compositionWindowDuration) &&
compositionWindowDuration > 0
? compositionWindowDuration
: null;
const finiteWindowFloor = maxDefinedNumber(
mediaWindowDurationCandidate,
compositionWindowDurationCandidate,
);
const timelineLooksLoopInflated =
timelineDurationCandidate != null &&
finiteWindowFloor != null &&
timelineDurationCandidate > finiteWindowFloor + 1;
// Prefer explicit authored root duration first.
// If absent, guard against loop-inflated GSAP durations by trusting finite media window.
const preferredRootDuration =
attrDurationCandidate ??
(timelineLooksLoopInflated
? finiteWindowFloor
: maxDefinedNumber(
timelineDurationCandidate,
mediaWindowDurationCandidate,
compositionWindowDurationCandidate,
));
const rootCompositionDuration = preferredRootDuration ?? null;
const rootCompositionEnd =
rootCompositionDuration != null ? rootCompositionStart + rootCompositionDuration : null;
const timelineWindowEnd =
rootCompositionEnd ??
(typeof mediaWindowEnd === "number" && Number.isFinite(mediaWindowEnd) && mediaWindowEnd > 0
? mediaWindowEnd
: null);
const clampDurationToRootWindow = (start: number, duration: number): number => {
if (!Number.isFinite(duration) || duration <= 0) return 0;
if (timelineWindowEnd == null || !Number.isFinite(timelineWindowEnd)) return duration;
if (!Number.isFinite(start) || start >= timelineWindowEnd) return 0;
return Math.max(0, Math.min(duration, timelineWindowEnd - start));
};
const clips: RuntimeTimelineClip[] = [];
const scenes: RuntimeTimelineScene[] = [];
// Only collect elements that are explicitly part of the timeline:
// - Elements with data-start or data-track-index (timed clips)
// - Elements with data-composition-id (sub-compositions)
// - Media elements (video, audio, img)
// Elements without data-start (e.g. GSAP-animated scenes) are not included
// as clips — they have no declared timing so the timeline can't show their
// actual visibility window. They can still appear as scenes via the separate
// scene collection below.
const nodes = Array.from(
document.querySelectorAll(
"[data-start], [data-track-index], [data-composition-id], video, audio, img",
),
);
let maxEnd = 0;
for (const [i, node] of nodes.entries()) {
if (node === root) continue;
if (["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"].includes(node.tagName))
continue;
const compositionContext = resolveNearestCompositionContext(node, root);
const start = startResolver.resolveStartForElement(
node,
compositionContext.inheritedStart ?? 0,
);
const nodeCompositionId = node.getAttribute("data-composition-id");
let duration = parseElementDurationAttr(node);
if (duration == null && nodeCompositionId && nodeCompositionId !== rootCompositionId) {
duration = resolveTimelineDurationSeconds(nodeCompositionId);
}
if (duration == null && node instanceof HTMLMediaElement) {
if (Number.isFinite(node.duration)) {
duration = resolveNaturalMediaTimelineDuration(node, node.duration);
}
}
if (duration == null) {
const inheritedDuration = compositionContext.inheritedDuration;
if (inheritedDuration != null && inheritedDuration > 0) {
const inheritedStart = compositionContext.inheritedStart ?? 0;
const inheritedEnd = inheritedStart + inheritedDuration;
duration = Math.max(0, inheritedEnd - start);
}
}
if (duration == null || duration <= 0) continue;
duration = clampDurationToRootWindow(start, duration);
if (duration <= 0) continue;
const end = start + duration;
maxEnd = Math.max(maxEnd, end);
const tag = node.tagName.toLowerCase();
const kind: RuntimeTimelineClip["kind"] =
nodeCompositionId && nodeCompositionId !== rootCompositionId
? "composition"
: tag === "video"
? "video"
: tag === "audio"
? "audio"
: tag === "img"
? "image"
: "element";
clips.push({
id: stableClipId(node) ?? nodeCompositionId ?? null,
label: buildTimelineClipLabel(node, kind, clips.length),
start,
duration,
track: parseAuthoredTrack(node, i),
zIndex: readInlineZIndex(node),
stackingContextId: resolveCssStackingContextId(node),
kind,
tagName: tag,
compositionId: node.getAttribute("data-composition-id"),
compositionAncestors: compositionContext.compositionAncestors,
parentCompositionId: compositionContext.parentCompositionId,
nodePath: null,
compositionSrc: toAbsoluteAssetUrl(node.getAttribute("data-composition-src")),
playbackStart: readElementPlaybackStart(node),
playbackRate: readElementPlaybackRate(node),
assetUrl: resolveNodeAssetUrl(node),
timelineRole: node.getAttribute("data-timeline-role"),
timelineLabel: node.getAttribute("data-timeline-label"),
timelineGroup: node.getAttribute("data-timeline-group"),
timelinePriority: parseNum(node.getAttribute("data-timeline-priority")),
});
}
// ── GSAP introspection ──────────────────────────────────────────────────
// Discover elements animated by GSAP that weren't picked up by the DOM query
// (e.g. scene divs controlled purely via opacity/display tweens).
// Introspect the master timeline's tweens to find their targets and time ranges.
// ── GSAP introspection ──────────────────────────────────────────────────
// Discover scene-level elements animated by GSAP that weren't picked up by
// the DOM query. Introspect the master timeline's tweens, resolve absolute
// time ranges, and bubble child tween ranges up to their nearest scene-level
// ancestor (direct child of root with an id).
const gsapClipIds = new Set(clips.map((c) => c.id));
const rootCompositionIdForGsap = root?.getAttribute("data-composition-id") ?? null;
const masterTimeline = rootCompositionIdForGsap
? (timelineRegistry[rootCompositionIdForGsap] ?? null)
: null;
if (masterTimeline && root) {
type GsapTween = {
targets?: () => Element[];
startTime?: () => number;
duration?: () => number;
parent?: GsapTween;
};
const tlWithChildren = masterTimeline as typeof masterTimeline & {
getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => GsapTween[];
};
if (typeof tlWithChildren.getChildren === "function") {
try {
const tweens = tlWithChildren.getChildren(true, true, false) ?? [];
// Build a set of direct children of root that have an id — these are
// scene-level containers. Tween ranges on their descendants get bubbled
// up to expand the scene's time range.
const sceneElements = new Map<Element, { id: string; start: number; end: number }>();
for (const child of root.children) {
const childEl = child as HTMLElement;
if (!childEl.id) continue;
const tag = childEl.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link") continue;
sceneElements.set(childEl, { id: childEl.id, start: Infinity, end: -Infinity });
}
// Find the scene-level ancestor for a given element
const findSceneAncestor = (el: Element): Element | null => {
let cursor: Element | null = el;
while (cursor) {
if (sceneElements.has(cursor)) return cursor;
if (cursor === root) return null;
cursor = cursor.parentElement;
}
return null;
};
// Walk all tweens and accumulate time ranges per scene element
for (const tween of tweens) {
if (typeof tween.targets !== "function") continue;
if (typeof tween.startTime !== "function" || typeof tween.duration !== "function")
continue;
let tweenStart = tween.startTime();
let parent = tween.parent;
while (parent && parent !== masterTimeline && typeof parent.startTime === "function") {
tweenStart += parent.startTime();
parent = parent.parent;
}
const tweenEnd = tweenStart + tween.duration();
if (!Number.isFinite(tweenStart) || !Number.isFinite(tweenEnd)) continue;
for (const target of tween.targets()) {
if (!(target instanceof Element)) continue;
// Bubble up to the scene-level ancestor
const scene = findSceneAncestor(target);
if (!scene) continue;
const range = sceneElements.get(scene);
if (!range) continue;
range.start = Math.min(range.start, tweenStart);
range.end = Math.max(range.end, tweenEnd);
}
}
// Create clips for scene elements that have tween ranges
const gsapTrack = clips.length > 0 ? Math.max(...clips.map((c) => c.track)) + 1 : 0;
for (const [element, range] of sceneElements) {
if (range.start === Infinity || range.end === -Infinity) continue;
const el = element as HTMLElement;
if (gsapClipIds.has(el.id)) continue;
const duration = Math.max(0, range.end - range.start);
if (duration <= 0) continue;
const clampedDuration = clampDurationToRootWindow(range.start, duration);
if (clampedDuration <= 0) continue;
maxEnd = Math.max(maxEnd, range.start + clampedDuration);
clips.push({
id: el.id,
label:
el.getAttribute("data-timeline-label") ??
el.getAttribute("data-label") ??
el.getAttribute("aria-label") ??
el.id,
start: range.start,
duration: clampedDuration,
track: parseAuthoredTrack(el, gsapTrack),
zIndex: readInlineZIndex(el),
stackingContextId: resolveCssStackingContextId(el),
kind: "element",
tagName: el.tagName.toLowerCase(),
compositionId: el.getAttribute("data-composition-id"),
compositionAncestors: rootCompositionIdForGsap ? [rootCompositionIdForGsap] : [],
parentCompositionId: rootCompositionIdForGsap,
nodePath: null,
compositionSrc: null,
playbackStart: readElementPlaybackStart(el),
playbackRate: readElementPlaybackRate(el),
assetUrl: null,
timelineRole: el.getAttribute("data-timeline-role"),
timelineLabel: el.getAttribute("data-timeline-label"),
timelineGroup: el.getAttribute("data-timeline-group"),
timelinePriority: parseNum(el.getAttribute("data-timeline-priority")),
});
gsapClipIds.add(el.id);
}
} catch (err) {
// GSAP introspection is best-effort — don't break timeline if it fails
swallow("runtime.timeline.site1", err);
}
}
}
// ── Persistent overlays ─────────────────────────────────────────────────
// Direct children of root that are pure structural overlays should only
// surface in the timeline when authors explicitly opt them in. Otherwise
// background layers like "backdrop" make the whole composition read as a
// long clip, which is misleading in Studio.
if (root && rootCompositionDuration != null && rootCompositionDuration > 0) {
const overlayTrack = clips.length > 0 ? Math.max(...clips.map((c) => c.track)) + 1 : 0;
for (const child of root.children) {
const el = child as HTMLElement;
if (!el.id) continue;
if (gsapClipIds.has(el.id)) continue;
const timelineRole = el.getAttribute("data-timeline-role");
if (timelineRole !== "overlay" && timelineRole !== "persistent-overlay") continue;
const tag = el.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
// Skip elements that are invisible (display:none in their CSS class)
const computed = window.getComputedStyle(el);
if (computed.display === "none") continue;
const clampedDuration = clampDurationToRootWindow(0, rootCompositionDuration);
if (clampedDuration <= 0) continue;
maxEnd = Math.max(maxEnd, clampedDuration);
clips.push({
id: el.id,
label:
el.getAttribute("data-timeline-label") ??
el.getAttribute("data-label") ??
el.getAttribute("aria-label") ??
el.id,
start: 0,
duration: clampedDuration,
track: parseAuthoredTrack(el, overlayTrack),
zIndex: readInlineZIndex(el),
stackingContextId: resolveCssStackingContextId(el),
kind: "element",
tagName: tag,
compositionId: el.getAttribute("data-composition-id"),
compositionAncestors: rootCompositionIdForGsap ? [rootCompositionIdForGsap] : [],
parentCompositionId: rootCompositionIdForGsap,
nodePath: null,
compositionSrc: null,
playbackStart: readElementPlaybackStart(el),
playbackRate: readElementPlaybackRate(el),
assetUrl: null,
timelineRole,
timelineLabel: el.getAttribute("data-timeline-label"),
timelineGroup: el.getAttribute("data-timeline-group"),
timelinePriority: parseNum(el.getAttribute("data-timeline-priority")),
});
gsapClipIds.add(el.id);
}
}
// Track assignment honors the authored data-track-index verbatim: a clip stays
// on the track it was placed on, regardless of kind. (Previously mixed-kind
// tracks were split onto separate rows, but that renumbered tracks — breaking
// "drop a clip onto an existing track" and causing the written track to drift
// from the displayed one on every move. Track index is display-only; render
// never reads it, so honoring it verbatim is the correct NLE behavior.)
for (const compositionNode of compositionNodes) {
if (compositionNode === root) continue;
const compositionId = compositionNode.getAttribute("data-composition-id");
if (!compositionId || !isSceneLikeCompositionId(compositionId)) continue;
const start = startResolver.resolveStartForElement(compositionNode, 0);
let durationFromAttr = parseElementDurationAttr(compositionNode);
if (
(durationFromAttr == null || durationFromAttr <= 0) &&
parseElementEndAttr(compositionNode) != null
) {
const end = parseElementEndAttr(compositionNode)!;
durationFromAttr = Math.max(0, end - start);
}
const durationFromTimeline = resolveTimelineDurationSeconds(compositionId);
const duration =
durationFromAttr && durationFromAttr > 0 ? durationFromAttr : durationFromTimeline;
if (duration == null || duration <= 0) continue;
const clampedDuration = clampDurationToRootWindow(start, duration);
if (clampedDuration <= 0) continue;
scenes.push({
id: compositionId,
label: compositionNode.getAttribute("data-label") ?? compositionId,
start,
duration: clampedDuration,
thumbnailUrl: toAbsoluteAssetUrl(compositionNode.getAttribute("data-thumbnail-url")),
avatarName: null,
});
}
// Timeline payload duration should reflect the playable composition window,
// not just the furthest currently-surfaced clip. Studio can intentionally
// hide structural/background tracks from the timeline UI; if we collapse the
// payload duration down to the last visible clip end, the controls jump even
// though playback still runs for the full authored root duration.
const safeDuration = Math.max(1, maxEnd || 1, rootCompositionDuration ?? 0);
const shouldEmitNonDeterministicInf = timelineLooksLoopInflated && attrDurationCandidate == null;
const durationInFrames = shouldEmitNonDeterministicInf
? Number.POSITIVE_INFINITY
: Math.max(1, Math.ceil(safeDuration * Math.max(1, params.canonicalFps)));
return {
...runtimeProtocolMetadata(params.canonicalFps),
source: "hf-preview",
type: "timeline",
compositionContractVersion: COMPOSITION_CONTRACT_VERSION,
durationSeconds: shouldEmitNonDeterministicInf ? Number.POSITIVE_INFINITY : safeDuration,
durationInFrames,
clips,
scenes,
compositionWidth: parseNum(root?.getAttribute("data-width")) ?? 1920,
compositionHeight: parseNum(root?.getAttribute("data-height")) ?? 1080,
};
}