Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
64076fc
fix(studio): an edit on a GSAP-animated layer writes its value at the…
miguel-heygen Oct 1, 2026
629c894
fix(studio): undo pressed while an edit saves undoes that edit, not o…
miguel-heygen Oct 2, 2026
db69205
fix(studio): an edit stays saved when the history reply after it cann…
miguel-heygen Oct 2, 2026
c6eb82b
feat(studio): export ColorField and GradientField; a session style co…
miguel-heygen Oct 1, 2026
f3de9cb
refactor(studio): name the map form instead of commenting it
miguel-heygen Oct 1, 2026
fee9a16
feat(studio): a host can move layers by exact film px, one undo per call
miguel-heygen Oct 1, 2026
eee5a34
fix(studio): an outside change during a drag waits for the drop to show
miguel-heygen Oct 2, 2026
70132fb
fix(studio): an edit no longer reopens the live-updates stream, and t…
miguel-heygen Oct 2, 2026
c419469
fix(studio): an undo no longer waits behind paused preview videos for…
miguel-heygen Oct 2, 2026
375458d
feat(studio): a host whose API needs same-origin cookies can have Stu…
miguel-heygen Oct 2, 2026
b275921
docs(studio-server): a host on same-origin cookies sets the Studio cr…
miguel-heygen Oct 2, 2026
0121fa3
test(studio): an undo is not held up by the preview's paused videos
miguel-heygen Oct 2, 2026
101ef72
fix(studio): a resize inside one gesture writes a mixed tween once, i…
miguel-heygen Oct 2, 2026
785a073
chore(studio): keep the carried fixes inside the comment ratchet; tel…
miguel-heygen Oct 2, 2026
901e6d0
test(studio): a drag on a mixed tween is one write under the gesture key
miguel-heygen Oct 2, 2026
d2aa4b8
fix(studio): freeze-frame, loudness and peak-map requests go through …
miguel-heygen Oct 2, 2026
d32b39c
fix(studio): an undo with no project open reloads in full instead of …
miguel-heygen Oct 2, 2026
0fa64b8
refactor(studio): drop the soft reload's reparse seeding, the held-el…
miguel-heygen Oct 2, 2026
ef37c5b
test(studio): keep the undo-restore browser test off the network and …
miguel-heygen Oct 2, 2026
f19f052
test(studio): nested composition reads expect the cookie-free request…
miguel-heygen Oct 2, 2026
6d19a86
fix(studio): an edit at the playhead rewrites its tween in place, so …
miguel-heygen Oct 2, 2026
e75eb23
fix(studio): a rotate on a tween that turns another axis holds its an…
miguel-heygen Oct 2, 2026
a6e0ec5
fix(studio): plan every group member before the first write so one re…
miguel-heygen Oct 2, 2026
b7a8f54
chore(studio): the fetch lint rule also catches window.fetch, globalT…
miguel-heygen Oct 2, 2026
7859d49
docs(studio-server): set the credentials flag before Studio loads; a …
miguel-heygen Oct 2, 2026
411c7c5
test(studio): the undo socket test needs six pinned videos under Vite…
miguel-heygen Oct 2, 2026
a26f2d7
fix(studio): Studio leaves cookies out of its requests only when serv…
miguel-heygen Oct 2, 2026
4e1e75d
fix(studio): undo takes back the first edit, and the first save never…
miguel-heygen Oct 2, 2026
1a4e4dd
fix(studio): undo of a move whose save failed stays undone and steps …
miguel-heygen Oct 2, 2026
3cc6010
fix(studio): a style set by its JavaScript name saves under its CSS name
miguel-heygen Oct 2, 2026
1f8f970
test(studio): the undo socket test holds round 0 to a full restore too
miguel-heygen Oct 2, 2026
2efce14
fix(studio): the host revert keeps returning what shows the edit again
miguel-heygen Oct 2, 2026
ee9d130
fix(studio): an entrance rewritten at the playhead still shows its st…
miguel-heygen Oct 2, 2026
5b9009a
fix(studio): undo takes back the edit pending at the key even after a…
miguel-heygen Oct 2, 2026
10ee45c
chore(studio): lift the late-write quarantine now that undo never wai…
miguel-heygen Oct 2, 2026
e64a81c
test(studio): pin an entrance's start render on the written tween, no…
miguel-heygen Oct 2, 2026
504ab6b
revert(studio): leave entrances as on main, immediateRender does not …
miguel-heygen Oct 2, 2026
e5e0b2e
revert(studio): undo goes back to the newest claim after the key, as …
miguel-heygen Oct 2, 2026
5574c4a
revert(studio): first-undo history opening moves to the next PR
miguel-heygen Oct 2, 2026
1b21d60
revert(studio): the undo socket test exempts round 0 again until firs…
miguel-heygen Oct 2, 2026
175b171
test(studio): bank the edit accuracy cases this PR makes pass
miguel-heygen Oct 2, 2026
20fe5f0
refactor(studio): share one project file fetch between the plain and …
miguel-heygen Oct 2, 2026
8b3c9dd
test(studio): share the WebMCP host shim between the browser gates
miguel-heygen Oct 2, 2026
ad8dca5
test(studio-server): share one stub adapter across the route tests
miguel-heygen Oct 2, 2026
99985d3
refactor(studio): type the preview slot props from the NLE context
miguel-heygen Oct 2, 2026
704efa1
test(studio): share the setup the new editing tests copied between files
miguel-heygen Oct 2, 2026
4d6e6d9
refactor(studio): split playhead tween picking and undo path merging …
miguel-heygen Oct 2, 2026
5ebd2b3
test(studio): pin when a resize at the playhead also writes the ancho…
miguel-heygen Oct 2, 2026
deb57e4
test(parsers): pin rewriting a tween in place as keyframes
miguel-heygen Oct 2, 2026
68b0342
chore(studio): cut comments the new helper names already say
miguel-heygen Oct 2, 2026
358a94b
fix(studio): composition thumbnails keep two fetches in flight, as th…
miguel-heygen Oct 2, 2026
e7546d1
fix(studio): a playhead edit parses a tween not yet played and matche…
miguel-heygen Oct 2, 2026
46c37bf
fix(parsers): a new position argument goes after the last argument, s…
miguel-heygen Oct 2, 2026
8aff9c3
fix(studio): a second undo pressed during one save steps back to the …
miguel-heygen Oct 2, 2026
9b60011
test(studio): pin the slot an aborted thumbnail frees and the shared …
miguel-heygen Oct 2, 2026
234d290
refactor(studio): split the unplayed-tween parse into small helpers a…
miguel-heygen Oct 2, 2026
e19d346
fix(studio): a playhead edit keeps the tween's default ease, delay ti…
miguel-heygen Oct 2, 2026
64b90e7
fix(studio): a group move refuses layers animated in two files and pl…
miguel-heygen Oct 2, 2026
e69af95
style(studio): format two test files
miguel-heygen Oct 2, 2026
ef7a702
refactor(studio): check a group's files inside the preflight helper
miguel-heygen Oct 2, 2026
119398b
refactor(studio): move the group preflight checks out of the editing …
miguel-heygen Oct 3, 2026
f207597
chore(studio): merge origin/main into the playhead writer branch
miguel-heygen Oct 3, 2026
3d8fa96
fix(studio): read an unreached tween without redrawing other layers, …
miguel-heygen Oct 3, 2026
e0b42ec
test(studio): use the shared selection helper in main's keyframe-at-t…
miguel-heygen Oct 3, 2026
3dfff87
chore(studio): fix a comment that read as a repo path and cut one add…
miguel-heygen Oct 3, 2026
8347faa
fix(studio): read a later tween's start as playback does, and count e…
miguel-heygen Oct 3, 2026
035aa1d
fix(studio): two undo presses during one save step back two edits, no…
miguel-heygen Oct 3, 2026
8581c5f
fix(studio): an undo that succeeds by any route retires the claim on …
miguel-heygen Oct 3, 2026
45aefa6
fix(studio): a claim is never handed back to an edit another undo has…
miguel-heygen Oct 3, 2026
5f4055d
fix(studio): an undo picks its claimed edit at the press and skips it…
miguel-heygen Oct 3, 2026
707e3d6
fix(studio): a press refuses, as main did, once its edit is undone an…
miguel-heygen Oct 3, 2026
5518787
fix(studio): an unplayed keyframe tween clears every keyframe's chann…
miguel-heygen Oct 3, 2026
b87e8ee
fix(studio): reading an unplayed tween puts back every transform valu…
miguel-heygen Oct 3, 2026
13d7b8d
fix(studio): reading an unplayed tween restores each layer's whole st…
miguel-heygen Oct 3, 2026
acc76b9
fix(studio): reading an unplayed tween skips plain-object targets and…
miguel-heygen Oct 3, 2026
8e24b55
chore(studio): merge main into the playhead writer carrier
miguel-heygen Oct 3, 2026
4312326
fix(studio): shared tween-target filter uses GSAP's own styled-elemen…
miguel-heygen Oct 3, 2026
375d1ca
fix(studio): match a parsed tween on any target, and check a later ke…
miguel-heygen Oct 3, 2026
b8c7f75
docs(studio): say the unplayed-tween read restores styled elements only
miguel-heygen Oct 3, 2026
cf1eb39
test(studio): a drag on a fromTo start shows the dropped x through th…
miguel-heygen Oct 3, 2026
802c3d6
chore(studio): merge main into the playhead writer carrier
miguel-heygen Oct 3, 2026
93a9d43
chore(studio): merge main with the core seek fix into the playhead wr…
miguel-heygen Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,8 @@ jobs:
packages/studio/data/projects/fade-handles
cp -R packages/studio/tests/e2e/fixtures/inline-text-caret \
packages/studio/data/projects/inline-text-caret
cp -R packages/studio/tests/e2e/fixtures/undo-socket-pool \
packages/studio/data/projects/undo-socket-pool
- name: Read the base branch's work-count ceilings
# A ceiling may not disappear, or rise without a new reason, against the branch this PR merges into.
if: github.event_name == 'pull_request'
Expand Down Expand Up @@ -1000,6 +1002,28 @@ jobs:
STUDIO_URL="http://localhost:5317/#project/inline-text-caret" \
EVIDENCE_DIR=/tmp/inline-text-caret \
node packages/studio/tests/e2e/inline-text-caret.mjs
- name: Install ffmpeg for the paused-video fixture
if: success() || failure()
uses: ./.github/actions/install-ffmpeg-linux
- name: Undo while paused videos hold the preview's sockets
# Counts the undo's wait for a socket, not its total time: unpinned it is 0 ms.
if: success() || failure()
run: |
set -euo pipefail
NODE_ENV=production bun run --cwd packages/studio dev -- --port 5318 --strictPort &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
for i in $(seq 1 60); do
if curl -sf "http://localhost:5318/" >/dev/null 2>&1; then break; fi
sleep 1
done
if ! curl -sf "http://localhost:5318/" >/dev/null 2>&1; then
echo "FAIL: studio dev server did not start on port 5318"
exit 1
fi
STUDIO_URL="http://localhost:5318/#project/undo-socket-pool" \
STUDIO_PROJECT_DIR="$PWD/packages/studio/data/projects/undo-socket-pool" \
node packages/studio/tests/e2e/undo-socket-pool.mjs | tee /tmp/undo-socket-pool.json
- name: Upload gate evidence
# The gate's whole output is machine-readable evidence, and a red run is
# exactly when someone needs to read it. Keep it on failure too.
Expand All @@ -1011,6 +1035,7 @@ jobs:
/tmp/timeline-gate-*.json
/tmp/studio-open-counts.json
/tmp/inline-text-caret/*.png
/tmp/undo-socket-pool.json
if-no-files-found: error

# Manual editing accuracy in the built Studio, gated against the base branch's baseline.json.
Expand Down
39 changes: 39 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,45 @@
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
},
{
"files": ["packages/studio/src/**/*.ts", "packages/studio/src/**/*.tsx"],
"excludeFiles": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*TestUtils.ts",
"**/*TestUtils.tsx",
"**/*TestHarness.ts",
"**/*TestHarness.tsx",
"packages/studio/src/utils/studioApiFetch.ts"
],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "fetch",
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
}
],
"no-restricted-properties": [
"error",
{
"object": "window",
"property": "fetch",
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
},
{
"object": "globalThis",
"property": "fetch",
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
},
{
"object": "self",
"property": "fetch",
"message": "Use studioApiFetch (utils/studioApiFetch.ts): it keeps Studio's requests off the preview media's sockets."
}
]
}
}
]
}
12 changes: 12 additions & 0 deletions docs/packages/studio-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ export function mountStudioApi(
}
```

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

A `StudioApiAdapter` supplies:

- project listing and resolution;
Expand Down
2 changes: 1 addition & 1 deletion packages/parsers/src/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1903,7 +1903,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
tweenCallToAnimation(call, scope, script, identifierBindings),
);
applyTimelineDefaults(rawAnims, detection.defaults);
resolveTimelinePositions(rawAnims);
resolveTimelinePositions(rawAnims, collectAddLabelDefs(ast, ref, scope, calls));
const animations = assignStableIds(rawAnims);
const located = calls.map((call, i) => ({
id: animations[i]!.id,
Expand Down
58 changes: 58 additions & 0 deletions packages/parsers/src/gsapWriter.acorn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
copyAnimationsInScript,
removeAnimationFromScript,
removeKeyframeFromScript,
replaceTweenWithKeyframesInScript,
updateAnimationInScript,
updateKeyframeInScript,
} from "./gsapWriterAcorn.js";
Expand Down Expand Up @@ -535,3 +536,60 @@ tl.from("#goodbye", { opacity: 0 }, 1);`;
expect(copyAnimationsInScript(script, "#tag", "#tag-2", 3)).toBe(script);
});
});

describe("replaceTweenWithKeyframesInScript", () => {
const script = `var tl = gsap.timeline({ paused: true });
tl.fromTo("#a", { x: 0 }, { x: 100, duration: 1, delay: 0.5, onStart: go }, 1);
tl.set("#b", { x: 5 }, 0);`;
const tweenOn = (selector: string) =>
parseGsapScriptAcorn(script).animations.find((a) => a.targetSelector === selector)!;
const edit = (position: number, extra = {}) => ({
targetSelector: "#a",
position,
duration: 2,
keyframes: [
{ percentage: 100, properties: { x: 100 } },
{ percentage: 0, properties: { x: 0 } },
],
...extra,
});
const rewritten = (out: string | null) => out?.split("\n")[1];

it("leaves a tween it cannot find, or a set(), to the caller", () => {
expect(replaceTweenWithKeyframesInScript(script, "#missing", edit(1))).toBeNull();
expect(replaceTweenWithKeyframesInScript(script, tweenOn("#b").id, edit(0))).toBeNull();
});

it("rewrites a tween that stays put as to(), keeping its position, delay and callbacks", () => {
const { id, resolvedStart } = tweenOn("#a");
expect(rewritten(replaceTweenWithKeyframesInScript(script, id, edit(resolvedStart!)))).toBe(
'tl.to("#a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, delay: 0.5, onStart: go, duration: 2 }, 1);',
);
});

it("moves a retargeted tween to the new position without its delay, with the edit's ease", () => {
const out = replaceTweenWithKeyframesInScript(
script,
tweenOn("#a").id,
edit(3, { targetSelector: "#c", ease: "none", easeEach: "power1.in" }),
);
expect(rewritten(out)).toBe(
'tl.to("#c", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 }, easeEach: "power1.in" }, onStart: go, duration: 2, ease: "none" }, 3);',
);
});
});

describe("position argument", () => {
it("is added after the last argument, so a call with a trailing comma stays valid", () => {
const script = `var tl = gsap.timeline({ paused: true });
tl.to(
"#a",
{ x: 100, duration: 1 },
);
window.__timelines["t"] = tl;`;
const id = parseGsapScriptAcorn(script).animations[0]!.id;
const result = updateAnimationInScript(script, id, { position: 0.5 });
expect(() => new Function("gsap", "window", result)).not.toThrow();
expect(parseGsapScriptAcorn(result).animations[0]!.position).toBe(0.5);
});
});
51 changes: 50 additions & 1 deletion packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,8 @@ function overwritePosition(ms: MagicString, call: TweenCallInfo, position: numbe
if (call.positionArg) {
ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position));
} else {
ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`);
const last = call.node.arguments.at(-1);
ms.appendLeft(last ? last.end : call.node.end - 1, `, ${valueToCode(position)}`);
}
}

Expand Down Expand Up @@ -1790,6 +1791,54 @@ export function materializeKeyframesFromScript(
return ms.toString();
}

/**
* Rewrites a tween as `to()` with these keyframes where it stands: the call keeps its place in the
* script and its position argument, so a tween placed after it ('>', '<', '+=', none) stays put.
*/
export function replaceTweenWithKeyframesInScript(
script: string,
animationId: string,
edit: {
targetSelector: string;
position: number;
duration: number;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>;
ease?: string;
easeEach?: string;
},
): string | null {
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((l) => l.id === animationId);
const call = target?.call;
if (!target || call?.varsArg?.type !== "ObjectExpression") return null;
const { animation } = target;
if (animation.method !== "to" && animation.method !== "from" && animation.method !== "fromTo")
return null;
const ms = new MagicString(script);
const start = animation.resolvedStart ?? animation.position;
const moved = typeof start !== "number" || Math.abs(start - edit.position) > 5e-4;
const kept = preservedVarsEntries(call.varsArg, script).filter(
(e) => !/^\s*duration\s*:/.test(e) && !(moved && /^\s*delay\s*:/.test(e)),
);
const sorted = [...edit.keyframes].sort((a, b) => a.percentage - b.percentage);
const parts = [`keyframes: ${buildKeyframeObjectCode(sorted, edit.easeEach)}`, ...kept];
parts.push(`duration: ${valueToCode(edit.duration)}`);
if (edit.ease) parts.push(`ease: ${JSON.stringify(edit.ease)}`);
if (animation.hasUnresolvedSelector || animation.targetSelector !== edit.targetSelector) {
const selectorArg = call.node.arguments[0];
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(edit.targetSelector));
}
convertMethodToTo(ms, animation, call, call.varsArg);
overwriteVarsArg(ms, call, `{ ${parts.join(", ")} }`);
if (moved) overwritePosition(ms, call, edit.position);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should fix (3): when the call has no position argument and a trailing comma after its vars object, overwritePosition appends at call.node.end - 1 and produces },\n, 0.5);. Reproduced through the route: 200, the file is written, and the script no longer parses. Insert after call.node.arguments.at(-1).end instead.

return ms.toString();
}

// ── Add animation with keyframes ──────────────────────────────────────────────

/** Insert a new keyframed `to()` call and return the new animation ID. */
Expand Down
11 changes: 2 additions & 9 deletions packages/studio-server/src/routes/files.patchRace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fileContentVersion, identifyFileWrite } from "../helpers/fileVersion";
import { registerFileRoutes } from "./files";
import { stubAdapter } from "./stubAdapter.test-helpers";

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