mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
refactor(studio): update layout, config, and remove agent activity tracking (#64)
## Summary - **NLELayout**: Add toolbar slot, composition breadcrumb navigation, improved responsive layout - **Vite config**: Add full project API (preview, thumbnail, render, file CRUD) for standalone dev mode - Remove AgentActivityTrack component (replaced by timeline clips) - Add HTML editor utilities for composition source editing - Guard setInterval cleanup to dev-only to prevent `vite build` from hanging in CI 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -26,7 +26,9 @@ function getLanguageExtension(language: string) {
|
||||
case "js":
|
||||
case "typescript":
|
||||
case "ts":
|
||||
return javascript({ typescript: language === "typescript" || language === "ts" });
|
||||
return javascript({
|
||||
typescript: language === "typescript" || language === "ts",
|
||||
});
|
||||
default:
|
||||
return html();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
|
||||
import { useState, useCallback, useRef, memo, type ReactNode } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import { NLEPreview } from "./NLEPreview";
|
||||
@@ -9,7 +10,9 @@ interface NLELayoutProps {
|
||||
portrait?: boolean;
|
||||
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
|
||||
previewOverlay?: ReactNode;
|
||||
/** Slot rendered below the timeline tracks (e.g., agent activity swim lanes) */
|
||||
/** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */
|
||||
timelineToolbar?: ReactNode;
|
||||
/** Slot rendered below the timeline tracks */
|
||||
timelineFooter?: ReactNode;
|
||||
/** Increment to force the preview to reload (e.g., after file writes) */
|
||||
refreshKey?: number;
|
||||
@@ -17,6 +20,15 @@ interface NLELayoutProps {
|
||||
activeCompositionPath?: string | null;
|
||||
/** Callback to expose the iframe ref (for element picker, etc.) */
|
||||
onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
|
||||
/** Callback when the viewed composition changes (drill-down/back) */
|
||||
onCompositionChange?: (compositionPath: string | null) => void;
|
||||
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
|
||||
renderClipContent?: (
|
||||
element: TimelineElement,
|
||||
style: { clip: string; label: string },
|
||||
) => ReactNode;
|
||||
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
||||
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
||||
}
|
||||
|
||||
const MIN_TIMELINE_H = 100;
|
||||
@@ -27,10 +39,14 @@ export const NLELayout = memo(function NLELayout({
|
||||
projectId,
|
||||
portrait,
|
||||
previewOverlay,
|
||||
timelineToolbar,
|
||||
timelineFooter,
|
||||
refreshKey,
|
||||
activeCompositionPath,
|
||||
onIframeRef,
|
||||
onCompositionChange,
|
||||
renderClipContent,
|
||||
onCompIdToSrcChange,
|
||||
}: NLELayoutProps) {
|
||||
const {
|
||||
iframeRef,
|
||||
@@ -38,8 +54,17 @@ export const NLELayout = memo(function NLELayout({
|
||||
seek,
|
||||
onIframeLoad: baseOnIframeLoad,
|
||||
saveSeekPosition,
|
||||
resetPlayer,
|
||||
} = useTimelinePlayer();
|
||||
|
||||
// Reset timeline state when the project changes to prevent stale data from a
|
||||
// previous project leaking into the new one.
|
||||
const prevProjectIdRef = useRef<string | null>(null);
|
||||
if (prevProjectIdRef.current !== projectId) {
|
||||
prevProjectIdRef.current = projectId;
|
||||
resetPlayer();
|
||||
}
|
||||
|
||||
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
|
||||
const prevRefreshKeyRef = useRef(refreshKey);
|
||||
if (refreshKey !== prevRefreshKeyRef.current) {
|
||||
@@ -55,7 +80,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
|
||||
// Composition ID → actual file path mapping, built from the raw index.html
|
||||
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/files/index.html`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { content?: string }) => {
|
||||
@@ -70,15 +95,28 @@ export const NLELayout = memo(function NLELayout({
|
||||
if (id && src) map.set(id, src);
|
||||
}
|
||||
setCompIdToSrc(map);
|
||||
onCompIdToSrcChange?.(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [projectId]);
|
||||
});
|
||||
|
||||
// Composition drill-down stack
|
||||
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
|
||||
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
||||
]);
|
||||
|
||||
// Wrap setCompositionStack to auto-notify parent on composition change
|
||||
const onCompositionChangeRef = useRef(onCompositionChange);
|
||||
onCompositionChangeRef.current = onCompositionChange;
|
||||
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
|
||||
setCompositionStack((prev) => {
|
||||
const next = typeof action === "function" ? action(prev) : action;
|
||||
const id = next[next.length - 1]?.id;
|
||||
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Resizable timeline height
|
||||
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
||||
const isDragging = useRef(false);
|
||||
@@ -126,7 +164,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
usePlayerStore.getState().setElements([]);
|
||||
|
||||
// Toggle: if already viewing this composition, go back to parent (like Premiere)
|
||||
setCompositionStack((prev) => {
|
||||
updateCompositionStack((prev) => {
|
||||
const currentId = prev[prev.length - 1].id;
|
||||
if (currentId === resolvedPath && prev.length > 1) {
|
||||
return prev.slice(0, -1);
|
||||
@@ -141,14 +179,15 @@ export const NLELayout = memo(function NLELayout({
|
||||
return [...prev, { id: resolvedPath, label, previewUrl }];
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef_ is a stable ref; .current mutates and should not be a dep
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[projectId, compIdToSrc],
|
||||
);
|
||||
|
||||
// Navigate back to a specific breadcrumb level
|
||||
const handleNavigateComposition = useCallback((index: number) => {
|
||||
usePlayerStore.getState().setElements([]);
|
||||
setCompositionStack((prev) => prev.slice(0, index + 1));
|
||||
updateCompositionStack((prev) => prev.slice(0, index + 1));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Navigate to a composition when activeCompositionPath changes
|
||||
@@ -157,11 +196,11 @@ export const NLELayout = memo(function NLELayout({
|
||||
prevActiveCompRef.current = activeCompositionPath;
|
||||
queueMicrotask(() => usePlayerStore.getState().setElements([]));
|
||||
if (activeCompositionPath === "index.html") {
|
||||
setCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
|
||||
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
|
||||
} else if (activeCompositionPath.startsWith("compositions/")) {
|
||||
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
|
||||
setCompositionStack((prev) => {
|
||||
updateCompositionStack((prev) => {
|
||||
if (prev[prev.length - 1].id === activeCompositionPath) return prev;
|
||||
return [
|
||||
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
||||
@@ -201,9 +240,10 @@ export const NLELayout = memo(function NLELayout({
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape" && compositionStack.length > 1) {
|
||||
setCompositionStack((prev) => prev.slice(0, -1));
|
||||
updateCompositionStack((prev) => prev.slice(0, -1));
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[compositionStack.length],
|
||||
);
|
||||
|
||||
@@ -255,11 +295,16 @@ export const NLELayout = memo(function NLELayout({
|
||||
onDoubleClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
if (compositionStack.length > 1) {
|
||||
setCompositionStack((prev) => prev.slice(0, -1));
|
||||
updateCompositionStack((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Timeline onSeek={seek} onDrillDown={handleDrillDown} />
|
||||
{timelineToolbar}
|
||||
<Timeline
|
||||
onSeek={seek}
|
||||
onDrillDown={handleDrillDown}
|
||||
renderClipContent={renderClipContent}
|
||||
/>
|
||||
{timelineFooter}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user