diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc
index 7fbf76be91..4f5f8f6b28 100644
--- a/.fallowrc.jsonc
+++ b/.fallowrc.jsonc
@@ -44,7 +44,7 @@
// Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel.
"packages/studio/src/components/editor/KeyframeDiamond.tsx",
"packages/studio/src/components/editor/SpringEaseEditor.tsx",
- // NLE notice — rendered conditionally via NLELayout when timeline is first shown.
+ // NLE notice — rendered conditionally via NLEContext/EditorShell when timeline is first shown.
"packages/studio/src/components/nle/TimelineEditorNotice.tsx",
// Zoom hook extracted for downstream razor-blade PRs (#1330, #1331).
"packages/studio/src/player/components/useTimelineZoom.ts",
@@ -53,9 +53,6 @@
"packages/studio/src/hooks/gsapTargetCache.ts",
// Preview helper consumed dynamically from the studio iframe bridge.
"packages/studio/src/hooks/gsapRuntimePreview.ts",
- // TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
- // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
- "packages/studio/src/components/editor/CanvasContextMenu.tsx",
],
"ignorePatterns": [
"docs/**",
@@ -93,10 +90,52 @@
"packages/cli/src/cloud/_gen/**",
],
"ignoreExports": [
- // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ // Part of useTimelineEditing's inferred public return type; consumers invoke
+ // handleTimelineGroupResize without importing the change type directly.
{
- "file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts",
- "exports": ["readEffectiveZIndex"],
+ "file": "packages/studio/src/hooks/useTimelineGroupEditing.ts",
+ "exports": ["TimelineGroupResizeChange"],
+ },
+ {
+ "file": "packages/studio/src/player/components/timelineGroupEditing.ts",
+ "exports": [
+ "buildTimelineGroupResizeMembers",
+ "resolveTimelineGroupResizeChanges",
+ "applyTimelineGroupResizePreview",
+ ],
+ },
+ {
+ "file": "packages/studio/src/player/components/timelineEditing.ts",
+ "exports": [
+ "selectTimelineElementsInMarquee",
+ "TimelineGroupResizeEdge",
+ "TimelineGroupTimingMember",
+ ],
+ },
+ {
+ "file": "packages/studio/src/player/lib/timelineElementHelpers.ts",
+ "exports": ["furthestClipEndFromDocument", "furthestClipEndFromSource"],
+ },
+ {
+ "file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx",
+ "exports": ["DeleteConfirm"],
+ },
+ {
+ "file": "packages/studio/src/utils/timelineAssetDrop.ts",
+ "exports": [
+ "setCompositionDurationToContent",
+ "extendCompositionDurationIfNeeded",
+ "fitTimelineAssetGeometry",
+ "resolveTimelineAssetCompositionSize",
+ ],
+ },
+ {
+ "file": "packages/studio/src/components/sidebar/assetHelpers.ts",
+ "exports": ["truncateMiddle", "formatDuration"],
+ },
+ {
+ "file": "packages/studio/src/utils/studioHelpers.ts",
+ "exports": ["resolveDroppedAssetDimensions"],
},
// drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
@@ -320,6 +359,11 @@
// and attribute tag-patchers; only the PatchOperation type gained two
// optional fields here, but the line shift makes fallow re-flag them.
"packages/studio/src/utils/sourcePatcher.ts",
+ // useGsapSelectionHandlers.ts: pre-existing parallel structure with
+ // useDomEditWiring.ts (thin useCallback wrappers guarding on selection);
+ // only gained two optional pass-through parameters here, but the line
+ // shift makes fallow re-flag the pre-existing clone.
+ "packages/studio/src/hooks/useGsapSelectionHandlers.ts",
// gsapParser.ts: recast/babel GSAP writer — intentional duplication between
// recast and acorn parallel implementations (pre-existing, moved from core).
"packages/parsers/src/gsapParser.ts",
@@ -441,13 +485,6 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
- // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
- // removed by studio-dnd/pr22 when the final config lands.
- "packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
- "packages/studio/src/components/editor/CanvasContextMenu.tsx",
- "packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts",
- "packages/studio/src/player/components/timelineCollision.test.ts",
- "packages/studio/src/player/components/timelineStackingSync.test.ts",
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
// patch*InTag pre-date this PR; only the PatchOperation type gained two
// optional fields, but the line-shift fingerprint re-flags the inherited
@@ -587,14 +624,12 @@
"packages/studio/src/components/editor/Transform3DCube.tsx",
"packages/studio/src/components/LintModal.tsx",
"packages/studio/src/components/MediaPreview.tsx",
- "packages/studio/src/components/nle/NLELayout.tsx",
"packages/studio/src/components/nle/NLEPreview.tsx",
"packages/studio/src/components/sidebar/AudioRow.tsx",
"packages/studio/src/components/sidebar/BlocksTab.tsx",
"packages/studio/src/components/sidebar/CompositionsTab.tsx",
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
"packages/studio/src/components/storyboard/StoryboardLoaded.tsx",
- "packages/studio/src/components/StudioPreviewArea.tsx",
"packages/studio/src/components/StudioRightPanel.tsx",
"packages/studio/src/components/StudioToast.tsx",
"packages/studio/src/components/ui/Tooltip.tsx",
diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts
index 378a6a9bb4..9c7700b5fc 100644
--- a/packages/core/src/runtime/init.test.ts
+++ b/packages/core/src/runtime/init.test.ts
@@ -1564,6 +1564,36 @@ describe("initSandboxRuntimeModular", () => {
expect(seekTimes[seekTimes.length - 1]).toBe(0);
});
+ it("restores timed element visibility after a forced timeline rebind", () => {
+ document.body.innerHTML = `
+
+ `;
+ const clipExpired = document.querySelector("#clip-expired");
+ const clipFuture = document.querySelector("#clip-future");
+ const clipControl = document.querySelector("#clip-control");
+ window.__timelines = { root: createMockTimeline(30) };
+
+ initSandboxRuntimeModular();
+ window.__player?.seek(16.2);
+
+ expect(clipExpired?.style.visibility).toBe("hidden");
+ expect(clipFuture?.style.visibility).toBe("hidden");
+ expect(clipControl?.style.visibility).toBe("visible");
+
+ if (clipExpired) clipExpired.style.visibility = "visible";
+ if (clipFuture) clipFuture.style.visibility = "visible";
+
+ window.__hfForceTimelineRebind?.();
+
+ expect(clipExpired?.style.visibility).toBe("hidden");
+ expect(clipFuture?.style.visibility).toBe("hidden");
+ expect(clipControl?.style.visibility).toBe("visible");
+ });
+
it("onSetMuted preserves authored muted attribute on video elements", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index acaf614e9e..67ace5d152 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -1334,6 +1334,7 @@ export function initSandboxRuntimeModular(): void {
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = () => {
childrenBound = false;
bindRootTimelineIfAvailable();
+ syncTimedElementVisibility(state.currentTime);
};
const emitRootStageLayoutDiagnostics = () => {
@@ -1691,6 +1692,64 @@ export function initSandboxRuntimeModular(): void {
const dataHiddenDisplayRestores = new WeakMap();
const dataHiddenDisplayNodes = new WeakSet();
+ const syncTimedElementVisibility = (currentTime: number) => {
+ const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
+ const rootComp = resolveRootCompositionElement();
+ for (const rawNode of visibilityNodes) {
+ if (!(rawNode instanceof HTMLElement)) continue;
+
+ if (rawNode.hasAttribute("data-hidden")) {
+ if (!dataHiddenDisplayNodes.has(rawNode)) {
+ dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display"));
+ dataHiddenDisplayNodes.add(rawNode);
+ }
+ rawNode.style.display = "none";
+ if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
+ colorGradingRuntime?.setSourceVisibility(rawNode, false);
+ }
+ continue;
+ }
+
+ if (dataHiddenDisplayNodes.has(rawNode)) {
+ const previousDisplay = dataHiddenDisplayRestores.get(rawNode);
+ if (previousDisplay) {
+ rawNode.style.display = previousDisplay;
+ } else {
+ rawNode.style.removeProperty("display");
+ }
+ dataHiddenDisplayRestores.delete(rawNode);
+ dataHiddenDisplayNodes.delete(rawNode);
+ }
+
+ let isVisibleNow = isTimedElementVisibleAt(rawNode, currentTime);
+ // Descendants must not override a hidden ancestor clip. CSS visibility can
+ // otherwise leak child pixels through inactive scenes because a descendant
+ // with visibility:visible escapes an ancestor's visibility:hidden.
+ if (isVisibleNow) {
+ let ancestor = rawNode.parentElement;
+ while (ancestor) {
+ if (ancestor === rootComp) break;
+ if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) {
+ if (!isTimedElementVisibleAt(ancestor, currentTime)) {
+ isVisibleNow = false;
+ break;
+ }
+ }
+ ancestor = ancestor.parentElement;
+ }
+ }
+ rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
+ if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
+ colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
+ }
+ if (isVisibleNow) {
+ if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display");
+ } else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) {
+ rawNode.style.display = "none";
+ }
+ }
+ };
+
const syncMediaForCurrentState = () => {
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
const compositionRoot = element.closest("[data-composition-id]");
@@ -1773,61 +1832,7 @@ export function initSandboxRuntimeModular(): void {
},
});
}
- const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
- const rootComp = resolveRootCompositionElement();
- for (const rawNode of visibilityNodes) {
- if (!(rawNode instanceof HTMLElement)) continue;
-
- if (rawNode.hasAttribute("data-hidden")) {
- if (!dataHiddenDisplayNodes.has(rawNode)) {
- dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display"));
- dataHiddenDisplayNodes.add(rawNode);
- }
- rawNode.style.display = "none";
- if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
- colorGradingRuntime?.setSourceVisibility(rawNode, false);
- }
- continue;
- }
-
- if (dataHiddenDisplayNodes.has(rawNode)) {
- const previousDisplay = dataHiddenDisplayRestores.get(rawNode);
- if (previousDisplay) {
- rawNode.style.display = previousDisplay;
- } else {
- rawNode.style.removeProperty("display");
- }
- dataHiddenDisplayRestores.delete(rawNode);
- dataHiddenDisplayNodes.delete(rawNode);
- }
-
- let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime);
- // Descendants must not override a hidden ancestor clip. CSS visibility can
- // otherwise leak child pixels through inactive scenes because a descendant
- // with visibility:visible escapes an ancestor's visibility:hidden.
- if (isVisibleNow) {
- let ancestor = rawNode.parentElement;
- while (ancestor) {
- if (ancestor === rootComp) break;
- if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) {
- if (!isTimedElementVisibleAt(ancestor, state.currentTime)) {
- isVisibleNow = false;
- break;
- }
- }
- ancestor = ancestor.parentElement;
- }
- }
- rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
- if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
- colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
- }
- if (isVisibleNow) {
- if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display");
- } else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) {
- rawNode.style.display = "none";
- }
- }
+ syncTimedElementVisibility(state.currentTime);
};
const postState = (force: boolean) => {
diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts
index b26558b9c3..ef76ef618c 100644
--- a/packages/studio-server/src/helpers/sourceMutation.ts
+++ b/packages/studio-server/src/helpers/sourceMutation.ts
@@ -2,6 +2,7 @@ import { parseHTML } from "linkedom";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety";
+import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js";
export interface SourceMutationTarget {
@@ -349,8 +350,11 @@ export function splitElementInHtml(
el.parentElement!.appendChild(clone);
}
+ const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
return {
- html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
+ // The split owns its new nodes' stable ids. Leaving the clone unstamped makes
+ // the next preview request persist different bytes after history is recorded.
+ html: ensureHfIds(html),
matched: true,
newId,
};
diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts
index 6bd4d92dcb..47bc557fc0 100644
--- a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts
+++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts
@@ -12,8 +12,11 @@ describe("splitElementInHtml — hfId clone isolation", () => {
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
expect(matched).toBe(true);
+ const { document } = parseHTML(html);
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
expect(occurrences).toBe(1);
+ expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).toMatch(/^hf-/);
+ expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).not.toBe("hf-abc123");
});
});
diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts
index 90699abd5a..6df7cf3672 100644
--- a/packages/studio-server/src/routes/files.test.ts
+++ b/packages/studio-server/src/routes/files.test.ts
@@ -1,6 +1,14 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFileRoutes } from "./files";
@@ -38,6 +46,14 @@ function createAdapter(projectDir: string): StudioApiAdapter {
};
}
+function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Promise {
+ return app.request(`http://localhost/projects/demo/file-mutations/patch-elements-batch/${file}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ patches }),
+ });
+}
+
describe("registerFileRoutes", () => {
it("returns empty content for missing files when caller marks the read optional", async () => {
const projectDir = createProjectDir();
@@ -116,13 +132,13 @@ describe("registerFileRoutes", () => {
}),
},
);
+ expect(response.status).toBe(200);
const payload = (await response.json()) as {
changed?: boolean;
path?: string;
backupPath?: string;
};
- expect(response.status).toBe(200);
expect(payload.changed).toBe(true);
expect(payload.path).toBe("index.html");
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
@@ -132,6 +148,96 @@ describe("registerFileRoutes", () => {
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After");
});
+ it("applies an ordered element patch batch with one file write", async () => {
+ const projectDir = createProjectDir();
+ const original =
+ 'Back
Front
';
+ writeFileSync(join(projectDir, "index.html"), original);
+ 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" }],
+ },
+ ]);
+ expect(response.status).toBe(200);
+ const payload = (await response.json()) as {
+ changed?: boolean;
+ matched?: boolean[];
+ content?: string;
+ backupPath?: string;
+ };
+
+ expect(response.status).toBe(200);
+ expect(payload.changed).toBe(true);
+ expect(payload.matched).toEqual([true, true]);
+ expect(payload.content).toBe(readFileSync(join(projectDir, "index.html"), "utf-8"));
+ 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);
+ expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(1);
+ });
+
+ it("returns changed false without writing for a no-op element patch batch", async () => {
+ const projectDir = createProjectDir();
+ const original = 'Title
';
+ writeFileSync(join(projectDir, "index.html"), original);
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const response = await postElementPatchBatch(app, "index.html", [
+ {
+ target: { id: "title" },
+ operations: [{ type: "inline-style", property: "z-index", value: "4" }],
+ },
+ ]);
+ expect(response.status).toBe(200);
+ const payload = (await response.json()) as {
+ changed?: boolean;
+ matched?: boolean[];
+ content?: string;
+ backupPath?: string;
+ };
+
+ expect(payload.changed).toBe(false);
+ expect(payload.matched).toEqual([true]);
+ expect(payload.content).toBe(original);
+ expect(payload.backupPath).toBeUndefined();
+ expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false);
+ });
+
+ it("rejects an unsafe value anywhere in an element patch batch without writing", async () => {
+ const projectDir = createProjectDir();
+ const original = 'First
Second
';
+ writeFileSync(join(projectDir, "index.html"), original);
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const response = await postElementPatchBatch(app, "index.html", [
+ {
+ target: { id: "first" },
+ operations: [{ type: "inline-style", property: "z-index", value: "2" }],
+ },
+ {
+ target: { id: "second", selectorIndex: Number.NaN },
+ operations: [{ type: "inline-style", property: "z-index", value: "1" }],
+ },
+ ]);
+ expect(response.status).toBe(400);
+ const payload = (await response.json()) as { error?: string; fields?: string[] };
+
+ expect(payload.error).toContain("unsafe values");
+ expect(payload.fields).toContain("body.target.selectorIndex");
+ expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(original);
+ expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false);
+ });
+
// A realistic sub-composition: markup + GSAP wrapped in a , tweens
// targeting element variables resolved from querySelector, with interleaved
// gsap.set() calls. This is the shape every scaffolded composition uses.
@@ -205,6 +311,136 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas
return payload.animations[0];
}
+ function postGsapMutationBatch(app: Hono, file: string, body: unknown): Promise {
+ return app.request(`http://localhost/projects/demo/gsap-mutations-batch/${file}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ }
+
+ it("applies an ordered GSAP mutation batch with one before/after write result", async () => {
+ const projectDir = createProjectDir();
+ writeHtml(projectDir, "comp.html", FROMTO_COMP);
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+ const anim = await getFirstAnimation(app, "comp.html");
+
+ const res = await postGsapMutationBatch(app, "comp.html", {
+ mutations: [
+ {
+ type: "update-from-property",
+ animationId: anim.id,
+ property: "opacity",
+ value: 0.2,
+ },
+ {
+ type: "update-from-property",
+ animationId: anim.id,
+ property: "x",
+ value: -25,
+ },
+ ],
+ });
+ const result = (await res.json()) as {
+ ok: boolean;
+ changed: boolean;
+ before: string;
+ after: string;
+ backupPath: string;
+ parsed: { animations: Array<{ fromProperties?: Record }> };
+ };
+
+ expect(res.status).toBe(200);
+ expect(result.ok).toBe(true);
+ expect(result.changed).toBe(true);
+ expect(result.before).toBe(FROMTO_COMP);
+ expect(result.after).toBe(readFileSync(join(projectDir, "comp.html"), "utf-8"));
+ expect(readFileSync(join(projectDir, result.backupPath), "utf-8")).toBe(FROMTO_COMP);
+ expect(result.parsed.animations[0].fromProperties).toMatchObject({ opacity: 0.2, x: -25 });
+ });
+
+ it.each(["first", "middle", "last"] as const)(
+ "rejects an invalid %s mutation without writing any part of the batch",
+ async (position) => {
+ const projectDir = createProjectDir();
+ writeHtml(projectDir, "comp.html", FROMTO_COMP);
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+ const anim = await getFirstAnimation(app, "comp.html");
+ const valid = {
+ type: "update-from-property",
+ animationId: anim.id,
+ property: "opacity",
+ value: 0.2,
+ };
+ const invalid =
+ position === "first"
+ ? {}
+ : position === "middle"
+ ? { ...valid, value: null }
+ : { type: "not-a-mutation" };
+ const mutations =
+ position === "first"
+ ? [invalid, valid, valid]
+ : position === "middle"
+ ? [valid, invalid, valid]
+ : [valid, valid, invalid];
+
+ const res = await postGsapMutationBatch(app, "comp.html", { mutations });
+
+ expect(res.status).toBe(400);
+ expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(FROMTO_COMP);
+ },
+ );
+
+ it("re-syncs position holds when a batch mixes hold-sync and ordinary mutations", async () => {
+ const projectDir = createProjectDir();
+ const html = ``;
+ writeHtml(projectDir, "hold.html", html);
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const res = await postGsapMutationBatch(app, "hold.html", {
+ mutations: [
+ {
+ type: "add-with-keyframes",
+ targetSelector: "#box",
+ position: 1,
+ duration: 1,
+ keyframes: [
+ { percentage: 0, properties: { x: 10, y: 20 } },
+ { percentage: 100, properties: { x: 30, y: 40 } },
+ ],
+ },
+ {
+ type: "add",
+ targetSelector: "#box",
+ method: "set",
+ position: 0,
+ properties: { opacity: 0.5 },
+ },
+ ],
+ });
+ const result = (await res.json()) as { scriptText: string };
+
+ expect(res.status).toBe(200);
+ expect(result.scriptText).toContain("hf-hold");
+ expect(result.scriptText.match(/hf-hold/g)).toHaveLength(1);
+ });
+
+ it.each([{}, { mutations: [] }])("rejects an empty or missing mutation batch", async (body) => {
+ const projectDir = createProjectDir();
+ const app = new Hono();
+ registerFileRoutes(app, createAdapter(projectDir));
+
+ const res = await postGsapMutationBatch(app, "index.html", body);
+
+ expect(res.status).toBe(400);
+ });
+
it("update-from-property updates a fromTo start value in place", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "comp.html", FROMTO_COMP);
diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts
index 8ad12293ba..8aceab9aa7 100644
--- a/packages/studio-server/src/routes/files.ts
+++ b/packages/studio-server/src/routes/files.ts
@@ -106,6 +106,12 @@ interface RouteContext {
json: (data: unknown, status?: number) => Response;
}
+interface ResolvedGsapFile {
+ project: { dir: string };
+ filePath: string;
+ absPath: string;
+}
+
/** Resolve project + safe absolute path for any project-scoped route. */
async function resolveProjectPath(
c: RouteContext,
@@ -155,6 +161,25 @@ type MutationTarget = {
selectorIndex?: number;
};
+interface ElementPatchRequest {
+ target: MutationTarget;
+ operations: PatchOperation[];
+}
+
+function foldElementPatches(
+ originalContent: string,
+ patches: ElementPatchRequest[],
+): { content: string; matched: boolean[] } {
+ let content = originalContent;
+ const matched: boolean[] = [];
+ for (const patch of patches) {
+ const result = patchElementInHtml(content, patch.target, patch.operations);
+ content = result.html;
+ matched.push(result.matched);
+ }
+ return { content, matched };
+}
+
/** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */
function writeIfChanged(
c: RouteContext,
@@ -844,6 +869,130 @@ async function executeGsapMutation(
return executeGsapMutationAcorn(body, block, respond);
}
+function validateGsapMutationRequest(
+ c: RouteContext,
+ body: GsapMutationRequest | null,
+): Response | null {
+ if (!body || typeof body !== "object" || !("type" in body) || !body.type) {
+ return c.json({ error: "mutation type required" }, 400);
+ }
+ const unsafeFields = findUnsafeMutationValues(body);
+ if (unsafeFields.length > 0) return rejectUnsafeMutationValues(c, unsafeFields);
+ if (
+ body.type === "shift-positions-batch" &&
+ (!("shifts" in body) || !Array.isArray(body.shifts))
+ ) {
+ return c.json({ error: "shift-positions-batch requires a `shifts` array" }, 400);
+ }
+ return null;
+}
+
+async function prepareGsapMutationScript(
+ c: RouteContext,
+ res: ResolvedGsapFile,
+ firstMutation: GsapMutationRequest,
+): Promise<
+ | Response
+ | {
+ html: string;
+ block: NonNullable>;
+ }
+> {
+ let html = readFileSync(res.absPath, "utf-8");
+ let block = extractGsapScriptBlock(html);
+ if (!block && (firstMutation.type === "add" || firstMutation.type === "add-with-keyframes")) {
+ const compId = html.match(/data-composition-id="([^"]+)"/)?.[1] ?? "main";
+ const { GSAP_CDN } = await import("@hyperframes/core");
+ const bootstrap = [
+ ``,
+ "",
+ ].join("\n");
+ html = html.includes("