mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat: add Studio current-frame capture (#565)
## Problem Closes #555. Studio users could inspect the preview, but there was no first-class way to capture the current rendered frame as an image. ## What this fixes - Adds a `Capture` action to the Studio header toolbar so it does not cover the video preview. - Downloads the current composition frame as a PNG using the current player time. - Extends the existing thumbnail route and Studio/CLI thumbnail generators with an explicit PNG format path while preserving JPEG thumbnails for existing previews. - Adds URL/filename utility coverage plus thumbnail route coverage for PNG requests. ## Root cause Studio already had frame thumbnail generation, but the API path was JPEG-oriented and the editor UI only used it for previews. There was no current-frame capture affordance wired to the player state. ## Verification ### Local - `bun run --filter @hyperframes/core test src/studio-api/routes/thumbnail.test.ts` - `bun run --filter @hyperframes/studio test src/utils/frameCapture.test.ts src/player/components/PlayerControls.test.ts` - `bun run --filter @hyperframes/studio typecheck` - `bun run --filter @hyperframes/core typecheck` - `bun run --filter @hyperframes/cli typecheck` - `bunx oxlint packages/cli/src/server/studioServer.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/types.ts packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/vite.config.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/utils/frameCapture.test.ts` - `bunx oxfmt --check packages/cli/src/server/studioServer.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/types.ts packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/vite.config.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/utils/frameCapture.test.ts` - `git diff --check` ### Browser <img width="1027" height="910" alt="image" src="https://github.com/user-attachments/assets/71973af4-0279-4074-9 <img width="1026" height="902" alt="Screenshot 2026-04-29 at 16 17 22" src="https://github.com/user-attachments/assets/a32e1c19-b793-40b9-82f8-de8bbb11f123" /> 060-130839a2d419" />
This commit is contained in:
@@ -278,11 +278,18 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
|||||||
};
|
};
|
||||||
}, opts.selector);
|
}, opts.selector);
|
||||||
}
|
}
|
||||||
const screenshot = (await page.screenshot({
|
const screenshot = (await page.screenshot(
|
||||||
type: "jpeg",
|
opts.format === "png"
|
||||||
quality: 80,
|
? {
|
||||||
...(clip ? { clip } : {}),
|
type: "png",
|
||||||
})) as Buffer;
|
...(clip ? { clip } : {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "jpeg",
|
||||||
|
quality: 80,
|
||||||
|
...(clip ? { clip } : {}),
|
||||||
|
},
|
||||||
|
)) as Buffer;
|
||||||
return screenshot;
|
return screenshot;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -51,6 +51,27 @@ describe("registerThumbnailRoutes", () => {
|
|||||||
compPath: "index.html",
|
compPath: "index.html",
|
||||||
seekTime: 1.2,
|
seekTime: 1.2,
|
||||||
selector: "#title-card",
|
selector: "#title-card",
|
||||||
|
format: "jpeg",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards png capture requests and returns a png content type", async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const app = new Hono();
|
||||||
|
registerThumbnailRoutes(app, adapter);
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
"http://localhost/projects/demo/thumbnail/compositions%2Fintro.html?t=2&format=png",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("image/png");
|
||||||
|
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
compPath: "compositions/intro.html",
|
||||||
|
seekTime: 2,
|
||||||
|
format: "png",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
|||||||
const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0;
|
const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0;
|
||||||
const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
|
const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
|
||||||
const selector = url.searchParams.get("selector") || undefined;
|
const selector = url.searchParams.get("selector") || undefined;
|
||||||
|
const format = url.searchParams.get("format") === "png" ? "png" : "jpeg";
|
||||||
|
const contentType = format === "png" ? "image/png" : "image/jpeg";
|
||||||
|
|
||||||
// Determine composition dimensions from HTML
|
// Determine composition dimensions from HTML
|
||||||
let compW = vpWidth || 1920;
|
let compW = vpWidth || 1920;
|
||||||
@@ -48,11 +50,11 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
|||||||
const selectorKey = selector
|
const selectorKey = selector
|
||||||
? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}`
|
? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}`
|
||||||
: "";
|
: "";
|
||||||
const cacheKey = `${THUMBNAIL_CACHE_VERSION}_${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}${selectorKey}.jpg`;
|
const cacheKey = `${THUMBNAIL_CACHE_VERSION}_${format}_${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
|
||||||
const cachePath = join(cacheDir, cacheKey);
|
const cachePath = join(cacheDir, cacheKey);
|
||||||
if (existsSync(cachePath)) {
|
if (existsSync(cachePath)) {
|
||||||
return new Response(new Uint8Array(readFileSync(cachePath)), {
|
return new Response(new Uint8Array(readFileSync(cachePath)), {
|
||||||
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=60" },
|
headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=60" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
|||||||
height: compH,
|
height: compH,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
selector,
|
selector,
|
||||||
|
format,
|
||||||
});
|
});
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
return c.json({ error: "Thumbnail generation returned null" }, 500);
|
return c.json({ error: "Thumbnail generation returned null" }, 500);
|
||||||
@@ -72,7 +75,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
|||||||
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
||||||
writeFileSync(cachePath, buffer);
|
writeFileSync(cachePath, buffer);
|
||||||
return new Response(new Uint8Array(buffer), {
|
return new Response(new Uint8Array(buffer), {
|
||||||
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=60" },
|
headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=60" },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface StudioApiAdapter {
|
|||||||
height: number;
|
height: number;
|
||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
selector?: string;
|
selector?: string;
|
||||||
|
format?: "jpeg" | "png";
|
||||||
}) => Promise<Buffer | null>;
|
}) => Promise<Buffer | null>;
|
||||||
|
|
||||||
/** Optional: resolve session ID to project (multi-project mode). */
|
/** Optional: resolve session ID to project (multi-project mode). */
|
||||||
|
|||||||
+130
-51
@@ -1,11 +1,19 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
|
import {
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { useMountEffect } from "./hooks/useMountEffect";
|
import { useMountEffect } from "./hooks/useMountEffect";
|
||||||
import { NLELayout } from "./components/nle/NLELayout";
|
import { NLELayout } from "./components/nle/NLELayout";
|
||||||
import { SourceEditor } from "./components/editor/SourceEditor";
|
import { SourceEditor } from "./components/editor/SourceEditor";
|
||||||
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
||||||
import { RenderQueue } from "./components/renders/RenderQueue";
|
import { RenderQueue } from "./components/renders/RenderQueue";
|
||||||
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
||||||
import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
|
import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
|
||||||
import { AudioWaveform } from "./player/components/AudioWaveform";
|
import { AudioWaveform } from "./player/components/AudioWaveform";
|
||||||
import type { TimelineElement } from "./player";
|
import type { TimelineElement } from "./player";
|
||||||
import { LintModal } from "./components/LintModal";
|
import { LintModal } from "./components/LintModal";
|
||||||
@@ -40,6 +48,8 @@ import {
|
|||||||
getTimelineToggleTitle,
|
getTimelineToggleTitle,
|
||||||
shouldHandleTimelineToggleHotkey,
|
shouldHandleTimelineToggleHotkey,
|
||||||
} from "./utils/timelineDiscovery";
|
} from "./utils/timelineDiscovery";
|
||||||
|
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
|
||||||
|
import { Camera } from "./icons/SystemIcons";
|
||||||
|
|
||||||
interface EditingFile {
|
interface EditingFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -264,6 +274,7 @@ export function StudioApp() {
|
|||||||
const [globalDragOver, setGlobalDragOver] = useState(false);
|
const [globalDragOver, setGlobalDragOver] = useState(false);
|
||||||
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
||||||
const [timelineVisible, setTimelineVisible] = useState(true);
|
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||||
|
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastBlockedTimelineToastAtRef = useRef(0);
|
const lastBlockedTimelineToastAtRef = useRef(0);
|
||||||
@@ -298,6 +309,26 @@ export function StudioApp() {
|
|||||||
const toggleTimelineVisibility = useCallback(() => {
|
const toggleTimelineVisibility = useCallback(() => {
|
||||||
setTimelineVisible((visible) => !visible);
|
setTimelineVisible((visible) => !visible);
|
||||||
}, []);
|
}, []);
|
||||||
|
const toggleLeftSidebar = useCallback(() => {
|
||||||
|
setLeftCollapsed((collapsed) => !collapsed);
|
||||||
|
}, []);
|
||||||
|
const refreshCaptureFrameTime = useCallback(() => {
|
||||||
|
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useMountEffect(() => {
|
||||||
|
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
||||||
|
return liveTime.subscribe(setCaptureFrameTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
const captureFrameHref = projectId
|
||||||
|
? buildFrameCaptureUrl({
|
||||||
|
projectId,
|
||||||
|
compositionPath: activeCompPath,
|
||||||
|
currentTime: captureFrameTime,
|
||||||
|
})
|
||||||
|
: "#";
|
||||||
|
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
||||||
useMountEffect(() => () => {
|
useMountEffect(() => () => {
|
||||||
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
||||||
});
|
});
|
||||||
@@ -496,6 +527,28 @@ export function StudioApp() {
|
|||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTimelineVisibility}
|
||||||
|
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
|
||||||
|
title={getTimelineToggleTitle(true)}
|
||||||
|
aria-label="Hide timeline editor"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 7h14" />
|
||||||
|
<path d="m8 11 4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -787,6 +840,42 @@ export function StudioApp() {
|
|||||||
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCaptureFrameClick = useCallback(
|
||||||
|
async (event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const currentTime = usePlayerStore.getState().currentTime;
|
||||||
|
setCaptureFrameTime(currentTime);
|
||||||
|
const href = buildFrameCaptureUrl({
|
||||||
|
projectId,
|
||||||
|
compositionPath: activeCompPath,
|
||||||
|
currentTime,
|
||||||
|
});
|
||||||
|
const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(href, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Capture failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Capture failed";
|
||||||
|
showToast(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeCompPath, projectId, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTimelineElementDelete = useCallback(
|
const handleTimelineElementDelete = useCallback(
|
||||||
async (element: TimelineElement) => {
|
async (element: TimelineElement) => {
|
||||||
const pid = projectIdRef.current;
|
const pid = projectIdRef.current;
|
||||||
@@ -1345,55 +1434,19 @@ export function StudioApp() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Right: toolbar buttons */}
|
{/* Right: toolbar buttons */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<a
|
||||||
onClick={() => setLeftCollapsed((v) => !v)}
|
href={captureFrameHref}
|
||||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
download={captureFrameFilename}
|
||||||
!leftCollapsed
|
onClick={handleCaptureFrameClick}
|
||||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
onFocus={refreshCaptureFrameTime}
|
||||||
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
onPointerDown={refreshCaptureFrameTime}
|
||||||
}`}
|
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
||||||
title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
|
title="Capture current frame"
|
||||||
|
aria-label="Capture current frame"
|
||||||
>
|
>
|
||||||
<svg
|
<Camera size={14} />
|
||||||
width="14"
|
<span>Capture</span>
|
||||||
height="14"
|
</a>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
||||||
<path d="M9 3v18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleTimelineVisibility}
|
|
||||||
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
|
||||||
timelineVisible
|
|
||||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
||||||
: "text-neutral-300 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-800"
|
|
||||||
}`}
|
|
||||||
title={getTimelineToggleTitle(timelineVisible)}
|
|
||||||
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
>
|
|
||||||
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
||||||
<line x1="3" y1="9" x2="21" y2="9" />
|
|
||||||
<line x1="3" y1="5" x2="21" y2="5" />
|
|
||||||
</svg>
|
|
||||||
<span>Timeline</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setRightCollapsed((v) => !v)}
|
onClick={() => setRightCollapsed((v) => !v)}
|
||||||
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
||||||
@@ -1422,7 +1475,32 @@ export function StudioApp() {
|
|||||||
{/* Main content: sidebar + preview + right panel */}
|
{/* Main content: sidebar + preview + right panel */}
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
{/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
|
{/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
|
||||||
{!leftCollapsed && (
|
{leftCollapsed ? (
|
||||||
|
<div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleLeftSidebar}
|
||||||
|
className="flex h-8 w-8 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="Show sidebar"
|
||||||
|
aria-label="Show 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="M5 4v16" />
|
||||||
|
<path d="m10 7 5 5-5 5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
width={leftWidth}
|
width={leftWidth}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@@ -1469,6 +1547,7 @@ export function StudioApp() {
|
|||||||
}
|
}
|
||||||
onLint={handleLint}
|
onLint={handleLint}
|
||||||
linting={linting}
|
linting={linting}
|
||||||
|
onToggleCollapse={toggleLeftSidebar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
|
|||||||
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
||||||
import { NLEPreview } from "./NLEPreview";
|
import { NLEPreview } from "./NLEPreview";
|
||||||
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
||||||
|
import {
|
||||||
|
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
||||||
|
getTimelineToggleTitle,
|
||||||
|
} from "../../utils/timelineDiscovery";
|
||||||
|
|
||||||
interface NLELayoutProps {
|
interface NLELayoutProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -197,6 +201,7 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
|
|
||||||
// Resizable timeline height
|
// Resizable timeline height
|
||||||
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
||||||
|
const isTimelineVisible = timelineVisible ?? true;
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -366,16 +371,11 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
onNavigate={handleNavigateComposition}
|
onNavigate={handleNavigateComposition}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PlayerControls
|
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
||||||
onTogglePlay={togglePlay}
|
|
||||||
onSeek={seek}
|
|
||||||
timelineVisible={timelineVisible ?? true}
|
|
||||||
onToggleTimeline={onToggleTimeline}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(timelineVisible ?? true) && (
|
{isTimelineVisible ? (
|
||||||
<>
|
<>
|
||||||
{/* Resize divider */}
|
{/* Resize divider */}
|
||||||
<div
|
<div
|
||||||
@@ -417,7 +417,42 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
) : onToggleTimeline ? (
|
||||||
|
<div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
|
||||||
|
<div className="flex h-10 items-center justify-between px-3">
|
||||||
|
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
||||||
|
Timeline
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleTimeline}
|
||||||
|
className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
|
||||||
|
title={getTimelineToggleTitle(false)}
|
||||||
|
aria-label="Show timeline editor"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="13" width="18" height="8" rx="1" />
|
||||||
|
<path d="M7 9h10" />
|
||||||
|
<path d="M8 5h8" />
|
||||||
|
</svg>
|
||||||
|
<span>Show</span>
|
||||||
|
<span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
|
||||||
|
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface LeftSidebarProps {
|
|||||||
codeChildren?: ReactNode;
|
codeChildren?: ReactNode;
|
||||||
onLint?: () => void;
|
onLint?: () => void;
|
||||||
linting?: boolean;
|
linting?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeftSidebar = memo(function LeftSidebar({
|
export const LeftSidebar = memo(function LeftSidebar({
|
||||||
@@ -57,6 +58,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
codeChildren,
|
codeChildren,
|
||||||
onLint,
|
onLint,
|
||||||
linting,
|
linting,
|
||||||
|
onToggleCollapse,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
||||||
|
|
||||||
@@ -122,6 +124,30 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
>
|
>
|
||||||
Assets
|
Assets
|
||||||
</button>
|
</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"
|
||||||
|
>
|
||||||
|
<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 */}
|
{/* Tab content */}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
CaretRight,
|
CaretRight,
|
||||||
ClipboardText,
|
ClipboardText,
|
||||||
ArrowCounterClockwise,
|
ArrowCounterClockwise,
|
||||||
|
Camera as PhCamera,
|
||||||
Gear,
|
Gear,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
||||||
@@ -127,4 +128,5 @@ export const ChevronDown = makeIcon(CaretDown);
|
|||||||
export const ChevronRight = makeIcon(CaretRight);
|
export const ChevronRight = makeIcon(CaretRight);
|
||||||
export const ClipboardList = makeIcon(ClipboardText);
|
export const ClipboardList = makeIcon(ClipboardText);
|
||||||
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
||||||
|
export const Camera = makeIcon(PhCamera);
|
||||||
export const Settings = makeIcon(Gear);
|
export const Settings = makeIcon(Gear);
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
||||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||||
import {
|
|
||||||
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
||||||
getTimelineToggleTitle,
|
|
||||||
} from "../../utils/timelineDiscovery";
|
|
||||||
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
|
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
|
||||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||||
|
|
||||||
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
|
|||||||
interface PlayerControlsProps {
|
interface PlayerControlsProps {
|
||||||
onTogglePlay: () => void;
|
onTogglePlay: () => void;
|
||||||
onSeek: (time: number) => void;
|
onSeek: (time: number) => void;
|
||||||
timelineVisible?: boolean;
|
|
||||||
onToggleTimeline?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerControls = memo(function PlayerControls({
|
export const PlayerControls = memo(function PlayerControls({
|
||||||
onTogglePlay,
|
onTogglePlay,
|
||||||
onSeek,
|
onSeek,
|
||||||
timelineVisible,
|
|
||||||
onToggleTimeline,
|
|
||||||
}: PlayerControlsProps) {
|
}: PlayerControlsProps) {
|
||||||
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
||||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||||
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline toggle */}
|
|
||||||
{onToggleTimeline !== undefined && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleTimeline}
|
|
||||||
className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
|
|
||||||
timelineVisible
|
|
||||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
||||||
: "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
||||||
}`}
|
|
||||||
title={getTimelineToggleTitle(Boolean(timelineVisible))}
|
|
||||||
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
>
|
|
||||||
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
||||||
<line x1="3" y1="9" x2="21" y2="9" />
|
|
||||||
<line x1="3" y1="5" x2="21" y2="5" />
|
|
||||||
</svg>
|
|
||||||
<span>Timeline</span>
|
|
||||||
<span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
|
|
||||||
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
|
|||||||
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
|
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
|
||||||
|
|
||||||
export const defaultTimelineTheme: TimelineTheme = {
|
export const defaultTimelineTheme: TimelineTheme = {
|
||||||
shellBackground: "#0A0E15",
|
shellBackground: "#0A0A0B",
|
||||||
shellBorder: "rgba(255,255,255,0.05)",
|
shellBorder: "rgba(255,255,255,0.05)",
|
||||||
rulerBorder: "rgba(255,255,255,0.045)",
|
rulerBorder: "rgba(255,255,255,0.045)",
|
||||||
rowBackground: "#0A0E15",
|
rowBackground: "#0A0A0B",
|
||||||
rowBorder: "rgba(255,255,255,0.05)",
|
rowBorder: "rgba(255,255,255,0.05)",
|
||||||
gutterBackground: "#0D121B",
|
gutterBackground: "#0A0A0B",
|
||||||
gutterBorder: "rgba(255,255,255,0.05)",
|
gutterBorder: "rgba(255,255,255,0.05)",
|
||||||
textPrimary: "#E8EDF5",
|
textPrimary: "#E8EDF5",
|
||||||
textSecondary: "#8391A8",
|
textSecondary: "#8391A8",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./frameCapture";
|
||||||
|
|
||||||
|
describe("frame capture utilities", () => {
|
||||||
|
it("builds a PNG capture URL for the master composition", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-29T12:00:00Z"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildFrameCaptureUrl({
|
||||||
|
projectId: "demo project",
|
||||||
|
compositionPath: null,
|
||||||
|
currentTime: 1.23456,
|
||||||
|
origin: "http://localhost:5194",
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000",
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a safe filename from a nested composition path", () => {
|
||||||
|
expect(buildFrameCaptureFilename("compositions/intro.html", 2.5)).toBe("intro-2-500s.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export interface FrameCaptureRequest {
|
||||||
|
projectId: string;
|
||||||
|
compositionPath: string | null;
|
||||||
|
currentTime: number;
|
||||||
|
origin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCompositionPath(compositionPath: string | null): string {
|
||||||
|
return compositionPath && compositionPath !== "master" ? compositionPath : "index.html";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFrameCaptureUrl({
|
||||||
|
projectId,
|
||||||
|
compositionPath,
|
||||||
|
currentTime,
|
||||||
|
origin = window.location.origin,
|
||||||
|
}: FrameCaptureRequest): string {
|
||||||
|
const compPath = normalizeCompositionPath(compositionPath);
|
||||||
|
const url = new URL(
|
||||||
|
`/api/projects/${encodeURIComponent(projectId)}/thumbnail/${encodeURIComponent(compPath)}`,
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
|
||||||
|
url.searchParams.set("format", "png");
|
||||||
|
url.searchParams.set("v", String(Date.now()));
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFrameCaptureFilename(compositionPath: string | null, currentTime: number) {
|
||||||
|
const compPath = normalizeCompositionPath(compositionPath);
|
||||||
|
const base =
|
||||||
|
compPath
|
||||||
|
.split("/")
|
||||||
|
.pop()
|
||||||
|
?.replace(/\.html$/i, "") || "frame";
|
||||||
|
const frameTime = Math.max(0, currentTime).toFixed(3).replace(".", "-");
|
||||||
|
return `${base}-${frameTime}s.png`;
|
||||||
|
}
|
||||||
@@ -250,7 +250,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
|||||||
await page.setViewport({
|
await page.setViewport({
|
||||||
width: opts.width,
|
width: opts.width,
|
||||||
height: opts.height,
|
height: opts.height,
|
||||||
deviceScaleFactor: 0.5,
|
deviceScaleFactor: opts.format === "png" ? 1 : 0.5,
|
||||||
});
|
});
|
||||||
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -307,11 +307,18 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
|||||||
};
|
};
|
||||||
}, opts.selector);
|
}, opts.selector);
|
||||||
}
|
}
|
||||||
const buf = await page.screenshot({
|
const buf = await page.screenshot(
|
||||||
type: "jpeg",
|
opts.format === "png"
|
||||||
quality: 75,
|
? {
|
||||||
...(clip ? { clip } : {}),
|
type: "png",
|
||||||
});
|
...(clip ? { clip } : {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "jpeg",
|
||||||
|
quality: 75,
|
||||||
|
...(clip ? { clip } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
await page.close();
|
await page.close();
|
||||||
return buf as Buffer;
|
return buf as Buffer;
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user