diff --git a/packages/studio/.gitignore b/packages/studio/.gitignore new file mode 100644 index 00000000..9012af4f --- /dev/null +++ b/packages/studio/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +data/projects/ diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 9e181b32..b1716382 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,7 +1,10 @@ -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useCallback, useRef, useEffect, type ReactNode } from "react"; import { NLELayout } from "./components/nle/NLELayout"; import { SourceEditor } from "./components/editor/SourceEditor"; import { FileTree } from "./components/editor/FileTree"; +import { CompositionThumbnail } from "./player/components/CompositionThumbnail"; +import { VideoThumbnail } from "./player/components/VideoThumbnail"; +import type { TimelineElement } from "./player/store/playerStore"; import { XIcon, CodeIcon, @@ -80,6 +83,24 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () const errors = findings.filter((f) => f.severity === "error"); const warnings = findings.filter((f) => f.severity === "warning"); const hasIssues = findings.length > 0; + const [copied, setCopied] = useState(false); + + const handleCopyToAgent = async () => { + const lines = findings.map((f) => { + let line = `[${f.severity}] ${f.message}`; + if (f.file) line += `\n File: ${f.file}`; + if (f.fixHint) line += `\n Fix: ${f.fixHint}`; + return line; + }); + const text = `Fix these HyperFrames lint issues:\n\n${lines.join("\n\n")}`; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // ignore + } + }; return (
) : ( -
- +
+
)}
@@ -119,7 +140,19 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
- {/* Findings */} + {/* Copy to agent + findings */} + {hasIssues && ( +
+ +
+ )}
{!hasIssues && (
@@ -139,8 +172,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () {f.file &&

{f.file}

} {f.fixHint && (
- -

{f.fixHint}

+ +

{f.fixHint}

)}
@@ -156,8 +189,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: () {f.file &&

{f.file}

} {f.fixHint && (
- -

{f.fixHint}

+ +

{f.fixHint}

)}
@@ -202,6 +235,67 @@ export function StudioApp() { const [editingFile, setEditingFile] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(false); const [fileTree, setFileTree] = useState([]); + const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); + + const renderClipContent = useCallback( + (el: TimelineElement, style: { clip: string; label: string }): ReactNode => { + const pid = projectIdRef.current; + if (!pid) return null; + + // Resolve composition source path using the compIdToSrc map + let compSrc = el.compositionSrc; + if (compSrc && compIdToSrc.size > 0) { + const resolved = + compIdToSrc.get(el.id) || + compIdToSrc.get(compSrc.replace(/^compositions\//, "").replace(/\.html$/, "")); + if (resolved) compSrc = resolved; + } + + if (compSrc) { + const previewUrl = `/api/projects/${pid}/preview/comp/${compSrc}`; + return ( + + ); + } + + if ((el.tag === "video" || el.tag === "img") && el.src) { + const mediaSrc = el.src.startsWith("http") + ? el.src + : `/api/projects/${pid}/preview/${el.src}`; + return ( + + ); + } + + // HTML scene divs — render from index.html at the scene's time + if (el.tag === "div" && el.duration > 0) { + const previewUrl = `/api/projects/${pid}/preview`; + return ( + + ); + } + + return null; + }, + [compIdToSrc], + ); const [lintModal, setLintModal] = useState(null); const [linting, setLinting] = useState(false); const [refreshKey, setRefreshKey] = useState(0); @@ -212,6 +306,7 @@ export function StudioApp() { const [_renderError, setRenderError] = useState(null); const refreshTimerRef = useRef | null>(null); const projectIdRef = useRef(projectId); + const previewIframeRef = useRef(null); // Listen for external file changes (user editing HTML outside the editor). // In dev: use Vite HMR. In embedded/production: use SSE from /api/events. @@ -462,9 +557,11 @@ export function StudioApp() { { + previewIframeRef.current = iframe; + }} />
@@ -488,7 +585,7 @@ export function StudioApp() { diff --git a/packages/studio/src/components/editor/SourceEditor.tsx b/packages/studio/src/components/editor/SourceEditor.tsx index a631216f..098b93db 100644 --- a/packages/studio/src/components/editor/SourceEditor.tsx +++ b/packages/studio/src/components/editor/SourceEditor.tsx @@ -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(); } diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 8faadbac..48752bf4 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -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) => 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(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>(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([ { 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)); } }} > - + {timelineToolbar} + {timelineFooter} diff --git a/packages/studio/src/hooks/useCodeEditor.ts b/packages/studio/src/hooks/useCodeEditor.ts index d26d3fc4..467343db 100644 --- a/packages/studio/src/hooks/useCodeEditor.ts +++ b/packages/studio/src/hooks/useCodeEditor.ts @@ -16,7 +16,7 @@ export interface UseCodeEditorReturn { setActiveFile: (path: string) => void; updateContent: (content: string) => void; markSaved: (path: string) => void; - /** External update from agent — updates saved content, shows reload indicator */ + /** External update — updates saved content, shows reload indicator */ externalUpdate: (path: string, content: string) => void; } diff --git a/packages/studio/src/hooks/useElementPicker.ts b/packages/studio/src/hooks/useElementPicker.ts index ea22a072..6ba1dfb6 100644 --- a/packages/studio/src/hooks/useElementPicker.ts +++ b/packages/studio/src/hooks/useElementPicker.ts @@ -156,7 +156,11 @@ export function useElementPicker( ( elementId: string, selector: string, - op: { type: "inline-style" | "attribute" | "text-content"; property: string; value: string }, + op: { + type: "inline-style" | "attribute" | "text-content"; + property: string; + value: string; + }, ) => { const opts = optionsRef.current; if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return; diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index 73d71049..9c7b9be4 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -10,12 +10,14 @@ export { PlayerControls, Timeline, PreviewPanel, + VideoThumbnail, + CompositionThumbnail, useTimelinePlayer, usePlayerStore, liveTime, formatTime, } from "./player"; -export type { TimelineElement, ZoomMode } from "./player"; +export type { TimelineElement } from "./player"; // Editor export { SourceEditor } from "./components/editor/SourceEditor"; @@ -27,4 +29,11 @@ export { StudioApp } from "./App"; // Hooks export { useCodeEditor } from "./hooks/useCodeEditor"; +export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor"; export { useElementPicker } from "./hooks/useElementPicker"; +export type { PickedElement } from "./hooks/useElementPicker"; + +// Utilities +export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher"; +export type { PatchOperation } from "./utils/sourcePatcher"; +export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor"; diff --git a/packages/studio/src/player/components/AgentActivityTrack.tsx b/packages/studio/src/player/components/AgentActivityTrack.tsx deleted file mode 100644 index da491c8c..00000000 --- a/packages/studio/src/player/components/AgentActivityTrack.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { memo } from "react"; - -const TRACK_H = 20; -const GUTTER = 32; - -export interface AgentActivity { - agentId: string; - name: string; - color: string; - /** Active work periods mapped to VIDEO time (not wall clock) */ - periods: Array<{ start: number; end: number }>; - /** Element creation events at specific video times */ - events: Array<{ time: number; type: "create" | "modify" }>; -} - -interface AgentActivityTrackProps { - agents: AgentActivity[]; - duration: number; -} - -export const AgentActivityTrack = memo(function AgentActivityTrack({ - agents, - duration, -}: AgentActivityTrackProps) { - if (agents.length === 0 || duration <= 0) return null; - - return ( -
- {/* Section header */} -
- - - - Agent Activity -
- - {agents.map((agent) => ( -
- {/* Gutter: agent name */} -
-
-
- - {/* Lane */} -
- {/* Active work periods */} - {agent.periods.map((period, i) => { - const leftPct = (period.start / duration) * 100; - const widthPct = ((period.end - period.start) / duration) * 100; - return ( -
- ); - })} - - {/* Events: diamonds for create, circles for modify */} - {agent.events.map((event, i) => { - const leftPct = (event.time / duration) * 100; - return ( -
- {event.type === "create" ? ( -
- ) : ( -
- )} -
- ); - })} -
-
- ))} -
- ); -}); diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index edf29815..b33c52e1 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -54,7 +54,7 @@ export const TimelineClip = memo(function TimelineClip({ ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)` : undefined, border: isSelected - ? "2px solid rgba(255,255,255,0.9)" + ? `2px solid rgba(255,255,255,0.9)` : `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`, boxShadow: isSelected ? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)` @@ -63,7 +63,6 @@ export const TimelineClip = memo(function TimelineClip({ : "none", transition: "border-color 120ms, box-shadow 120ms", zIndex: isSelected ? 10 : isHovered ? 5 : 1, - cursor: "pointer", }} title={ isComposition diff --git a/packages/studio/src/utils/htmlEditor.ts b/packages/studio/src/utils/htmlEditor.ts new file mode 100644 index 00000000..2cdb921b --- /dev/null +++ b/packages/studio/src/utils/htmlEditor.ts @@ -0,0 +1,164 @@ +/** + * HTML Editor — Utility functions for parsing and manipulating HyperFrame HTML source. + */ + +/** + * Parse a CSS inline style string into a key-value map. + * e.g. "opacity: 0.5; transform: matrix(1,0,0,1,0,0)" → + * { opacity: "0.5", transform: "matrix(1,0,0,1,0,0)" } + */ +export function parseStyleString(style: string): Record { + const result: Record = {}; + for (const decl of style.split(";")) { + const colonIdx = decl.indexOf(":"); + if (colonIdx < 0) continue; + const key = decl.slice(0, colonIdx).trim(); + const value = decl.slice(colonIdx + 1).trim(); + if (key && value) result[key] = value; + } + return result; +} + +/** + * Merge `newStyles` into an opening tag string's `style` attribute. + * - New values win over existing ones. + * - If no `style` attribute is present, one is added before the closing `>`. + */ +export function mergeStyleIntoTag(tag: string, newStyles: string): string { + if (!newStyles.trim()) return tag; + + const incoming = parseStyleString(newStyles); + + // Match style="..." or style='...' — handle multi-line attrs via dotall-like trick + const styleAttrRe = /style=(["'])([\s\S]*?)\1/; + const match = tag.match(styleAttrRe); + + if (match) { + const quote = match[1]; + const existing = parseStyleString(match[2]); + const merged = { ...existing, ...incoming }; + const serialized = Object.entries(merged) + .map(([k, v]) => `${k}: ${v}`) + .join("; "); + return tag.replace(styleAttrRe, `style=${quote}${serialized}${quote}`); + } + + // No style attribute — insert one before the closing `>` + const serialized = Object.entries(incoming) + .map(([k, v]) => `${k}: ${v}`) + .join("; "); + // Handle self-closing tags (`/>`) and regular closing (`>`) + return tag.replace(/(\/?>)$/, ` style="${serialized}"$1`); +} + +/** + * Find the full element block (opening tag through closing tag) in the source. + * Uses quote-aware scanning to handle attributes containing >. + * Uses depth counting to handle nested same-name tags. + */ +export function findElementBlock( + html: string, + elementId: string, +): { + start: number; + end: number; + openTag: string; + tagName: string; + indent: string; + innerContent: string; + isSelfClosing: boolean; +} | null { + let idIdx = html.indexOf(`id="${elementId}"`); + if (idIdx < 0) idIdx = html.indexOf(`id='${elementId}'`); + if (idIdx < 0) return null; + + // Walk backward to find < and capture indent + let tagStart = idIdx; + while (tagStart > 0 && html[tagStart] !== "<") tagStart--; + + let indentStart = tagStart; + while (indentStart > 0 && html[indentStart - 1] !== "\n") indentStart--; + const indent = html.slice(indentStart, tagStart); + + // Walk forward from id to find the closing > of the opening tag + let tagEnd = idIdx; + let inQuote: string | null = null; + while (tagEnd < html.length) { + const ch = html[tagEnd]; + if (inQuote) { + if (ch === inQuote) inQuote = null; + } else { + if (ch === '"' || ch === "'") inQuote = ch; + if (ch === ">") { + tagEnd++; + break; + } + } + tagEnd++; + } + + const openTag = html.slice(tagStart, tagEnd); + const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i); + if (!tagNameMatch) return null; + + const tagName = tagNameMatch[1]; + const isSelfClosing = + openTag.trimEnd().endsWith("/>") || + ["img", "br", "hr", "input", "meta", "link", "source"].includes(tagName.toLowerCase()); + + if (isSelfClosing) { + return { + start: tagStart, + end: tagStart + openTag.length, + openTag, + tagName, + indent: /^[\t ]*$/.test(indent) ? indent : "", + innerContent: "", + isSelfClosing: true, + }; + } + + // Find matching closing tag using depth counting + const closeTag = ``; + const openPattern = `<${tagName.toLowerCase()}`; + let depth = 0; + let pos = tagStart; + const lower = html.toLowerCase(); + + while (pos < html.length) { + if (lower.startsWith("", pos + 4); + pos = commentEnd < 0 ? html.length : commentEnd + 3; + continue; + } + + if (lower.startsWith(openPattern, pos) && /[\s>/]/.test(html[pos + openPattern.length] || "")) { + depth++; + pos += openPattern.length; + continue; + } + + if (lower.startsWith(closeTag, pos)) { + depth--; + if (depth === 0) { + const end = pos + closeTag.length; + const innerContent = html.slice(tagStart + openTag.length, pos); + return { + start: tagStart, + end, + openTag, + tagName, + indent: /^[\t ]*$/.test(indent) ? indent : "", + innerContent, + isSelfClosing: false, + }; + } + pos += closeTag.length; + continue; + } + + pos++; + } + + return null; +} diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json index 81fdba5a..f0d9032c 100644 --- a/packages/studio/tsconfig.json +++ b/packages/studio/tsconfig.json @@ -13,8 +13,14 @@ "sourceMap": true, "outDir": "dist", "rootDir": "src", - "types": ["vite/client"] + "types": ["vite/client"], + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "noEmit": true, + "incremental": true, + "resolveJsonModule": true, + "isolatedModules": true }, "include": ["src"], - "exclude": ["dist", "node_modules", "src/dev-server.ts"] + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] } diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index fb82083d..2e990868 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -8,6 +8,7 @@ import { writeFileSync, lstatSync, realpathSync, + createReadStream, } from "node:fs"; import { join, resolve, sep } from "node:path"; @@ -20,6 +21,53 @@ function isSafePath(base: string, resolved: string): boolean { // Lazy-load the bundler via Vite's SSR module loader (resolves .ts imports correctly) let _bundler: ((dir: string) => Promise) | null = null; +// Shared Puppeteer browser instance — lazy-init, reused across thumbnail requests +let _browser: import("puppeteer-core").Browser | null = null; +let _browserLaunchPromise: Promise | null = null; + +async function getSharedBrowser(): Promise { + if (_browser?.connected) return _browser; + if (_browserLaunchPromise) return _browserLaunchPromise; + _browserLaunchPromise = (async () => { + const puppeteer = await import("puppeteer-core"); + const executablePath = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + ].find((p) => existsSync(p)); + if (!executablePath) return null; + _browser = await puppeteer.default.launch({ + headless: true, + executablePath, + args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + }); + _browserLaunchPromise = null; + return _browser; + })(); + return _browserLaunchPromise; +} + +// Render job store with TTL cleanup (fixes globalThis memory leak) +const renderJobs = new Map< + string, + { id: string; status: string; progress: number; outputPath: string } +>(); +// Only run cleanup interval in dev mode — setInterval keeps the process +// alive and prevents `vite build` from exiting, causing CI timeouts. +if (process.env.NODE_ENV !== "production" && !process.argv.includes("build")) { + setInterval(() => { + const now = Date.now(); + for (const [key, job] of renderJobs) { + if ( + (job.status === "complete" || job.status === "failed") && + now - parseInt(key.split("-").pop() || "0") > 300_000 + ) { + renderJobs.delete(key); + } + } + }, 60_000); +} + /** Minimal project API for standalone dev mode */ function devProjectApi(): Plugin { const dataDir = resolve(__dirname, "data/projects"); @@ -44,26 +92,146 @@ function devProjectApi(): Plugin { server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith("/api/")) return next(); - // Render endpoints — not yet wired up in standalone studio + // ── Render endpoints ────────────────────────────────────────── + const PRODUCER_URL = (process.env.PRODUCER_SERVER_URL || "http://127.0.0.1:9847").replace( + /\/+$/, + "", + ); + + // POST /api/projects/:id/render — start a render job via producer + const renderMatch = + req.method === "POST" && req.url.match(/\/api\/projects\/([^/]+)\/render/); + if (renderMatch) { + const pid = renderMatch[1]; + const pDir = join(dataDir, pid); + if (!existsSync(pDir)) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Project not found" })); + return; + } + const jobId = `${pid}-${Date.now()}`; + const outputDir = resolve(dataDir, "../renders"); + if (!existsSync(outputDir)) { + const { mkdirSync: mk } = await import("fs"); + mk(outputDir, { recursive: true }); + } + const outputPath = join(outputDir, `${jobId}.mp4`); + // Store job state — referenced by the SSE progress endpoint and the fetch callback below + const _jobState = { id: jobId, status: "rendering", progress: 0, outputPath }; + renderJobs.set(jobId, _jobState); + + // Start render in background + fetch(`${PRODUCER_URL}/render/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectDir: pDir, outputPath, fps: 30, quality: "standard" }), + }) + .then(async (resp) => { + if (!resp.ok || !resp.body) { + _jobState.status = "failed"; + return; + } + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split("\n\n"); + buffer = blocks.pop() || ""; + for (const block of blocks) { + const data = block + .split("\n") + .filter((l) => l.startsWith("data:")) + .map((l) => l.slice(5).trim()) + .join(""); + if (!data) continue; + try { + const evt = JSON.parse(data); + if (evt.type === "progress") { + _jobState.progress = evt.progress; + } + if (evt.type === "complete") { + _jobState.status = "complete"; + _jobState.outputPath = evt.outputPath || outputPath; + } + if (evt.type === "error") { + _jobState.status = "failed"; + } + } catch {} + } + } + if (_jobState.status === "rendering") _jobState.status = "complete"; + }) + .catch(() => { + _jobState.status = "failed"; + }); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jobId, status: "rendering" })); + return; + } + + // GET /api/render/:jobId/progress — SSE progress stream if ( - req.url.startsWith("/api/render/") || - (req.method === "POST" && req.url.match(/\/api\/projects\/[^/]+\/render/)) + req.method === "GET" && + req.url.startsWith("/api/render/") && + req.url.endsWith("/progress") ) { - res.writeHead(501, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Render not available in standalone studio mode" })); + const jobId = req.url.replace("/api/render/", "").replace("/progress", ""); + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + const interval = setInterval(() => { + const state = renderJobs.get(jobId) as { status: string; progress: number } | undefined; + if (!state) { + clearInterval(interval); + res.end(); + return; + } + res.write( + `event: progress\ndata: ${JSON.stringify({ status: state.status, progress: state.progress })}\n\n`, + ); + if (state.status === "complete" || state.status === "failed") { + clearInterval(interval); + setTimeout(() => res.end(), 100); + } + }, 500); + req.on("close", () => clearInterval(interval)); return; } - // GET /api/runtime.js — serve the HyperFrames runtime - if (req.method === "GET" && req.url === "/api/runtime.js") { - const cliRuntime = resolve(__dirname, "..", "cli", "dist", "hyperframe-runtime.js"); - if (existsSync(cliRuntime)) { - res.writeHead(200, { "Content-Type": "text/javascript", "Cache-Control": "no-store" }); - res.end(readFileSync(cliRuntime, "utf-8")); - } else { - res.writeHead(404); - res.end("runtime not built"); + // GET /api/render/:jobId/download — serve the rendered MP4 + if ( + req.method === "GET" && + req.url.startsWith("/api/render/") && + req.url.endsWith("/download") + ) { + const jobId = req.url.replace("/api/render/", "").replace("/download", ""); + const jobState = renderJobs.get(jobId) as + | { outputPath?: string; status: string } + | undefined; + if ( + !jobState || + jobState.status !== "complete" || + !jobState.outputPath || + !existsSync(jobState.outputPath) + ) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Render not ready or not found" })); + return; } + const fileStat = statSync(jobState.outputPath); + res.writeHead(200, { + "Content-Type": "video/mp4", + "Content-Length": String(fileStat.size), + "Content-Disposition": `attachment; filename="${jobId}.mp4"`, + }); + const stream = createReadStream(jobState.outputPath); + stream.pipe(res); return; } @@ -158,8 +326,10 @@ function devProjectApi(): Plugin { // GET /api/projects/:id if (req.method === "GET" && !rest) { const files: string[] = []; + const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]); function walk(d: string, prefix: string) { for (const entry of readdirSync(d, { withFileTypes: true })) { + if (IGNORE_DIRS.has(entry.name)) continue; const rel = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isDirectory()) walk(join(d, entry.name), rel); else files.push(rel); @@ -178,21 +348,25 @@ function devProjectApi(): Plugin { let bundled = bundler ? await bundler(projectDir) : readFileSync(join(projectDir, "index.html"), "utf-8"); - // Inject so relative asset paths resolve through /preview/ route - const baseTag = ``; - if (bundled.includes("")) { - bundled = bundled.replace("", `${baseTag}`); - } else { - bundled = baseTag + bundled; + + // Inject runtime if not already present + const runtimeUrl = + "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js"; + if (!bundled.includes("hyperframe.runtime")) { + const runtimeTag = ``; + if (bundled.includes("")) { + bundled = bundled.replace("", `${runtimeTag}\n`); + } else { + bundled += `\n${runtimeTag}`; + } } - // Inject runtime if available and not already set - const cliRuntime = resolve(__dirname, "..", "cli", "dist", "hyperframe-runtime.js"); - if (existsSync(cliRuntime) && bundled.includes('src=""')) { - bundled = bundled.replace( - 'data-hyperframes-preview-runtime="1" src=""', - 'data-hyperframes-preview-runtime="1" src="/api/runtime.js"', - ); + + // Inject for relative asset resolution + const baseHref = `/api/projects/${projectId}/preview/`; + if (!bundled.includes("/i, ``); } + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", @@ -273,15 +447,9 @@ function devProjectApi(): Plugin { ); // Build a standalone HTML page with GSAP + runtime - // Resolve runtime: env var → built CLI dist → empty (no runtime) - let runtimeUrl = (process.env.HYPERFRAME_RUNTIME_URL || "").trim(); - if (!runtimeUrl) { - // In dev: serve the built runtime from the CLI dist - const cliRuntime = resolve(__dirname, "..", "cli", "dist", "hyperframe-runtime.js"); - if (existsSync(cliRuntime)) { - runtimeUrl = `/api/runtime.js`; - } - } + const runtimeUrl = + (process.env.HYPERFRAME_RUNTIME_URL || "").trim() || + "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js"; const standalone = ` @@ -300,6 +468,105 @@ ${content} return; } + // GET /api/projects/:id/thumbnail/* — generate JPEG thumbnail via Puppeteer + if (req.method === "GET" && rest.startsWith("/thumbnail/")) { + const compPath = decodeURIComponent(rest.replace("/thumbnail/", "").split("?")[0]); + const url = new URL(req.url!, `http://${req.headers.host}`); + const seekTime = parseFloat(url.searchParams.get("t") || "0.5") || 0.5; + const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0; + const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0; + + // Determine the preview URL for this composition + const previewUrl = + compPath === "index.html" + ? `http://${req.headers.host}/api/projects/${projectId}/preview` + : `http://${req.headers.host}/api/projects/${projectId}/preview/comp/${compPath}`; + + // Cache path + const cacheDir = join(projectDir, ".thumbnails"); + const cacheKey = `${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}.jpg`; + const cachePath = join(cacheDir, cacheKey); + + // Return cached thumbnail if available + if (existsSync(cachePath)) { + res.writeHead(200, { + "Content-Type": "image/jpeg", + "Cache-Control": "public, max-age=60", + }); + res.end(readFileSync(cachePath)); + return; + } + + try { + const browser = await getSharedBrowser(); + if (!browser) { + res.writeHead(501, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Chrome not found for thumbnails" })); + return; + } + // Detect composition dimensions from the HTML file + let compW = vpWidth || 1920; + let compH = vpHeight || 1080; + if (!vpWidth) { + const htmlFile = join(projectDir, compPath); + if (existsSync(htmlFile)) { + const html = readFileSync(htmlFile, "utf-8"); + const wMatch = html.match(/data-width=["'](\d+)["']/); + const hMatch = html.match(/data-height=["'](\d+)["']/); + if (wMatch) compW = parseInt(wMatch[1]); + if (hMatch) compH = parseInt(hMatch[1]); + } + } + + const page = await browser.newPage(); + await page.setViewport({ width: compW, height: compH, deviceScaleFactor: 0.5 }); + await page.goto(previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); + + // Wait for GSAP + seek + await page + .waitForFunction( + `!!(window.__timelines && Object.keys(window.__timelines).length > 0)`, + { timeout: 5000 }, + ) + .catch(() => {}); + await page.evaluate((t: number) => { + const w = window as Window & { + __timelines?: Record void; pause: () => void }>; + }; + if (w.__timelines) { + const tl = Object.values(w.__timelines)[0]; + if (tl) { + tl.seek(t); + tl.pause(); + } + } + }, seekTime); + await page.evaluate("document.fonts?.ready"); + await new Promise((r) => setTimeout(r, 100)); + + const buffer = await page.screenshot({ type: "jpeg", quality: 75 }); + await page.close(); + + // Cache + if (!existsSync(cacheDir)) { + const { mkdirSync } = await import("fs"); + mkdirSync(cacheDir, { recursive: true }); + } + writeFileSync(cachePath, buffer); + + res.writeHead(200, { + "Content-Type": "image/jpeg", + "Cache-Control": "public, max-age=60", + }); + res.end(buffer); + } catch (err) { + console.warn("[Studio] Thumbnail generation failed:", err); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Thumbnail generation failed" })); + } + return; + } + // GET /api/projects/:id/preview/* — serve static assets (images, audio, etc.) if (req.method === "GET" && rest.startsWith("/preview/")) { const subPath = decodeURIComponent(rest.replace("/preview/", "").split("?")[0]); @@ -310,29 +577,13 @@ ${content} return; } const isText = /\.(html|css|js|json|svg|txt)$/i.test(subPath); - const mimeTypes: Record = { - ".html": "text/html", - ".js": "text/javascript", - ".css": "text/css", - ".json": "application/json", - ".svg": "image/svg+xml", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".webp": "image/webp", - ".gif": "image/gif", - ".mp4": "video/mp4", - ".webm": "video/webm", - ".mp3": "audio/mpeg", - ".wav": "audio/wav", - ".m4a": "audio/mp4", - ".ogg": "audio/ogg", - ".woff2": "font/woff2", - ".woff": "font/woff", - ".ttf": "font/ttf", - }; - const ext = "." + subPath.split(".").pop()?.toLowerCase(); - const contentType = mimeTypes[ext] ?? "application/octet-stream"; + const contentType = subPath.endsWith(".html") + ? "text/html" + : subPath.endsWith(".js") + ? "text/javascript" + : subPath.endsWith(".css") + ? "text/css" + : "application/octet-stream"; res.writeHead(200, { "Content-Type": contentType }); res.end(readFileSync(file, isText ? "utf-8" : undefined)); return;