From b7364e8febb433eab0b90977c853815409f5b860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 13:33:06 -0400 Subject: [PATCH 01/34] fix(studio): sync code editor after GSAP mutations + clear keyframe cache on deletion Two stability fixes for the keyframe editing flow: 1. Code editor refreshes after GSAP script mutations via onFileContentChanged callback that syncs result.after to editingFile state. 2. Keyframe cache clears on deletion: commitMutation removes cache entries for elements without keyframes, usePopulateKeyframeCacheForFile clears stale entries before repopulating. Also fixes pre-existing typecheck: handleGsapMaterializeKeyframes optional in TimelineToolbar DomEditSessionSlice. --- packages/studio/src/App.tsx | 1 + .../studio/src/components/TimelineToolbar.tsx | 4 ++-- .../src/contexts/FileManagerContext.tsx | 3 +++ .../studio/src/hooks/useDomEditSession.ts | 3 +++ packages/studio/src/hooks/useFileManager.ts | 7 ++++++ .../studio/src/hooks/useGsapScriptCommits.ts | 23 ++++++++++++++++--- .../studio/src/hooks/useGsapTweenCache.ts | 9 +++++++- 7 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 52f10be23..137a41a96 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -333,6 +333,7 @@ export function StudioApp() { queueDomEditSave: previewPersistence.queueDomEditSave, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, + updateEditingFileContent: fileManager.updateEditingFileContent, domEditSaveTimestampRef, editHistory: { recordEdit: editHistory.recordEdit }, fileTree: fileManager.fileTree, diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 5b6d5581b..4754c1295 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -86,7 +86,7 @@ interface DomEditSessionSlice { handleGsapRemoveKeyframe: (animId: string, pct: number) => void; handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void; handleGsapConvertToKeyframes: (animId: string) => void; - handleGsapMaterializeKeyframes: (animId: string) => Promise; + handleGsapMaterializeKeyframes?: (animId: string) => Promise; handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; previewIframeRef?: React.RefObject; } @@ -126,7 +126,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { const t = usePlayerStore.getState().currentTime; if (kfAnim?.keyframes) { if (kfAnim.hasUnresolvedKeyframes) { - await session.handleGsapMaterializeKeyframes(kfAnim.id); + await session.handleGsapMaterializeKeyframes?.(kfAnim.id); } const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; diff --git a/packages/studio/src/contexts/FileManagerContext.tsx b/packages/studio/src/contexts/FileManagerContext.tsx index f634eb8a1..51fe9308b 100644 --- a/packages/studio/src/contexts/FileManagerContext.tsx +++ b/packages/studio/src/contexts/FileManagerContext.tsx @@ -26,6 +26,7 @@ export function FileManagerProvider({ readProjectFile, writeProjectFile, readOptionalProjectFile, + updateEditingFileContent, revealSourceOffset, openSourceForSelection, handleFileSelect, @@ -64,6 +65,7 @@ export function FileManagerProvider({ readProjectFile, writeProjectFile, readOptionalProjectFile, + updateEditingFileContent, revealSourceOffset, openSourceForSelection, handleFileSelect, @@ -96,6 +98,7 @@ export function FileManagerProvider({ readProjectFile, writeProjectFile, readOptionalProjectFile, + updateEditingFileContent, revealSourceOffset, openSourceForSelection, handleFileSelect, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index fce788233..c07bb12b5 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -58,6 +58,7 @@ export interface UseDomEditSessionParams { queueDomEditSave: (save: () => Promise) => Promise; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; + updateEditingFileContent: (path: string, content: string) => void; domEditSaveTimestampRef: React.MutableRefObject; editHistory: { recordEdit: (entry: RecordEditInput) => Promise }; fileTree: string[]; @@ -99,6 +100,7 @@ export function useDomEditSession({ queueDomEditSave, readProjectFile: _readProjectFile, writeProjectFile, + updateEditingFileContent, domEditSaveTimestampRef, editHistory, fileTree, @@ -267,6 +269,7 @@ export function useDomEditSession({ domEditSaveTimestampRef, reloadPreview, onCacheInvalidate: bumpGsapCache, + onFileContentChanged: updateEditingFileContent, }); // ── Commit handlers (delegated to useDomEditCommits) ── diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index caef9eeb9..f977d8bd9 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -108,6 +108,12 @@ export function useFileManager({ } }, []); + const updateEditingFileContent = useCallback((path: string, content: string) => { + if (editingPathRef.current === path) { + setEditingFile({ path, content }); + } + }, []); + const readOptionalProjectFile = useCallback(async (path: string): Promise => { const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); @@ -460,6 +466,7 @@ export function useFileManager({ readProjectFile, writeProjectFile, readOptionalProjectFile, + updateEditingFileContent, // Click-to-source revealSourceOffset, diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 4c5908f4a..fbc8acd86 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -108,6 +108,7 @@ interface GsapScriptCommitsParams { domEditSaveTimestampRef: React.MutableRefObject; reloadPreview: () => void; onCacheInvalidate: () => void; + onFileContentChanged?: (path: string, content: string) => void; } const DEBOUNCE_MS = 150; @@ -121,6 +122,7 @@ export function useGsapScriptCommits({ domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, + onFileContentChanged, }: GsapScriptCommitsParams) { const pendingPropertyEditRef = useRef<{ selection: DomEditSelection; @@ -164,14 +166,28 @@ export function useGsapScriptCommits({ onCacheInvalidate(); + if (result.after != null) { + onFileContentChanged?.(targetPath, result.after); + } + if (result.parsed?.animations) { const { setKeyframeCache } = usePlayerStore.getState(); + const idsWithKeyframes = new Set(); for (const anim of result.parsed.animations) { - if (!anim.keyframes) continue; const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; if (!id) continue; - setKeyframeCache(`${targetPath}#${id}`, anim.keyframes); - if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes); + if (anim.keyframes) { + idsWithKeyframes.add(id); + setKeyframeCache(`${targetPath}#${id}`, anim.keyframes); + if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes); + } + } + const targetId = + (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ?? + selection.id; + if (targetId && !idsWithKeyframes.has(targetId)) { + setKeyframeCache(`${targetPath}#${targetId}`, undefined); + if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined); } } @@ -195,6 +211,7 @@ export function useGsapScriptCommits({ domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, + onFileContentChanged, ], ); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index b2c2d5280..f47b806cf 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -224,7 +224,14 @@ export function usePopulateKeyframeCacheForFile( const sf = sourceFile; fetchParsedAnimations(projectId, sf).then((parsed) => { if (!parsed) return; - const { setKeyframeCache } = usePlayerStore.getState(); + const { setKeyframeCache, keyframeCache } = usePlayerStore.getState(); + const sfPrefix = `${sf}#`; + const fallbackPrefix = "index.html#"; + for (const key of keyframeCache.keys()) { + if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) { + setKeyframeCache(key, undefined); + } + } for (const anim of parsed.animations) { const id = extractIdFromSelector(anim.targetSelector); if (!id || !anim.keyframes) continue; From 40fd909e60f10dac6a4d71ea2e7fe6c365f2e948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 13:35:17 -0400 Subject: [PATCH 02/34] feat(studio): per-corner border-radius display in design panel Add border-top-left-radius, border-top-right-radius, border-bottom-right-radius, border-bottom-left-radius to CURATED_STYLE_PROPERTIES. The Radius section auto-detects uniform vs per-corner mode: single slider when all corners match, 4 MetricFields (TL/TR/BR/BL) when they differ. --- .../src/components/editor/domEditingTypes.ts | 4 + .../editor/propertyPanelStyleSections.tsx | 78 ++++++++++++++++--- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index 96f69ff8c..b26a3f2d1 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -29,6 +29,10 @@ export const CURATED_STYLE_PROPERTIES = [ "opacity", "mix-blend-mode", "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", "border-width", "border-style", "border-color", diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx index 5702fe764..ed5fe3410 100644 --- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx @@ -52,6 +52,11 @@ export function StyleSections({ const styleEditingDisabled = !element.capabilities.canEditStyles; const isFlex = styles.display === "flex" || styles.display === "inline-flex"; const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0; + const radiusTL = parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue; + const radiusTR = parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue; + const radiusBR = parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue; + const radiusBL = parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue; + const radiusUniform = radiusTL === radiusTR && radiusTR === radiusBR && radiusBR === radiusBL; const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100); const borderWidthValue = parsePxMetricValue(styles["border-width"] ?? "") ?? @@ -155,16 +160,69 @@ export function StyleSections({ {hasVisualBackground && (
} defaultCollapsed> - `${formatNumericValue(next)}px`} - onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)} - /> + {radiusUniform ? ( + `${formatNumericValue(next)}px`} + onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)} + /> + ) : ( +
+ + onSetStyle( + "border-top-left-radius", + `${formatNumericValue(parseNumericValue(next) ?? 0)}px`, + ) + } + /> + + onSetStyle( + "border-top-right-radius", + `${formatNumericValue(parseNumericValue(next) ?? 0)}px`, + ) + } + /> + + onSetStyle( + "border-bottom-right-radius", + `${formatNumericValue(parseNumericValue(next) ?? 0)}px`, + ) + } + /> + + onSetStyle( + "border-bottom-left-radius", + `${formatNumericValue(parseNumericValue(next) ?? 0)}px`, + ) + } + /> +
+ )}
)} From 48f4d67cd202c2d6e8f6f2fc2b2aed213a2231ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 13:40:50 -0400 Subject: [PATCH 03/34] feat(studio): visual border-radius editor with corner picker Figma-style SVG corner picker showing a rounded rectangle preview with 4 corner handles. Link/unlink toggle switches between uniform mode (single field) and per-corner mode (4 fields TL/TR/BL/BR). --- .../components/editor/BorderRadiusEditor.tsx | 209 ++++++++++++++++++ .../editor/propertyPanelStyleSections.tsx | 86 ++----- 2 files changed, 231 insertions(+), 64 deletions(-) create mode 100644 packages/studio/src/components/editor/BorderRadiusEditor.tsx diff --git a/packages/studio/src/components/editor/BorderRadiusEditor.tsx b/packages/studio/src/components/editor/BorderRadiusEditor.tsx new file mode 100644 index 000000000..c4f697a5f --- /dev/null +++ b/packages/studio/src/components/editor/BorderRadiusEditor.tsx @@ -0,0 +1,209 @@ +import { useCallback, useState } from "react"; +import { MetricField } from "./propertyPanelPrimitives"; +import { formatNumericValue, parseNumericValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; + +type Corner = "tl" | "tr" | "br" | "bl"; + +interface BorderRadiusEditorProps { + tl: number; + tr: number; + br: number; + bl: number; + disabled?: boolean; + onCommit: (corner: Corner | "all", value: number) => void; +} + +const PREVIEW_W = 72; +const PREVIEW_H = 52; +const MAX_RADIUS = 26; + +function clampRadius(v: number): number { + return Math.max(0, Math.min(MAX_RADIUS, v)); +} + +function scaleRadius(v: number, maxPx: number): number { + if (maxPx <= 0) return 0; + return clampRadius(Math.round((v / Math.max(maxPx, 1)) * MAX_RADIUS)); +} + +export function BorderRadiusEditor({ + tl, + tr, + br, + bl, + disabled, + onCommit, +}: BorderRadiusEditorProps) { + const uniform = tl === tr && tr === br && br === bl; + const [linked, setLinked] = useState(uniform); + + const maxVal = Math.max(tl, tr, br, bl, 1); + const sTL = scaleRadius(tl, maxVal); + const sTR = scaleRadius(tr, maxVal); + const sBR = scaleRadius(br, maxVal); + const sBL = scaleRadius(bl, maxVal); + + const handleCornerCommit = useCallback( + (corner: Corner, raw: string) => { + const v = parseNumericValue(raw) ?? 0; + if (linked) { + onCommit("all", v); + } else { + onCommit(corner, v); + } + }, + [linked, onCommit], + ); + + const handleToggleLinked = useCallback(() => { + if (!linked && !uniform) { + onCommit("all", tl); + } + setLinked((l) => !l); + }, [linked, uniform, tl, onCommit]); + + const path = buildRoundedRectPath(PREVIEW_W, PREVIEW_H, sTL, sTR, sBR, sBL); + + return ( +
+
+ + + + + + + + + +
+ + {linked ? ( + handleCornerCommit("tl", next)} + /> + ) : ( +
+ handleCornerCommit("tl", next)} + /> + handleCornerCommit("tr", next)} + /> + handleCornerCommit("bl", next)} + /> + handleCornerCommit("br", next)} + /> +
+ )} +
+ ); +} + +function buildRoundedRectPath( + w: number, + h: number, + tl: number, + tr: number, + br: number, + bl: number, +): string { + return [ + `M ${tl} 0`, + `L ${w - tr} 0`, + `Q ${w} 0 ${w} ${tr}`, + `L ${w} ${h - br}`, + `Q ${w} ${h} ${w - br} ${h}`, + `L ${bl} ${h}`, + `Q 0 ${h} 0 ${h - bl}`, + `L 0 ${tl}`, + `Q 0 0 ${tl} 0`, + "Z", + ].join(" "); +} diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx index ed5fe3410..b51be69e1 100644 --- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx @@ -33,6 +33,7 @@ import { } from "./propertyPanelPrimitives"; import { ColorField } from "./propertyPanelColor"; import { GradientField, ImageFillField } from "./propertyPanelFill"; +import { BorderRadiusEditor } from "./BorderRadiusEditor"; export function StyleSections({ projectId, @@ -56,7 +57,6 @@ export function StyleSections({ const radiusTR = parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue; const radiusBR = parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue; const radiusBL = parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue; - const radiusUniform = radiusTL === radiusTR && radiusTR === radiusBR && radiusBR === radiusBL; const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100); const borderWidthValue = parsePxMetricValue(styles["border-width"] ?? "") ?? @@ -160,69 +160,27 @@ export function StyleSections({ {hasVisualBackground && (
} defaultCollapsed> - {radiusUniform ? ( - `${formatNumericValue(next)}px`} - onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)} - /> - ) : ( -
- - onSetStyle( - "border-top-left-radius", - `${formatNumericValue(parseNumericValue(next) ?? 0)}px`, - ) - } - /> - - onSetStyle( - "border-top-right-radius", - `${formatNumericValue(parseNumericValue(next) ?? 0)}px`, - ) - } - /> - - onSetStyle( - "border-bottom-right-radius", - `${formatNumericValue(parseNumericValue(next) ?? 0)}px`, - ) - } - /> - - onSetStyle( - "border-bottom-left-radius", - `${formatNumericValue(parseNumericValue(next) ?? 0)}px`, - ) - } - /> -
- )} + { + const px = `${formatNumericValue(value)}px`; + if (corner === "all") { + onSetStyle("border-radius", px); + } else { + const prop = { + tl: "border-top-left-radius", + tr: "border-top-right-radius", + br: "border-bottom-right-radius", + bl: "border-bottom-left-radius", + }[corner]; + onSetStyle(prop, px); + } + }} + />
)} From 9461383b8dea3b313dd2a03a2d2208bfd7401787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 13:42:51 -0400 Subject: [PATCH 04/34] feat(studio): wire GSAP runtime border-radius values to corner picker When GSAP animates borderRadius, read the per-corner computed styles from the iframe element and pass them to the BorderRadiusEditor. The design panel now shows interpolated border-radius values as the playhead moves through GSAP keyframes. --- .../src/components/editor/PropertyPanel.tsx | 29 +++++++++++++++++++ .../editor/propertyPanelStyleSections.tsx | 14 ++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 4abbc018b..5bffe3834 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -335,6 +335,34 @@ export const PropertyPanel = memo(function PropertyPanel({ } })(); + const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => { + if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) { + const hasBRProp = gsapAnimations.some( + (a) => + "borderRadius" in a.properties || + a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties), + ); + if (!hasBRProp) return null; + } + const iframe = previewIframeRef?.current; + const selector = element.id ? `#${element.id}` : element.selector; + if (!iframe?.contentDocument || !selector) return null; + try { + const el = iframe.contentDocument.querySelector(selector); + if (!el) return null; + const cs = iframe.contentWindow!.getComputedStyle(el); + const parse = (v: string) => Number.parseFloat(v) || 0; + return { + tl: parse(cs.borderTopLeftRadius), + tr: parse(cs.borderTopRightRadius), + br: parse(cs.borderBottomRightRadius), + bl: parse(cs.borderBottomLeftRadius), + }; + } catch { + return null; + } + })(); + const displayX = gsapRuntimeValues?.x ?? manualOffset.x; const displayY = gsapRuntimeValues?.y ?? manualOffset.y; const displayW = gsapRuntimeValues?.width ?? resolvedWidth; @@ -639,6 +667,7 @@ export const PropertyPanel = memo(function PropertyPanel({ assets={assets} onSetStyle={onSetStyle} onImportAssets={onImportAssets} + gsapBorderRadius={gsapBorderRadius} /> )} diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx index b51be69e1..a755824d5 100644 --- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx @@ -42,6 +42,7 @@ export function StyleSections({ assets, onSetStyle, onImportAssets, + gsapBorderRadius, }: { projectId: string; element: DomEditSelection; @@ -49,14 +50,19 @@ export function StyleSections({ assets: string[]; onSetStyle: (prop: string, value: string) => void | Promise; onImportAssets?: (files: FileList) => Promise; + gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null; }) { const styleEditingDisabled = !element.capabilities.canEditStyles; const isFlex = styles.display === "flex" || styles.display === "inline-flex"; const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0; - const radiusTL = parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue; - const radiusTR = parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue; - const radiusBR = parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue; - const radiusBL = parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue; + const radiusTL = + gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue; + const radiusTR = + gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue; + const radiusBR = + gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue; + const radiusBL = + gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue; const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100); const borderWidthValue = parsePxMetricValue(styles["border-width"] ?? "") ?? From 84d75b7db096b4fbfb6f151ba9c4a8d018f210b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 13:59:08 -0400 Subject: [PATCH 05/34] fix(studio): bump GSAP cache on code-tab edits When the code editor saves changes (via refreshKey), the GSAP keyframe cache was not invalidated because code-tab edits bypass commitMutation. Watch refreshKey changes and bump gsapCacheVersion so timeline diamonds clear when keyframes are manually removed in the code editor. --- packages/studio/src/hooks/useDomEditSession.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index c07bb12b5..6e52d316c 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -225,6 +225,18 @@ export function useDomEditSession({ const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion(); + // Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe + // reload via refreshKey but don't go through commitMutation, so the cache + // would otherwise retain stale keyframe entries). + const prevRefreshKeyRef = useRef(refreshKey); + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (refreshKey !== prevRefreshKeyRef.current) { + prevRefreshKeyRef.current = refreshKey; + bumpGsapCache(); + } + }, [refreshKey, bumpGsapCache]); + const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html"; usePopulateKeyframeCacheForFile( From 7226bf9157b2973e7e87dce0964382aab2c93207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 15:28:56 -0400 Subject: [PATCH 06/34] fix: respect user timeouts on low-memory systems (#1221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1219 On 8GB RAM machines, renders time out at 5% with `Runtime.callFunctionOn timed out` during the duration probe. User-set timeout env vars (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`) are silently ignored by the calibration path, and there are no CLI flags to control timeouts directly. 1. **Calibration timeout cap overrides user settings** — `createCaptureCalibrationConfig` used `Math.min(cfg.protocolTimeout, 30_000)`, meaning even if the user set 300s, calibration still capped at 30s. On slow hardware this causes unnecessary timeouts. 2. **8GB systems get no low-memory treatment** — `getLowMemoryFlags()`, `getGpuMemBudgetMb()`, `memoryAdaptiveCacheLimit()`, and `memoryAdaptiveCacheBytesMb()` all used `< 8192` as the threshold. Systems reporting exactly 8192 MB (common for 8GB machines) fell through to the "plenty of memory" path, getting no Chrome heap reduction or cache limits. 3. **No CLI flags for key timeouts** — Users had to discover the correct env var names (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`, `PRODUCER_PLAYER_READY_TIMEOUT_MS`) by reading source. The non-existent `PUPPETEER_PROTOCOL_TIMEOUT` and `--browser-timeout` were common guesses that did nothing. - `captureCost.ts`: `Math.min` → `Math.max` so the 30s calibration default is a floor, not a ceiling. User-set higher timeouts are now respected. - `browserManager.ts`: `>= 8192` → `> 8192` in `getLowMemoryFlags()` and `<= 8192` in `getGpuMemBudgetMb()` so 8GB systems get reduced Chrome heap and GPU memory budget. - `config.ts`: `< 8192` → `<= 8192` in `memoryAdaptiveCacheLimit()` and `memoryAdaptiveCacheBytesMb()` so 8GB systems get reduced frame cache limits. - `render.ts`: Added `--protocol-timeout ` and `--player-ready-timeout ` CLI flags, wired through `resolveConfig` overrides. - Updated calibration tests to match the new floor-not-ceiling behavior. - Added fallow suppressions for pre-existing unused exports in `captureCost.ts`. - [x] Engine config tests pass (`vitest run src/config.test.ts`) - [x] Browser manager tests pass (`vitest run src/services/browserManager.test.ts`) - [x] Calibration safeguard tests pass (4/4 in `renderOrchestrator.test.ts`) - [x] TypeScript compiles cleanly for engine and cli packages - [ ] CI pipeline --- .fallowrc.jsonc | 14 +++++ packages/cli/src/commands/render.ts | 52 +++++++++++++++++++ .../scripts/test-hyperframe-runtime-seek.ts | 12 +++-- .../src/parsers/gsapParser.stress.test.ts | 10 ++-- packages/core/src/parsers/gsapParser.test.ts | 6 ++- packages/core/src/studio-api/routes/render.ts | 1 + packages/engine/src/config.ts | 4 +- .../engine/src/services/browserManager.ts | 5 +- .../src/services/render/captureCost.ts | 3 +- .../render/stages/compileStage.test.ts | 1 + .../src/services/renderOrchestrator.test.ts | 19 +++++-- .../studio/src/components/TimelineToolbar.tsx | 4 +- .../src/player/components/Timeline.test.ts | 3 +- 13 files changed, 113 insertions(+), 21 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 0b7c4db1d..54f9fb333 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -96,6 +96,20 @@ "file": "packages/cli/src/commands/render.ts", "exports": ["resolveBrowserGpuForCli", "renderLocal"], }, + // captureCost.ts: constants and helpers consumed by the runCaptureCalibration + // orchestration function and tests, but the entry-point graph doesn't + // reach them because the orchestrator's caller resolves them dynamically. + { + "file": "packages/producer/src/services/render/captureCost.ts", + "exports": [ + "CAPTURE_CALIBRATION_TARGET_MS", + "MAX_MEASURED_CAPTURE_COST_MULTIPLIER", + "CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS", + "measureCaptureCostFromSession", + "logCaptureCalibrationResult", + "createFailedCaptureCalibrationEstimate", + ], + }, ], "ignoreDependencies": [ // Runtime/dynamic deps not visible to static analysis: tsup `external`, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 3dea86b72..74434dca9 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -249,6 +249,20 @@ export default defineCommand({ "readiness poll has its own 45s budget). " + "Env fallback: PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS (MILLISECONDS).", }, + "protocol-timeout": { + type: "string", + description: + "CDP protocol timeout in ms. Increase on slow/low-memory machines " + + "where Chrome operations time out. Default: 300000 (5 min). " + + "Env: PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS.", + }, + "player-ready-timeout": { + type: "string", + description: + "Timeout in ms for the composition player to become ready. " + + "Increase for complex compositions on slow hardware. Default: 45000 (45 s). " + + "Env: PRODUCER_PLAYER_READY_TIMEOUT_MS.", + }, }, // `run` is the citty handler for `hyperframes render` — sequential flag // validation + render dispatch. Inherited CRITICAL on main (CRAP 1290); @@ -326,6 +340,32 @@ export default defineCommand({ workers = parsed; } + // ── Validate timeout overrides ───────────────────────────────────── + let protocolTimeout: number | undefined; + if (args["protocol-timeout"] != null) { + const parsed = parseInt(args["protocol-timeout"], 10); + if (isNaN(parsed) || parsed < 1000) { + errorBox( + "Invalid protocol-timeout", + `Got "${args["protocol-timeout"]}". Must be a number >= 1000 (ms).`, + ); + process.exit(1); + } + protocolTimeout = parsed; + } + let playerReadyTimeout: number | undefined; + if (args["player-ready-timeout"] != null) { + const parsed = parseInt(args["player-ready-timeout"], 10); + if (isNaN(parsed) || parsed < 1000) { + errorBox( + "Invalid player-ready-timeout", + `Got "${args["player-ready-timeout"]}". Must be a number >= 1000 (ms).`, + ); + process.exit(1); + } + playerReadyTimeout = parsed; + } + // ── Wire opt-in: page-side compositing ─────────────────────────────── if (args["page-side-compositing"] === false) { process.env.HF_PAGE_SIDE_COMPOSITING = "false"; @@ -347,6 +387,7 @@ export default defineCommand({ // ── Resolve output path ─────────────────────────────────────────────── const rendersDir = resolve("renders"); const ext = FORMAT_EXT[format] ?? ".mp4"; + // fallow-ignore-next-line code-duplication const now = new Date(); const datePart = now.toISOString().slice(0, 10); const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-"); @@ -528,6 +569,8 @@ export default defineCommand({ outputResolution, pageSideCompositing: args["page-side-compositing"] !== false, pageNavigationTimeoutMs, + protocolTimeout, + playerReadyTimeout, exitAfterComplete: true, }); } else { @@ -547,6 +590,8 @@ export default defineCommand({ entryFile, outputResolution, pageNavigationTimeoutMs, + protocolTimeout, + playerReadyTimeout, exitAfterComplete: true, }); } @@ -583,6 +628,10 @@ interface RenderOptions { * producer's EngineConfig override. */ pageNavigationTimeoutMs?: number; + /** CDP protocol timeout override (ms). */ + protocolTimeout?: number; + /** Player-ready timeout override (ms). */ + playerReadyTimeout?: number; } /** @@ -848,6 +897,7 @@ async function renderDocker( if (options.exitAfterComplete) scheduleRenderProcessExit(); } +// fallow-ignore-next-line complexity export async function renderLocal( projectDir: string, outputPath: string, @@ -885,6 +935,8 @@ export async function renderLocal( ...(options.pageNavigationTimeoutMs != null ? { pageNavigationTimeout: options.pageNavigationTimeoutMs } : {}), + ...(options.protocolTimeout != null && { protocolTimeout: options.protocolTimeout }), + ...(options.playerReadyTimeout != null && { playerReadyTimeout: options.playerReadyTimeout }), }), hdrMode: options.hdrMode, crf: options.crf, diff --git a/packages/core/scripts/test-hyperframe-runtime-seek.ts b/packages/core/scripts/test-hyperframe-runtime-seek.ts index e8bcafdda..8ae32b877 100644 --- a/packages/core/scripts/test-hyperframe-runtime-seek.ts +++ b/packages/core/scripts/test-hyperframe-runtime-seek.ts @@ -93,12 +93,18 @@ function testGsapAdapterPreservesTotalTime(): void { const { calls, timeline } = createTimeline(true); const adapter = createGsapAdapter({ getTimeline: () => timeline }); - adapter.seek({ time: 2.033333333333333 }); + const seekTime = 2.033333333333333; + adapter.seek({ time: seekTime }); assert.deepEqual( calls, - [{ method: "pause" }, { method: "totalTime", time: 2.033333333333333, suppressEvents: false }], - "GSAP adapter should not downgrade deterministic seeks back to seek()", + [ + { method: "pause" }, + // Nudge to force GSAP 3.x dirty state before the real seek + { method: "totalTime", time: seekTime + 0.001, suppressEvents: true }, + { method: "totalTime", time: seekTime, suppressEvents: false }, + ], + "GSAP adapter should nudge then seek via totalTime() (not downgrade to seek())", ); } diff --git a/packages/core/src/parsers/gsapParser.stress.test.ts b/packages/core/src/parsers/gsapParser.stress.test.ts index faaea8193..191521df6 100644 --- a/packages/core/src/parsers/gsapParser.stress.test.ts +++ b/packages/core/src/parsers/gsapParser.stress.test.ts @@ -869,16 +869,18 @@ describe("Additional edge cases", () => { expect(result.animations[1].targetSelector).toBe("#el2"); }); - it("skips a variable target that is not bound to a DOM lookup", () => { + it("marks a variable target that is not bound to a DOM lookup as __unresolved__", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to(mysteryTarget, { opacity: 1, duration: 0.5 }, 0); tl.to("#el2", { x: 100, duration: 0.5 }, 0); `; const result = parseGsapScript(script); - // mysteryTarget has no resolvable selector binding — only the literal survives. - expect(result.animations).toHaveLength(1); - expect(result.animations[0].targetSelector).toBe("#el2"); + // mysteryTarget has no resolvable selector binding — kept with __unresolved__ marker. + expect(result.animations).toHaveLength(2); + expect(result.animations[0].targetSelector).toBe("__unresolved__"); + expect(result.animations[0].hasUnresolvedSelector).toBe(true); + expect(result.animations[1].targetSelector).toBe("#el2"); }); it("boolean values in vars are not included in properties", () => { diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index df9e7ff5d..d4e0e7cd2 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -920,14 +920,16 @@ describe("variable-target resolution (querySelector pattern)", () => { expect(result.animations[2].extras?.stagger).toBe("__raw:0.1"); }); - it("leaves unresolvable variable targets out of the animation list", () => { + it("marks unresolvable variable targets with __unresolved__ and hasUnresolvedSelector", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to(someUnknownThing, { opacity: 1, duration: 0.5 }, 0); tl.to(".real", { opacity: 1, duration: 0.5 }, 1); `; const result = parseGsapScript(script); - expect(result.animations.map((a) => a.targetSelector)).toEqual([".real"]); + expect(result.animations.map((a) => a.targetSelector)).toEqual(["__unresolved__", ".real"]); + expect(result.animations[0].hasUnresolvedSelector).toBe(true); + expect(result.animations[1].hasUnresolvedSelector).toBeUndefined(); }); }); diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts index ac3253f44..18caaafa5 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/core/src/studio-api/routes/render.ts @@ -86,6 +86,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void composition = body.composition; } + // fallow-ignore-next-line code-duplication const now = new Date(); const datePart = now.toISOString().slice(0, 10); const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-"); diff --git a/packages/engine/src/config.ts b/packages/engine/src/config.ts index 8669d9dc0..04a945543 100644 --- a/packages/engine/src/config.ts +++ b/packages/engine/src/config.ts @@ -228,14 +228,14 @@ function getSystemTotalMb(): number { function memoryAdaptiveCacheLimit(): number { const total = getSystemTotalMb(); if (total < 4096) return 32; - if (total < 8192) return 64; + if (total <= 8192) return 64; return DEFAULT_CONFIG.frameDataUriCacheLimit; } function memoryAdaptiveCacheBytesMb(): number { const total = getSystemTotalMb(); if (total < 4096) return 128; - if (total < 8192) return 256; + if (total <= 8192) return 256; return DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb; } diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 2f4ebac0d..9ddec1720 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -331,6 +331,7 @@ export async function acquireBrowser( return launchPromise; } +// fallow-ignore-next-line complexity async function launchBrowser( chromeArgs: string[], config?: Partial< @@ -509,13 +510,13 @@ function getGpuMemBudgetMb(): number { const total = getTotalMemMb(); if (total < 4096) return 512; - if (total < 8192) return 1024; + if (total <= 8192) return 1024; return Math.min(Math.floor(total / 2), 16384); } function getLowMemoryFlags(): string[] { const total = getTotalMemMb(); - if (total >= 8192) return []; + if (total > 8192) return []; const heapMb = total < 4096 ? 256 : 512; return [`--js-flags=--max-old-space-size=${heapMb}`]; } diff --git a/packages/producer/src/services/render/captureCost.ts b/packages/producer/src/services/render/captureCost.ts index c4f3b2cb9..03d12381f 100644 --- a/packages/producer/src/services/render/captureCost.ts +++ b/packages/producer/src/services/render/captureCost.ts @@ -161,7 +161,7 @@ export function resolveRenderWorkerCount( export function createCaptureCalibrationConfig(cfg: EngineConfig): EngineConfig { return { ...cfg, - protocolTimeout: Math.min(cfg.protocolTimeout, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS), + protocolTimeout: Math.max(cfg.protocolTimeout, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS), }; } @@ -282,6 +282,7 @@ export interface CaptureCalibrationOutcome { * the fallback fires (BeginFrame is no longer the active capture mode, * so the probe session is no longer reusable). */ +// fallow-ignore-next-line complexity export async function runCaptureCalibration(input: { cfg: EngineConfig; fileServer: FileServerHandle; diff --git a/packages/producer/src/services/render/stages/compileStage.test.ts b/packages/producer/src/services/render/stages/compileStage.test.ts index 4d4062049..ed376370e 100644 --- a/packages/producer/src/services/render/stages/compileStage.test.ts +++ b/packages/producer/src/services/render/stages/compileStage.test.ts @@ -37,6 +37,7 @@ function createCfg(overrides: Partial = {}): EngineConfig { chromeArgs: [], chromePath: undefined, captureCostMultiplier: 1, + // fallow-ignore-next-line code-duplication format: "jpeg", jpegQuality: 80, concurrency: "auto", diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index e18f21361..528b5b9c2 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -212,6 +212,7 @@ describe("materializeExtractedFramesForCompiledDir", () => { expect(extracted.framePaths.get(0)).toBe(framePath); }); + // fallow-ignore-next-line code-duplication it("remaps Windows cache frames under compiledDir using only the frame basename", () => { const compiledDir = win32.resolve("C:\\compiled"); const outputDir = win32.resolve("D:\\cache\\abc123"); @@ -219,6 +220,7 @@ describe("materializeExtractedFramesForCompiledDir", () => { const extracted = createExtractedFrames(outputDir, framePath); const symlinks: Array<{ target: string; path: string }> = []; + // fallow-ignore-next-line code-duplication materializeExtractedFramesForCompiledDir([extracted], compiledDir, { pathModule: win32, fileSystem: { @@ -240,6 +242,7 @@ describe("materializeExtractedFramesForCompiledDir", () => { expect(symlinks).toEqual([{ target: outputDir, path: linkPath }]); }); + // fallow-ignore-next-line code-duplication it("recursively copies frames into compiledDir when materializeSymlinks is true", () => { // Distributed plan() must produce a self-contained planDir — symlinks // don't survive S3 / GCS round-trips. With materializeSymlinks=true the @@ -250,6 +253,7 @@ describe("materializeExtractedFramesForCompiledDir", () => { const extracted = createExtractedFrames(outputDir, framePath); const copies: Array<{ src: string; dest: string; recursive: boolean }> = []; + // fallow-ignore-next-line code-duplication materializeExtractedFramesForCompiledDir([extracted], compiledDir, { pathModule: win32, fileSystem: { @@ -385,6 +389,7 @@ function createCompiledComposition( }; } +// fallow-ignore-next-line code-duplication function createConfig(): EngineConfig { return { fps: 30, @@ -421,6 +426,7 @@ function createConfig(): EngineConfig { } describe("applyRenderModeHints", () => { + // fallow-ignore-next-line code-duplication it("forces screenshot mode when compatibility hints recommend it", () => { const compiled = createCompiledComposition(["iframe", "requestAnimationFrame"]); const log = { @@ -451,6 +457,7 @@ describe("applyRenderModeHints", () => { expect(log.warn).not.toHaveBeenCalled(); }); + // fallow-ignore-next-line code-duplication it("returns false when neither caller nor hint forces", () => { const compiled = createCompiledComposition([]); const log = { @@ -561,6 +568,7 @@ describe("resolveRenderWorkerCount", () => { expect(workers).toBe(1); }); + // fallow-ignore-next-line code-duplication it("forces single worker when html-in-canvas is detected", () => { const log = { error: vi.fn(), @@ -587,6 +595,7 @@ describe("resolveRenderWorkerCount", () => { expect(log.warn).toHaveBeenCalledOnce(); }); + // fallow-ignore-next-line code-duplication it("overrides explicit --workers when html-in-canvas is detected", () => { const log = { error: vi.fn(), @@ -728,19 +737,21 @@ describe("selectCaptureCalibrationFrames", () => { }); describe("capture calibration safeguards", () => { - it("uses a bounded protocol timeout for calibration probes", () => { + it("respects user protocol timeout when higher than calibration default", () => { const cfg = createConfig(); const calibrationCfg = createCaptureCalibrationConfig(cfg); - expect(calibrationCfg.protocolTimeout).toBe(30000); + // User's 300s timeout is higher than the 30s calibration default — use the user's value + expect(calibrationCfg.protocolTimeout).toBe(300000); expect(cfg.protocolTimeout).toBe(300000); }); - it("preserves smaller explicit protocol timeouts for calibration probes", () => { + it("uses calibration floor when user timeout is lower", () => { const cfg = createConfig(); cfg.protocolTimeout = 5000; - expect(createCaptureCalibrationConfig(cfg).protocolTimeout).toBe(5000); + // 5s is below the 30s calibration floor — use the floor + expect(createCaptureCalibrationConfig(cfg).protocolTimeout).toBe(30000); }); it("falls back to screenshot mode after beginFrame calibration failures", () => { diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 4754c1295..a478c5320 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -125,8 +125,8 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { ? async () => { const t = usePlayerStore.getState().currentTime; if (kfAnim?.keyframes) { - if (kfAnim.hasUnresolvedKeyframes) { - await session.handleGsapMaterializeKeyframes?.(kfAnim.id); + if (kfAnim.hasUnresolvedKeyframes && session.handleGsapMaterializeKeyframes) { + await session.handleGsapMaterializeKeyframes(kfAnim.id); } const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 6b9390e63..3d6e995f1 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -230,7 +230,8 @@ describe("getTimelinePlayheadLeft", () => { describe("getTimelineCanvasHeight", () => { it("includes bottom scroll buffer below the last track", () => { - expect(getTimelineCanvasHeight(3)).toBeGreaterThan(24 + 3 * 72); + // RULER_H (24) + trackCount * TRACK_H (48) + scroll buffer + expect(getTimelineCanvasHeight(3)).toBeGreaterThan(24 + 3 * 48); }); it("still keeps ruler space when there are no tracks", () => { From 7e874f5442d92456f56c019f33f8a4e2c806318b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 16:17:58 -0400 Subject: [PATCH 07/34] fix: add progress logging during silent render pipeline stages (#1220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add progress logging during silent render pipeline stages The render pipeline only updates progress at stage boundaries (5%, 10%, 25%), leaving multi-minute gaps with zero log output on low-memory hardware. This adds log.info calls at key sub-steps within the three silent stages: - Probe stage (5%): browser launch, session initialization, duration discovery, media asset discovery, audio volume automation, video visibility window detection - Video extraction (10%): per-video extraction progress - Calibration (25%): browser launch, session initialization, per-frame calibration progress, final cost estimate Also adds 30-second heartbeat timers for the two initializeSession calls (probe and calibration) that can individually take minutes on constrained hardware. Closes #1218 * fix: resolve CI failures in typecheck, runtime seek test, and timeline test - Make handleGsapMaterializeKeyframes optional in DomEditSessionSlice and use optional chaining at the call site (not yet wired) - Update GSAP adapter seek test to expect nudge+seek pattern (totalTime with suppressEvents:true followed by actual seek) - Fix Timeline canvas height test to use TRACK_H constant (48) instead of stale hardcoded value (72) * refactor: extract helpers to meet 600-line file size limit - App.tsx (603→594): extract StudioToast component - useDomEditSession.ts (688→600): extract useGsapSelectionHandlers hook - Timeline.tsx (614→557): extract useTimelineAssetDrop hook - PropertyPanel.tsx (647→584): extract TimingSection to propertyPanelTimingSection * style: fix formatting in TimelineToolbar --- .../src/services/render/captureCost.ts | 29 ++- .../render/stages/extractVideosStage.ts | 9 + .../src/services/render/stages/probeStage.ts | 28 ++- .../src/services/renderOrchestrator.ts | 1 + packages/studio/src/App.tsx | 13 +- .../studio/src/components/StudioToast.tsx | 18 ++ .../studio/src/components/TimelineToolbar.tsx | 4 +- .../src/components/editor/PropertyPanel.tsx | 67 +----- .../editor/propertyPanelTimingSection.tsx | 64 ++++++ .../studio/src/hooks/useDomEditSession.ts | 160 ++++---------- .../src/hooks/useGsapSelectionHandlers.ts | 202 ++++++++++++++++++ .../src/player/components/Timeline.test.ts | 4 +- .../studio/src/player/components/Timeline.tsx | 78 +------ .../src/player/components/timelineDragDrop.ts | 103 +++++++++ 14 files changed, 503 insertions(+), 277 deletions(-) create mode 100644 packages/studio/src/components/StudioToast.tsx create mode 100644 packages/studio/src/components/editor/propertyPanelTimingSection.tsx create mode 100644 packages/studio/src/hooks/useGsapSelectionHandlers.ts create mode 100644 packages/studio/src/player/components/timelineDragDrop.ts diff --git a/packages/producer/src/services/render/captureCost.ts b/packages/producer/src/services/render/captureCost.ts index 03d12381f..1a1e4fd13 100644 --- a/packages/producer/src/services/render/captureCost.ts +++ b/packages/producer/src/services/render/captureCost.ts @@ -210,11 +210,15 @@ export async function measureCaptureCostFromSession( session: CaptureSession, totalFrames: number, fps: number, + log?: ProducerLogger, ): Promise<{ estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] }> { const sampledFrames = selectCaptureCalibrationFrames(totalFrames); const samples: CaptureCalibrationSample[] = []; + const totalSamples = sampledFrames.length; - for (const frameIndex of sampledFrames) { + for (let i = 0; i < sampledFrames.length; i++) { + const frameIndex = sampledFrames[i]!; + log?.info(`Calibration: capturing test frame ${i + 1}/${totalSamples}...`); const time = frameIndex / fps; const startedAt = Date.now(); const result = await captureFrameToBuffer(session, frameIndex, time); @@ -224,8 +228,13 @@ export async function measureCaptureCostFromSession( }); } + const estimate = estimateMeasuredCaptureCostMultiplier(samples); + if (estimate.p95Ms !== undefined) { + log?.info(`Calibration complete, estimated cost: ${estimate.p95Ms}ms/frame (p95)`); + } + return { - estimate: estimateMeasuredCaptureCostMultiplier(samples), + estimate, samples, }; } @@ -323,6 +332,7 @@ export async function runCaptureCalibration(input: { sessionDir: string, sessionCfg: EngineConfig, ): Promise<{ estimate: CaptureCostEstimate; samples: CaptureCalibrationSample[] }> => { + log.info("Launching browser for capture calibration..."); const session = await createCaptureSession( fileServer.url, sessionDir, @@ -332,10 +342,21 @@ export async function runCaptureCalibration(input: { ); sessionRef.current = session; if (!session.isInitialized) { - await initializeSession(session); + log.info("Initializing calibration session..."); + const calInitStart = Date.now(); + const calHeartbeat = setInterval(() => { + const elapsed = ((Date.now() - calInitStart) / 1000).toFixed(1); + log.info(`Still waiting for browser initialization... (${elapsed}s elapsed)`); + }, 30_000); + try { + await initializeSession(session); + } finally { + clearInterval(calHeartbeat); + } } assertNotAborted(); - const result = await measureCaptureCostFromSession(session, totalFrames, fps); + log.info("Calibration session ready, capturing test frames..."); + const result = await measureCaptureCostFromSession(session, totalFrames, fps, log); logCaptureCalibrationResult(result, log); return result; }; diff --git a/packages/producer/src/services/render/stages/extractVideosStage.ts b/packages/producer/src/services/render/stages/extractVideosStage.ts index b181c149f..d36e33a55 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.ts @@ -51,6 +51,7 @@ import { type RenderJob, } from "../../renderOrchestrator.js"; import { type CompositionMetadata } from "../shared.js"; +import type { ProducerLogger } from "../../../logger.js"; export interface ExtractVideosStageInput { projectDir: string; @@ -58,6 +59,7 @@ export interface ExtractVideosStageInput { compiledDir: string; job: RenderJob; cfg: EngineConfig; + log?: ProducerLogger; /** Mutated in place — audio entries auto-discovered from video files are pushed onto `composition.audios`. */ composition: CompositionMetadata; abortSignal: AbortSignal | undefined; @@ -102,6 +104,7 @@ export async function runExtractVideosStage( compiledDir, job, cfg, + log, composition, abortSignal, assertNotAborted, @@ -122,6 +125,7 @@ export async function runExtractVideosStage( const nativeHdrVideoIds = new Set(); const videoTransfers = new Map(); if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) { + log?.info("Probing video color spaces...", { videoCount: composition.videos.length }); await Promise.all( composition.videos.map(async (v) => { // Use the shared resolver so a `