mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
5edaf6db7ab0c0c2ce16a7292d7e848d314423d8
475
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
89db718899 |
feat(studio): mirror canvas z-order actions into timeline lanes (track order = default paint order) (#2380)
* feat(studio): mirror canvas z-order actions into timeline lanes, badge z overrides
Track order = default paint order; authored z = advanced override.
- timelineZMirror.ts: pure resolver mapping a successful z-menu action to a
timeline lane move — closest track in the action's direction that is free
over the clip's whole span, else a new lane adjacent to the crossed
neighbor; temporal-overlap scope (default pending product sign-off, see
module doc); visual zone only; same-file reference scoping; persistTrack
via the shared authored-space rules. null for non-clips (menu stays
z-only) and at-extreme/no-overlap cases.
- useCanvasZOrderTimelineMirror.ts: after the z commit resolves, the mirror
persists the lane move through the same machinery as a timeline lane drag
(optimistic store update, authoredTrack refresh, rollback); inserts reuse
commitTrackInsert's renumber via a shared buildTrackInsertEdits core. Both
writes share one coalesce key (zReorderCoalesceKey) and fold into ONE undo
entry (test proves it over the real history reducer). The mirror never
triggers the lane->z stacking sync, so it cannot fight the z values the
action just set.
- timelineZOverride.ts + TimelineClip badge: clips whose paint order
contradicts lane order among temporally-overlapping same-context visual
neighbors (laneIsAbove XOR paintsAbove, the stacking-sync predicates) show
a 'z' badge — authored z overrides are surfaced instead of silently
disagreeing with the timeline.
- Timeline.tsx track derivations extracted to useTimelineTrackDerivations
(600-line cap).
* fix(studio): fold mirrored z-order gestures into one undo entry across slow persists
Live verification caught the z write and the mirrored lane write splitting
into two undo entries: the mirror runs after the z persist's server round
trip, which exceeds editHistory's default 300ms coalesce window under real
latency (the unit test's deterministic clock sat inside it).
zReorderCoalesceKey now mints a per-gesture-unique key (monotonic seq, the
laneChangeGestureSeq precedent) and both records carry coalesceMs Infinity —
distinct gestures can never merge, and one gesture always folds regardless
of write latency. coalesceMs threaded through the persist chain alongside
coalesceKey. Also hardens the existing lane-drag move->z fold, which had the
same latent split. Fold test now simulates a 400ms gap (failed before the
fix, passes after); a two-separate-gestures test asserts two entries.
* feat(studio): flashless lane mirror, z-order menu icons, close-gap track menu
- Track-only batch moves (the z-mirror's lane hop and the insert renumber)
skip the GSAP fallback round-trip and the preview reload entirely — the
renderer never reads data-track-index, and the live DOM patch + optimistic
store update cover the UI. Mixed batches keep current behavior. Kills the
canvas blink on mirrored Bring/Send actions (live-verified: an
iframe-scoped marker survives the whole gesture).
- The four z-order menu items get 16px stroke icons (single layer diamond +
directional arrow for Forward/Backward; pierced two-layer stack for
Front/Back); labels unchanged — they are the industry-standard names.
- New track context menu on empty lane space: 'Close gap' (shifts the next
clip and every clip after it on that lane left by the clicked gap's width;
leading gaps count, so a single clip with empty space before it compacts
to 0) and 'Close all gaps' (whole lane contiguous from 0). Pure gap math
in timelineGaps.ts; persists through the drag path's atomic batch move
(one undo per action); refuses when a clip that must shift is locked;
items disable when there is nothing to close.
* fix(studio): rebind-only preview sync for unmutated timing edits, classical z-menu order
Timing edits that rewrote NO GSAP positions (gap closes and moves of
selector-addressed caption clips, zero-delta batches, comps without a
rewritable script) full-reloaded the preview — and the rerun-current-scripts
attempt was wrong for real compositions: re-executing init-style scripts
(three.js scenes, caption engines) is exactly the unsafe case, verified live
by doubled init warnings and a fallback reload anyway.
The correct observation: when mutated === false the existing __timelines are
still valid — only the runtime's clip visibility windows are stale, and the
live DOM timing attributes were already patched. So the no-mutation path now
runs applySoftReloadFinalization only (seek + __hfForceTimelineRebind +
manual-edits reapply), extracted from the soft-reload machinery — zero
script execution. This also un-blinks comps with no GSAP script at all,
which previously always remounted. Rewritten-script soft reloads,
cannot-soft-reload, otherFileChanged, and mutation failures keep their
existing behavior. gsapSoftReload's undo/redo restore section moved verbatim
to gsapUndoRestore.ts for the 600-line cap.
Also: z-order menu items reordered to the classical arrangement (Bring to
Front, Bring Forward, Send Backward, Send to Back).
Live-verified on a three.js-heavy composition: Close-all-gaps shifted 4
caption clips with correct cumulative amounts, the preview iframe was never
remounted (marker survived), and one undo reverted everything.
* fix(studio): bound forward/backward mirror to a one-element step
User-specified semantic: Bring Forward / Send Backward move the clip past
EXACTLY ONE element. The mirror's lane target is now bounded by the next
temporally-overlapping element beyond the crossed neighbor: a free lane
strictly between the two is taken (closest to the neighbor), and when they
are back-to-back a new track is inserted immediately beyond the crossed
element — never past the second one. Previously the resolver took the
closest free lane anywhere beyond the neighbor, which could carry the track
past a second element while the z action only stepped past one — a
track/paint contradiction our own zOverride badge would flag. Front/back
keep whole-set semantics (past everything; back stays above the audio
zone). End-to-end test pins the 3-stacked case through commitZMirrorLaneMove
to the persisted renumbered tracks.
* feat(studio): permanent gap-menu rows with hover and click-select gap highlights
- TrackGapContextMenu always renders both rows; an inapplicable action dims
with a tooltip ("No gap here" / lock reason / "No gaps on this track")
instead of vanishing into a one-item menu. Width badge only when a gap
exists under the pointer.
- Hovering an ACTIONABLE row highlights the strip(s) it would close in the
timeline: the single gap for Close gap, every current gap (leading included)
for Close all gaps. New resolveAllGapIntervals in timelineGaps.ts reports
present-state intervals (epsilon-tolerant, overlap-safe), distinct from
resolveAllTrackGaps' post-compaction starts.
- Click-selecting a single clip paints a quieter tint over its lane's gaps
(suppressed for marquee multi-selection and during drags; the gap-menu hover
wins on its own lane). Derivation lives in useTimelineGapHighlights with the
pure buildTimelineGapStrips exported and unit-tested.
- Strips render in TimelineCanvas with the drop-placeholder geometry (row top
+ clip inset), dashed accent for hover, faint tint for selection.
- Timeline.tsx stayed under the 600-line cap by extracting the scroll-viewport
plumbing (ResizeObserver width + shortcut-hint sync) into
useTimelineScrollViewport, behavior unchanged.
* feat(studio): stronger capcut-style timeline zoom steps
One button press / pinch gesture now moves the zoom meaningfully: step
factors 1.25x/0.8x -> 1.5x/(2/3) (kept reciprocal so in+out round-trips) and
pinch sensitivity 0.0035 -> 0.007. Addresses "zooming several times to get
anywhere" feedback; cursor anchoring unchanged.
* feat(studio): three-way z sync — layers drags mirror timeline lanes, panel tracks live z edits
Completes the layers/canvas/timeline sync triangle: the Layers panel was the
one surface whose reorders never reached the timeline, and the one that went
stale when the other two wrote z flashlessly.
- Layers drag -> minimal z + equal-jump lane mirror. handleReorder now uses
the canvas menu's realization core via resolveZOrderReposition (one
between-z write when a strict gap exists, band-safe scoped renumber
otherwise) instead of computeReorderZValues' all-sibling stamp — that
helper is deleted, completing the #2347 unification follow-up. The drop
then mirrors into a timeline lane move through the same machinery as the
canvas menu (new resolveRepositionLaneMove: the clip lands on a free lane
strictly between its NEW paint neighbors' lanes — nearest clip siblings in
the desired render order, decorations skipped — else a track insert at
that boundary; audio zone never crossed). Both writes share one
per-gesture zReorderCoalesceKey with an unbounded fold window, so a drag
is exactly ONE undo entry; useCanvasZOrderTimelineMirror's plumbing is
factored into useMirrorLaneMoveCommit and reused by the new
useLayerReorderTimelineMirror. A same-slot drop is a hard no-op (new
order-equality guard in resolveZOrderReposition).
- Panel staleness fix: flashless z commits (skipReload) reload nothing and
bump no refreshKey, so the panel's z-sorted order went stale while paused.
handleDomZIndexReorderCommit now bumps a store zEditVersion on apply AND
rollback; the panel re-collects on it. Verified live: the panel re-sorts
the instant a drag commits and again on undo.
- Layer click reveal (useLayerRevealOverride): clicking a layer that stays
hidden at the current frame (animation-parked opacity, non-clip
display/visibility hides, hidden ancestors) temporarily forces the chain
visible with live inline styles — exact priors restored on deselect, on
another reveal, on play, and on unmount; never persisted (file diff == 0
verified live). Clips keep the existing seek-into-window behavior; the
override applies on a short defer so a seek-revealed clip needs none.
- layerOrdering's unused hasExplicitZIndex probe (zero callers) removed.
Live-verified on a bed copy: a 2-position layers drag wrote exactly one
element (z 6->23 + data-track-index 15->2), the timeline lane moved without
a reload, and a single Cmd+Z restored the file byte-identically.
* feat(studio): full-track selection highlight, borderless gap hover strips
- Click-selecting a clip now lights the WHOLE lane minus its clips — leading
gap, inter-clip gaps, and the open space after the last clip to the rendered
end (new resolveLaneEmptyIntervals; displayDuration threaded into the strip
derivation). Still click-only: any drag/resize suppresses the strips, and a
marquee multi-select never shows them.
- The gap-menu hover strips drop the dashed border (user feedback) — fill only,
nudged to 0.18 alpha to keep the same visual weight.
* feat(studio): selected layer paints on top via a reader-transparent z lift
Clicking a layer in the Layers tab now shows the element as if it were at the
very top of the stack while selected — whatever its authored z or panel
position — extending the reveal override (which already forced hidden chains
visible) with a temporary inline z lift:
- liftElementToTop parks the TRUE effective z in data-hf-reveal-prior-z and
writes a far-top inline z; a static element gets a layout-preserving
position:relative with its prior parked in data-hf-reveal-prior-pos. Only
the RENDERER sees the lift: all three studio z readers
(readTimelineElementZIndex, getElementZIndex, readEffectiveZIndex) return
the parked prior while the attribute is present, so the canvas z-menu, the
zOverride badge, the lane mirror, the stacking sync, and the panel sort
keep reasoning on the element's real z.
- Strictly ephemeral: exact priors restored on deselect / another reveal /
play / unmount, each property only while it still holds the value the
override wrote (a later real edit is never clobbered). File diff == 0
verified live across a full lift/restore cycle.
- A z-reorder commit CONSUMES an active lift (handleDomZIndexReorderCommit
reads the parked position for its persist-position:relative static check,
then drops the attributes) — the committed z becomes the truth and the
later restore is a guarded no-op.
* fix(studio): flashless undo/redo — three full-reload causes in the soft-restore path
Cmd+Z blinked the canvas on essentially every undo. Three independent causes
in applyUndoRestoreToPreview, each sufficient on its own:
1. Master-view path gate: activeCompPath is NULL at the master view, so the
'paths[0] === activeCompPath' eligibility check could never match the
index.html restore and every default-view undo full-reloaded at the first
gate. Normalized to the codebase-wide 'activeCompPath ?? "index.html"'.
2. Nested identity innerHTML check: the diff compared each identified
element's innerHTML, but the composition root wraps every clip — any child
change re-detected at the root rejected the restore. Change detection now
compares only each element's OWN attribute surface; structure/text
integrity is still guaranteed by the normalize-residual whole-doc pass
(text nodes, added/removed elements, and un-identified attrs all remain
after normalization and force the full reload).
3. id-only identity: elements addressed by data-hf-id / selector (no DOM id)
fell outside the diff entirely. Identity is now id OR data-hf-id, with the
live sync resolving either.
Also stop re-running an UNCHANGED GSAP script: attribute-only restores (z,
lane, timing, style — the overwhelmingly common undo) now use the rebind-only
finalization (seek + __hfForceTimelineRebind + manual reapply, zero script
execution — the same path as flashless timing edits), instead of tearing down
and rebuilding live timelines or full-reloading when the script can't be
scoped. A restore whose script text genuinely changed still re-runs it via
applySoftReload, and structural restores (split/delete) still full-reload.
Live-verified on the bed (iframe marker): gap-close undo AND redo both keep
the iframe mounted, live DOM lands on the restored values, disk restored
byte-identically.
* feat(studio): left breathing pad before t=0, double zoom sensitivity again
TRACKS_LEFT_PAD (48px) — the horizontal sibling of TRACKS_TOP_PAD: empty lane
surface between the sticky gutter and the ruler's 00:00 / the first clips,
scrolling WITH the content.
- The lanes and the ruler realize it as a plain flow spacer between the
sticky gutter cell and the time-mapped content div, so every
content-relative computation (clip left = t*pps, beat lines, lane-menu
time, clip drag deltas) is untouched by construction.
- Canvas-space overlays shift by the pad: playhead (getTimelinePlayheadLeft),
gap strips, drop placeholder, snap guide, range highlight, marquee clip
rects, beat SVG; the insert line spans the pad.
- Every pointer->time inverse subtracts it symmetrically: seekFromX, razor,
range/marquee anchors, asset drops, and the zoom-anchor gutter basis; fit
pps and the display width account for the consumed viewport width.
- Live-verified: t=0 clip edge, the 00:00 tick, and the playhead line center
all sit at GUTTER + TRACKS_LEFT_PAD, and a ruler click lands the playhead
center exactly under the pointer.
Also doubles the timeline zoom sensitivity again (user feedback after
feel-testing the first bump): button steps 1.5x/(2/3) -> 2x/0.5, pinch
0.007 -> 0.014.
* fix(studio): left pad renders as true empty space, not lane surface
The pad before t=0 inherited each row's background and bottom border from the
row wrapper, so it read as track lanes. Lane visuals now live on the cells:
the sticky gutter keeps its own separator (header column stays delineated),
the time-mapped content div carries the row background + separator, and the
pad spacer stays transparent — bare shell background, no lines. The
new-track insertion line also starts at the pad's end instead of crossing it.
* fix(studio): no vertical line in the ruler band before 00:00
The ruler corner's right border drew the header-boundary line through the
ruler strip, so the band didn't read as starting at 00:00. Dropped it — the
boundary line belongs to the track rows below; the ruler stays completely
clean from the panel edge to the first tick, matching the empty left pad.
* refactor(studio): remove the timeline z-override badge
User decision: the "z" chip on clips never earned its place — dropped
entirely (timelineZOverride.ts + test deleted, TimelineClip badge rendering
and the zOverrideKeys derivation/threading removed). This also eliminates the
review's D2 finding at the root: the badge's cross-document comparison
(stackingContextId ?? null collides across source files in the expanded view)
produced false positives, and there is no longer a detector to mis-fire.
overlapsInTime/paintsAbove lose their export (the badge was their only
external consumer); the paint-order predicate itself is unchanged.
* fix(studio): collision-free expanded child lanes and host-window gap floors
Review findings D1 (blocker) and 4.
- D1: buildChildElements assigned expanded children synthetic display rows as
`host.track + index` — integers that can EQUAL a real clip's lane in another
file (host on 0 with two children puts child #2 on 1). Lane grouping merges
purely by track number, so the collision fused clips from different source
files into one display lane, and lane-scoped actions (the gap menu) then
batch-persisted a foreign file's clip. Children now take FRACTIONS strictly
between the host's lane and the next integer — structurally unable to
collide with any normalized lane, while still rendering as ordered rows
under the host. Regression test pins the reviewer's exact two-file scenario.
- Finding 4: gap math compacted toward absolute 0, but an expanded child's
display time is host-anchored — close/compact could drag it before its host
window and persist a wrong (even negative) local time. All gap functions
now take a lane FLOOR (laneGapFloor: 0 for ordinary lanes, the children's
expandedParentStart for child lanes — single-origin per lane post-D1),
threaded through the menu model, hover highlights, selected-lane strips,
and both commits. Close-gap shifts clamp at the gap's own left edge.
* fix(studio): scope mirror references, insert writes, and crossed-neighbor identity
Review findings 1, 2, and 3.
- Finding 1: buildTrackInsertEdits normalized the FULL display set and
persisted every shifted clip — writing host-lane numbers into OTHER
composition files when expanded children were showing. The renumber write
set is now the edited element's own source file (the sanctioned multi-write
converges one FILE to lane space, never neighbors' files); foreign clips
keep their authored tracks and re-derive display lanes. The locked-clip
refusal scopes the same way. Expanded-origin elements refuse the insert
outright (a new lane is a host-space renumber, meaningless in the child's
file), and the mirrors restrict an expanded child's lane candidates to its
own siblings' lanes — a sub-comp child still mirrors WITHIN its sub-comp
(persisting the sibling's authored track) but can never land on a host lane
with no same-file occupant. authoredTrackForLane's offset fallback rounds:
fractional synthetic rows must never leak fractions into data-track-index.
- Finding 2: the mirror comparison sets required only sameSourceFile, but a
file can contain several CSS stacking contexts and leaf z is only
comparable within one. Both resolvers now scope by samePaintScope — same
source file AND same stackingContextId (the file check also stops null root
contexts of different files from comparing equal in the expanded view).
- Finding 3: the crossed-neighbor key was derived without selectorIndex, so
duplicate class selectors (.sub) resolved to occurrence 0 — a different
clip. The key now carries getSelectorIndex, matching how z-reorder entries
derive theirs.
* fix(studio): z-to-lane gestures are one serialized transaction gated on durable persists
Review findings 5 and 7.
- Finding 5: commitDomEditPatchBatches resolved successfully even when the
server matched NO patch target — the z write never reached disk (the
preview reloads to reconverge) yet the lane mirror still ran, desyncing
track order from what actually paints. The commit now resolves a durability
report ({allMatched, changed}; the save queue and commit types are generic
over the result), and the mirror phase is skipped on allMatched === false.
- Finding 7: the z persist rides the DOM-edit save queue while the lane move
rides the timeline/SDK path — two queues, so a second rapid gesture's z
write could land BETWEEN the first gesture's z and lane phases. Every
z-to-lane gesture (canvas z-order menu AND Layers-panel drag) now runs
through runZLaneGesture: a single module-level tail that serializes the
COMPLETE two-phase transaction, with unit tests for ordering, the
durability gate, and queue resilience to failed gestures. The timeline
lane-drag's inverse (move-then-z-sync) shares its phases' await ordering
already; cross-gesture serialization for that path is noted as follow-up.
- LayersPanel's pure sort helpers moved to layersPanelSort.ts (600-line cap).
* fix(studio): multi-clip GSAP batch mutations roll back on late failure
Review finding 6. finishGroupTimingGsapFallback mutates files sequentially
per clip; a late per-clip failure left the earlier rewrites on disk with no
aggregate history entry — unreachable by undo. foldGsapMutationIntoHistory
already snapshots every touched path before mutating; on a mutation failure
it now restores each path whose disk content changed (all-or-nothing batch),
reports restore errors without masking the original failure, and rethrows.
Regression test drives a two-clip batch whose second rewrite fails and
asserts the first clip's write is restored byte-identically.
* fix(studio): scope mirror inserts to their lane zone
* fix(studio): unify source-scoped clip identity
* fix(studio): isolate track insert topology
* fix(studio): harden timeline paint synchronization
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
|
||
|
|
19139b91ed |
fix(studio): make vertical lane moves persist correctly and harden the z/lane pipeline
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)
|
||
|
|
c6da79cbdb |
Merge pull request #2358 from heygen-com/07-13-fix_core_cli_attribute_figma_rest_failures_to_the_endpoint_that_failed
fix(core,cli): attribute figma REST failures to the endpoint that failed |
||
|
|
cb69d3fe00 | fix(render): surface structured outcomes (#2153) | ||
|
|
4ca1552e20 |
fix(core,cli): attribute figma REST failures to the endpoint that failed
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. |
||
|
|
cc3ca2f9f7 | fix(core): preserve runtime transport contract | ||
|
|
dcefdd98ca | fix(player): version runtime protocol | ||
|
|
df29fa7a5e |
feat(studio): revamps Studio + improves code quality (#2291)
* 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> |
||
|
|
0af07a07c5 | fix(core): enforce strict runtime safety | ||
|
|
687883124f |
fix(skills): hold frame content through transitions (#2235)
* fix(skills): hold frame content through transitions * test(storyboard): cover normal transition worker roots * style(skills): format transition injectors * chore: refresh skills manifest |
||
|
|
03c47dbb37 |
Merge pull request #2230 from heygen-com/fix/studio-graded-element-editing
fix(studio): graded elements survive manual editing (disappear/resize/rotate/crop/panel) |
||
|
|
5d1cafff82 |
fix(studio): address review findings on graded-element editing
Review follow-ups (both reviewers, all findings): - resize captures scope to the resize group: convert-to-keyframes resolvedFromValues and the whole-offset backfill pass the group filter, so an opacity-touching intro tween can't ride into a converted scale tween (the rotation fix's contract, now uniform across intercepts) - commitStaticSet resolves every group's target set BEFORE committing and coalesces groups landing on the same legacy mixed set into one commit — the second commit can no longer chase a stale group-derived id - installAuthoredOpacityCapture also stamps an element the moment it GAINS data-color-grading at runtime (attributeFilter), not just at insertion - both writer twins now share the same emitted-set dedupe shape - applySoftReload's positional tail becomes a SoftReloadOptions object - readAllAnimatedProperties builds the group-filtered key set immutably instead of deleting from the set mid-iteration - applyAuthoredInlineOpacity documents the priority-lossy round-trip - the marquee hit-test reads activeCompositionPathRef like its neighbors New tests: resize intercept (scale route + group filter + non-uniform longhands), after-write-HTML / stamp / empty-stamp opacity restore, the no-op-commit-with-missed-instant-patch soft-reload contract, and the runtime-gained-grading stamp. |
||
|
|
67cfae2587 |
refactor: address review nits
- merge gsapResizeIntercept's duplicate module imports - move the core-constant imports to the file headers (picker, domEditingDom) - justify the cross-realm HTMLElement casts (iframe-realm nodes fail instanceof; access is duck-typed) |
||
|
|
179c942cd2 | fix(core): make timing masks safe for source loaders | ||
|
|
3147c8e063 |
fix(core): capture authored inline opacity at parse time and follow source geometry
The color-grading engine hides its source element with inline 'opacity: 0 !important', so any code that later reads or re-captures the element's opacity sees the hide instead of the authored value. Stamp the authored inline opacity on every [data-color-grading] element at document parse time (MutationObserver installed at runtime-bundle eval, before any composition script runs) and prefer the stamp when hiding/restoring. Also re-sync the grading canvas when the source's inline geometry mutates (rAF-throttled style observer): a studio drag moves the source via its transform, which fires no media event, so the visible canvas froze in place until the next seek. |
||
|
|
b95ddd74d5 |
fix(cli): honor direct entries in keyframe shots (#2217)
* fix(cli): honor direct entries in keyframe shots * fix(core): rebase entry-authored asset paths for direct-entry bundling |
||
|
|
16ab8b2935 | fix(core): preserve playhead during volume probing (#2143) | ||
|
|
718c67b387 |
fix: escape NUL bytes in HFMASK regex (Bun blank renders) + Windows junction for studio preview links (#2140)
* fix(core): escape NUL delimiters in HFMASK mask token and restore regex Raw 0x00 bytes in the maskInertRegions token and restore regex made timingCompiler.ts binary to git and shipped raw NULs into dist/cli.js. Bun's transpiler (<= 1.3.11) corrupts raw NULs in regex literals into literal backslash-uFFFD text, so restore never matched: every masked <style>/<script> region was dropped, the player never initialized, and bunx renders produced blank white frames showing HFMASK tokens. Use \u0000 escapes instead, which survive any transpile layer, and add a byte-level regression test (behavior is identical under Node, so only a byte check catches this). Fixes the first half of #2139. * fix(cli): use NTFS junctions for studio project links on Windows linkProjectIntoStudioData called symlinkSync(dir, path, "dir"), which needs Developer Mode or elevation on Windows, so preview and dev in local-studio mode died with EPERM for default-configured users. Junctions need no privilege, work for directories, and keep the live write-back the studio depends on (a copy fallback would decouple the studio from the real project). Covers both preview and dev, which share the helper. Fixes the second half of #2139. |
||
|
|
a8f242e615 |
Merge pull request #2112 from heygen-com/vi/figma-scopes-retry
fix(figma): auth/retry/batch hardening, mapper fidelity, skill routing, setup docs |
||
|
|
c8eff1a4ba |
fix(core,cli): figma IMAGE fills dropped, rasterize double-paint, tokens false-success
nodeToHtml routed rasterize eligibility off node.type alone, so a RECTANGLE/FRAME with an IMAGE fill fell through to the generic <div> path — fillCss() has no IMAGE case, so it rendered an empty box. IMAGE-filled nodes now route to rasterize like vectors, regardless of node.type. Rasterized nodes (vectors, now image fills too) were also getting their own fill/corner-radius CSS applied on top of the already- rendered <img> — a flat color block behind/around the real art, flattening non-rectangular shapes into rounded rects. decorationCss now skips background and corner-radius/clip for rasterized nodes; opacity and effects still apply since those aren't baked into the export. tokens.ts's styles-fallback path hardcoded entries: [] regardless of how many published styles were actually found, so the CLI printed "recorded published style metadata instead" even when styles() returned zero results. Added styleCount to the result so the message reflects what happened, and points at the MCP get_variable_defs fallback when there's nothing to fall back to. Co-Authored-By: Claude Opus <noreply@anthropic.com> |
||
|
|
960c31f668 | fix(core): make generated artifact formatting deterministic | ||
|
|
0971f3b2a7 | chore(core): refresh position edits render artifact | ||
|
|
d2f32c831c | fix(core): narrow wrapped seek function type | ||
|
|
68ae4e5160 | fix(core,producer): harden render reapply wiring | ||
|
|
4e3d639e1a | fix(core): preserve position edit fold detection during seeks | ||
|
|
85bab88afb | fix(core,producer): render SDK position edits in producer pipeline | ||
|
|
87e2a70f9a |
fix(core,cli): address PR review — consistent 403 error shape, cap Retry-After, URL-safe ref split
Rames's inline findings on #2112: - forbiddenError now RETURNS in every branch (BAD_TOKEN no longer throws inside) so the caller's single throw covers all cases — no mixed throw/return contract for a future wrapping caller. - retryAfterMs capped at 60s: a spec-legal Retry-After: 3600 no longer silently blocks the CLI for an hour before RATE_LIMITED. - asset ref gathering extracted to gatherAssetRefs() and made URL-safe: bare fileKey:nodeId tokens comma-split, but a figma URL with commas in its query (multi-select node-id=1:2,3:4) is kept whole. - Documented in SKILL that 429 retry lives in the shared request path, so EVERY read endpoint retries (not just asset) — blast-radius note the reviewer asked for. variables intentionally still retries: its fallback is REQUIRES_ENTERPRISE-only, and a 429 there is transient, not a gate. Tests: retry-cap (3600→60000), non-styles endpoint retry, gatherAssetRefs URL-vs-bare split. client 24, cli asset 11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4fc699fee6 |
fix(core,cli): parse figma 403 body, batch asset fetch, fix NO_TOKEN box
Extends the scope+retry work from the figma bug-bash (valid report:
9-bugs-with-repros; the skill-not-used report was discarded).
- 403-body parse (bug 4): figma returns 403 {"err":"Invalid token"} for bad
PATs (NOT 401), and 403 {"err":"Invalid scope(s)… requires X"} for missing
scopes. get() now reads the body: "Invalid token" reclassifies to BAD_TOKEN
with re-mint advice; a scope body surfaces figma's own diagnosis verbatim;
else falls back to the endpoint's scope hint. Reads both err and message
(variables endpoint uses message). One fix, honest messages for bugs 1/4/9.
- Batch asset fetch (requested): figma asset accepts multiple refs
(space-separated or comma-joined) of one file and renders them in a SINGLE
/v1/images call via new client.renderNodes — figma's documented per-minute
rate-limit workaround. runAssetImport delegates to runAssetImportMany;
cache-checks per node, batches only the misses, one index.md regen.
- NO_TOKEN box (bug 8): errorBox indented only the first hint line, mangling
the numbered setup list. Indent every line; single-line hints unchanged.
Verified live: 3 refs -> 3 imports -> 1 request; bad token -> BAD_TOKEN not
scope advice. Client suite 22, cli figma 33.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
a8f86e653d |
Merge pull request #2068 from heygen-com/worktree-fix-timeline-zindex-reorder
feat(studio): lane-model timeline — vertical drag restacks via z-index |
||
|
|
1bb7688347 |
fix(core): name missing figma scope in 403, retry 429 with backoff
Two bugs from live figma-integration use: 1. `tokens` styles fallback 403s on non-Enterprise. /v1/files/:key/styles needs library_content:read — a scope the setup docs and the generic FORBIDDEN message both omitted, so the user saw "missing a read scope" with no way to know which. Each endpoint now carries a scope hint; the 403 names the exact scope (styles → library_content:read). Setup text and skill scope list updated to include Library content: Read-only. 2. `asset` (and every per-node component render) had no 429 handling — the message said "back off and retry" but the client didn't. Two imports in a row tripped the per-minute limit and hard-failed. get() now retries 429 with exponential backoff, honoring Retry-After when present, before surfacing RATE_LIMITED after maxRetries (default 3). sleep is injectable so tests don't wait. Batch multi-node asset syntax (the documented /v1/images comma-ids rate workaround) is a separate enhancement — retry makes the reported failure self-heal, including the many-node component path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a9901cb661 | feat(core): sub-composition variable render path | ||
|
|
f7ee0768ae | feat(core): declarative variable bindings — data-var-src, data-var-text, css custom props | ||
|
|
91831f3f8e | feat(sdk): variable usage scan + preview-values adapter seam | ||
|
|
839881a98e | feat(sdk): variable declaration edit ops (declare/update/remove) (#2047) | ||
|
|
fcbd4cb0f6 | feat(sdk): variable declaration read apis + browser-safe variables entry (#2046) | ||
|
|
1bc32bd47f |
fix(core): applyPositionEdits fails silently across iframe realms (#2096)
## What `applyPositionEdits(doc)` in `@hyperframes/core/runtime/position-edits` guarded each candidate element with `instanceof HTMLElement`. `doc` is frequently an iframe's document (the SDK's edit preview, any host embedding a composition), whose elements are `HTMLElement` instances of *that frame's realm* — never this module's. The check silently no-ops on every single element cross-realm, so bulk position edits never apply inside an iframe. ## Why Found during an audit of `@hyperframes/sdk`'s surface against pacific's movio integration. Pacific's `canvas-react` code has an explicit workaround comment for this exact bug: *"Upstream fix would be duck-typing in `@hyperframes/core` — until then, all host code must use this wrapper."* Every iframe-hosted consumer has had to reimplement the bulk-apply loop themselves to avoid it. ## How Use the document's own realm's `HTMLElement` constructor (`doc.defaultView?.HTMLElement`) instead of the module-scope global. Duck-type on `.style` when `defaultView` is unavailable (a detached/synthetic document). The single-element `applyPositionEditToElement` was already realm-safe — only the bulk wrapper had the bug. ## Test plan - [x] New regression test using a real jsdom iframe — confirmed it fails on the old `instanceof HTMLElement` check (0 applied, expected 1) and passes with the fix - [x] Full existing `positionEdits.test.ts` suite passes (14/14) - [x] Full `@hyperframes/core` suite passes (81 files / 1131 tests) - [x] `bun run build` clean (core + full workspace, incl. studio) |
||
|
|
d6d0fccbf2 |
fix(core): reproduce figma's vertical text trim via text-box-trim
A figma text node whose box is shorter than its line-height carries vertically-trimmed (cap-to-baseline) bounds. The mapper positioned the box at those bounds but let the browser lay glyphs with half-leading, pushing them ~6px low on a 70px font (glyph-centroid measurement against figma's own render: +9.1px vs figma's +3.4px inside the same pill). Emitting text-box-trim: trim-both / text-box-edge: cap alphabetic reproduces the trim in the render engine; post-fix centroid agrees within 0.4px and the motion verifier's min window score improved 20.3 -> 25.3dB. Trim applies only to single-line trimmed text; boxes matching their line-height are untouched. Skill: component imports now include a static fidelity self-check step against figma's PNG export of the same node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3d59dcc694 |
fix(core,figma-skill): address PR feedback — regex key escaping, no-shell psnr probe
- motionContextToDocs: escape regex metacharacters in arrayAfterKey / scalarAfterKey key interpolation (safe today for \\w+ keys; now safe for any future caller), and document balancedBlock's no-strings invariant. - verify-motion.mjs: execSync shell string -> spawnSync with array args (JSON.stringify is not shell escaping); verifier re-calibrated unchanged (faithful render still PASS at min 20.30dB). - command-failure-tracking: rebase folded the group-delegation skip into upstream's recursive wrapCommand (HF#2033) — leaf commands now assert their own flag tables, so `figma component --namee` is rejected at the leaf while `--name` passes the group; heuristic invariant documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a2243f7586 |
feat(core,figma-skill): mechanical motion translation + objective fidelity gate
Two guarantees so figma-motion imports can't drift from the design again: - motionContextToDocs(): raw get_motion_context response -> MotionDoc[], in code. Parses the motion.dev snippets (the reliable encoding; the CSS snippets stretch durations and can disagree), strips loop-wrap tail keyframes (sub-ms segments at the window end are the loop reset, not authored motion), preserves bezier eases verbatim. Fixture test uses the verbatim response from a real Motion timeline whose translation was frame-validated against Figma's own export_video render. - skills/figma/scripts/verify-motion.mjs: mandatory post-render gate. Compares motion-energy deltas between the render and the export_video ground truth so static import fidelity cancels out and the score isolates choreography. Calibrated on a faithful translation (min 20.3dB) vs a diverging one (min 5.0dB); threshold 15dB. The skill's Motion step now routes through both: no hand transcription, no unverified completion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
63ff046c30 |
fix(core): figma mapper prefixes digit-leading element ids
slugify("3D Object - Headphones") produced id="3d-object-headphones" —
valid HTML, but querySelector("#3d-…") throws (CSS idents cannot start
with a digit), which kills GSAP targeting and figma-motion translation
against imported components. uniqueSlug now prefixes digit-leading slugs
("n3d-object-headphones"). Found translating a real Figma Motion timeline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
dfe63af3ae |
fix(core,cli): parent-relative figma child geometry; groups stop rejecting subcommand flags
Both found running the brand-loop guide end-to-end against the Simple Design System: - nodeToHtml subtracted the ROOT origin from every node's absolute bounds, but CSS absolute positioning resolves against the nearest positioned ancestor — every nesting level re-added its ancestors' offsets, drifting nested content down-right and pushing deep children off-frame (hero buttons invisible, pricing grid collapsed to one card). Children now subtract their PARENT's box; regression test with a two-level tree. - trackCommandFailures asserted unknown flags against the command group's own (flagless) arg table even when the group was delegating to a subcommand, so `figma component <ref> --name x` imported and THEN threw "Unknown flag: --name". The assertion is now skipped when the first positional names a subcommand; leaf and non-delegating behavior is unchanged and covered by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5a48321726 |
fix(core): applyPositionEdits fails silently across iframe realms
applyPositionEdits(doc) guarded each element with `instanceof HTMLElement` — but `doc` is frequently an iframe's document (the SDK's edit preview, any host embedding a composition), whose elements are HTMLElement instances of THAT frame's realm, never this module's. The check silently no-ops on every single element cross-realm, so bulk position edits never apply inside an iframe. Use the document's own realm's HTMLElement constructor (doc.defaultView); duck-type on `.style` when defaultView is unavailable (a detached/synthetic document). The single-element applyPositionEditToElement was already realm-safe — only the bulk wrapper had the bug. Added a regression test using a real jsdom iframe, confirmed it fails on the old `instanceof HTMLElement` check and passes with the fix. |
||
|
|
c1b8815cb2 |
feat(sdk): export getRootElements/isNewHostBoundary/bareId, fix relative data-start
Closes gaps surfaced by pacific#30298 (hyperframes layer panel), where consumer
code had to hand-roll fixes for things the SDK/core already solve or nearly solve:
- getRootElements(): getElements() flattens the tree, so every descendant also
appears as its own top-level entry. buildRoots() already computes true roots
internally; this exposes it directly instead of making consumers re-derive
roots by filtering out descendant ids.
- Export isNewHostBoundary + bareId from @hyperframes/sdk: both already existed
internally (engine/model.ts) but weren't exported, so consumers were
duplicating sub-composition-boundary detection and scoped-id-to-DOM-leaf
conversion by hand.
- Export stripEmbeddedRuntimeScripts + RUNTIME_BOOTSTRAP_ATTR from
@hyperframes/core, and wire serialize({ stripRuntime: true }) on the SDK
session: a proper tokenizing implementation already existed in
compiler/htmlDocument.ts (handles more runtime-script marker variants than a
naive regex), just never exported. The SDK itself imports these via narrow
subpaths (./runtime/start-expression, ./compiler/html-document) rather than
the wide ./compiler barrel, matching the SDK's existing import convention and
avoiding pulling Node-only compiler code (fs/path) into browser bundles.
- Fix getElementTimings(): data-start can be a relative-reference expression
("intro", "intro + 2" — see parseStartExpression's grammar), not just an
absolute number. The old code did a raw parseFloat() on it, which silently
resolved any reference expression to 0. Now resolves references recursively
against the target element's own resolved start + duration, Node-safe (no
live GSAP timeline needed for this case).
14 new tests (session.timings.test.ts, session.subcomp.test.ts). Full sdk
suite: 417/417 passing. Full workspace build (incl. studio) verified clean.
|
||
|
|
9f6c20e482 |
perf(studio): grow composition duration live on extend, no preview remount
Extending a clip past the video end used to force the server-fallback path that fully remounts the preview iframe (the SDK fast path can't express the root composition's data-duration, and the runtime bakes+drops data-duration at load so it can't be patched live). On a large comp that remount is a visible hitch. Add a runtime control-bridge action set-root-duration -> clock.setDuration, so the studio can grow the transport length in place. On an extend the studio now posts it (and patches the clip's own timing live) instead of reloading; it only reloads when a GSAP source rewrite actually happened (the gsap-mutation endpoints now report a mutated flag). Non-animated extends — the common case — commit as fast as a normal edit. Verified: bridge dispatch + studio no-reload/post-message paths unit- tested; core/studio/studio-server typecheck + suites green; the built runtime artifact carries the handler; E2E confirms the extend no longer remounts the preview and still persists. |
||
|
|
070ee91694 | feat(studio): expose resolved z-index and stacking context on timeline clips | ||
|
|
6f0b57d608 |
fix(core): scope sub-composition html/body styles to the composition box (#2089)
Sub-composition <head> styles targeting html/body/:root (width/height/ overflow/background) were injected into the parent document unscoped by both the Studio runtime mount (compositionLoader) and the render-time inliner (inlineSubCompositions/htmlBundler). scopeCssToComposition deliberately passed html/body/:root through unchanged, so a sub-composition smaller than the root clobbered the host <body> dimensions and its overflow:hidden clipped the composite to the last sub-comp's size. Only the top-left element painted; everything else (and framework-owned video positioned outside that box) was clipped away. Add a scopeRootSelectors option to scopeCssToComposition that remaps html/body/:root to the composition's own box, and enable it everywhere sub-composition styles are scoped. The universal selector stays untouched. Top-level composition scoping is unchanged (it legitimately owns the document). Covered by new compositionScoping tests. |
||
|
|
57b3c78987 |
feat(media-use): color grading — grade/lut resolve, smart-grade, grade-compare + compare (#2041)
* feat(media-use): color grading — grade/lut resolve, smart-grade, grade-compare CLI Add color grading to media-use as first-class resolve types plus a faithful comparison command. All local, offline, deterministic — no model, no GPU. - resolve -t grade / -t lut: produce a data-color-grading block (or a frozen .cube). Look cascade: core preset (no file) -> bundled .cube library -> parametric buildCube. Emitted .cube is Rec.709 and validated against core's colorLuts constraints (LUT_3D_SIZE <= 64) before it is frozen. - smart grade (grade --for <media>): ffmpeg signalstats -> adjust suggestion (exposure / contrast / white balance), surfaced with the measured evidence on stderr as a starting point; never auto-applied. - hyperframes grade-compare: renders N candidate grades onto a reference frame through the real runtime shader into one labeled comparison PNG, so an agent picks a look without opening Studio. Prepends an "original" baseline cell by default (--no-baseline to omit). Shares the headless-capture pipeline with snapshot via capture/captureCompositionFrame. - media-use SKILL: proactive "media opportunity pass" guidance (grounded signal -> offer, ask once, surface don't mutate). Verified: media-use 116/116, grade-compare 7/7, snapshot 9/9, lint + format clean, full build green, comparison renders end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * test(cli): narrow grade-compare baseline assertion off unknown-typed grading Assert the whole cell via toEqual instead of reaching into .grading.preset / .grading.lut on the unknown-typed field, keeping the test typecheck-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * feat(media-use): agent-authored LUTs via --params + validate --from cube; never-read-.cube guardrail - resolve -t lut / -t grade --params '<json>': build a parametric .cube from explicit params (bypassing the intent cascade), validate, and freeze in one step. --intent becomes the optional description. Lets an agent commit a look it computed itself. - --from <file.cube> now validates the ingested LUT for lut/grade types and rejects an invalid/oversized cube (no partial write) — the escape hatch for a LUT the agent generated with its own code. - SKILL.md: hard rule to never read a .cube body into context (~size^3 lines, zero legible signal) — inspect via grade-compare (see it) or cube-validate (ok/size), read the manifest description for meaning; plus both authoring paths and the parametric-vs-film-stock ceiling note. Verified: media-use 116/116, lint + format clean; smokes — --params builds a valid frozen cube, grade --params returns a lut block, bad JSON and an oversized --from cube are both rejected with no stray file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * fix(cli): grade-compare validates referenced LUTs, warns on no-op cells, caps candidates Bug-bash follow-ups — grade-compare silently accepted bad input: - Validate LUT *content*, not just existence: each referenced .cube is parsed with core's parseCubeLut (now exported from @hyperframes/core) and rejected with a per-cell error ("LUT for \"<label>\" is not a valid .cube: ..."). A file that exists but isn't a valid cube no longer renders a silent no-op cell. - Warn on inactive cells: a grading that normalizes to inactive (e.g. a malformed {lut:12345}) emits a stderr warning naming the cell; the auto-prepended "original" baseline is intentionally inactive and stays silent. stdout remains valid JSON. - Cap candidates at 16 (excluding baseline): over-cap input renders the first N and reports {truncated:true, total:M} on stdout + a stderr note — no silent drop, no unbounded giant sheet. Verified: grade-compare 10/10; non-cube LUT → clear error; {lut:12345} → warning + ok; 20 cells → cells=17 truncated total=20; valid runs unchanged. Lint/format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * feat(cli): general `hyperframes compare` visual-variant primitive Generalize grade-compare's "render N variants → one labeled sheet → the agent looks and picks" loop into a standalone command that works on ANY variation (font, layout, motion, grade, whole compositions) — the tool never needs to know what differs. - `hyperframes compare <path...> [--at <sec>] [--labels a,b,c] [--out] [--cols] [--json]`: renders each agent-authored composition variant through the real runtime (captureCompositionFrame) and stitches one labeled comparison sheet + JSON ({ok, sheet, rendered, variants, truncated?/total?}). 2+ paths required; caps at 16 with loud truncation. It presents, it does not judge — choosing is the caller's job. - Factored the shared "render a labeled set → contact sheet" path so compare, grade-compare, and snapshot all sit on it (no duplication). grade-compare is now the first color-specific specialization of this primitive. - New pathArgs util + contactSheet test; hyperframes-cli SKILL documents compare as the agent's "see your own renders and choose" primitive. Verified: 26/26 across compare + grade-compare + snapshot + contactSheet (no regressions); compare renders 3 variants into one visibly-distinct labeled sheet; 2+-path error path clean; lint/format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * fix(ci): green the skills CI — skip ffmpeg tests when absent, oxfmt markdown The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by design — skills tests are meant to be node-builtin-only). The grade-analyzer + smart-grade tests shell to ffmpeg and were failing there with ENOENT. Guard them to skip when ffmpeg isn't on PATH; they still run locally / where it is. Also oxfmt README.md + hyperframes/media-use SKILL.md (the whole-repo `oxfmt --check .` Format job caught markdown left unformatted by the rebase conflict resolution). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * fix(ci): skip core-conformance test when tsx is unavailable The "Test: skills" CI job installs no deps, so the normalizeHfColorGrading conformance test (which imports core's TS via `node --import tsx`) failed there. Guard it to skip when tsx can't resolve; runs locally / in the deps-installed Test job. Completes the skills-CI greening (the ffmpeg guards handled the rest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * fix(cli): escape grade-compare src double-quotes (CodeQL XSS) + Windows-safe compare test - grade-compare built `<img src="...">` (double-quoted) with the single-quote escaper, leaving `"` unescaped — a `"` in the frame path could break out (CodeQL: incomplete HTML attribute sanitization). Use escapeXml for src. - compare label test hard-coded POSIX paths that can't match on Windows; assert the derived labels (the subject); path resolution is covered elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * refactor(media-use): generate LUT library from params (drop committed .cube files) The 3 bundled .cube files were 733 lines each (2,199 total) and were themselves buildCube output — pure repo bloat. Replace with compact per-look params in luts/index.json, generated on resolve; add an optional `url` for future scanned LUTs to be CDN-hosted + downloaded on demand (freezeUrl) instead of committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * feat(media-use): serve library LUTs from CDN on-demand (static.heygen.ai/luts), params fallback Looks now carry a CDN `url` (hosted at s3://heygen-public/luts → static.heygen.ai/luts/<id>.cube); resolve downloads + validates + freezes on demand, like bgm/image. `params` stays as the deterministic offline fallback (--local-only, or if the download fails), so resolution is never blocked on the network. Provider prefers url, falls back to params. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv * fix(media-use): address #2041 review — atomic LUT writes, compare telemetry, follow-ups - Atomic .cube writes: library provider (url + params) and the parametric generator now write to a .tmp path, validate, then rename, so a crash can never orphan an invalid .cube at the final path (was validate-after-write). - track("media_use_resolve") now emits provenance.via (url/params-fallback/params). - grade-compare + compare: --timeout flag (was hardcoded 5000) and a media_use_compare event (cells, truncated, total, render_ready_timed_out); openSettledCompositionPage now surfaces the render-ready timeout. - compare staging skips node_modules/.git; --for gets an upfront existence check. - Rec.709 luma comment; HYPERFRAMES_ANALYZE_TIMEOUT_MS override; measured note uses basename; LUT s3 hosting moved from index.json into luts/README.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5ebc5bb10f |
fix(producer): scope per-instance variables for repeated sub-composition mounts (#2070)
#2066 fixed sub-composition data-variable-values on the render path for a single mount, but the reusable-template pattern from #2064 (the same sub-comp mounted multiple times with different values) still diverged from preview/snapshot: every mount shared one __hfVariablesByComp key and one CSS scope selector, so the last mount's values clobbered the earlier ones and all-but-one instance rendered blank. The producer now assigns per-instance runtime composition ids (assignBundledRuntimeCompositionIds) and threads hostIdentityMap into the shared inliner, mirroring the preview bundler. The shared inliner's default buildScopeSelector already scopes by the runtime id, and timelines remap to it via the scoping proxy, so each instance's variables, CSS, and timeline land under its own id. Pixel-verified end to end: two mounts of one sub-comp with different data-variable-values now render their own content (green CARD_A / blue CARD_B), matching snapshot; single-instance behavior is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
41ad5b4690 |
fix(engine): inject sub-composition variables on the render path (#2066)
render left window.__hyperframes.getVariables() empty inside every
sub-composition mounted via data-composition-src, so each instance rendered
its declared JS defaults instead of the per-instance data-variable-values.
preview/snapshot injected them correctly, so the composition looked right in
every authoring/QA surface and then rendered wrong content silently (exit 0).
Any template-library workflow (reusable sub-comp scenes parametrized per
video) shipped placeholder/default text in the final MP4.
The plumbing already existed on main: htmlCompiler passes
readVariableDefaults/parseHostVariables and populates result.variablesByComp,
and the CSS-custom-property path (emitRootCompositionVariableStyles) reaches
the render. But the render compiler emitted only the CSS vars and never the
JS table window.__hfVariablesByComp that the scoped getVariables reads, while
the preview bundler (htmlBundler) did -- so getVariables() returned {} only
during render.
Fix, so the paths cannot drift again: buildVariablesByCompScript, colocated
with the reader in compositionScoping.ts and shared by both compile paths.
htmlBundler now calls it instead of an inline string; htmlCompiler injects it
before the inlined sub-comp scripts, using the already-populated
result.variablesByComp.
Verified end-to-end: a sub-comp painting its background from a color variable
now renders the injected value under render, matching snapshot; previously it
rendered the default. 3 new producer tests; 89 htmlCompiler + core-compiler
tests pass.
Closes #2064.
|
||
|
|
d9368ec051 |
fix(core): address PR feedback — ReDoS-safe slug trim, getVariables cleanups
- slugify: replace the anchored alternated trim regex (/^-+|-+$/g) with a character-scan trim — CodeQL js/polynomial-redos blocker. - readRenderOverrides: fold the readOverrides wrapper into the exported function (one name, no pass-through). - getVariables: deduplicate declarers with a Set, matching injectCompositionCssVariables. - Move the tokenSlug import to the top of the file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |