Review 1 (restore commit):
- asset reveal now clears any open preview overlay (stuck-overlay repro:
preview on A, click already-added B — A stayed open over the reveal)
- duration readout rolls back on failed persist: captureDurationRollback
snapshots store + live root data-duration before the optimistic sync and
restores both in every move/resize/delete/group catch (golden's
previousDuration pattern)
- asset preview opened during running playback dismisses immediately (the
RAF loop bypasses the store, so the subscription alone never fired)
- persistTimelineBatchEdit resolves the target (findTagByTarget) before
treating identical output as a no-op — a mistargeted member now throws
like the single-element path instead of being silently dropped
- a post-mutation history-fold failure no longer suppresses the preview
sync: fold errors are surfaced separately and the rewritten script still
syncs (previously the preview kept stale GSAP positions with no recovery)
- timelineRevealScroll guards degenerate viewports (windowSize <= 0)
- CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches
Review 2 (single-source-of-truth pass):
- createTimelineElementFromManifestClip — the one manifest->element
boundary — now carries authoredTrack and stackingContextId; expanded
sub-comp children preserve both (authoredTrack in their OWN file's space)
- authoredTrackForLane scopes occupants to the dragged clip's sourceFile
(a foreign file's authored values are a different coordinate space);
nearest-same-file-lane offset fallback
- optimistic store updates mirror the persisted track into authoredTrack
(and roll it back on failure), so consecutive drags before a reload
resolve from fresh data
- spill sub-lanes: documented decision — dropping onto a spill lane is a
legitimate same-track join (occupants share the authored track by
construction); false 'never a lane-move target' docstring rewritten
- single-element fallback persists vertical-only moves (early return now
requires neither start nor track changed; live DOM patch includes
data-track-index)
- canonical contextKey helper for stacking-context normalization
- new pipeline test crosses the REAL factory boundary (sparse authored
tracks -> factory -> expansion -> normalize -> drag commit -> persisted
attribute), no injected fields
Two legibility fixes for the canvas z-order menu, from user feel-testing:
- z-only commits no longer remount the preview iframe. The commit hook
already applies the inline z (+ injected position) to the live elements and
updates the store synchronously; the post-commit reloadPreview() was a
redundant full remount that read as a canvas 'blink' on every action.
commitDomEditPatchBatches gains skipReload, engaged only when provably
safe: every op is an inline-style patch AND the server reports every patch
matched — anything else falls back to the reload so the preview reconverges
with disk. The file-watcher's own reload stays suppressed by the existing
domEditSaveTimestampRef window, so the skip is real.
- Bring Forward / Send Backward step over the next VISIBLY overlapping
sibling. The nearest z-neighbor in a composition is often invisible at the
current frame (runtime hides time-inactive clips with inline
visibility/display; GSAP parks elements at opacity 0), so the step crossed
something the user couldn't see — 'enabled but nothing happens'. The
forward/backward set now filters on element-level computed visibility
(display/visibility/opacity, injectable for tests); enable/disable shares
the resolver so the menu is honest: actions disable when no visible
neighbor exists. Front/back keep the full painting family.
- The neighbor that was stepped over gets a 600ms accent flash, drawn in the
studio overlay layer (never in the iframe DOM), so the action shows its
work.
An adversarial review of the canvas context-menu z-order pipeline (Bring to
Front / Forward / Backward / Send to Back) found the resolver math sound but
the glue between the menu and the commit hook broken:
- The menu optimistically wrote style.zIndex AND position: relative to the
live elements BEFORE the commit hook ran. The hook decides whether to
persist position by checking getComputedStyle(el).position === 'static' —
always false after the pre-apply — so the position patch was never
persisted on the menu path and the reorder silently reverted at the
post-commit reload for any nested/static element (root clips survive only
because the runtime forces position:absolute). The same pre-apply made the
failure rollback capture the already-mutated values, restoring the broken
state on persist errors. The menu no longer pre-applies; the hook owns the
live writes (it already applied both synchronously) and now sees true
priors. Siblings without a persistable identity still get their z applied
live-only so a renumber stays visually coherent.
- The commit hook's entry.key store-sync plumbing had zero production
callers; the store zIndex went stale until full reload. All three callers
(canvas menu via PreviewOverlays, timeline lane z-sync, LayersPanel) now
derive and pass the timeline store key (new deriveTimelineStoreKey helper).
- patchElementBatch discarded the server's per-patch matched[]; unresolvable
siblings persisted partially and silently. Unmatched targets now warn and
report save-failure telemetry (z-reorder-unmatched) without rolling back
the matched subset.
- template/noscript elements counted as painting siblings, so renumber
fallbacks wrote z-index/position into <template> tags in the source file.
Excluded from the sibling family.
- The default undo coalesce key merged DISTINCT z actions within 300ms into
one undo entry; the action kind is now part of the key (LayersPanel drags
keep coalescing within a drag; explicit lane-move gesture keys untouched).
- rectsIntersect comment claimed touching rects intersect; the strict
inequalities say otherwise — comment fixed.
Vertical clip moves committed in the store but never survived: two persist
bugs plus a runtime renumber all fought the stable-track-lanes model.
- timelineMoveAdapter deliberately stripped the track from lane-reorder
persists ('z-only reorder path' — the old z-driven lane model). Lane =
authored data-track-index now: lane-reorder and track-insert both persist
the track; plain timing moves omit it to stay SDK-fast-path eligible.
- Display lanes and file tracks are different coordinate spaces:
normalizeToZones packs sparse authored tracks (1,2,... or gaps, or DOM-index
fallbacks) onto contiguous display lanes, and lane edits persisted the LANE
number — silently re-targeting the wrong row in any non-0-contiguous file.
Elements now record their authoredTrack when remapped; a lane change
persists the target lane's authored track (store stays in lane space).
- The runtime split same-track clips of different kinds (video vs caption
div) onto separate renumbered tracks at discovery, so authored indices
never round-tripped ('drop onto an existing track' bounced back). Removed:
data-track-index is honored verbatim (render never reads it); kind-based
row presentation belongs in the display layer if ever wanted.
Adversarial review fixes on the same pipeline:
- runtime: parseInt(attr) || fallback dropped authored track 0 for GSAP and
overlay clips (parseAuthoredTrack helper honors 0)
- single-clip move fallback persisted only data-start — lane changes snapped
back on reload (now passes the track to the patch builder)
- lane-change z-sync candidate ignored a multi-selection's time shift, so
patches were computed against stale overlap sets
- track insert around a locked clip persisted a colliding renumber (the next
normalize merged lanes); the insert is now refused with a warning
- computeStackingPatches compared leaf z across CSS stacking contexts, where
ancestor z decides paint order; the sync now partitions by
stackingContextId and never patches across contexts
Timeline geometry (user-reported):
- fit zoom leaves 20% trailing headroom (FIT_ZOOM_HEADROOM in
timelineLayout.ts; single fit-pps source, so ruler/lanes/playhead/drag all
inherit it)
- playhead line center now sits exactly on GUTTER + t*pps at every zoom
(wrapper had shrink-wrapped to the 9px diamond, off-centering the line);
ruler ticks center on their timestamp
- ruler: frame-mode steps snap to whole frames (no duplicate labels), hour
steps added for far zoom-out, tick positions computed as exact multiples
(no float drift)
The Studio stack rebuild (#2291) landed the remaining NLE layers but dropped
or regressed several final-wave behaviors from the reviewed studio-dnd stack,
and never repaired the stale timelineZones.ts that #2279 introduced. Restores:
- TimelineRuler: sticky under vertical scroll, full-height gridlines removed
(beat lines only), frame-number tick labels via a persisted timeDisplayMode
store preference (PlayerControls toggle now store-backed)
- timelineZones: stable track lanes — lane = authored data-track-index
ascending; z is paint order only (replaces the stale z-driven lane pack,
which broke track insert-band commits that contractually depend on it)
- persistTimelineBatchEdit: a batch member whose patch is a no-op (attributes
already at target values, e.g. in a track-insert renumber) is skipped
instead of aborting and rolling back the whole batch — this alone made
new-track creation (incl. the top insert band) fail silently
- useTimelineStackingSync: unresolvable clips read as NaN again so
timelineStackingSync's Number.isFinite exclusion contract holds (z=0
fabrications skewed stacking boundaries)
- timelineAssetDrop: drops land on the drop track (no overlap bump to
max-track+1), data-hf-id stamped, audio gets data-volume
- timing edits: soft-reload the server's rewritten GSAP script instead of a
full iframe remount (no all-clips flash on move/resize); full reload only
when no scriptText or the soft path can't apply, and one full reload when a
group edit touches non-active files (new hooks/timelineTimingSync.ts)
- duration: content-driven grow-AND-shrink on move/resize/delete, synced
optimistically to the store and the live root data-duration at release
(was a grow-only ratchet; shrink never updated the readout)
New UX: sidebar asset click opens a compact non-modal preview over the canvas
(dismiss on outside click, Escape, playback, or seek), and clicking an
already-added asset reveals its clip in the timeline (smooth minimal scroll
to its time and lane; vertical-only in fit zoom).
Verified by pointer-driving a real project: sticky ruler + gridline removal,
no iframe remount on move/resize (marker survives, GSAP tween positions
rewritten in place), duration readout 40->37->40 on shrink/stretch, and
top-insert-band track creation renumbering lanes correctly on disk.
Matches the isPathInside pattern in fileServer.ts. The helper is called from
webmAlphaCheck.ts's own webmAlphaAdvisory (same file), but fallow doesn't
count intra-file consumption or test-file imports. Suppress the false
positive rather than dropping the direct unit test — the 1/2/3-frame
byte-count gate + per-frame stride logic is load-bearing enough to warrant
independent tests, not just coverage through the outer advisory function.
Keep the image-to-video recipe; remove the broader capability table (photo
avatar, digital twin, cinematic, translate, lipsync, ai-clipping, voice). It
drifted toward the API-reference surface OP1 says media-use shouldn't carry, and
--request-schema already self-documents each command. Fix the now-dangling
'(below)' pointer in the image-to-video note.
Send `X-HeyGen-Client-Source: hyperframes` from buildAuthHeaders on every
HeyGen API call (both OAuth and API-key), so backend billing meta can
isolate hyperframes CLI usage. The single buildAuthHeaders chokepoint covers
the core CLI + cloud client. Mirrors the media-use tagging; the OAuth-only
X-HeyGen-Source cli free-gate header is unchanged.
* docs(skills): add cloud render + variables to CLI skill, media-use generative use cases
The hyperframes-cli skill only documented self-managed AWS Lambda rendering; the
zero-infra HeyGen-hosted `cloud render` path (a real, shipped command with its
own docs page) was absent from every skill, so agents never surfaced it.
- hyperframes-cli: add `cloud` to the frontmatter verb list + entry point; new
Cloud and Variables sections; new references/cloud.md distilled from
docs/deploy/cloud.mdx; routing + workflow rows.
- media-use: add image-to-video recipe (heygen video create type:image) plus a
table of other HeyGen generative use cases (photo avatar, digital twin, video
translation, lipsync, voice design) in references/operations.md; surface them
in the SKILL coverage/provider rows.
- Sync README + docs/guides/skills.mdx catalog entries to mention cloud render.
* docs(media-use): point HeyGen generative use cases at --request-schema
Verified against the installed heygen CLI (v0.3.0): no capability gap that would
need the raw API. Replace hardcoded body-field lists with a pointer to
`heygen video create --request-schema` (self-documenting, can't rot), correct
the image-to-video motion_prompt/expressiveness support, and add the
cinematic_avatar, ai-clipping, and photo-avatar creation paths.
* fix(skills): correct media-use manifest hash (clean-tree regen)
The prior regen was polluted by the gitignored skills/media-use/eval-report.html
(a suppressed mv error left it present), so the committed media-use hash didn't
match a clean checkout. Regenerate with no untracked artifacts present.
* feat(cli): coordinate-frame layout findings in check
Four production compositions shipped with 100-600px layout drift, each a
different coordinate-frame confusion the check graded info or missed
entirely: viewport pixels written as container left/top, gsap x/y
treated as absolute position, a -350px margin fighting flex centering,
and stage-relative path coords drawn into a nested SVG.
Three new layout findings close the class:
- positioned_out_of_parent: an absolute/fixed element rendering mostly
outside its positioning ancestor (warning) — the parent needs no
overflow clipping, which is what let container_overflow miss it.
- box_out_of_canvas: a painted panel breaching the canvas (warning) —
text is canvas_overflow's, media is frame_out_of_frame's, painted
boxes were nobody's.
- connector_detached: a connector path whose endpoints land far from
every anchorable element (warning) — measured coordinates drawn into
an SVG with a different origin.
canvas_overflow additionally promotes from info to warning when held
across samples AND the breach exceeds 5% of the canvas.
All three are persistence-tiered and respect data-layout-allow-overflow.
Verified against the four incident compositions: every one now surfaces
its drift as held warnings (previously: info or silence).
* fix(cli): harden coordinate-frame findings against review false positives
Reworks all three findings after two-lens review (adversarial FP hunt in
real Chrome + maintainer pass):
- escaped_container (was positioned_out_of_parent): uses offsetParent
(transform-aware, skips fixed-as-canvas), exempts fully-detached
callouts within an attachment allowance while still flagging
touching-but-mostly-outside drift.
- panel_out_of_canvas (was box_out_of_canvas): paint alone qualifies
(flat solid panels were a false negative), fully off-canvas rects are
parked entrances and stay silent, pointer-events:none marks decorative
layers, hero-sized breaches warn while small bleeds stay info.
- connector_detached: endpoints via getPointAtLength + getScreenCTM
(viewBox, preserveAspectRatio, group transforms, every command type),
defs/marker/clipPath subtrees skipped, word-boundary connector naming,
containment tier limited to opaque non-ancestor targets (a text-bearing
wrapper contains its own diagram's endpoints).
- canvas_overflow promotion requires partial visibility — a fully
off-canvas rect is a parked entrance, not drift.
Verified: the four incident compositions still surface their drift as
held warnings; the review's false-positive repros (fixed HUD, callout,
parked entrance, corner bleed, marker arrowheads, g-transform and
viewBox-scaled connectors) are clean at warning level. Docs and the CLI
skill reference now describe the coordinate-frame findings.
* fix(cli): panel ownership is geometric — direct-text panels were a silent false negative
A painted panel whose direct text stays in-bounds while its box breaches
the canvas produced neither finding: canvas_overflow measures the text
range and panel_out_of_canvas skipped every own-text element. Skip the
panel finding only when the element's own text ALSO breaches (that
geometry belongs to canvas_overflow); pin the message/fixHint wording of
all three findings with positive assertions; document the SVG-internal
anchor blind spot.
* fix(cli): classify panel decoration by paint kind, not pointer-events
pointer-events:none exempted the framed-painting incident's gold frame
layers — hero content that happens to disable hit-testing. Decoration is
now gradient-only paint (spotlights, textures, vignettes); url() images,
solid fills and borders are content regardless of pointer-events.
* fix(cli): add fixHint to the test-local AuditIssue shape
* fix(cli): gradient stops decide content vs decoration; ownership matches canvas_overflow's tolerance
A gradient with any solid stop (alpha >= 0.6) is content — heroes and
cards painted with linear-gradient were invisible under the blanket
gradient exemption; all-translucent stops (spotlights, vignettes) stay
decoration. The text-ownership check now uses the audit tolerance that
canvas_overflow itself fires at, making the contract strict-mutex: any
text breach past that tolerance cedes the element, so a shallow 20px
text breach no longer double-reports.
Address Miguel's R1 blocker + Rames/Miga's testability nit:
- The `-frames:v 3` decode samples AT MOST 3 frames; a legitimate 1- or
2-frame WebM (256 or 512 bytes) was returned as `undefined` (probe
failure), silently skipping the advisory even when every available
pixel was opaque. Accept any positive whole-frame byte count ≤ 768
(multiples of 256), distinguishing successful short-EOF from partial/
malformed decode.
- Export `sampledAlphaIsFullyOpaque` and add 11 direct tests covering:
3/2/1-frame opaque decodes → true; transparent pixel at pos 0 or
final byte → false (guards the alpha-byte stride); non-frame-multiple
/ over-3-frame / zero byte counts → undefined; execFileSync throw →
undefined; findFFmpeg missing → undefined; and one args-shape guard
pinning the load-bearing `-c:v libvpx-vp9` before `-i` (without which
the default decoder silently discards VP9 alpha and the whole check
would false-positive on genuinely-transparent WebMs).
- Update advisory wording from "3 sampled decoded frames" to "every
sampled decoded pixel" so the message is honest for short WebMs.
18/18 tests pass locally under `vitest run`.
Review feedback on #2358: the batch-miss RENDER_FAILED in
runAssetImportMany (asset.ts) throws the same typed error as
client.ts's single-node renderNode, but wasn't labeled — so
cli_error.endpoint would silently come back undefined for the
flow that most heavily exercises /v1/images.
cli_error had no way to tell which figma REST call (images, files_nodes,
variables_local, styles, ...) actually hit RATE_LIMITED/FORBIDDEN/etc, so
the dashboard could see failures spike but not which call caused them.
FigmaClientError now carries a low-cardinality endpoint label (never the
raw fileKey/nodeId), threaded through to cli_error's endpoint property.
Extends webmAlphaCheck.ts (from #2044) with a pixel-level decode probe.
After the tag check passes, decodes 3 sampled frames via
`ffmpeg -c:v libvpx-vp9 -pix_fmt rgba -f rawvideo` at 8x8 and emits a
distinct advisory if every alpha byte reads 255.
#2044 detects the "tag absent" failure mode (ffprobe shows no
`alpha_mode` in stream tags). It doesn't catch a stricter case reported
on CLI 0.7.56 / Windows 11: ALPHA_MODE=1 present but BlockAdditional
alpha side data empty. Under current logic webmAlphaAdvisory sees
`alphaMode: true` and stays silent, so the render ships as opaque
without any signal.
The -metadata:s:v:0 alpha_mode=1 push is a muxer directive that some
ffmpeg builds write unconditionally, independent of whether libvpx-vp9
emitted the alpha plane. Tag presence is necessary but not sufficient
evidence of preserved alpha.
Advisory text names both possibilities (opaque composition OR silent
alpha drop) plus the concrete workaround (png-sequence + prores repack).
Fast path (no tag or missing tag) is unchanged. Probe adds ~1s per
WebM render only when the tag says alpha.
Adopt the fake-timer pattern the sibling "recovers when a crashed reclaimer
leaves both lock directories" test in the same file already uses. Without
fake timers, if the dynamic `import("./manager.js")` beat between
`installFsMocks({ initialMtimeMs: Date.now() })` and the `withInstallLock`
call exceeds `staleMs` (50 ms on a busy shared runner with `vi.resetModules()`
per beforeEach), the new immediate-stale short-circuit added in #2328 fires
on iteration 1, breaks out before `waitedMs` reaches `waitNoticeMs=20`, and
no "Waiting for another hyperframes process" warn ever emits — the assertion
at `manager.test.ts:428` (`expected false to be true`) then fails.
Under fake timers, `Date.now()` is frozen at the mtime seed, so the lock
stays non-stale across the dynamic import and the wait-notice branch
observes real polling; `vi.advanceTimersByTimeAsync(staleMs + pollMs * 5)`
then drives the loop past both the wait-notice threshold and the stale
deadline so the reclaim + acquisition still resolves.
Test-only change; no production-code diff. Verified 27/27 in
`packages/cli/src/browser/manager.test.ts` under `vitest run`.
* fix(cli): three occlusion-probe false-positive sources in text_occluded
- pointer-events:none text is invisible to elementFromPoint, so the probe
always hit whatever paints beneath and misread visible text as buried;
restore hit-testing on the element for the duration of the probe
- a backgroundImage counted as opaque regardless of alpha, so a 4%-alpha
grid/scrim gradient qualified as an occluder; gradients now occlude only
when their colours reach alpha > 0.6 (url() images unchanged)
- a visible container whose every text-bearing descendant is still at
opacity 0 (entrance not started) was probed anyway; skip when no text
ink is on screen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): address review — document-wide hit-testing restore, gradient compositing, whitespace ink
- restore hit-testing for ALL pointer-events:none elements during the text
audit pass (not just the probed text): an occluder that itself carries
pointer-events:none is invisible to elementFromPoint, which made truly
buried text read as clean once the text alone became hittable
- hasVisibleTextInk ignores whitespace-only text nodes (indented markup
defeated the gate) and uses a 0.05 floor so mid-fade text keeps its
persistence occurrences
- hasOpaqueBackground composites gradient alpha with background-color
(two 0.5-alpha layers paint at ~0.75); gradientMaxAlpha returns opaque
for any colour function it cannot score (oklch/lab/...); percentage
alpha values now parse as fractions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): walk the elementsFromPoint stack in occluderAt
A transparent layer that becomes hittable (pointer-events restored) must
not mask an opaque occluder painting beneath it — single-point
elementFromPoint returned the transparent top and dropped two genuinely
buried cases in the census acceptance run; the stack walk keeps 10/10.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): composite stacked background-image layers when judging occluder opacity
Two 0.5-alpha gradient layers paint at 0.75 combined; taking the max
color-stop alpha across the whole declaration under-counted them and
suppressed real text_occluded findings. Split layers at top-level commas
(paren-aware), score each, composite as 1-prod(1-a_i). Also pins the
0.05 text-ink floor with a boundary test (review feedback on #2357).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): keep walking the occlusion stack past pair-specific exemptions
sharedPreserve3d and isCrossSceneTransitionOverlap excuse one hit, not
the whole probe; returning null let a transparent decorative layer in
the text's 3D context mask a real occluder below it (review feedback).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## What
Make Player connection resources symmetric and scope slideshow media ownership.
## Why
Reconnect could retain destroyed handles, while slideshow operations scanned and mutated unrelated document media.
## How
Null and recreate connection-owned resources idempotently and introduce an OwnedMediaRegistry with abortable cleanup.
## Test plan
- [x] Player reconnect, media-scope, slideshow, and owned-media registry tests
- [x] Stack-wide lint, format, build, typecheck, and relevant integration gates
## What
Version the Player/runtime protocol and pin injected core runtime code to the Player release.
## Why
An unversioned CDN runtime and untyped messages allowed silent host/runtime drift and ambiguous frame timing.
## How
Define RuntimeProtocolV1 with capabilities and rational fps, generate the versioned runtime URL, validate major versions, and retain a tested legacy fallback.
## Test plan
- [x] Player, Core, and Studio protocol tests; Player build-time runtime-pin verification
- [x] Stack-wide lint, format, build, typecheck, and relevant integration gates
## What
Verify publish artifacts as clean consumers instead of only scanning tarball manifests.
## Why
A package can contain all declared files and still fail when installed or imported by Node, TypeScript, or a browser bundler.
## How
Pack workspaces, install them into isolated fixtures, validate relative imports, and execute representative consumer entry points.
## Test plan
- [x] packed-manifest unit tests; clean packed install and consumer smoke
- [x] Stack-wide lint, format, build, typecheck, and relevant integration gates
* feat(studio): glue API coexistence layer for the NLE swap
What: extends 21 glue files so the OLD timeline/canvas engine and the NEW
NLE components type-check side by side: playerStore (multi-select setters,
zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain
optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/
timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the
NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props
gain optional callbacks/params, contexts gain *Optional hooks, and
TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting
both engines' change shapes. patchDocumentRootDuration's test rides along.
Why: this is the keystone that dissolves the old "welded glue" problem —
every symbol the NLE components need is ADDED next to what the old engine
still uses, so the engine components and the swaps can land as separate
reviewable PRs.
How: 15 authored intermediate files (main content + additive symbols; no
behavior changes — new fields optional, new callbacks unused until wired)
plus 6 files whose final content is already purely additive. New exports
without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed
by the app-shell swap.
Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines
compile); bunx vitest run (full suite green incl. the 6 new
patchDocumentRootDuration tests); fallow audit clean.
* feat(studio): timeline interaction hooks and lanes component (unwired)
What: the timeline-side wiring layer, unwired: TimelineLanes (the lane
renderer driving drag/resize/marquee), timelineMarquee (+tests),
useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning,
useTimelineEditingDrops.
Why: everything between the pure drag math and <Timeline> itself; the
timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these.
How: new files, tsc-clean against the coexistence layer. Unwired components
carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap.
Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow
audit clean.
* feat(studio): NLE shell assembly (unwired)
What: EditorShell (the full editor layout replacing NLELayout +
StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and
useTimelineEditCallbacks (the callback bag bridging store edits to the
timeline), all unwired.
Why: the shell that App swaps to in the final step; reviewing it standalone
keeps that swap PR small.
How: new files against the coexistence layer; TEMP(studio-dnd) entries
until App mounts EditorShell in the app-shell swap.
Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit
clean.
* feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine
What: flips the timeline glue to its final form (23 files): Timeline and
TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag
drives preview/commit through the new drag engine, range selection goes
multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9
old-engine files this orphans (group drag, marquee selection, snap targets,
layer gutter, selection overlays + their suites) — each is compile- or
gate-forced by this swap, verified by probe.
Why: second swap step; timeline-only, canvas and App untouched.
How: modified files to final content + forced deletions.
playerStore/timelineEditing/timelineCallbacks stay at their coexistence
form until the app swap (the old App still runs on them).
Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean.
* feat(studio): clip thumbnail modules
What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode
with SVG/AVIF format fallbacks and rounded-corner clipping — plus
VideoThumbnail updates.
Why: the decode layer for timeline clip thumbnails, ahead of the visual
refresh that renders them.
How: new modules + one modified file; purely presentational.
Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit
clean.
* feat(studio): assets/blocks panel behaviors + preview helpers
What: blocks tab install flow, right-panel and global drag-overlay polish,
music beat analysis and clip-content rendering hooks, and the
preview-helper utilities backing asset preview.
Why: completes the studio NLE stack on top of the visual refresh.
How: modified files only (kept as one PR: splitting further would produce
sub-150-LOC fragments of interdependent panel glue).
Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc
--noEmit; fallow audit clean.
* fix(studio): restore timeline playback loop
* fix(studio): restore missing GSAP helpers module
* refactor(studio): split timeline GSAP helpers
* style(studio): keep timeline helper under size limit
* fix(studio): restore timeline overlays module
* fix(studio): remove stale GSAP import
* fix(studio): restore canonical timeline dependencies
* style(studio): format restored timeline helpers
* style(studio): satisfy helper line limit
* fix(studio): repair rebuilt timeline integration
* feat(studio): complete rebuilt NLE cutover
* fix(studio): guard project and timeline race boundaries
* fix(studio): preserve graded resize and crop geometry
* fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local
* fix(studio): treat duration-0 tweens as static holds and settle resize position before persist
Instant holds (to()/fromTo() with duration 0) were classified as animated
tweens by every commit route, so resizing or rotating them converted the
hold into a corrupt duration-0 keyframes tween (new value at 0%, old at
100%) that GSAP drops; panel edits appended a losing set. A shared
isInstantHold() now routes them through the static replace-in-place path,
and percentage math guards zero-duration windows.
Separately, anchored-corner resizes painted 3-5 frames at the new size but
old position while the offset persist round-tripped the server. The commit
path now applies the corrected GSAP position synchronously before awaiting
the offset persist, mirroring the scale route's settle.
* feat(studio): gesture-transaction seam with commit observability
Introduce runGestureTransaction — one owner for a gesture commit's
settle -> persist -> record lifecycle. It settles the live DOM
synchronously before any async persist, folds every mutation into one
undo entry via a per-transaction coalesceKey, restores pre-gesture state
exactly once on failure, and asserts (dev console) + reports (PostHog:
commit_transaction / commit_invariant_violation / commit_transaction_failed)
that a persist never changes pixels. The box-size resize path is migrated
onto it; the ad hoc per-route coalesceKey/reload handling is removed.
Extract the resize draft-rect math into resizeDraft.ts to keep the
gesture-handler file under the size cap.
Also: keep url_hash telemetry to the route slug only (drop the query
string, which carried the user's selected element id/selector), and gate
the [hf-resize] diagnostics behind localStorage hf-resize-debug so they
ship as opt-in tracing rather than console noise.
* fix(studio): transaction owns the undo label
The coalesced history entry took the last sub-mutation's label, so a
resize surfaced as "Move layer" (the offset persist) in undo/redo. The
seam now stamps tx.label on every wrapped mutation, so the folded entry
reads as the gesture.
* fix(studio): atomic static size/position commits (no data loss)
Static resize/position holds updated an existing set via delete+add — two
undo entries, and a delete that succeeded before a failed add lost the
hold on disk. Use one in-place update-properties mutation when a set
exists (one undo entry, no partial-failure window). The keyframed-hold
heal that can't be expressed as a property update now adds before it
deletes, so any single failure leaves a recoverable duplicate, never a
lost hold. Transaction-owned commits are tracked via a WeakSet so the
heal path never double-wraps an already-wrapped gesture.
* fix(core): restore timed-clip visibility after a forced timeline rebind
__hfForceTimelineRebind force-rendered the re-registered timeline but never
re-ran the per-[data-start] visibility pass, so after undo or soft reload
every clip rendered regardless of its time window until a full page reload.
Extract the visibility loop into syncTimedElementVisibility and call it from
both syncMediaForCurrentState (unchanged) and the rebind.
* fix(studio): atomic z-order/keyframe/split commits, one undo entry each
Three edit-commit paths hardened onto the one-transaction invariant:
- Z-order reorder (useElementLifecycleOps): N per-element writes now fold
into one undo entry (coalesceMs Infinity) and, on a failed persist,
restore already-written files to disk so no partial reorder survives.
- Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate
convert phase no longer full-reloads the preview (skipReload), killing the
black-flash remount; convert + edit share one coalesce key = one undo entry.
- Razor split-all (useRazorSplit): snapshot before the batch and restore on
any failure, so a mid-batch error never leaves un-revertable partial splits.
Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams,
readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit
logic across these paths. Commit options thread as one partial object rather
than field-by-field. Test setup extracted into colocated helpers.
* fix(studio): fold multi-step edits into one undo entry; guard text revert
- Gesture recording (useGestureCommit): the per-property-group commits now
share one coalesce key and only the last reloads, so a recording is one
undo entry and one preview reload instead of up to four.
- Delete selected keyframes (deleteSelectedKeyframes, split out of
timelineEditingHelpers): N removals fold into one coalesced undo entry
with a single reload.
- Text-field commit (useDomEditTextCommits): commitDomTextFields now uses
the same version-guarded revert as handleDomTextCommit, so a stale failed
commit can no longer stomp a newer successful one.
* feat(studio): batch a gesture's mutations into one atomic server write
A transaction that emits N mutations previously did N sequential POSTs,
each rewriting the file and soft-reloading — the root of the multi-phase
persist window. Add a gsap-mutations-batch endpoint that validates every
mutation up front, applies them in one in-memory rewrite chain, and writes
the file once (all-or-nothing: an invalid entry rejects the whole batch,
no partial write). The seam buffers a transaction's commits and, when more
than one targets the same file, dispatches a single batch — one write, one
history entry, one reload. The batch capability rides on the existing
commit-function reference; no option fields are threaded through callers.
* fix(studio): soften off-canvas indicator outline to 30% opacity
The dashed off-canvas selection outline at 60% was noisy with many
protruding elements on screen; drop the resting opacity to 30% (hover
still restores full opacity so it stays discoverable).
* fix(studio): drop off-canvas indicator outline to 10% opacity
Follow-up to the 30% softening — 10% resting opacity reads much calmer
with many protruding elements; hover still restores full opacity.
* fix(studio): gate [hf-commit] console traces to dev only
The start/settled/persisted/restore lifecycle traces logged on every
gesture commit in all environments — console noise for end users. Route
them through a dev-only traceCommit helper (matching the pixel-violation
error's existing DEV gate). The commit_* PostHog events stay always on;
they are the production observability, the console lines are a dev aid.
* fix(studio): count actual reloads, not softReload requests, in commit telemetry
A resize's size and offset persists both request softReload; the seam
counted each request, so a batched gesture reported reload_count 2 even
though the batch is one write and one reload. Compute the count from what
dispatchBufferedCommits actually did — one for a batch, the request count
for the sequential fallback.
* fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop
- Hover overlay applied the element's rotation only to the selection chrome,
not the hover box; it now rotates about center like the selection, via a
shared orientedGroupAwareOverlayRect router (one owner for rotation-aware
overlay geometry across hover/selection/off-canvas).
- Off-canvas indicator was axis-aligned; it now rotates with the element and
inverse-rotates the canvas-exclusion clip into the element's local frame,
so the protruding-sliver clip stays correct for rotated elements.
- Crop commit re-lifted the element only in the commit's .then(), so one
frame painted the cropped state (the flicker). Re-lift synchronously right
after onStyleCommit (which applies the clip before its first await), so the
cropped state never paints; the persisted file value is unchanged.
* fix(studio): address code-review findings across the commit-hardening campaign
Correctness (would ship green, bite under latency):
- Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert
folds into one undo entry instead of splitting past the 300ms default.
- The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the
field); multi-keyframe delete and convert coalesce correctly when SDK-routed.
- Razor split-all's rollback is guarded so a failing restore can't swallow the
error toast that tells the user the split failed.
Simplification (single source of truth / no dead flexibility):
- Decompose resolveResizeDraftRect (drops a fallow-ignore suppression).
- Delegate the third readProjectFileContent copy to the shared helper.
- Inline setPatchFromUpdateProperties (its only caller passes one mutation).
- One toSdkPersistOptions translates gesture overrides to SDK options.
- Bundle the reorder-rollback deps into one object (was 7-9 positional args).
- Dedupe the 'last group reloads' ternary; type gesture options as
CommitMutationOptions; drop a Map+array wrapper around a single write.
* feat(studio): atomic z-order reorder via batch patch-element endpoint
Z-order reorder issued N per-element inline-style patches (one server
write each), so a mid-chain failure could leave a partial reorder on disk.
Add a patch-elements-batch endpoint that validates every patch, folds them
over the file in one in-memory rewrite, and writes once (all-or-nothing;
unsafe input rejects with no write). The reorder now sends one batch per
source file and records one undo entry. Because a failed atomic write
persists nothing, the interim disk-write-back rollback (restoreReorderedFile
/ restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure
rolls back only live DOM/store state. Closes the last disk-atomicity gap.
* fix(studio): razor-split undo no longer silently no-ops
The split clone was written to disk without a data-hf-id, so the split
endpoint recorded that unstamped HTML as the undo entry's afterHash. The
next reloadPreview() ran the preview route's ensureHfIds write-back, which
minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk
hash no longer matched afterHash and editHistory's content-mismatch guard
silently refused the undo (no write, no network, no error). Stamp the split
output via ensureHfIds in splitElementInHtml before it is written/returned,
so the preview write-back is a no-op and the recorded afterHash always
equals the final on-disk bytes. Fixes at the source rather than relaxing the
mismatch guard. Corrects the stale comment that credited forceReloadSdkSession.
* feat(studio): closed-hand grab cursor on the rotate handle
The rotate handle used the default arrow cursor; show a grabbing
(closed-hand) cursor on hover to signal it's grabbed and dragged to rotate.
* fix(studio): dropping a dragged element over another no longer selects it
A moved drag's release fired the box click, which re-selected whatever now
sat under the pointer via the hover cache — so dropping an element over a
higher-z one selected the drop target instead of keeping the dragged
element selected. The drag-move branch now suppresses the next box click,
mirroring the resize branch.
* fix(studio): group drag is one undo entry, not one per element
Dragging a multi-selected group committed each member's position write as
its own undo entry, so reverting took N Cmd+Z presses. Force a shared
coalesceKey (infinite window) across every member's commit so they fold
into a single undo entry, like the other multi-step commit paths.
* fix(studio): undo of a split no longer leaves a ghost clip in the timeline
The file and the composition iframe revert correctly on undo, but the
timeline panel kept a ghost node for the split clone. The element-merge
that repopulates the timeline preserves elements the fresh scan dropped —
intended for enriched sub-composition children a bare DOM re-scan misses,
but it also preserved a genuinely-removed TOP-LEVEL element (the split
clone after undo), leaving a phantom clip. Restrict the preserve to
elements with a compositionSrc (the enriched sub-comp children); a
top-level element missing from the fresh scan was truly removed.
---------
Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
The `hyperframes feedback` convention only prompted for a free-text
`--comment` "with the failing composition pattern and what you tried".
Agents dutifully filed vague reports (blank CJK text, mid-run exit, 4K
timeout) with no error string, no failure-mode, and — critically — no
published composition, so none could be reproduced or root-caused.
Two additions to the CLI skill:
- Lead bug reports with `--file-issue` (+ `--dir`), which publishes a
minimal repro of the project to a public URL. A comment alone almost
never lets a maintainer reproduce; the composition is what does.
- Give the `--comment` a concrete bug checklist: exact error string
verbatim + whether output was produced / fell back / hard-exited; the
isolated trigger; exact command + HF_*/PRODUCER_* env; frame/timestamp +
visual defect. Drop the "repeat env" ask (the CLI already attaches it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>