Skip to content

feat(studio): instant, flicker-free manual editing#1605

Merged
miguel-heygen merged 38 commits into
mainfrom
feat/instant-manual-edit-preview
Jun 22, 2026
Merged

feat(studio): instant, flicker-free manual editing#1605
miguel-heygen merged 38 commits into
mainfrom
feat/instant-manual-edit-preview

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

This is the final polish slice of the instant manual-editing work. It closes out the last of the visible flicker when committing a manual edit in the studio: a value/keyframe commit now updates the preview in place with no full iframe remount, the SourceEditor no longer re-saves (and re-reloads) on a programmatic content sync, and a composition that uses GSAP motionPath gets MotionPathPlugin reliably present — both in the served preview HTML and eagerly in the live iframe — so adding a motion path never takes the async-load flash path.

(Previously #1605 was the whole instant-editing feature. After the split it carries only this closing polish slice; everything below describes solely what is in this diff.)

What's changed

useGsapScriptCommits.ts — no full remount for soft-reloadable edits

  • applyPreviewSync: dropped the if (!applySoftReload(...)) reloadPreview() escalation. A softReload: true commit now calls applySoftReload and never escalates to a full iframe remount, even if the soft reload reports failure — the remount is the worst flash and re-inlines sub-compositions (reverting a subcomp's keyframes), while the live gsap.set already shows the committed value, so a transiently-failing soft reload still leaves a correct screen.
  • sdkRefresh: same invariant. extractGsapScriptText returning null (ambiguous/structural — zero/multiple scripts) still does a full reloadPreview(), but a single-script soft-reloadable edit only soft-reloads. Full reload stays reserved for the structural path.

gsapSoftReload.tsverifyTimelinesPopulated hardening + shared plugin URL + bootstrap

  • verifyTimelinesPopulated(win, targetKeys) checks the expected keys the script re-registers (parsed from __timelines["..."] in the script text) rather than "any non-empty key". A scoped soft reload re-runs exactly one composition, so the right success signal is "my target keys are back" — avoiding the transient false (global map momentarily empty right after the re-run) that used to escalate to the full remount. Falls back to "any key present" with no target keys.
  • Extracted the MotionPathPlugin CDN URL into a single MOTION_PATH_PLUGIN_CDN constant, shared by the soft-reload async fallback and the new bootstrap.
  • New ensureMotionPathPluginLoaded(iframe): pre-loads + registers MotionPathPlugin once per iframe load. Idempotent (no-ops if present or a load is in flight, guarded by __hfMotionPathPluginLoading); registers an already-present plugin without appending; no-ops without gsap.registerPlugin; clears the loading flag on both onload and onerror (CDN failure can be retried; the async fallback still covers it).

NLELayout.tsx — eager plugin preload at iframe load

  • onIframeLoad calls ensureMotionPathPluginLoaded(iframeRef.current) after baseOnIframeLoad(), so win.MotionPathPlugin is set before any studio edit. The first soft reload after a user adds a motion path to a composition that never used one runs synchronously instead of the async <script src> load path (which kills/clears the timeline mid-load — a visible flash).

SourceEditor.tsxExternalSync annotation breaks the re-save loop

  • Added an ExternalSync CodeMirror Annotation. The content-sync effect that pushes new source into the editor tags its dispatch with ExternalSync.of(true), and the updateListener ignores transactions carrying it. Programmatic content syncs (a manual-edit commit writing the source back into the editor) are no longer mistaken for user keystrokes, so they don't fire onChange → re-save → preview reload.

preview.ts — version-matched MotionPathPlugin in served preview HTML

  • New injectMotionPathPluginIfNeeded, wired into injectStudioPreviewAugmentations. When the served HTML uses motionPath (/motionPath\s*[:{]/, anywhere in the bundle — the plugin registers globally, so sub-composition usage counts) and doesn't already ship the plugin, it injects the MotionPathPlugin script directly after the composition's own gsap(.min).js tag (the plugin must register onto an already-loaded gsap, often at body-end not <head>), version-matched to that gsap (gsap@<version>) to avoid GSAP's minor-version warning. Falls back to <head> only when no gsap tag is found.

Why

Earlier slices removed most of the flash, but two paths still flickered on a manual edit. First, a soft-reloadable commit could still escalate to a full iframe remount whenever applySoftReload reported failure — and the common "failure" was a transient empty-__timelines read right after a correct re-run, so an edit that worked still flashed and re-inlined sub-compositions (reverting their keyframes). Hardening the verify + removing the escalation kills that. Second — the real remaining flash — the SourceEditor re-save loop: a commit writes new source into the editor, the update listener treated that as a user edit, re-saved, and triggered a reload; the ExternalSync annotation severs that loop. The MotionPath changes ensure adding a motion path never falls into the async plugin-load path that clears the timeline mid-load.

Testing

  • gsapSoftReload.test.tsapplySoftReload returns true when the re-run re-registers the script's expected key (hardened verify); editing composition A leaves B's timeline intact (scoped kill regression); runs synchronously (no async load, no CDN <script>) when MotionPathPlugin is already present (timeline repopulated inline, __hfForceTimelineRebind + __player.seek called); falls back to async load when the plugin is genuinely absent (onerror still runs the script). New ensureMotionPathPluginLoaded suite: null-iframe / missing-gsap no-ops; appends + registers once (sets/clears the loading flag); idempotent while loading; registers an already-present plugin without appending; clears the flag + allows retry on error.
  • useGsapScriptCommits.test.tsx — a softReload: true commit whose applySoftReload returns false no longer escalates to reloadPreview() (with/without instantPatch, with/without a prior failing patch); the structural case still full-reloads; the pre-change characterization test is retained with its assertion flipped so the change reads as intentional.
  • preview.test.ts — injects MotionPathPlugin when the composition uses motionPath, version-matched to its own gsap and ordered after the core gsap script; does not inject when there's no motionPath.

Stack

Part of the GSAP keyframe/motion-path stack: #1553 → #1554 → #1555 → #1607 → #1608 → #1609 → #1610 → #1611 → #1567 → #1612 → #1613 → #1605. Top of the stack, on #1613. (Was the whole instant-editing PR; now scoped to the final polish slice after the split.) Builds independently; combined diff byte-identical to the originally-reviewed work.

Bundled CJS deps like wawoff2 call __dirname; without the shim they throw
"__dirname is not defined in ES module" at render time. Also ignore .zed/.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 1ea09b1d. The four commits compose well and each has a clear story (drop escalation, harden verify + bootstrap plugin, break re-save loop, inject plugin server-side). Race-fix classification for the headline "flicker-free" claim: MOVE — the visible flash from reloadPreview() is structurally eliminated for the value-only path (the iframe never remounts), but a new failure mode is introduced: a silently stale preview when both the instant patch AND the soft reload report failure. The defense (live gsap.set already shows the value) is correct for the current playhead and shaky for any seek that follows. Details below.

Concerns

  • Silent staleness when applySoftReload returns false for a non-transient reason. The PR justifies dropping the → reloadPreview() escalation by citing transient verifyTimelinesPopulated flakes (now hardened by scoping to expected targetKeys). But applySoftReload returns false for several non-transient reasons too: !win.gsap, !win.__hfForceTimelineRebind, targetKeys.length === 0 (script has no parseable __timelines["…"] key), gsapScripts.length === 0 (live DOM has no GSAP <script>). These are now silently swallowed — the source file is current, the panel cache is invalidated, but the iframe's runtime is stale until the user happens to trigger a full reload some other way. At minimum, log a warning + emit a one-shot telemetry event from applySoftReload returning false so we'd see this regress in the field. Even better: keep the escalation for the permanent-failure signals (those four false paths above) and only drop it for the verify-failed path that motivates the change. Today's binary boolean collapses transient vs permanent into one signal — worth differentiating.

  • Observability gap on the fast-path-failed case (carries over from #1613). When patchRuntimeTweenInPlace returns false, the code silently falls through to applySoftReload. Combined with the above, a patch:false → soft:false sequence now produces zero visible signal and zero telemetry. Add a counter (PostHog or log) for instantPatch_failed and softReload_failed so we can verify the U4 invariant ("live state is already correct") holds in production, not just in the unit test fixture.

  • ensureMotionPathPluginLoaded race window. The bootstrap runs on onIframeLoad. Between __hfMotionPathPluginLoading = true and pluginScript.onload, a user-triggered soft reload that needs the plugin will see !win.MotionPathPlugin AND win.__hfMotionPathPluginLoading === true → the soft-reload's own async-load fallback (needsMotionPath && !win.MotionPathPlugin && win.gsap) fires, queuing a second <script> tag with the same URL. Both eventually load (browser caches the second), both executeScript() callbacks run — the second one tears down + re-executes the script the first one just registered. Idempotent but wasteful and visually a re-flash. The bootstrap's __hfMotionPathPluginLoading flag should be honored by applySoftReload's async path too (e.g. if (win.__hfMotionPathPluginLoading) wait-for-it instead of queue-another).

  • SourceEditor ExternalSync semantics. The annotation correctly suppresses the onChange callback on programmatic syncs — good. But the useEffect only dispatches when current !== content (the textual compare). After a manual-edit commit, the source file gets written server-side, onFileContentChanged?.(targetPath, after) fires, the editor's content prop updates, the effect re-runs and dispatches the new doc tagged ExternalSync. Fine. However: if the user is mid-typing when the commit lands (rare but possible — they kicked off a drag, then started typing in the editor before the commit returned), the current is the user's in-flight typed state, content is the server-write — they differ, so the effect replaces the user's typing wholesale, tagged ExternalSync, AND no onChange fires (because of the annotation). The user's in-flight keystrokes are silently lost without a save. Edge case but recoverable — worth either docstring + tracking, or a quick "did the editor have focus?" check before the replace.

Nits

  • injectMotionPathPluginIfNeeded uses regex gsap@([\d.]+) to extract the composition's gsap version. If the comp ships gsap from a non-@-versioned URL (e.g. self-hosted /static/gsap.min.js), version falls back to GSAP_CDN_VERSION (3.15.0) — which works, but mismatches the comp's actual gsap if it's a different version. Acceptable for now; just a known soft skew. (nit)
  • The 87-line growth of gsapSoftReload.ts puts it past the file's prior surface area; might be worth splitting ensureMotionPathPluginLoaded + the CDN constant into a sibling module before the next addition pushes against the 600-line cap. (nit)
  • applyPreviewSync doc comment in #1613 says "fall back to existing soft/full reload"; this PR drops the → full reload half. Update the comment in this PR's file too. (nit)

What I didn't verify

  • The actual cross-stack diff against main vs the stack tip — gh pr diff shows the per-PR slice and I trusted the PR body's claim that the combined stack diff is byte-identical to the originally-reviewed work.
  • That __hfForceTimelineRebind is guaranteed to be present on the preview iframe by the time a studio edit commits (the !win.__hfForceTimelineRebind → return false path is one of the silent-failure modes I flagged above).
  • The full keyboard interaction matrix for the SourceEditor mid-typing race — I traced the effect's textual compare path but didn't reproduce.

— Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 1ea09b1d. Concur with @james-russo-rames-d-jusso — the four commits compose well and each has a clear story, but the escalation drop's defense is scoped to the instantPatch-ran case while the drop applies universally. That's the load-bearing silent-staleness blocker.

Verified at HEAD:

  • ExternalSync annotation in SourceEditor.tsx: only the view.dispatch at :140 carries ExternalSync.of(true); the other dispatch at :152 is selection/scroll-only (no docChanged, listener doesn't run). No spurious suppression of unrelated dispatches.
  • verifyTimelinesPopulated regex /__timelines\s*\[\s*["']([^"'`]+)["'`]\s*]/g matches string-literal keys including whitespace variants. **Cannot match dynamic-key registrations** (window.__timelines[k] = tl). When zero keys parse → applySoftReloadshort-circuits toreturn false` early.
  • injectMotionPathPluginIfNeeded: /motionPath\s*[:{]/ regex matches sub-composition usage. Version-matching via match[0].match(/gsap@([\d.]+)/)?.[1] falls back to GSAP_CDN_VERSION (3.15.0) for non-@-versioned (self-hosted) URLs — no major-version compatibility check.
  • ensureMotionPathPluginLoaded: finalize closure sets win.__hfMotionPathPluginLoading = false on BOTH onload and onerror. Test at gsapSoftReload.test.ts:510 confirms retry works after a CDN load error.
  • "Byte-identical combined stack diff" body claim: spot-check main...1ea09b1d shows ~+6212/-699 across 84 files vs body's ~+6378/-849. Variance is rebase-adjustment-shaped. Roughly matches; not anomalous.

Concur with @james-russo-rames-d-jusso's BLOCKERs:

  • Silent staleness when applySoftReload returns false for a non-transient reason. The PR justifies dropping → reloadPreview() by citing transient verifyTimelinesPopulated flakes (now hardened by scoping to expected targetKeys). But applySoftReload returns false for several non-transient reasons too: !win.gsap, !win.__hfForceTimelineRebind, targetKeys.length === 0 (dynamic-key registration), gsapScripts.length === 0 (live DOM has no GSAP <script>). Each is now silently swallowed — source updated, panel cache invalidated, iframe runtime stale until a full reload triggers some other way.

    The body's defense ("live gsap.set already shows the value") is only correct for the value-only-drag path where instantPatch ran successfully BEFORE the soft reload would have fired. The escalation drop applies to ALL softReload: true commits. Concretely:

    • useGsapPropertyDebounce.flushPendingPropertyEdit ships softReload: true with NO instantPatch
    • commitKeyframedPosition, extendTweenAndAddKeyframe, commitFlatViaKeyframes add-keyframe, etc. all ship softReload: true with no instantPatch

    Maps to band-aid pattern #4 (defensive code that catches its own throw and treats failure as success) + #7 (silent failure fallback). Fix shape: differentiate transient (verify-failed) from permanent (!gsap, !__hfForceTimelineRebind, targetKeys empty, gsapScripts empty); drop the escalation only on the verify-failed signal that motivated the change. Strong concur with Rames on BLOCK.

  • Observability gap (patch:false → soft:false carries from #1613). Combined with the above, zero visible signal AND zero telemetry on the worst case. The U4 invariant ("live state is already correct") needs production evidence, not just unit fixture. Counter for instantPatch_failed + softReload_failed is cheap. NIT-leaning-BLOCK depending on how often this surfaces in practice.

  • ensureMotionPathPluginLoaded race window. Between bootstrap setting __hfMotionPathPluginLoading = true and onload/onerror, a soft reload that needs the plugin sees !win.MotionPathPlugin && win.gsap AND ignores the bootstrap flag → queues a SECOND <script> tag. Both eventually load, second tears down + re-executes the first's work. Idempotent but visible re-flash. Fix: honor __hfMotionPathPluginLoading in applySoftReload's async-load branch (poll/wait instead of queue-another). NIT-leaning-BLOCK depending on the practical hit rate.

  • SourceEditor ExternalSync mid-typing race. If user is mid-typing when commit lands: current !== content triggers the effect, dispatches with ExternalSync.of(true), OVERWRITES user's keystrokes, AND no onChange fires (annotation suppresses it). User keystrokes lost silently. Edge case but real. Fix: docstring + view.hasFocus() guard before the replace.

Concur with @james-russo-rames-d-jusso on nits:

  • injectMotionPathPluginIfNeeded falls back to GSAP_CDN_VERSION for self-hosted gsap → mismatches comp's actual version. Soft skew, low practical risk (HF ships 3.x).
  • 87-line growth of gsapSoftReload.ts past the file's prior surface area — worth a split before next addition pushes the 600-line cap.
  • applyPreviewSync doc comment in #1613 says "fall back to existing soft/full reload"; update in this PR's diff too.

Verdict: BLOCK. The silent-staleness escalation drop is the hard one; the mid-typing race + plugin race are smaller-but-real follow-ons. Once the escalation differentiates transient vs permanent failures (Rames's framing is exactly right), the rest is comment-level.

Review by Via

Array-form keyframe removal in both the recast and acorn writers, plus
update/add/remove-motion-path-point and add-motion-path. Exclude _auto and
data from tween property-group classification.
… motion-path sentinel, parity blocks

- Regression test for the `data` GSAP-key exclusion (parallel to _auto).
- splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the
  from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests.
- addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated.
- Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring
  removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug —
  documented as it.skip with a ready assertion (acorn cutover follow-up).
… stamping

Wire the new mutations into the file save route. Only authored clips suppress
descendant stamping, so auto-stamped animated scenes can inline-expand.

Hide in-flow timed clips with `display:none` only when they are LEAF clips (no
nested timed clips). `display:none` on a container removes its whole subtree,
hiding descendants that are still inside their own visibility window — e.g. an
in-flow composition root whose effective window clamps to the timeline end would
black out a child video that should still show (the hdr-hlg regression).
Containers keep `visibility:hidden`, which a visible descendant can override; only
leaves leave the flow, which is all the split-overlap case needs.
…lint rule

A position or rotation add/set mutation makes the GSAP timeline the single source of
truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS
var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both
channels, and the add-strip fires for the position AND rotation property groups.

Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio
cannot save drag/resize edits to elements in a registered timeline — the exact premise
the single-source work inverts (the timeline is now the edit target). Removed the rule,
its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests.
… cache, strip rotation channel

- HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript
  authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta,
  shift-positions, scale-positions, split-animations. (add stays out: flat tweens
  only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.)
- init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change;
  visible/hidden branches both go through isTimedClipInFlow (was .get() by accident).
- keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set
  strips the stale --hf-studio-rotation channel.
…, isZeroDurationSet, array-ease tests

- useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold
  (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once.
- Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent.
- parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior
  ease is stripped without shifting the other keyframes' percentages.
…e-parse guard, clearProps restore

BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the
commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the
server save, fixing both consumers (useEnableKeyframes + useGestureCommit's
showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation
return type widened void→Promise<void> (fire-and-forget consumers ignore it).
- stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live).
- commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek,
  so a failed commit leaves the dropped pose, not a cleared element.
…iant, donut limit, nearestPointOnPath t-semantics
…nd-aware geometry guard, gate createMode, screen-space drag threshold
…lace

Defensive runtime helper: locate the element's tween in window.__timelines via the
shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek
the playhead — without re-running the whole composition. Returns false (caller falls
back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed
keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant,
flicker-free manual edits.
…cline dynamic-expression patches

- resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose
  vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write
  {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props.
- patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch],
  instead of silently dropping the computed expression.
A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on
success the preview updates in place with NO reload (instant), on false it falls back
to the existing soft reload. Extracts the preview-sync tail into a testable
applyPreviewSync helper. No behavior change when instantPatch is absent.
…tPatch

Static-element position and rotation set commits now attach instantPatch{selector,
change:{kind:set}} so the drag updates in place with no reload. Structural ops (new
tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit
it and keep the soft reload — keyframe instant-patch needs object-form keyframe support
in patchRuntimeTweenInPlace (deferred).
…tion, patch both coalesced commits, wire onAsyncFailure

- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
  update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
  values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
  so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
  per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)
A softReload edit (and the SDK single-script refresh) no longer escalates to a full
reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set
already shows the value, and a remount is the worst flash + re-inlines subcomps
(reverting their keyframes). verifyTimelinesPopulated now checks the expected target
keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty.
Full reload stays only for the structural (no-softReload) and ambiguous-script paths.
…ync-flash

ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout
onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the
timeline. So when a user adds a motion path to a composition that didn't originally
use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN
async path (the flash). Idempotent + defensive; the existing async fallback stays for
genuine cold-start/CDN-failure.
The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged —
including the programmatic dispatch that syncs external content (e.g. a manual-edit
commit writing the source back into the open editor). That made the editor re-save the
file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe
edit — defeating the in-place instant patch and causing the flash. Annotate the
programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save.
…es motionPath

A studio-created motion path writes a gsap motionPath tween into the single-source
timeline, but the preview HTML only loaded gsap core — so the first render threw
"Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject
MotionPathPlugin right after the composition's gsap script, version-matched to it.
…odes + observability, SourceEditor focus guard

BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' |
'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full
reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script,
or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the
TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome
(origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted.
- SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an
  in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused).
- Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append).
@miguel-heygen miguel-heygen force-pushed the feat/instant-patch-wiring branch from decff5d to ff2be9d Compare June 20, 2026 21:21
@miguel-heygen miguel-heygen force-pushed the feat/instant-manual-edit-preview branch from 1ea09b1 to ca5d144 Compare June 20, 2026 21:21
Timeline inline expansion was dead for nested children inside index.html:
the tree keyed id-less elements by a synthetic __clip-N while the manifest
keyed them null, so parent<->child never joined. Both now resolve identity
through stableClipId (id || data-hf-id), which every generated element has.
Comps that ship a baked inline runtime were double-loaded (preview injects
its own) and the baked copy failed to parse inline (Unexpected token '<').
Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also
tag the comp root with data-composition-file so the studio resolves a comp's
top-level elements to the right source file instead of defaulting to
index.html (which made the GSAP panel parse the wrong, multi-timeline file).
Replaces the double-click-on-canvas UX (which painted text over the preview)
with a 'Set motion destination' toggle next to Snap/Grid, shown only when the
selected element can take a path. While armed, one canvas press places the
destination. Also removes the dead TimelinePropertyRows component.
Dropped clampDiamondLeft, which forced boundary keyframes fully inside the
clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's
midpoint now sits exactly on its % (the clip is overflow-visible).
…frames tween

Resizing an element with no size animation wrote keyframes:{ <playhead%>:
{width,height} } — one mid-point stop GSAP can't interpolate, so it rendered
NaN/0 dimensions at every other frame and the element vanished (worst off 0%).
Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize
now writes tl.set({width,height}), held at all frames; re-resizing updates it
in place.
Only successful probes were cached, so CORS/404 cross-origin media was
re-probed every rAF-driven timeline re-derive, flooding the console. Remember
failed URLs and skip them.
ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is
present and clashes with the DOM number the call returns. Type it number.
…oad clearProps

- Fix soft-reload clearProps destroying element inline styles — save cssText,
  clear, restore, strip only transform
- Fix resize no-op on re-resize: delete+add instead of two update-property
- Route set tweens through static resize path (convertToKeyframes skips sets)
- Re-fetch animation ID before drag commit to prevent stale-ID duplicates
- Guard editDebugLog for Node test environments
- Fix NLELayout setState-during-render (move reset to useEffect)
- Stop SnapToolbar pointer events propagating to canvas deselect handler
- Enable click-to-add waypoints on cubic motion paths
- Add whole-path drag offset (Alt+drag shifts all keyframes together)
- Add Canvas shortcuts section to ShortcutsPanel
- Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance)
- Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
@miguel-heygen miguel-heygen force-pushed the feat/instant-manual-edit-preview branch from 2011099 to 859d4f8 Compare June 22, 2026 04:51
@miguel-heygen miguel-heygen changed the base branch from feat/instant-patch-wiring to main June 22, 2026 05:20
@miguel-heygen miguel-heygen merged commit 091137e into main Jun 22, 2026
38 of 42 checks passed
@miguel-heygen miguel-heygen deleted the feat/instant-manual-edit-preview branch June 22, 2026 05:21
@github-actions

Copy link
Copy Markdown

Fallow audit report

Found 269 findings.

Duplication (206, showing 50)
Severity Rule Location Description
minor fallow/code-duplication packages/core/src/lint/rules/captions.ts:196 Code clone group 1 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:243 Code clone group 2 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:336 Code clone group 2 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:600 Code clone group 1 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:622 Code clone group 3 (37 lines, 2 instances)
minor fallow/code-duplication packages/core/src/lint/rules/gsap.ts:801 Code clone group 3 (37 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:313 Code clone group 4 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:345 Code clone group 5 (12 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:364 Code clone group 4 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:390 Code clone group 5 (12 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:404 Code clone group 5 (12 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:445 Code clone group 5 (12 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1603 Code clone group 6 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1746 Code clone group 6 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1915 Code clone group 7 (6 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1941 Code clone group 7 (6 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2141 Code clone group 8 (22 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2192 Code clone group 8 (22 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2320 Code clone group 9 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2355 Code clone group 10 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2369 Code clone group 11 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2385 Code clone group 9 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2402 Code clone group 12 (11 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2402 Code clone group 13 (12 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2404 Code clone group 10 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2415 Code clone group 13 (12 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2415 Code clone group 12 (11 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2420 Code clone group 10 (6 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2432 Code clone group 13 (12 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2492 Code clone group 14 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2503 Code clone group 15 (11 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2518 Code clone group 11 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2520 Code clone group 14 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2532 Code clone group 15 (11 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2563 Code clone group 16 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2593 Code clone group 17 (15 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2593 Code clone group 18 (9 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2593 Code clone group 16 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2621 Code clone group 18 (9 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2621 Code clone group 16 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2637 Code clone group 17 (15 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2637 Code clone group 16 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:2637 Code clone group 18 (9 lines, 3 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:625 Code clone group 19 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:740 Code clone group 19 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1335 Code clone group 20 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1363 Code clone group 20 (10 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:1457 Code clone group 21 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2112 Code clone group 22 (27 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:2147 Code clone group 23 (9 lines, 2 instances)

Showing 50 of 206 findings. Run fallow locally or inspect the CI output for the full report.

Health (63, showing 50)
Severity Rule Location Description
minor fallow/high-crap-score packages/core/src/runtime/timeline.ts:56 'normalizeTrackAssignments' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/core/src/runtime/timeline.ts:154 'buildTimelineClipLabel' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/core/src/runtime/timeline.ts:180 'collectRuntimeTimelinePayload' has CRAP score 212.0 (threshold: 30.0, cyclomatic 143)
minor fallow/high-crap-score packages/core/src/runtime/timeline.ts:218 'resolveMediaWindowEndSeconds' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-cognitive-complexity packages/core/src/studio-api/helpers/subComposition.ts:131 'buildSubCompositionHtml' has cognitive complexity 16 (threshold: 15)
minor fallow/high-crap-score packages/studio/src/components/StudioPreviewArea.tsx:140 '<arrow>' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
major fallow/high-crap-score packages/studio/src/components/StudioPreviewArea.tsx:163 'onDeleteKeyframe' has CRAP score 90.0 (threshold: 30.0, cyclomatic 9)
minor fallow/high-crap-score packages/studio/src/components/StudioPreviewArea.tsx:218 'onToggleKeyframeAtPlayhead' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
major fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:37 'useKeyframeToggle' has CRAP score 72.0 (threshold: 30.0, cyclomatic 8)
minor fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:172 '<arrow>' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
minor fallow/high-cyclomatic-complexity packages/studio/src/components/editor/DomEditOverlay.tsx:72 'DomEditOverlay' has cyclomatic complexity 25 (threshold: 20)
minor fallow/high-crap-score packages/studio/src/components/editor/DomEditOverlay.tsx:95 'selectionShapeStyles' has CRAP score 49.5 (threshold: 30.0, cyclomatic 13)
minor fallow/high-crap-score packages/studio/src/components/editor/DomEditOverlay.tsx:256 'handleOverlayMouseDown' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/components/editor/DomEditOverlay.tsx:350 '<arrow>' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/components/editor/MotionPathNode.tsx:9 'MotionPathNode' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
critical fallow/high-crap-score packages/studio/src/components/editor/MotionPathOverlay.tsx:340 'onPathDown' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/components/editor/SourceEditor.tsx:86 'mountEditor' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
critical fallow/high-crap-score packages/studio/src/components/editor/domEditingDom.ts:202 'escapeCssIdentifier' has CRAP score 148.4 (threshold: 30.0, cyclomatic 24)
minor fallow/high-crap-score packages/studio/src/components/editor/manualEdits.ts:119 'isTimelinePlaying' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/components/editor/manualEditsDom.ts:239 'stripGsapTranslateFromTransform' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
major fallow/high-crap-score packages/studio/src/components/editor/manualEditsDom.ts:274 'applyStudioPathOffsetViaGsap' has CRAP score 56.3 (threshold: 30.0, cyclomatic 14)
minor fallow/high-cognitive-complexity packages/studio/src/components/editor/manualOffsetDrag.ts:299 'createManualOffsetDragMember' has cognitive complexity 17 (threshold: 15)
critical fallow/high-crap-score packages/studio/src/components/editor/useDomEditOverlayRects.ts:114 'update' has CRAP score 283.7 (threshold: 30.0, cyclomatic 34)
major fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:8 'elementHome' has CRAP score 72.0 (threshold: 30.0, cyclomatic 8)
minor fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:28 'isPreviewHtmlElement' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
major fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:81 'tick' has CRAP score 90.0 (threshold: 30.0, cyclomatic 9)
major fallow/high-crap-score packages/studio/src/components/editor/useMotionPathData.ts:124 '<arrow>' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
major fallow/high-crap-score packages/studio/src/hooks/gsapDragPositionCommit.ts:103 'resolveDragRuntime' has CRAP score 56.3 (threshold: 30.0, cyclomatic 14)
minor fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeBridge.ts:76 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
minor fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeBridge.ts:109 '<arrow>' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeBridge.ts:137 'resolveGroupTween' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeBridge.ts:302 'tryGsapResizeIntercept' has CRAP score 46.7 (threshold: 30.0, cyclomatic 41)
minor fallow/high-cognitive-complexity packages/studio/src/hooks/gsapRuntimeBridge.ts:464 'tryGsapRotationIntercept' has cognitive complexity 19 (threshold: 15)
critical fallow/high-crap-score packages/studio/src/hooks/gsapRuntimeKeyframes.ts:200 'resolveRuntimeTween' has CRAP score 33.0 (threshold: 30.0, cyclomatic 30)
critical fallow/high-complexity packages/studio/src/hooks/gsapRuntimeKeyframes.ts:259 'readRuntimeKeyframes' has cyclomatic complexity 22 (threshold: 20) and cognitive complexity 41 (threshold: 15)
minor fallow/high-cognitive-complexity packages/studio/src/hooks/gsapRuntimeKeyframes.ts:324 'hasNonHoldTweenForElement' has cognitive complexity 17 (threshold: 15)
minor fallow/high-cognitive-complexity packages/studio/src/hooks/gsapShared.ts:112 'parsePercentageKeyframes' has cognitive complexity 24 (threshold: 15)
minor fallow/high-crap-score packages/studio/src/hooks/useEnableKeyframes.ts:110 'readElementPosition' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
minor fallow/high-crap-score packages/studio/src/hooks/useEnableKeyframes.ts:242 'promoteSetToKeyframes' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/hooks/useEnableKeyframes.ts:286 'applyArcWaypointAtPlayhead' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/studio/src/hooks/useEnableKeyframes.ts:339 '<arrow>' has CRAP score 71.3 (threshold: 30.0, cyclomatic 16)
minor fallow/high-crap-score packages/studio/src/hooks/useGestureCommit.ts:27 'partitionKeyframesByGroup' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
minor fallow/high-crap-score packages/studio/src/hooks/useGestureCommit.ts:133 'simplified' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
critical fallow/high-crap-score packages/studio/src/hooks/useGestureCommit.ts:273 'handleToggleRecording' has CRAP score 210.0 (threshold: 30.0, cyclomatic 14)
critical fallow/high-crap-score packages/studio/src/hooks/useGestureRecording.ts:55 'readBasePosition' has CRAP score 156.0 (threshold: 30.0, cyclomatic 12)
critical fallow/high-crap-score packages/studio/src/hooks/useGestureRecording.ts:90 'connectGsapRuntime' has CRAP score 210.0 (threshold: 30.0, cyclomatic 14)
minor fallow/high-crap-score packages/studio/src/hooks/useGestureRecording.ts:156 'computeIframeScale' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
major fallow/high-crap-score packages/studio/src/hooks/useGestureRecording.ts:164 'resolveGestureProperties' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
minor fallow/high-crap-score packages/studio/src/hooks/useGestureRecording.ts:279 'startRecording' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
critical fallow/high-crap-score packages/studio/src/hooks/useGestureRecording.ts:356 'tick' has CRAP score 156.0 (threshold: 30.0, cyclomatic 12)

Showing 50 of 63 findings. Run fallow locally or inspect the CI output for the full report.

Generated by fallow.

WaterrrForever added a commit that referenced this pull request Jun 22, 2026
The function was split out into gsapDragPositionCommit.ts in #1605, but
the test kept importing it from ./gsapDragCommit, which no longer exports
it — yielding `is not a function` at runtime. Import from the correct
module to match the production import in gsapRuntimeBridge.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WaterrrForever added a commit that referenced this pull request Jun 22, 2026
The function was split out into gsapDragPositionCommit.ts in #1605, but the
test kept importing it from ./gsapDragCommit, which no longer exports it —
yielding 'is not a function' at runtime. Import from the correct module.

Inherited main breakage (same fix as #1631); fixes the Test CI check on this
branch independently of merge order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants