mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
* 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
640 lines
27 KiB
TypeScript
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,
|
|
};
|
|
}
|