diff --git a/packages/studio/CLAUDE.md b/packages/studio/CLAUDE.md deleted file mode 100644 index 81151375..00000000 --- a/packages/studio/CLAUDE.md +++ /dev/null @@ -1,30 +0,0 @@ -# Studio - -## Architecture - -The **frontend** is fully isolated from `core/` and `core_v2/`. It does NOT import, read, or depend on any code from those packages. Keep it that way. - -The **backend** depends on `@hyperframes/core` for shared utilities: -- **Timing compilation** (`compileTimingAttrs`, `injectDurations`) — resolves `data-duration="auto"` etc. -- **Hyperframe runtime source** (`loadHyperframeRuntimeSource`) — bundled runtime injected into served HTML - -The hyperframe runtime (modular sources under `core/src/runtime/`) handles: -1. Intercepting `gsap.timeline()` to capture the master timeline -2. Exposing `window.__player` (play/pause/seek/getTime/getDuration/isPlaying) -3. RAF-based media sync (video/audio playback tied to timeline position) -4. Visibility bookends from `data-start` + `data-duration` - -If you need new playback features, add them to `core/src/runtime/` — do NOT pull in the core gsapInterceptor (that's for the deprecated editor). - -## Data Attributes (core_v2 convention) - -- `data-start` — start time in seconds -- `data-duration` — duration in seconds -- `data-track-index` — timeline track number -- `data-media-start` — media offset (optional) -- `data-volume` — audio/video volume 0-1 (optional) - -## Ports - -- Backend: 3002 -- Frontend: 5175 diff --git a/packages/studio/backend/package.json b/packages/studio/backend/package.json deleted file mode 100644 index 0eec9fa3..00000000 --- a/packages/studio/backend/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@hyperframes/studio-backend", - "private": true, - "version": "0.0.0", - "type": "module", - "exports": { - "./embedded": "./src/embedded.ts", - "./routes/projects": "./src/routes/projects.ts", - "./routes/render": "./src/routes/render.ts" - }, - "scripts": { - "dev": "tsx watch src/index.ts", - "start": "tsx src/index.ts" - }, - "dependencies": { - "@hyperframes/core": "workspace:*", - "@hono/node-server": "^1.8.0", - "adm-zip": "^0.5.16", - "cheerio": "^1.2.0", - "hono": "^4.0.0", - "mime-types": "^3.0.2" - }, - "devDependencies": { - "@types/adm-zip": "^0.5.7", - "@types/mime-types": "^3.0.1", - "@types/node": "^20.0.0", - "tsx": "^4.0.0", - "typescript": "^5.0.0" - } -} diff --git a/packages/studio/backend/src/embedded.ts b/packages/studio/backend/src/embedded.ts deleted file mode 100644 index b2089e49..00000000 --- a/packages/studio/backend/src/embedded.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Embedded studio server factory. - * - * Creates a Hono app with all studio backend routes mounted, suitable for - * embedding in the CLI bundle. Uses the same Hono import as the route modules - * to avoid class duplication when bundled by tsup. - */ -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { projects } from "./routes/projects"; -import { projectRender, renderJobs } from "./routes/render"; - -export interface EmbeddedAppOptions { - /** CORS origin (default: "*") */ - corsOrigin?: string; -} - -/** - * Create a fully configured Hono app with studio backend routes. - * All imports use the same module scope, avoiding Hono class mismatches in bundles. - */ -export function createEmbeddedApp(options: EmbeddedAppOptions = {}): InstanceType { - const app = new Hono(); - - app.use( - "/*", - cors({ - origin: options.corsOrigin ?? "*", - allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowHeaders: ["Content-Type"], - }), - ); - - app.get("/api/health", (ctx) => ctx.json({ status: "ok", service: "studio-embedded" })); - - app.route("/api/projects", projects); - app.route("/api/projects", projectRender); - app.route("/api/render", renderJobs); - - return app; -} diff --git a/packages/studio/backend/src/index.ts b/packages/studio/backend/src/index.ts deleted file mode 100644 index 49ee32c7..00000000 --- a/packages/studio/backend/src/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { serve } from "@hono/node-server"; -import { projects } from "./routes/projects"; -import { projectRender, renderJobs } from "./routes/render"; - -const app = new Hono(); - -app.use( - "/*", - cors({ - origin: ["http://localhost:5175"], - allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowHeaders: ["Content-Type"], - }) -); - -app.get("/", (c) => c.json({ status: "ok", service: "studio" })); - -app.route("/api/projects", projects); -app.route("/api/projects", projectRender); -app.route("/api/render", renderJobs); - -const port = 3002; - -console.log(`Studio backend running on http://localhost:${port}`); - -serve({ - fetch: app.fetch, - port, -}); diff --git a/packages/studio/backend/src/routes/projects.ts b/packages/studio/backend/src/routes/projects.ts deleted file mode 100644 index b579d5f0..00000000 --- a/packages/studio/backend/src/routes/projects.ts +++ /dev/null @@ -1,604 +0,0 @@ -import { Hono } from "hono"; -import { - readFileSync, - writeFileSync, - existsSync, - readdirSync, - rmSync, - statSync, - mkdirSync, -} from "fs"; -import { join, resolve, extname, basename, dirname } from "path"; -import { randomUUID } from "crypto"; -import mime from "mime-types"; -import * as cheerio from "cheerio"; -import { extractZip } from "../utils/zip"; -import { loadHyperframeRuntimeSource } from "@hyperframes/core"; -import { compileHtml } from "../utils/htmlCompiler"; - -const DATA_DIR = process.env.STUDIO_DATA_DIR - ? resolve(process.env.STUDIO_DATA_DIR) - : resolve(import.meta.dirname, "../../data/projects"); -let _interceptorScript: string | undefined; -function getInterceptorScript(): string { - if (_interceptorScript === undefined) { - // Try pre-built runtime file first (exists in CLI bundle), fall back to esbuild - try { - const prebuiltPath = resolve(import.meta.dirname, "hyperframe-runtime.js"); - if (existsSync(prebuiltPath)) { - _interceptorScript = readFileSync(prebuiltPath, "utf-8"); - } else { - _interceptorScript = loadHyperframeRuntimeSource(); - } - } catch { - _interceptorScript = loadHyperframeRuntimeSource(); - } - } - return _interceptorScript; -} -const RENDER_MODE_SCRIPT = `(function() { - function waitForPlayer() { - if (window.__player && typeof window.__player.renderSeek === "function") { - window.__renderReady = true; - return; - } - if (window.__player) { - window.__player.renderSeek = function(time) { - var tl = window.__player._timeline; - if (!tl) return; - tl.pause(); - tl.seek(time, false); - }; - window.__renderReady = true; - return; - } - setTimeout(waitForPlayer, 50); - } - waitForPlayer(); -})();`; - -interface ProjectMeta { - id: string; - name: string; - createdAt: string; -} - -interface PresenceHeartbeatBody { - sessionId: string; - filePath?: string; - line?: number; - column?: number; - color?: string; -} - -interface PresenceSession { - sessionId: string; - filePath?: string; - line?: number; - column?: number; - color?: string; - lastSeen: number; -} - -const PRESENCE_ENABLED = process.env.PRESENCE_ENABLED !== "false"; -const PRESENCE_TTL_MS = Number(process.env.PRESENCE_TTL_MS ?? "30000"); -const PRESENCE_HEARTBEAT_MAX_AGE_MS = Math.max(1_000, PRESENCE_TTL_MS); -const presenceByProject = new Map>(); - -function getProjectDir(id: string) { - return join(DATA_DIR, id); -} - -function readMeta(id: string): ProjectMeta | null { - const metaPath = join(getProjectDir(id), "meta.json"); - if (!existsSync(metaPath)) return null; - return JSON.parse(readFileSync(metaPath, "utf-8")); -} - -function sanitizePresenceSession( - session: PresenceSession, - now: number -): PresenceSession | null { - if (now - session.lastSeen > PRESENCE_HEARTBEAT_MAX_AGE_MS) { - return null; - } - - return session; -} - -function getActivePresenceSessions(projectId: string): PresenceSession[] { - const now = Date.now(); - const projectPresence = presenceByProject.get(projectId); - if (!projectPresence) return []; - - const active: PresenceSession[] = []; - for (const [sessionId, session] of projectPresence.entries()) { - const sanitized = sanitizePresenceSession(session, now); - if (!sanitized) { - projectPresence.delete(sessionId); - continue; - } - active.push(sanitized); - } - - if (projectPresence.size === 0) { - presenceByProject.delete(projectId); - } - - return active; -} - -setInterval(() => { - const projectIds = Array.from(presenceByProject.keys()); - for (const projectId of projectIds) { - getActivePresenceSessions(projectId); - } -}, Math.max(1_000, Math.floor(PRESENCE_HEARTBEAT_MAX_AGE_MS / 2))).unref(); - -/** Inject the sandbox interceptor script before */ -function injectInterceptor(html: string): string { - const interceptorTag = ``; - const renderTag = ``; - - if (html.includes("")) { - html = html.replace("", () => `${interceptorTag}\n`); - } else { - const doctypeIdx = html.toLowerCase().indexOf("= 0) { - const insertPos = html.indexOf(">", doctypeIdx) + 1; - html = html.slice(0, insertPos) + interceptorTag + html.slice(insertPos); - } else { - html = interceptorTag + html; - } - } - - if (html.includes("")) { - return html.replace("", () => `${renderTag}\n`); - } - return html + renderTag; -} - -export const projects = new Hono(); - -// List all projects -projects.get("/", (c) => { - if (!existsSync(DATA_DIR)) return c.json([]); - - const dirs = readdirSync(DATA_DIR, { withFileTypes: true }).filter((d) => - d.isDirectory() || d.isSymbolicLink() - ); - - const projectList: ProjectMeta[] = []; - for (const dir of dirs) { - const meta = readMeta(dir.name); - if (meta) projectList.push(meta); - } - - // Sort newest first - projectList.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - - return c.json(projectList); -}); - -// Upload ZIP -projects.post("/upload", async (c) => { - const formData = await c.req.formData(); - const file = formData.get("file") as File | null; - const name = (formData.get("name") as string) || "Untitled Project"; - - if (!file) { - return c.json({ error: "No file provided" }, 400); - } - - if (!file.name.endsWith(".zip")) { - return c.json({ error: "File must be a .zip" }, 400); - } - - const id = randomUUID(); - const projectDir = getProjectDir(id); - - const buffer = Buffer.from(await file.arrayBuffer()); - const result = extractZip(buffer, projectDir); - - if (!result.success) { - // Clean up on failure - if (existsSync(projectDir)) { - rmSync(projectDir, { recursive: true, force: true }); - } - return c.json({ error: result.error }, 400); - } - - // Write metadata - const meta: ProjectMeta = { - id, - name, - createdAt: new Date().toISOString(), - }; - writeFileSync(join(projectDir, "meta.json"), JSON.stringify(meta, null, 2)); - - return c.json(meta, 201); -}); - -// Get project metadata (with composition dimensions) -projects.get("/:id", (c) => { - const id = c.req.param("id"); - const meta = readMeta(id); - if (!meta) return c.json({ error: "Project not found" }, 404); - - // Try to extract composition dimensions from index.html - const indexPath = join(getProjectDir(id), "index.html"); - let width: number | null = null; - let height: number | null = null; - - if (existsSync(indexPath)) { - try { - const html = readFileSync(indexPath, "utf-8"); - const $ = cheerio.load(html); - // Look for root composition element with data-width/data-height - const rootComp = $( - "[data-composition-id][data-width][data-height]" - ).first(); - if (rootComp.length) { - width = parseInt(rootComp.attr("data-width") || "", 10) || null; - height = parseInt(rootComp.attr("data-height") || "", 10) || null; - } - } catch { - // Ignore parse errors - } - } - - return c.json({ ...meta, width, height }); -}); - -// Delete project -projects.delete("/:id", (c) => { - const id = c.req.param("id"); - const projectDir = getProjectDir(id); - - if (!existsSync(projectDir)) { - return c.json({ error: "Project not found" }, 404); - } - - rmSync(projectDir, { recursive: true, force: true }); - presenceByProject.delete(id); - return c.json({ success: true }); -}); - -projects.post("/:id/presence/heartbeat", async (c) => { - if (!PRESENCE_ENABLED) { - return c.json({ enabled: false }, 200); - } - - const id = c.req.param("id"); - const projectDir = getProjectDir(id); - if (!existsSync(projectDir)) { - return c.json({ error: "Project not found" }, 404); - } - - const body = await c.req.json(); - const sessionId = body.sessionId?.trim(); - if (!sessionId) { - return c.json({ error: "sessionId is required" }, 400); - } - - const line = - typeof body.line === "number" && Number.isFinite(body.line) - ? Math.max(1, Math.floor(body.line)) - : undefined; - const column = - typeof body.column === "number" && Number.isFinite(body.column) - ? Math.max(1, Math.floor(body.column)) - : undefined; - - let projectPresence = presenceByProject.get(id); - if (!projectPresence) { - projectPresence = new Map(); - presenceByProject.set(id, projectPresence); - } - - projectPresence.set(sessionId, { - sessionId, - filePath: typeof body.filePath === "string" ? body.filePath : undefined, - line, - column, - color: typeof body.color === "string" ? body.color : undefined, - lastSeen: Date.now(), - }); - - return c.json({ enabled: true }); -}); - -projects.get("/:id/presence", (c) => { - if (!PRESENCE_ENABLED) { - return c.json({ enabled: false, ttlMs: PRESENCE_HEARTBEAT_MAX_AGE_MS, sessions: [] }); - } - - const id = c.req.param("id"); - const projectDir = getProjectDir(id); - if (!existsSync(projectDir)) { - return c.json({ error: "Project not found" }, 404); - } - - const sessions = getActivePresenceSessions(id); - return c.json({ enabled: true, ttlMs: PRESENCE_HEARTBEAT_MAX_AGE_MS, sessions }); -}); - -// Update an element's data-start attribute in index.html -projects.patch("/:id/elements/:elementId", async (c) => { - const id = c.req.param("id"); - const elementId = c.req.param("elementId"); - const projectDir = getProjectDir(id); - const indexPath = join(projectDir, "index.html"); - - if (!existsSync(indexPath)) { - return c.json({ error: "Project or index.html not found" }, 404); - } - - const body = await c.req.json<{ start: number }>(); - if (typeof body.start !== "number" || isNaN(body.start)) { - return c.json({ error: "start must be a valid number" }, 400); - } - - const html = readFileSync(indexPath, "utf-8"); - const $ = cheerio.load(html, null, false); - const el = $(`[id="${elementId}"]`); - - if (!el.length) { - return c.json({ error: `Element #${elementId} not found` }, 404); - } - - const newStart = Math.max(0, body.start); - el.attr("data-start", String(newStart)); - writeFileSync(indexPath, $.html(), "utf-8"); - - console.log(`[MoveClip] Updated #${elementId} data-start to ${newStart}`); - return c.json({ elementId, start: newStart }); -}); - -// Preview the final injected HTML as plain text (for debugging) -projects.get("/:id/preview", async (c) => { - const id = c.req.param("id"); - const projectDir = getProjectDir(id); - const indexPath = join(projectDir, "index.html"); - - if (!existsSync(indexPath)) { - return c.json({ error: "Project or index.html not found" }, 404); - } - - const rawHtml = readFileSync(indexPath, "utf-8"); - const compiled = await compileHtml(rawHtml, projectDir); - const html = injectInterceptor(compiled); - - return new Response(html, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "no-cache", - }, - }); -}); - -// Preview the original HTML before injection (for comparison) -projects.get("/:id/preview-raw", (c) => { - const id = c.req.param("id"); - const indexPath = join(getProjectDir(id), "index.html"); - - if (!existsSync(indexPath)) { - return c.json({ error: "Project or index.html not found" }, 404); - } - - const rawHtml = readFileSync(indexPath, "utf-8"); - - return new Response(rawHtml, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "no-cache", - }, - }); -}); - -// Serve extracted static files -projects.get("/:id/serve/*", async (c) => { - const id = c.req.param("id"); - const projectDir = getProjectDir(id); - - if (!existsSync(projectDir)) { - return c.json({ error: "Project not found" }, 404); - } - - // Get the file path after /serve/ - const url = new URL(c.req.url); - const servePrefix = `/api/projects/${id}/serve/`; - let filePath = decodeURIComponent( - url.pathname.slice(url.pathname.indexOf(servePrefix) + servePrefix.length) - ); - - if (!filePath) filePath = "index.html"; - - // Directory traversal protection - const resolvedPath = resolve(projectDir, filePath); - if (!resolvedPath.startsWith(projectDir)) { - return c.json({ error: "Forbidden" }, 403); - } - - if (!existsSync(resolvedPath) || statSync(resolvedPath).isDirectory()) { - return c.json({ error: "File not found" }, 404); - } - - const ext = extname(resolvedPath); - const contentType = mime.lookup(ext) || "application/octet-stream"; - - // For HTML files: compile timing attrs (+ ffprobe unresolved media), then inject interceptor for index.html - if (ext === ".html") { - const rawHtml = readFileSync(resolvedPath, "utf-8"); - const compiled = await compileHtml(rawHtml, projectDir); - const html = - filePath === "index.html" ? injectInterceptor(compiled) : compiled; - - return new Response(html, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-cache", - }, - }); - } - - const content = readFileSync(resolvedPath); - - return new Response(content, { - headers: { - "Content-Type": contentType, - "Cache-Control": "no-cache", - }, - }); -}); - -// --- File CRUD for CodeSandbox editor --- - -const EDITABLE_EXTENSIONS = new Set([ - ".html", - ".css", - ".js", - ".json", - ".svg", - ".txt", - ".ts", - ".jsx", - ".tsx", -]); - -function extToLanguage(ext: string): string { - const map: Record = { - ".html": "html", - ".css": "css", - ".js": "javascript", - ".jsx": "javascript", - ".ts": "typescript", - ".tsx": "typescript", - ".json": "json", - ".svg": "xml", - ".txt": "plaintext", - }; - return map[ext] || "plaintext"; -} - -// Recursively collect editable files from a directory -function collectEditableFiles( - dir: string, - baseDir: string, - results: { filename: string; language: string; size: number }[] = [] -): { filename: string; language: string; size: number }[] { - const entries = readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = join(dir, entry.name); - const relativePath = fullPath.slice(baseDir.length + 1); // +1 for trailing slash - - if (entry.isDirectory()) { - collectEditableFiles(fullPath, baseDir, results); - } else if (entry.isFile()) { - if (entry.name === "meta.json") continue; - const ext = extname(entry.name).toLowerCase(); - if (!EDITABLE_EXTENSIONS.has(ext)) continue; - - const stat = statSync(fullPath); - results.push({ - filename: relativePath, - language: extToLanguage(ext), - size: stat.size, - }); - } - } - - return results; -} - -// List editable text files (including subfolders) -projects.get("/:id/files", (c) => { - const id = c.req.param("id"); - const projectDir = getProjectDir(id); - - if (!existsSync(projectDir)) { - return c.json({ error: "Project not found" }, 404); - } - - const files = collectEditableFiles(projectDir, projectDir); - - // Sort: index.html first, then by path (folders grouped) - files.sort((a, b) => { - if (a.filename === "index.html") return -1; - if (b.filename === "index.html") return 1; - return a.filename.localeCompare(b.filename); - }); - - return c.json({ files }); -}); - -// Read file content -projects.get("/:id/files/:filename", async (c) => { - const id = c.req.param("id"); - const filename = c.req.param("filename"); - const projectDir = getProjectDir(id); - - const resolvedPath = resolve(projectDir, filename); - if (!resolvedPath.startsWith(projectDir)) { - return c.json({ error: "Forbidden" }, 403); - } - - if (!existsSync(resolvedPath) || statSync(resolvedPath).isDirectory()) { - return c.json({ error: "File not found" }, 404); - } - - const ext = extname(filename).toLowerCase(); - const raw = readFileSync(resolvedPath, "utf-8"); - const isCompiled = c.req.query("compiled") === "true"; - const content = - isCompiled && ext === ".html" ? await compileHtml(raw, projectDir) : raw; - - return c.json({ - filename, - language: extToLanguage(ext), - content, - }); -}); - -// Write file content -projects.put("/:id/files/:filename", async (c) => { - const id = c.req.param("id"); - const filename = c.req.param("filename"); - const projectDir = getProjectDir(id); - - if (!existsSync(projectDir)) { - return c.json({ error: "Project not found" }, 404); - } - - const resolvedPath = resolve(projectDir, filename); - if (!resolvedPath.startsWith(projectDir)) { - return c.json({ error: "Forbidden" }, 403); - } - - const ext = extname(filename).toLowerCase(); - if (!EDITABLE_EXTENSIONS.has(ext)) { - return c.json({ error: `File type ${ext} is not editable` }, 400); - } - - const body = await c.req.json<{ content: string }>(); - if (typeof body.content !== "string") { - return c.json({ error: "content must be a string" }, 400); - } - - // Create parent directories if they don't exist - const parentDir = dirname(resolvedPath); - if (!existsSync(parentDir)) { - mkdirSync(parentDir, { recursive: true }); - } - - writeFileSync(resolvedPath, body.content, "utf-8"); - - return c.json({ - filename, - language: extToLanguage(ext), - size: Buffer.byteLength(body.content, "utf-8"), - }); -}); diff --git a/packages/studio/backend/src/routes/render.ts b/packages/studio/backend/src/routes/render.ts deleted file mode 100644 index 895c5195..00000000 --- a/packages/studio/backend/src/routes/render.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Render Routes - * - * Endpoints for rendering sandbox-studio compositions to MP4. - * Runs the producer pipeline directly (no Docker needed for local dev). - * Docker mode can be re-enabled later for production. - */ - -import { Hono } from "hono"; -import { streamSSE } from "hono/streaming"; -import { spawn, type ChildProcess } from "child_process"; -import { existsSync, mkdirSync, statSync, createReadStream } from "fs"; -import { join, resolve } from "path"; -import { randomUUID } from "crypto"; -import { Readable } from "stream"; - -const DATA_DIR = process.env.STUDIO_DATA_DIR - ? resolve(process.env.STUDIO_DATA_DIR) - : resolve(import.meta.dirname, "../../data/projects"); -const RENDERS_DIR = process.env.STUDIO_RENDERS_DIR - ? resolve(process.env.STUDIO_RENDERS_DIR) - : resolve(import.meta.dirname, "../../data/renders"); -const PRODUCER_CLI = resolve(import.meta.dirname, "../../../../producer/src/internal-render.ts"); - -interface RenderJobState { - id: string; - projectId: string; - status: "queued" | "rendering" | "complete" | "failed"; - progress: number; - stage: string; - error?: string; - outputPath?: string; - startedAt: number; - completedAt?: number; - logs: string[]; - proc?: ChildProcess; -} - -const jobs = new Map(); - -function getProjectDir(id: string) { - return join(DATA_DIR, id); -} - -function parseProgressFromLog(line: string): { progress?: number; stage?: string } { - const progressMatch = line.match(/\[Producer\]\s+([\d.]+)%\s*-\s*(.*)/); - if (progressMatch) { - return { - progress: parseFloat(progressMatch[1] ?? ""), - stage: (progressMatch[2] ?? "").trim(), - }; - } - - const stageMatch = line.match(/\[Orchestrator\]\s+Stage\s+(\d+\/\d+):\s+(.*)/); - if (stageMatch) { - return { stage: `${stageMatch[1] ?? ""}: ${stageMatch[2] ?? ""}` }; - } - - return {}; -} - -export const renderJobs = new Hono(); -export const projectRender = new Hono(); - -// POST /api/projects/:id/render -- Start a render job -projectRender.post("/:id/render", async (c) => { - const projectId = c.req.param("id"); - const projectDir = getProjectDir(projectId); - - if (!existsSync(projectDir)) { - return c.json({ error: "Project not found" }, 404); - } - - if (!existsSync(join(projectDir, "index.html"))) { - return c.json({ error: "Project has no index.html" }, 400); - } - - const body = await c.req.json<{ debug?: boolean; sequential?: boolean }>().catch(() => ({} as { debug?: boolean; sequential?: boolean })); - const debug = body.debug ?? false; - const sequential = body.sequential ?? false; - - if (!existsSync(RENDERS_DIR)) mkdirSync(RENDERS_DIR, { recursive: true }); - - const jobId = randomUUID(); - const outputFilename = `${projectId}-${jobId.slice(0, 8)}.mp4`; - const outputPath = join(RENDERS_DIR, outputFilename); - - const job: RenderJobState = { - id: jobId, - projectId, - status: "rendering", - progress: 0, - stage: "Starting", - outputPath, - startedAt: Date.now(), - logs: [], - }; - - jobs.set(jobId, job); - - // Run the producer directly via tsx (no Docker needed for local dev) - const indexHtml = join(projectDir, "index.html"); - const args = [ - PRODUCER_CLI, - indexHtml, - "-o", outputPath, - "-f", "30", - "-q", "standard", - ...(debug ? ["--debug"] : []), - ...(sequential ? ["-w", "1"] : []), - ]; - - console.log(`[Render] Starting job ${jobId} for project ${projectId}`); - console.log(`[Render] Command: tsx ${args.join(" ")}`); - - const proc = spawn("npx", ["tsx", ...args], { - stdio: ["pipe", "pipe", "pipe"], - cwd: resolve(import.meta.dirname, "../../../.."), - }); - job.proc = proc; - - const handleOutput = (data: Buffer) => { - const lines = data.toString().split("\n").filter(Boolean); - for (const line of lines) { - job.logs.push(line); - if (job.logs.length > 1000) job.logs.shift(); - - const { progress, stage } = parseProgressFromLog(line); - if (progress !== undefined) job.progress = progress; - if (stage) job.stage = stage; - - // Log to backend console for visibility - console.log(`[Render:${jobId.slice(0, 8)}] ${line}`); - } - }; - - proc.stdout?.on("data", handleOutput); - proc.stderr?.on("data", handleOutput); - - proc.on("close", (code) => { - job.completedAt = Date.now(); - delete job.proc; - - if (code === 0 && job.outputPath && existsSync(job.outputPath)) { - job.status = "complete"; - job.progress = 100; - job.stage = "Complete"; - console.log(`[Render] Job ${jobId} complete: ${job.outputPath}`); - } else { - job.status = "failed"; - job.error = `Process exited with code ${code}`; - console.error(`[Render] Job ${jobId} failed (exit ${code})`); - } - }); - - proc.on("error", (err) => { - job.status = "failed"; - job.error = err.message; - job.completedAt = Date.now(); - delete job.proc; - console.error(`[Render] Job ${jobId} error: ${err.message}`); - }); - - return c.json({ jobId, status: "rendering" }); -}); - -// GET /api/render/:id/status -- Job status -renderJobs.get("/:id/status", (c) => { - const jobId = c.req.param("id"); - const job = jobs.get(jobId); - - if (!job) return c.json({ error: "Job not found" }, 404); - - return c.json({ - id: job.id, - projectId: job.projectId, - status: job.status, - progress: job.progress, - stage: job.stage, - error: job.error, - elapsed: job.completedAt - ? job.completedAt - job.startedAt - : Date.now() - job.startedAt, - }); -}); - -// GET /api/render/:id/progress -- SSE progress stream -renderJobs.get("/:id/progress", (c) => { - const jobId = c.req.param("id"); - const job = jobs.get(jobId); - - if (!job) return c.json({ error: "Job not found" }, 404); - - return streamSSE(c, async (stream) => { - let lastProgress = -1; - let lastStage = ""; - - const sendUpdate = async () => { - const elapsed = job.completedAt - ? job.completedAt - job.startedAt - : Date.now() - job.startedAt; - - await stream.writeSSE({ - event: "progress", - data: JSON.stringify({ - status: job.status, - progress: job.progress, - stage: job.stage, - error: job.error, - elapsed, - }), - }); - }; - - await sendUpdate(); - - while (job.status === "rendering" || job.status === "queued") { - await new Promise((resolve) => setTimeout(resolve, 500)); - - if (job.progress !== lastProgress || job.stage !== lastStage) { - lastProgress = job.progress; - lastStage = job.stage; - await sendUpdate(); - } - } - - await sendUpdate(); - }); -}); - -// GET /api/render/:id/download -- Download the rendered MP4 -renderJobs.get("/:id/download", (c) => { - const jobId = c.req.param("id"); - const job = jobs.get(jobId); - - if (!job) return c.json({ error: "Job not found" }, 404); - if (job.status !== "complete") return c.json({ error: "Render not complete" }, 400); - if (!job.outputPath || !existsSync(job.outputPath)) { - return c.json({ error: "Output file not found" }, 404); - } - - const stats = statSync(job.outputPath); - const filename = `${job.projectId}.mp4`; - - c.header("Content-Type", "video/mp4"); - c.header("Content-Length", String(stats.size)); - c.header("Content-Disposition", `attachment; filename="${filename}"`); - - const stream = createReadStream(job.outputPath); - return c.body(Readable.toWeb(stream) as ReadableStream); -}); - -// GET /api/render/:id/logs -- Get render logs -renderJobs.get("/:id/logs", (c) => { - const jobId = c.req.param("id"); - const job = jobs.get(jobId); - - if (!job) return c.json({ error: "Job not found" }, 404); - - return c.json({ logs: job.logs }); -}); diff --git a/packages/studio/backend/src/utils/ffprobe.ts b/packages/studio/backend/src/utils/ffprobe.ts deleted file mode 100644 index 8f8d9510..00000000 --- a/packages/studio/backend/src/utils/ffprobe.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { spawn } from "child_process"; - -const durationCache = new Map(); - -export async function probeMediaDuration(src: string): Promise { - const cached = durationCache.get(src); - if (cached !== undefined) return cached; - - const duration = await runFFprobe(src); - durationCache.set(src, duration); - return duration; -} - -function runFFprobe(src: string): Promise { - return new Promise((resolve, reject) => { - const args = [ - "-v", "quiet", - "-print_format", "json", - "-show_format", - src, - ]; - - const proc = spawn("ffprobe", args); - let stdout = ""; - let stderr = ""; - - proc.stdout.on("data", (d) => { stdout += d.toString(); }); - proc.stderr.on("data", (d) => { stderr += d.toString(); }); - - proc.on("close", (code) => { - if (code !== 0) { - console.warn(`[FFprobe] Failed for ${src}: exit ${code}`); - resolve(0); - return; - } - try { - const output = JSON.parse(stdout); - const duration = parseFloat(output?.format?.duration ?? "0"); - console.log(`[FFprobe] ${src.slice(-40)}: ${duration}s`); - resolve(duration); - } catch { - console.warn(`[FFprobe] Parse error for ${src}`); - resolve(0); - } - }); - - proc.on("error", (err) => { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - console.warn("[FFprobe] ffprobe not found, skipping duration resolution"); - } - resolve(0); - }); - }); -} diff --git a/packages/studio/backend/src/utils/htmlCompiler.ts b/packages/studio/backend/src/utils/htmlCompiler.ts deleted file mode 100644 index a6f375e1..00000000 --- a/packages/studio/backend/src/utils/htmlCompiler.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { resolve } from "path"; -import { compileTimingAttrs, injectDurations, type ResolvedDuration } from "@hyperframes/core"; -import { probeMediaDuration } from "./ffprobe"; - -/** - * Compile HTML with full duration resolution. - * - * 1. Static pass: compileTimingAttrs() adds data-end where data-duration exists - * 2. For unresolved video/audio (no data-duration): probe via ffprobe, inject durations - */ -export async function compileHtml(rawHtml: string, projectDir: string): Promise { - const { html: staticCompiled, unresolved } = compileTimingAttrs(rawHtml); - - const mediaUnresolved = unresolved.filter( - (el) => el.tagName === "video" || el.tagName === "audio" - ); - - if (mediaUnresolved.length === 0) return staticCompiled; - - const resolutions: ResolvedDuration[] = []; - - for (const el of mediaUnresolved) { - if (!el.src) continue; - - const src = el.src.startsWith("http://") || el.src.startsWith("https://") - ? el.src - : resolve(projectDir, el.src); - - const fileDuration = await probeMediaDuration(src); - if (fileDuration <= 0) continue; - - const effectiveDuration = fileDuration - el.mediaStart; - resolutions.push({ - id: el.id, - duration: effectiveDuration > 0 ? effectiveDuration : fileDuration, - }); - } - - if (resolutions.length === 0) return staticCompiled; - - return injectDurations(staticCompiled, resolutions); -} diff --git a/packages/studio/backend/src/utils/zip.ts b/packages/studio/backend/src/utils/zip.ts deleted file mode 100644 index 1eb6b37c..00000000 --- a/packages/studio/backend/src/utils/zip.ts +++ /dev/null @@ -1,138 +0,0 @@ -import AdmZip from "adm-zip"; -import { join } from "path"; -import { mkdirSync, writeFileSync } from "fs"; - -export interface ExtractResult { - success: boolean; - error?: string; -} - -/** - * Extract a ZIP buffer into the target directory. - * Validates that index.html exists at root or inside a single wrapper directory. - * If there's a single wrapper dir, flattens it so index.html ends up at the project root. - */ -/** Returns true if the entry is OS junk or a hidden file */ -function isHiddenEntry(entryName: string): boolean { - if (entryName.startsWith("__MACOSX")) return true; - return entryName.split("/").some((seg) => seg.startsWith(".")); -} - -export function extractZip( - buffer: Buffer, - targetDir: string, -): ExtractResult { - const zip = new AdmZip(buffer); - const entries = zip.getEntries().filter((e) => !isHiddenEntry(e.entryName)); - - if (entries.length === 0) { - return { success: false, error: "ZIP file is empty" }; - } - - // Check if index.html exists at root - const hasRootIndex = entries.some( - (e) => e.entryName === "index.html" && !e.isDirectory, - ); - - // If no index.html, check if there's a single .html file at root we can use - let renameToIndex: string | null = null; - if (!hasRootIndex) { - const rootHtmlFiles = entries.filter( - (e) => - !e.isDirectory && - !e.entryName.includes("/") && - e.entryName.endsWith(".html"), - ); - if (rootHtmlFiles.length === 1) { - renameToIndex = rootHtmlFiles[0]?.entryName ?? null; - } - } - - // Check for single wrapper directory pattern (e.g., "project/index.html") - let stripPrefix = ""; - if (!hasRootIndex && !renameToIndex) { - const topLevelDirs = new Set(); - for (const entry of entries) { - const firstSlash = entry.entryName.indexOf("/"); - if (firstSlash > 0) { - topLevelDirs.add(entry.entryName.slice(0, firstSlash)); - } - } - - if (topLevelDirs.size === 1) { - const wrapperDir = [...topLevelDirs][0] ?? ""; - const hasNestedIndex = entries.some( - (e) => - e.entryName === `${wrapperDir}/index.html` && !e.isDirectory, - ); - if (hasNestedIndex) { - stripPrefix = `${wrapperDir}/`; - } else { - // Check for single .html in wrapper dir - const nestedHtmlFiles = entries.filter( - (e) => - !e.isDirectory && - e.entryName.startsWith(`${wrapperDir}/`) && - !e.entryName.slice(wrapperDir.length + 1).includes("/") && - e.entryName.endsWith(".html"), - ); - if (nestedHtmlFiles.length === 1) { - stripPrefix = `${wrapperDir}/`; - renameToIndex = nestedHtmlFiles[0]?.entryName.slice(stripPrefix.length) ?? null; - } else { - const found = entries - .filter((e) => !e.isDirectory) - .map((e) => e.entryName) - .join(", "); - return { - success: false, - error: `ZIP must contain index.html (or a single .html file). Found: ${found}`, - }; - } - } - } else { - const found = entries - .filter((e) => !e.isDirectory) - .map((e) => e.entryName) - .join(", "); - return { - success: false, - error: `ZIP must contain index.html (or a single .html file). Found: ${found}`, - }; - } - } - - // Extract files - for (const entry of entries) { - let relativePath = entry.entryName; - - // Strip wrapper directory prefix if needed - if (stripPrefix && relativePath.startsWith(stripPrefix)) { - relativePath = relativePath.slice(stripPrefix.length); - } else if (stripPrefix) { - continue; // Skip files outside the wrapper (e.g., __MACOSX) - } - - if (!relativePath || relativePath === "/") continue; - - // Directory traversal protection - if (relativePath.includes("..")) continue; - - // Rename single .html file to index.html - if (renameToIndex && relativePath === renameToIndex) { - relativePath = "index.html"; - } - - const fullPath = join(targetDir, relativePath); - - if (entry.isDirectory) { - mkdirSync(fullPath, { recursive: true }); - } else { - const dir = fullPath.substring(0, fullPath.lastIndexOf("/")); - mkdirSync(dir, { recursive: true }); - writeFileSync(fullPath, entry.getData()); - } - } - - return { success: true }; -} diff --git a/packages/studio/backend/tsconfig.json b/packages/studio/backend/tsconfig.json deleted file mode 100644 index 205abe67..00000000 --- a/packages/studio/backend/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "esModuleInterop": true, - "strict": true, - "noUncheckedIndexedAccess": true, - "skipLibCheck": true, - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/packages/studio/frontend/index.html b/packages/studio/frontend/index.html deleted file mode 100644 index 0fa1458a..00000000 --- a/packages/studio/frontend/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - Sandbox Studio - - - - - -
- - - diff --git a/packages/studio/frontend/package.json b/packages/studio/frontend/package.json deleted file mode 100644 index 8f46c7e5..00000000 --- a/packages/studio/frontend/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@hyperframes/studio-frontend", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@monaco-editor/react": "^4.7.0", - "lucide-react": "^0.563.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "zustand": "^4.5.0" - }, - "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitejs/plugin-react": "^4.0.0", - "autoprefixer": "^10.4.0", - "postcss": "^8.4.0", - "tailwindcss": "^3.4.0", - "typescript": "^5.0.0", - "vite": "^5.0.0" - } -} diff --git a/packages/studio/frontend/src/App.tsx b/packages/studio/frontend/src/App.tsx deleted file mode 100644 index bc38a7df..00000000 --- a/packages/studio/frontend/src/App.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useState, useEffect } from "react"; -import { HomePage } from "./components/HomePage"; -import { ProjectPage } from "./components/ProjectPage"; - -function parseHash(): { page: "home" | "project"; projectId?: string } { - const hash = window.location.hash || "#/"; - const match = hash.match(/^#\/project\/(.+)$/); - if (match) return { page: "project", projectId: match[1] }; - return { page: "home" }; -} - -export default function App() { - const [route, setRoute] = useState(parseHash); - - useEffect(() => { - const onHashChange = () => setRoute(parseHash()); - window.addEventListener("hashchange", onHashChange); - return () => window.removeEventListener("hashchange", onHashChange); - }, []); - - if (route.page === "project" && route.projectId) { - return ; - } - - return ; -} diff --git a/packages/studio/frontend/src/api/client.ts b/packages/studio/frontend/src/api/client.ts deleted file mode 100644 index 0243bae2..00000000 --- a/packages/studio/frontend/src/api/client.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Schema } from "./validate"; - -const BASE_URL = "/api"; - -export async function apiFetch( - path: string, - options?: RequestInit, -): Promise { - const res = await fetch(`${BASE_URL}${path}`, options); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `Request failed: ${res.status}`); - } - return res.json(); -} - -export function validatedApiFetch(schema: Schema) { - return async (path: string, options?: RequestInit): Promise => { - const res = await fetch(`${BASE_URL}${path}`, options); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `Request failed: ${res.status}`); - } - const json: unknown = await res.json(); - return schema.parse(json); - }; -} diff --git a/packages/studio/frontend/src/api/files.ts b/packages/studio/frontend/src/api/files.ts deleted file mode 100644 index c5fa4f4d..00000000 --- a/packages/studio/frontend/src/api/files.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { apiFetch, validatedApiFetch } from "./client"; -import { FileContentSchema, FileListResponseSchema } from "./schemas"; -import type { Infer } from "./validate"; - -export type ProjectFile = Infer["files"][number]; - -export type FileContent = Infer; - -const fetchFileList = validatedApiFetch(FileListResponseSchema); -const fetchFileContent = validatedApiFetch(FileContentSchema); - -export async function listProjectFiles(projectId: string): Promise { - const data = await fetchFileList(`/projects/${projectId}/files`); - return data.files; -} - -export async function getFileContent(projectId: string, filename: string, compiled?: boolean): Promise { - const qs = compiled ? "?compiled=true" : ""; - return fetchFileContent(`/projects/${projectId}/files/${encodeURIComponent(filename)}${qs}`); -} - -export async function saveFileContent(projectId: string, filename: string, content: string): Promise { - await apiFetch(`/projects/${projectId}/files/${encodeURIComponent(filename)}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); -} diff --git a/packages/studio/frontend/src/api/projects.ts b/packages/studio/frontend/src/api/projects.ts deleted file mode 100644 index 06e41157..00000000 --- a/packages/studio/frontend/src/api/projects.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { apiFetch, validatedApiFetch } from "./client"; -import { - ProjectMetaSchema, - ProjectPresenceResponseSchema, - HeartbeatResponseSchema, -} from "./schemas"; -import { v } from "./validate"; -import type { Infer } from "./validate"; - -export type ProjectMeta = Infer; - -export interface PresenceSession { - sessionId: string; - filePath?: string; - line?: number; - column?: number; - color?: string; - lastSeen: number; -} - -export interface PresenceHeartbeatBody { - sessionId: string; - filePath?: string; - line?: number; - column?: number; - color?: string; -} - -export type ProjectPresenceResponse = Infer; - -export const COLLAB_CURSOR_ENABLED = Boolean( - (globalThis as { __HF_COLLAB_CURSOR_ENABLED?: boolean }).__HF_COLLAB_CURSOR_ENABLED -); - -const fetchProjects = validatedApiFetch(v.array(ProjectMetaSchema)); -const fetchProject = validatedApiFetch(ProjectMetaSchema); -const fetchPresence = validatedApiFetch(ProjectPresenceResponseSchema); -const fetchHeartbeat = validatedApiFetch(HeartbeatResponseSchema); - -export async function listProjects(): Promise { - return fetchProjects("/projects"); -} - -export async function getProject(id: string): Promise { - return fetchProject(`/projects/${id}`); -} - -export async function uploadProject( - file: File, - name: string -): Promise { - const formData = new FormData(); - formData.append("file", file); - formData.append("name", name); - - return fetchProject("/projects/upload", { - method: "POST", - body: formData, - }); -} - -export async function deleteProject(id: string): Promise { - await apiFetch(`/projects/${id}`, { method: "DELETE" }); -} - -export async function updateElementStart( - projectId: string, - elementId: string, - start: number -): Promise { - await apiFetch( - `/projects/${projectId}/elements/${encodeURIComponent(elementId)}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ start }), - } - ); -} - -export async function heartbeatProjectPresence( - projectId: string, - body: PresenceHeartbeatBody -): Promise<{ enabled: boolean }> { - return fetchHeartbeat(`/projects/${projectId}/presence/heartbeat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); -} - -export async function getProjectPresence( - projectId: string -): Promise { - return fetchPresence(`/projects/${projectId}/presence`); -} diff --git a/packages/studio/frontend/src/api/render.ts b/packages/studio/frontend/src/api/render.ts deleted file mode 100644 index 6c465d59..00000000 --- a/packages/studio/frontend/src/api/render.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { validatedApiFetch } from "./client"; -import { StartRenderResponseSchema, RenderProgressSchema } from "./schemas"; -import type { Infer } from "./validate"; - -export type RenderProgress = Infer; - -const fetchStartRender = validatedApiFetch(StartRenderResponseSchema); - -export async function startRender(projectId: string, options?: { debug?: boolean; sequential?: boolean }): Promise { - const res = await fetchStartRender( - `/projects/${projectId}/render`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - debug: options?.debug ?? false, - sequential: options?.sequential ?? false, - }), - } - ); - return res.jobId; -} - -export function subscribeProgress( - jobId: string, - onProgress: (data: RenderProgress) => void, - onError?: (error: Event) => void -): () => void { - const eventSource = new EventSource(`/api/render/${jobId}/progress`); - - eventSource.addEventListener("progress", (event) => { - try { - const result = RenderProgressSchema.safeParse(JSON.parse(event.data)); - if (result.success) { - onProgress(result.data); - } - } catch (err: unknown) { - // Malformed JSON — ignore - } - }); - - if (onError) { - eventSource.onerror = onError; - } - - return () => eventSource.close(); -} - -export function getDownloadUrl(jobId: string): string { - return `/api/render/${jobId}/download`; -} diff --git a/packages/studio/frontend/src/api/schemas.ts b/packages/studio/frontend/src/api/schemas.ts deleted file mode 100644 index f5035b41..00000000 --- a/packages/studio/frontend/src/api/schemas.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { v } from "./validate"; - -// ── Project schemas ───────────────────────────────────────────────────────── - -export const ProjectMetaSchema = v.object({ - id: v.string(), - name: v.string(), - createdAt: v.string(), - width: v.number().nullable().optional(), - height: v.number().nullable().optional(), -}); - -export const PresenceSessionSchema = v.object({ - sessionId: v.string(), - filePath: v.string().optional(), - line: v.number().optional(), - column: v.number().optional(), - color: v.string().optional(), - lastSeen: v.number(), -}); - -export const ProjectPresenceResponseSchema = v.object({ - enabled: v.boolean(), - ttlMs: v.number(), - sessions: v.array(PresenceSessionSchema), -}); - -export const HeartbeatResponseSchema = v.object({ - enabled: v.boolean(), -}); - -// ── File schemas ──────────────────────────────────────────────────────────── - -export const ProjectFileSchema = v.object({ - filename: v.string(), - language: v.string(), - size: v.number(), -}); - -export const FileContentSchema = v.object({ - filename: v.string(), - language: v.string(), - content: v.string(), -}); - -export const FileListResponseSchema = v.object({ - files: v.array(ProjectFileSchema), -}); - -// ── Render schemas ────────────────────────────────────────────────────────── - -export const StartRenderResponseSchema = v.object({ - jobId: v.string(), - status: v.string(), -}); - -export const RenderProgressSchema = v.object({ - status: v.enum(["queued", "rendering", "complete", "failed"]), - progress: v.number(), - stage: v.string(), - error: v.string().optional(), - elapsed: v.number(), -}); diff --git a/packages/studio/frontend/src/api/validate.ts b/packages/studio/frontend/src/api/validate.ts deleted file mode 100644 index a99e7105..00000000 --- a/packages/studio/frontend/src/api/validate.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Lightweight Zod-like schema validation — zero dependencies. - * Supports: string, number, boolean, enum, array, object, optional, nullable. - * Usage mirrors Zod: v.object({ id: v.string() }).parse(data) - */ - -export interface Schema { - parse(d: unknown): T; - safeParse(d: unknown): { success: true; data: T } | { success: false; error: Error }; - optional(): Schema; - nullable(): Schema; -} - -function schema(parse: (d: unknown) => T): Schema { - return { - parse, - safeParse(d: unknown) { - try { - return { success: true, data: parse(d) }; - } catch (e) { - return { success: false, error: e instanceof Error ? e : new Error(String(e)) }; - } - }, - optional(): Schema { - return schema((d) => (d === undefined ? undefined : parse(d))); - }, - nullable(): Schema { - return schema((d) => (d === null ? null : parse(d))); - }, - }; -} - -function string(): Schema { - return schema((d) => { - if (typeof d !== "string") throw new Error(`expected string, got ${typeof d}`); - return d; - }); -} - -function number(): Schema { - return schema((d) => { - if (typeof d !== "number") throw new Error(`expected number, got ${typeof d}`); - return d; - }); -} - -function boolean(): Schema { - return schema((d) => { - if (typeof d !== "boolean") throw new Error(`expected boolean, got ${typeof d}`); - return d; - }); -} - -function enumType(values: readonly [T, ...T[]]): Schema { - const set = new Set(values); - return schema((d) => { - if (typeof d !== "string" || !set.has(d)) { - throw new Error(`expected one of [${values.join(", ")}], got ${JSON.stringify(d)}`); - } - return d as T; - }); -} - -function array(itemSchema: Schema): Schema { - return schema((d) => { - if (!Array.isArray(d)) throw new Error(`expected array, got ${typeof d}`); - return d.map((item, i) => { - try { - return itemSchema.parse(item); - } catch (e) { - throw new Error(`[${i}]: ${e instanceof Error ? e.message : e}`); - } - }); - }); -} - -type ObjectShape = Record>; -type Infer = S extends Schema ? T : never; -type InferShape = { [K in keyof S]: Infer }; - -function object(shape: S): Schema> { - return schema((d) => { - if (typeof d !== "object" || d === null || Array.isArray(d)) { - throw new Error(`expected object, got ${typeof d}`); - } - const obj = d as Record; - const result: Record = {}; - for (const key of Object.keys(shape)) { - const field = shape[key]; - if (!field) continue; - try { - result[key] = field.parse(obj[key]); - } catch (e) { - throw new Error(`${key}: ${e instanceof Error ? e.message : e}`); - } - } - return result as InferShape; - }); -} - -export type { Infer }; - -export const v = { - string, - number, - boolean, - enum: enumType, - array, - object, -}; diff --git a/packages/studio/frontend/src/components/CodeSandbox.tsx b/packages/studio/frontend/src/components/CodeSandbox.tsx deleted file mode 100644 index ae5a27a9..00000000 --- a/packages/studio/frontend/src/components/CodeSandbox.tsx +++ /dev/null @@ -1,607 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { ArrowLeft, Monitor, Smartphone } from "lucide-react"; -import Editor from "@monaco-editor/react"; -import { - listProjectFiles, - getFileContent, - saveFileContent, - type ProjectFile, -} from "../api/files"; -import { - COLLAB_CURSOR_ENABLED, - getProjectPresence, - heartbeatProjectPresence, - type PresenceSession, -} from "../api/projects"; -import { usePlayerStore } from "../store/playerStore"; -import { useTimelinePlayer } from "../hooks/useTimelinePlayer"; -import { Player } from "./Player"; -import { PlayerControls } from "./PlayerControls"; -import { Timeline } from "./Timeline"; -import { FileTree } from "./FileTree"; -import { FileTabs } from "./FileTabs"; - -const SESSION_STORAGE_KEY = "hf.browserSessionId"; -const PRESENCE_POLL_MS = 4000; - -type CursorPosition = { - lineNumber: number; - column: number; -}; - -type EditorLike = { - getPosition: () => CursorPosition | null; - onDidChangeCursorPosition: ( - listener: (event: { position: CursorPosition }) => void - ) => { dispose: () => void }; - deltaDecorations: ( - oldDecorations: string[], - newDecorations: unknown[] - ) => string[]; - getModel: () => { - getLineCount: () => number; - getLineMaxColumn: (lineNumber: number) => number; - } | null; -}; - -type MonacoLike = { - Range: new ( - startLineNumber: number, - startColumn: number, - endLineNumber: number, - endColumn: number - ) => unknown; - editor: { - TrackedRangeStickiness: { - NeverGrowsWhenTypingAtEdges: number; - }; - }; -}; - -function getOrCreateBrowserSessionId(): string { - if (typeof window === "undefined") return "server-session"; - const existing = window.sessionStorage.getItem(SESSION_STORAGE_KEY); - if (existing) return existing; - - const id = - typeof window.crypto?.randomUUID === "function" - ? window.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - window.sessionStorage.setItem(SESSION_STORAGE_KEY, id); - return id; -} - -interface CodeSandboxProps { - projectId: string; - projectName: string; - initialPortrait?: boolean; - onExit: () => void; -} - -export function CodeSandbox({ - projectId, - projectName, - initialPortrait = true, - onExit, -}: CodeSandboxProps) { - const [files, setFiles] = useState([]); - const [openFiles, setOpenFiles] = useState([]); - const [activeFile, setActiveFile] = useState(null); - const [fileContents, setFileContents] = useState>( - new Map() - ); - const [dirtyFiles, setDirtyFiles] = useState>(new Set()); - const [fileLanguages, setFileLanguages] = useState>( - new Map() - ); - const [portrait, setPortrait] = useState(initialPortrait); - const [showCompiled, setShowCompiled] = useState(false); - const [compiledContents, setCompiledContents] = useState>( - new Map() - ); - - const { isPlaying, currentTime, duration, timelineReady } = usePlayerStore(); - const { iframeRef, togglePlay, seek, onIframeLoad } = useTimelinePlayer(); - - const saveTimerRef = useRef | null>(null); - const dirtyRef = useRef>(dirtyFiles); - const contentsRef = useRef>(fileContents); - const restoreTimeRef = useRef(null); - const sessionIdRef = useRef(getOrCreateBrowserSessionId()); - const remoteCursorRef = useRef<{ - filePath?: string; - line?: number; - column?: number; - } | null>(null); - const activeFileRef = useRef(activeFile); - const editorRef = useRef(null); - const monacoRef = useRef(null); - const cursorListenerRef = useRef<{ dispose: () => void } | null>(null); - const decorationIdsRef = useRef([]); - const [remotePresence, setRemotePresence] = useState([]); - - // Keep refs in sync - dirtyRef.current = dirtyFiles; - contentsRef.current = fileContents; - activeFileRef.current = activeFile; - - // Load file list on mount - useEffect(() => { - listProjectFiles(projectId).then((projectFiles) => { - setFiles(projectFiles); - const langs = new Map(); - for (const f of projectFiles) langs.set(f.filename, f.language); - setFileLanguages(langs); - - // Auto-open index.html - const indexFile = projectFiles.find((f) => f.filename === "index.html"); - if (indexFile) { - setOpenFiles(["index.html"]); - setActiveFile("index.html"); - loadFileContent("index.html"); - } else if (projectFiles.length > 0) { - const first = projectFiles[0]?.filename; - if (!first) return; - setOpenFiles([first]); - setActiveFile(first); - loadFileContent(first); - } - }); - }, [projectId]); - - // After a reload, restore playback position once the timeline is ready again - useEffect(() => { - if (timelineReady && restoreTimeRef.current !== null) { - const t = restoreTimeRef.current; - restoreTimeRef.current = null; - // Small delay to ensure the player is fully initialized - setTimeout(() => seek(t), 50); - } - }, [timelineReady, seek]); - - async function loadFileContent(filename: string) { - if (contentsRef.current.has(filename)) return; - const data = await getFileContent(projectId, filename); - setFileContents((prev) => new Map(prev).set(filename, data.content)); - } - - async function loadCompiledContent(filename: string) { - const data = await getFileContent(projectId, filename, true); - setCompiledContents((prev) => new Map(prev).set(filename, data.content)); - } - - useEffect(() => { - if ( - showCompiled && - activeFile?.endsWith(".html") && - !compiledContents.has(activeFile) - ) { - loadCompiledContent(activeFile); - } - }, [showCompiled, activeFile, compiledContents]); - - function handleFileClick(filename: string) { - if (!openFiles.includes(filename)) { - setOpenFiles((prev) => [...prev, filename]); - } - setActiveFile(filename); - loadFileContent(filename); - } - - function handleTabClose(filename: string) { - setOpenFiles((prev) => { - const next = prev.filter((f) => f !== filename); - if (activeFile === filename) { - setActiveFile(next.length > 0 ? next[next.length - 1] ?? null : null); - } - return next; - }); - } - - async function handleNewFile(filename: string) { - if (files.some((f) => f.filename === filename)) return; - await saveFileContent(projectId, filename, ""); - const ext = filename.includes(".") - ? filename.slice(filename.lastIndexOf(".")) - : ""; - const langMap: Record = { - ".html": "html", - ".css": "css", - ".js": "javascript", - ".jsx": "javascript", - ".ts": "typescript", - ".tsx": "typescript", - ".json": "json", - ".svg": "xml", - ".txt": "plaintext", - }; - const lang = langMap[ext] || "plaintext"; - const newFile: ProjectFile = { filename, language: lang, size: 0 }; - setFiles((prev) => [...prev, newFile]); - setFileLanguages((prev) => new Map(prev).set(filename, lang)); - setFileContents((prev) => new Map(prev).set(filename, "")); - setOpenFiles((prev) => [...prev, filename]); - setActiveFile(filename); - } - - const flushDirtyFiles = useCallback(async () => { - const dirty = new Set(dirtyRef.current); - if (dirty.size === 0) return; - - const contents = contentsRef.current; - const saves = Array.from(dirty).map((f) => - saveFileContent(projectId, f, contents.get(f) ?? "") - ); - await Promise.all(saves); - - setDirtyFiles((prev) => { - const next = new Set(prev); - for (const f of dirty) next.delete(f); - return next; - }); - - // Invalidate compiled cache for saved HTML files - setCompiledContents((prev) => { - const next = new Map(prev); - for (const f of dirty) { - if (f.endsWith(".html")) next.delete(f); - } - return next; - }); - - // Reload preview, preserving playback position - const iframe = iframeRef.current; - if (iframe) { - restoreTimeRef.current = usePlayerStore.getState().currentTime; - // Reset timelineReady so the restore effect fires on next ready - usePlayerStore.getState().setTimelineReady(false); - iframe.src = iframe.src; - } - }, [projectId, iframeRef]); - - function handleEditorChange(value: string | undefined) { - if (!activeFile || value === undefined) return; - - setFileContents((prev) => new Map(prev).set(activeFile, value)); - setDirtyFiles((prev) => new Set(prev).add(activeFile)); - - // Debounced save - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(flushDirtyFiles, 800); - } - - // Cleanup timer on unmount - useEffect(() => { - return () => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - }; - }, []); - - useEffect(() => { - if (!COLLAB_CURSOR_ENABLED) return; - if (!activeFile) { - remoteCursorRef.current = null; - return; - } - - const position = editorRef.current?.getPosition(); - remoteCursorRef.current = { - filePath: activeFile, - line: position?.lineNumber ?? 1, - column: position?.column ?? 1, - }; - }, [activeFile]); - - useEffect(() => { - if (!COLLAB_CURSOR_ENABLED) { - setRemotePresence([]); - return; - } - - let cancelled = false; - - const syncPresence = async () => { - try { - const cursor = remoteCursorRef.current; - await heartbeatProjectPresence(projectId, { - sessionId: sessionIdRef.current, - filePath: cursor?.filePath ?? activeFileRef.current ?? undefined, - line: cursor?.line, - column: cursor?.column, - }); - - const response = await getProjectPresence(projectId); - if (cancelled || !response.enabled) return; - setRemotePresence( - response.sessions.filter( - (session) => session.sessionId !== sessionIdRef.current - ) - ); - } catch { - if (!cancelled) { - setRemotePresence([]); - } - } - }; - - void syncPresence(); - const intervalId = window.setInterval(() => { - void syncPresence(); - }, PRESENCE_POLL_MS); - - return () => { - cancelled = true; - window.clearInterval(intervalId); - setRemotePresence([]); - }; - }, [projectId]); - - useEffect(() => { - const editor = editorRef.current; - const monaco = monacoRef.current; - if (!editor) return; - - if (!COLLAB_CURSOR_ENABLED || !activeFile || !monaco) { - decorationIdsRef.current = editor.deltaDecorations( - decorationIdsRef.current, - [] - ); - return; - } - - const model = editor.getModel(); - if (!model) return; - - const decorations = remotePresence - .filter( - (session) => - session.filePath === activeFile && - typeof session.line === "number" && - typeof session.column === "number" - ) - .map((session) => { - const line = Math.min( - Math.max(1, Math.floor(session.line ?? 1)), - model.getLineCount() - ); - const maxColumn = model.getLineMaxColumn(line); - const column = Math.min( - Math.max(1, Math.floor(session.column ?? 1)), - maxColumn - ); - const endColumn = Math.min(column + 1, maxColumn); - - return { - range: new monaco.Range( - line, - column, - line, - Math.max(column, endColumn) - ), - options: { - className: "hf-remote-cursor-blink", - stickiness: - monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, - hoverMessage: { - value: `Session ${session.sessionId.slice(0, 8)}`, - }, - }, - }; - }); - - decorationIdsRef.current = editor.deltaDecorations( - decorationIdsRef.current, - decorations - ); - }, [activeFile, remotePresence]); - - const handleEditorMount = useCallback((editor: unknown, monaco: unknown) => { - editorRef.current = editor as EditorLike; - monacoRef.current = monaco as MonacoLike; - - cursorListenerRef.current?.dispose(); - cursorListenerRef.current = editorRef.current.onDidChangeCursorPosition( - (event) => { - if (!COLLAB_CURSOR_ENABLED || !activeFileRef.current) return; - remoteCursorRef.current = { - filePath: activeFileRef.current, - line: event.position.lineNumber, - column: event.position.column, - }; - } - ); - }, []); - - useEffect(() => { - return () => { - cursorListenerRef.current?.dispose(); - const editor = editorRef.current; - if (editor) { - decorationIdsRef.current = editor.deltaDecorations( - decorationIdsRef.current, - [] - ); - } - }; - }, []); - - const isHtmlFile = activeFile?.endsWith(".html") ?? false; - const isCompiledView = showCompiled && isHtmlFile; - const currentContent = activeFile - ? isCompiledView - ? compiledContents.get(activeFile) - : fileContents.get(activeFile) - : undefined; - const currentLanguage = activeFile - ? fileLanguages.get(activeFile) ?? "plaintext" - : "plaintext"; - - return ( -
- - {/* Minimal header */} -
- - - {projectName} - - -
- - {/* Three-panel layout */} -
- {/* File tree */} - - - {/* Editor area */} -
- { - setActiveFile(f); - loadFileContent(f); - }} - onTabClose={handleTabClose} - /> - {/* Source/Compiled toggle hidden for now - {isHtmlFile && ( -
-
- - -
- {isCompiledView && ( - - Read-only - - )} -
- )} - */} -
- {activeFile && currentContent !== undefined ? ( - - ) : ( -
- {activeFile ? "Loading..." : "Select a file to edit"} -
- )} -
-
- - {/* Preview pane with player + controls + timeline */} -
-
- - Preview - -
- - -
-
-
- -
-
- - -
-
-
-
- ); -} diff --git a/packages/studio/frontend/src/components/FileTabs.tsx b/packages/studio/frontend/src/components/FileTabs.tsx deleted file mode 100644 index e823997c..00000000 --- a/packages/studio/frontend/src/components/FileTabs.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { X } from "lucide-react"; - -interface FileTabsProps { - openFiles: string[]; - activeFile: string | null; - dirtyFiles: Set; - onTabClick: (filename: string) => void; - onTabClose: (filename: string) => void; -} - -function getDisplayName(path: string, allPaths: string[]): string { - const name = path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path; - // Check if there are duplicate filenames - if so, show parent folder - const duplicates = allPaths.filter((p) => { - const n = p.includes("/") ? p.slice(p.lastIndexOf("/") + 1) : p; - return n === name; - }); - if (duplicates.length > 1 && path.includes("/")) { - const parts = path.split("/"); - return parts.slice(-2).join("/"); - } - return name; -} - -export function FileTabs({ openFiles, activeFile, dirtyFiles, onTabClick, onTabClose }: FileTabsProps) { - return ( -
- {openFiles.map((filename) => ( -
onTabClick(filename)} - title={filename} - className={`flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer border-r border-neutral-200 min-w-0 ${ - activeFile === filename - ? "bg-white text-neutral-800" - : "text-neutral-500 hover:text-neutral-700 hover:bg-neutral-50" - }`} - > - {getDisplayName(filename, openFiles)} - {dirtyFiles.has(filename) && ( - - )} - {openFiles.length > 1 && ( - - )} -
- ))} -
- ); -} diff --git a/packages/studio/frontend/src/components/FileTree.tsx b/packages/studio/frontend/src/components/FileTree.tsx deleted file mode 100644 index 8dab842b..00000000 --- a/packages/studio/frontend/src/components/FileTree.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { Plus, ChevronRight, ChevronDown, Folder } from "lucide-react"; -import { useState, useMemo } from "react"; -import type { ProjectFile } from "../api/files"; - -interface FileTreeProps { - files: ProjectFile[]; - activeFile: string | null; - onFileClick: (filename: string) => void; - onNewFile: (filename: string) => void; -} - -const FILE_COLORS: Record = { - html: "bg-orange-400", - css: "bg-blue-400", - javascript: "bg-yellow-400", - typescript: "bg-blue-500", - json: "bg-green-400", - xml: "bg-purple-400", - plaintext: "bg-neutral-400", -}; - -interface TreeNode { - name: string; - path: string; - isFolder: boolean; - language?: string; - children: TreeNode[]; -} - -function buildTree(files: ProjectFile[]): TreeNode[] { - const root: TreeNode[] = []; - - for (const file of files) { - const parts = file.filename.split("/"); - let currentLevel = root; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (!part) continue; - const isFile = i === parts.length - 1; - const path = parts.slice(0, i + 1).join("/"); - - let existing = currentLevel.find((n) => n.name === part); - if (!existing) { - existing = { - name: part, - path, - isFolder: !isFile, - language: isFile ? file.language : undefined, - children: [], - }; - currentLevel.push(existing); - } - currentLevel = existing.children; - } - } - - // Sort: folders first, then files, alphabetically - function sortNodes(nodes: TreeNode[]) { - nodes.sort((a, b) => { - // index.html at root always first - if (a.path === "index.html") return -1; - if (b.path === "index.html") return 1; - if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; - return a.name.localeCompare(b.name); - }); - for (const node of nodes) { - if (node.children.length > 0) sortNodes(node.children); - } - } - sortNodes(root); - - return root; -} - -interface TreeItemProps { - node: TreeNode; - depth: number; - activeFile: string | null; - expandedFolders: Set; - onFileClick: (path: string) => void; - onToggleFolder: (path: string) => void; -} - -function TreeItem({ node, depth, activeFile, expandedFolders, onFileClick, onToggleFolder }: TreeItemProps) { - const isExpanded = expandedFolders.has(node.path); - const paddingLeft = 12 + depth * 12; - - if (node.isFolder) { - return ( - <> - - {isExpanded && - node.children.map((child) => ( - - ))} - - ); - } - - return ( - - ); -} - -export function FileTree({ files, activeFile, onFileClick, onNewFile }: FileTreeProps) { - const [isCreating, setIsCreating] = useState(false); - const [newFileName, setNewFileName] = useState(""); - const [expandedFolders, setExpandedFolders] = useState>(new Set()); - - const tree = useMemo(() => buildTree(files), [files]); - - function handleCreate() { - const name = newFileName.trim(); - if (!name) { - setIsCreating(false); - return; - } - onNewFile(name); - setNewFileName(""); - setIsCreating(false); - } - - function handleToggleFolder(path: string) { - setExpandedFolders((prev) => { - const next = new Set(prev); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return next; - }); - } - - return ( -
-
- Files -
-
- {tree.map((node) => ( - - ))} -
- -
- {isCreating ? ( - setNewFileName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleCreate(); - if (e.key === "Escape") { setIsCreating(false); setNewFileName(""); } - }} - onBlur={handleCreate} - placeholder="path/to/file.ext" - className="w-full bg-white border border-neutral-300 rounded px-2 py-1 text-sm text-neutral-800 placeholder-neutral-400 outline-none focus:border-blue-500" - /> - ) : ( - - )} -
-
- ); -} diff --git a/packages/studio/frontend/src/components/HomePage.tsx b/packages/studio/frontend/src/components/HomePage.tsx deleted file mode 100644 index c95659c0..00000000 --- a/packages/studio/frontend/src/components/HomePage.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { UploadZone } from "./UploadZone"; -import { ProjectList } from "./ProjectList"; -import { - listProjects, - deleteProject, - type ProjectMeta, -} from "../api/projects"; - -export function HomePage() { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - - const fetchProjects = useCallback(async () => { - try { - const data = await listProjects(); - setProjects(data); - } catch (err) { - console.error("Failed to load projects:", err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchProjects(); - }, [fetchProjects]); - - const handleDelete = async (id: string) => { - try { - await deleteProject(id); - setProjects((prev) => prev.filter((p) => p.id !== id)); - } catch (err) { - console.error("Failed to delete project:", err); - } - }; - - return ( -
-
-

- Sandbox Studio -

-

- Upload a GSAP project ZIP and play it back with video controls. -

- -
- { - window.location.hash = `#/project/${projectId}`; - }} - /> -
- -

- Projects -

- {loading ? ( -

Loading...

- ) : ( - - )} -
-
- ); -} diff --git a/packages/studio/frontend/src/components/HtmlPreview.tsx b/packages/studio/frontend/src/components/HtmlPreview.tsx deleted file mode 100644 index 7973f2f8..00000000 --- a/packages/studio/frontend/src/components/HtmlPreview.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useState, useEffect, useMemo } from "react"; -import { Copy, Check } from "lucide-react"; - -interface HtmlPreviewProps { - projectId: string; - version: number; -} - -function formatElement(el: Element): string { - const tag = el.tagName.toLowerCase(); - const attrs: string[] = []; - - for (const attr of el.attributes) { - attrs.push(`${attr.name}="${attr.value}"`); - } - - if (attrs.length === 0) return `<${tag} />`; - - // One attribute per line, indented - return `<${tag}\n${attrs.map((a) => ` ${a}`).join("\n")}\n/>`; -} - -function extractTimelineElements(html: string): string { - const doc = new DOMParser().parseFromString(html, "text/html"); - const els = doc.querySelectorAll("[data-start]"); - if (els.length === 0) return ""; - - return Array.from(els).map(formatElement).join("\n\n"); -} - -export function HtmlPreview({ projectId, version }: HtmlPreviewProps) { - const [rawHtml, setRawHtml] = useState(null); - const [loading, setLoading] = useState(true); - const [copied, setCopied] = useState(false); - - useEffect(() => { - let cancelled = false; - setLoading(true); - fetch(`/api/projects/${projectId}/preview-raw`) - .then((res) => { - if (!res.ok) throw new Error("Failed to fetch"); - return res.text(); - }) - .then((text) => { - if (!cancelled) setRawHtml(text); - }) - .catch(() => { - if (!cancelled) setRawHtml(null); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { cancelled = true; }; - }, [projectId, version]); - - const filtered = useMemo( - () => (rawHtml ? extractTimelineElements(rawHtml) : null), - [rawHtml] - ); - - const handleCopy = async () => { - if (!filtered) return; - await navigator.clipboard.writeText(filtered); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( -
- {/* Header */} -
- - Timeline Elements - - -
- - {/* Content */} -
- {loading ? ( -
-
Loading...
-
- ) : ( -
-            {filtered ?? ""}
-          
- )} -
-
- ); -} diff --git a/packages/studio/frontend/src/components/Player.tsx b/packages/studio/frontend/src/components/Player.tsx deleted file mode 100644 index a9b21aa3..00000000 --- a/packages/studio/frontend/src/components/Player.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { forwardRef, useRef, useState, useEffect, useCallback } from "react"; - -const NATIVE_W = 1920; -const NATIVE_H = 1080; - -interface PlayerProps { - projectId: string; - onLoad: () => void; - portrait?: boolean; -} - -export const Player = forwardRef( - ({ projectId, onLoad, portrait }, ref) => { - const containerRef = useRef(null); - const [scale, setScale] = useState(1); - - const w = portrait ? NATIVE_H : NATIVE_W; - const h = portrait ? NATIVE_W : NATIVE_H; - - const updateScale = useCallback(() => { - const el = containerRef.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - setScale(Math.min(rect.width / w, rect.height / h)); - }, [w, h]); - - useEffect(() => { - updateScale(); - const ro = new ResizeObserver(updateScale); - if (containerRef.current) ro.observe(containerRef.current); - return () => ro.disconnect(); - }, [updateScale]); - - return ( -
-