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:
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Window } from "happy-dom";
|
||||
import {
|
||||
filterNestedDomEditGroupItems,
|
||||
focusDomEditOverlayElement,
|
||||
hasDomEditRotationChanged,
|
||||
resolveDomEditCoordinateScale,
|
||||
resolveDomEditGroupOverlayRect,
|
||||
resolveDomEditResizeGesture,
|
||||
resolveDomEditRotationGesture,
|
||||
} from "./DomEditOverlay";
|
||||
|
||||
describe("focusDomEditOverlayElement", () => {
|
||||
it("focuses the canvas overlay without scrolling", () => {
|
||||
const calls: Array<FocusOptions | undefined> = [];
|
||||
focusDomEditOverlayElement({
|
||||
focus: (options?: FocusOptions) => calls.push(options),
|
||||
});
|
||||
|
||||
expect(calls).toEqual([{ preventScroll: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDomEditCoordinateScale", () => {
|
||||
it("uses the top-level preview scale when no source boundary dimensions are available", () => {
|
||||
expect(
|
||||
resolveDomEditCoordinateScale({
|
||||
rootScaleX: 0.5,
|
||||
rootScaleY: 0.5,
|
||||
}),
|
||||
).toEqual({
|
||||
scaleX: 0.5,
|
||||
scaleY: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts source-local pixels through a scaled nested composition host", () => {
|
||||
expect(
|
||||
resolveDomEditCoordinateScale({
|
||||
rootScaleX: 0.5,
|
||||
rootScaleY: 0.5,
|
||||
sourceRectWidth: 960,
|
||||
sourceRectHeight: 540,
|
||||
sourceWidth: 1920,
|
||||
sourceHeight: 1080,
|
||||
}),
|
||||
).toEqual({
|
||||
scaleX: 0.25,
|
||||
scaleY: 0.25,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDomEditGroupOverlayRect", () => {
|
||||
it("returns a bounding box that contains every selected element", () => {
|
||||
expect(
|
||||
resolveDomEditGroupOverlayRect([
|
||||
{ left: 40, top: 30, width: 80, height: 50, editScaleX: 1, editScaleY: 1 },
|
||||
{ left: 150, top: 10, width: 30, height: 120, editScaleX: 0.5, editScaleY: 0.5 },
|
||||
{ left: 20, top: 90, width: 50, height: 20, editScaleX: 2, editScaleY: 2 },
|
||||
]),
|
||||
).toEqual({
|
||||
left: 20,
|
||||
top: 10,
|
||||
width: 160,
|
||||
height: 120,
|
||||
editScaleX: 1,
|
||||
editScaleY: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for an empty group", () => {
|
||||
expect(resolveDomEditGroupOverlayRect([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterNestedDomEditGroupItems", () => {
|
||||
it("keeps top-level selected elements so descendants are not moved twice", () => {
|
||||
const window = new Window();
|
||||
const parent = window.document.createElement("div");
|
||||
const child = window.document.createElement("div");
|
||||
const sibling = window.document.createElement("div");
|
||||
parent.append(child);
|
||||
|
||||
expect(
|
||||
filterNestedDomEditGroupItems([
|
||||
{ key: "parent", element: parent },
|
||||
{ key: "child", element: child },
|
||||
{ key: "sibling", element: sibling },
|
||||
]).map((item) => item.key),
|
||||
).toEqual(["parent", "sibling"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDomEditResizeGesture", () => {
|
||||
it("resizes width and height independently by default", () => {
|
||||
expect(
|
||||
resolveDomEditResizeGesture({
|
||||
originWidth: 240,
|
||||
originHeight: 120,
|
||||
actualWidth: 240,
|
||||
actualHeight: 120,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
dx: 30,
|
||||
dy: 12,
|
||||
uniform: false,
|
||||
}),
|
||||
).toEqual({
|
||||
overlayWidth: 270,
|
||||
overlayHeight: 132,
|
||||
width: 270,
|
||||
height: 132,
|
||||
});
|
||||
});
|
||||
|
||||
it("snaps width and height to the same value when Shift is held", () => {
|
||||
expect(
|
||||
resolveDomEditResizeGesture({
|
||||
originWidth: 240,
|
||||
originHeight: 120,
|
||||
actualWidth: 240,
|
||||
actualHeight: 120,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
dx: 30,
|
||||
dy: 12,
|
||||
uniform: true,
|
||||
}),
|
||||
).toEqual({
|
||||
overlayWidth: 270,
|
||||
overlayHeight: 270,
|
||||
width: 270,
|
||||
height: 270,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the dominant pointer delta for uniform shrink", () => {
|
||||
expect(
|
||||
resolveDomEditResizeGesture({
|
||||
originWidth: 300,
|
||||
originHeight: 180,
|
||||
actualWidth: 300,
|
||||
actualHeight: 180,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
dx: 8,
|
||||
dy: -40,
|
||||
uniform: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
width: 260,
|
||||
height: 260,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes source-local dimensions when the edited source is scaled down in master view", () => {
|
||||
expect(
|
||||
resolveDomEditResizeGesture({
|
||||
originWidth: 100,
|
||||
originHeight: 50,
|
||||
actualWidth: 400,
|
||||
actualHeight: 200,
|
||||
scaleX: 0.25,
|
||||
scaleY: 0.25,
|
||||
dx: 25,
|
||||
dy: 10,
|
||||
uniform: false,
|
||||
}),
|
||||
).toEqual({
|
||||
overlayWidth: 125,
|
||||
overlayHeight: 60,
|
||||
width: 500,
|
||||
height: 240,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDomEditRotationGesture", () => {
|
||||
it("rotates by the pointer angle around the element center", () => {
|
||||
expect(
|
||||
resolveDomEditRotationGesture({
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
startX: 0,
|
||||
startY: -10,
|
||||
currentX: 10,
|
||||
currentY: 0,
|
||||
actualAngle: 5,
|
||||
snap: false,
|
||||
}),
|
||||
).toEqual({ angle: 95 });
|
||||
});
|
||||
|
||||
it("uses the shortest delta across the 180 degree boundary", () => {
|
||||
expect(
|
||||
resolveDomEditRotationGesture({
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
startX: -10,
|
||||
startY: 1.76,
|
||||
currentX: -10,
|
||||
currentY: -1.76,
|
||||
actualAngle: 0,
|
||||
snap: false,
|
||||
}).angle,
|
||||
).toBeCloseTo(20, 1);
|
||||
});
|
||||
|
||||
it("snaps to 15 degree increments when requested", () => {
|
||||
expect(
|
||||
resolveDomEditRotationGesture({
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
startX: 10,
|
||||
startY: 0,
|
||||
currentX: 10,
|
||||
currentY: 3.25,
|
||||
actualAngle: 0,
|
||||
snap: true,
|
||||
}),
|
||||
).toEqual({ angle: 15 });
|
||||
});
|
||||
|
||||
it("allows small pointer movements when the rounded angle changes", () => {
|
||||
const nextRotation = resolveDomEditRotationGesture({
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
startX: 0,
|
||||
startY: -40,
|
||||
currentX: 1,
|
||||
currentY: -40,
|
||||
actualAngle: 0,
|
||||
snap: false,
|
||||
});
|
||||
|
||||
expect(nextRotation.angle).toBe(1.4);
|
||||
expect(hasDomEditRotationChanged(0, nextRotation.angle)).toBe(true);
|
||||
expect(hasDomEditRotationChanged(0, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user