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
+130 -18
View File
@@ -7,6 +7,67 @@ function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeStyleAttributeValue(value: string, quote: string): string {
return quote === '"' ? value.replace(/"/g, "&quot;") : value.replace(/'/g, "&#39;");
}
function splitInlineStyleDeclarations(style: string): string[] {
const declarations: string[] = [];
let current = "";
let quote: string | null = null;
let entity = false;
let parenDepth = 0;
for (const char of style) {
if (entity) {
current += char;
if (char === ";") entity = false;
continue;
}
if (char === "&") {
entity = true;
current += char;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === "(") {
parenDepth += 1;
current += char;
continue;
}
if (char === ")") {
parenDepth = Math.max(0, parenDepth - 1);
current += char;
continue;
}
if (char === ";" && parenDepth === 0) {
declarations.push(current);
current = "";
continue;
}
current += char;
}
if (current) declarations.push(current);
return declarations;
}
export interface PatchOperation {
type: "inline-style" | "attribute" | "text-content";
property: string;
@@ -74,7 +135,7 @@ export function resolveSourceFile(
*/
function patchInlineStyle(html: string, elementId: string, prop: string, value: string): string {
// Find the element tag with this id
const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(elementId)}"[^>]*)>`, "i");
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
const match = idPattern.exec(html);
if (!match) return html;
@@ -86,12 +147,13 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
if (!tag) return html;
// Check if there's an existing style attribute
const styleMatch = /\bstyle="([^"]*)"/.exec(tag);
const styleMatch = /\bstyle=(["'])([\s\S]*?)\1/.exec(tag);
if (styleMatch) {
const existingStyle = styleMatch[1];
const existingStyle = styleMatch[2];
const quote = styleMatch[1];
// Parse existing properties
const props = new Map<string, string>();
for (const part of existingStyle.split(";")) {
for (const part of splitInlineStyleDeclarations(existingStyle)) {
const colon = part.indexOf(":");
if (colon < 0) continue;
const key = part.slice(0, colon).trim();
@@ -102,13 +164,14 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
props.set(prop, value);
// Rebuild style string
const newStyle = Array.from(props.entries())
.map(([k, v]) => `${k}: ${v}`)
.map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
.join("; ");
const newTag = tag.replace(/\bstyle="[^"]*"/, `style="${newStyle}"`);
const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
return html.replace(tag, newTag);
} else {
// No existing style — add one
const newTag = tag.replace(/>$/, "") + ` style="${prop}: ${value}"`;
const newTag =
tag.replace(/>$/, "") + ` style="${prop}: ${escapeStyleAttributeValue(value, '"')}"`;
return html.replace(tag, newTag);
}
}
@@ -137,7 +200,7 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin
function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
if (target.id) {
const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(target.id)}"[^>]*)>`, "i");
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(target.id)}\\2[^>]*)>`, "i");
const match = idPattern.exec(html);
if (match?.index != null) {
return {
@@ -154,7 +217,7 @@ function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
if (compositionIdMatch) {
const compId = compositionIdMatch[1];
const pattern = new RegExp(
`(<[^>]*\\bdata-composition-id="${escapeRegex(compId)}"[^>]*)>`,
`(<[^>]*\\bdata-composition-id=(["'])${escapeRegex(compId)}\\2[^>]*)>`,
"i",
);
const match = pattern.exec(html);
@@ -201,8 +264,13 @@ export function readAttributeByTarget(
if (!match) return undefined;
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
const valueMatch = new RegExp(`\\b${fullAttr}="([^"]*)"`).exec(match.tag);
return valueMatch?.[1];
const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag);
return valueMatch?.[2];
}
export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined {
const match = findTagByTarget(html, target);
return match?.tag;
}
function patchAttributeByTarget(
@@ -215,7 +283,7 @@ function patchAttributeByTarget(
if (!match) return html;
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
const attrPattern = new RegExp(`\\b${fullAttr}="[^"]*"`);
const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
const tag = match.tag;
if (attrPattern.test(tag)) {
@@ -231,13 +299,13 @@ function patchAttributeByTarget(
* Apply an attribute change to an element in the HTML source.
*/
function patchAttribute(html: string, elementId: string, attr: string, value: string): string {
const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(elementId)}"[^>]*)>`, "i");
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
const match = idPattern.exec(html);
if (!match) return html;
const tag = match[1];
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
const attrPattern = new RegExp(`\\b${fullAttr}="[^"]*"`);
const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
if (attrPattern.test(tag)) {
// Update existing attribute
@@ -254,11 +322,53 @@ function patchAttribute(html: string, elementId: string, attr: string, value: st
* Apply a text content change to an element.
*/
function patchTextContent(html: string, elementId: string, value: string): string {
// Match the element and its content: <tagname id="elementId"...>content</tagname>
const pattern = new RegExp(`(<[^>]*\\bid="${elementId}"[^>]*>)([\\s\\S]*?)(<\\/[a-z]+>)`, "i");
const match = pattern.exec(html);
const openTagPattern = new RegExp(
`(<([a-z0-9-]+)[^>]*\\bid=(["'])${escapeRegex(elementId)}\\3[^>]*>)`,
"i",
);
const match = openTagPattern.exec(html);
if (!match || match.index == null) return html;
const openingTag = match[1];
const tagName = match[2];
const contentStart = match.index + openingTag.length;
const closingIndex = findMatchingClosingTagIndex(html, tagName, contentStart);
if (closingIndex < 0) return html;
return `${html.slice(0, contentStart)}${value}${html.slice(closingIndex)}`;
}
function findMatchingClosingTagIndex(html: string, tagName: string, contentStart: number): number {
const tagPattern = new RegExp(`</?${escapeRegex(tagName)}\\b[^>]*>`, "gi");
tagPattern.lastIndex = contentStart;
let depth = 1;
let match: RegExpExecArray | null;
while ((match = tagPattern.exec(html)) !== null) {
const tag = match[0];
if (tag.startsWith("</")) {
depth -= 1;
if (depth === 0) return match.index;
continue;
}
if (!tag.endsWith("/>")) depth += 1;
}
return -1;
}
function patchTextContentByTarget(html: string, target: PatchTarget, value: string): string {
const match = findTagByTarget(html, target);
if (!match) return html;
return html.replace(pattern, `${match[1]}${value}${match[3]}`);
const tagNameMatch = /^<([a-z0-9-]+)/i.exec(match.tag);
const tagName = tagNameMatch?.[1];
if (!tagName) return html;
const closingIndex = findMatchingClosingTagIndex(html, tagName, match.end + 1);
if (closingIndex < 0) return html;
return `${html.slice(0, match.end + 1)}${value}${html.slice(closingIndex)}`;
}
/**
@@ -290,6 +400,8 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO
return patchInlineStyleByTarget(html, target, op.property, op.value);
case "attribute":
return patchAttributeByTarget(html, target, op.property, op.value);
case "text-content":
return patchTextContentByTarget(html, target, op.value);
default:
return html;
}