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:
Miguel Ángel
2026-05-13 01:48:12 +02:00
committed by GitHub
parent 03475d54c6
commit 91bdffffe6
74 changed files with 11760 additions and 9759 deletions
@@ -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 };
}