From ee8989385af6076085d9eb7f4d1361cf186f753f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 29 Apr 2026 11:49:34 -0400 Subject: [PATCH] feat: add Studio current-frame capture --- packages/cli/src/server/studioServer.ts | 17 +- .../src/studio-api/routes/thumbnail.test.ts | 21 ++ .../core/src/studio-api/routes/thumbnail.ts | 9 +- packages/core/src/studio-api/types.ts | 1 + packages/studio/src/App.tsx | 181 +++++++++++++----- .../studio/src/components/nle/NLELayout.tsx | 51 ++++- .../src/components/sidebar/LeftSidebar.tsx | 26 +++ packages/studio/src/icons/SystemIcons.tsx | 2 + .../src/player/components/PlayerControls.tsx | 41 ---- .../src/player/components/timelineTheme.ts | 6 +- .../studio/src/utils/frameCapture.test.ts | 26 +++ packages/studio/src/utils/frameCapture.ts | 38 ++++ packages/studio/vite.config.ts | 19 +- 13 files changed, 321 insertions(+), 117 deletions(-) create mode 100644 packages/studio/src/utils/frameCapture.test.ts create mode 100644 packages/studio/src/utils/frameCapture.ts diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index adc99d606..0f4d06024 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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; diff --git a/packages/core/src/studio-api/routes/thumbnail.test.ts b/packages/core/src/studio-api/routes/thumbnail.test.ts index 24e13e4f2..9d8c75753 100644 --- a/packages/core/src/studio-api/routes/thumbnail.test.ts +++ b/packages/core/src/studio-api/routes/thumbnail.test.ts @@ -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", }), ); }); diff --git a/packages/core/src/studio-api/routes/thumbnail.ts b/packages/core/src/studio-api/routes/thumbnail.ts index c5107fde6..afff8e688 100644 --- a/packages/core/src/studio-api/routes/thumbnail.ts +++ b/packages/core/src/studio-api/routes/thumbnail.ts @@ -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); diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index 73364e8c7..71aa96371 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -72,6 +72,7 @@ export interface StudioApiAdapter { height: number; previewUrl: string; selector?: string; + format?: "jpeg" | "png"; }) => Promise; /** Optional: resolve session ID to project (multi-project mode). */ diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index aeeca36dc..6584c492c 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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(null); const [timelineVisible, setTimelineVisible] = useState(true); + const [captureFrameTime, setCaptureFrameTime] = useState(0); const dragCounterRef = useRef(0); const toastTimerRef = useRef | 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() { > + + @@ -787,6 +840,42 @@ export function StudioApp() { toastTimerRef.current = setTimeout(() => setAppToast(null), 4000); }, []); + const handleCaptureFrameClick = useCallback( + async (event: MouseEvent) => { + 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() { {/* Right: toolbar buttons */}
- - + + Capture + +
+ ) : ( )} diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index ffbde95cd..79757b081 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -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(null); @@ -366,16 +371,11 @@ export const NLELayout = memo(function NLELayout({ onNavigate={handleNavigateComposition} /> )} - + - {(timelineVisible ?? true) && ( + {isTimelineVisible ? ( <> {/* Resize divider */}
{timelineFooter}
} - )} + ) : onToggleTimeline ? ( +
+
+
+ Timeline +
+ +
+
+ ) : null} ); }); diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 5c211d0a8..a4172888c 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -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(getPersistedTab); @@ -122,6 +124,30 @@ export const LeftSidebar = memo(function LeftSidebar({ > Assets + {onToggleCollapse && ( + + )} {/* Tab content */} diff --git a/packages/studio/src/icons/SystemIcons.tsx b/packages/studio/src/icons/SystemIcons.tsx index e3df7dc89..d63ad8cae 100644 --- a/packages/studio/src/icons/SystemIcons.tsx +++ b/packages/studio/src/icons/SystemIcons.tsx @@ -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); diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index dcde46b50..b9d6deede 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -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({ ))} - - {/* Timeline toggle */} - {onToggleTimeline !== undefined && ( - - )} ); }); diff --git a/packages/studio/src/player/components/timelineTheme.ts b/packages/studio/src/player/components/timelineTheme.ts index 1b29dbf7f..5613e8bcb 100644 --- a/packages/studio/src/player/components/timelineTheme.ts +++ b/packages/studio/src/player/components/timelineTheme.ts @@ -63,12 +63,12 @@ const TRACK_STYLES: Record = { 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", diff --git a/packages/studio/src/utils/frameCapture.test.ts b/packages/studio/src/utils/frameCapture.test.ts new file mode 100644 index 000000000..b4dc4193c --- /dev/null +++ b/packages/studio/src/utils/frameCapture.test.ts @@ -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"); + }); +}); diff --git a/packages/studio/src/utils/frameCapture.ts b/packages/studio/src/utils/frameCapture.ts new file mode 100644 index 000000000..80c5adf62 --- /dev/null +++ b/packages/studio/src/utils/frameCapture.ts @@ -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`; +} diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 4926f7a8b..6d99b3a36 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -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; })();