From bea32b8aae5bd9008c5fbe453a17a8a4588365ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 9 Aug 2026 16:10:02 -0700 Subject: [PATCH] fix(studio): stop a Studio edit from reloading the preview (#3137) * fix(studio): stop a Studio edit from reloading the preview as if it were external Every mutation route wrote the file without leaving a write receipt, so the watcher's broadcast of Studio's own edit arrived with no identity on it. The external-change coordinator could not tell that echo from an agent or an editor writing the file behind Studio's back, so it took the safe branch and did a full iframe reload. That reload hides the stage for the length of the reload, which is what the flash after a text edit was. Every mutation write now goes through one helper that records the receipt, and the client claims the write before the request goes out rather than after it: the server writes and the watcher fires while the request is still in flight, so a token marked from the response can arrive after the echo it was meant to match. Reproduced in the browser before and after, with the reload path traced end to end. Before, a patch-element write logged `token: null` then a reload from the coordinator; after, the same write logs the token and `suppressed: own write token`, with no reload. Adds `hf-reload-debug` (localStorage, off by default) alongside the existing `hf-resize-debug`: it records each file-change decision and its reason, plus the stack of whoever asked for a full reload. * fix(studio): claim the timeline and caption writes too, not just the DOM ones The receipt only helps when the client marked the token it sent, and the GSAP mutation writers never sent one. A drag commits through gsap-mutations, so the server minted a token the client had never seen, the change came back looking like someone else's, and the preview did the full reload the receipt was meant to prevent. Same one-line claim on both GSAP mutation writers, the timing sync's mutation call, and the caption auto-save PUT. The rollback call stays deliberately unclaimed and says why: it runs because a mutation did not converge, so the preview is on bytes nobody can vouch for and the reload is the point. Verified live: a drag-shaped update-properties on the timeline now logs `suppressed: own write token` with no reload, where it logged a coordinator reload before. * refactor(studio): keep timelineTimingSync under the size cap Claiming the timeline writes pushed this file one line past the 600-line gate. Same change as the branch made later, landed with the commit that caused it. * fix(studio): cover remaining write receipt paths * fix(studio): preserve batch write receipts * fix(cli): emit every file in a watcher burst --- packages/cli/src/server/fileWatcher.test.ts | 38 ++++- packages/cli/src/server/fileWatcher.ts | 12 +- packages/cli/src/server/studioServer.ts | 10 +- .../src/helpers/fileVersion.test.ts | 22 ++- .../studio-server/src/helpers/fileVersion.ts | 10 +- .../studio-server/src/routes/files.test.ts | 144 ++++++++++++++++-- packages/studio-server/src/routes/files.ts | 132 ++++++++++++---- .../src/captions/hooks/useCaptionSync.ts | 3 +- .../src/hooks/gsapScriptCommitHelpers.ts | 3 +- .../studio/src/hooks/timelineTimingSync.ts | 5 +- .../studio/src/hooks/useDomEditCommits.ts | 3 +- .../src/hooks/useDomEditCommitsHelpers.ts | 3 +- .../src/hooks/useElementLifecycleOps.ts | 3 +- .../hooks/useExternalFileChangeCoordinator.ts | 19 ++- packages/studio/src/hooks/useFileManager.ts | 10 +- packages/studio/src/hooks/useGroupCommits.ts | 3 +- .../studio/src/hooks/useGsapScriptCommits.ts | 5 +- .../studio/src/hooks/useTimelineEditing.ts | 3 +- .../src/player/hooks/useTimelinePlayer.ts | 2 + .../src/utils/razorSplitTransaction.test.ts | 18 ++- .../studio/src/utils/razorSplitTransaction.ts | 2 + packages/studio/src/utils/reloadDebug.ts | 30 ++++ .../studio/src/utils/studioFileVersion.ts | 17 ++- .../src/utils/timelineCompositionInsert.ts | 3 +- 24 files changed, 419 insertions(+), 81 deletions(-) create mode 100644 packages/studio/src/utils/reloadDebug.ts diff --git a/packages/cli/src/server/fileWatcher.test.ts b/packages/cli/src/server/fileWatcher.test.ts index 686e14ed5..57b0e8a65 100644 --- a/packages/cli/src/server/fileWatcher.test.ts +++ b/packages/cli/src/server/fileWatcher.test.ts @@ -1,12 +1,21 @@ import { EventEmitter } from "node:events"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type WatchCallback = (eventType: string, filename: string | Buffer | null) => void; const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void }; mockWatcher.close = vi.fn(); -vi.mock("node:fs", () => ({ - watch: vi.fn(() => mockWatcher), -})); +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + watch: vi.fn((_path: string, _options: unknown, onChange: WatchCallback) => { + mockWatcher.on("change", onChange); + return mockWatcher; + }), + }; +}); const { shouldWatchProjectFile, createProjectWatcher } = await import("./fileWatcher.js"); @@ -30,6 +39,27 @@ describe("shouldWatchProjectFile", () => { }); describe("createProjectWatcher", () => { + beforeEach(() => { + mockWatcher.removeAllListeners(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("notifies once for every file changed in one debounce burst", () => { + vi.useFakeTimers(); + const projectWatcher = createProjectWatcher("/fake/project/dir"); + const listener = vi.fn(); + projectWatcher.addListener(listener); + + mockWatcher.emit("change", "change", "scene-a.html"); + mockWatcher.emit("change", "change", "scene-b.html"); + mockWatcher.emit("change", "change", "scene-a.html"); + vi.advanceTimersByTime(300); + + expect(listener.mock.calls).toEqual([["scene-a.html"], ["scene-b.html"]]); + projectWatcher.close(); + }); + // Regression: fs.watch can fail asynchronously (e.g. EMFILE from exhausted // OS watch handles) via an 'error' event, not a thrown exception. An // EventEmitter 'error' with no listener crashes the whole process — this diff --git a/packages/cli/src/server/fileWatcher.ts b/packages/cli/src/server/fileWatcher.ts index 3f7dec580..d5e2f67e5 100644 --- a/packages/cli/src/server/fileWatcher.ts +++ b/packages/cli/src/server/fileWatcher.ts @@ -34,6 +34,7 @@ export function shouldWatchProjectFile(filename: string): boolean { export function createProjectWatcher(projectDir: string): ProjectWatcher { const listeners = new Set(); + const pendingPaths = new Set(); let debounceTimer: ReturnType | null = null; let watcher: FSWatcher | null = null; @@ -43,10 +44,16 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher { const relativePath = filename.toString(); if (!shouldWatchProjectFile(relativePath)) return; + pendingPaths.add(relativePath); if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - for (const fn of listeners) { - fn(relativePath); + const changedPaths = [...pendingPaths]; + pendingPaths.clear(); + debounceTimer = null; + for (const changedPath of changedPaths) { + for (const fn of listeners) { + fn(changedPath); + } } }, DEBOUNCE_MS); }); @@ -72,6 +79,7 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher { }, close() { if (debounceTimer) clearTimeout(debounceTimer); + pendingPaths.clear(); watcher?.close(); listeners.clear(); }, diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 613a3ddb7..9d9e83e05 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -30,6 +30,7 @@ import { createProjectSignature, createBackgroundRemovalJob, consumeFileWriteReceipt, + fileContentVersion, getMimeType, type PreviewApiAdapter, thumbnailDeviceScaleFactor, @@ -752,7 +753,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { app.get("/api/events", (c) => { return streamSSE(c, async (stream) => { const listener = (path: string) => { - const receipt = consumeFileWriteReceipt(resolve(projectDir, path)); + const absPath = resolve(projectDir, path); + let version: string | null = null; + try { + version = fileContentVersion(readFileSync(absPath, "utf-8")); + } catch { + // A deletion has no current bytes to match against an API write receipt. + } + const receipt = version ? consumeFileWriteReceipt(absPath, version) : null; stream .writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) }) .catch(() => {}); diff --git a/packages/studio-server/src/helpers/fileVersion.test.ts b/packages/studio-server/src/helpers/fileVersion.test.ts index 3ecd0fbdb..2ed451147 100644 --- a/packages/studio-server/src/helpers/fileVersion.test.ts +++ b/packages/studio-server/src/helpers/fileVersion.test.ts @@ -23,7 +23,25 @@ describe("file versions and write receipts", () => { }; recordFileWriteReceipt("/project/index.html", receipt); - expect(consumeFileWriteReceipt("/project/index.html")).toEqual(receipt); - expect(consumeFileWriteReceipt("/project/index.html")).toBeNull(); + expect(consumeFileWriteReceipt("/project/index.html", receipt.version)).toEqual(receipt); + expect(consumeFileWriteReceipt("/project/index.html", receipt.version)).toBeNull(); + }); + + it("matches the final debounced watcher version instead of receipt insertion order", () => { + const first = { + path: "index.html", + version: fileContentVersion("first"), + writeToken: "write-1", + }; + const last = { + path: "index.html", + version: fileContentVersion("last"), + writeToken: "write-2", + }; + recordFileWriteReceipt("/project/index.html", first); + recordFileWriteReceipt("/project/index.html", last); + + expect(consumeFileWriteReceipt("/project/index.html", last.version)).toEqual(last); + expect(consumeFileWriteReceipt("/project/index.html", first.version)).toEqual(first); }); }); diff --git a/packages/studio-server/src/helpers/fileVersion.ts b/packages/studio-server/src/helpers/fileVersion.ts index ab95e866b..dd2345abc 100644 --- a/packages/studio-server/src/helpers/fileVersion.ts +++ b/packages/studio-server/src/helpers/fileVersion.ts @@ -32,13 +32,17 @@ export function recordFileWriteReceipt(absPath: string, receipt: FileWriteReceip receipts.set(absPath, current); } -/** Attach one API write's identity to the corresponding filesystem-watch echo. */ -export function consumeFileWriteReceipt(absPath: string): FileWriteReceipt | null { +/** Attach one API write's identity to the watcher echo for its exact bytes. */ +export function consumeFileWriteReceipt( + absPath: string, + expectedVersion: string, +): FileWriteReceipt | null { const now = Date.now(); const current = (receipts.get(absPath) ?? []).filter( (entry) => now - entry.recordedAt < RECEIPT_TTL_MS, ); - const receipt = current.shift() ?? null; + const receiptIndex = current.findIndex((entry) => entry.version === expectedVersion); + const receipt = receiptIndex === -1 ? null : (current.splice(receiptIndex, 1)[0] ?? null); if (current.length > 0) receipts.set(absPath, current); else receipts.delete(absPath); if (!receipt) return null; diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index ffd9f61a8..e6413b4ad 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -65,10 +65,18 @@ function createAdapter(projectDir: string): StudioApiAdapter { }; } -function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Promise { +function postElementPatchBatch( + app: Hono, + file: string, + patches: unknown[], + writeToken?: string, +): Promise { return app.request(`http://localhost/projects/demo/file-mutations/patch-elements-batch/${file}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(writeToken ? { "X-Hyperframes-Write-Token": writeToken } : {}), + }, body: JSON.stringify({ patches }), }); } @@ -76,10 +84,14 @@ function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Pro function postElementPatchBatches( app: Hono, batches: Array<{ sourceFile: string; patches: unknown[] }>, + writeToken?: string, ): Promise { return app.request("http://localhost/projects/demo/file-mutations/patch-element-batches", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(writeToken ? { "X-Hyperframes-Write-Token": writeToken } : {}), + }, body: JSON.stringify({ batches }), }); } @@ -119,7 +131,10 @@ describe("registerFileRoutes", () => { const insert = (expectedVersion: string) => app.request("http://localhost/projects/demo/file-mutations/insert-composition/index.html", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-Hyperframes-Write-Token": "studio-insert-1", + }, body: JSON.stringify({ sourcePath: "child.html", start: 4, track: 0, expectedVersion }), }); @@ -131,6 +146,11 @@ describe("registerFileRoutes", () => { expect(result.after).toContain('data-duration="7"'); expect(result.after).toContain(`id="${result.hostId}"`); expect(result.version).toBe(fileContentVersion(result.after)); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), result.version)).toEqual({ + path: "index.html", + version: result.version, + writeToken: "studio-insert-1", + }); const committed = result.after; const stale = await insert(fileContentVersion(before)); @@ -366,7 +386,7 @@ describe("registerFileRoutes", () => { expect(payload.version).toBe(fileContentVersion("after")); expect(payload.writeToken).toBe("studio-write-1"); expect(response.headers.get("etag")).toBe(payload.version); - expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({ + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), payload.version!)).toEqual({ path: "index.html", version: payload.version, writeToken: "studio-write-1", @@ -425,6 +445,39 @@ describe("registerFileRoutes", () => { expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After"); }); + // Without the receipt the client cannot recognise its own edit in the watcher + // broadcast, so it treats it as someone else's write and does a full preview + // reload — a visible blank on the stage right after the user typed. + it("leaves a write receipt so the patch's own file-change echo is identifiable", async () => { + const projectDir = createProjectDir(); + writeFileSync(projectDir + "/index.html", '
Before
'); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/patch-element/index.html", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Hyperframes-Write-Token": "studio-patch-1", + }, + body: JSON.stringify({ + target: { id: "title" }, + operations: [{ type: "text-content", property: "textContent", value: "After" }], + }), + }, + ); + + expect(response.status).toBe(200); + const version = fileContentVersion(readFileSync(join(projectDir, "index.html"), "utf-8")); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), version)).toEqual({ + path: "index.html", + version, + writeToken: "studio-patch-1", + }); + }); + it("applies an ordered element patch batch with one file write", async () => { const projectDir = createProjectDir(); const original = @@ -433,16 +486,21 @@ describe("registerFileRoutes", () => { const app = new Hono(); registerFileRoutes(app, createAdapter(projectDir)); - const response = await postElementPatchBatch(app, "index.html", [ - { - target: { id: "back" }, - operations: [{ type: "inline-style", property: "z-index", value: "2" }], - }, - { - target: { id: "front" }, - operations: [{ type: "inline-style", property: "z-index", value: "1" }], - }, - ]); + const response = await postElementPatchBatch( + app, + "index.html", + [ + { + target: { id: "back" }, + operations: [{ type: "inline-style", property: "z-index", value: "2" }], + }, + { + target: { id: "front" }, + operations: [{ type: "inline-style", property: "z-index", value: "1" }], + }, + ], + "studio-layer-order-1", + ); expect(response.status).toBe(200); const payload = (await response.json()) as { changed?: boolean; @@ -458,6 +516,12 @@ describe("registerFileRoutes", () => { expect(payload.content).toContain('id="back" style="z-index: 2"'); expect(payload.content).toContain('id="front" style="z-index: 1"'); expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(original); + const version = fileContentVersion(payload.content!); + expect(consumeFileWriteReceipt(join(projectDir, "index.html"), version)).toEqual({ + path: "index.html", + version, + writeToken: "studio-layer-order-1", + }); expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(1); }); @@ -519,6 +583,52 @@ describe("registerFileRoutes", () => { expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false); }); + it("leaves one exact write receipt for every file in a durable element patch batch", async () => { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "index.html"), '
Before
'); + writeFileSync(join(projectDir, "scene.html"), '
Before
'); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await postElementPatchBatches( + app, + [ + { + sourceFile: "index.html", + patches: [ + { + target: { id: "index" }, + operations: [{ type: "text-content", property: "textContent", value: "After" }], + }, + ], + }, + { + sourceFile: "scene.html", + patches: [ + { + target: { id: "scene" }, + operations: [{ type: "text-content", property: "textContent", value: "After" }], + }, + ], + }, + ], + "studio-group-drag-1", + ); + const payload = (await response.json()) as { + files: Array<{ sourceFile: string; after: string }>; + }; + + expect(response.status).toBe(200); + for (const file of payload.files) { + const version = fileContentVersion(file.after); + expect(consumeFileWriteReceipt(join(projectDir, file.sourceFile), version)).toEqual({ + path: file.sourceFile, + version, + writeToken: "studio-group-drag-1", + }); + } + }); + it("refuses every file when one batch contains an unmatched target", async () => { const projectDir = createProjectDir(); const indexOriginal = '
Present
'; @@ -732,7 +842,9 @@ describe("registerFileRoutes", () => { expect(payload.files[0].after).toContain('id="a-split"'); expect(payload.files[0].after).toContain('id="b-split"'); expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(payload.files[0].after); - expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({ + expect( + consumeFileWriteReceipt(join(projectDir, "index.html"), payload.files[0].version), + ).toEqual({ path: "index.html", version: payload.files[0].version, writeToken: "cut-test", diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 3ec64bbff..dbe2f9b91 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -112,6 +112,7 @@ interface RouteContext { param: (name: string) => string; path: string; query: (name: string) => string | undefined; + header: (name: string) => string | undefined; }; header: (name: string, value: string) => void; json: (data: unknown, status?: number) => Response; @@ -399,6 +400,69 @@ export function commitElementPatchBatches( return { durable: true, files }; } +function commitElementPatchBatchesWithReceipts( + c: RouteContext, + projectDir: string, + batches: ElementPatchBatchRequest[], +): ReturnType { + const result = commitElementPatchBatches(projectDir, batches); + if ("error" in result || !result.durable) return result; + + for (const file of result.files) { + if (!file.changed) continue; + const absPath = resolveWithinProject(projectDir, file.sourceFile); + if (!absPath) throw new Error(`Committed element patch escaped project: ${file.sourceFile}`); + recordMutationReceipt(c, file.sourceFile, absPath, file.after); + } + return result; +} + +/** + * Record the receipt that claims a mutation result. + * + * The file watcher broadcasts every write, including the ones Studio itself just + * asked for. The receipt is what lets the client tell its own echo from an agent + * or an editor writing the file behind its back: without one, the client treats + * its own edit as an external change and does a full preview reload, which blanks + * the stage for a few hundred milliseconds right after the user typed. Every + * mutation route records through here so no route can forget. + */ +function recordMutationReceipt( + c: RouteContext, + filePath: string, + absPath: string, + html: string, +): { version: string; writeToken: string } { + const version = fileContentVersion(html); + const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token")); + recordFileWriteReceipt(absPath, { path: filePath, version, writeToken }); + return { version, writeToken }; +} + +function writeFileWithReceipt( + c: RouteContext, + filePath: string, + absPath: string, + html: string, +): { version: string; writeToken: string } { + writeFileSync(absPath, html, "utf-8"); + // The synchronous write cannot yield before its receipt is recorded; keep this block await-free. + return recordMutationReceipt(c, filePath, absPath, html); +} + +function writeMutationResult( + c: RouteContext, + projectDir: string, + filePath: string, + absPath: string, + html: string, +): { backupPath: string | null; version: string } { + const backup = snapshotBeforeWrite(projectDir, absPath); + if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`); + const { version } = writeFileWithReceipt(c, filePath, absPath, html); + return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version }; +} + /** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */ function writeIfChanged( c: RouteContext, @@ -411,15 +475,13 @@ function writeIfChanged( if (next === original) { return c.json({ ok: true, changed: false, content: original, path: filePath }); } - const backup = snapshotBeforeWrite(projectDir, absPath); - if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`); - writeFileSync(absPath, next, "utf-8"); + const { backupPath } = writeMutationResult(c, projectDir, filePath, absPath, next); return c.json({ ok: true, changed: true, content: next, path: filePath, - backupPath: backupPathForResponse(projectDir, backup.backupPath), + backupPath, }); } @@ -1238,10 +1300,13 @@ async function applyGsapMutations( return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409); } if (changed) { - const backup = snapshotBeforeWrite(res.project.dir, res.absPath); - if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`); - backupPath = backupPathForResponse(res.project.dir, backup.backupPath); - writeFileSync(res.absPath, newHtml, "utf-8"); + backupPath = writeMutationResult( + c, + res.project.dir, + res.filePath, + res.absPath, + newHtml, + ).backupPath; } const responsePayload: Record = { @@ -2388,10 +2453,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500); - writeFileSync(ctx.absPath, insertion.html, "utf-8"); - const version = fileContentVersion(insertion.html); - const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token")); - recordFileWriteReceipt(ctx.absPath, { path: ctx.filePath, version, writeToken }); + const { version, writeToken } = writeFileWithReceipt( + c, + ctx.filePath, + ctx.absPath, + insertion.html, + ); c.header("ETag", version); return c.json({ ok: true, @@ -2623,10 +2690,13 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { version, }); } - const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); - if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); - writeFileSync(ctx.absPath, result.html, "utf-8"); - const version = fileContentVersion(result.html); + const { version, backupPath } = writeMutationResult( + c, + ctx.project.dir, + ctx.filePath, + ctx.absPath, + result.html, + ); c.header("ETag", version); return c.json({ ok: true, @@ -2635,7 +2705,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { newId: result.newId, path: ctx.filePath, version, - backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), + backupPath, }); }); @@ -2676,16 +2746,20 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { path: ctx.filePath, }); } - const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); - if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); - writeFileSync(ctx.absPath, patched, "utf-8"); + const { backupPath } = writeMutationResult( + c, + ctx.project.dir, + ctx.filePath, + ctx.absPath, + patched, + ); return c.json({ ok: true, changed: true, matched, content: patched, path: ctx.filePath, - backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), + backupPath, }); }); @@ -2707,7 +2781,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const unsafeFields = findUnsafeElementPatchBatchValues(body.batches); if (unsafeFields.length > 0) return rejectUnsafeMutationValues(c, unsafeFields); - const result = commitElementPatchBatches(project.dir, body.batches); + const result = commitElementPatchBatchesWithReceipts(c, project.dir, body.batches); if ("error" in result) { return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile); } @@ -2735,7 +2809,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { return rejectUnsafeMutationValues(c, unsafeFields); } - const result = commitElementPatchBatches(ctx.project.dir, [batch]); + const result = commitElementPatchBatchesWithReceipts(c, ctx.project.dir, [batch]); if ("error" in result) { return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile); } @@ -2807,16 +2881,20 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { result.error === "grouped elements must share a single parent" ? 422 : 400, ); } - const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath); - if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`); - writeFileSync(ctx.absPath, result.html, "utf-8"); + const { backupPath } = writeMutationResult( + c, + ctx.project.dir, + ctx.filePath, + ctx.absPath, + result.html, + ); return c.json({ ok: true, changed: true, groupId: result.groupId, content: result.html, path: ctx.filePath, - backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath), + backupPath, }); }); diff --git a/packages/studio/src/captions/hooks/useCaptionSync.ts b/packages/studio/src/captions/hooks/useCaptionSync.ts index 5fdbf8013..1b2037a0f 100644 --- a/packages/studio/src/captions/hooks/useCaptionSync.ts +++ b/packages/studio/src/captions/hooks/useCaptionSync.ts @@ -3,6 +3,7 @@ import { useCaptionStore } from "../store"; import { useMountEffect } from "../../hooks/useMountEffect"; import { trackEvent } from "../../telemetry/client"; import type { CaptionStyle } from "../types"; +import { studioWriteHeaders } from "../../utils/studioFileVersion"; interface CaptionOverrideEntry { wordId?: string; @@ -77,7 +78,7 @@ export function useCaptionSync(projectId: string | null) { fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, { method: "PUT", - headers: { "Content-Type": "text/plain" }, + headers: { "Content-Type": "text/plain", ...studioWriteHeaders() }, body: JSON.stringify(overrides, null, 2), }).catch((error: unknown) => { // Caption auto-save is a data-loss path; surface failures via telemetry diff --git a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts index 8c6e45a02..4bca7d0e2 100644 --- a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts +++ b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts @@ -3,6 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes"; export { PROPERTY_DEFAULTS } from "./gsapShared"; import { idSelector, matchesExactlyOne } from "./gsapShared"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; /** * The selector to author a NEW tween against, minting an id on the element when @@ -119,7 +120,7 @@ export async function assignGsapTargetAutoIdIfNeeded({ `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(patchBody), }, ); diff --git a/packages/studio/src/hooks/timelineTimingSync.ts b/packages/studio/src/hooks/timelineTimingSync.ts index 919d259a9..aac58e926 100644 --- a/packages/studio/src/hooks/timelineTimingSync.ts +++ b/packages/studio/src/hooks/timelineTimingSync.ts @@ -7,6 +7,7 @@ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftR import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers"; import type { RecordEditInput } from "../utils/studioFileHistory"; import { patchDocumentRootDuration } from "./timelineEditingGsap"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; class GsapPreviewConvergenceError extends Error {} class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {} @@ -58,6 +59,8 @@ async function rollbackOwnedMutation( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`, { method: "POST", + // Deliberately unclaimed: a rollback runs because a mutation did not + // converge, so let the restored file reload the preview. headers: { "Content-Type": "application/json" }, body: JSON.stringify({ expected, restore }), }, @@ -156,7 +159,7 @@ async function postGsapMutation( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(mutation), }, ); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 7a02be735..deb5ae9e3 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -33,6 +33,7 @@ import { readErrorResponseBody, } from "./useDomEditCommitsHelpers"; import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; interface RecordEditInput { label: string; kind: EditHistoryKind; @@ -201,7 +202,7 @@ export function useDomEditCommits({ `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(patchBody), }, ); diff --git a/packages/studio/src/hooks/useDomEditCommitsHelpers.ts b/packages/studio/src/hooks/useDomEditCommitsHelpers.ts index dd82aff58..a440c6e4e 100644 --- a/packages/studio/src/hooks/useDomEditCommitsHelpers.ts +++ b/packages/studio/src/hooks/useDomEditCommitsHelpers.ts @@ -1,6 +1,7 @@ import { StudioSaveHttpError, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics"; import type { DomEditPatchBatch } from "./domEditCommitTypes"; import { formatFieldsSuffix } from "./gsapScriptCommitHelpers"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; export function formatUnsafeFieldList(fields: Array<{ path: string }>): string { return fields.map((field) => field.path).join(", "); @@ -99,7 +100,7 @@ export async function patchElementBatches(projectId: string, batches: DomEditPat `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body, }, ); diff --git a/packages/studio/src/hooks/useElementLifecycleOps.ts b/packages/studio/src/hooks/useElementLifecycleOps.ts index 4e17c5af3..3df6bf402 100644 --- a/packages/studio/src/hooks/useElementLifecycleOps.ts +++ b/packages/studio/src/hooks/useElementLifecycleOps.ts @@ -21,6 +21,7 @@ import { } from "../components/editor/useLayerRevealOverride"; import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes"; import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams { /** Route delete through SDK when session resolves the hf-id. */ @@ -115,7 +116,7 @@ export function useElementLifecycleOps({ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ target: patchTarget }), }, ); diff --git a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts index 4d1684285..b1de697b0 100644 --- a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts +++ b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts @@ -4,6 +4,7 @@ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics"; import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage"; import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; import { consumeStudioWriteToken } from "../utils/studioFileVersion"; +import { logReload } from "../utils/reloadDebug"; type ExternalChangeDrainResult = | { status: "clean" } @@ -194,6 +195,7 @@ export function useExternalFileChangeCoordinator({ const reloadAcceptedGeneration = useCallback( (path: string) => { + logReload("reload", { path, by: "external-change coordinator" }); reloadPreview(); reloadSdkSession(path); }, @@ -221,11 +223,22 @@ export function useExternalFileChangeCoordinator({ pendingTimelinePaths.delete(path); const content = readFileChangeContent(payload); - if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return; - if (content != null && isSelfWriteEcho(path, content)) return; + const token = readFileChangeWriteToken(payload); + logReload("file-change", { path, token: token ?? null, hasContent: content != null }); + if (consumeStudioWriteToken(token)) { + logReload("suppressed", { path, why: "own write token" }); + return; + } + if (content != null && isSelfWriteEcho(path, content)) { + logReload("suppressed", { path, why: "own content echo" }); + return; + } const identity = eventIdentity(path, payload); - if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) return; + if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) { + logReload("suppressed", { path, why: "duplicate event" }); + return; + } lastEventIdentityRef.current = identity; const generation = ++generationRef.current; const result = await drainPendingChanges(); diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index 28eaeb88d..f2eb0201d 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -10,11 +10,7 @@ import { StudioFileConflictError, StudioSaveNetworkError, } from "../utils/studioSaveDiagnostics"; -import { - createStudioWriteToken, - markStudioWriteToken, - studioExpectedFileVersion, -} from "../utils/studioFileVersion"; +import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion"; import { useFileTree } from "./useFileTree"; import { useEditorSave } from "./useEditorSave"; @@ -124,8 +120,6 @@ export function useFileManager({ await retryStudioSave(async () => { // Each request gets its own receipt identity. If a committed request loses its response, // the retry can produce a second filesystem receipt that must be suppressed independently. - const writeToken = createStudioWriteToken(); - markStudioWriteToken(writeToken); let response: Response; try { response = await fetch( @@ -134,7 +128,7 @@ export function useFileManager({ method: "PUT", headers: { "Content-Type": "text/plain", - "X-Hyperframes-Write-Token": writeToken, + ...studioWriteHeaders(), ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }), }, body: content, diff --git a/packages/studio/src/hooks/useGroupCommits.ts b/packages/studio/src/hooks/useGroupCommits.ts index 6e838f1db..1ac24bd14 100644 --- a/packages/studio/src/hooks/useGroupCommits.ts +++ b/packages/studio/src/hooks/useGroupCommits.ts @@ -5,6 +5,7 @@ import { type DomEditCommitBaseParams, } from "../utils/studioFileHistory"; import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; interface UseGroupCommitsParams extends DomEditCommitBaseParams { /** Resync the SDK session after a server-side write (the wrapper/unwrap changes @@ -75,7 +76,7 @@ async function commitStructuralMutation( `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(body), }, ); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index b4b7d6f63..f1a21a3af 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -36,6 +36,7 @@ import { useGsapSaveFailureTelemetry, useSafeGsapCommitMutation, } from "./useSafeGsapCommitMutation"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; async function mutateGsapScript( projectId: string, @@ -46,7 +47,7 @@ async function mutateGsapScript( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify(mutation), }, ); @@ -65,7 +66,7 @@ async function mutateGsapScriptBatch( `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ mutations }), }, ); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index bf846f06f..10f0da7de 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -36,6 +36,7 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture"; import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover"; import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics"; +import { studioWriteHeaders } from "../utils/studioFileVersion"; type TimelineMoveUpdates = Pick & { stackingReorder?: TimelineStackingReorderIntent | null; @@ -412,7 +413,7 @@ export function useTimelineEditing({ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ target: patchTarget }), }, ); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 4ac8e6be0..b53efd3f3 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -4,6 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect"; import { usePlaybackKeyboard } from "./usePlaybackKeyboard"; import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks"; import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop"; +import { logReload } from "../../utils/reloadDebug"; export type { ClipManifestClip } from "../lib/playbackTypes"; export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter"; @@ -445,6 +446,7 @@ export function useTimelinePlayer() { const refreshPlayer = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; + logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack })); saveSeekPosition(); // Hide the iframe across the full reload so the user never sees the reloading // document's RAW DOM (every clip stacked and visible) in the window between the diff --git a/packages/studio/src/utils/razorSplitTransaction.test.ts b/packages/studio/src/utils/razorSplitTransaction.test.ts index e942dc188..567bbffe3 100644 --- a/packages/studio/src/utils/razorSplitTransaction.test.ts +++ b/packages/studio/src/utils/razorSplitTransaction.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { TimelineElement } from "../player"; import { buildAtomicCutIntents, runAtomicCutTransaction } from "./razorSplitTransaction"; +import { consumeStudioWriteToken, resetStudioWriteTokens } from "./studioFileVersion"; const element = (over: Partial = {}): TimelineElement => ({ id: "clip", @@ -14,7 +15,10 @@ const element = (over: Partial = {}): TimelineElement => ({ ...over, }); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + resetStudioWriteTokens(); + vi.unstubAllGlobals(); +}); describe("buildAtomicCutIntents", () => { it("deduplicates runtime aliases but keeps repeated authored hosts distinct", () => { @@ -45,12 +49,16 @@ describe("buildAtomicCutIntents", () => { }); function installCutServer(options: { status?: number } = {}) { - const requests: Array<{ url: string; body?: unknown }> = []; + const requests: Array<{ url: string; body?: unknown; headers?: HeadersInit }> = []; vi.stubGlobal( "fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); - requests.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + requests.push({ + url, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + headers: init?.headers, + }); if (url.includes("/files/")) { return new Response(JSON.stringify({ content: "before", version: '"v0"' }), { status: 200, @@ -109,6 +117,10 @@ describe("runAtomicCutTransaction", () => { "/api/projects/launch%2Fdemo/files/index.html", "/api/projects/launch%2Fdemo/file-mutations/split-batch", ]); + const splitRequest = requests.find((request) => request.url.includes("split-batch")); + const writeToken = new Headers(splitRequest?.headers).get("X-Hyperframes-Write-Token"); + expect(writeToken).toMatch(/^cut:/); + expect(consumeStudioWriteToken(writeToken)).toBe(true); expect(writeProjectFile).not.toHaveBeenCalled(); expect(recordEdit).toHaveBeenCalledWith({ label: "Split timeline clip", diff --git a/packages/studio/src/utils/razorSplitTransaction.ts b/packages/studio/src/utils/razorSplitTransaction.ts index b11e4e406..19ab58abc 100644 --- a/packages/studio/src/utils/razorSplitTransaction.ts +++ b/packages/studio/src/utils/razorSplitTransaction.ts @@ -3,6 +3,7 @@ import type { RecordEditInput } from "../hooks/timelineEditingHelpers"; import { buildPatchTarget } from "./timelineElementSplit"; import { serializeStudioFileMutations } from "./studioFileMutationCoordinator"; import { buildProjectApiPath } from "./projectRouting"; +import { markStudioWriteToken } from "./studioFileVersion"; type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise; @@ -117,6 +118,7 @@ async function requestAtomicCut( }); } const transactionToken = `cut:${crypto.randomUUID()}`; + markStudioWriteToken(transactionToken); const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), { method: "POST", headers: { diff --git a/packages/studio/src/utils/reloadDebug.ts b/packages/studio/src/utils/reloadDebug.ts new file mode 100644 index 000000000..b884aa9f3 --- /dev/null +++ b/packages/studio/src/utils/reloadDebug.ts @@ -0,0 +1,30 @@ +// Preview full-reload diagnostics — grep [hf-reload]. Off by default; opt in per +// session with `localStorage.setItem("hf-reload-debug", "1")` (then reload). +// +// A full reload blanks the stage for ~100-300ms, so any reload the user did not +// ask for reads as a flash. These lines answer the only question that matters +// when one appears: who asked for it, and why the write that triggered it was +// not recognised as Studio's own. +let enabled: boolean | null = null; + +function isEnabled(): boolean { + if (enabled === null) { + try { + enabled = localStorage.getItem("hf-reload-debug") === "1"; + } catch { + enabled = false; + } + } + return enabled; +} + +export function logReload( + stage: string, + data: Record | (() => Record) = {}, +): void { + if (!isEnabled()) return; + const details = typeof data === "function" ? data() : data; + console.log( + `[hf-reload] ${JSON.stringify({ stage, t: Math.round(performance.now()), ...details })}`, + ); +} diff --git a/packages/studio/src/utils/studioFileVersion.ts b/packages/studio/src/utils/studioFileVersion.ts index 2737c069b..685cb9592 100644 --- a/packages/studio/src/utils/studioFileVersion.ts +++ b/packages/studio/src/utils/studioFileVersion.ts @@ -48,6 +48,21 @@ export async function studioExpectedFileVersion( return versions.get(path); } -export function createStudioWriteToken(): string { +function createStudioWriteToken(): string { return globalThis.crypto.randomUUID(); } + +/** + * Headers that claim the write a mutation request is about to make as our own. + * + * The token is marked BEFORE the request goes out on purpose: the server writes + * the file and the watcher broadcasts it while the request is still in flight, so + * a token marked from the response can arrive after the echo it was meant to + * match. An unmatched echo reads as an external change and costs a full preview + * reload, which the user sees as a flash right after their own edit. + */ +export function studioWriteHeaders(): Record { + const token = createStudioWriteToken(); + markStudioWriteToken(token); + return { "X-Hyperframes-Write-Token": token }; +} diff --git a/packages/studio/src/utils/timelineCompositionInsert.ts b/packages/studio/src/utils/timelineCompositionInsert.ts index 144b6c4ef..275905a6d 100644 --- a/packages/studio/src/utils/timelineCompositionInsert.ts +++ b/packages/studio/src/utils/timelineCompositionInsert.ts @@ -2,6 +2,7 @@ import { createStudioSaveHttpError } from "./studioSaveDiagnostics"; import { serializeStudioFileMutation } from "./studioFileMutationCoordinator"; import type { RecordEditInput } from "./studioFileHistory"; import { buildProjectApiPath } from "./projectRouting"; +import { studioWriteHeaders } from "./studioFileVersion"; interface TimelineCompositionInsertionResult { path: string; @@ -34,7 +35,7 @@ async function insertTimelineComposition(input: { ), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...studioWriteHeaders() }, body: JSON.stringify({ sourcePath: input.sourcePath, start: input.start,