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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user