diff --git a/bun.lock b/bun.lock
index 22d85f2c8..aae8f7dc3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -121,7 +121,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
- "version": "0.1.2",
+ "version": "0.1.3",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -135,6 +135,7 @@
"@codemirror/view": "^6.40.0",
"@hyperframes/core": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
+ "@use-gesture/react": "^10.3.1",
"codemirror": "^6.0.1",
},
"devDependencies": {
@@ -143,9 +144,12 @@
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
+ "puppeteer-core": "^24.40.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
+ "vitest": "^3.2.4",
+ "zustand": "^5.0.0",
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
@@ -667,6 +671,10 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "22.19.15" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
+ "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="],
+
+ "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="],
+
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "7.29.0", "@babel/plugin-transform-react-jsx-self": "7.27.1", "@babel/plugin-transform-react-jsx-source": "7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "7.20.5", "react-refresh": "0.17.0" }, "peerDependencies": { "vite": "5.4.21" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@bcoe/v8-coverage": "1.0.2", "ast-v8-to-istanbul": "0.3.12", "debug": "4.4.3", "istanbul-lib-coverage": "3.2.2", "istanbul-lib-report": "3.0.1", "istanbul-lib-source-maps": "5.0.6", "istanbul-reports": "3.2.0", "magic-string": "0.30.21", "magicast": "0.3.5", "std-env": "3.10.0", "test-exclude": "7.0.2", "tinyrainbow": "2.0.0" }, "peerDependencies": { "vitest": "3.2.4" } }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="],
@@ -1361,8 +1369,6 @@
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "3.2.0", "picocolors": "1.1.1" }, "peerDependencies": { "browserslist": "4.28.1" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
- "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "18.3.1" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
-
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.8", "rollup": "4.59.0" }, "optionalDependencies": { "@types/node": "22.19.15", "fsevents": "2.3.3" }, "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
@@ -1419,7 +1425,7 @@
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
- "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "1.6.0" }, "optionalDependencies": { "@types/react": "19.2.14", "react": "18.3.1" } }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
+ "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
diff --git a/packages/studio/.gitignore b/packages/studio/.gitignore
new file mode 100644
index 000000000..9012af4f9
--- /dev/null
+++ b/packages/studio/.gitignore
@@ -0,0 +1,3 @@
+dist/
+node_modules/
+data/projects/
diff --git a/packages/studio/package.json b/packages/studio/package.json
index 7afd1152c..b4e94b2a8 100644
--- a/packages/studio/package.json
+++ b/packages/studio/package.json
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio",
- "version": "0.1.2",
+ "version": "0.1.3",
"files": [
"src",
"dist"
@@ -15,7 +15,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
@@ -30,6 +32,7 @@
"@codemirror/view": "^6.40.0",
"@hyperframes/core": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
+ "@use-gesture/react": "^10.3.1",
"codemirror": "^6.0.1"
},
"devDependencies": {
@@ -38,9 +41,12 @@
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
+ "puppeteer-core": "^24.40.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
- "vite": "^5.0.0"
+ "vite": "^5.0.0",
+ "vitest": "^3.2.4",
+ "zustand": "^5.0.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index e8f3022fa..2d0c02974 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -1,7 +1,13 @@
-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 { TimelineToolbar } from "./components/timeline/TimelineToolbar";
+import { usePlayerStore } from "./player/store/playerStore";
+import { EditModal } from "./components/timeline/EditModal";
+import { VideoThumbnail } from "./player/components/VideoThumbnail";
+import type { TimelineElement } from "./player/store/playerStore";
import {
XIcon,
CodeIcon,
@@ -98,8 +104,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
) : (
-
-
+
+
)}
@@ -139,8 +145,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
{f.file &&
{f.file}
}
{f.fixHint && (
-
-
{f.fixHint}
+
+
{f.fixHint}
)}
@@ -156,8 +162,8 @@ function LintModal({ findings, onClose }: { findings: LintFinding[]; onClose: ()
{f.file &&
{f.file}
}
{f.fixHint && (
-
-
{f.fixHint}
+
+
{f.fixHint}
)}
@@ -202,7 +208,69 @@ export function StudioApp() {
const [editingFile, setEditingFile] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [fileTree, setFileTree] = useState([]);
+ const [compIdToSrc, setCompIdToSrc] = useState")) {
+ bundled = bundled.replace("", `${runtimeTag}\n`);
+ } else {
+ bundled += `\n${runtimeTag}`;
+ }
+ }
+
+ // 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",
@@ -245,7 +444,9 @@ function devProjectApi(): Plugin {
);
// Build a standalone HTML page with GSAP + runtime
- const runtimeUrl = (process.env.HYPERFRAME_RUNTIME_URL || "").trim() || "";
+ const runtimeUrl =
+ (process.env.HYPERFRAME_RUNTIME_URL || "").trim() ||
+ "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
const standalone = `
@@ -264,6 +465,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]);