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
@@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react";
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
import { copyTextToClipboard } from "../../utils/clipboard";
interface AssetsTabProps {
projectId: string;
@@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({
);
const handleCopyPath = useCallback(async (path: string) => {
try {
await navigator.clipboard.writeText(path);
const copied = await copyTextToClipboard(path);
if (copied) {
setCopiedPath(path);
setTimeout(() => setCopiedPath(null), 1500);
} catch {
// ignore
}
}, []);
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveCompositionPreviewScale } from "./CompositionsTab";
import { resolveCompositionPreviewScale, resolveThumbnailSeekTime } from "./CompositionsTab";
describe("resolveCompositionPreviewScale", () => {
it("scales a 16:9 stage to fit the composition card", () => {
@@ -35,3 +35,18 @@ describe("resolveCompositionPreviewScale", () => {
).toBeCloseTo(80 / 1920);
});
});
describe("resolveThumbnailSeekTime", () => {
it("uses the default 3s frame for compositions longer than 3s", () => {
expect(resolveThumbnailSeekTime(6)).toBe(3);
});
it("uses the midpoint for compositions shorter than 3s", () => {
expect(resolveThumbnailSeekTime(2)).toBe(1);
});
it("falls back to the default 3s frame when duration is unknown", () => {
expect(resolveThumbnailSeekTime(null)).toBe(3);
expect(resolveThumbnailSeekTime(Number.NaN)).toBe(3);
});
});
@@ -1,4 +1,4 @@
import { memo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
interface CompositionsTabProps {
projectId: string;
@@ -8,6 +8,17 @@ interface CompositionsTabProps {
}
const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
const THUMBNAIL_SEEK_TIME_SECONDS = 3;
const THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS = 10;
type PreviewWindow = Window & {
__player?: {
play?: () => void;
pause?: () => void;
seek?: (time: number) => void;
getDuration?: () => number;
};
};
export function resolveCompositionPreviewScale(input: {
cardWidth: number;
@@ -28,6 +39,54 @@ export function resolveCompositionPreviewScale(input: {
return Math.min(scaleX, scaleY);
}
export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
if (
Number.isFinite(durationSeconds) &&
durationSeconds != null &&
durationSeconds > 0 &&
durationSeconds < THUMBNAIL_SEEK_TIME_SECONDS
) {
return durationSeconds / 2;
}
return THUMBNAIL_SEEK_TIME_SECONDS;
}
function parsePositiveNumber(value: string | null): number | null {
if (value == null) return null;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function resolveIframeDuration(iframe: HTMLIFrameElement | null): number | null {
const win = iframe?.contentWindow as PreviewWindow | null;
const playerDuration = win?.__player?.getDuration?.();
if (Number.isFinite(playerDuration) && playerDuration != null && playerDuration > 0) {
return playerDuration;
}
const doc = iframe?.contentDocument;
const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null;
return (
parsePositiveNumber(root?.getAttribute("data-composition-duration") ?? null) ??
parsePositiveNumber(root?.getAttribute("data-duration") ?? null)
);
}
function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay: boolean): boolean {
const player = (iframe?.contentWindow as PreviewWindow | null)?.__player;
if (!player) return false;
if (shouldPlay) {
player.play?.();
return true;
}
player.pause?.();
player.seek?.(resolveThumbnailSeekTime(resolveIframeDuration(iframe)));
return true;
}
function CompCard({
projectId,
comp,
@@ -41,7 +100,25 @@ function CompCard({
}) {
const [hovered, setHovered] = useState(false);
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
if (syncTimer.current) {
clearTimeout(syncTimer.current);
syncTimer.current = null;
}
const sync = (remainingAttempts: number) => {
if (syncIframePlayback(iframeRef.current, shouldPlay) || remainingAttempts <= 0) return;
syncTimer.current = setTimeout(() => sync(remainingAttempts - 1), 100);
};
sync(THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS);
}, []);
const handleEnter = () => {
hoverTimer.current = setTimeout(() => setHovered(true), 300);
};
@@ -53,7 +130,6 @@ function CompCard({
setHovered(false);
};
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
const previewScale = resolveCompositionPreviewScale({
cardWidth: 80,
@@ -62,6 +138,17 @@ function CompCard({
stageHeight: stageSize.height,
});
useEffect(() => {
requestIframePlaybackSync(hovered);
}, [hovered, requestIframePlaybackSync]);
useEffect(() => {
return () => {
if (hoverTimer.current) clearTimeout(hoverTimer.current);
if (syncTimer.current) clearTimeout(syncTimer.current);
};
}, []);
return (
<div
onClick={onSelect}
@@ -74,49 +161,34 @@ function CompCard({
}`}
>
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
{/* Live iframe preview on hover */}
{hovered && (
<iframe
src={previewUrl}
sandbox="allow-scripts allow-same-origin"
className="absolute left-0 top-0 border-none pointer-events-none"
style={{
transformOrigin: "0 0",
width: stageSize.width,
height: stageSize.height,
transform: `scale(${previewScale})`,
}}
onLoad={(e) => {
try {
const iframe = e.currentTarget;
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
const width =
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
const height =
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
setStageSize({ width, height });
} catch {
setStageSize(DEFAULT_PREVIEW_STAGE);
}
}}
tabIndex={-1}
/>
)}
{/* Static thumbnail — hidden while hovering */}
<div
className="absolute inset-0 transition-opacity duration-150"
style={{ opacity: hovered ? 0 : 1 }}
>
<img
src={thumbnailUrl}
alt={name}
loading="lazy"
className="w-full h-full object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
<iframe
ref={iframeRef}
src={previewUrl}
sandbox="allow-scripts allow-same-origin"
loading="lazy"
className="absolute left-0 top-0 border-none pointer-events-none"
style={{
transformOrigin: "0 0",
width: stageSize.width,
height: stageSize.height,
transform: `scale(${previewScale})`,
}}
onLoad={(e) => {
try {
const iframe = e.currentTarget;
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
const width = Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
const height =
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
setStageSize({ width, height });
requestIframePlaybackSync(hovered);
} catch {
setStageSize(DEFAULT_PREVIEW_STAGE);
}
}}
title={`${name} preview`}
tabIndex={-1}
/>
</div>
<div className="min-w-0 flex-1">
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
@@ -90,64 +90,71 @@ export const LeftSidebar = memo(function LeftSidebar({
style={{ width }}
>
{/* Tabs — Code first */}
<div className="flex border-b border-neutral-800/50 flex-shrink-0">
<button
type="button"
onClick={() => selectTab("code")}
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
tab === "code"
? "text-neutral-200 border-b-2 border-studio-accent"
: "text-neutral-500 hover:text-neutral-400"
}`}
>
Code
</button>
<button
type="button"
onClick={() => selectTab("compositions")}
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
tab === "compositions"
? "text-neutral-200 border-b-2 border-studio-accent"
: "text-neutral-500 hover:text-neutral-400"
}`}
>
Compositions
</button>
<button
type="button"
onClick={() => selectTab("assets")}
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
tab === "assets"
? "text-neutral-200 border-b-2 border-studio-accent"
: "text-neutral-500 hover:text-neutral-400"
}`}
>
Assets
</button>
{onToggleCollapse && (
<button
type="button"
onClick={onToggleCollapse}
className="mx-1 my-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
title="Hide sidebar"
aria-label="Hide sidebar"
<div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
<div className="flex items-center gap-2">
<div
className="grid min-w-0 flex-1 gap-1 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
style={{ gridTemplateColumns: "0.9fr 1.25fr 0.9fr" }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
<button
type="button"
onClick={() => selectTab("code")}
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
tab === "code"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:text-neutral-200"
}`}
>
<path d="m14 7-5 5 5 5" />
<path d="M19 4v16" />
</svg>
</button>
)}
Code
</button>
<button
type="button"
onClick={() => selectTab("compositions")}
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
tab === "compositions"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:text-neutral-200"
}`}
>
Compositions
</button>
<button
type="button"
onClick={() => selectTab("assets")}
className={`rounded-[14px] px-2.5 py-2 text-[10px] font-semibold transition-all ${
tab === "assets"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:text-neutral-200"
}`}
>
Assets
</button>
</div>
{onToggleCollapse && (
<button
type="button"
onClick={onToggleCollapse}
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
title="Hide sidebar"
aria-label="Hide sidebar"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m14 7-5 5 5 5" />
<path d="M19 4v16" />
</svg>
</button>
)}
</div>
</div>
{/* Tab content */}