Add VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT env var (default: false) to
gate the GSAP drag/resize/rotation intercept in useDomEditSession. When
off, dragging GSAP elements falls through to the standard CSS path
instead of committing via script mutation.
Manual dragging (STUDIO_PREVIEW_MANUAL_EDITING_ENABLED) remains on.
Wire the razor tool into Studio's timeline UI:
- B enters razor mode (crosshair cursor + red vertical guide line)
- Click any clip to split at the click position
- Shift+click splits all clips across every track at that time
- V or Escape exits razor mode
- Toolbar shows selection arrow / scissors toggle
Add useRazorSplit hook for split orchestration (HTML + GSAP mutation).
Add activeTool state to playerStore. Add preview reload after timeline
move/resize operations so the composition re-renders with updated timing.
Extract shared utilities to reduce duplication across timeline components:
- PlayheadIndicator: shared playhead rendering (was duplicated in
TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs
- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a
stripStudioEdits flag on the delete mutation type so they only fire on
user-initiated deletes, not on internal delete-then-recreate drags.
- Add bakeVisibilityOnDelete to the remove-all-keyframes handler so
elements with CSS opacity:0 stay visible after collapsing keyframes.
- Fix integer rounding in readAllAnimatedProperties: use 3-decimal
precision for visual properties (opacity, scale, rotation) instead of
Math.round which corrupted mid-fade values to 0.
- Guard VISUAL_BASELINE against cross-tween contamination by querying
__timelines for properties animated by other tweens on the same element.
- Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one
containing opacity, guard against relative values (+=/-=/*=), and add
Number.isFinite check.
- Fix falsy-zero doubling in drag commit: replace || fallback with
Number.isFinite so a base GSAP position of 0 is correctly preserved.
- Fix gesture recording sign inversion: remove pointerElementOffset
subtraction from dx/dy formula and instead apply it once to basePosition
so the element center tracks the pointer.
- Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts).
- Strip all diagnostic logs from production code.
* docs(readme): swap hero media to hyperframes-logo-motion
Replaces the prior hfgif-1280.webp hero with a new logo-motion clip
Bin trimmed for the launch. Converted the source MP4 to animated webp
(the existing hero's format) so it auto-plays in the GitHub README the
same way the old one did - MP4 sources don't render inline or autoplay
in <img> tags.
- New asset: static.heygen.ai/hyperframes-oss/docs/images/
hyperframes-logo-motion-1280.webp (1280x720, 85 frames, 199KB)
- ffmpeg conversion: scale=1280, libwebp_anim, q=80, loop=0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(studio): format 5 hooks files (oxfmt)
* style: remove unused imports in studio hooks (pre-existing lint failures)
CI Lint on main was already failing with 5 unused-import errors in
packages/studio/src/hooks/. Removed the unused symbols to unblock the
README hero PR's CI:
- gsapRuntimeBridge.ts: resolveTweenStart, resolveTweenDuration
- useGsapScriptCommits.ts: usePlayerStore
- useTimelineEditing.ts: PatchTarget (type-only)
- gsapDragCommit.ts: readGsapProperty
Bundled into the README PR per James's request to fix CI in-place.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(studio): add childRects: [] to DomEditOverlay test mock
useDomEditOverlayRects' return type added a childRects: OverlayRect[]
field; the DomEditOverlay test's mock didn't get updated and was
returning an object without it, so DomEditOverlay.tsx's
'childRects.length > 0' check threw TypeError on undefined.
One-line mock-vs-hook contract realignment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(studio): drive player-store currentTime in selection-hydration test (#1311 follow-up)
The 'hydrates seek first, preserves the initial url state, then restores
selection' test was failing because PR #1311 (keyframes feat) changed
useStudioUrlState to read currentTime from the player store via
usePlayerStore((s) => s.currentTime), removing it from the hook's prop
shape. The test was still trying to drive currentTime via the harness
prop, which is now a no-op — so the selection-hydration useEffect's
time-stability guard
Math.abs(currentTime - stableTimeRef.current!) > 0.05
never passed (store currentTime stayed at 0 while stableTimeRef caught
the 4.2 seek target). buildDomSelectionFromTarget was never reached,
applyDomSelection was never called, and the assertion got 0 calls.
Fix: setState the store's currentTime to 4.2 ahead of the rerender so
the hook's selector picks it up and the time-stability guard passes.
Harness prop kept as-is — it's a no-op but doesn't hurt.
Pre-existing failure on main HEAD 81416ab3; surfaced as CI gate on the
unrelated docs/readme-hero-motion-update PR.
* test(studio): stub getBoundingClientRect + flush RAF in DomEditOverlay test
The 'renders selected bounds right after clicking a movable selection'
test asserts the selection box appears after pointerdown, but happy-dom
returns 0 for newly-created elements' getBoundingClientRect. The
overlay's compRect updates via a RAF loop that early-returns when iframe
width is 0; the keyframes PR a468550f added a compRect.width > 0 guard
to the selection-box render path, so compRect=0 silently gates the box
off and the assertion fails.
Stub Element.prototype.getBoundingClientRect to return 800x450 for the
test, and flush two RAFs after render so the compRect state update lands
before the pointerdown assertion. Restore the prototype at test end.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Miguel Sierra <miguel.sierra@heygen.com>
* feat(studio): carry hfId on TimelineElement, wire through buildPatchTarget (R7, T5b)
* refactor(studio): extract readHfId helper, fix empty-string normalization, add comments (R7 review)
- Extract readHfId(el) to domEditingLayers.ts — centralizes `?.trim() || undefined`
normalisation; guards against empty-string data-hf-id reaching findTagByTarget
- Wire readHfId into domEditingLayers.ts and useDomEditCommits.ts (the one site
that still used `?? undefined` instead of `|| undefined`)
- Re-export readHfId through domEditing.ts public API
- Add readHfId unit tests: present, absent, empty-string, whitespace-only
- Add comment on PatchTarget: runtime validation lives in findTagByTarget, type is docs-only
- Suppress pre-existing unused re-exports in timelineDOM.ts (backward-compat re-exports
brought into fallow scope by the T5b hfId changes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): clear data-hf-id on split clone to prevent dual-match (R7 review)
cloneNode(true) copies all attributes including data-hf-id. Without clearing it,
both halves of a split share the same hf-id; the server's findByHfId picks the first
match and silently patches the wrong clip. Remove the attribute from the clone so
write-back re-mints a fresh id on the next preview load.
Adds a test: splitElementInHtml — hfId clone isolation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(studio): add hfId to DomEditLayerItem + getDomLayerPatchTarget return type (R7 review)
- Add hfId to DomEditLayerItem interface (domEditingTypes.ts) so layer item
construction in collectDomEditLayerItems compiles
- Widen getDomLayerPatchTarget return type to include hfId + populate it from
data-hf-id attribute (domEditingElement.ts)
- Widen findDomEditSelectionTarget to check hfId-first when no id/selector
- Widen Pick types in domEditOverlayGeometry.ts and useGsapScriptCommits.ts
- Add hfId to buildMissingCompositionElements element construction
- Add hfId-targeted test coverage in domEditing.test.ts,
domEditOverlayGeometry.test.ts, timelineIframeHelpers.test.ts
- Update hfIds.test.ts KNOWN LIMITATION labels — write-back landed in R7 T1-2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- Adds `hfId` field to `resolveDomEditSelection` — reads `data-hf-id` off the live element and stores it in `DomEditSelection.hfId`
- `DomEditSelection extends PatchTarget` which already declares `hfId?: string`, so this is a single new line at the return site
- Widens `MutationTarget` in `files.ts` to include `hfId?: string` (type hygiene — the value already survives through `parseMutationBody`'s by-reference pass, so this is documentation not a behaviour change)
## Why
R7 / Task 5a. The full hf-id write-back and patch-engine infrastructure (R1 + R7 Tasks 0–4, PRs #1269–#1292) is server-complete. The only missing piece was: the Studio client never read `data-hf-id` off a hit-tested element, so `target.hfId` was always `undefined` and the `hfId`-first lookup branches in both patch engines were unreachable in production. This PR fixes the selection side — the commit wire (#1297) completes the path.
## Test plan
- [ ] `packages/studio/src/components/editor/domEditingLayers.test.ts` — two new tests with jsdom environment:
- `resolveDomEditSelection` on an element with `data-hf-id` → `selection.hfId` is populated
- element without `data-hf-id` → `selection.hfId` is `undefined`
- [ ] All 65 studio test files pass, all 72 core test files pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* refactor(core,studio): extract draft-marker constants to core (R7, Task 4)
Create draftMarkers.ts in core with 5 shared CSS custom property names and the
gesture DOM attribute. PreviewAdapter imports from draftMarkers.ts instead of
hardcoding strings. Adds @hyperframes/core/studio-api/draft-markers export
subpath. Studio's manualEditsTypes.ts re-exports the shared constants from core
so all existing call sites are unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): address R7 code-review findings (C1–C14, P6–P7)
- previewAdapter: auto-revert previous gesture in applyDraft (C3); clearDraftProps
on commitPreview not just revertDraft (C4); isVisible NaN→visible for JSDOM (P7);
remove redundant GestureState.hfId field (C12); remove Array.from (C14);
extract clearDraftProps/revertGesture helpers (C5/C6)
- hfIdPersist: replace string-equality change detection with attribute count to
avoid false-positive writes on single-quoted HTML (C1); re-read disk before
write for TOCTOU guard (C7); remove normalizeHfIds wrapper (C11)
- preview.ts: remove dead null-check on normalizedDisk after diskMain guard (C9);
catch path re-reads disk fresh instead of using stale pre-request snapshot (C8)
- hfIds.test.ts: replace tautological second stability test with cross-document
content-keyed id stability test (P6)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(core): follow-up R7 review fixes — CSS.escape fallback, invariant docs, new edge-case tests
- hfIdPersist: remove ensureHfIds re-export (P2); add JSDoc invariant note;
improve TOCTOU comment; pass err to console.warn
- preview.ts: split import — ensureHfIds from parsers/hfIds.js (not re-export)
- previewAdapter: CSS.escape + inline fallback for non-browser environments;
add JSDoc for atTime caller-seek contract; add 0.01 opacity-threshold comment
- previewAdapter.test: rename atTime test to clarify adapter-does-not-seek;
add nested-hf-root-without-id test; add resize→move prop-leak test;
add revertDraft-after-commit no-op test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(core): bundle-vs-disk id-stability test; comment double ensureHfIds (P3)
- preview.test: add "bundle returning untagged HTML gets same ids as disk" test —
guards against id divergence when bundler reads a pre-write cache snapshot;
content-keyed FNV1a minting ensures served ids == disk ids for same source HTML
- preview.ts: comment the second ensureHfIds call explaining it's intentional for
adapter-injected elements and idempotent on the no-bundle path (P3 from miguel)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(core): wire-contract comment on mintHfId + fallow suppressions (R7)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(core): clip-model hf- ids minted at parse, emitted as data-hf-id (R1)
* docs(core): document legacy-id round-trip in clip-model readback (R1 review)
Addresses Rames' review on #1270: clarifies that a pre-R1 clip authored with
id="my-title" round-trips as data-hf-id="my-title" (non-hf-shaped but stable,
exact-match) by design — targeting uses exact [data-hf-id="…"] match and does
not require the hf- shape; legacy values re-mint only at the R7 write-back. Not
a bug. Comment-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(core): fix misleading legacy-id migration comment in htmlParser.ts
The original comment said legacy data-hf-id values "are re-minted only
once the R7 write-back persists freshly-minted ids to source" — which is
incorrect. ensureHfIds skips elements that already carry data-hf-id, so
legacy values (e.g. data-hf-id="my-title") persist indefinitely and are
NOT automatically re-minted. Exact-match targeting still works correctly.
Update comment to reflect actual behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(studio): sourcePatcher data-hf-id targeting (R1, T3)
* fix(studio): warn on duplicate match in execDataAttrPattern (R1, T3 review)
Addresses Rames' review on #1271: execDataAttrPattern returned the first regex
match without checking for a second. A duplicate id/data-hf-id in source (id
drift) would silently patch one element and leave the other stale. Now warns
when more than one element matches. By the mint contract it should never fire.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(studio): pin hfId-is-authoritative-over-selector contract (R1, T3 review)
Adds test: "hfId match is authoritative — selector is not used as a
narrowing filter". When hfId matches element A and selector points at
element B, findTagByTarget returns A without consulting selector as a
narrowing filter. Pins the intended behaviour so a future refactor
cannot silently start narrowing by selector.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(studio): add T5b rotation+motion build-patches characterization
Extends manualEditsDomPatches.test.ts with rotation and motion pairs.
Same 4-pattern structure: populated, empty, clear restores originals,
build/clear symmetry. Merges duplicate manualEditsTypes import block.
* test(studio): add T5c review-fix gaps in manualEditsDomPatches characterization
Fixes four gaps identified in max-setting code review:
- Box-size clear: replace arrayContaining with full ordered toEqual (30 ops)
- Box-size / pathOffset / rotation clear: add empty-string coercion tests
(origVal||null must produce null, not set property to "")
- Rotation clear: add test for absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR
- Motion clear: prove input-independence by calling with both empty and populated
element and asserting identical output
* refactor(core): extract maxEndTime+serialize to parsers/test-utils.ts (TU)
Deduplicate helpers shared by T1 (htmlParser.roundtrip.test.ts) and T2
(stableIds.test.ts). Both files inline identical implementations; extract
to test-utils.ts so future parser tests (T6a…) import one copy.
Also fix lefthook fallow command to unset GIT_DIR+GIT_INDEX_FILE before
running — those vars are set by git in worktree hook context and block
fallow’s internal temp-worktree creation.
* test(core): add T10 PreviewAdapter contract stubs (spec for R7)
All 14 tests are it.todo, following the T4 pattern. The stubs define the
full createPreviewAdapter interface — elementAtPoint (root exclusion,
hf-id ancestor walk, opacity filter), applyDraft/revertDraft (draft
marker lifecycle), commitPreview (patch derivation), and getElementTimings
(data-start/data-end reader).
createPreviewAdapter does not exist yet; R7 implements it and converts
these stubs to real assertions.
* test(core): add T6a GSAP parser golden baselines (Recast/Babel snapshot)
6 toMatchFileSnapshot tests across 3 representative scripts (minimal,
moderate, complex). Captures parseGsapScript + serializeGsapAnimations
output before the Recast → Meriyah swap so any parser change is detected
as a golden diff rather than a silent behavioral regression.
Goldens live in src/parsers/__goldens__/ and are checked in. Add
__goldens__/** to fallow ignorePatterns (data files, not modules) and to
.prettierignore so oxfmt does not reformat vitest-written snapshot files.
* test(core,studio): add T3+T7 hfId targeting stubs (spec for R1)
T3 (sourcePatcher.test.ts): 5 it.todo stubs for PatchTarget.hfId targeting
— style, text, attribute patches plus preservation and fallthrough cases.
T7 (sourceMutation.test.ts): 2 it.todo stubs for SourceMutationTarget.hfId
— basic patch and data-hf-id survival after patch.
Neither interface has hfId yet. R1 adds the field + [data-hf-id="…"] branch
in findTagByTarget / findTargetElement, then converts these to real assertions.
Fixes four gaps identified in max-setting code review:
- Box-size clear: replace arrayContaining with full ordered toEqual (30 ops)
- Box-size / pathOffset / rotation clear: add empty-string coercion tests
(origVal||null must produce null, not set property to "")
- Rotation clear: add test for absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR
- Motion clear: prove input-independence by calling with both empty and populated
element and asserting identical output
## Summary
T5 (part 1 of 3) — characterization test suite for `manualEditsDomPatches.ts`.
The source module exports 8 functions (4 build/clear pairs) that write and restore draft-marker attributes and inline styles onto iframe elements before source-patch operations. It had zero tests.
This PR covers the **pathOffset** and **boxSize** pairs with 4 patterns each:
- **populated** — fully-configured element produces exact expected `PatchOperation[]` in declaration order
- **empty** — bare element yields only the mandatory marker attribute op
- **clear restores originals** — `buildClear*` reads `STUDIO_ORIGINAL_*` attrs and produces correct restore values
- **build/clear symmetry** — every `{type, property}` key that `build*` can emit is also addressed by `buildClear*`; an orphan here means a property stranded in committed source HTML
Uses `@vitest-environment happy-dom` matching the Studio package convention. Element setup via `document.createElement` + `style.setProperty` / `setAttribute`.
## Stack
- **#1257** (this PR) — pathOffset + boxSize pairs
- **#1258** — rotation + motion pairs
- **#1259** — review-fix gaps (ordered clear assertion, coercion paths, edge cases)
Compositions are now self-contained: the compiler captures font files
and embeds them as woff2 data URIs, eliminating silent render-time
fallback when the render environment lacks the author's fonts.
Resolution order (each tier falls through to the next):
1. Existing @font-face → use as-is
2. Bundled alias (38 cross-platform mappings) → embed data URI
3. Google Fonts → fetch, cache, embed
4. Local system font → locate on OS, compress to woff2, embed
5. Local @font-face paths → read file, compress, inline as data URI
6. External CDN stylesheets → fetch CSS, extract @font-face, inline
7. Alias map fallback → closest bundled equivalent
8. Actionable error with guidance
Key changes:
- System font locator (macOS/Windows/Linux) with path-bounding and
symlink defense (realpathSync + O_NOFOLLOW)
- woff2 compression via wawoff2 (WASM, cross-platform)
- Multi-weight/style variant capture with length-sorted token matching
- External stylesheet inlining with SSRF defense (assertPublicHttpsUrl,
HTTPS-only, private-host blocking, 2MB cap, 4-concurrent limit)
- Studio auto-import via GET /fonts/file API + renderAliasFor() derived
from shared FONT_ALIAS_MAP (no more hand-curated drift)
- failClosedFontFetch throws on unresolved fonts in distributed renders
- Single source of truth: @hyperframes/core/fonts/aliases
- system_font_will_alias lint rule (escalates to warning for distributed)
- Default to Inter + JetBrains Mono in templates and CSS reset
* feat(gsap): add innerText support to GSAP inspector for counter animations (#1244)
Adds 'innerText' as a supported GSAP property so number roll-up animations
(count-up from 0 to some value) are visible and editable in the GSAP inspector
panel.
- Add 'innerText' to SUPPORTED_PROPS in gsapConstants.ts
- Add label 'Counter Value', tooltip, and step constraint (1) in
gsapAnimationConstants.ts
The snap modifier that controls integer rounding is already preserved
verbatim via the EXTRAS_KEYS round-trip, so rounding behavior survives
edits without any additional UI changes.
Closes#1179
* feat(registry): add text-effects catalog section and morph-text component
Introduces a new "Text Effects" catalog section (below Effects) for text-focused visual components.
- Add `text-effects` BlockCategory to core registry types with violet color
- Add `text-effect` tag resolver in resolveBlockCategory (checked before generic `effect` tag)
- Tag caption-blend-difference, texture-mask-text, and morph-text with `text-effect`
- Update studio catalog order and color map to include text-effects
- Add morph-text component: gooey SVG threshold morph cycling through editable statements
using GSAP seekable proxy pattern for deterministic/seekable rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(registry): add morph-text preview video
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(registry): fix morph-text.html formatting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(catalog): add Text Effects section and morph-text page
Moves caption-blend-difference and texture-mask-text out of Effects into a new
"Text Effects" section below it. Adds morph-text component page with install
instructions and preview video.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore(registry): add demo.html for morph-text catalog preview rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(registry): address PR review feedback on morph-text and text-effects
- Restore `effect` tag on caption-blend-difference and texture-mask-text
alongside `text-effect` so existing tag-equality searches/analytics still match
- Fix morphPause script fallback from "0.25" to "1.5" to match data attribute default
- Add Math.max(0, ...) guard to blur values (intent clarity)
- Add prefers-reduced-motion: skip morph and show first word statically
- Remove CATEGORY_ORDER record from useBlockCatalog; derive order from
BLOCK_CATEGORIES array (single source of truth, no drift)
- Add comment to demo.html documenting its purpose (catalog preview script only)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## What
Extends `editHistory.test.ts` with T11 from the SDK migration test plan: history coalescing gaps and origin guard stubs.
## Tests
**New passing test (1):**
- **cross-prop coalescing separation** — two edits within the coalesce window but with *different* `coalesceKey` values produce two separate undo entries, not one coalesced entry. Fills the gap left by the existing same-file coalescing tests (lines 176–243).
**`.todo` stubs (2):**
- `gesture-start/commit collapses intermediate drag steps into one undo entry` — requires gesture lifecycle API not yet built
- `origin:applyPatches edits excluded from undo stack` — requires SDK session object (`session.on("patch", ...)`, `session.dispatch(...)`) which doesn't exist yet; needed to prevent undo loops when SDK patches are applied
## Stack
Stacked on T8 (#1241). Prerequisite for T4 (#1243).
Adds 'innerText' as a supported GSAP property so number roll-up animations
(count-up from 0 to some value) are visible and editable in the GSAP inspector
panel.
- Add 'innerText' to SUPPORTED_PROPS in gsapConstants.ts
- Add label 'Counter Value', tooltip, and step constraint (1) in
gsapAnimationConstants.ts
The snap modifier that controls integer rounding is already preserved
verbatim via the EXTRAS_KEYS round-trip, so rounding behavior survives
edits without any additional UI changes.
Closes#1179
* feat(studio): add drag-to-reorder in layers panel with z-index persistence
Layers panel now sorts siblings by computed z-index (descending) to
reflect visual stacking order. Users can drag layer rows to reorder
them within a sibling group — on drop, sequential z-index values are
assigned and persisted via the existing inline-style patch pipeline
with a single preview reload.
- sortLayersByZIndex: recursive sibling-group sort by computed z-index
- useLayerDrag: pointer-capture drag gesture with 4px threshold,
insertion indicator line, and depth-constrained sibling reorder
- handleDomZIndexReorderCommit: batch z-index commit with coalesced
undo entry and single skipRefresh=false on the final patch
* fix(studio): harden layer drag-to-reorder edge cases
- Guard drag initiation against locked compositions by checking
data-timeline-locked ancestors in isLayerDraggable
- Show not-allowed cursor and reduced opacity on non-draggable layer rows
- Fire toast when attempting to drag a layer with no same-depth siblings
- Preserve z-index spacing on reorder by redistributing existing values
instead of flattening to sequential integers
- Auto-set position:relative on unpositioned elements when z-index is
applied so the stacking order actually takes visual effect
- Add tests for isLayerDraggable (anonymous, id, selector, locked, free)
* fix(studio): handle z-index ties in layer reorder + trim file sizes
- Fall back to sequential z-index when any duplicates exist in the
sibling set, not just when all values are identical — fixes silent
no-op reorder when tied values preserve DOM-order stacking
- Trim useDomEditSession.ts from 602 to 600 lines (CI file-size gate)
* test(studio): add duplicate z-index tiebreak test for layer sorting
Cover the [2, 1, 2] case where tied z-index values fall back to
reverse DOM order — locks the hasDupes fix against regressions.
* style(studio): fix oxfmt formatting in LayersPanel
The fit-to-children merge re-inlined TimingSection that was already
extracted to propertyPanelTimingSection.ts, pushing the file to 687
lines (over the 600 limit) and introducing format issues.
- Removed duplicate TimingSection, import from extracted module
- Extracted computeFitToChildrenSize to propertyPanelHelpers
- Formatted PropertyPanel.tsx (608 lines, down from 687)
Adds an icon button next to W/H fields that computes the bounding
box union of all visible children and resizes the element to fit.
Uses BCR union scaled to composition pixels, filters visibility:hidden.
Connect snap engine and UI components to the preview canvas gesture
system. Dragging or resizing elements now shows Figma-style alignment
guides with snap-to-edge, snap-to-center, and grid snap.
- Collect snap targets once at gesture start, reuse per frame
- resolveSnapAdjustment called per pointermove during drag
- resolveResizeSnapAdjustment for resize gestures
- lastSnappedDx/Dy stored on GestureState for consistent drop
- Alt/Option key temporarily disables snap
- SnapToolbar rendered in preview area with snap prefs state
React components and DOM utilities for the snap system:
- SnapGuideOverlay: pre-allocated div pool (6 guides + 4 spacing)
for ref-driven guide line rendering during drag
- SnapToolbar: magnet/grid toggle with S/G keyboard shortcuts,
right-click grid popover for spacing config
- GridOverlay: CSS repeating-linear-gradient grid, GPU composited
- snapTargetCollection: walks iframe DOM tree to collect visible
elements as snap targets, cross-iframe safe (nodeType check)
* 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
Closes#1219
## Problem
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.
## Root causes
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.
## Changes
- `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 <ms>` and `--player-ready-timeout <ms>` 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`.
## Test plan
- [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