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);
|
||||
}
|
||||
const screenshot = (await page.screenshot({
|
||||
type: "jpeg",
|
||||
quality: 80,
|
||||
...(clip ? { clip } : {}),
|
||||
})) as Buffer;
|
||||
const screenshot = (await page.screenshot(
|
||||
opts.format === "png"
|
||||
? {
|
||||
type: "png",
|
||||
...(clip ? { clip } : {}),
|
||||
}
|
||||
: {
|
||||
type: "jpeg",
|
||||
quality: 80,
|
||||
...(clip ? { clip } : {}),
|
||||
},
|
||||
)) as Buffer;
|
||||
return screenshot;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -51,6 +51,27 @@ describe("registerThumbnailRoutes", () => {
|
||||
compPath: "index.html",
|
||||
seekTime: 1.2,
|
||||
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 vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
|
||||
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
|
||||
let compW = vpWidth || 1920;
|
||||
@@ -48,11 +50,11 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
||||
const selectorKey = selector
|
||||
? `_${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);
|
||||
if (existsSync(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,
|
||||
previewUrl,
|
||||
selector,
|
||||
format,
|
||||
});
|
||||
if (!buffer) {
|
||||
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 });
|
||||
writeFileSync(cachePath, 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) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface StudioApiAdapter {
|
||||
height: number;
|
||||
previewUrl: string;
|
||||
selector?: string;
|
||||
format?: "jpeg" | "png";
|
||||
}) => Promise<Buffer | null>;
|
||||
|
||||
/** 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 { NLELayout } from "./components/nle/NLELayout";
|
||||
import { SourceEditor } from "./components/editor/SourceEditor";
|
||||
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
||||
import { RenderQueue } from "./components/renders/RenderQueue";
|
||||
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 type { TimelineElement } from "./player";
|
||||
import { LintModal } from "./components/LintModal";
|
||||
@@ -40,6 +48,8 @@ import {
|
||||
getTimelineToggleTitle,
|
||||
shouldHandleTimelineToggleHotkey,
|
||||
} from "./utils/timelineDiscovery";
|
||||
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
|
||||
import { Camera } from "./icons/SystemIcons";
|
||||
|
||||
interface EditingFile {
|
||||
path: string;
|
||||
@@ -264,6 +274,7 @@ export function StudioApp() {
|
||||
const [globalDragOver, setGlobalDragOver] = useState(false);
|
||||
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
||||
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
||||
const dragCounterRef = useRef(0);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastBlockedTimelineToastAtRef = useRef(0);
|
||||
@@ -298,6 +309,26 @@ export function StudioApp() {
|
||||
const toggleTimelineVisibility = useCallback(() => {
|
||||
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(() => () => {
|
||||
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
||||
});
|
||||
@@ -496,6 +527,28 @@ export function StudioApp() {
|
||||
>
|
||||
+
|
||||
</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>
|
||||
@@ -787,6 +840,42 @@ export function StudioApp() {
|
||||
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(
|
||||
async (element: TimelineElement) => {
|
||||
const pid = projectIdRef.current;
|
||||
@@ -1345,55 +1434,19 @@ export function StudioApp() {
|
||||
</div>
|
||||
{/* Right: toolbar buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setLeftCollapsed((v) => !v)}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
!leftCollapsed
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
||||
}`}
|
||||
title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
|
||||
<a
|
||||
href={captureFrameHref}
|
||||
download={captureFrameFilename}
|
||||
onClick={handleCaptureFrameClick}
|
||||
onFocus={refreshCaptureFrameTime}
|
||||
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="Capture current frame"
|
||||
aria-label="Capture current frame"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
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>
|
||||
<Camera size={14} />
|
||||
<span>Capture</span>
|
||||
</a>
|
||||
<button
|
||||
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 ${
|
||||
@@ -1422,7 +1475,32 @@ export function StudioApp() {
|
||||
{/* Main content: sidebar + preview + right panel */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* 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
|
||||
width={leftWidth}
|
||||
projectId={projectId}
|
||||
@@ -1469,6 +1547,7 @@ export function StudioApp() {
|
||||
}
|
||||
onLint={handleLint}
|
||||
linting={linting}
|
||||
onToggleCollapse={toggleLeftSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
|
||||
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
||||
import { NLEPreview } from "./NLEPreview";
|
||||
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
||||
import {
|
||||
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
||||
getTimelineToggleTitle,
|
||||
} from "../../utils/timelineDiscovery";
|
||||
|
||||
interface NLELayoutProps {
|
||||
projectId: string;
|
||||
@@ -197,6 +201,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
|
||||
// Resizable timeline height
|
||||
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
||||
const isTimelineVisible = timelineVisible ?? true;
|
||||
const isDragging = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -366,16 +371,11 @@ export const NLELayout = memo(function NLELayout({
|
||||
onNavigate={handleNavigateComposition}
|
||||
/>
|
||||
)}
|
||||
<PlayerControls
|
||||
onTogglePlay={togglePlay}
|
||||
onSeek={seek}
|
||||
timelineVisible={timelineVisible ?? true}
|
||||
onToggleTimeline={onToggleTimeline}
|
||||
/>
|
||||
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(timelineVisible ?? true) && (
|
||||
{isTimelineVisible ? (
|
||||
<>
|
||||
{/* Resize divider */}
|
||||
<div
|
||||
@@ -417,7 +417,42 @@ export const NLELayout = memo(function NLELayout({
|
||||
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ interface LeftSidebarProps {
|
||||
codeChildren?: ReactNode;
|
||||
onLint?: () => void;
|
||||
linting?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
@@ -57,6 +58,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
codeChildren,
|
||||
onLint,
|
||||
linting,
|
||||
onToggleCollapse,
|
||||
}: LeftSidebarProps) {
|
||||
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
||||
|
||||
@@ -122,6 +124,30 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
>
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Tab content */}
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
CaretRight,
|
||||
ClipboardText,
|
||||
ArrowCounterClockwise,
|
||||
Camera as PhCamera,
|
||||
Gear,
|
||||
} 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 ClipboardList = makeIcon(ClipboardText);
|
||||
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
||||
export const Camera = makeIcon(PhCamera);
|
||||
export const Settings = makeIcon(Gear);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import {
|
||||
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
||||
getTimelineToggleTitle,
|
||||
} from "../../utils/timelineDiscovery";
|
||||
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
|
||||
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
|
||||
interface PlayerControlsProps {
|
||||
onTogglePlay: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
timelineVisible?: boolean;
|
||||
onToggleTimeline?: () => void;
|
||||
}
|
||||
|
||||
export const PlayerControls = memo(function PlayerControls({
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
timelineVisible,
|
||||
onToggleTimeline,
|
||||
}: PlayerControlsProps) {
|
||||
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
|
||||
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
|
||||
|
||||
export const defaultTimelineTheme: TimelineTheme = {
|
||||
shellBackground: "#0A0E15",
|
||||
shellBackground: "#0A0A0B",
|
||||
shellBorder: "rgba(255,255,255,0.05)",
|
||||
rulerBorder: "rgba(255,255,255,0.045)",
|
||||
rowBackground: "#0A0E15",
|
||||
rowBackground: "#0A0A0B",
|
||||
rowBorder: "rgba(255,255,255,0.05)",
|
||||
gutterBackground: "#0D121B",
|
||||
gutterBackground: "#0A0A0B",
|
||||
gutterBorder: "rgba(255,255,255,0.05)",
|
||||
textPrimary: "#E8EDF5",
|
||||
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({
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
deviceScaleFactor: 0.5,
|
||||
deviceScaleFactor: opts.format === "png" ? 1 : 0.5,
|
||||
});
|
||||
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await page.evaluate(() => {
|
||||
@@ -307,11 +307,18 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
};
|
||||
}, opts.selector);
|
||||
}
|
||||
const buf = await page.screenshot({
|
||||
type: "jpeg",
|
||||
quality: 75,
|
||||
...(clip ? { clip } : {}),
|
||||
});
|
||||
const buf = await page.screenshot(
|
||||
opts.format === "png"
|
||||
? {
|
||||
type: "png",
|
||||
...(clip ? { clip } : {}),
|
||||
}
|
||||
: {
|
||||
type: "jpeg",
|
||||
quality: 75,
|
||||
...(clip ? { clip } : {}),
|
||||
},
|
||||
);
|
||||
await page.close();
|
||||
return buf as Buffer;
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user