diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef1f49dd1f8..4a979d6b222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' @@ -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. @@ -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. diff --git a/.oxlintrc.json b/.oxlintrc.json index c9ef018f3f6..243863419b8 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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." + } + ] + } } ] } diff --git a/docs/packages/studio-server.mdx b/docs/packages/studio-server.mdx index b8d4f9765e0..640e199f276 100644 --- a/docs/packages/studio-server.mdx +++ b/docs/packages/studio-server.mdx @@ -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; diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index f4d1f3956cb..7ca9187384b 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -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, diff --git a/packages/parsers/src/gsapWriter.acorn.test.ts b/packages/parsers/src/gsapWriter.acorn.test.ts index d32cf2f00e5..880bb17dd3e 100644 --- a/packages/parsers/src/gsapWriter.acorn.test.ts +++ b/packages/parsers/src/gsapWriter.acorn.test.ts @@ -13,6 +13,7 @@ import { copyAnimationsInScript, removeAnimationFromScript, removeKeyframeFromScript, + replaceTweenWithKeyframesInScript, updateAnimationInScript, updateKeyframeInScript, } from "./gsapWriterAcorn.js"; @@ -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); + }); +}); diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 8077725bb97..bcc347fd7fa 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -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)}`); } } @@ -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; + 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); + return ms.toString(); +} + // ── Add animation with keyframes ────────────────────────────────────────────── /** Insert a new keyframed `to()` call and return the new animation ID. */ diff --git a/packages/studio-server/src/routes/files.patchRace.test.ts b/packages/studio-server/src/routes/files.patchRace.test.ts index c03862028fa..43122c516b6 100644 --- a/packages/studio-server/src/routes/files.patchRace.test.ts +++ b/packages/studio-server/src/routes/files.patchRace.test.ts @@ -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, @@ -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", diff --git a/packages/studio-server/src/routes/files.replaceInPlace.test.ts b/packages/studio-server/src/routes/files.replaceInPlace.test.ts new file mode 100644 index 00000000000..26ee3ea8701 --- /dev/null +++ b/packages/studio-server/src/routes/files.replaceInPlace.test.ts @@ -0,0 +1,146 @@ +// @vitest-environment node +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Hono } from "hono"; +import { afterEach, describe, expect, it } from "vitest"; +import { registerFileRoutes } from "./files"; +import { stubAdapter } from "./stubAdapter.test-helpers"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +interface Anim { + id: string; + targetSelector: string; + resolvedStart?: number; + method: string; +} + +const notHold = (a: Anim) => a.method !== "set"; + +function app(script: string): Hono { + const dir = mkdtempSync(join(tmpdir(), "hf-replace-in-place-")); + dirs.push(dir); + const html = ` +
+ +`; + writeFileSync(join(dir, "comp.html"), html); + const hono = new Hono(); + registerFileRoutes(hono, stubAdapter(dir)); + return hono; +} + +async function animations(hono: Hono): Promise { + const res = await hono.request("http://localhost/projects/demo/gsap-animations/comp.html"); + return ((await res.json()) as { animations: Anim[] }).animations; +} + +// The script's calls, without the hold sets the server keeps before a first position keyframe. +const calls = (code: string) => + code.split("\n").filter((line) => line.startsWith("tl.") && !line.includes("hf-hold")); + +/** A move on `selector` at the playhead, sent the way the playhead writer sends it. */ +async function moveAtPlayhead(script: string, selector: string) { + const hono = app(script); + const before = await animations(hono); + const edited = before.find((a) => a.targetSelector === selector)!; + const res = await hono.request("http://localhost/projects/demo/gsap-mutations/comp.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "replace-with-keyframes", + animationId: edited.id, + targetSelector: selector, + position: edited.resolvedStart, + duration: 1, + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 40 } }, + { percentage: 100, properties: { x: 100 } }, + ], + easeEach: "none", + }), + }); + const { after: html } = (await res.json()) as { after: string }; + const after = await animations(hono); + const starts = (list: Anim[]) => list.map((a) => [a.targetSelector, a.resolvedStart]); + return { html, starts: starts(after.filter(notHold)), was: starts(before) }; +} + +describe("an edit at the playhead rewrites its tween where it stands", () => { + it.each([ + [ + "no position argument", + `tl.to("#a", { x: 100, duration: 1 });\ntl.to("#b", { y: 50, duration: 1 });`, + ], + [ + "'<' and '+='", + `tl.to("#a", { x: 100, duration: 1 }, 1);\ntl.to("#b", { y: 50, duration: 1 }, "<");\ntl.to("#c", { y: 9, duration: 1 }, "+=0.5");`, + ], + [ + "'>'", + `tl.to("#a", { x: 100, duration: 1 }, 0.5);\ntl.to("#b", { y: 50, duration: 1 }, ">");`, + ], + [ + "a label", + `tl.addLabel("intro", 2);\ntl.to("#a", { x: 100, duration: 1 }, "intro");\ntl.to("#b", { y: 50, duration: 1 }, "intro+=0.5");`, + ], + [ + "a from() entrance", + `tl.from("#a", { x: -60, duration: 1 });\ntl.to("#b", { y: 50, duration: 1 });`, + ], + ])("leaves every other tween's source and start alone after %s", async (_, script) => { + const { html, starts, was } = await moveAtPlayhead(script, "#a"); + + expect(starts).toEqual(was); + const others = calls(script).filter((line) => !line.includes('"#a"')); + expect(calls(html).filter((line) => !line.includes('"#a"'))).toEqual(others); + expect(calls(html).findIndex((line) => line.includes('"#a"'))).toBe( + calls(script).findIndex((line) => line.includes('"#a"')), + ); + }); + + it("keeps a chained call's next link in place", async () => { + const script = `tl.to("#a", { x: 100, duration: 1 }).to("#b", { y: 50, duration: 1 });`; + const { html, starts, was } = await moveAtPlayhead(script, "#a"); + + expect(starts).toEqual(was); + expect(calls(html)).toHaveLength(1); + expect(calls(html)[0]).toMatch( + /^tl\.to\("#a", \{ keyframes: .*\}\)\.to\("#b", \{ y: 50, duration: 1 \}\);$/, + ); + }); + + it.each([ + [ + "a relative one", + `tl.to("#b", { y: 50, duration: 1 }, 0);\ntl.to("#a", { x: 100, duration: 1 }, "<");`, + `"<");`, + ], + [ + "a label", + `tl.addLabel("intro", 2);\ntl.to("#a", { x: 100, duration: 1 }, "intro");`, + `"intro");`, + ], + [ + "the last tween's number", + `tl.to("#b", { y: 50, duration: 1 }, 0);\ntl.to("#a", { x: 100, duration: 1 }, 1);`, + `}, 1);`, + ], + ])("keeps the edited tween's own position argument when it is %s", async (_, script, tail) => { + const { html, starts, was } = await moveAtPlayhead(script, "#a"); + + expect(starts).toEqual(was); + const edited = calls(html).find((line) => line.includes('"#a"'))!; + expect(edited.endsWith(tail)).toBe(true); + expect(calls(html).indexOf(edited)).toBe(calls(script).findIndex((l) => l.includes('"#a"'))); + }); +}); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 6dcafa22db1..a35f6f7c5c0 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -71,6 +71,7 @@ import { addMotionPathToScript, removeArcPathFromScript, addAnimationWithKeyframesToScript, + replaceTweenWithKeyframesInScript, splitAnimationsInScript, splitIntoPropertyGroupsFromScript, shiftPositionsInScript, @@ -1297,6 +1298,25 @@ export type GsapMutationRequest = type GsapMutationResult = string | { script: string; skippedSelectors: string[] }; +function replaceInPlaceUnlessSetOrUnknown( + scriptText: string, + body: Extract, +): string { + const edit = { ...body, easeEach: resolveReplacementEaseEach(scriptText, body) }; + const inPlace = replaceTweenWithKeyframesInScript(scriptText, body.animationId, edit); + if (inPlace !== null) return inPlace; + const script = removeAnimationFromScript(scriptText, body.animationId); + return addAnimationWithKeyframesToScript( + script, + body.targetSelector, + body.position, + body.duration, + body.keyframes, + body.ease, + edit.easeEach, + ).script; +} + function resolveReplacementEaseEach( scriptText: string, request: { animationId: string; easeEach?: string }, @@ -1777,17 +1797,7 @@ function executeGsapMutationAcorn( if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) { stripStudioEditsFromTarget(block.document, body.targetSelector); } - const script = removeAnimationFromScript(block.scriptText, body.animationId); - const added = addAnimationWithKeyframesToScript( - script, - body.targetSelector, - body.position, - body.duration, - body.keyframes, - body.ease, - resolveReplacementEaseEach(block.scriptText, body), - ); - return added.script; + return replaceInPlaceUnlessSetOrUnknown(block.scriptText, body); } case "split-animations": { if ( @@ -2149,17 +2159,7 @@ async function executeGsapMutationRecast( if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) { stripStudioEditsFromTarget(block.document, body.targetSelector); } - const script = removeAnimationFromScript(block.scriptText, body.animationId); - const added = addAnimationWithKeyframesToScript( - script, - body.targetSelector, - body.position, - body.duration, - body.keyframes, - body.ease, - resolveReplacementEaseEach(block.scriptText, body), - ); - return added.script; + return replaceInPlaceUnlessSetOrUnknown(block.scriptText, body); } case "split-animations": { if ( diff --git a/packages/studio-server/src/routes/freezeFrame.test.ts b/packages/studio-server/src/routes/freezeFrame.test.ts index a123ebbb546..33de1ff550b 100644 --- a/packages/studio-server/src/routes/freezeFrame.test.ts +++ b/packages/studio-server/src/routes/freezeFrame.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { registerFreezeFrameRoutes, type FrameExtractor } from "./freezeFrame"; import { fileContentVersion } from "../helpers/fileVersion"; -import type { StudioApiAdapter } from "../types"; +import { stubAdapter } from "./stubAdapter.test-helpers"; const tempDirs: string[] = []; afterEach(() => { @@ -25,17 +25,8 @@ function setup( tempDirs.push(dir); mkdirSync(dirname(join(dir, file.path)), { recursive: true }); writeFileSync(join(dir, file.path), file.html); - const adapter: StudioApiAdapter = { - listProjects: () => [], - resolveProject: async (id: string) => ({ id, dir }), - bundle: async () => null, - lint: async () => ({ findings: [] }), - runtimeUrl: "/api/runtime.js", - rendersDir: () => "/tmp/renders", - startRender: () => ({ id: "j", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }), - }; const app = new Hono(); - registerFreezeFrameRoutes(app, adapter, extract, stillToken); + registerFreezeFrameRoutes(app, stubAdapter(dir), extract, stillToken); const post = (body: unknown) => app.request("http://localhost/projects/demo/file-mutations/freeze-frame", { method: "POST", diff --git a/packages/studio-server/src/routes/peaks.test.ts b/packages/studio-server/src/routes/peaks.test.ts index 748a924f3b6..b4986fd72fd 100644 --- a/packages/studio-server/src/routes/peaks.test.ts +++ b/packages/studio-server/src/routes/peaks.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { registerPeakRoutes } from "./peaks"; -import type { StudioApiAdapter } from "../types"; +import { stubAdapter } from "./stubAdapter.test-helpers"; const dirs: string[] = []; afterEach(() => { @@ -15,17 +15,8 @@ function setup(decode: (path: string) => Promise) { const dir = mkdtempSync(join(tmpdir(), "hf-peaks-route-")); dirs.push(dir); writeFileSync(join(dir, "talk.mp4"), "video"); - const adapter: StudioApiAdapter = { - listProjects: () => [], - resolveProject: async (id: string) => ({ id, dir }), - bundle: async () => null, - lint: async () => ({ findings: [] }), - runtimeUrl: "/api/runtime.js", - rendersDir: () => "/tmp/renders", - startRender: () => ({ id: "j", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }), - }; const app = new Hono(); - registerPeakRoutes(app, adapter, decode); + registerPeakRoutes(app, stubAdapter(dir), decode); return app; } diff --git a/packages/studio-server/src/routes/stubAdapter.test-helpers.ts b/packages/studio-server/src/routes/stubAdapter.test-helpers.ts new file mode 100644 index 00000000000..b5e9004480d --- /dev/null +++ b/packages/studio-server/src/routes/stubAdapter.test-helpers.ts @@ -0,0 +1,14 @@ +import type { StudioApiAdapter } from "../types"; + +/** An adapter that resolves every project id to `dir` and stubs the rest. */ +export function stubAdapter(dir: string): StudioApiAdapter { + return { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir }), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => "/tmp/renders", + startRender: () => ({ id: "j", status: "rendering", progress: 0, outputPath: "/tmp/o.mp4" }), + }; +} diff --git a/packages/studio/src/captions/hooks/useCaptionSync.ts b/packages/studio/src/captions/hooks/useCaptionSync.ts index 0523ab4e8f3..1c9c44b58e3 100644 --- a/packages/studio/src/captions/hooks/useCaptionSync.ts +++ b/packages/studio/src/captions/hooks/useCaptionSync.ts @@ -5,6 +5,7 @@ import { useMountEffect } from "../../hooks/useMountEffect"; import { trackEvent } from "../../telemetry/client"; import type { CaptionStyle } from "../types"; import { studioWriteHeaders } from "../../utils/studioFileVersion"; +import { studioApiFetch } from "../../utils/studioApiFetch"; interface CaptionOverrideEntry { wordId?: string; @@ -93,11 +94,14 @@ export function useCaptionSync(projectId: string | null) { const seqAtSave = editSeqRef.current; const overrides = buildOverrides(state.model); - fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), { - method: "PUT", - headers: { "Content-Type": "text/plain", ...studioWriteHeaders() }, - body: JSON.stringify(overrides, null, 2), - }) + studioApiFetch( + buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), + { + method: "PUT", + headers: { "Content-Type": "text/plain", ...studioWriteHeaders() }, + body: JSON.stringify(overrides, null, 2), + }, + ) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); // A newer edit may have re-armed the debounce while this PUT was in @@ -171,7 +175,7 @@ export function useCaptionSync(projectId: string | null) { let data: { content?: string }; try { - const res = await fetch( + const res = await studioApiFetch( buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), ); if (!res.ok) return; // no overrides file yet — normal diff --git a/packages/studio/src/components/ProjectUnreachableBanner.tsx b/packages/studio/src/components/ProjectUnreachableBanner.tsx index b51be2e2e07..dd7eaeb21af 100644 --- a/packages/studio/src/components/ProjectUnreachableBanner.tsx +++ b/packages/studio/src/components/ProjectUnreachableBanner.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { studioApiFetch } from "../utils/studioApiFetch"; interface ServedProject { id: string; @@ -24,8 +25,7 @@ interface ProjectUnreachableBannerProps { * * The wording is deliberately narrower than "this project is gone". We can see * which project this server serves; we cannot see why it does not serve this - * one, and nothing here is lost. See - * `docs/hyperframes/plans/sdk/2026-09-20-stale-project-id-remaining-doors-plan.md` §C. + * one, and nothing here is lost. */ export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBannerProps) { const [served, setServed] = useState(null); @@ -35,7 +35,7 @@ export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBanner // A list we could not read is not evidence of anything, so a failed fetch // leaves `served` empty and the banner falls back to the vague wording // rather than claiming this Studio serves nothing. - fetch("/api/projects") + studioApiFetch("/api/projects") .then((res) => (res.ok ? res.json() : { projects: [] })) .then((data: { projects?: ServedProject[] }) => { if (!cancelled) setServed(data.projects ?? []); diff --git a/packages/studio/src/components/editor/CropPresetBar.tsx b/packages/studio/src/components/editor/CropPresetBar.tsx index e830d40ab28..316012a3297 100644 --- a/packages/studio/src/components/editor/CropPresetBar.tsx +++ b/packages/studio/src/components/editor/CropPresetBar.tsx @@ -5,6 +5,7 @@ import { CROP_ASPECT_PRESETS, centredCropInsets } from "./cropPresets"; interface CropPresetBarProps { left: number; top: number; + drawnFrom: { left: number; top: number }; elementWidth: number; elementHeight: number; /** Centred insets for a ratio, or `null` to remove the crop. */ @@ -19,6 +20,7 @@ const CHIP = export function CropPresetBar({ left, top, + drawnFrom, elementWidth, elementHeight, onApply, @@ -38,7 +40,11 @@ export function CropPresetBar({ aria-label="Crop presets" data-dom-edit-crop-bar="true" className="pointer-events-auto absolute z-20 flex gap-1 rounded-md border border-white/15 bg-neutral-900/95 p-1 shadow-lg" - style={{ left, top: Math.max(4, top - 34), transform: "translateX(-50%)" }} + style={{ + left: `calc(${left}px + var(--hf-sel-x, ${drawnFrom.left}px) - ${drawnFrom.left}px)`, + top: `max(4px, calc(${top - 34}px + var(--hf-sel-y, ${drawnFrom.top}px) - ${drawnFrom.top}px))`, + transform: "translateX(-50%)", + }} onPointerDown={(event) => event.stopPropagation()} >