Skip to content

Commit 7dea1e5

Browse files
fix(studio): playhead keyframe edits keep their ease, undo names its edit and never waits on videos (#4889)
* fix(studio): an edit on a GSAP-animated layer writes its value at the playhead into the file's tween * fix(studio): undo pressed while an edit saves undoes that edit, not one started after the key * fix(studio): an edit stays saved when the history reply after it cannot be read * feat(studio): export ColorField and GradientField; a session style commit takes a map * refactor(studio): name the map form instead of commenting it * feat(studio): a host can move layers by exact film px, one undo per call handleDomGroupMoveBy(moves) on useDomEditSession and DomEditValue moves each selection by its delta in composition px, for Desktop's align and distribute. It reuses the group drag's own path: a drag member per element (press-time route, measured offset map), the delta carried to screen px through the preview frames, then handleGsapAwareGroupPathOffsetCommit, so one shared coalesce key gives one undo entry and GSAP members take the GSAP write. A member that can't move refuses the call before anything is written; the refusal rejects with its reason and shows no Studio toast (the group commit takes refusalToast: false; telemetry stays). Zero deltas are skipped. Tests: a pre-offset element and one under a half-scale parent move by exactly {10,0} and {0,-5} under one group key; a half-scale parent with a 30 deg rotated ancestor lands within the 0.001 px a translate is written with; a GSAP member goes through the GSAP write; three refusal kinds write nothing, leave the boxes where they were, show no toast and leave no gesture mark, while a canvas group drag still toasts; the session hands the call to the group commit with refusalToast: false. Each mutation (no frame mapping, no zero skip, no restore, no teardown, no capability check, toast on refusal) fails at least one of them. * fix(studio): an outside change during a drag waits for the drop to show While a gesture mark is live in the preview, refreshPlayer takes the shadow load instead of swapping scenes in place. A swap replaced the node under the pointer mid-drag; the shadow load is then held by the existing gesture hold and, if the drop saved, reloaded fresh by the stale rule, so this adds no second "released" signal. Test: an outside change during a live drag on a preview that can swap scenes leaves the dragged node in place, shows nothing under the pointer, and after the drop loads fresh with the dropped position. It fails on the parent without the condition ("swapped under the pointer"). Cost case, measured in a real headless preview on the edit-accuracy bench's per-frame teleport check. Every GSAP instant patch was forced to miss and fall back to reloadPreview during its own commit; move and rotate of a held element (gsap.set and tl.set holds), px and xPercent placement, r0 and r30, root and nested, at 100%: - The gesture mark was live when the fallback asked for the reload, but had always ended by the time refreshPlayer ran (refreshKey is consumed in an effect after the commit settles). So the new condition never fired there: root previews offer no scene swap and load shadow on both builds, and nested previews swap on both builds. - Worst post-release frame offset, 32 cases on this build and 32 on the parent: 0.00 px over 76-106 frames each (release to settle). No frame was off by more than 0.5 px. - Sensitivity: a 3 px nudge injected 50 ms after the fallback reads back as 3.00 px. * fix(studio): an edit no longer reopens the live-updates stream, and thumbnails ask one at a time * fix(studio): an undo no longer waits behind paused preview videos for a connection * feat(studio): a host whose API needs same-origin cookies can have Studio send them * docs(studio-server): a host on same-origin cookies sets the Studio credentials opt-out * test(studio): an undo is not held up by the preview's paused videos * fix(studio): a resize inside one gesture writes a mixed tween once, in place, never beside it * chore(studio): keep the carried fixes inside the comment ratchet; telemetry posts through studioApiFetch * test(studio): a drag on a mixed tween is one write under the gesture key * fix(studio): freeze-frame, loudness and peak-map requests go through studioApiFetch too * fix(studio): an undo with no project open reloads in full instead of skipping nested files * refactor(studio): drop the soft reload's reparse seeding, the held-element sweep covers it * test(studio): keep the undo-restore browser test off the network and inside its timeout * test(studio): nested composition reads expect the cookie-free request studioApiFetch sends * fix(studio): an edit at the playhead rewrites its tween in place, so later tweens keep their times * fix(studio): a rotate on a tween that turns another axis holds its angle and never turns early * fix(studio): plan every group member before the first write so one refusal moves none A group drag or nudge preflighted each member against the move refusal rule only. The value-at-playhead planner ran later, in the commit loop, after earlier members were already flushed to disk, so a tween with extras, eased keyframes or an unknown ease left the group half moved. The group preflight now runs the commit's own route and keyframe plan for every member with its real offset, so any refusal refuses the whole group with nothing written and one toast attributed to the refusing member. The plan is not reused in the commit: earlier members' writes change the file under it, so each member re-plans against the fresh tween, from the same planValueEdit. * chore(studio): the fetch lint rule also catches window.fetch, globalThis.fetch and self.fetch * docs(studio-server): set the credentials flag before Studio loads; a mounting host injects it * test(studio): the undo socket test needs six pinned videos under Vite and checks each undo restores * fix(studio): Studio leaves cookies out of its requests only when served from loopback * fix(studio): undo takes back the first edit, and the first save never waits on media * fix(studio): undo of a move whose save failed stays undone and steps back no older edit * fix(studio): a style set by its JavaScript name saves under its CSS name * test(studio): the undo socket test holds round 0 to a full restore too * fix(studio): the host revert keeps returning what shows the edit again * fix(studio): an entrance rewritten at the playhead still shows its start state before it starts * fix(studio): undo takes back the edit pending at the key even after a later edit claims * chore(studio): lift the late-write quarantine now that undo never waits on videos; format * test(studio): pin an entrance's start render on the written tween, not a happy-dom GSAP run * revert(studio): leave entrances as on main, immediateRender does not render a keyframed start This reverts commit ee9d130 and its written-vars test. * revert(studio): undo goes back to the newest claim after the key, as on main This reverts commit 5b9009a. * revert(studio): first-undo history opening moves to the next PR This reverts commit 4e1e75d. * revert(studio): the undo socket test exempts round 0 again until first-undo lands This reverts commit 1f8f970. * test(studio): bank the edit accuracy cases this PR makes pass * refactor(studio): share one project file fetch between the plain and optional reads * test(studio): share the WebMCP host shim between the browser gates * test(studio-server): share one stub adapter across the route tests * refactor(studio): type the preview slot props from the NLE context * test(studio): share the setup the new editing tests copied between files * refactor(studio): split playhead tween picking and undo path merging into small tested helpers * test(studio): pin when a resize at the playhead also writes the anchor move * test(parsers): pin rewriting a tween in place as keyframes * chore(studio): cut comments the new helper names already say * fix(studio): composition thumbnails keep two fetches in flight, as the document lifetime fix expects * fix(studio): a playhead edit parses a tween not yet played and matches only its own tween * fix(parsers): a new position argument goes after the last argument, so trailing commas stay valid * fix(studio): a second undo pressed during one save steps back to the edit before it * test(studio): pin the slot an aborted thumbnail frees and the shared tween refusal * refactor(studio): split the unplayed-tween parse into small helpers and name the claim release * fix(studio): a playhead edit keeps the tween's default ease, delay timing and loop settings * fix(studio): a group move refuses layers animated in two files and plans at the real drop * style(studio): format two test files * refactor(studio): check a group's files inside the preflight helper * refactor(studio): move the group preflight checks out of the editing hook, under 600 lines * fix(studio): read an unreached tween without redrawing other layers, and compare write files * test(studio): use the shared selection helper in main's keyframe-at-time telemetry test * chore(studio): fix a comment that read as a repo path and cut one added comment * fix(studio): read a later tween's start as playback does, and count every keyframe add * fix(studio): two undo presses during one save step back two edits, not one twice * fix(studio): an undo that succeeds by any route retires the claim on the edit it undid * fix(studio): a claim is never handed back to an edit another undo has already undone * fix(studio): an undo picks its claimed edit at the press and skips it once undone * fix(studio): a press refuses, as main did, once its edit is undone and a newer edit exists * fix(studio): an unplayed keyframe tween clears every keyframe's channels before it is read * fix(studio): reading an unplayed tween puts back every transform value its clear removed * fix(studio): reading an unplayed tween restores each layer's whole style and GSAP state * fix(studio): reading an unplayed tween skips plain-object targets and restores attributes * fix(studio): shared tween-target filter uses GSAP's own styled-element test * fix(studio): match a parsed tween on any target, and check a later keyframe channel's start * docs(studio): say the unplayed-tween read restores styled elements only * test(studio): a drag on a fromTo start shows the dropped x through the runtime seek
1 parent 660674f commit 7dea1e5

152 files changed

Lines changed: 6315 additions & 3195 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,8 @@ jobs:
850850
packages/studio/data/projects/fade-handles
851851
cp -R packages/studio/tests/e2e/fixtures/inline-text-caret \
852852
packages/studio/data/projects/inline-text-caret
853+
cp -R packages/studio/tests/e2e/fixtures/undo-socket-pool \
854+
packages/studio/data/projects/undo-socket-pool
853855
- name: Read the base branch's work-count ceilings
854856
# A ceiling may not disappear, or rise without a new reason, against the branch this PR merges into.
855857
if: github.event_name == 'pull_request'
@@ -1000,6 +1002,28 @@ jobs:
10001002
STUDIO_URL="http://localhost:5317/#project/inline-text-caret" \
10011003
EVIDENCE_DIR=/tmp/inline-text-caret \
10021004
node packages/studio/tests/e2e/inline-text-caret.mjs
1005+
- name: Install ffmpeg for the paused-video fixture
1006+
if: success() || failure()
1007+
uses: ./.github/actions/install-ffmpeg-linux
1008+
- name: Undo while paused videos hold the preview's sockets
1009+
# Counts the undo's wait for a socket, not its total time: unpinned it is 0 ms.
1010+
if: success() || failure()
1011+
run: |
1012+
set -euo pipefail
1013+
NODE_ENV=production bun run --cwd packages/studio dev -- --port 5318 --strictPort &
1014+
SERVER_PID=$!
1015+
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
1016+
for i in $(seq 1 60); do
1017+
if curl -sf "http://localhost:5318/" >/dev/null 2>&1; then break; fi
1018+
sleep 1
1019+
done
1020+
if ! curl -sf "http://localhost:5318/" >/dev/null 2>&1; then
1021+
echo "FAIL: studio dev server did not start on port 5318"
1022+
exit 1
1023+
fi
1024+
STUDIO_URL="http://localhost:5318/#project/undo-socket-pool" \
1025+
STUDIO_PROJECT_DIR="$PWD/packages/studio/data/projects/undo-socket-pool" \
1026+
node packages/studio/tests/e2e/undo-socket-pool.mjs | tee /tmp/undo-socket-pool.json
10031027
- name: Upload gate evidence
10041028
# The gate's whole output is machine-readable evidence, and a red run is
10051029
# exactly when someone needs to read it. Keep it on failure too.
@@ -1011,6 +1035,7 @@ jobs:
10111035
/tmp/timeline-gate-*.json
10121036
/tmp/studio-open-counts.json
10131037
/tmp/inline-text-caret/*.png
1038+
/tmp/undo-socket-pool.json
10141039
if-no-files-found: error
10151040

10161041
# Manual editing accuracy in the built Studio, gated against the base branch's baseline.json.

‎.oxlintrc.json‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,45 @@
4545
"react-hooks/rules-of-hooks": "error",
4646
"react-hooks/exhaustive-deps": "warn"
4747
}
48+
},
49+
{
50+
"files": ["packages/studio/src/**/*.ts", "packages/studio/src/**/*.tsx"],
51+
"excludeFiles": [
52+
"**/*.test.ts",
53+
"**/*.test.tsx",
54+
"**/*TestUtils.ts",
55+
"**/*TestUtils.tsx",
56+
"**/*TestHarness.ts",
57+
"**/*TestHarness.tsx",
58+
"packages/studio/src/utils/studioApiFetch.ts"
59+
],
60+
"rules": {
61+
"no-restricted-globals": [
62+
"error",
63+
{
64+
"name": "fetch",
65+
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
66+
}
67+
],
68+
"no-restricted-properties": [
69+
"error",
70+
{
71+
"object": "window",
72+
"property": "fetch",
73+
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
74+
},
75+
{
76+
"object": "globalThis",
77+
"property": "fetch",
78+
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
79+
},
80+
{
81+
"object": "self",
82+
"property": "fetch",
83+
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
84+
}
85+
]
86+
}
4887
}
4988
]
5089
}

‎docs/packages/studio-server.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ export function mountStudioApi(
3535
}
3636
```
3737

38+
When it is served from loopback (`localhost`, `127.0.0.1` or `[::1]`), Studio
39+
sends its API requests without cookies, so they don't queue behind the
40+
preview's media. On any other origin it sends them as usual, so remote and
41+
proxied setups (Codespaces, tunnels, an access proxy) keep their cookies by
42+
default. A loopback host that authenticates its mounted API with same-origin
43+
cookies sets `VITE_STUDIO_API_SAME_ORIGIN_CREDENTIALS=true`, at build time or at
44+
runtime in `window.__HF_STUDIO_ENV__`; it costs that host the connection
45+
speedup.
46+
Studio reads the flag once, when its modules load, so set
47+
`window.__HF_STUDIO_ENV__` before loading Studio; the CLI injects it, but a host
48+
that mounts the API itself must inject it too.
49+
3850
A `StudioApiAdapter` supplies:
3951

4052
- project listing and resolution;

‎packages/parsers/src/gsapParserAcorn.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1903,7 +1903,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
19031903
tweenCallToAnimation(call, scope, script, identifierBindings),
19041904
);
19051905
applyTimelineDefaults(rawAnims, detection.defaults);
1906-
resolveTimelinePositions(rawAnims);
1906+
resolveTimelinePositions(rawAnims, collectAddLabelDefs(ast, ref, scope, calls));
19071907
const animations = assignStableIds(rawAnims);
19081908
const located = calls.map((call, i) => ({
19091909
id: animations[i]!.id,

‎packages/parsers/src/gsapWriter.acorn.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
copyAnimationsInScript,
1414
removeAnimationFromScript,
1515
removeKeyframeFromScript,
16+
replaceTweenWithKeyframesInScript,
1617
updateAnimationInScript,
1718
updateKeyframeInScript,
1819
} from "./gsapWriterAcorn.js";
@@ -535,3 +536,60 @@ tl.from("#goodbye", { opacity: 0 }, 1);`;
535536
expect(copyAnimationsInScript(script, "#tag", "#tag-2", 3)).toBe(script);
536537
});
537538
});
539+
540+
describe("replaceTweenWithKeyframesInScript", () => {
541+
const script = `var tl = gsap.timeline({ paused: true });
542+
tl.fromTo("#a", { x: 0 }, { x: 100, duration: 1, delay: 0.5, onStart: go }, 1);
543+
tl.set("#b", { x: 5 }, 0);`;
544+
const tweenOn = (selector: string) =>
545+
parseGsapScriptAcorn(script).animations.find((a) => a.targetSelector === selector)!;
546+
const edit = (position: number, extra = {}) => ({
547+
targetSelector: "#a",
548+
position,
549+
duration: 2,
550+
keyframes: [
551+
{ percentage: 100, properties: { x: 100 } },
552+
{ percentage: 0, properties: { x: 0 } },
553+
],
554+
...extra,
555+
});
556+
const rewritten = (out: string | null) => out?.split("\n")[1];
557+
558+
it("leaves a tween it cannot find, or a set(), to the caller", () => {
559+
expect(replaceTweenWithKeyframesInScript(script, "#missing", edit(1))).toBeNull();
560+
expect(replaceTweenWithKeyframesInScript(script, tweenOn("#b").id, edit(0))).toBeNull();
561+
});
562+
563+
it("rewrites a tween that stays put as to(), keeping its position, delay and callbacks", () => {
564+
const { id, resolvedStart } = tweenOn("#a");
565+
expect(rewritten(replaceTweenWithKeyframesInScript(script, id, edit(resolvedStart!)))).toBe(
566+
'tl.to("#a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, delay: 0.5, onStart: go, duration: 2 }, 1);',
567+
);
568+
});
569+
570+
it("moves a retargeted tween to the new position without its delay, with the edit's ease", () => {
571+
const out = replaceTweenWithKeyframesInScript(
572+
script,
573+
tweenOn("#a").id,
574+
edit(3, { targetSelector: "#c", ease: "none", easeEach: "power1.in" }),
575+
);
576+
expect(rewritten(out)).toBe(
577+
'tl.to("#c", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 }, easeEach: "power1.in" }, onStart: go, duration: 2, ease: "none" }, 3);',
578+
);
579+
});
580+
});
581+
582+
describe("position argument", () => {
583+
it("is added after the last argument, so a call with a trailing comma stays valid", () => {
584+
const script = `var tl = gsap.timeline({ paused: true });
585+
tl.to(
586+
"#a",
587+
{ x: 100, duration: 1 },
588+
);
589+
window.__timelines["t"] = tl;`;
590+
const id = parseGsapScriptAcorn(script).animations[0]!.id;
591+
const result = updateAnimationInScript(script, id, { position: 0.5 });
592+
expect(() => new Function("gsap", "window", result)).not.toThrow();
593+
expect(parseGsapScriptAcorn(result).animations[0]!.position).toBe(0.5);
594+
});
595+
});

‎packages/parsers/src/gsapWriterAcorn.ts‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,8 @@ function overwritePosition(ms: MagicString, call: TweenCallInfo, position: numbe
443443
if (call.positionArg) {
444444
ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position));
445445
} else {
446-
ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`);
446+
const last = call.node.arguments.at(-1);
447+
ms.appendLeft(last ? last.end : call.node.end - 1, `, ${valueToCode(position)}`);
447448
}
448449
}
449450

@@ -1790,6 +1791,54 @@ export function materializeKeyframesFromScript(
17901791
return ms.toString();
17911792
}
17921793

1794+
/**
1795+
* Rewrites a tween as `to()` with these keyframes where it stands: the call keeps its place in the
1796+
* script and its position argument, so a tween placed after it ('>', '<', '+=', none) stays put.
1797+
*/
1798+
export function replaceTweenWithKeyframesInScript(
1799+
script: string,
1800+
animationId: string,
1801+
edit: {
1802+
targetSelector: string;
1803+
position: number;
1804+
duration: number;
1805+
keyframes: Array<{
1806+
percentage: number;
1807+
properties: Record<string, number | string>;
1808+
ease?: string;
1809+
auto?: boolean;
1810+
}>;
1811+
ease?: string;
1812+
easeEach?: string;
1813+
},
1814+
): string | null {
1815+
const parsed = parseGsapScriptAcornForWrite(script);
1816+
const target = parsed?.located.find((l) => l.id === animationId);
1817+
const call = target?.call;
1818+
if (!target || call?.varsArg?.type !== "ObjectExpression") return null;
1819+
const { animation } = target;
1820+
if (animation.method !== "to" && animation.method !== "from" && animation.method !== "fromTo")
1821+
return null;
1822+
const ms = new MagicString(script);
1823+
const start = animation.resolvedStart ?? animation.position;
1824+
const moved = typeof start !== "number" || Math.abs(start - edit.position) > 5e-4;
1825+
const kept = preservedVarsEntries(call.varsArg, script).filter(
1826+
(e) => !/^\s*duration\s*:/.test(e) && !(moved && /^\s*delay\s*:/.test(e)),
1827+
);
1828+
const sorted = [...edit.keyframes].sort((a, b) => a.percentage - b.percentage);
1829+
const parts = [`keyframes: ${buildKeyframeObjectCode(sorted, edit.easeEach)}`, ...kept];
1830+
parts.push(`duration: ${valueToCode(edit.duration)}`);
1831+
if (edit.ease) parts.push(`ease: ${JSON.stringify(edit.ease)}`);
1832+
if (animation.hasUnresolvedSelector || animation.targetSelector !== edit.targetSelector) {
1833+
const selectorArg = call.node.arguments[0];
1834+
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(edit.targetSelector));
1835+
}
1836+
convertMethodToTo(ms, animation, call, call.varsArg);
1837+
overwriteVarsArg(ms, call, `{ ${parts.join(", ")} }`);
1838+
if (moved) overwritePosition(ms, call, edit.position);
1839+
return ms.toString();
1840+
}
1841+
17931842
// ── Add animation with keyframes ──────────────────────────────────────────────
17941843

17951844
/** Insert a new keyframed `to()` call and return the new animation ID. */

‎packages/studio-server/src/routes/files.patchRace.test.ts‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Hono } from "hono";
1414
import { afterEach, describe, expect, it, vi } from "vitest";
1515
import { fileContentVersion, identifyFileWrite } from "../helpers/fileVersion";
1616
import { registerFileRoutes } from "./files";
17+
import { stubAdapter } from "./stubAdapter.test-helpers";
1718

1819
const hooks = vi.hoisted(() => ({
1920
transforming: undefined as (() => void) | undefined,
@@ -76,15 +77,7 @@ function project() {
7677
const path = join(dir, "index.html");
7778
writeFileSync(path, ORIGINAL);
7879
const app = new Hono();
79-
registerFileRoutes(app, {
80-
listProjects: () => [],
81-
resolveProject: async (id: string) => ({ id, dir }),
82-
bundle: async () => null,
83-
lint: async () => ({ findings: [] }),
84-
runtimeUrl: "/api/runtime.js",
85-
rendersDir: () => "/tmp/renders",
86-
startRender: () => ({ id: "job", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }),
87-
});
80+
registerFileRoutes(app, stubAdapter(dir));
8881
const post = (route: string, body: unknown) =>
8982
app.request(`http://localhost/projects/demo/file-mutations/${route}`, {
9083
method: "POST",

0 commit comments

Comments
 (0)