mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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, """) : value.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user