refactor(core): unify composition contract (#2157)

* refactor(core): unify composition contract

* fix(parsers): parse start expressions linearly
This commit is contained in:
James Russo
2026-07-16 02:44:22 -04:00
committed by GitHub
parent 9ed255c0ef
commit 21cb722ebd
36 changed files with 1148 additions and 441 deletions
@@ -50,9 +50,16 @@ export interface ClipManifestClip {
}
export interface ClipManifest {
protocolVersion?: number;
compositionContractVersion?: number;
capabilities?: readonly string[];
fps?: { numerator: number; denominator: number };
durationSeconds?: number;
clips: ClipManifestClip[];
scenes: Array<{ id: string; label: string; start: number; duration: number }>;
durationInFrames: number;
compositionWidth?: number;
compositionHeight?: number;
}
export type IframeWindow = Window & {
@@ -14,7 +14,12 @@ describe("Studio runtime protocol", () => {
type: "control",
action: "seek",
protocolVersion: 1,
capabilities: ["seconds-time", "rational-fps", "seek-keep-playing"],
capabilities: [
"seconds-time",
"rational-fps",
"seek-keep-playing",
"composition-manifest-v1",
],
fps: { numerator: 60, denominator: 1 },
timeSeconds: 1.25,
});
+8 -11
View File
@@ -11,6 +11,7 @@
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "./playbackTypes";
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
import { readClipTiming } from "@hyperframes/core/composition-contract";
import {
resolveMediaElement,
applyMediaMetadataFromElement,
@@ -255,24 +256,20 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
if (node === rootComp) return;
if (isTimelineIgnoredElement(node)) return;
const el = node as HTMLElement;
const startStr = el.getAttribute("data-start");
if (startStr == null) return;
const start = parseFloat(startStr);
if (isNaN(start)) return;
const timing = readClipTiming(el);
const start = timing.start;
if (start == null) return;
if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
const tagLower = el.tagName.toLowerCase();
let dur = 0;
const durStr = el.getAttribute("data-duration");
if (durStr != null) dur = parseFloat(durStr);
if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
let dur = timing.duration ?? 0;
if (dur <= 0) dur = Math.max(0, rootDuration - start);
if (Number.isFinite(rootDuration) && rootDuration > 0) {
dur = Math.min(dur, Math.max(0, rootDuration - start));
}
if (!Number.isFinite(dur) || dur <= 0) return;
const trackStr = el.getAttribute("data-track-index");
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
const track = timing.trackSource === "default" ? trackCounter++ : timing.trackIndex;
// fallow-ignore-next-line code-duplication
const compId = el.getAttribute("data-composition-id");
const selector = getTimelineElementSelector(el);
@@ -299,7 +296,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
tag: tagLower,
start,
duration: dur,
track: isNaN(track) ? 0 : track,
track,
domId: el.id || undefined,
hfId: el.getAttribute("data-hf-id") || undefined,
selector,
@@ -12,6 +12,7 @@
import type { TimelineElement } from "../store/playerStore";
import type { IframeWindow } from "./playbackTypes";
import { readClipTiming } from "@hyperframes/core/composition-contract";
import {
getTimelineElementSelector,
getTimelineElementSourceFile,
@@ -38,6 +39,9 @@ export function normalizePreviewViewport(doc: Document, win: Window): void {
win.scrollTo({ top: 0, left: 0, behavior: "auto" });
}
// Legacy recovery retained until versioned composition manifests complete
// their compatibility soak across published CDN runtimes.
// fallow-ignore-next-line complexity
export function autoHealMissingCompositionIds(doc: Document): void {
const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
const referencedIds = new Set<string>();
@@ -258,6 +262,141 @@ export function stopScrubPreviewAudio(): void {
// Enrich missing compositions from DOM
// ---------------------------------------------------------------------------
function timelineDuration(iframeWin: IframeWindow, compositionId: string): number {
return (
(
iframeWin.__timelines?.[compositionId] as { duration?: () => number } | undefined
)?.duration?.() ?? 0
);
}
function createTimedElementLookup(doc: Document): Map<string, Element> {
const timedById = new Map<string, Element>();
for (const timed of doc.querySelectorAll("[data-start]")) {
for (const id of [
timed.id,
timed.getAttribute("data-hf-id"),
timed.getAttribute("data-composition-id"),
]) {
if (id) timedById.set(id, timed);
}
}
return timedById;
}
function createReferenceEndResolver(
timedById: ReadonlyMap<string, Element>,
iframeWin: IframeWindow,
): (refId: string, visiting: ReadonlySet<string>) => number | null {
const resolveEnd = (refId: string, visiting: ReadonlySet<string>): number | null => {
if (visiting.has(refId)) return null;
const referenced = timedById.get(refId);
if (!referenced) return null;
const next = new Set(visiting).add(refId);
const timing = readClipTiming(referenced, {
resolveReferenceEnd: (nestedId) => resolveEnd(nestedId, next),
});
if (timing.end != null) return timing.end;
const compositionId = referenced.getAttribute("data-composition-id");
const duration = compositionId ? timelineDuration(iframeWin, compositionId) : 0;
return timing.start == null || duration <= 0 ? null : timing.start + duration;
};
return resolveEnd;
}
function clampCompositionWindow(
start: number,
duration: number,
rootDuration: number,
): { start: number; duration: number } | null {
if (!Number.isFinite(duration) || duration <= 0) return null;
const safeStart = Number.isFinite(start) ? start : 0;
if (!Number.isFinite(rootDuration) || rootDuration <= 0) {
return { start: safeStart, duration };
}
if (safeStart >= rootDuration) return null;
const clamped = Math.min(duration, Math.max(0, rootDuration - safeStart));
return clamped > 0 ? { start: safeStart, duration: clamped } : null;
}
function nonEmpty(value: string, fallback: string): string {
return value || fallback;
}
function optionalNonEmpty(value: string | null): string | undefined {
return value || undefined;
}
function attachCompositionSource(
entry: TimelineElement,
element: HTMLElement,
compositionSrc: string | null,
): TimelineElement {
if (compositionSrc) return { ...entry, compositionSrc };
const innerVideo = element.querySelector("video[src]");
if (!innerVideo) return entry;
return { ...entry, src: optionalNonEmpty(innerVideo.getAttribute("src")), tag: "video" };
}
function buildMissingCompositionEntry(params: {
doc: Document;
iframeWin: IframeWindow;
element: HTMLElement;
compositionId: string;
rootDuration: number;
fallbackIndex: number;
resolveEnd: (refId: string, visiting: ReadonlySet<string>) => number | null;
}): TimelineElement | null {
const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex, resolveEnd } =
params;
const timing = readClipTiming(element, {
resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])),
});
const window = clampCompositionWindow(
timing.start ?? 0,
timing.duration ?? timelineDuration(iframeWin, compositionId),
rootDuration,
);
if (!window) return null;
const preferredId = nonEmpty(element.id, compositionId);
const compositionSrc =
element.getAttribute("data-composition-src") ?? element.getAttribute("data-composition-file");
const selector = getTimelineElementSelector(element);
const sourceFile = getTimelineElementSourceFile(element);
const selectorIndex = getTimelineElementSelectorIndex(doc, element, selector);
const label = getTimelineElementDisplayLabel({
id: preferredId,
label: element.getAttribute("data-timeline-label") ?? element.getAttribute("data-label"),
tag: element.tagName,
});
const identity = buildTimelineElementIdentity({
preferredId,
label,
fallbackIndex,
domId: optionalNonEmpty(element.id),
selector,
selectorIndex,
sourceFile,
});
const entry: TimelineElement = {
id: identity.id,
label,
key: identity.key,
tag: element.tagName.toLowerCase(),
start: window.start,
duration: window.duration,
track: timing.trackIndex,
domId: optionalNonEmpty(element.id),
hfId: optionalNonEmpty(element.getAttribute("data-hf-id")),
selector,
selectorIndex,
sourceFile,
zIndex: readTimelineElementZIndex(element),
};
return attachCompositionSource(entry, element, compositionSrc);
}
/**
* Scan the iframe DOM for composition hosts missing from the current
* timeline elements and add them. The CDN runtime often fails to resolve
@@ -279,117 +418,24 @@ export function buildMissingCompositionElements(
const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
const missing: TimelineElement[] = [];
hosts.forEach((host) => {
const resolveEnd = createReferenceEndResolver(createTimedElementLookup(doc), iframeWin);
for (const host of hosts) {
const el = host as HTMLElement;
const compId = el.getAttribute("data-composition-id");
if (!compId || compId === rootCompId) return;
if (existingIds.has(el.id) || existingIds.has(compId)) return;
// Resolve start: numeric or element-reference
const startAttr = el.getAttribute("data-start") ?? "0";
let start = parseFloat(startAttr);
if (isNaN(start)) {
const ref =
doc.getElementById(startAttr) ||
doc.querySelector(`[data-composition-id="${CSS.escape(startAttr)}"]`);
if (ref) {
const refStartAttr = ref.getAttribute("data-start") ?? "0";
let refStart = parseFloat(refStartAttr);
// Recursively resolve one level of reference for the ref's own start
if (isNaN(refStart)) {
const refRef =
doc.getElementById(refStartAttr) ||
doc.querySelector(`[data-composition-id="${CSS.escape(refStartAttr)}"]`);
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
const rrCompId = refRef?.getAttribute("data-composition-id");
const rrDur =
parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
(rrCompId
? ((
iframeWin.__timelines?.[rrCompId] as { duration?: () => number } | undefined
)?.duration?.() ?? 0)
: 0);
refStart = rrStart + rrDur;
}
const refCompId = ref.getAttribute("data-composition-id");
const refDur =
parseFloat(ref.getAttribute("data-duration") ?? "") ||
(refCompId
? ((
iframeWin.__timelines?.[refCompId] as { duration?: () => number } | undefined
)?.duration?.() ?? 0)
: 0);
start = refStart + refDur;
} else {
start = 0;
}
}
// Resolve duration from data-duration or GSAP timeline
let dur = parseFloat(el.getAttribute("data-duration") ?? "");
if (isNaN(dur) || dur <= 0) {
dur =
(
iframeWin.__timelines?.[compId] as { duration?: () => number } | undefined
)?.duration?.() ?? 0;
}
if (!Number.isFinite(dur) || dur <= 0) return;
if (!Number.isFinite(start)) start = 0;
if (Number.isFinite(rootDuration) && rootDuration > 0) {
if (start >= rootDuration) return;
dur = Math.min(dur, Math.max(0, rootDuration - start));
if (dur <= 0) return;
}
const trackStr = el.getAttribute("data-track-index");
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
// fallow-ignore-next-line code-duplication
const compSrc =
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
const selector = getTimelineElementSelector(el);
const sourceFile = getTimelineElementSourceFile(el);
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
const label = getTimelineElementDisplayLabel({
id: el.id || compId || null,
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
tag: el.tagName,
});
const identity = buildTimelineElementIdentity({
preferredId: el.id || compId || null,
label,
if (!compId || compId === rootCompId) continue;
if (existingIds.has(el.id) || existingIds.has(compId)) continue;
const entry = buildMissingCompositionEntry({
doc,
iframeWin,
element: el,
compositionId: compId,
rootDuration,
fallbackIndex: missing.length,
domId: el.id || undefined,
selector,
selectorIndex,
sourceFile,
resolveEnd,
});
const entry: TimelineElement = {
id: identity.id,
label,
key: identity.key,
tag: el.tagName.toLowerCase(),
start,
duration: dur,
track: isNaN(track) ? 0 : track,
domId: el.id || undefined,
hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex,
sourceFile,
zIndex: readTimelineElementZIndex(el),
};
if (compSrc) {
entry.compositionSrc = compSrc;
} else {
// Inline composition — expose inner video for thumbnails
const innerVideo = el.querySelector("video[src]");
if (innerVideo) {
entry.src = innerVideo.getAttribute("src") || undefined;
entry.tag = "video";
}
}
missing.push(entry);
});
if (entry) missing.push(entry);
}
// Patch existing elements that are missing compositionSrc
let patched = false;