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
@@ -1,437 +1,50 @@
import type { DomEditSelection } from "./domEditing";
// Public surface — re-exports types, constants, and ops; owns DOM application logic.
export const STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json";
export const STUDIO_MOTION_TIMELINE_ID = "studio-motion";
export {
STUDIO_MOTION_PATH,
STUDIO_MOTION_TIMELINE_ID,
STUDIO_GSAP_EASE_OPTIONS,
type StudioMotionTarget,
type StudioGsapMotionValues,
type StudioGsapCustomEase,
type StudioCustomEaseControlPoints,
type StudioGsapMotion,
type StudioGsapMotionPreset,
type StudioGsapMotionDirection,
type StudioGsapPresetMotionOptions,
type StudioMotionManifest,
type StudioGsapTimeline,
type StudioMotionWindow,
} from "./studioMotionTypes";
const STUDIO_MOTION_ATTR = "data-hf-studio-motion";
const STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR = "data-hf-studio-motion-original-transform";
const STUDIO_MOTION_ORIGINAL_OPACITY_ATTR = "data-hf-studio-motion-original-opacity";
const STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR = "data-hf-studio-motion-original-visibility";
export {
clampStudioCustomEasePoints,
parseStudioCustomEaseData,
serializeStudioCustomEaseData,
controlPointsForGsapEase,
buildStudioGsapPresetMotion,
emptyStudioMotionManifest,
parseStudioMotionManifest,
serializeStudioMotionManifest,
isStudioMotionManifestPath,
upsertStudioGsapMotion,
removeStudioMotionForSelection,
getStudioMotionForSelection,
} from "./studioMotionOps";
export interface StudioMotionTarget {
sourceFile: string;
selector?: string;
selectorIndex?: number;
id?: string;
}
import {
STUDIO_MOTION_ATTR,
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
STUDIO_MOTION_TIMELINE_ID,
type StudioGsapMotion,
type StudioMotionManifest,
type StudioMotionTarget,
type StudioMotionWindow,
} from "./studioMotionTypes";
export interface StudioGsapMotionValues {
x?: number;
y?: number;
scale?: number;
rotation?: number;
opacity?: number;
autoAlpha?: number;
}
export interface StudioGsapCustomEase {
id: string;
data: string;
}
export interface StudioCustomEaseControlPoints {
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface StudioGsapMotion {
kind: "gsap-motion";
target: StudioMotionTarget;
start: number;
duration: number;
ease: string;
customEase?: StudioGsapCustomEase;
from: StudioGsapMotionValues;
to: StudioGsapMotionValues;
updatedAt?: string;
}
export type StudioGsapMotionPreset = "fade-up" | "slide" | "pop";
export type StudioGsapMotionDirection = "up" | "down" | "left" | "right";
export const STUDIO_GSAP_EASE_OPTIONS = [
"none",
"power1.in",
"power1.out",
"power1.inOut",
"power2.in",
"power2.out",
"power2.inOut",
"power3.in",
"power3.out",
"power3.inOut",
"power4.in",
"power4.out",
"power4.inOut",
"sine.in",
"sine.out",
"sine.inOut",
"expo.in",
"expo.out",
"expo.inOut",
"circ.in",
"circ.out",
"circ.inOut",
"back.in(1.7)",
"back.out(1.7)",
"back.inOut(1.7)",
"elastic.out(1, 0.45)",
"bounce.out",
] as const;
const DEFAULT_CUSTOM_EASE_POINTS: StudioCustomEaseControlPoints = {
x1: 0.215,
y1: 0.61,
x2: 0.355,
y2: 1,
};
const GSAP_EASE_CONTROL_POINTS: Record<string, StudioCustomEaseControlPoints> = {
none: { x1: 0, y1: 0, x2: 1, y2: 1 },
"power1.in": { x1: 0.55, y1: 0.085, x2: 0.68, y2: 0.53 },
"power1.out": { x1: 0.25, y1: 0.46, x2: 0.45, y2: 0.94 },
"power1.inOut": { x1: 0.455, y1: 0.03, x2: 0.515, y2: 0.955 },
"power2.in": { x1: 0.55, y1: 0.055, x2: 0.675, y2: 0.19 },
"power2.out": { x1: 0.215, y1: 0.61, x2: 0.355, y2: 1 },
"power2.inOut": { x1: 0.645, y1: 0.045, x2: 0.355, y2: 1 },
"power3.in": { x1: 0.895, y1: 0.03, x2: 0.685, y2: 0.22 },
"power3.out": { x1: 0.165, y1: 0.84, x2: 0.44, y2: 1 },
"power3.inOut": { x1: 0.77, y1: 0, x2: 0.175, y2: 1 },
"power4.in": { x1: 0.755, y1: 0.05, x2: 0.855, y2: 0.06 },
"power4.out": { x1: 0.23, y1: 1, x2: 0.32, y2: 1 },
"power4.inOut": { x1: 0.86, y1: 0, x2: 0.07, y2: 1 },
"sine.in": { x1: 0.47, y1: 0, x2: 0.745, y2: 0.715 },
"sine.out": { x1: 0.39, y1: 0.575, x2: 0.565, y2: 1 },
"sine.inOut": { x1: 0.445, y1: 0.05, x2: 0.55, y2: 0.95 },
"expo.in": { x1: 0.95, y1: 0.05, x2: 0.795, y2: 0.035 },
"expo.out": { x1: 0.19, y1: 1, x2: 0.22, y2: 1 },
"expo.inOut": { x1: 1, y1: 0, x2: 0, y2: 1 },
"circ.in": { x1: 0.6, y1: 0.04, x2: 0.98, y2: 0.335 },
"circ.out": { x1: 0.075, y1: 0.82, x2: 0.165, y2: 1 },
"circ.inOut": { x1: 0.785, y1: 0.135, x2: 0.15, y2: 0.86 },
"back.in(1.7)": { x1: 0.6, y1: -0.28, x2: 0.735, y2: 0.045 },
"back.out(1.7)": { x1: 0.175, y1: 0.885, x2: 0.32, y2: 1.275 },
"back.inOut(1.7)": { x1: 0.68, y1: -0.55, x2: 0.265, y2: 1.55 },
"elastic.out(1, 0.45)": { x1: 0.16, y1: 1.32, x2: 0.28, y2: 0.86 },
"bounce.out": { x1: 0.34, y1: 1.56, x2: 0.64, y2: 0.74 },
};
const CUSTOM_EASE_DATA_PATTERN =
/^M\s*0\s*,\s*0\s*C\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s+1\s*,\s*1\s*$/i;
export interface StudioGsapPresetMotionOptions {
start: number;
duration: number;
distance: number;
ease: string;
direction?: StudioGsapMotionDirection;
customEase?: StudioGsapCustomEase;
}
export interface StudioMotionManifest {
version: 1;
motions: StudioGsapMotion[];
}
interface StudioGsapTimeline {
fromTo?: (
target: HTMLElement,
from: Record<string, unknown>,
to: Record<string, unknown>,
at: number,
) => StudioGsapTimeline;
time?: (time: number) => StudioGsapTimeline;
totalTime?: (time: number, suppressEvents?: boolean) => StudioGsapTimeline;
pause?: () => StudioGsapTimeline;
kill?: () => void;
duration?: () => number;
}
type StudioMotionWindow = Window & {
gsap?: {
timeline?: (vars?: Record<string, unknown>) => StudioGsapTimeline;
set?: (target: HTMLElement, vars: Record<string, unknown>) => void;
registerPlugin?: (...plugins: unknown[]) => void;
};
CustomEase?: { create?: (id: string, data: string) => void };
__player?: {
getTime?: () => number;
renderSeek?: (time: number) => void;
seek?: (time: number) => void;
};
__timeline?: { time?: () => number };
__timelines?: Record<string, StudioGsapTimeline | undefined>;
__hfStudioMotionApply?: () => number;
__hfStudioMotionWrapped?: boolean;
};
export function emptyStudioMotionManifest(): StudioMotionManifest {
return { version: 1, motions: [] };
}
function clampPositiveNumber(value: number, fallback: number): number {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function clampNonNegativeNumber(value: number, fallback: number): number {
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function sanitizeEase(value: string): string {
return value.trim() || "none";
}
function roundEaseNumber(value: number): number {
return Math.round(value * 1000) / 1000;
}
function clampRange(value: number, min: number, max: number, fallback: number): number {
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
}
export function clampStudioCustomEasePoints(
points: Partial<StudioCustomEaseControlPoints>,
): StudioCustomEaseControlPoints {
return {
x1: roundEaseNumber(clampRange(points.x1 ?? DEFAULT_CUSTOM_EASE_POINTS.x1, 0, 1, 0.215)),
y1: roundEaseNumber(clampRange(points.y1 ?? DEFAULT_CUSTOM_EASE_POINTS.y1, -0.6, 1.6, 0.61)),
x2: roundEaseNumber(clampRange(points.x2 ?? DEFAULT_CUSTOM_EASE_POINTS.x2, 0, 1, 0.355)),
y2: roundEaseNumber(clampRange(points.y2 ?? DEFAULT_CUSTOM_EASE_POINTS.y2, -0.6, 1.6, 1)),
};
}
export function parseStudioCustomEaseData(
data: string | undefined,
): StudioCustomEaseControlPoints | null {
if (!data) return null;
const match = data.trim().match(CUSTOM_EASE_DATA_PATTERN);
if (!match) return null;
const points = {
x1: Number.parseFloat(match[1] ?? ""),
y1: Number.parseFloat(match[2] ?? ""),
x2: Number.parseFloat(match[3] ?? ""),
y2: Number.parseFloat(match[4] ?? ""),
};
if (!Object.values(points).every(Number.isFinite)) return null;
return clampStudioCustomEasePoints(points);
}
function formatEaseNumber(value: number): string {
const rounded = roundEaseNumber(value);
if (Object.is(rounded, -0)) return "0";
return `${rounded}`;
}
export function serializeStudioCustomEaseData(points: StudioCustomEaseControlPoints): string {
const clamped = clampStudioCustomEasePoints(points);
return `M0,0 C${formatEaseNumber(clamped.x1)},${formatEaseNumber(clamped.y1)} ${formatEaseNumber(clamped.x2)},${formatEaseNumber(clamped.y2)} 1,1`;
}
export function controlPointsForGsapEase(ease: string): StudioCustomEaseControlPoints {
return GSAP_EASE_CONTROL_POINTS[ease] ?? DEFAULT_CUSTOM_EASE_POINTS;
}
export function buildStudioGsapPresetMotion(
preset: StudioGsapMotionPreset,
options: StudioGsapPresetMotionOptions,
): Omit<StudioGsapMotion, "kind" | "target" | "updatedAt"> {
const start = clampNonNegativeNumber(options.start, 0);
const duration = clampPositiveNumber(options.duration, 0.6);
const distance = clampPositiveNumber(options.distance, 32);
const ease = sanitizeEase(options.ease);
const direction = options.direction ?? "up";
const base = { start, duration, ease, customEase: options.customEase };
if (preset === "pop") {
return {
...base,
from: { scale: 0.88, autoAlpha: 0 },
to: { scale: 1, autoAlpha: 1 },
};
}
if (preset === "slide") {
const x = direction === "right" ? -distance : direction === "left" ? distance : 0;
const y = direction === "down" ? -distance : direction === "up" ? distance : 0;
return {
...base,
from: { x, y, autoAlpha: 0 },
to: { x: 0, y: 0, autoAlpha: 1 },
};
}
return {
...base,
from: { y: direction === "down" ? -distance : distance, autoAlpha: 0 },
to: { y: 0, autoAlpha: 1 },
};
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function parseMotionValues(value: unknown): StudioGsapMotionValues | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const parsed: StudioGsapMotionValues = {};
for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"] as const) {
const next = finiteNumber(record[key]);
if (next != null) parsed[key] = next;
}
return Object.keys(parsed).length > 0 ? parsed : null;
}
function parseTarget(value: unknown): StudioMotionTarget | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const sourceFile = typeof record.sourceFile === "string" ? record.sourceFile : "";
if (!sourceFile) return null;
const selector = typeof record.selector === "string" ? record.selector : undefined;
const id = typeof record.id === "string" ? record.id : undefined;
if (!selector && !id) return null;
return {
sourceFile,
selector,
selectorIndex: finiteNumber(record.selectorIndex) ?? undefined,
id,
};
}
function parseCustomEase(value: unknown): StudioGsapCustomEase | undefined {
if (!value || typeof value !== "object") return undefined;
const record = value as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : "";
const data = typeof record.data === "string" ? record.data.trim() : "";
if (!id || !data) return undefined;
return { id, data };
}
function parseGsapMotion(value: unknown): StudioGsapMotion | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (record.kind !== "gsap-motion") return null;
const target = parseTarget(record.target);
if (!target) return null;
const start = finiteNumber(record.start);
const duration = finiteNumber(record.duration);
if (start == null || duration == null || start < 0 || duration <= 0) return null;
const ease = typeof record.ease === "string" && record.ease.trim() ? record.ease.trim() : "none";
const from = parseMotionValues(record.from);
const to = parseMotionValues(record.to);
if (!from || !to) return null;
return {
kind: "gsap-motion",
target,
start,
duration,
ease,
customEase: parseCustomEase(record.customEase),
from,
to,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
};
}
export function parseStudioMotionManifest(content: string): StudioMotionManifest {
if (!content.trim()) return emptyStudioMotionManifest();
try {
const parsed = JSON.parse(content) as unknown;
if (!parsed || typeof parsed !== "object") return emptyStudioMotionManifest();
const motions = (parsed as { motions?: unknown }).motions;
if (!Array.isArray(motions)) return emptyStudioMotionManifest();
return {
version: 1,
motions: motions
.map(parseGsapMotion)
.filter((motion): motion is StudioGsapMotion => motion !== null),
};
} catch {
return emptyStudioMotionManifest();
}
}
export function serializeStudioMotionManifest(manifest: StudioMotionManifest): string {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
function normalizeStudioFileChangePath(path: string): string {
return path
.trim()
.replace(/\\/g, "/")
.replace(/^\.?\//, "");
}
export function isStudioMotionManifestPath(path: string | null): boolean {
if (!path) return false;
const normalized = normalizeStudioFileChangePath(path);
return normalized === STUDIO_MOTION_PATH || normalized.endsWith(`/${STUDIO_MOTION_PATH}`);
}
function selectionTarget(selection: DomEditSelection): StudioMotionTarget {
return {
sourceFile: selection.sourceFile || "index.html",
selector: selection.selector,
selectorIndex: selection.selectorIndex,
id: selection.id ?? undefined,
};
}
function targetKey(target: StudioMotionTarget): string {
return [
target.sourceFile,
target.id ? `id:${target.id}` : "",
target.selector ? `selector:${target.selector}` : "",
target.selectorIndex != null ? `index:${target.selectorIndex}` : "",
].join("|");
}
function sameSelectionTarget(motion: StudioGsapMotion, selection: DomEditSelection): boolean {
const target = selectionTarget(selection);
if (motion.target.sourceFile !== target.sourceFile) return false;
if (motion.target.id && target.id && motion.target.id === target.id) return true;
return targetKey(motion.target) === targetKey(target);
}
export function upsertStudioGsapMotion(
manifest: StudioMotionManifest,
selection: DomEditSelection,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
): StudioMotionManifest {
const target = selectionTarget(selection);
const nextMotion: StudioGsapMotion = {
kind: "gsap-motion",
target,
...motion,
updatedAt: new Date().toISOString(),
};
return {
version: 1,
motions: [
...manifest.motions.filter((existing) => targetKey(existing.target) !== targetKey(target)),
nextMotion,
],
};
}
export function removeStudioMotionForSelection(
manifest: StudioMotionManifest,
selection: DomEditSelection,
): StudioMotionManifest {
return {
version: 1,
motions: manifest.motions.filter((motion) => !sameSelectionTarget(motion, selection)),
};
}
export function getStudioMotionForSelection(
manifest: StudioMotionManifest,
selection: DomEditSelection,
): StudioGsapMotion | null {
return manifest.motions.find((motion) => sameSelectionTarget(motion, selection)) ?? null;
}
// ── DOM Helpers ──
function sourceFileForElement(element: HTMLElement, activeCompositionPath: string | null): string {
let current: HTMLElement | null = element;