mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(ci): scope LOC check to studio, split useTimelinePlayer + hyperframes-player under 500 LOC (#750)
* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each) * fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files * feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux: - Detects the platform automatically - Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM) - Falls back to clear manual instructions with exact commands - 'hyperframes browser ensure' guides through the setup interactively - After setup, all render commands work without any flags * fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds Path exclusions are insufficient — Defender re-scans new files created during bun install before the exclusion takes effect. Disable real-time monitoring for the entire job duration instead (standard CI practice). * refactor(studio): split all files >500 LOC + extract useToast, delete allowlist All 11 large files split into focused modules under 500 LOC. App.tsx extracted toast logic into useToast hook (493 LOC now). .filesize-allowlist deleted — no longer needed. * fix: remove unused imports from split files, extract useToast from App.tsx App.tsx: 504 → 493 lines (toast logic extracted to useToast hook) timelineDOM.ts: remove unused imports from re-export pattern MotionPanel.tsx: remove unused clampStudioCustomEasePoints import studioMotionOps.ts: remove unused StudioGsapMotionDirection import * fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs) * fix(producer): use node --experimental-strip-types instead of tsx for build:fonts Eliminates the tsx binary dependency that Windows Defender locks during bun install, causing EPERM errors. Node 22.6+ strips TypeScript types natively with no external binary. * chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500) * fix(ci): disable Windows Defender before checkout to prevent all EPERM races * fix(producer): skip build:fonts if fontData.generated.ts already exists The generated file is tracked in git, so CI doesn't need to regenerate it. This avoids @fontsource/inter node_modules access on Windows which triggers EPERM from Defender scanning during bun install.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Playback adapter utilities: factory for the static-seek adapter used when a
|
||||
* composition exposes only a `renderSeek` / `seek` API (no native play/pause
|
||||
* support), plus a thin wrapper that normalises GSAP-style `TimelineLike`
|
||||
* objects to the `PlaybackAdapter` interface.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PlaybackAdapter,
|
||||
RuntimePlaybackAdapter,
|
||||
StaticSeekPlaybackClock,
|
||||
TimelineLike,
|
||||
} from "./playbackTypes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure numeric helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isFinitePositive(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
export function clampTime(time: number, duration: number): number {
|
||||
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
|
||||
const safeTime = Math.max(0, Number.isFinite(time) ? time : 0);
|
||||
return safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
|
||||
}
|
||||
|
||||
export function getAdapterDuration(adapter: PlaybackAdapter | null | undefined): number {
|
||||
if (!adapter) return 0;
|
||||
try {
|
||||
const duration = Number(adapter.getDuration());
|
||||
return isFinitePositive(duration) ? duration : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getDefaultStaticSeekPlaybackClock(win: Window): StaticSeekPlaybackClock {
|
||||
return {
|
||||
now: () => win.performance.now(),
|
||||
requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
|
||||
cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static-seek adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wraps a render-only player (exposes `renderSeek`/`seek` but no native
|
||||
* play/pause) and drives playback via `requestAnimationFrame`.
|
||||
*/
|
||||
export function createStaticSeekPlaybackAdapter(
|
||||
player: Pick<RuntimePlaybackAdapter, "getTime"> &
|
||||
Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>,
|
||||
duration: number,
|
||||
clock: StaticSeekPlaybackClock,
|
||||
getPlaybackRate: () => number = () => 1,
|
||||
): PlaybackAdapter {
|
||||
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
|
||||
let currentTime = clampTime(Number(player.getTime?.() ?? 0), safeDuration);
|
||||
let playing = false;
|
||||
let rafId = 0;
|
||||
let playStartTime = currentTime;
|
||||
let playStartNow = clock.now();
|
||||
|
||||
const renderSeek = (time: number) => {
|
||||
currentTime = clampTime(time, safeDuration);
|
||||
if (typeof player.renderSeek === "function") {
|
||||
player.renderSeek(currentTime);
|
||||
return;
|
||||
}
|
||||
player.seek?.(currentTime);
|
||||
};
|
||||
|
||||
const stopTicker = () => {
|
||||
if (rafId) {
|
||||
clock.cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const tick: FrameRequestCallback = (now) => {
|
||||
if (!playing) return;
|
||||
const playbackRate = Math.max(0.1, Number(getPlaybackRate()) || 1);
|
||||
const elapsed = ((now - playStartNow) / 1000) * playbackRate;
|
||||
renderSeek(playStartTime + elapsed);
|
||||
if (currentTime >= safeDuration) {
|
||||
playing = false;
|
||||
rafId = 0;
|
||||
return;
|
||||
}
|
||||
rafId = clock.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
return {
|
||||
play: () => {
|
||||
if (playing || safeDuration <= 0) return;
|
||||
if (currentTime >= safeDuration) renderSeek(0);
|
||||
playing = true;
|
||||
playStartTime = currentTime;
|
||||
playStartNow = clock.now();
|
||||
stopTicker();
|
||||
rafId = clock.requestAnimationFrame(tick);
|
||||
},
|
||||
pause: () => {
|
||||
playing = false;
|
||||
stopTicker();
|
||||
},
|
||||
seek: (time) => {
|
||||
renderSeek(time);
|
||||
if (playing) {
|
||||
playStartTime = currentTime;
|
||||
playStartNow = clock.now();
|
||||
}
|
||||
},
|
||||
getTime: () => currentTime,
|
||||
getDuration: () => safeDuration,
|
||||
isPlaying: () => playing,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GSAP timeline wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
||||
return {
|
||||
play: () => tl.play(),
|
||||
pause: () => tl.pause(),
|
||||
seek: (t) => {
|
||||
tl.pause();
|
||||
tl.seek(t);
|
||||
},
|
||||
getTime: () => tl.time(),
|
||||
getDuration: () => tl.duration(),
|
||||
isPlaying: () => tl.isActive(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Keyboard shortcut filtering logic for playback controls.
|
||||
*
|
||||
* Determines whether a keydown event should be handled as a playback shortcut
|
||||
* or ignored (e.g. when focus is in an input field, or when caption edit mode
|
||||
* is active and the user is navigating caption segments).
|
||||
*/
|
||||
|
||||
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
||||
|
||||
const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"button",
|
||||
"a[href]",
|
||||
"[contenteditable='true']",
|
||||
"[role='button']",
|
||||
"[role='checkbox']",
|
||||
"[role='combobox']",
|
||||
"[role='menuitem']",
|
||||
"[role='radio']",
|
||||
"[role='slider']",
|
||||
"[role='spinbutton']",
|
||||
"[role='switch']",
|
||||
"[role='textbox']",
|
||||
].join(",");
|
||||
|
||||
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof target !== "object") return false;
|
||||
const candidate = target as { closest?: unknown };
|
||||
if (typeof candidate.closest !== "function") return false;
|
||||
return (
|
||||
(candidate.closest as (selector: string) => Element | null).call(
|
||||
target,
|
||||
PLAYBACK_SHORTCUT_IGNORED_SELECTOR,
|
||||
) !== null
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaybackShortcutCaptionState {
|
||||
isCaptionEditMode: boolean;
|
||||
selectedCaptionSegmentCount: number;
|
||||
}
|
||||
|
||||
type PlaybackShortcutEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"altKey" | "ctrlKey" | "metaKey" | "code" | "target"
|
||||
>;
|
||||
|
||||
export function shouldIgnorePlaybackShortcutEvent(
|
||||
event: PlaybackShortcutEvent,
|
||||
captionState: PlaybackShortcutCaptionState = {
|
||||
isCaptionEditMode: false,
|
||||
selectedCaptionSegmentCount: 0,
|
||||
},
|
||||
): boolean {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return true;
|
||||
if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
|
||||
return (
|
||||
PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
|
||||
captionState.isCaptionEditMode &&
|
||||
captionState.selectedCaptionSegmentCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** JKL shuttle speeds (×1, ×2, ×4). */
|
||||
export const SHUTTLE_SPEEDS = [1, 2, 4] as const;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared type definitions for the timeline playback subsystem.
|
||||
* Kept in a separate module so adapter, DOM, and hook modules can all import
|
||||
* from here without creating circular dependencies.
|
||||
*/
|
||||
|
||||
export interface PlaybackAdapter {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
getTime: () => number;
|
||||
getDuration: () => number;
|
||||
isPlaying: () => boolean;
|
||||
}
|
||||
|
||||
export type RuntimePlaybackAdapter = PlaybackAdapter & {
|
||||
renderSeek?: (time: number) => void;
|
||||
};
|
||||
|
||||
export interface StaticSeekPlaybackClock {
|
||||
now: () => number;
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
||||
cancelAnimationFrame: (handle: number) => void;
|
||||
}
|
||||
|
||||
export interface TimelineLike {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
time: () => number;
|
||||
duration: () => number;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
|
||||
export interface ClipManifestClip {
|
||||
id: string | null;
|
||||
label: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
kind: "video" | "audio" | "image" | "element" | "composition";
|
||||
tagName: string | null;
|
||||
compositionId: string | null;
|
||||
parentCompositionId: string | null;
|
||||
compositionSrc: string | null;
|
||||
assetUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ClipManifest {
|
||||
clips: ClipManifestClip[];
|
||||
scenes: Array<{ id: string; label: string; start: number; duration: number }>;
|
||||
durationInFrames: number;
|
||||
}
|
||||
|
||||
export type IframeWindow = Window & {
|
||||
__player?: RuntimePlaybackAdapter;
|
||||
__timeline?: TimelineLike;
|
||||
__timelines?: Record<string, TimelineLike>;
|
||||
__clipManifest?: ClipManifest;
|
||||
};
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Higher-level timeline DOM operations: element factories, DOM-to-element
|
||||
* parsing, timeline merging, and standalone composition helpers.
|
||||
*
|
||||
* Preview iframe utilities (normaliseViewport, autoHeal, unmute, resolveIframe,
|
||||
* buildMissingCompositionElements) live in timelineIframeHelpers.ts.
|
||||
*
|
||||
* Pure functions (no React, no store reads) — testable in isolation.
|
||||
*/
|
||||
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "./playbackTypes";
|
||||
import {
|
||||
resolveMediaElement,
|
||||
applyMediaMetadataFromElement,
|
||||
getTimelineElementDisplayLabel,
|
||||
getImplicitTimelineLayerLabel,
|
||||
isImplicitTimelineLayerCandidate,
|
||||
getTimelineElementSelector,
|
||||
getTimelineElementSourceFile,
|
||||
getTimelineElementSelectorIndex,
|
||||
buildTimelineElementKey,
|
||||
buildTimelineElementIdentity,
|
||||
getTimelineElementIdentity,
|
||||
} from "./timelineElementHelpers";
|
||||
|
||||
// Re-export helpers that were previously public from this module so that
|
||||
// existing import sites (hook + tests) don't need to change.
|
||||
export {
|
||||
readTimelineDurationFromDocument,
|
||||
resolveMediaElement,
|
||||
applyMediaMetadataFromElement,
|
||||
getTimelineElementSelector,
|
||||
getTimelineElementSourceFile,
|
||||
getTimelineElementSelectorIndex,
|
||||
buildTimelineElementIdentity,
|
||||
getTimelineElementIdentity,
|
||||
findTimelineDomNodeForClip,
|
||||
} from "./timelineElementHelpers";
|
||||
|
||||
// Re-export iframe helpers so the hook can keep a single import source.
|
||||
export {
|
||||
normalizePreviewViewport,
|
||||
autoHealMissingCompositionIds,
|
||||
unmutePreviewMedia,
|
||||
resolveIframe,
|
||||
buildMissingCompositionElements,
|
||||
} from "./timelineIframeHelpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimelineElement factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createTimelineElementFromManifestClip(params: {
|
||||
clip: ClipManifestClip;
|
||||
fallbackIndex: number;
|
||||
doc?: Document | null;
|
||||
hostEl?: Element | null;
|
||||
}): TimelineElement {
|
||||
const { clip, fallbackIndex, doc } = params;
|
||||
let hostEl = params.hostEl ?? null;
|
||||
const label = getTimelineElementDisplayLabel({
|
||||
id: clip.id,
|
||||
label: clip.label,
|
||||
tag: clip.tagName || clip.kind,
|
||||
});
|
||||
|
||||
let domId: string | undefined;
|
||||
let selector: string | undefined;
|
||||
let selectorIndex: number | undefined;
|
||||
let sourceFile: string | undefined;
|
||||
|
||||
if (hostEl) {
|
||||
domId = hostEl.id || undefined;
|
||||
selector = getTimelineElementSelector(hostEl);
|
||||
selectorIndex =
|
||||
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
|
||||
sourceFile = getTimelineElementSourceFile(hostEl);
|
||||
}
|
||||
|
||||
const identity = buildTimelineElementIdentity({
|
||||
preferredId: clip.id,
|
||||
label,
|
||||
fallbackIndex,
|
||||
domId,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
});
|
||||
const entry: TimelineElement = {
|
||||
id: identity.id,
|
||||
label,
|
||||
key: identity.key,
|
||||
tag: clip.tagName || clip.kind,
|
||||
start: clip.start,
|
||||
duration: clip.duration,
|
||||
track: clip.track,
|
||||
domId,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
};
|
||||
|
||||
if (hostEl) {
|
||||
applyMediaMetadataFromElement(entry, hostEl);
|
||||
}
|
||||
if (clip.assetUrl) entry.src = clip.assetUrl;
|
||||
if (clip.kind === "composition" && clip.compositionId) {
|
||||
let resolvedSrc = clip.compositionSrc;
|
||||
if (!resolvedSrc) {
|
||||
hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
||||
resolvedSrc =
|
||||
hostEl?.getAttribute("data-composition-src") ??
|
||||
hostEl?.getAttribute("data-composition-file") ??
|
||||
null;
|
||||
}
|
||||
if (resolvedSrc) {
|
||||
entry.compositionSrc = resolvedSrc;
|
||||
} else if (hostEl) {
|
||||
const innerVideo = hostEl.querySelector("video[src]");
|
||||
if (innerVideo) {
|
||||
entry.src = innerVideo.getAttribute("src") || undefined;
|
||||
entry.tag = "video";
|
||||
}
|
||||
}
|
||||
if (hostEl) {
|
||||
entry.domId = hostEl.id || undefined;
|
||||
entry.selector = getTimelineElementSelector(hostEl);
|
||||
entry.selectorIndex =
|
||||
doc && entry.selector
|
||||
? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
|
||||
: undefined;
|
||||
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
||||
const nextIdentity = buildTimelineElementIdentity({
|
||||
preferredId: clip.id,
|
||||
label,
|
||||
fallbackIndex,
|
||||
domId: entry.domId,
|
||||
selector: entry.selector,
|
||||
selectorIndex: entry.selectorIndex,
|
||||
sourceFile: entry.sourceFile,
|
||||
});
|
||||
entry.id = nextIdentity.id;
|
||||
entry.key = nextIdentity.key;
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function createImplicitTimelineLayersFromDOM(
|
||||
doc: Document,
|
||||
rootDuration: number,
|
||||
existingElements: readonly TimelineElement[] = [],
|
||||
): TimelineElement[] {
|
||||
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
|
||||
const rootComp = doc.querySelector("[data-composition-id]");
|
||||
if (!rootComp) return [];
|
||||
|
||||
const existingKeys = new Set(existingElements.map(getTimelineElementIdentity));
|
||||
const maxTrack = existingElements.reduce(
|
||||
(max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0),
|
||||
-1,
|
||||
);
|
||||
const layers: TimelineElement[] = [];
|
||||
|
||||
for (const child of Array.from(rootComp.children)) {
|
||||
if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue;
|
||||
|
||||
const selector = getTimelineElementSelector(child);
|
||||
if (!selector) continue;
|
||||
const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector);
|
||||
const sourceFile = getTimelineElementSourceFile(child);
|
||||
const label = getImplicitTimelineLayerLabel(child);
|
||||
const identity = buildTimelineElementIdentity({
|
||||
preferredId: child.id || null,
|
||||
label,
|
||||
fallbackIndex: existingElements.length + layers.length,
|
||||
domId: child.id || undefined,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
});
|
||||
if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
|
||||
|
||||
layers.push({
|
||||
domId: child.id || undefined,
|
||||
duration: rootDuration,
|
||||
id: identity.id,
|
||||
key: identity.key,
|
||||
label,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
start: 0,
|
||||
tag: child.tagName.toLowerCase(),
|
||||
timingSource: "implicit",
|
||||
track: maxTrack + 1 + layers.length,
|
||||
});
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse [data-start] elements from a Document into TimelineElement[].
|
||||
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
||||
*/
|
||||
export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
||||
const rootComp = doc.querySelector("[data-composition-id]");
|
||||
const nodes = doc.querySelectorAll("[data-start]");
|
||||
const els: TimelineElement[] = [];
|
||||
let trackCounter = 0;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node === rootComp) return;
|
||||
const el = node as HTMLElement;
|
||||
const startStr = el.getAttribute("data-start");
|
||||
if (startStr == null) return;
|
||||
const start = parseFloat(startStr);
|
||||
if (isNaN(start)) 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);
|
||||
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 compId = el.getAttribute("data-composition-id");
|
||||
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: tagLower,
|
||||
});
|
||||
const identity = buildTimelineElementIdentity({
|
||||
preferredId: el.id || compId || null,
|
||||
label,
|
||||
fallbackIndex: els.length,
|
||||
domId: el.id || undefined,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
});
|
||||
const entry: TimelineElement = {
|
||||
id: identity.id,
|
||||
label,
|
||||
key: identity.key,
|
||||
tag: tagLower,
|
||||
start,
|
||||
duration: dur,
|
||||
track: isNaN(track) ? 0 : track,
|
||||
domId: el.id || undefined,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
timingSource: "authored",
|
||||
};
|
||||
|
||||
const mediaEl = resolveMediaElement(el);
|
||||
if (mediaEl) {
|
||||
if (mediaEl.tagName === "IMG") {
|
||||
entry.tag = "img";
|
||||
}
|
||||
const src = mediaEl.getAttribute("src");
|
||||
if (src) entry.src = src;
|
||||
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
|
||||
if (vol) entry.volume = parseFloat(vol);
|
||||
applyMediaMetadataFromElement(entry, el);
|
||||
}
|
||||
|
||||
// Sub-compositions
|
||||
const compSrc =
|
||||
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
||||
if (compSrc) {
|
||||
entry.compositionSrc = compSrc;
|
||||
} else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
|
||||
// Inline composition — expose inner video for thumbnails
|
||||
const innerVideo = el.querySelector("video[src]");
|
||||
if (innerVideo) {
|
||||
entry.src = innerVideo.getAttribute("src") || undefined;
|
||||
entry.tag = "video";
|
||||
}
|
||||
}
|
||||
|
||||
els.push(entry);
|
||||
});
|
||||
|
||||
return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mergeTimelineElementsPreservingDowngrades(
|
||||
currentElements: TimelineElement[],
|
||||
nextElements: TimelineElement[],
|
||||
currentDuration: number,
|
||||
nextDuration: number,
|
||||
): TimelineElement[] {
|
||||
const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
|
||||
const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
|
||||
|
||||
if (
|
||||
currentElements.length === 0 ||
|
||||
nextElements.length >= currentElements.length ||
|
||||
safeNextDuration > safeCurrentDuration
|
||||
) {
|
||||
return nextElements;
|
||||
}
|
||||
|
||||
const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
|
||||
const preserved = currentElements.filter(
|
||||
(element) => !nextIdentities.has(getTimelineElementIdentity(element)),
|
||||
);
|
||||
if (preserved.length === 0) return nextElements;
|
||||
return [...nextElements, ...preserved];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone composition helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolveStandaloneRootCompositionSrc(iframeSrc: string): string | undefined {
|
||||
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
|
||||
return compPathMatch ? decodeURIComponent(compPathMatch[1]) : undefined;
|
||||
}
|
||||
|
||||
export function buildStandaloneRootTimelineElement(params: {
|
||||
compositionId: string;
|
||||
tagName: string;
|
||||
rootDuration: number;
|
||||
iframeSrc: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
}): TimelineElement | null {
|
||||
if (!Number.isFinite(params.rootDuration) || params.rootDuration <= 0) return null;
|
||||
|
||||
const compositionSrc = resolveStandaloneRootCompositionSrc(params.iframeSrc);
|
||||
|
||||
return {
|
||||
id: params.compositionId,
|
||||
label: getTimelineElementDisplayLabel({
|
||||
id: params.compositionId,
|
||||
tag: params.tagName,
|
||||
}),
|
||||
key: buildTimelineElementKey({
|
||||
id: params.compositionId,
|
||||
fallbackIndex: 0,
|
||||
selector: params.selector,
|
||||
selectorIndex: params.selectorIndex,
|
||||
sourceFile: compositionSrc,
|
||||
}),
|
||||
tag: params.tagName.toLowerCase() || "div",
|
||||
start: 0,
|
||||
duration: params.rootDuration,
|
||||
track: 0,
|
||||
compositionSrc,
|
||||
selector: params.selector,
|
||||
selectorIndex: params.selectorIndex,
|
||||
sourceFile: compositionSrc,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Low-level helpers for building and identifying TimelineElement objects.
|
||||
*
|
||||
* Covers: duration reading, media-element metadata extraction, selector/key/
|
||||
* identity builders, DOM node lookup, and implicit layer detection. These are
|
||||
* intentionally dependency-free (no store, no hooks) so they can be used in
|
||||
* both the React hook and test environments.
|
||||
*/
|
||||
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "./playbackTypes";
|
||||
import { isFinitePositive } from "./playbackAdapter";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Duration attribute helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function readDurationAttribute(el: Element | null | undefined): number {
|
||||
if (!el) return 0;
|
||||
const duration =
|
||||
Number.parseFloat(el.getAttribute("data-duration") ?? "") ||
|
||||
Number.parseFloat(el.getAttribute("data-hf-authored-duration") ?? "");
|
||||
return isFinitePositive(duration) ? duration : 0;
|
||||
}
|
||||
|
||||
export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
|
||||
if (!doc) return 0;
|
||||
const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
|
||||
if (rootDuration > 0) return rootDuration;
|
||||
|
||||
let maxEnd = 0;
|
||||
for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
|
||||
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
||||
const duration = readDurationAttribute(node);
|
||||
if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
|
||||
maxEnd = Math.max(maxEnd, start + duration);
|
||||
}
|
||||
return maxEnd;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM element type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isHtmlElement(el: Element): el is HTMLElement {
|
||||
const HtmlElementCtor = el.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
|
||||
return typeof HtmlElementCtor !== "undefined" && el instanceof HtmlElementCtor;
|
||||
}
|
||||
|
||||
export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
|
||||
const win = el.ownerDocument.defaultView ?? window;
|
||||
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
||||
const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
|
||||
if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
|
||||
const candidate = el.querySelector("video, audio, img");
|
||||
return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
||||
const mediaStartAttr = el.getAttribute("data-playback-start")
|
||||
? "playback-start"
|
||||
: el.getAttribute("data-media-start")
|
||||
? "media-start"
|
||||
: undefined;
|
||||
const mediaStartValue =
|
||||
el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
|
||||
if (mediaStartValue != null) {
|
||||
const playbackStart = parseFloat(mediaStartValue);
|
||||
if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
|
||||
}
|
||||
if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
|
||||
|
||||
const mediaEl = resolveMediaElement(el);
|
||||
if (!mediaEl) return;
|
||||
|
||||
entry.tag = mediaEl.tagName.toLowerCase();
|
||||
const src = mediaEl.getAttribute("src");
|
||||
if (src) entry.src = src;
|
||||
|
||||
const win = mediaEl.ownerDocument.defaultView ?? window;
|
||||
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
||||
if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
|
||||
|
||||
const sourceDurationAttr =
|
||||
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
|
||||
const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : mediaEl.duration;
|
||||
if (Number.isFinite(sourceDuration) && sourceDuration > 0) {
|
||||
entry.sourceDuration = sourceDuration;
|
||||
}
|
||||
|
||||
const playbackRate = mediaEl.defaultPlaybackRate;
|
||||
if (Number.isFinite(playbackRate) && playbackRate > 0) {
|
||||
entry.playbackRate = playbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getTimelineElementDisplayLabel(input: {
|
||||
id?: string | null;
|
||||
label?: string | null;
|
||||
tag?: string | null;
|
||||
}): string {
|
||||
const label = input.label?.trim();
|
||||
if (label) return label;
|
||||
const id = input.id?.trim();
|
||||
if (id) return id;
|
||||
const tag = input.tag?.trim().toLowerCase();
|
||||
return tag ? `${tag} clip` : "Timeline clip";
|
||||
}
|
||||
|
||||
export const IMPLICIT_TIMELINE_LAYER_SKIP_TAGS = new Set([
|
||||
"base",
|
||||
"link",
|
||||
"meta",
|
||||
"noscript",
|
||||
"script",
|
||||
"style",
|
||||
"template",
|
||||
]);
|
||||
|
||||
export function humanizeTimelineIdentifier(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
export function getImplicitTimelineLayerLabel(el: HTMLElement): string {
|
||||
const explicitLabel =
|
||||
el.getAttribute("data-timeline-label") ??
|
||||
el.getAttribute("data-label") ??
|
||||
el.getAttribute("aria-label");
|
||||
if (explicitLabel?.trim()) return explicitLabel.trim();
|
||||
if (el.id.trim()) return humanizeTimelineIdentifier(el.id);
|
||||
const classes = el.className.split(/\s+/).filter(Boolean);
|
||||
const className = classes.find((value) => value !== "clip") ?? classes[0];
|
||||
if (className) return humanizeTimelineIdentifier(className);
|
||||
return getTimelineElementDisplayLabel({ tag: el.tagName });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selector / identity / key builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getTimelineElementSelector(el: Element): string | undefined {
|
||||
if (isHtmlElement(el) && el.id) return `#${el.id}`;
|
||||
const compId = el.getAttribute("data-composition-id");
|
||||
if (compId) return `[data-composition-id="${compId}"]`;
|
||||
if (isHtmlElement(el)) {
|
||||
const classes = el.className.split(/\s+/).filter(Boolean);
|
||||
const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
|
||||
if (firstClass) return `.${firstClass}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getTimelineElementSourceFile(el: Element): string | undefined {
|
||||
const ownerRoot = el.parentElement?.closest("[data-composition-id]");
|
||||
return (
|
||||
ownerRoot?.getAttribute("data-composition-file") ??
|
||||
ownerRoot?.getAttribute("data-composition-src") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function getTimelineElementSelectorIndex(
|
||||
doc: Document,
|
||||
el: Element,
|
||||
selector: string | undefined,
|
||||
): number | undefined {
|
||||
if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const matches = Array.from(doc.querySelectorAll(selector));
|
||||
const matchIndex = matches.indexOf(el);
|
||||
return matchIndex >= 0 ? matchIndex : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTimelineElementKey(params: {
|
||||
id: string;
|
||||
fallbackIndex: number;
|
||||
domId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile?: string;
|
||||
}): string {
|
||||
const scope = params.sourceFile ?? "index.html";
|
||||
if (params.domId) return `${scope}#${params.domId}`;
|
||||
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
|
||||
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
||||
}
|
||||
|
||||
export function buildTimelineElementIdentity(params: {
|
||||
preferredId?: string | null;
|
||||
label: string;
|
||||
fallbackIndex: number;
|
||||
domId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile?: string;
|
||||
}): { id: string; key: string } {
|
||||
const id =
|
||||
params.preferredId?.trim() ||
|
||||
buildTimelineElementKey({
|
||||
id: params.label,
|
||||
fallbackIndex: params.fallbackIndex,
|
||||
domId: params.domId,
|
||||
selector: params.selector,
|
||||
selectorIndex: params.selectorIndex,
|
||||
sourceFile: params.sourceFile,
|
||||
});
|
||||
const key = buildTimelineElementKey({
|
||||
id,
|
||||
fallbackIndex: params.fallbackIndex,
|
||||
domId: params.domId,
|
||||
selector: params.selector,
|
||||
selectorIndex: params.selectorIndex,
|
||||
sourceFile: params.sourceFile,
|
||||
});
|
||||
return { id, key };
|
||||
}
|
||||
|
||||
export function getTimelineElementIdentity(element: TimelineElement): string {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM node querying
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getTimelineDomNodes(doc: Document): Element[] {
|
||||
const rootComp = doc.querySelector("[data-composition-id]");
|
||||
return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
|
||||
}
|
||||
|
||||
function numbersNearlyEqual(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < 0.001;
|
||||
}
|
||||
|
||||
function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
|
||||
const tagName = clip.tagName?.toLowerCase();
|
||||
if (tagName && node.tagName.toLowerCase() !== tagName) return false;
|
||||
|
||||
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
||||
if (Number.isFinite(start) && !numbersNearlyEqual(start, clip.start)) return false;
|
||||
|
||||
const duration = Number.parseFloat(node.getAttribute("data-duration") ?? "");
|
||||
if (Number.isFinite(duration) && !numbersNearlyEqual(duration, clip.duration)) return false;
|
||||
|
||||
const track = Number.parseInt(node.getAttribute("data-track-index") ?? "", 10);
|
||||
if (Number.isFinite(track) && track !== clip.track) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
||||
return (
|
||||
doc.getElementById(id) ??
|
||||
doc.querySelector(`[data-composition-id="${id}"]`) ??
|
||||
doc.querySelector(`.${id}`) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function findTimelineDomNodeForClip(
|
||||
doc: Document,
|
||||
clip: ClipManifestClip,
|
||||
fallbackIndex: number,
|
||||
usedNodes = new Set<Element>(),
|
||||
): Element | null {
|
||||
const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
|
||||
if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
|
||||
|
||||
const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
|
||||
const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
|
||||
if (exact) return exact;
|
||||
|
||||
return candidates[fallbackIndex] ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implicit layer detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
|
||||
if (!isHtmlElement(el)) return false;
|
||||
if (el.parentElement !== root) return false;
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
|
||||
if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
|
||||
return Boolean(getTimelineElementSelector(el));
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Runtime iframe integration utilities.
|
||||
*
|
||||
* Handles the boundary between the studio host page and the preview iframe:
|
||||
* - Viewport normalisation on load
|
||||
* - Auto-healing missing data-composition-id attributes
|
||||
* - Unmuting media via postMessage
|
||||
* - Resolving the underlying <iframe> from any wrapper element
|
||||
* - Scanning the DOM for composition hosts the manifest missed
|
||||
* (element-reference starts that the CDN runtime fails to resolve)
|
||||
*/
|
||||
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { IframeWindow } from "./playbackTypes";
|
||||
import {
|
||||
getTimelineElementSelector,
|
||||
getTimelineElementSourceFile,
|
||||
getTimelineElementSelectorIndex,
|
||||
getTimelineElementDisplayLabel,
|
||||
buildTimelineElementIdentity,
|
||||
} from "./timelineElementHelpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewport / DOM normalisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function normalizePreviewViewport(doc: Document, win: Window): void {
|
||||
if (doc.documentElement) {
|
||||
doc.documentElement.style.overflow = "hidden";
|
||||
doc.documentElement.style.margin = "0";
|
||||
}
|
||||
if (doc.body) {
|
||||
doc.body.style.overflow = "hidden";
|
||||
doc.body.style.margin = "0";
|
||||
}
|
||||
win.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
export function autoHealMissingCompositionIds(doc: Document): void {
|
||||
const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
|
||||
const referencedIds = new Set<string>();
|
||||
const scopedNodes = Array.from(doc.querySelectorAll("style, script"));
|
||||
for (const node of scopedNodes) {
|
||||
const text = node.textContent || "";
|
||||
if (!text) continue;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = compositionIdRe.exec(text)) !== null) {
|
||||
const id = (match[1] || "").trim();
|
||||
if (id) referencedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (referencedIds.size === 0) return;
|
||||
|
||||
const existingIds = new Set<string>();
|
||||
const existingNodes = Array.from(doc.querySelectorAll<HTMLElement>("[data-composition-id]"));
|
||||
for (const node of existingNodes) {
|
||||
const id = node.getAttribute("data-composition-id");
|
||||
if (id) existingIds.add(id);
|
||||
}
|
||||
|
||||
for (const compId of referencedIds) {
|
||||
if (compId === "root" || existingIds.has(compId)) continue;
|
||||
const host =
|
||||
doc.getElementById(`${compId}-layer`) ||
|
||||
doc.getElementById(`${compId}-comp`) ||
|
||||
doc.getElementById(compId);
|
||||
if (!host) continue;
|
||||
if (!host.getAttribute("data-composition-id")) {
|
||||
host.setAttribute("data-composition-id", compId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Muting / iframe resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
|
||||
if (!iframe) return;
|
||||
try {
|
||||
iframe.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action: "set-muted", muted: false },
|
||||
"*",
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("[useTimelinePlayer] Failed to unmute preview media", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the underlying iframe from any host element. Supports:
|
||||
* - Direct `<iframe>` element (most common — studio's own `Player.tsx`)
|
||||
* - Custom elements (e.g. `<hyperframes-player>`) whose shadow DOM contains an iframe
|
||||
* - Wrapper elements whose light DOM contains a descendant iframe
|
||||
*
|
||||
* Exported so web-component consumers can pre-resolve the iframe before
|
||||
* assigning it to `iframeRef` returned by `useTimelinePlayer`. Returns `null`
|
||||
* when the element has no associated iframe yet.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { iframeRef } = useTimelinePlayer();
|
||||
* const playerElRef = useRef<HyperframesPlayer>(null);
|
||||
*
|
||||
* useEffect(() => {
|
||||
* iframeRef.current = resolveIframe(playerElRef.current);
|
||||
* }, [iframeRef]);
|
||||
* ```
|
||||
*/
|
||||
export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
|
||||
if (!el) return null;
|
||||
if (el instanceof HTMLIFrameElement) return el;
|
||||
return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enrich missing compositions from DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scan the iframe DOM for composition hosts missing from the current
|
||||
* timeline elements and add them. The CDN runtime often fails to resolve
|
||||
* element-reference starts (`data-start="intro"`) so composition hosts
|
||||
* are silently dropped from `__clipManifest`. This pass reads the DOM +
|
||||
* GSAP timeline registry directly to fill the gaps.
|
||||
*/
|
||||
export function buildMissingCompositionElements(
|
||||
doc: Document,
|
||||
iframeWin: IframeWindow,
|
||||
currentEls: readonly TimelineElement[],
|
||||
rootDuration: number,
|
||||
): { missing: TimelineElement[]; updatedEls: TimelineElement[]; patched: boolean } {
|
||||
const existingIds = new Set(currentEls.map((e) => e.id));
|
||||
const rootComp = doc.querySelector("[data-composition-id]");
|
||||
const rootCompId = rootComp?.getAttribute("data-composition-id");
|
||||
// Use [data-composition-id][data-start] — the composition loader strips
|
||||
// data-composition-src after loading, so we can't rely on it.
|
||||
const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
|
||||
const missing: TimelineElement[] = [];
|
||||
|
||||
hosts.forEach((host) => {
|
||||
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="${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="${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;
|
||||
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,
|
||||
fallbackIndex: missing.length,
|
||||
domId: el.id || undefined,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
});
|
||||
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,
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
};
|
||||
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);
|
||||
});
|
||||
|
||||
// Patch existing elements that are missing compositionSrc
|
||||
let patched = false;
|
||||
const updatedEls = (currentEls as TimelineElement[]).map((existing) => {
|
||||
if (existing.compositionSrc) return existing;
|
||||
// Find the matching DOM host by element id or composition id
|
||||
const host =
|
||||
doc.getElementById(existing.id) ??
|
||||
doc.querySelector(`[data-composition-id="${existing.id}"]`);
|
||||
if (!host) return existing;
|
||||
const compSrc =
|
||||
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
||||
if (compSrc) {
|
||||
patched = true;
|
||||
return { ...existing, compositionSrc: compSrc };
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
|
||||
return { missing, updatedEls, patched };
|
||||
}
|
||||
Reference in New Issue
Block a user