mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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:
@@ -255,9 +255,11 @@ describe("bundleToSingleHtml", () => {
|
||||
const host = document.querySelector("#scene-host");
|
||||
|
||||
expect(host?.getAttribute("data-composition-id")).toBe("scene");
|
||||
expect(host?.getAttribute("data-composition-file")).toBe("compositions/scene.html");
|
||||
expect(host?.getAttribute("data-start")).toBe("intro");
|
||||
expect(host?.getAttribute("data-width")).toBe("1920");
|
||||
expect(host?.querySelector(".title")?.textContent).toBe("Scene");
|
||||
expect(host?.querySelector(".title")?.closest("[data-composition-file]")).toBe(host);
|
||||
expect(
|
||||
Array.from(host?.children ?? []).some(
|
||||
(child) => child.getAttribute("data-composition-id") === "scene",
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
parseHTMLContent,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument";
|
||||
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
|
||||
import {
|
||||
rewriteAssetPaths,
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "./rewriteSubCompPaths";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
import { validateHyperframeHtmlContract } from "./staticGuard";
|
||||
|
||||
@@ -501,18 +505,31 @@ export async function bundleToSingleHtml(
|
||||
el.setAttribute(attr, val);
|
||||
},
|
||||
);
|
||||
const styledEls = innerRoot
|
||||
? innerRoot.querySelectorAll("[style]")
|
||||
: contentDoc.querySelectorAll("[style]");
|
||||
rewriteInlineStyleAssetUrls(
|
||||
styledEls,
|
||||
src,
|
||||
(el: Element) => el.getAttribute("style"),
|
||||
(el: Element, val: string) => {
|
||||
el.setAttribute("style", val);
|
||||
},
|
||||
);
|
||||
|
||||
if (innerRoot) {
|
||||
const innerW = innerRoot.getAttribute("data-width");
|
||||
const innerH = innerRoot.getAttribute("data-height");
|
||||
if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW);
|
||||
if (innerH && !hostEl.getAttribute("data-height")) hostEl.setAttribute("data-height", innerH);
|
||||
innerRoot.setAttribute("data-composition-file", src);
|
||||
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
|
||||
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
|
||||
} else {
|
||||
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
|
||||
hostEl.innerHTML = contentDoc.body.innerHTML || "";
|
||||
}
|
||||
hostEl.setAttribute("data-composition-file", src);
|
||||
hostEl.removeAttribute("data-composition-src");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rewriteAssetPath, rewriteCssAssetUrls } from "./rewriteSubCompPaths.js";
|
||||
import {
|
||||
rewriteAssetPath,
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "./rewriteSubCompPaths.js";
|
||||
|
||||
describe("rewriteAssetPath", () => {
|
||||
it("rewrites `../` against the sub-composition dir", () => {
|
||||
@@ -36,4 +40,19 @@ describe("rewriteAssetPath", () => {
|
||||
expect(out).not.toMatch(/\\/);
|
||||
expect(out).not.toMatch(/:\\/);
|
||||
});
|
||||
|
||||
it("rewrites CSS urls inside inline style attributes", () => {
|
||||
const elements = [{ style: `background-image: url("../cover.png")` }];
|
||||
|
||||
rewriteInlineStyleAssetUrls(
|
||||
elements,
|
||||
"compositions/scene.html",
|
||||
(el) => el.style,
|
||||
(el, value) => {
|
||||
el.style = value;
|
||||
},
|
||||
);
|
||||
|
||||
expect(elements[0]?.style).toBe(`background-image: url("cover.png")`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,28 @@ export function rewriteAssetPaths<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite CSS url(...) references inside inline style attributes.
|
||||
*/
|
||||
export function rewriteInlineStyleAssetUrls<T>(
|
||||
elements: Iterable<T>,
|
||||
compSrcPath: string,
|
||||
getStyle: (el: T) => string | null | undefined,
|
||||
setStyle: (el: T, value: string) => void,
|
||||
): void {
|
||||
const compDir = dirname(compSrcPath);
|
||||
if (!compDir || compDir === ".") return;
|
||||
|
||||
for (const el of elements) {
|
||||
const style = getStyle(el);
|
||||
if (!style) continue;
|
||||
const rewritten = rewriteCssAssetUrls(style, compSrcPath);
|
||||
if (rewritten !== style) {
|
||||
setStyle(el, rewritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite CSS url(...) references in a sub-composition's inline styles so
|
||||
* ../foo.woff2 remains valid after the CSS is hoisted into the root document.
|
||||
|
||||
Reference in New Issue
Block a user