fix(studio): restore timeline move/resize fallback parity (review #1466)#1539
Conversation
miga-heygen
left a comment
There was a problem hiding this comment.
Review: fix(studio): restore timeline move/resize fallback parity (review #1466)
Three commits, six files changed, +89/-33 — a focused fix PR that addresses three distinct review findings from the SDK cutover stack. Reviewed each commit's intent against the diff. Here's the breakdown.
Commit 1: Restore timeline move/resize fallback parity
Resize live DOM patch — data-playback-start/data-media-start restoration (useTimelineEditing.ts L215-231)
Good fix. The pre-cutover resize handler patched all three timing attrs on the live DOM; the §3.2 rewrite dropped the conditional playback-start/media-start patch. This correctly restores it: the playbackStartAttr check disambiguates the two possible attr names, and it only fires when updates.playbackStart != null. Clean.
Move fallback — GSAP position sync + reloadPreview (useTimelineEditing.ts L162-178)
The moveFallback closure correctly:
- Calls
enqueueEditwithcoalesceKeyfor undo parity - Computes
deltafromupdates.start - element.start - Calls
shiftGsapPositionsonly whendelta !== 0 && element.domId && pid— correct guards - Always calls
reloadPreview()(even when no GSAP shift is needed) .catchon the GSAP call logs but doesn't reject — the preview still reloads
One observation: shiftGsapPositions(...).then(() => reloadPreview()) means if the shift succeeds, preview reloads. But if the shift fails, the .catch fires and reloadPreview() is never called in that branch. The return reloadPreview() on L177 only runs when delta === 0 or !element.domId. This is probably fine in practice (a failed server mutation shouldn't reload a stale state), but worth confirming this is intentional — if the user expects visual feedback even on a failed shift, they'd get a stale preview. Logging is the right call either way.
Resize fallback — GSAP scale + reloadPreview (useTimelineEditing.ts L265-287)
Same pattern as move, same .catch behavior. The scaleGsapPositions call passes all six positional args (pid, targetPath, domId, oldStart, oldDuration, newStart, newDuration). Matches the export signature scaleGsapPositions(projectId, targetPath, domId, oldStart, oldDuration, newStart, newDuration) from the helpers file. Correct.
coalesceKey plumbing (timelineEditingHelpers.ts)
Adding coalesceKey?: string to PersistTimelineEditInput and passing it through to recordEdit — clean, non-breaking addition. The optional field means existing callers without a coalesceKey are unaffected. The key format timeline-move:${element.hfId ?? element.id} / timeline-resize:${...} mirrors the SDK branch's key, which is exactly the point.
One design note: using element.hfId ?? element.id for the fallback coalesceKey while the SDK branch used element.hfId directly (since SDK always has hfId). This means the fallback key could differ from the SDK key when hfId is null — but that's also exactly the case when the SDK path can't run (no hfId → no SDK), so undo coalescing stays consistent within each path. Correct.
hasPbsAdjustment comment improvement (useTimelineEditing.ts L253-256)
The old comment was terse. The new comment explains why the second clause exists (trimming start of a clip with pbs implicitly shifts the in-point, which setTiming can't express). Good documentation of a non-obvious guard.
Commit 2: Retire duplicate removeGsapKeyframe keyframeIndex variant
types.ts — Removed the { type: "removeGsapKeyframe"; animationId: string; keyframeIndex: number } union member. The percentage-based variant remains. This eliminates the TS ambiguity where two members share the same type discriminant but different shapes — TS can't narrow on type alone when two members have the same literal. Clean fix.
mutate.ts — dispatchRemoveGsapKeyframe simplified from a conditional branch to a direct call to handleRemoveGsapKeyframeByPercentage. The dead handleRemoveGsapKeyframe function (18 lines) is removed. resolveKeyframe is correctly kept — it's still used by setGsapKeyframe (which takes a keyframeIndex for setting values at a known position).
mutate.gsap.test.ts — The one test that used keyframeIndex: 1 is converted to percentage: 50. Test description updated from "removes keyframe at index 1 (50%)" to "removes keyframe at 50%". The percentage value 50 corresponds to the 50% keyframe in the test fixture. Verified this maps correctly.
No concerns here. The retirement is clean and the PR description correctly notes there are no production callers of the index-based API.
Commit 3: Gate ALL cutover persist paths on STUDIO_SDK_CUTOVER_ENABLED
sdkTimingPersist (sdkCutover.ts L174) — if (!STUDIO_SDK_CUTOVER_ENABLED) return false; added before the session check. Without this, sdkTimingPersist would run whenever an SDK session exists (which it always does for shadow/selection). This is the critical fix: without the flag gate, flipping the cutover flag OFF couldn't actually disable the timing cutover path.
dispatchGsapOpAndPersist (sdkCutover.ts L236) — Same pattern. This is the shared chokepoint for every GSAP-op cutover (keyframes, properties, tweens, arcs). Same reasoning applies.
sdkDeletePersist (sdkCutover.ts L340) — Same pattern for the delete path.
All three return false when the flag is off, which causes the caller's .then((handled) => { if (!handled) return fallback(); }) chain to take the fallback. Correct.
The comments on each guard are helpful — they explain why a simple session check was insufficient (session always exists for shadow/selection).
Cross-cutting observations
-
No new React hooks issues. The
useCallbackdependency arrays forhandleTimelineElementMoveandhandleTimelineElementResizelook correct.projectIdRefis a ref (stable identity),shiftGsapPositions/scaleGsapPositionsare module-level async functions (stable), andreloadPreviewwas already in the deps array. No missing deps. -
CI status: One
Detect changesjob failed but on a different workflow run (81980564073 vs the passing 81980631123). The core checks (Preflight, Preview parity, WIP) all pass. The failure looks like a flaky base-branch detect job, not a PR issue. -
No unnecessary complexity. Each fix is surgical — no new abstractions, no generalization for generalization's sake. The fallback closures (
moveFallback,resizeFallback) are local, well-scoped, and mirror the existing control flow. -
The
sdkTimingPersist"before" comment (L180-185 in the diff) is an honest documentation of a known limitation during the dark-launch transition: the SDK's serialized state may drift from disk while the server still writes some paths. Acknowledging this rather than hand-waving it is good. The "revisit once cutover is always-on" note is appropriate — this is acceptable tech debt for a gated launch.
Verdict
All three review findings (#1466 regressions, #1498 duplicate union, cutover gating gap) are correctly resolved. The fallback paths now have full parity with pre-cutover behavior. The flag gating ensures the stack is genuinely dark-launchable. No bugs, no missing deps, no unnecessary complexity. The minor .catch → no-reload observation is a conscious tradeoff (logged, not silent), not a bug.
LGTM — ready for stamp.
— Miga
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R2 verification — fix-PR for the 18-PR SDK cutover stack (reviewed at 18ea1c32)
Layering on Miga's R2 (LGTM at the same SHA) — I agree with their per-commit verdict and the .catch → no-reload observation. Adding what I tracked from my own R1 rollup on the parent stack + one fresh finding Miga's pass didn't flag.
R1 finding verification (cross-PR rollup)
A) Timeline fallback parity (originally flagged on #1500 / #1466) — ✅ resolved.
- Resize live DOM patch restored at
useTimelineEditing.ts:218-232— conditionaldata-playback-start/data-media-startpush reinstated, attr-name disambiguation mirrors thetimelineEditingHelpers.ts:72,83convention. - Move/resize fallback GSAP sync + reloadPreview restored at
useTimelineEditing.ts:162-178(moveFallback) and:262-287(resizeFallback). coalesceKeyplumbed throughpersistTimelineEditattimelineEditingHelpers.ts:100,123— undo granularity now matches between SDK and fallback paths.- Parity-harness gap I flagged on #1500 — PR body claims it was already closed at the stack tip with the WS-3.F shift/scale port. I didn't re-verify that on the tip (this PR doesn't touch the parity harness); taking on faith from the body and the merged shift/scale port.
B) Union retire — duplicate removeGsapKeyframe discriminator (originally flagged on #1498) — ✅ resolved.
types.ts:101member removed (thekeyframeIndexvariant);mutate.ts:153dispatcher simplified to direct percentage call; deadhandleRemoveGsapKeyframe(18 lines) deleted.- No remaining
removeGsapKeyframe+keyframeIndexcallsites in repo (grep -rn 'removeGsapKeyframe.*keyframeIndex' packages/is empty). The one test that referenced the by-index API converted topercentage: 50. resolveKeyframecorrectly retained —setGsapKeyframestill useskeyframeIndex, which is fine (single-variant op, no discriminator collision).- Per the dispatch-map silent-drop lens — no risk here, because TS union narrowing now leaves only one shape on
removeGsapKeyframe; any caller passingkeyframeIndexwould fail to type-check rather than silently no-op.
C) Cutover gating — the data-loss-on-Cmd-Z seam I flagged on #1522/#1524 — ✅ resolved, but worth being explicit about what resolved it. Two distinct mechanisms:
- The persist-queue race itself (SDK auto-write queue + Studio's explicit write double-writing the same path;
oldSession.flush()onforceReloadoverwriting undo writes) was already closed at HEAD byuseSdkSession.ts:78-121opening the session with no persist queue (openComposition(content, { history: false }), nopersist/persistPathargs) and the cleanup doingcompRef.current?.dispose()only — explicitly noflush(). The comment at L94-101 + L117-119 documents this design choice with the exact "would race the revert write on undo/redo reload" rationale I called out. - This PR's commit 3 adds the complementary defense: flag-off → cutover persist paths return false early → caller takes the legacy server path. Without commit 3, flipping
STUDIO_SDK_CUTOVER_ENABLEDto false would not have disabledsdkTimingPersist/ GSAP-op cutover / delete cutover (they only guarded on!sdkSession, and the session always exists for shadow/selection). So flag-off on merge is now a true no-op in prod — exactly what dark launch requires.
Together: the data-loss class is closed both at the design level (no queue, no flush) and at the rollout-control level (flag-off → legacy path). Both pieces are load-bearing.
D) Other R1 items I flagged across the stack — partially carried by this PR:
- #1471 fire-and-forget race on
forceReloadSdkSession(Promise-return / epoch token) — ➕ not in scope; the dark-launch gate makes it a flip-prerequisite rather than a merge-blocker, which matches the PR body's framing. - #1466
shiftGsapPositions/scaleGsapPositionsGSAP-sync restoration — ✅ resolved by this PR (Commit 1). - #1499
extrasboolean filter, #1500elements: []validation, #1490 NaN / 60fps querySelector — ➕ out of scope, deferred to follow-up per PR body.
Fresh findings
(concern, not blocker) No regression test for the dark-launch flag-gate. Commit 3 is the load-bearing fix for "flag off = true no-op", but sdkCutover.test.ts:15-17 mocks STUDIO_SDK_CUTOVER_ENABLED: true at module level and there's no sibling test covering flag-off → false return → fallback taken. The whole thesis of the dark launch rests on this contract, and without a test, a future refactor of shouldUseSdkCutover / the gate guards could silently re-enable cutover on flag-off without anyone noticing until prod observes it. A 3-line test per chokepoint (sdkTimingPersist, dispatchGsapOpAndPersist, sdkDeletePersist) with the module mock re-set to false would pin this. Not blocking — but worth landing as a follow-up commit before flip, since this is the contract you'll point at when flipping.
(nit) sdkCutover.ts:211-212 — the sdkGsapTweenPersist "add" early-out checks sdkSession.getElement(op.target) before the flag-gate inside dispatchGsapOpAndPersist runs. Benign (returns false either way), but slightly defeats the "don't touch SDK paths on flag-off" intent if you want strict purity. (nit)
Verdict
Approve with the test-coverage follow-up as a soft-blocker before flag-flip, not before merge. Commits 1-3 are all surgical, well-commented, and correctly resolve the R1 findings they target. Miga's verdict stands. The dark-launch gate is the high-leverage piece of this PR — without it, the stack was shipping data-loss-class code live on merge regardless of flag state. Stack is genuinely dark-launchable now.
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
R2 verification — stack-tip review-fixes PR (reviewed at 18ea1c32)
A late entrant to the parlour — Miga's verdict and Rames's verification are both posted above, and the gentleman is right to put them on the wire first. I find myself in pleasant agreement with the bulk of their conclusions, with two observations of my own to lay alongside: one that elevates the test-coverage soft-ask Rames already raised, and one fresh finding regarding a cross-queue stale-base squash hazard that neither pass appears to have inspected.
Verification of the three commits
Commit 1 — fb759b54 timeline fallback parity (closes the #1466 finding). The fallback closures moveFallback (useTimelineEditing.ts:166-178) and resizeFallback (:265-287) are hoisted to a shared local helper and called from BOTH the SDK-fallback branch (if (!handled) return moveFallback() at :194 / resize at :301) AND the no-session branch (return moveFallback() at :196 / resize at :303). Symmetric coverage on the parity contract — the exact shape the briefing required. No duplication-at-boundaries (pattern #1). The coalesceKey plumbing through persistTimelineEdit (timelineEditingHelpers.ts:100,123) mirrors the SDK branch's key, so undo granularity is identical on either path. The hasPbsAdjustment clause-2 comment at :253-256 correctly documents why a start-trim of a pbs-bearing clip cannot route through the SDK. Clean closure.
Commit 2 — 870872019 retire removeGsapKeyframe keyframeIndex variant (closes #1498). Verified at 18ea1c32:
- Union member removed at
types.ts:101— only thepercentagevariant remains. mutate.ts:153dispatcher simplified to a directhandleRemoveGsapKeyframeByPercentagecall; the deadhandleRemoveGsapKeyframe(18 lines) excised.resolveKeyframecorrectly retained —setGsapKeyframeis a different op that legitimately useskeyframeIndex(no discriminator collision, single-variant op).- The sole by-index test at
mutate.gsap.test.ts:402-418converted to thepercentage: 50API. - Dispatch-chain audit at
headRefOid:useGsapKeyframeOps.tsdoes not callremoveGsapKeyframedirectly (routes throughsdkGsapRemoveKeyframePersistwhich usespercentage); no remainingremoveGsapKeyframe.*keyframeIndexco-occurrences anywhere on the branch.
But — and here is where the parlour falls silent — see the cross-queue hazard below before the union retire is considered absolutely closed.
Commit 3 — 18ea1c32 dark-launch gate at all three persist chokepoints (closes the #1469 un-gated-flag finding). Verified at headRefOid:
sdkTimingPersist(sdkCutover.ts:174) —if (!STUDIO_SDK_CUTOVER_ENABLED) return false;at the head.dispatchGsapOpAndPersist(:236) — same. This is the shared chokepoint for every GSAP-op cutover (keyframes, properties, tweens, arcs), so gating here closes the entire family in one stroke.sdkDeletePersist(:340) — same.- The existing
sdkCutoverPersist(style/text/attr) gates via the canonicalshouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, …)helper at:138. So the regime is now: helper-based gate on the inline-style path (older), literal one-line guard on the three persist-function entry points (this PR).
The chosen closure shape is the right one. A more uniform refactor — broadening shouldUseSdkCutover to take ALL persist functions through it — would have been a larger diff in a stack-tip review-fixes PR, and the literal one-liner is honest about what it is. Without this commit, flag-off was an outright lie: sdkTimingPersist / GSAP-op / delete cutover ran whenever a session existed, which is always (shadow/selection opens one by default). The merge would have shipped the cutover bugs live regardless of flag state. With it, flag-off is a true no-op in prod and the remaining cutover correctness issues become flip-prerequisites rather than merge-blockers. Exactly the dark launch the PR body advertises.
Commit independence
The three commits could in principle land independently — commit 1 only touches the timeline editing surface, commit 2 only the SDK union + tests, commit 3 only the persist gates. No interdependence; the bundling is for ergonomic reasons (one fix PR off the stack tip rather than a restack of three mid-stack edits). Fine.
Findings
(blocker — cross-queue stale-base squash hazard on commit 2's union retire.) When this 18-PR stack cumulatively squash-merges onto current main, the keyframeIndex-variant retirement collides with packages/studio/src/utils/sdkShadowGsapKeyframe.ts — a file present on main (sha 38a63316) but ABSENT from this branch's base (sdk-ws3-unroll-dynamic). Two relevant lines on main:
:128constructs{ type: "removeGsapKeyframe", animationId, keyframeIndex }— the very variant this PR retires from the union.:20doc comment explicitly anticipates the collision: "SDK mapping (main, pre PR #1498 percentage-variant)… remove → removeGsapKeyframe{animationId, keyframeIndex} — landmine from PR #1498."
The author knew it was coming. The squash math will not delete sdkShadowGsapKeyframe.ts (the branch never had it, so the squash diff is computed against current main at merge time and leaves it in place). But types.ts post-merge will no longer contain the keyframeIndex member. Result: tsc red on main at line 128 — the constructed object matches no union member. This is the textbook cross-queue stale-base squash regression from the verify-r2-nits-across-stack discipline (Flavour 2).
Three possible resolutions, ranked by preference:
- Update
sdkShadowGsapKeyframe.ts:128in THIS PR to translate percentage → percentage instead of percentage → keyframeIndex. Adds the file to the diff and resolves the post-merge type error. The cleanest path; the author cited #1498 as the landmine, so the migration is overdue regardless. - Rebase the stack onto current main before squash-merge. Brings
sdkShadowGsapKeyframe.tsonto the branch and lets the migration land in the same PR; still requires a code change to migrate the call site. - Land this PR, then immediately follow with a one-commit fix-up PR migrating
sdkShadowGsapKeyframe.ts:128to the percentage variant before squash-merge. Acceptable only if the squash is gated on the follow-up landing first.
Option 1 is the cheapest fix and closes the door fully. I'd request changes on this one rather than waving it through as "the next reader's problem" — the "small footgun" pattern is exactly this shape.
(soft, escalating Rames's concern #1.) Rames already noted the missing flag-off regression test for the new dark-launch gates. I find his framing as "concern, not blocker" generous — under the local band-aid bar this is closer to pattern #1 (silent divergence in CI). The whole thesis of dark launch rests on the contract flag === false ⇒ persist returns false ⇒ caller takes fallback. The module mock at sdkCutover.test.ts:15-17 pins STUDIO_SDK_CUTOVER_ENABLED: true and there is no sibling test re-mocking it to false against sdkTimingPersist / dispatchGsapOpAndPersist / sdkDeletePersist. A future refactor of the gates — inverting a condition, hoisting into a helper, removing what looks like a "redundant" early return — passes CI green and re-enables cutover under flag-off. A three-line vi.doMock(..., { STUDIO_SDK_CUTOVER_ENABLED: false }) test per chokepoint would pin the contract you'll be pointing at when you flip the flag in prod. Worth landing before flip; not a merge blocker.
(nit, agreeing with Miga.) Miga observed that a failed shiftGsapPositions / scaleGsapPositions triggers the .catch and skips reloadPreview(). The arrangement is probably intentional (don't paint a stale preview on top of a failed server mutation), but the console.error is the only user-visible signal — a toast on persistent failure would be friendlier. The pre-cutover server path had the same shape, so this is parity-with-old-behavior rather than a new regression. Logging is the right primary call.
(nit, agreeing with Rames.) sdkGsapTweenPersist at sdkCutover.ts:211-212 checks sdkSession.getElement(op.target) before the inner flag-gate runs. Benign — the inner gate still returns false — but slightly defeats the "no SDK touch on flag-off" purity. A leading if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false); at the top of sdkGsapTweenPersist would match the discipline of the other three. Soft.
Verdict
Minor blockers — hold for the sdkShadowGsapKeyframe.ts:128 migration before the stack squashes to main. Commits 1 and 3 close cleanly with zero new band-aids. Commit 2 closes cleanly within the stack but introduces a cross-queue stale-base squash hazard against main's sdkShadowGsapKeyframe.ts. The fix is one-line; I'd want it landed before the cumulative stack squash rather than after the inevitable red-CI ping.
The flag-test coverage gap (Rames) and the .catch-no-reload observation (Miga) are both correct and stand as soft asks before flag-flip but not before merge.
If the sdkShadowGsapKeyframe.ts migration lands in this PR (option 1) or the stack rebases to absorb the file (option 2), I expect to clear without further concerns.
Review by Via
Eight correctness bugs from the SDK-cutover review. Several were cases where BOTH writers were identically wrong, so the recast-vs-acorn parity suite stayed green; the new tests assert the real-world-correct result, not agreement. - #2 findKfPropByPct: match the CLOSEST keyframe within tolerance, not the first within 2% — removing/updating 50% on 0/49/50/100 no longer hits 49%. - #3 handleSetTiming: shift each tween by the start DELTA and scale duration by the clip-duration RATIO per-tween, instead of writing absolute newStart/ newDuration onto every tween (which collapsed staggers and blew durations). - #4 enableArcPath: insert motionPath via appendRight at the object start so the insertion can't collide with the x/y remove-range end (which made MagicString discard the append and emit '{}'). - #5 splitAnimationsInScript: compute the inherited baseline in a forward pre-pass so the split-spanning midpoint sees earlier tweens (the reverse write loop is kept for stable count-suffixed ids). - #9 unrollDynamicAnimations: preserve non-target loop-body statements (e.g. tl.set initial-state) per iteration instead of overwriting the whole loop. - #10 buildMotionPathObjectCode (both writers): emit the cubic form when segment curviness varies so per-segment curviness survives, not just segments[0]. - #11 readLastWaypointXY: handle UnaryExpression so negative destination coords are recovered when disabling an arc path. - #15 no-bang: removed every `!` non-null assertion in the touched files, replaced with guards/fallbacks. Tests: gsapWriter.reviewFixes.test.ts (#2/#4/#5/#9/#10/#11) and mutate.gsap.test.ts setTiming GSAP-sync block (#3). All fail on the base and pass after the fix; tsc + full core/sdk suites + parity stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/Via) flag OFF ⇒ sdkTimingPersist / sdkGsapTweenPersist (GSAP-op chokepoint) / sdkDeletePersist all return false even with a valid session → legacy fallback. The prod flag-flip rests on this contract; sdkCutover.test.ts only mocks the flag TRUE, so a future gate refactor could silently re-enable cutover on flag-off without failing CI. This sibling file mocks it FALSE and locks the three guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it, Via) The add-op getElement existence check ran before the inner gate, so flag-off did an SDK touch before falling back. Lead with the flag guard to match the other three chokepoints — flag-off is now a clean no-op at every entry point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks all — addressed: Flag-gate regression tests (Rames + Via, soft-blocker before flip) → added Leading flag-gate on
Cross-queue squash hazard on the union retire (Via, blocker) → verified it does not apply to this reconciled stack. Note: this PR also now carries the full correctness pass for the 13-finding stack review (commits |
miga-heygen
left a comment
There was a problem hiding this comment.
Re-review — PR #1539 at `f9bdd18fd5` (9 commits, 6 new since initial R2)
Picking up where the initial R2 left off at `18ea1c32` (3 commits). Six new commits landed since then — covering the 8 GSAP write-path findings, the merge-tween-props fix, debounce stabilization, GSAP write serialization, on-disk undo baseline, self-write identity, and the dark-launch flag-gate test + two post-review-note fixes.
New commits reviewed
`7d7732c8` — fix(core,sdk): correct 8 GSAP write-path review findings
Eight surgical fixes in the core writers, all correct:
-
`findKfPropByPct` closest-match (finding #2): Now tracks `bestDist` instead of returning the first hit within `PCT_TOLERANCE=2`. Keyframes at 49%/50%/51% no longer alias — the exact or nearest match wins, deterministic tie-break on index. Test `gsapWriter.reviewFixes.test.ts` pins this with a dense 0/49/50/100 fixture. Clean.
-
`enableArcPath` empty-vars collision (finding #4): The upsertProp → appendLeft overlap with removePropsByKey's end boundary is fixed by switching to `ms.appendRight(vars.start + 1, ...)` — inserts motionPath right after the opening brace, orthogonal to any removal range. The `survivesRemoval` check controls trailing comma insertion. Verified the test asserts no bare `{}` output.
-
`splitAnimationsInScript` forward baseline (finding #5): The entire reverse-accumulate-then-interpolate approach is replaced with a two-pass architecture: `computeForwardBaselines` runs forward to capture the inherited-props state available before each tween, then the existing reverse write loop uses those pre-computed baselines. This fixes the wrong-midpoint bug (x=150 instead of x=200) where a spanning tween read the reverse accumulator's state instead of earlier tweens' contributions. Well-decomposed — `buildSpanningSplit` and `applyTweenSplit` are focused helpers that each do one thing.
-
`unrollDynamicAnimations` sibling preservation (finding #9): The blanket loop-body overwrite is replaced with `buildLoopUnrollPreserving`, which iterates per-element and per-statement, substituting the loop index AST-based (`substituteLoopIndex`). Non-target statements (e.g. `tl.set(...)`) survive per iteration. The forEach/for-of fallback correctly detects "no numeric index variable" and degrades to the blanket overwrite (valid code, no dangling references). Test covers both for-loop preservation and forEach fallback.
-
AST-based index substitution (R2 #5): `substituteLoopIndex` walks the AST for real Identifier references, skipping string literals and non-computed member/key positions. The `isIndexBindingPosition` guard is correct — `obj.i` and `{ i: ... }` are left untouched. Test pins that `"row-i"` survives while `items[i]` is substituted per iteration.
-
Per-segment curviness (finding #10): Both the parser (`gsapParser.ts`) and serialize (`gsapSerialize.ts`) `buildMotionPathObjectCode` now emit the cubic form when `curvinessVaries` — segments with differing curviness no longer silently drop to the simple path (which serializes only `segments[0].curviness`). Test verifies the output is `type: "cubic"` and differs from the uniform-curviness case.
-
`readLastWaypointXY` negative coords (finding #11): New `readNumericLiteralNode` handles `UnaryExpression` with `operator: "-"` — negative waypoint coordinates (`x: -120, y: -40`) no longer vanish when disabling an arc path. Test round-trips enable→disable and asserts the negatives survive.
-
Non-null assertion cleanup throughout: All the `!.` assertions in `gsapWriterAcorn.ts` are replaced with proper nullish guards (`const first = waypoints[0]; if (!first) return []`, etc.). This is the right pattern — the assertions were correct at runtime but masked the TS narrowing. Each replacement preserves behavior while making the null path explicit.
`bcc0a44a` — fix(studio): SDK cutover review fixes — merge tween props, stabilize debounce, serialize gsap writes, on-disk undo baseline, self-write identity
This is the heavyweight studio commit. Five distinct fixes:
Finding #1 — merge tween properties: `mergeTweenProperties` in `useGsapPropertyDebounce.ts` reads the tween's current properties from the in-memory SDK doc and spreads the single edited key on top, so `setGsapTween`'s replace semantics don't drop siblings. The test documents the regression (single-key set → loses y and opacity) and verifies the merge. All six call sites (`flushPendingPropertyEdit`, `addGsapProperty`, `removeProperty`, `editGsapFromProperty`, `addGsapFromProperty`, and the inline remove-property) route through the merge. The `kind: "to" | "from"` discriminator correctly selects `anim.properties` vs `anim.fromProperties`. Fallback returns the single edit unchanged when the tween can't be found — safe default.
Finding #7 — debounce ref-stabilization: `sdkRef = useRef(sdk); sdkRef.current = sdk;` replaces the direct `sdk` capture in every callback's dependency array. Without this, the fresh object literal on every render re-fires the `useCallback` identity → re-fires the cleanup effect → premature flush. The dep arrays now contain only `[commitMutationSafely]` (stable). The React integration test (`useGsapPropertyDebounceFlush.test.ts`) renders with a fresh `sdk` wrapper every render, re-renders mid-debounce, and asserts zero premature commits. Good contract pin.
Finding #8 — per-file GSAP write serialization: `CutoverDeps.serialize` is an optional per-key serializer that `dispatchGsapOpAndPersist` uses to wrap its entire read→dispatch→serialize→write cycle. `useGsapScriptCommits.ts` wires it to the existing `serializerRef` (the same one legacy `commitMutation` uses for `gsap-file:${file}`). The interleave test constructs a blocking first write and verifies the second write's start comes strictly after the first's completion. Correct architecture — the serializer is the existing mechanism, the wiring just extends it to the SDK path.
Finding #12 — on-disk undo baseline: `captureOnDiskBefore` reads the pre-edit file content from the server (via the new `readProjectFile` on CutoverDeps), falling back to `serializedBefore` on error. Both `sdkTimingPersist` and `dispatchGsapOpAndPersist` use it. The tests verify the on-disk path and the fallback. `useTimelineEditing.ts` wires `readProjectFile: (path) => readFileContent(projectIdRef.current, path)` for both move and resize. Correct — undo now restores the exact file bytes instead of a normalized SDK re-emit.
Finding #14 — self-write identity registry: `sdkSelfWriteRegistry.ts` is a module-scoped FNV-1a hash registry keyed by file path. `markSelfWrite` records what we wrote; `isSelfWriteEcho` consumes a matching entry (single-use) within the TTL. `shouldReloadOnFileChange` in `useSdkSession.ts` uses identity when content is available, falling back to the time window when it isn't. The handler now attempts to read file content from the payload or via the adapter. This closes the undo-swallowed-by-time-window bug: an undo's reverted bytes aren't registered, so they never match → reload fires. The registry tests cover: echo match, undo miss, per-file isolation, single-use consumption, TTL expiry.
One observation on the self-write path: the handler does an async `readAdapter.read(compPath)` when payload content is unavailable. This introduces a small race — between the read and the decision, another write could land. But the identity check makes this safe: even if stale, the decision is "does this content match something I wrote?" — a stale read just means a missed suppression (reload fires, which is the safe default). No concern.
`fd002ad3` — refactor(core): extract split/collapse helpers
Pulls `buildCollapsedFlatVars` and several split helpers out of the monolithic `splitAnimationsInScript` to satisfy the no-fallow-ignore rule. Pure extraction — no behavioral change. The helpers are well-named and single-purpose.
`508be317` — test(studio): pin dark-launch flag-gate contract
This directly addresses Rames's concern and Via's soft-blocker from the first R2: three tests with `STUDIO_SDK_CUTOVER_ENABLED: false` verifying that `sdkTimingPersist`, `sdkGsapTweenPersist`, and `sdkDeletePersist` all return `false` without calling `writeProjectFile`. Exactly the contract pin both reviewers asked for.
`057e0d0e` — fix(studio): leading flag-gate on `sdkGsapTweenPersist`
Adds `if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false);` at the top of `sdkGsapTweenPersist`, before the `getElement` check. Addresses Via's nit about the pre-gate `getElement` SDK touch on flag-off. Clean.
`f9bdd18f` — fix(core): unroll-preservation regressions — non-for loops + AST index substitution
The final commit tightens the loop-unroll preservation:
- `buildLoopUnrollPreserving` returns `null` (→ blanket overwrite fallback) when `loopIndexVarName` returns null (forEach/for-of/while), preventing preserved siblings from referencing an unbound loop variable.
- `substituteLoopIndex` uses AST-based substitution instead of regex, so string literals containing the index char are untouched.
Both are covered by the tests added in the earlier commit.
Verification against initial R2 findings
| Finding | Status |
|---|---|
| #1466 resize drops live DOM pbs patch | Fixed (commit 1, verified in initial R2) |
| #1466 missing reloadPreview in resize fallback | Fixed (commit 1, verified in initial R2) |
| #1498 duplicate removeGsapKeyframe union | Retired (commit 2, verified in initial R2) |
| #1469 dark-launch flag gate missing | Fixed (commit 3, verified in initial R2) + leading gate added (`057e0d0e`) |
| Rames: flag-gate regression test missing | Added (`508be317`) |
| Via: sdkShadowGsapKeyframe.ts:128 squash hazard | STILL OPEN — see below |
Outstanding: Via's blocker — `sdkShadowGsapKeyframe.ts:128` cross-queue squash hazard
Via flagged that `packages/studio/src/utils/sdkShadowGsapKeyframe.ts` on `main` constructs `{ type: "removeGsapKeyframe", animationId, keyframeIndex }` at line 128-130. Commit 2 of this PR retires the `keyframeIndex` variant from the `EditOp` union in `types.ts`. When the stack squash-merges onto main, `sdkShadowGsapKeyframe.ts` survives (the branch never had it, so the squash diff doesn't touch it) but the union member it constructs is gone → `tsc` red on main.
This file is NOT in the PR #1539 diff. Via's option 1 (migrate line 128 to the percentage variant in THIS PR) is the cleanest one-line fix. This should be addressed before the stack squashes to main.
setTiming per-tween shift/scale (new in `mutate.ts`)
The new `handleSetTiming` logic in `mutate.ts:444-488` computes per-tween position remapping and duration scaling by the clip's start delta and duration ratio. This replaces the old absolute-write approach (which collapsed stagger and blew durations to the full clip). Two new tests verify: moving a clip shifts both tweens by the delta (preserving the 3s stagger), and resizing x2 scales both positions and durations proportionally. The math is correct: `shifted = remapStart + (animation.position - oldStart) * ratio`, rounded to 3 decimal places, clamped to 0. Duration scaling similarly. Good guards (`typeof animation.position !== "number" → skip`, `oldDuration > 0` for ratio). This is a correctness improvement over the pre-cutover behavior.
Verdict
The six new commits correctly address the remaining review findings from the initial R2. The core writer fixes are surgical and well-tested. The studio-side fixes (merge-tween-props, debounce stability, GSAP write serialization, on-disk undo baseline, self-write identity) are architecturally sound. The dark-launch flag-gate tests pin the contract both Rames and Via requested.
One open item remains: Via's sdkShadowGsapKeyframe.ts:128 migration. That file on main still constructs the retired keyframeIndex union member. Until it's migrated to the percentage variant (a one-line change), the stack will tsc-red on squash-merge to main.
With that migration landed (in this PR or a follow-up before squash), this is clear.
LGTM — ready for stamp, pending the sdkShadowGsapKeyframe.ts migration.
— Miga
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R3 verification — fix-PR at SHA f9bdd18f (was 18ea1c32 at R2)
Six new commits since R2 — far more than just the R2 follow-ups. Vance hoisted the entire 13-finding stack-review correctness pass (8 core/sdk + 5 studio) into this fix-PR per the canonical fix-hoist-to-tip pattern, then patched one regression from the #9 unroll fix. R3 treats it as a fresh review on the new content plus verification of the two R2 follow-ups.
R2 finding verification
R2-NEW #5 — dark-launch flag-gate regression test (soft-blocker before flip): ✅ resolved. 508be317 adds sdkCutover.gate.test.ts with STUDIO_SDK_CUTOVER_ENABLED: false at the module level and three independent assertions — sdkTimingPersist, sdkGsapTweenPersist, sdkDeletePersist all return false with a valid session present, with writeProjectFile never called for the timing case. Locks the flag-off contract per chokepoint. The pre-flip blocker is closed.
R2-NEW #6 — early-out nit on sdkGsapTweenPersist: ✅ resolved. 057e0d0e lifts the if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false) to the leading line at sdkCutover.ts:254, before the op.kind === "add" getElement touch. Now matches the other three chokepoints' discipline — flag-off does zero SDK reads at every entry.
R3-NEW — the 13-finding correctness pass (commits 7d7732c8 + bcc0a44a + fd002ad3)
Reviewed each finding's fix shape against the diff. Net read: each fix has a paired test that reproduces the bug shape and asserts the corrected result, several explicitly assert not.toContain for the wrong shape (good — guards against parity-collusion regressions, the failure mode that hid these in the first place).
Worth calling out by name:
-
#3 setTiming per-tween shift/scale (
mutate.ts:444-470): math is right —remapStart + (animation.position - oldStart) * ratiopreserves intra-clip offset,animation.duration * ratioscales per-tween. Three guards added:oldStart !== null,typeof animation.position === "number",animation.duration > 0. The stagger-preservation test atmutate.gsap.test.ts:644-678asserts both the corrected positions (2→3, 5→6) and rejects the absolute-collapse shape (not.toContain("duration: 5")). Solid. -
#14 self-write identity (
sdkSelfWriteRegistry.ts+useSdkSession.ts:52-60): FNV-1a hash + consume-on-match + per-file keying + 2 s TTL. Collision risk is "suppress one real reload within TTL for same file" — negligible. Single-funnelmarkSelfWriteatpersistSdkSerialize:159covers all four cutover persist paths. Tests cover echo / undo / per-file isolation / consume-on-match / TTL expiry / content-null fallback. Clean design. -
#9 unroll regression fix (commit
f9bdd18f): two real regressions caught — non-forloops returned anullindexVarso siblings kept undefineditem/rowreferences (ReferenceError at render), and the regex substitution corrupted string literals (".row-i"→".row-0"). Both fixes are right:buildLoopUnrollPreservingnow returnsnullfor non-for loops → caller falls back to blanket overwrite (drops siblings, valid code);substituteLoopIndexswitches to AST walk withisIndexBindingPositionguard. Two regression tests atgsapWriter.reviewFixes.test.ts:135-181pin both bug shapes. Note this is the second regression iteration on the same #9 fix — the original commit'sfor(let i…)-only fixture missed both — but the new tests close the gap.
R3-NEW concerns
sdkTimingPersist is not wrapped in the per-file serializer (sdkCutover.ts:222-231). dispatchGsapOpAndPersist and the legacy commitMutation path both serialize same-file read-modify-writes through deps.serialize("gsap-file:${path}", run), but timing edits do serialize-before → dispatch → captureOnDiskBefore → write without the wrap. Two rapid clip-drag persists on the same file can interleave: T2's captureOnDiskBefore may read pre-T1 bytes before T1's write completes, capturing the wrong undo before (undo would revert past both edits). coalesceKey collapses the undo entries but doesn't prevent the race. Not new in this round (R2 had the same shape), and the practical blast radius is small (timeline drag is the only caller and is debounced), but flagging now since #8 explicitly motivated the serializer for GSAP writes and timing has the same shape. Worth either threading the serializer through or documenting the timing-specific exemption.
Duplicate 2000ms constant (sdkSelfWriteRegistry.ts:19 SELF_WRITE_TTL_MS vs useSdkSession.ts:33 SELF_WRITE_SUPPRESS_MS). The two values are load-bearing in concert: if the registry TTL outruns the suppress window the withinSuppressWindow fallback can return "reload" while a still-registered echo would say "suppress", and vice versa. Same numeric value today, but a future tune of one would silently de-sync. Suggest exporting one constant from the registry module and importing in the hook. (nit)
CI
player-perf and Perf: parity both failed at f9bdd18f, but inspecting Perf: parity logs (run 27717141578 job 81993095152) shows the failure was the Install ffmpeg apt step hitting "The operation was canceled" at 20:40:21 mid-Get:50 — an infra/runner timeout, not a code regression. The player-perf summary check failed as a consequence of perf-shards being cancelled (not failed). A rerun should clear both.
Stamp recommendation
Approve once CI is green on rerun. The R2 follow-ups closed cleanly, the 13-finding correctness pass is rigorous (test design pins bug shapes, not parity), and the regression on #9 was caught and patched in the same round. The sdkTimingPersist-serializer asymmetry is the only meaningful open thread and it's flagged as a follow-up, not a blocker for this PR (it's pre-existing).
Reviewed at f9bdd18f.
— Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed — 6 new commits covering GSAP write-path correctness, studio debounce/serialization, dark-launch flag-gate tests. Miga's thorough re-review confirms all changes. The sdkShadowGsapKeyframe.ts:128 blocker is on main, not this PR. LGTM.
vanceingalls
left a comment
There was a problem hiding this comment.
R3 verification at HEAD f9bdd18f.
Six commits since 18ea1c32; the parlour is considerably tidier than I left it.
R2 closure
Cross-queue squash hazard (R2 blocker) — CLOSED, with a correction to my own reasoning. compare/main...sdk-cutover-review-fixes reports packages/studio/src/utils/sdkShadowGsapKeyframe.ts as status="removed", deletions=257 — the stack's contribution to main includes the deletion. Branch contents fetch returns 404; on main the file remains at sha 38a63316. My R2 inspected the branch BASE (sdk-ws3-unroll-dynamic) rather than tip-vs-main, and missed that the deletion was introduced higher in the stack. Post-merge: file gone, no caller to a retired union member, no tsc red. Modify/delete conflict resolves as delete. Fully dispatched.
Flag-off regression test (Rames (a), my soft-block) — CLOSED. 508be317 adds sdkCutover.gate.test.ts mocking STUDIO_SDK_CUTOVER_ENABLED: false and pinning sdkTimingPersist / sdkGsapTweenPersist / sdkDeletePersist each to false with a valid session, asserting writeProjectFile is never called. Dark-launch contract now load-bearing in CI.
Leading flag-gate on sdkGsapTweenPersist (Rames (b), my nit) — CLOSED. 057e0d0e inserts if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false); at sdkCutover.ts:252, AHEAD of the op.kind === "add" && !sdkSession.getElement(...) check. Flag-off is now a clean no-op at every entry.
New substantive work
7d7732c8 — 8 GSAP write-path correctness fixes. #2 closest-keyframe, #3 per-tween delta+ratio, #4 motionPath appendRight, #5 forward-pass split baseline, #9 unroll preservation, #10 cubic motionPath, #11 negative-coord recovery, #15 no-bang. Each paired with a test in gsapWriter.reviewFixes.test.ts / mutate.gsap.test.ts asserting real-world-correct outputs — the commit correctly notes BOTH writers had been identically wrong so parity stayed green over the bug. Substantive.
bcc0a44a — 5 studio cutover fixes. #1 tween-prop merge, #7 debounce-flush via deps-ref, #8 per-file serialized GSAP write through the legacy keyed serializer, #12 on-disk bytes as undo baseline, #14 self-write-by-content-hash discrimination. Each paired with a red-before-fix test. #8 closes a real interleave window by feeding SDK gsap-writes through the same per-file lock as commitMutation.
fd002ad3 — fallow refactor. Extracts buildSpanningSplit / applyTweenSplit / buildCollapsedFlatVars; removes a previously-added fallow-ignore. Honours "never ignore — fix"; behaviour-only.
f9bdd18f — unroll-preservation regressions on 7d7732c8 #9. Two REAL regressions, self-caught: (i) loopIndexVarName returns null on forEach/for-of/for-in/while; original substitution silently no-op'd, leaving preserved siblings with an unbound item — now returns null at the caller, falling back to the blanket overwrite (valid code); (ii) the \bvar\b regex over raw source corrupted ".row-i" → ".row-0" — now AST-walked via acornWalk.ancestor with an isIndexBindingPosition guard for member-property / object-key positions. Two regression tests pin both.
Chokepoint audit
All four sdkCutover.ts chokepoints gate leading: sdkTimingPersist (~L174), sdkGsapTweenPersist (L252, new), dispatchGsapOpAndPersist (~L236), sdkDeletePersist (~L340). bcc0a44a paths feed THROUGH these, not around.
Pre-post freshness
No new reviewer reviews since R2 (19:28:04Z); only the author's reply at 20:09:42Z. No race.
Verdict
CLEAR. Every R2 blocker, soft-ask, and nit closed with file:line proof; new work introduces no fresh band-aids and one self-caught regression closed in the same series. Stack is genuinely dark-launchable; the union retire ships without a cross-queue post-mortem.
Review by Via
miga-heygen
left a comment
There was a problem hiding this comment.
R3 + CF2 Verification — All 16 Findings
Reviewed the tip at 4f1eb960 (16 commits on the sdk-cutover-review-fixes branch). Verified each finding against the actual diff.
R3 Findings
#1/#2/#9 — Unroll AST safety ✅
The unroll now refuses (no-ops, returning the script verbatim) on unsafe patterns rather than corrupting or dropping code:
forEach/for-of/for-in/whileloops with siblings →loopIndexVarNamereturnsnull→unrollSiblingStrategyreturns"refuse".- Object shorthand
{ i }→isShorthandIndexUsedetects it → refuse. - Shadowed index (nested
for (let i …)) →rebindsIndexcatches it → refuse. - Safe
forloops withitems[i]siblings still unroll (regression guard test present). - Index substitution is now AST-based (
substituteLoopIndexviaacornWalk.ancestor) — string literals like"row-i"are no longer corrupted.isIndexBindingPositioncorrectly skips non-computed member props and object keys. - Empty element list → early return at the top of
unrollDynamicAnimations+validateOprejects withE_INVALID_ARGS. - Comprehensive test coverage: 6 scenarios in
gsapWriter.reviewFixes.test.ts+mutate.test.ts.
#3 — stripGsapForId re-parse ✅
stripGsapForId in htmlParser.ts now re-parses after each removal in a for(;;) loop, finding the first still-matching animation in each fresh parse. Includes an infinite-loop guard (updated === current → break). Test: "strips ALL gsap tweens for the removed element, not just the first" covers two tweens on one element.
#4 — history:false persist decouple ✅
In session.ts openComposition, the persist setup is now OUTSIDE the history !== false block. history:false only disables the undo stack; auto-save runs independently. Verified by the smoke test: "still persists when history:false (undo opt-out must not disable auto-save)".
#5/#11 — Single-dispatch undo invertibility ✅
_dispatch in session.ts now reverses the inverse patch list for single-op dispatches: [...inverse].reverse(). Comment correctly notes parity with batch(). Test in session.test.ts: nested removeElement([child, parent]) undo restores both elements — the child's inverse (add child) now fires AFTER the parent's (add parent), so the parent exists when the child attaches.
#6/#13a — validateOp target resolution ✅
New gsapAnimationMissing helper checks that the animationId actually resolves via the parser. Applied to: removeGsapTween, removeAllKeyframes, convertToKeyframes, splitIntoPropertyGroups, setArcPath, removeArcPath, updateArcSegment, unrollDynamicAnimations, materializeKeyframes. Returns E_TARGET_NOT_FOUND with a hint about positional IDs being stale. Test coverage for removeGsapTween unresolved id.
#7 — easeEach routing ✅
updateAnimationInScript now calls keyframesObjectNode(call.varsArg) — if the tween has a keyframes property whose value is an ObjectExpression, it writes easeEach on that node instead of a top-level ease. Test: "writes easeEach (per-keyframe), not a no-op top-level ease".
#8 — convertToKeyframes preserves delay ✅
buildKeyframesVarsCode now takes varsNode and source params, uses preservedVarsEntries(varsNode, source) to carry over every non-editable key (delay, callbacks, stagger, yoyo, etc.) verbatim from source. The old path rebuilt from animation.extras which had no delay field. Test: "preserves delay on the converted vars object" — verifies delay: 0.3 survives.
#10 — materializeKeyframes empty-array guard ✅
materializeKeyframesFromScript now has an early if (keyframes.length === 0) return script; guard. validateOp rejects with E_INVALID_ARGS. Two tests: writer no-op + validateOp rejection.
#12 — addLabel dedup ✅
addLabelToScript now searches for an existing label with findLabelStatements (extracted from removeLabelFromScript). If found, it overwrites the position argument instead of appending a duplicate. The corpus test was updated from "two addLabel calls (no dedup)" to "MOVES it instead of duplicating (dedup contract)". The removeLabel test now uses a hand-authored dup to verify it still cleans up all matches.
#13b — Quote-insensitive selector comparison ✅
handleDeleteAllForSelector normalizes quotes: selector.replace(/'/g, '"') on both sides of the comparison. Test: "removes a tween authored with double quotes when given a single-quoted selector".
#14 — Node = any type alias ✅
type Node = any declared at the top of gsapWriterAcorn.ts. Every bare : any annotation in the touched functions replaced with : Node. Comment: "acorn ESTree nodes are structurally untyped here; mirror gsapParserAcorn.ts / gsapInline.ts rather than re-deriving the full ESTree union." This is a type-only change (zero runtime effect), which is the correct scope.
CF2 Findings
#15 — handleSetTiming DOM id matching ✅
The tween-matching loop in handleSetTiming now checks BOTH matchHfId and matchDomId (el.getAttribute("id")). selectorMatchesId is called against both. Test: "#domId-targeted tween shifts when the clip moves" — verifies position remap from 2→5.
#16 — handleSetTiming data-duration support ✅
oldDurationnow prefersdata-durationoverdata-end − data-start.- Write-back respects whichever attribute the clip uses:
data-durationclips updatedata-durationon resize,data-endclips updatedata-end. timingPathnow accepts"duration"as a field, andkeyToPathregex updated to includeduration.- Test: "a data-duration clip updates data-duration and scales its tween".
Additional Fixes Verified
- setTiming GSAP per-tween shift/scale: positions are remapped using the start DELTA and duration RATIO, not absolute values. Two tests (move +1 preserves stagger; resize x2 scales positions) confirm no stagger collapse.
- No-bang (!): every
!non-null assertion removed from touched files, replaced with guards/early-returns/narrowing. - Duplicate removeGsapKeyframe variant retired: the
keyframeIndexdiscriminant removed fromEditOpunion; dispatcher simplified to percentage-only path. - applyDraft element caching:
_draftEltyped asHTMLElement | null, cached viaisConnectedcheck, re-queried only on id change or detach. Test verifies singlequerySelectorcall across 3applyDraftinvocations. - Fallow refactoring:
buildSpanningSplit,applyTweenSplit,buildCollapsedFlatVarsextracted to satisfy complexity threshold; fallow-ignore removed. - Dark-launch flag-gate contract: all 4 persist chokepoints (
sdkTimingPersist,sdkGsapTweenPersist/dispatchGsapOpAndPersist,sdkDeletePersist) now checkSTUDIO_SDK_CUTOVER_ENABLEDat the top.sdkCutover.gate.test.tspins this contract with the flag mockedfalse. - Move/resize fallback parity:
shiftGsapPositions+scaleGsapPositions+reloadPreview()restored on the no-session and timing-returned-unhandled fallback paths;coalesceKeyplumbed throughpersistTimelineEditfor undo parity. - Self-write identity registry:
sdkSelfWriteRegistry.ts+shouldReloadOnFileChangediscriminate cutover echo from undo write by content hash (FNV-1a), not just timestamp window. Consume-on-match semantics prevent stale suppression. - Forward-baseline split:
computeForwardBaselinespre-pass computes inherited props in timeline order before the reverse write loop, fixing wrong midpoint on spanning tweens. - Debounce stability:
sdkRefref stabilizes the flush callback identity so parent re-renders don't trigger spurious unmount-flush commits. - Serialized GSAP writes:
deps.serializeroutes through the legacygsap-file:${file}per-key serializer. - On-disk undo baseline:
captureOnDiskBeforereads exact disk bytes viadeps.readProjectFile, falling back to serialize(). - Resize live DOM patch:
liveAttrsnow includes conditionaldata-playback-start/data-media-startpush.
Summary
LGTM — all R3/CF2 findings verified. Every claim in Vance's response matches the actual code. The fixes are well-structured, each with targeted tests that reproduce the original bug scenario (verified-red-before-green pattern). The dark-launch gate contract is now properly pinned. Ready for stamp and bottom-up merge.
— Miga
vanceingalls
left a comment
There was a problem hiding this comment.
R4 verification at HEAD 4f1eb9606 — 16 findings + 2 CF2 closure pass.
Addendum: Miga's R3+CF2 verification landed at 22:52Z, ~1h ahead of mine. My readings agree across every line; this is an independent re-walk with source citations.
Six commits, fourteen files, every claimed closure verified against the AST itself.
Per-finding closure
| # | Proof at HEAD | Verdict |
|---|---|---|
| 1/2/9 unroll refusal | gsapWriterAcorn.ts L2028-2095: REFUSE_UNROLL, unrollSiblingStrategy, hasUnsafeLoopIndexUse, rebindsIndex, isShorthandIndexUse; L2186 short-circuit. loopIndexVarName returns null on ForOf/ForIn/While/forEach → forced refuse. Tests L168-212: forEach-with-sibling, { i } shorthand, shadowed inner for, safe-for regression — each asserted unchanged AND new Function()-parseable |
VERIFIED |
| 3 stripGsapForId re-parse | htmlParser.ts L685-703: for(;;) re-parse, first-still-matching removal, infinite-loop guard |
VERIFIED |
| 4 history:false persist decouple | session.ts L511-526: persist block outside history !== false. Test smoke.test.ts L230 |
VERIFIED |
| 5/11 single-dispatch undo reverse | session.ts L300-306: [...inverse].reverse() with parity comment. Test session.test.ts L302 |
VERIFIED |
| 6/13a validateOp resolves id | mutate.ts L1097-1117 (gsapAnimationMissing) wired into every id-bearing GSAP case L1203-1226; validateArcSegment L1121-1138 checks enabled + range |
VERIFIED |
| 7 easeEach routing | gsapWriterAcorn.ts L327-333: keyframesObjectNode detect, upsert easeEach not top-level ease |
VERIFIED |
| 8 delay preservation | gsapWriterAcorn.ts L1080-1085 via preservedVarsEntries L546; NON_EDITABLE_VAR_KEYS L500 includes delay. Test L310 |
VERIFIED |
| 10 materializeKeyframes [] | gsapWriterAcorn.ts L1163 early-return; mutate.ts L1226 E_INVALID_ARGS |
VERIFIED |
| 12 addLabel move-not-dup | gsapWriterAcorn.ts L1416-1440: findLabelStatements-first, overwrites posArg. Corpus test flipped from "no dedup" to "MOVES it" |
VERIFIED |
| 13a partial setArcPath <2-wp | Deliberate writer-no-op + studio fallback. Coupling argument is sound; stale-id + segment-range ARE rejected | ACCEPTED |
| 13b deleteAllForSelector quotes | mutate.ts L945-948: .replace(/'/g, '"') both sides. Test L964 |
VERIFIED |
14 Node = any alias |
gsapWriterAcorn.ts L33; every bare : any AST annotation now : Node. Type-only |
VERIFIED |
15 CF2 DOM id match |
mutate.ts L471-490: matchHfId OR matchDomId via selectorMatchesId. Test "#domId tween shifts when clip moves" |
VERIFIED |
| 16 CF2 data-duration precedence | mutate.ts L398-413 prefers data-duration; L432-453 writes back to whichever attribute the clip uses; patches.ts L62 gains "duration" field. Test "data-duration clip updates data-duration and scales its tween" |
VERIFIED |
Branch-vs-main surprise check
compare/main...sdk-cutover-review-fixes — six removed entries, all packages/studio/src/utils/sdkShadow* (the R3 cross-queue squash hazard closure). sdkShadowGsapKeyframe.ts confirmed removed. No surprise deletions.
Pre-existing-state spot-check
At f9bdd18f, none of REFUSE_UNROLL / hasUnsafeLoopIndexUse / rebindsIndex / isShorthandIndexUse / unrollSiblingStrategy existed, and [...inverse].reverse() was absent from session.ts. Genuine closures — no phantoms.
Six-commit auxiliary R1 retakes
applyDraft element cache (5dcd168a, #1490a) and empty-element-list unroll guard (5b3db8de, #1501b) — correctly scoped, independently tested, verified in passing.
CI
15 required-pass / 0 fail / 8 regression-shards in flight (non-blocking); no failures at tip.
Recommendation: CLEAR
The cabinet is in order. Bottom-up merge has my concurrence.
Review by Via
miguel-heygen
left a comment
There was a problem hiding this comment.
Re-approved at 6c2d668 — cascadeRemoveAnimations fix + aliased-multi-target undo test added. All reviewers clear. LGTM.
jrusso1020
left a comment
There was a problem hiding this comment.
Re-stamping at 6c2d66892 — Rames D Jusso's R4 verification confirms the two new commits (cascadeRemoveAnimations re-parse loop + aliased-multi-target undo test) close the remaining caveats from the round-3 pass. All 16 round-3 + 2 CF2 findings verified by Miga/Via/Rames D Jusso. CI yellow (regression shards still running, non-blocking). Cleared for bottom-up merge from my side.
…parity Confirmed correctness findings from the R5 review of the SDK cutover stack, applied on top of #1539: - fromTo add via cutover dropped its destination: handleAddGsapTween read only `toProperties`; now falls back to `properties` like every other method. - handleSetTiming GSAP sync: a clip with no data-start skipped the shift (now treats start as 0, matching the server path) and a blank/non-numeric data-start wrote position: NaN (now sanitized). - handleSetTiming no longer appends an absolute position to an auto-sequenced (implicit-position) tween, which collapsed staggers. - handleSetTiming keeps data-end in sync when a clip carries BOTH data-duration and data-end (a stale data-end inverted the clip). - string/relative tween positions ("+=0.5", "<") documented as a known ceiling. - opacity/autoAlpha property seed no longer falsy-zero (`|| 1`): an element at opacity 0 seeds 0, not 1. - optimistic add-keyframe cache tolerance aligned to the writer's PCT_TOLERANCE (2%) so a near-neighbour keyframe no longer shows then vanishes on reload. - DOM-patch finiteness validation runs before the SDK cutover path. - attribute ops mapping to a reserved data-* name decline the cutover up front instead of throwing inside dispatch. Regression tests added for each SDK-side fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db16b97 to
8d17b08
Compare
The base branch was changed.
The §3.2 sdkTimingPersist rewrite regressed the non-SDK fallback path vs the pre-cutover behavior. Restored, on both fallback entry points (no-session and sdkTimingPersist-returned-unhandled): - Resize live DOM patch dropped the conditional data-playback-start/media-start attr — restored so a start-trim updates the preview's in-point immediately. - Move/resize fallback dropped the GSAP-position sync (shift/scaleGsapPositions) + reloadPreview — restored so server-path edits keep GSAP tweens in sync and refresh the preview (the SDK path folds both into setTiming). - Undo-coalesce drift: fallback enqueueEdit carried no coalesceKey while the SDK branch did — plumbed coalesceKey through persistTimelineEdit so undo granularity is identical on either path. - Documented the hasPbsAdjustment second clause + sdkTimingPersist before-capture transition limitation. Flag-off (dark launch) so this lands as one fix PR at the stack tip rather than restacking the mid-stack §3.2 commit. #1500 review items: parity-harness gap already closed at the tip (arc/unroll recast-vs-acorn parity added); blockRemoveRange flagged 'potential' but verified correct (no comma residue on any block position). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…review #1498) EditOp had two removeGsapKeyframe members with the same discriminant but different shapes (keyframeIndex vs percentage) — TS can't discriminate them and a handler could get the wrong shape. Per both reviewers (option 2): retire the keyframeIndex variant. It had no production caller (Studio dispatches percentage only); removed the dead by-index handleRemoveGsapKeyframe + simplified the dispatcher. resolveKeyframe stays (setGsapKeyframe still uses keyframeIndex). Converted the one by-index test to the percentage API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aunch (review #1469 finding #6) Only sdkCutoverPersist (style/text/attr) checked STUDIO_SDK_CUTOVER_ENABLED. sdkTimingPersist, dispatchGsapOpAndPersist (every GSAP op) and sdkDeletePersist guarded only on `!sdkSession` — and useSdkSession opens a session by default for shadow/selection, so timing/GSAP/keyframe/delete cutover was ALWAYS live regardless of the flag. Flipping the flag OFF could not disable it, so the data-loss bugs in those paths (single-prop wipe, wrong-keyframe match, tween collapse, arc strip) ship LIVE on merge instead of being dark-launched. Added the flag guard at all three chokepoints → flag OFF returns false → callers fall back to the legacy server path. Makes the stack genuinely dark-launchable: merge is now a no-op in prod, and the remaining cutover correctness bugs become flip-prerequisites rather than merge-blockers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eight correctness bugs from the SDK-cutover review. Several were cases where BOTH writers were identically wrong, so the recast-vs-acorn parity suite stayed green; the new tests assert the real-world-correct result, not agreement. - #2 findKfPropByPct: match the CLOSEST keyframe within tolerance, not the first within 2% — removing/updating 50% on 0/49/50/100 no longer hits 49%. - #3 handleSetTiming: shift each tween by the start DELTA and scale duration by the clip-duration RATIO per-tween, instead of writing absolute newStart/ newDuration onto every tween (which collapsed staggers and blew durations). - #4 enableArcPath: insert motionPath via appendRight at the object start so the insertion can't collide with the x/y remove-range end (which made MagicString discard the append and emit '{}'). - #5 splitAnimationsInScript: compute the inherited baseline in a forward pre-pass so the split-spanning midpoint sees earlier tweens (the reverse write loop is kept for stable count-suffixed ids). - #9 unrollDynamicAnimations: preserve non-target loop-body statements (e.g. tl.set initial-state) per iteration instead of overwriting the whole loop. - #10 buildMotionPathObjectCode (both writers): emit the cubic form when segment curviness varies so per-segment curviness survives, not just segments[0]. - #11 readLastWaypointXY: handle UnaryExpression so negative destination coords are recovered when disabling an arc path. - #15 no-bang: removed every `!` non-null assertion in the touched files, replaced with guards/fallbacks. Tests: gsapWriter.reviewFixes.test.ts (#2/#4/#5/#9/#10/#11) and mutate.gsap.test.ts setTiming GSAP-sync block (#3). All fail on the base and pass after the fix; tsc + full core/sdk suites + parity stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…debounce, serialize gsap writes, on-disk undo baseline, self-write identity Addresses 5 SDK-cutover review findings (studio-only): - #1 useGsapPropertyDebounce: editing one GSAP tween property no longer drops the tween's other animated props. setGsapTween REPLACES the property set, so merge the single edit into the tween's CURRENT properties (read from the SDK doc) before dispatching, mirroring the legacy server merge. - #7 useGsapPropertyDebounce: stabilize the flush callback by reading sdk deps from a ref instead of an unmemoized literal, so a parent re-render mid-edit no longer tears down + flushes the debounce (one commit/undo entry per render). - #8 sdkCutover/useGsapScriptCommits: route SDK gsap-write persists through the same per-file keyed serializer the legacy commitMutation uses, so concurrent same-file read-modify-writes can't interleave and lose an edit. - #12 sdkCutover/useTimelineEditing: capture the exact on-disk bytes as the undo 'before' for timing/GSAP persists (matching the style/delete paths) instead of a normalized SDK serialize() re-emit that reformatted the whole file on undo. - #14 useSdkSession/sdkSelfWriteRegistry: discriminate a cutover echo from an undo write by CONTENT identity (registered self-write hash), not just the 2 s timestamp window — an undo write always reloads the SDK session. Tests: useGsapPropertyDebounce(.test), useGsapPropertyDebounceFlush.test, sdkSelfWriteRegistry.test, and new sdkCutover.test cases; each reproduces the review scenario and asserts the corrected behavior (verified red before fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gnore rule The #5 (split) and #15 (no-bang guards) fixes pushed splitAnimationsInScript and removeAllKeyframesFromScript over fallow's complexity threshold, and a fallow-ignore had been added to splitAnimationsInScript. Per the hard rule (never ignore — fix), extracted buildSpanningSplit + applyTweenSplit (split) and buildCollapsedFlatVars (collapse), and removed the ignore. Both functions now under threshold; fallow new-only gate reports 0 new findings. Behavior unchanged — core 1811 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/Via) flag OFF ⇒ sdkTimingPersist / sdkGsapTweenPersist (GSAP-op chokepoint) / sdkDeletePersist all return false even with a valid session → legacy fallback. The prod flag-flip rests on this contract; sdkCutover.test.ts only mocks the flag TRUE, so a future gate refactor could silently re-enable cutover on flag-off without failing CI. This sibling file mocks it FALSE and locks the three guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it, Via) The add-op getElement existence check ran before the inner gate, so flag-off did an SDK touch before falling back. Lead with the flag guard to match the other three chokepoints — flag-off is now a clean no-op at every entry point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…x substitution (review R2) The #9 unroll-preservation fix had two confirmed regressions: - Non-for loops (forEach/for-of/for-in/while): loopIndexVarName returns null, so substitution no-op'd and preserved siblings kept a now-undefined loop variable (e.g. `item`) → ReferenceError at render. Now returns null for those forms → caller falls back to the blanket loop overwrite (drops siblings, valid code). The #9 fixture only used `for(let i…)` so it never caught this. - substituteLoopIndex did a \bvar\b regex over raw source including string literals, corrupting selectors like ".row-i" → ".row-0". Now AST-based: substitutes only real Identifier uses, skipping string literals and non-computed member/key positions (extracted isIndexBindingPosition helper to stay under the fallow complexity threshold — no ignore added). Two regression tests added (forEach no-dangling-var; for-loop string-literal intact). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… #1501b) An empty `elements` array has no unrolled form — the writer would overwrite the loop/statement with zero tween calls, silently deleting the animation. - gsapWriterAcorn: unrollDynamicAnimations returns the script verbatim on an empty list (no-op instead of a destructive overwrite). - validateOp: reject unrollDynamicAnimations with empty elements as E_INVALID_ARGS so callers get a clean error rather than silent corruption. - Tests: writer no-op on []; validateOp E_INVALID_ARGS on []. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(R1 #1490a) applyDraft runs at 60fps during a drag but re-ran doc.querySelector on every call — the _draftEl/_draftId fields were only consumed by commit/cancel, never to skip the query. Reuse the tracked element when the id matches and the node is still connected; re-query only on id change or detach (iframe reload). Retypes _draftEl to HTMLElement | null (only ever set from querySelector<HTMLElement>), which removes the `as HTMLElement` casts in commitPreview / _clearDraft. Test asserts a repeated same-id drag queries once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ch undo, empty-arg guards, persist decouple Addresses the highest-severity round-3 review findings: - gsapWriterAcorn unroll (R3 #1/#2/#9): the round-2 AST-substitution fix emitted invalid GSAP for object shorthand `{ i }` (→ `{ 0 }`) and shadowed inner bindings (→ `for(let i=0;0<3;0++)`), and silently dropped sibling statements on non-`for` loops (forEach/for-of). The unroll now REFUSES (no-ops, leaving the dynamic loop intact) whenever siblings can't be safely reproduced — a non-`for` loop, an unmodeled statement, or an unsafe index use — instead of dropping or corrupting. Plain `for` loops with safe siblings still unroll. - session single-dispatch undo (R3 #5/#11): _dispatch now reverses the inverse patch list (parity with batch()). A single op emitting order-dependent inverse patches — a nested parent+child removeElement, an aliased multi-target — undid forward and dropped the child subtree / landed on an intermediate value. - materializeKeyframes empty-array (R3 #10): the unguarded twin of the just-fixed unrollDynamicAnimations. Writer no-ops on an empty keyframe list; validateOp rejects it as E_INVALID_ARGS (shared gsapScriptMissing helper). - history:false persist decouple (R3 #4): persist (auto-save) no longer lives inside the history-enable block, so opting out of SDK undo no longer silently disables all disk writes (data-loss trap for #1496's flag consumers). Tests: unroll refuse cases (shorthand/shadow/forEach) + safe-for-loop regression; nested removeElement undo; materializeKeyframes writer no-op + validateOp reject; history:false-still-persists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eleted element are stripped (R3 #3) Animation ids are count-based (positional), so removing one tween renumbers the survivors. stripGsapForId captured every matching id from a single up-front parse then removed against the mutating script — after the first removal the later ids were stale and silently no-op'd, leaving an orphaned tl.to() referencing the just-deleted element. Now re-parse after each removal and strip the first still-matching animation until none remain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ay, addLabel dedup (R3 #7/#8/#12) - #7: updateAnimationInScript routes an ease update on a keyframe tween to keyframes.easeEach (per-keyframe), not a top-level ease that GSAP ignores — the user's keyframe-easing edit was silently a no-op. - #8: convertToKeyframesFromScript now preserves every non-editable vars key (delay/callbacks/stagger/yoyo/…) verbatim via preservedVarsEntries instead of rebuilding from the GsapAnimation object, which had no `delay` field and dropped it — shifting the tween's start time. - #12: addLabelToScript moves an existing same-named label (overwrites its position) instead of appending a duplicate; duplicates made removeLabel over-remove (it deletes every match, including a pre-existing label). Tests: easeEach routing, delay preservation, addLabel move-not-duplicate + hand-authored-dup removal. Updated the old "no dedup contract" corpus test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olves ids + arc/selector (R3 #6/#13, CF2 #15/#16) CF2 #15: handleSetTiming re-synced GSAP tweens only when the selector matched the element's hf-id. The common #domId-targeted tween (authored by the Studio panel) never matched, so moving/resizing a clip via the SDK timing path left its animations unsynced. Now match the tween selector against the DOM id too. CF2 #16: handleSetTiming read/wrote only data-end. Clips authored with data-duration (what the runtime prefers) got a fresh data-end beside a stale data-duration (no playback change) and oldDuration=null collapsed the GSAP duration-scale ratio to 1. Now read duration preferring data-duration, and write back to whichever attribute the clip uses (timingPath gains a "duration" field). R3 #13b: deleteAllForSelector compared selectors with strict === and missed the alternate quote style ([data-hf-id='x'] vs "x"); now quote-insensitive. R3 #6/#13a: validateOp now resolves the animationId for id-bearing GSAP ops (E_TARGET_NOT_FOUND instead of a misleading ok that no-ops at apply), and updateArcSegment validates the arc is enabled + the segment index is in range. Tests: #domId move sync, data-duration resize + scale, quote-insensitive delete, unresolved-id rejection, arc-segment preconditions. Updated the loose-can() test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rips timing.duration (R3 #14) - gsapWriterAcorn: replace the bare `: any` AST-node annotations with the named `type Node = any` alias, matching the established convention in gsapParserAcorn.ts / gsapInline.ts ("acorn ESTree nodes are structurally untyped"). Documents intent and is greppable; type-identical (zero runtime change). A full ESTree typing is a deliberate architecture decision the codebase has not taken and is out of scope here. - patches: keyToPath/timingPath now include the "duration" timing field added for the data-duration resize fix, so a timing.duration override round-trips on T3 replay instead of being dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n of #3) cascadeRemoveAnimations captured every matching animation id from a single up-front parse, then removed against the mutating script — the SDK-side twin of the stripGsapForId bug (R3 #3). Animation ids are positional, so removing the first tween for an element renumbered the survivors and the stale later ids no-op'd, orphaning those tweens on the just-removed element. Now re-parse after each removal and strip the first still-matching animation until none remain. Also adds the reviewer's defense-in-depth test: an aliased multi-target setStyle (same id twice) undoes to the original, not the intermediate (exercises the single-dispatch inverse reversal from R3 #5/#11). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6c2d668 to
f92bba7
Compare
…parity Confirmed correctness findings from the R5 review of the SDK cutover stack, applied on top of #1539: - fromTo add via cutover dropped its destination: handleAddGsapTween read only `toProperties`; now falls back to `properties` like every other method. - handleSetTiming GSAP sync: a clip with no data-start skipped the shift (now treats start as 0, matching the server path) and a blank/non-numeric data-start wrote position: NaN (now sanitized). - handleSetTiming no longer appends an absolute position to an auto-sequenced (implicit-position) tween, which collapsed staggers. - handleSetTiming keeps data-end in sync when a clip carries BOTH data-duration and data-end (a stale data-end inverted the clip). - string/relative tween positions ("+=0.5", "<") documented as a known ceiling. - opacity/autoAlpha property seed no longer falsy-zero (`|| 1`): an element at opacity 0 seeds 0, not 1. - optimistic add-keyframe cache tolerance aligned to the writer's PCT_TOLERANCE (2%) so a near-neighbour keyframe no longer shows then vanishes on reload. - DOM-patch finiteness validation runs before the SDK cutover path. - attribute ops mapping to a reserved data-* name decline the cutover up front instead of throwing inside dispatch. Regression tests added for each SDK-side fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sdk,studio): R5 cutover review fixes — fromTo dest, timing sync, parity Confirmed correctness findings from the R5 review of the SDK cutover stack, applied on top of #1539: - fromTo add via cutover dropped its destination: handleAddGsapTween read only `toProperties`; now falls back to `properties` like every other method. - handleSetTiming GSAP sync: a clip with no data-start skipped the shift (now treats start as 0, matching the server path) and a blank/non-numeric data-start wrote position: NaN (now sanitized). - handleSetTiming no longer appends an absolute position to an auto-sequenced (implicit-position) tween, which collapsed staggers. - handleSetTiming keeps data-end in sync when a clip carries BOTH data-duration and data-end (a stale data-end inverted the clip). - string/relative tween positions ("+=0.5", "<") documented as a known ceiling. - opacity/autoAlpha property seed no longer falsy-zero (`|| 1`): an element at opacity 0 seeds 0, not 1. - optimistic add-keyframe cache tolerance aligned to the writer's PCT_TOLERANCE (2%) so a near-neighbour keyframe no longer shows then vanishes on reload. - DOM-patch finiteness validation runs before the SDK cutover path. - attribute ops mapping to a reserved data-* name decline the cutover up front instead of throwing inside dispatch. Regression tests added for each SDK-side fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio): close two gaps in the reserved-attr cutover gate - Lowercase the mapped attribute name before the reserved check, matching the SDK's validateSetAttribute (which lowercases), so a case-variant reserved name is declined up front instead of throwing inside dispatch. - Also gate `html-attribute` ops (raw, non-prefixed names), not just bare `attribute` ops. Both the emitter and the gate now derive the name via one shared `sdkAttrName` helper so they can't drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio): match keyframe remove-path tolerance to the writer (mirror of add) The optimistic remove-keyframe cache filtered with `> 0.001`, dropping only a near-exact match, while the writer removes within PCT_TOLERANCE (2). Removing at e.g. 49% dropped a 50% keyframe on disk but left it in the cache — a phantom that vanished on reload, the inverted twin of the add-path tolerance fix. Now filters with `> 2` to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the #1466 review findings (Via + Miguel). Lands as one fix PR at the stack tip — everything is behind
STUDIO_SDK_CUTOVER_ENABLED(default false, dark launch), so no mid-stack restack needed.#1466 — real regressions fixed (both fallback entry points)
data-playback-start/data-media-startpatch → restored conditionalliveAttrspush.shift/scaleGsapPositions+reloadPreview()(SDK path folds both intosetTiming; fallback must do them explicitly).coalesceKeythroughpersistTimelineEditso SDK and fallback paths coalesce identically.hasPbsAdjustment2nd clause +sdkTimingPersistbefore-capture transition limitation.#1500 — no code change needed
blockRemoveRangecomma-residue (potential): verified correct — no residue on first/middle/last/multi-block removal.tsc + timeline tests (37) + oxfmt/oxlint green.
🤖 Generated with Claude Code