mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
## 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 ```
176 lines
4.9 KiB
TypeScript
176 lines
4.9 KiB
TypeScript
import { memo, useCallback, useState, useRef } from "react";
|
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
|
interface CompositionThumbnailProps {
|
|
previewUrl: string;
|
|
label: string;
|
|
labelColor: string;
|
|
accentColor?: string;
|
|
selector?: string;
|
|
selectorIndex?: number;
|
|
seekTime?: number;
|
|
duration?: number;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
const CLIP_HEIGHT = 66;
|
|
const THUMBNAIL_URL_VERSION = "v3";
|
|
|
|
export function buildCompositionThumbnailUrl({
|
|
previewUrl,
|
|
seekTime = 2,
|
|
duration = 5,
|
|
selector,
|
|
selectorIndex,
|
|
origin,
|
|
}: {
|
|
previewUrl: string;
|
|
seekTime?: number;
|
|
duration?: number;
|
|
selector?: string;
|
|
selectorIndex?: number;
|
|
origin: string;
|
|
}): string {
|
|
const thumbnailBase = previewUrl
|
|
.replace("/preview/comp/", "/thumbnail/")
|
|
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
const midTime = seekTime + duration / 2;
|
|
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
|
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
if (selector) {
|
|
thumbnailUrl.searchParams.set("selector", selector);
|
|
if (selectorIndex != null && selectorIndex > 0) {
|
|
thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
|
|
}
|
|
}
|
|
return thumbnailUrl.toString();
|
|
}
|
|
|
|
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
previewUrl,
|
|
label,
|
|
labelColor,
|
|
accentColor = "#6B7280",
|
|
selector,
|
|
selectorIndex,
|
|
seekTime = 2,
|
|
duration = 5,
|
|
}: CompositionThumbnailProps) {
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [aspect, setAspect] = useState(16 / 9);
|
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
|
|
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
|
roRef.current?.disconnect();
|
|
if (!el) return;
|
|
|
|
const measured = el.parentElement?.clientWidth || el.clientWidth;
|
|
setContainerWidth(measured);
|
|
|
|
const target = el.parentElement || el;
|
|
roRef.current = new ResizeObserver(([entry]) => {
|
|
setContainerWidth(entry.contentRect.width);
|
|
});
|
|
roRef.current.observe(target);
|
|
}, []);
|
|
|
|
useMountEffect(() => () => {
|
|
roRef.current?.disconnect();
|
|
});
|
|
|
|
const url = buildCompositionThumbnailUrl({
|
|
previewUrl,
|
|
seekTime,
|
|
duration,
|
|
selector,
|
|
selectorIndex,
|
|
origin: window.location.origin,
|
|
});
|
|
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
|
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
|
|
return (
|
|
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
<img
|
|
src={url}
|
|
alt=""
|
|
draggable={false}
|
|
loading="eager"
|
|
onLoad={(e) => {
|
|
const img = e.currentTarget;
|
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
setAspect(img.naturalWidth / img.naturalHeight);
|
|
}
|
|
setLoaded(true);
|
|
}}
|
|
className="hidden"
|
|
/>
|
|
|
|
{loaded ? (
|
|
<div className="absolute inset-0 flex">
|
|
{Array.from({ length: frameCount }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="relative h-full flex-shrink-0 overflow-hidden"
|
|
style={{ width: frameW }}
|
|
>
|
|
<img
|
|
src={url}
|
|
alt=""
|
|
draggable={false}
|
|
className="absolute inset-0 h-full w-full object-cover opacity-60"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="absolute inset-0 animate-pulse"
|
|
style={{
|
|
background:
|
|
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
background: `linear-gradient(120deg, ${accentColor}2e, transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08))`,
|
|
}}
|
|
/>
|
|
|
|
<div className="absolute left-2 top-2 z-10">
|
|
<span
|
|
className="block max-w-full truncate rounded-md px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none"
|
|
style={{
|
|
color: labelColor,
|
|
background: `${accentColor}2e`,
|
|
boxShadow: `inset 0 0 0 1px ${accentColor}40`,
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
}}
|
|
>
|
|
<span
|
|
className="block truncate text-[9px] font-semibold leading-tight"
|
|
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
|
>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|