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
+60 -147
View File
@@ -4,7 +4,8 @@ import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../
import type { TimelineElement } from "../../player";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import { NLEPreview } from "./NLEPreview";
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
import { useCompositionStack } from "./useCompositionStack";
import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle,
@@ -101,19 +102,14 @@ export const NLELayout = memo(function NLELayout({
saveSeekPosition,
} = useTimelinePlayer();
// Reset timeline state when the project changes to prevent stale data from a
// previous project leaking into the new one.
// Reset timeline state when the project changes
const prevProjectIdRef = useRef(projectId);
if (prevProjectIdRef.current !== projectId) {
prevProjectIdRef.current = projectId;
// Only reset Zustand state during render (safe — pure state update).
// Imperative cleanup (RAF, intervals) happens in resetPlayer's store reset.
usePlayerStore.getState().reset();
}
// Save seek position before the Player component creates a new player
// on refreshKey change. The Player handles the actual reload via the
// dual-player crossfade; we just need to persist the current time.
// Save seek position before refresh
const prevRefreshKeyRef = useRef(refreshKey);
useEffect(() => {
if (refreshKey === prevRefreshKeyRef.current) return;
@@ -121,14 +117,61 @@ export const NLELayout = memo(function NLELayout({
saveSeekPosition();
}, [refreshKey, saveSeekPosition]);
// Wrap onIframeLoad to also notify parent of iframe ref
const onIframeLoad = useCallback(() => {
baseOnIframeLoad();
onIframeRef?.(iframeRef.current);
}, [baseOnIframeLoad, iframeRef, onIframeRef]);
// Composition ID → actual file path mapping, built from the raw index.html
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
const {
compositionStack,
updateCompositionStack,
handleNavigateComposition,
handleDrillDown: drillDown,
masterSeekRef,
compIdToSrc,
setCompIdToSrc,
} = useCompositionStack({
projectId,
activeCompositionPath,
onCompositionChange,
});
// Wrap handleDrillDown to also scan the iframe DOM for data-composition-src
const iframeRef_ = iframeRef;
const handleDrillDown = useCallback(
(element: TimelineElement) => {
if (!element.compositionSrc) return;
// Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown
const compId = element.id;
let resolvedPath = compIdToSrc.get(compId);
if (!resolvedPath) {
try {
const doc = iframeRef_.current?.contentDocument;
if (doc) {
const host = doc.querySelector(
`[data-composition-id="${compId}"][data-composition-src]`,
);
if (host) {
resolvedPath = host.getAttribute("data-composition-src") || undefined;
}
}
} catch {
/* cross-origin */
}
}
// Delegate with the resolved compositionSrc (may be same as original)
drillDown({
id: compId,
compositionSrc: resolvedPath ?? element.compositionSrc,
});
},
[compIdToSrc, drillDown, iframeRef_],
);
// Composition ID → file path map from raw index.html
const compIdToSrcRef = useRef(compIdToSrc);
compIdToSrcRef.current = compIdToSrc;
useMountEffect(() => {
fetch(`/api/projects/${projectId}/files/index.html`)
.then((r) => r.json())
@@ -150,12 +193,6 @@ export const NLELayout = memo(function NLELayout({
});
// Patch elements with compositionSrc whenever elements or compIdToSrc change.
// The runtime strips data-composition-src from the DOM after loading, so elements
// arrive without it. This bridges the gap using the map built from raw HTML.
// Map keys are composition IDs (e.g. "dark-intro"), while element IDs may be
// DOM IDs with suffixes (e.g. "dark-intro-host"), so we try multiple lookups.
const compIdToSrcRef = useRef(compIdToSrc);
compIdToSrcRef.current = compIdToSrc;
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (compIdToSrc.size === 0) return;
@@ -165,7 +202,6 @@ export const NLELayout = memo(function NLELayout({
let patched = false;
const updated = elements.map((el) => {
if (el.compositionSrc) return el;
// Try exact match, then strip common suffixes (-host, -comp, -layer)
const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
if (src) {
patched = true;
@@ -175,15 +211,12 @@ export const NLELayout = memo(function NLELayout({
});
return patched ? updated : null;
};
// Patch current elements immediately
const patched = patchElements(usePlayerStore.getState().elements);
if (patched) usePlayerStore.getState().setElements(patched);
// Subscribe for future element updates — use a flag to prevent re-entrant patching
let patching = false;
return usePlayerStore.subscribe((state, prev) => {
if (patching) return;
if (state.elements === prev.elements || state.elements.length === 0) return;
// Skip if all elements already have compositionSrc
if (state.elements.every((el) => el.compositionSrc)) return;
patching = true;
const result = patchElements(state.elements);
@@ -192,26 +225,6 @@ export const NLELayout = memo(function NLELayout({
});
}, [compIdToSrc]);
// Composition drill-down stack
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
]);
// Wrap setCompositionStack to auto-notify parent on composition change
const onCompositionChangeRef = useRef(onCompositionChange);
onCompositionChangeRef.current = onCompositionChange;
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
setCompositionStack((prev) => {
const next = typeof action === "function" ? action(prev) : action;
const prevId = prev[prev.length - 1]?.id;
const nextId = next[next.length - 1]?.id;
if (prevId !== nextId) {
queueMicrotask(() => onCompositionChangeRef.current?.(nextId === "master" ? null : nextId));
}
return next;
});
}, []);
// Resizable timeline height
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
const hasLoadedOnceRef = useRef(false);
@@ -226,11 +239,11 @@ export const NLELayout = memo(function NLELayout({
useEffect(() => {
onCompositionLoadingChangeParent?.(compositionLoading);
}, [compositionLoading, onCompositionLoadingChangeParent]);
const isTimelineVisible = timelineVisible ?? true;
const isDragging = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
// Current preview URL — derived from composition stack
const currentLevel = compositionStack[compositionStack.length - 1];
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
@@ -238,106 +251,6 @@ export const NLELayout = memo(function NLELayout({
onIframeRef?.(iframeRef.current);
}, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
// Save master seek position before drilling down so we can restore it on back-navigation.
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
const masterSeekRef = useRef(0);
// Drill-down: push a sub-composition onto the stack
const iframeRef_ = iframeRef; // stable ref for the callback
const handleDrillDown = useCallback(
(element: TimelineElement) => {
if (!element.compositionSrc) return;
// Save current master playback position for back-navigation
masterSeekRef.current = usePlayerStore.getState().currentTime;
saveSeekPosition();
// compositionSrc may be a full URL (from runtime manifest) or a relative path
// Extract the element's composition ID from its timeline ID
const compId = element.id;
// 1. Check compIdToSrc map (from index.html)
// 2. Scan the current iframe DOM for data-composition-src attribute
// 3. Fall back to stripping the compositionSrc to a relative path
let resolvedPath = compIdToSrc.get(compId);
if (!resolvedPath) {
try {
const doc = iframeRef_.current?.contentDocument;
if (doc) {
const host = doc.querySelector(
`[data-composition-id="${compId}"][data-composition-src]`,
);
if (host) {
resolvedPath = host.getAttribute("data-composition-src") || undefined;
}
}
} catch {
/* cross-origin */
}
}
if (!resolvedPath) {
// Strip full URL to relative path if needed
const src = element.compositionSrc;
const compMatch = src.match(/compositions\/.*\.html/);
resolvedPath = compMatch ? compMatch[0] : src;
}
usePlayerStore.getState().setElements([]);
// Toggle: if already viewing this composition, go back to parent (like Premiere)
updateCompositionStack((prev) => {
const currentId = prev[prev.length - 1].id;
if (currentId === resolvedPath && prev.length > 1) {
return prev.slice(0, -1);
}
// Extract a clean label from the path (strip directories and extension)
const label =
resolvedPath
.split("/")
.pop()
?.replace(/\.html$/, "") || resolvedPath;
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
return [...prev, { id: resolvedPath, label, previewUrl }];
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[projectId, compIdToSrc],
);
// Navigate back to a specific breadcrumb level
const handleNavigateComposition = useCallback((index: number) => {
// When going back to master (index 0), restore the saved master position
if (index === 0 && masterSeekRef.current > 0) {
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
}
saveSeekPosition();
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => prev.slice(0, index + 1));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Navigate to a composition when activeCompositionPath changes.
// Uses useEffect to ensure state updates happen after render commit,
// avoiding render-time mutations that React can swallow during batching.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (activeCompositionPath === "index.html") {
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => {
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
return [
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
{ id: activeCompositionPath, label, previewUrl },
];
});
} else if (!activeCompositionPath) {
usePlayerStore.getState().setElements([]);
}
}, [activeCompositionPath, projectId, updateCompositionStack]);
// Resize divider handlers
const handleDividerPointerDown = useCallback(
(e: React.PointerEvent) => {
@@ -380,6 +293,9 @@ export const NLELayout = memo(function NLELayout({
[compositionStack.length],
);
// Suppress TS unused-var warning for masterSeekRef (used inside useCompositionStack)
void masterSeekRef;
return (
<div
ref={containerRef}
@@ -387,7 +303,7 @@ export const NLELayout = memo(function NLELayout({
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{/* Preview + player controls — takes remaining space above timeline */}
{/* Preview + player controls */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 relative">
<NLEPreview
@@ -402,7 +318,6 @@ export const NLELayout = memo(function NLELayout({
/>
{previewOverlay}
</div>
{/* Player controls always visible, regardless of timeline state */}
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
{compositionStack.length > 1 && (
<CompositionBreadcrumb
@@ -427,15 +342,13 @@ export const NLELayout = memo(function NLELayout({
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
</div>
{/* Timeline section — fixed height, resizable */}
{/* Timeline section */}
<div
className="relative flex flex-col flex-shrink-0"
style={{ height: timelineH }}
aria-disabled={timelineDisabled || undefined}
>
{/* Timeline tracks */}
<div
// flex-col: toolbar takes natural height, Timeline fills remainder.
className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
@@ -0,0 +1,126 @@
// Composition drill-down stack management for NLELayout
import { useState, useCallback, useRef, useEffect } from "react";
import { usePlayerStore } from "../../player";
import type { CompositionLevel } from "./CompositionBreadcrumb";
interface UseCompositionStackOptions {
projectId: string;
activeCompositionPath?: string | null;
onCompositionChange?: (compositionPath: string | null) => void;
}
interface UseCompositionStackResult {
compositionStack: CompositionLevel[];
updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
handleNavigateComposition: (index: number) => void;
handleDrillDown: (element: { id: string; compositionSrc?: string }) => void;
masterSeekRef: React.MutableRefObject<number>;
compIdToSrc: Map<string, string>;
setCompIdToSrc: React.Dispatch<React.SetStateAction<Map<string, string>>>;
}
export function useCompositionStack({
projectId,
activeCompositionPath,
onCompositionChange,
}: UseCompositionStackOptions): UseCompositionStackResult {
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
]);
const onCompositionChangeRef = useRef(onCompositionChange);
onCompositionChangeRef.current = onCompositionChange;
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
setCompositionStack((prev) => {
const next = typeof action === "function" ? action(prev) : action;
const id = next[next.length - 1]?.id;
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
return next;
});
}, []);
const masterSeekRef = useRef(0);
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
const compIdToSrcRef = useRef(compIdToSrc);
compIdToSrcRef.current = compIdToSrc;
const handleNavigateComposition = useCallback(
(index: number) => {
if (index === 0 && masterSeekRef.current > 0) {
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
}
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => prev.slice(0, index + 1));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleDrillDown = useCallback(
(element: { id: string; compositionSrc?: string }) => {
if (!element.compositionSrc) return;
masterSeekRef.current = usePlayerStore.getState().currentTime;
const compId = element.id;
let resolvedPath = compIdToSrcRef.current.get(compId);
if (!resolvedPath) {
const src = element.compositionSrc;
const compMatch = src.match(/compositions\/.*\.html/);
resolvedPath = compMatch ? compMatch[0] : src;
}
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => {
const currentId = prev[prev.length - 1].id;
if (currentId === resolvedPath && prev.length > 1) {
return prev.slice(0, -1);
}
const label =
resolvedPath
.split("/")
.pop()
?.replace(/\.html$/, "") || resolvedPath;
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
return [...prev, { id: resolvedPath, label, previewUrl }];
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[projectId],
);
// Navigate to a composition when activeCompositionPath changes.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (activeCompositionPath === "index.html") {
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => {
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
return [
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
{ id: activeCompositionPath, label, previewUrl },
];
});
} else if (!activeCompositionPath) {
usePlayerStore.getState().setElements([]);
}
}, [activeCompositionPath, projectId, updateCompositionStack]);
return {
compositionStack,
updateCompositionStack,
handleNavigateComposition,
handleDrillDown,
masterSeekRef,
compIdToSrc,
setCompIdToSrc,
};
}