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
+196
View File
@@ -0,0 +1,196 @@
/**
* Probes an iframe document to discover the composition's playback adapter
* and detect whether the HyperFrames runtime needs to be injected.
*
* The probe interval polls every 200 ms until one of:
* - A `PlaybackDurationAdapter` resolves with a positive duration, or
* - 40 attempts (~8 s) expire without a result.
*
* The `CompositionProbe` class owns the interval; the caller must call
* `stop()` on disconnect or src change.
*/
import { shouldInjectRuntime } from "./shouldInjectRuntime.js";
import {
type DirectTimelineAdapter,
type PlaybackDurationAdapter,
isDirectTimelineAdapter,
isObjectRecord,
isRuntimeDurationAdapter,
} from "./timeline-adapters.js";
const RUNTIME_CDN_URL =
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
export interface ProbeResult {
duration: number;
adapter: PlaybackDurationAdapter;
/** Resolved composition dimensions, if present in the document. */
compositionSize: { width: number; height: number } | null;
}
export interface ProbeCallbacks {
onReady: (result: ProbeResult) => void;
onError: (message: string) => void;
/** Called when runtime is successfully injected (informational). */
onRuntimeInjected?: () => void;
}
export class CompositionProbe {
private _interval: ReturnType<typeof setInterval> | null = null;
private _runtimeInjected = false;
constructor(
private readonly _iframe: HTMLIFrameElement,
private readonly _callbacks: ProbeCallbacks,
) {}
get runtimeInjected(): boolean {
return this._runtimeInjected;
}
/** Start (or restart) the probe. Stops any previously running probe first. */
start(): void {
this.stop();
this._runtimeInjected = false;
let attempts = 0;
this._interval = setInterval(() => {
attempts++;
try {
const win = this._iframe.contentWindow as Window & {
__player?: { getDuration: () => number };
__timelines?: Record<string, { duration: () => number }>;
__hf?: unknown;
};
if (!win) return;
const hasRuntime = !!(win.__hf || win.__player);
const hasTimelines = !!(win.__timelines && Object.keys(win.__timelines).length > 0);
const hasNestedCompositions =
!!this._iframe.contentDocument?.querySelector("[data-composition-src]");
if (
shouldInjectRuntime({
hasRuntime,
hasTimelines,
hasNestedCompositions,
runtimeInjected: this._runtimeInjected,
attempts,
})
) {
this._injectRuntime();
return;
}
if (this._runtimeInjected && !hasRuntime) return;
const adapter = this._resolvePlaybackDurationAdapter(win);
if (adapter && adapter.getDuration() > 0) {
this.stop();
const doc = this._iframe.contentDocument;
let compositionSize: { width: number; height: number } | null = null;
const root = doc?.querySelector("[data-composition-id]");
if (root) {
const w = parseInt(root.getAttribute("data-width") || "0", 10);
const h = parseInt(root.getAttribute("data-height") || "0", 10);
if (w > 0 && h > 0) compositionSize = { width: w, height: h };
}
this._callbacks.onReady({
duration: adapter.getDuration(),
adapter,
compositionSize,
});
return;
}
} catch {
/* cross-origin */
}
if (attempts >= 40) {
this.stop();
this._callbacks.onError("Composition timeline not found after 8s");
}
}, 200);
}
stop(): void {
if (this._interval !== null) {
clearInterval(this._interval);
this._interval = null;
}
}
// ── Adapter resolution (same-origin only) ────────────────────────────────
resolveDirectTimelineAdapter(): DirectTimelineAdapter | null {
try {
const win = this._iframe.contentWindow;
if (!win) return null;
return this._resolveDirectTimelineAdapterFromWindow(win);
} catch {
return null;
}
}
resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
return this._resolveDirectTimelineAdapterFromWindow(win);
}
hasRuntimeBridge(win: Window): boolean {
return Reflect.get(win, "__hf") !== undefined || isObjectRecord(Reflect.get(win, "__player"));
}
// ── Private ──────────────────────────────────────────────────────────────
private _injectRuntime(): void {
this._runtimeInjected = true;
try {
const doc = this._iframe.contentDocument;
if (!doc) return;
const script = doc.createElement("script");
script.src = RUNTIME_CDN_URL;
(doc.head || doc.documentElement).appendChild(script);
this._callbacks.onRuntimeInjected?.();
} catch {
/* cross-origin — can't inject */
}
}
private _resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
if (this.hasRuntimeBridge(win)) return null;
const timelines = Reflect.get(win, "__timelines");
if (!isObjectRecord(timelines)) return null;
const keys = Object.keys(timelines);
if (keys.length === 0) return null;
const rootId = this._iframe.contentDocument
?.querySelector("[data-composition-id]")
?.getAttribute("data-composition-id");
const key = rootId && rootId in timelines ? rootId : keys[keys.length - 1];
const timeline = timelines[key];
return isDirectTimelineAdapter(timeline) ? timeline : null;
}
private _resolvePlaybackDurationAdapter(win: Window): PlaybackDurationAdapter | null {
const runtimePlayer = Reflect.get(win, "__player");
if (isRuntimeDurationAdapter(runtimePlayer)) {
return { kind: "runtime", getDuration: () => runtimePlayer.getDuration() };
}
const timeline = this._resolveDirectTimelineAdapterFromWindow(win);
if (timeline) {
return {
kind: "direct-timeline",
timeline,
getDuration: () => timeline.duration(),
};
}
return null;
}
}