mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(core): unify composition contract (#2157)
* refactor(core): unify composition contract * fix(parsers): parse start expressions linearly
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user