feat: Persist Studio manual edits via manifest (#593)

## Summary

Studio manual geometry edits now persist as a project-local manifest instead of being baked into composition source on each gesture.

The manifest lives at:

```text
.hyperframes/studio-manual-edits.json
```

It is the source of truth for manual drag, resize, rotation, inspector geometry edits, group moves, and selected-layer reset.

## Architecture

- **Manifest-backed edits**: each edit stores a kind (`path-offset`, `box-size`, `rotation`), a source-scoped target, and the edit values.
- **Source-scoped resolution**: targets include `sourceFile`, `id`, `selector`, and `selectorIndex`, so duplicate selectors in nested compositions resolve against the owning source file.
- **Additive CSS layer**: move uses CSS `translate`, resize writes stable dimensions/flex sizing, and rotation uses CSS `rotate` over the authored base.
- **Shared replay runtime**: Studio preview, thumbnails, frame capture, producer renders, and CLI Studio renders/thumbnails all use the same core manual-edit render script.
- **Animation-safe replay**: Studio reapplies the manual layer after load, refresh, timeline seeks, player operations, playback frames, thumbnail seeks, and render seeks instead of rewriting GSAP timelines.
- **History and handoff**: the manifest is a normal project file, so undo/redo and agent edits can preserve, modify, or remove manual visual edits explicitly.

## User Impact

Users can move, resize, rotate, group-move, and reset supported layers from the canvas or inspector, then refresh, capture thumbnails/screenshots, play animated compositions, and render videos without manual edits drifting away from the edited state.

## Main Files

- `packages/studio/src/components/editor/manualEdits.ts`
- `packages/studio/src/components/editor/DomEditOverlay.tsx`
- `packages/studio/src/components/editor/PropertyPanel.tsx`
- `packages/studio/src/App.tsx`
- `packages/core/src/studio-api/helpers/manualEditsRenderScript.ts`
- `packages/studio/vite.config.ts`
- `packages/cli/src/server/studioServer.ts`
- `packages/core/src/compiler/htmlBundler.ts`
- `packages/producer/src/services/htmlCompiler.ts`
- `packages/core/src/studio-api/routes/thumbnail.ts`
- `packages/producer/src/services/fileServer.ts`
- `packages/producer/src/services/renderOrchestrator.ts`

## Test Plan

```bash
volta run --node 22.20.0 bun run build
volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/studio-api/helpers/manualEditsRenderScript.test.ts
volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/cli typecheck
volta run --node 22.20.0 bunx oxlint <changed files>
volta run --node 22.20.0 bunx oxfmt --check <changed files>
git diff --check
```
This commit is contained in:
Vance Ingalls
2026-05-03 23:06:11 -07:00
committed by GitHub
parent 1d15845a13
commit d0abe90a82
82 changed files with 15351 additions and 717 deletions
+128 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { applyPatchByTarget, readAttributeByTarget, type PatchOperation } from "./sourcePatcher";
import {
applyPatch,
applyPatchByTarget,
readAttributeByTarget,
readTagSnippetByTarget,
type PatchOperation,
} from "./sourcePatcher";
describe("applyPatchByTarget", () => {
it("updates a composition host by data-composition-id selector", () => {
@@ -29,6 +35,64 @@ describe("applyPatchByTarget", () => {
);
});
it("patches inline move styles by target", () => {
const html = `<div id="card" style="position: absolute; left: 108px; top: 112px"></div>`;
const withLeft = applyPatchByTarget(
html,
{ id: "card" },
{ type: "inline-style", property: "left", value: "160px" },
);
const withTop = applyPatchByTarget(
withLeft,
{ id: "card" },
{ type: "inline-style", property: "top", value: "140px" },
);
expect(withTop).toContain('style="position: absolute; left: 160px; top: 140px"');
});
it("patches inline resize styles by target", () => {
const html = `<div id="card" style="position: absolute; width: 380px; height: 196px"></div>`;
const withWidth = applyPatchByTarget(
html,
{ id: "card" },
{ type: "inline-style", property: "width", value: "420px" },
);
const withHeight = applyPatchByTarget(
withWidth,
{ id: "card" },
{ type: "inline-style", property: "height", value: "220px" },
);
expect(withHeight).toContain('style="position: absolute; width: 420px; height: 220px"');
});
it("escapes quoted CSS urls inside double-quoted style attributes", () => {
const html = `<div id="card" style="position: absolute; opacity: 1"></div>`;
const withBackground = applyPatchByTarget(
html,
{ id: "card" },
{
type: "inline-style",
property: "background-image",
value: `url("../ChatGPT Image Apr 22, 2026.png")`,
},
);
const withRadius = applyPatchByTarget(
withBackground,
{ id: "card" },
{ type: "inline-style", property: "border-radius", value: "12px" },
);
expect(withRadius).toContain(
"background-image: url(&quot;../ChatGPT Image Apr 22, 2026.png&quot;)",
);
expect(withRadius).toContain("border-radius: 12px");
});
it("updates media timing attributes by selector", () => {
const html = `<video class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></video>`;
@@ -62,6 +126,69 @@ describe("applyPatchByTarget", () => {
expect(readAttributeByTarget(html, { selector: ".hero" }, "duration")).toBe("1.4");
});
it("reads the matched tag snippet by target", () => {
const html = `<section id="hero" class="card clip" style="left: 120px; top: 180px"></section>`;
expect(readTagSnippetByTarget(html, { id: "hero" })).toBe(
`<section id="hero" class="card clip" style="left: 120px; top: 180px"`,
);
});
it("patches and reads single-quoted attributes and styles", () => {
const html =
"<section id='hero' class='card clip' data-start='0.2' style='left: 120px; top: 180px'></section>";
const moved = applyPatchByTarget(
html,
{ id: "hero" },
{ type: "inline-style", property: "left", value: "160px" },
);
const updated = applyPatchByTarget(
moved,
{ id: "hero" },
{ type: "attribute", property: "start", value: "0.4" },
);
expect(updated).toContain(`style='left: 160px; top: 180px'`);
expect(updated).toContain(`data-start="0.4"`);
expect(readAttributeByTarget(updated, { id: "hero" }, "start")).toBe("0.4");
});
it("replaces the full text body of a nested element by id", () => {
const html =
'<div id="panel"><strong>Headline</strong><span>Supporting copy</span></div><p>Outside</p>';
const patched = applyPatch(html, "panel", {
type: "text-content",
property: "text",
value: "<strong>New headline</strong><span>New supporting copy</span>",
});
expect(patched).toContain(
'<div id="panel"><strong>New headline</strong><span>New supporting copy</span></div>',
);
expect(patched).toContain("<p>Outside</p>");
});
it("does not stop at the first child closing tag when patching nested text", () => {
const html =
'<section id="card"><div><strong>Headline</strong></div><div>Copy</div></section><p>Outside</p>';
const patched = applyPatchByTarget(
html,
{ id: "card" },
{
type: "text-content",
property: "text",
value: "<strong>New headline</strong>",
},
);
expect(patched).toBe(
'<section id="card"><strong>New headline</strong></section><p>Outside</p>',
);
});
it("patches the correct duplicate selector occurrence", () => {
const html = [
`<div class="headline clip" data-start="0"></div>`,