From 7ab54241e40ebee286b966a79ae7c6a8642795e8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 10 Apr 2026 22:36:38 -0500 Subject: [PATCH 1/5] studio, streaming compositor, npm binary distribution - Add studio app with live preview, recording controls, and config editing - Implement single-pass streaming compositor for inline frame compositing - Add npm postinstall binary distribution (launcher, postinstall download from GitHub releases) - Fix recording temp file path bug when using stream sink - Add Zig modules: WebSocket CDP client, step runner, config parser, Chrome/ffmpeg installer - Cross-platform GitHub Actions release workflow for prebuilt Zig binaries - Fetch vendored C headers (nanosvg, stb) at build time instead of committing them --- .github/workflows/release-compositor.yml | 76 + .gitignore | 1 + apps/docs/next-env.d.ts | 2 +- apps/studio/components.json | 25 + apps/studio/next.config.ts | 7 + apps/studio/package.json | 44 + apps/studio/postcss.config.mjs | 7 + apps/studio/src/app/api/cors/route.ts | 139 + apps/studio/src/app/api/p/[...path]/route.ts | 85 + apps/studio/src/app/api/proxy/route.ts | 50 + apps/studio/src/app/api/render/route.ts | 95 + apps/studio/src/app/api/video/route.ts | 38 + apps/studio/src/app/favicon.ico | Bin 0 -> 25931 bytes apps/studio/src/app/globals.css | 145 ++ apps/studio/src/app/layout.tsx | 34 + apps/studio/src/app/page.tsx | 210 ++ .../studio/src/components/command-palette.tsx | 314 +++ apps/studio/src/components/diff-view.tsx | 89 + apps/studio/src/components/env-vars.tsx | 69 + .../studio/src/components/global-settings.tsx | 145 ++ apps/studio/src/components/header.tsx | 295 +++ .../studio/src/components/include-manager.tsx | 172 ++ apps/studio/src/components/json-editor.tsx | 124 + apps/studio/src/components/left-pane.tsx | 50 + apps/studio/src/components/preview.tsx | 1349 ++++++++++ apps/studio/src/components/props-pane.tsx | 1530 +++++++++++ apps/studio/src/components/sfx-config.tsx | 169 ++ .../src/components/simulation-controls.tsx | 236 ++ .../src/components/step-walkthrough.tsx | 103 + apps/studio/src/components/theme-editor.tsx | 224 ++ apps/studio/src/components/theme-provider.tsx | 10 + apps/studio/src/components/theme-toggle.tsx | 31 + apps/studio/src/components/timeline.tsx | 811 ++++++ .../studio/src/components/ui/alert-dialog.tsx | 129 + apps/studio/src/components/ui/badge.tsx | 30 + apps/studio/src/components/ui/button.tsx | 67 + apps/studio/src/components/ui/checkbox.tsx | 28 + apps/studio/src/components/ui/dialog.tsx | 115 + .../src/components/ui/dropdown-menu.tsx | 197 ++ apps/studio/src/components/ui/input.tsx | 19 + apps/studio/src/components/ui/label.tsx | 24 + apps/studio/src/components/ui/popover.tsx | 33 + apps/studio/src/components/ui/resizable.tsx | 42 + apps/studio/src/components/ui/scroll-area.tsx | 55 + apps/studio/src/components/ui/select.tsx | 92 + apps/studio/src/components/ui/separator.tsx | 28 + apps/studio/src/components/ui/slider.tsx | 50 + apps/studio/src/components/ui/switch.tsx | 30 + apps/studio/src/components/ui/tabs.tsx | 87 + apps/studio/src/components/ui/tooltip.tsx | 55 + .../src/components/validation-panel.tsx | 108 + apps/studio/src/components/video-player.tsx | 112 + .../src/hooks/use-keyboard-shortcuts.ts | 208 ++ apps/studio/src/hooks/use-media-query.ts | 17 + apps/studio/src/lib/preview-script.ts | 987 +++++++ apps/studio/src/lib/proxy-utils.ts | 86 + apps/studio/src/lib/utils.ts | 6 + apps/studio/src/store/config.ts | 381 +++ apps/studio/src/store/provider.tsx | 7 + apps/studio/src/store/validation.ts | 211 ++ apps/studio/tsconfig.json | 27 + packages/@webreel/compositor/.gitignore | 8 + packages/@webreel/compositor/binding/index.ts | 333 +++ packages/@webreel/compositor/build.zig | 93 + .../compositor/deps/nanosvg/nanosvg_impl.c | 7 + .../@webreel/compositor/deps/stb/stb_impl.c | 8 + packages/@webreel/compositor/package.json | 32 + .../@webreel/compositor/scripts/fetch-deps.sh | 40 + .../@webreel/compositor/scripts/install.sh | 99 + packages/@webreel/compositor/src/actions.zig | 521 ++++ packages/@webreel/compositor/src/base64.zig | 101 + packages/@webreel/compositor/src/cdp.zig | 356 +++ packages/@webreel/compositor/src/chrome.zig | 299 +++ .../@webreel/compositor/src/compositor.zig | 292 +++ packages/@webreel/compositor/src/config.zig | 461 ++++ .../@webreel/compositor/src/cpu_backend.zig | 226 ++ .../@webreel/compositor/src/cursor_motion.zig | 149 ++ .../@webreel/compositor/src/gpu_backend.zig | 617 +++++ packages/@webreel/compositor/src/hud.zig | 244 ++ packages/@webreel/compositor/src/install.zig | 182 ++ packages/@webreel/compositor/src/jpeg.zig | 94 + packages/@webreel/compositor/src/main.zig | 1403 ++++++++++ packages/@webreel/compositor/src/nanosvg.zig | 69 + packages/@webreel/compositor/src/runner.zig | 269 ++ .../compositor/src/shaders/composite.wgsl | 64 + packages/@webreel/compositor/src/timeline.zig | 195 ++ packages/@webreel/compositor/src/types.zig | 158 ++ .../@webreel/compositor/src/websocket.zig | 209 ++ packages/@webreel/compositor/tsconfig.json | 16 + packages/@webreel/core/package.json | 2 +- packages/@webreel/core/src/actions.ts | 18 +- packages/@webreel/core/src/compositor.ts | 539 ++-- packages/@webreel/core/src/cursor-motion.ts | 6 +- packages/@webreel/core/src/index.ts | 12 +- packages/@webreel/core/src/media.ts | 2 +- packages/@webreel/core/src/recorder.ts | 278 +- packages/@webreel/core/src/timeline.ts | 73 +- packages/@webreel/core/src/types.ts | 6 +- packages/webreel/.gitignore | 2 + packages/webreel/bin/webreel.js | 112 + packages/webreel/package.json | 5 +- packages/webreel/scripts/postinstall.js | 210 ++ packages/webreel/src/config.ts | 1 + packages/webreel/src/lib/config.ts | 34 +- packages/webreel/src/lib/runner.ts | 92 +- packages/webreel/src/lib/types.ts | 34 + pnpm-lock.yaml | 2309 ++++++++++++++--- turbo.json | 2 +- 108 files changed, 19368 insertions(+), 824 deletions(-) create mode 100644 .github/workflows/release-compositor.yml create mode 100644 apps/studio/components.json create mode 100644 apps/studio/next.config.ts create mode 100644 apps/studio/package.json create mode 100644 apps/studio/postcss.config.mjs create mode 100644 apps/studio/src/app/api/cors/route.ts create mode 100644 apps/studio/src/app/api/p/[...path]/route.ts create mode 100644 apps/studio/src/app/api/proxy/route.ts create mode 100644 apps/studio/src/app/api/render/route.ts create mode 100644 apps/studio/src/app/api/video/route.ts create mode 100644 apps/studio/src/app/favicon.ico create mode 100644 apps/studio/src/app/globals.css create mode 100644 apps/studio/src/app/layout.tsx create mode 100644 apps/studio/src/app/page.tsx create mode 100644 apps/studio/src/components/command-palette.tsx create mode 100644 apps/studio/src/components/diff-view.tsx create mode 100644 apps/studio/src/components/env-vars.tsx create mode 100644 apps/studio/src/components/global-settings.tsx create mode 100644 apps/studio/src/components/header.tsx create mode 100644 apps/studio/src/components/include-manager.tsx create mode 100644 apps/studio/src/components/json-editor.tsx create mode 100644 apps/studio/src/components/left-pane.tsx create mode 100644 apps/studio/src/components/preview.tsx create mode 100644 apps/studio/src/components/props-pane.tsx create mode 100644 apps/studio/src/components/sfx-config.tsx create mode 100644 apps/studio/src/components/simulation-controls.tsx create mode 100644 apps/studio/src/components/step-walkthrough.tsx create mode 100644 apps/studio/src/components/theme-editor.tsx create mode 100644 apps/studio/src/components/theme-provider.tsx create mode 100644 apps/studio/src/components/theme-toggle.tsx create mode 100644 apps/studio/src/components/timeline.tsx create mode 100644 apps/studio/src/components/ui/alert-dialog.tsx create mode 100644 apps/studio/src/components/ui/badge.tsx create mode 100644 apps/studio/src/components/ui/button.tsx create mode 100644 apps/studio/src/components/ui/checkbox.tsx create mode 100644 apps/studio/src/components/ui/dialog.tsx create mode 100644 apps/studio/src/components/ui/dropdown-menu.tsx create mode 100644 apps/studio/src/components/ui/input.tsx create mode 100644 apps/studio/src/components/ui/label.tsx create mode 100644 apps/studio/src/components/ui/popover.tsx create mode 100644 apps/studio/src/components/ui/resizable.tsx create mode 100644 apps/studio/src/components/ui/scroll-area.tsx create mode 100644 apps/studio/src/components/ui/select.tsx create mode 100644 apps/studio/src/components/ui/separator.tsx create mode 100644 apps/studio/src/components/ui/slider.tsx create mode 100644 apps/studio/src/components/ui/switch.tsx create mode 100644 apps/studio/src/components/ui/tabs.tsx create mode 100644 apps/studio/src/components/ui/tooltip.tsx create mode 100644 apps/studio/src/components/validation-panel.tsx create mode 100644 apps/studio/src/components/video-player.tsx create mode 100644 apps/studio/src/hooks/use-keyboard-shortcuts.ts create mode 100644 apps/studio/src/hooks/use-media-query.ts create mode 100644 apps/studio/src/lib/preview-script.ts create mode 100644 apps/studio/src/lib/proxy-utils.ts create mode 100644 apps/studio/src/lib/utils.ts create mode 100644 apps/studio/src/store/config.ts create mode 100644 apps/studio/src/store/provider.tsx create mode 100644 apps/studio/src/store/validation.ts create mode 100644 apps/studio/tsconfig.json create mode 100644 packages/@webreel/compositor/.gitignore create mode 100644 packages/@webreel/compositor/binding/index.ts create mode 100644 packages/@webreel/compositor/build.zig create mode 100644 packages/@webreel/compositor/deps/nanosvg/nanosvg_impl.c create mode 100644 packages/@webreel/compositor/deps/stb/stb_impl.c create mode 100644 packages/@webreel/compositor/package.json create mode 100755 packages/@webreel/compositor/scripts/fetch-deps.sh create mode 100755 packages/@webreel/compositor/scripts/install.sh create mode 100644 packages/@webreel/compositor/src/actions.zig create mode 100644 packages/@webreel/compositor/src/base64.zig create mode 100644 packages/@webreel/compositor/src/cdp.zig create mode 100644 packages/@webreel/compositor/src/chrome.zig create mode 100644 packages/@webreel/compositor/src/compositor.zig create mode 100644 packages/@webreel/compositor/src/config.zig create mode 100644 packages/@webreel/compositor/src/cpu_backend.zig create mode 100644 packages/@webreel/compositor/src/cursor_motion.zig create mode 100644 packages/@webreel/compositor/src/gpu_backend.zig create mode 100644 packages/@webreel/compositor/src/hud.zig create mode 100644 packages/@webreel/compositor/src/install.zig create mode 100644 packages/@webreel/compositor/src/jpeg.zig create mode 100644 packages/@webreel/compositor/src/main.zig create mode 100644 packages/@webreel/compositor/src/nanosvg.zig create mode 100644 packages/@webreel/compositor/src/runner.zig create mode 100644 packages/@webreel/compositor/src/shaders/composite.wgsl create mode 100644 packages/@webreel/compositor/src/timeline.zig create mode 100644 packages/@webreel/compositor/src/types.zig create mode 100644 packages/@webreel/compositor/src/websocket.zig create mode 100644 packages/@webreel/compositor/tsconfig.json create mode 100644 packages/webreel/.gitignore create mode 100644 packages/webreel/bin/webreel.js create mode 100644 packages/webreel/scripts/postinstall.js diff --git a/.github/workflows/release-compositor.yml b/.github/workflows/release-compositor.yml new file mode 100644 index 0000000..2a5e2a2 --- /dev/null +++ b/.github/workflows/release-compositor.yml @@ -0,0 +1,76 @@ +name: Release Compositor + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-linux + binary: webreel-linux-x64 + - os: ubuntu-latest + target: aarch64-linux + binary: webreel-linux-arm64 + - os: macos-latest + target: x86_64-macos + binary: webreel-darwin-x64 + - os: macos-latest + target: aarch64-macos + binary: webreel-darwin-arm64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.15.2 + + - name: Fetch deps + working-directory: packages/@webreel/compositor + run: bash scripts/fetch-deps.sh + + - name: Build + working-directory: packages/@webreel/compositor + run: | + zig build \ + -Doptimize=ReleaseFast \ + -Dtarget=${{ matrix.target }} \ + -Dexe-name=webreel + + - name: Rename binary + working-directory: packages/@webreel/compositor + run: | + mkdir -p dist + cp zig-out/bin/webreel dist/${{ matrix.binary }} 2>/dev/null || \ + cp zig-out/bin/webreel.exe dist/${{ matrix.binary }}.exe 2>/dev/null + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.binary }} + path: packages/@webreel/compositor/dist/${{ matrix.binary }}* + + release: + needs: build + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/**/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index ffeb0f9..012a1c1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ dist !.env.example coverage *.tsbuildinfo +next-env.d.ts *.log package-lock.json demo-reel.mp4 diff --git a/apps/docs/next-env.d.ts b/apps/docs/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/docs/next-env.d.ts +++ b/apps/docs/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/studio/components.json b/apps/studio/components.json new file mode 100644 index 0000000..2a42785 --- /dev/null +++ b/apps/studio/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts new file mode 100644 index 0000000..9493cd6 --- /dev/null +++ b/apps/studio/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const config: NextConfig = { + devIndicators: false, +}; + +export default config; diff --git a/apps/studio/package.json b/apps/studio/package.json new file mode 100644 index 0000000..be9fa41 --- /dev/null +++ b/apps/studio/package.json @@ -0,0 +1,44 @@ +{ + "name": "@webreel/studio", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "portless run --name studio.webreel next dev", + "build": "next build", + "start": "next start", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@codemirror/autocomplete": "^6", + "@codemirror/commands": "^6", + "@codemirror/lang-json": "^6", + "@codemirror/language": "^6", + "@codemirror/state": "^6", + "@codemirror/theme-one-dark": "^6", + "@codemirror/view": "^6", + "class-variance-authority": "^0.7", + "clsx": "^2", + "cmdk": "^1.1.1", + "codemirror": "^6", + "geist": "^1", + "jotai": "^2", + "lucide-react": "^0.575.0", + "next": "^16", + "next-themes": "^0.4", + "radix-ui": "^1", + "react": "^19", + "react-dom": "^19", + "react-resizable-panels": "^4", + "tailwind-merge": "^3", + "tw-animate-css": "^1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/apps/studio/postcss.config.mjs b/apps/studio/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/apps/studio/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/apps/studio/src/app/api/cors/route.ts b/apps/studio/src/app/api/cors/route.ts new file mode 100644 index 0000000..cbf3a37 --- /dev/null +++ b/apps/studio/src/app/api/cors/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server"; + +const STRIPPED_REQUEST_HEADERS = new Set([ + "host", + "origin", + "referer", + "cookie", + "authorization", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", +]); + +const STRIPPED_RESPONSE_HEADERS = new Set([ + "content-encoding", + "content-security-policy", + "content-security-policy-report-only", + "x-frame-options", + "strict-transport-security", + "set-cookie", + "transfer-encoding", +]); + +function resolveUrl(raw: string, base?: string): string { + if (/^https?:\/\//i.test(raw)) return raw; + if (!base) return raw; + try { + return new URL(raw, base).href; + } catch { + return raw; + } +} + +function isBlockedUrl(urlString: string): boolean { + try { + const parsed = new URL(urlString); + if (!["http:", "https:"].includes(parsed.protocol)) return true; + const hostname = parsed.hostname; + if ( + hostname === "localhost" || + hostname.startsWith("127.") || + hostname === "[::1]" || + hostname.startsWith("10.") || + hostname.startsWith("192.168.") || + /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || + hostname === "169.254.169.254" || + hostname.endsWith(".internal") + ) + return true; + return false; + } catch { + return true; + } +} + +function rewriteCssUrls(css: string, baseUrl: string): string { + return css.replace(/url\(\s*(['"]?)(\/?[^)'"]+)\1\s*\)/g, (_match, quote, rawUrl) => { + if (rawUrl.startsWith("data:") || rawUrl.startsWith("#")) return _match; + const absolute = resolveUrl(rawUrl, baseUrl); + if (!/^https?:\/\//i.test(absolute)) return _match; + return `url(${quote}/api/cors?url=${encodeURIComponent(absolute)}${quote})`; + }); +} + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get("url"); + if (!url) { + return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); + } + + if (isBlockedUrl(url)) { + return NextResponse.json({ error: "URL not allowed" }, { status: 403 }); + } + + try { + const headers: Record = { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: "*/*", + }; + + for (const [key, value] of request.headers.entries()) { + if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) { + headers[key] = value; + } + } + + const response = await fetch(url, { + headers, + redirect: "follow", + signal: AbortSignal.timeout(10_000), + }); + + const contentType = response.headers.get("content-type") || ""; + const responseHeaders = new Headers(); + + for (const [key, value] of response.headers.entries()) { + if (!STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase())) { + responseHeaders.set(key, value); + } + } + responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.set("Access-Control-Allow-Methods", "GET, OPTIONS"); + responseHeaders.set("Access-Control-Allow-Headers", "*"); + + if (contentType.includes("text/css")) { + const css = await response.text(); + const baseUrl = url.replace(/[?#].*$/, "").replace(/\/[^/]*$/, "/"); + const rewritten = rewriteCssUrls(css, baseUrl); + responseHeaders.set("Content-Type", contentType); + return new NextResponse(rewritten, { + status: response.status, + headers: responseHeaders, + }); + } + + responseHeaders.delete("content-encoding"); + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders, + }); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to fetch"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", + }, + }); +} diff --git a/apps/studio/src/app/api/p/[...path]/route.ts b/apps/studio/src/app/api/p/[...path]/route.ts new file mode 100644 index 0000000..e2502b6 --- /dev/null +++ b/apps/studio/src/app/api/p/[...path]/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PREVIEW_SCRIPT } from "@/lib/preview-script"; +import { + rewriteHtml, + rewriteCssUrls, + injectScript, + STRIPPED_RESPONSE_HEADERS, + DEFAULT_USER_AGENT, +} from "@/lib/proxy-utils"; + +function reconstructUrl(pathSegments: string[]): string | null { + if (pathSegments.length < 2) return null; + const protocol = pathSegments[0]; + if (protocol !== "https" && protocol !== "http") return null; + const rest = pathSegments.slice(1).join("/"); + return `${protocol}://${rest}`; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path: pathSegments } = await params; + const targetUrl = reconstructUrl(pathSegments); + if (!targetUrl) { + return NextResponse.json({ error: "Invalid proxy path" }, { status: 400 }); + } + + const searchParams = request.nextUrl.search; + const fullUrl = searchParams ? `${targetUrl}${searchParams}` : targetUrl; + + try { + const response = await fetch(fullUrl, { + headers: { + "User-Agent": DEFAULT_USER_AGENT, + Accept: request.headers.get("accept") ?? "*/*", + "Accept-Language": request.headers.get("accept-language") ?? "en-US,en;q=0.9", + }, + redirect: "follow", + }); + + const contentType = response.headers.get("content-type") || ""; + const responseHeaders = new Headers(); + + for (const [key, value] of response.headers.entries()) { + if (!STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase())) { + responseHeaders.set(key, value); + } + } + responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.delete("content-encoding"); + + if (contentType.includes("text/html") || contentType.includes("application/xhtml")) { + let html = await response.text(); + const finalUrl = response.url || fullUrl; + html = rewriteHtml(html, finalUrl); + html = injectScript(html, PREVIEW_SCRIPT); + responseHeaders.set("Content-Type", "text/html; charset=utf-8"); + return new NextResponse(html, { + status: response.status, + headers: responseHeaders, + }); + } + + if (contentType.includes("text/css")) { + const css = await response.text(); + const url = new URL(fullUrl); + const originPrefix = `/api/p/${url.protocol.replace(":", "")}/${url.host}`; + const rewritten = rewriteCssUrls(css, originPrefix); + responseHeaders.set("Content-Type", contentType); + return new NextResponse(rewritten, { + status: response.status, + headers: responseHeaders, + }); + } + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders, + }); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to fetch"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/apps/studio/src/app/api/proxy/route.ts b/apps/studio/src/app/api/proxy/route.ts new file mode 100644 index 0000000..63d2169 --- /dev/null +++ b/apps/studio/src/app/api/proxy/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PREVIEW_SCRIPT } from "@/lib/preview-script"; +import { rewriteHtml, injectScript, DEFAULT_USER_AGENT } from "@/lib/proxy-utils"; + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get("url"); + if (!url) { + return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); + } + + try { + const response = await fetch(url, { + headers: { + "User-Agent": DEFAULT_USER_AGENT, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + redirect: "follow", + }); + + const contentType = response.headers.get("content-type") || ""; + if ( + !contentType.includes("text/html") && + !contentType.includes("application/xhtml") + ) { + return new NextResponse(response.body, { + status: response.status, + headers: { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + }, + }); + } + + let html = await response.text(); + const finalUrl = response.url || url; + html = rewriteHtml(html, finalUrl); + html = injectScript(html, PREVIEW_SCRIPT); + + return new NextResponse(html, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to fetch"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/apps/studio/src/app/api/render/route.ts b/apps/studio/src/app/api/render/route.ts new file mode 100644 index 0000000..f4939d4 --- /dev/null +++ b/apps/studio/src/app/api/render/route.ts @@ -0,0 +1,95 @@ +import { NextRequest } from "next/server"; +import { spawn } from "node:child_process"; +import { writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +export async function POST(request: NextRequest) { + const body = (await request.json()) as { + config: unknown; + video?: string; + videos?: string[]; + }; + + if (!body.config) { + return Response.json({ error: "Missing config" }, { status: 400 }); + } + + const tempDir = join(tmpdir(), "webreel-studio"); + mkdirSync(tempDir, { recursive: true }); + const configPath = join(tempDir, `${randomUUID()}.json`); + writeFileSync(configPath, JSON.stringify(body.config, null, 2)); + + const webreelBin = join( + process.cwd(), + "..", + "..", + "packages", + "webreel", + "dist", + "index.js", + ); + + const videos = body.videos as string[] | undefined; + const args = ["record"]; + if (videos && videos.length > 0) { + args.push(...videos); + } else if (body.video) { + args.push(body.video); + } + args.push("-c", configPath, "--verbose"); + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const child = spawn("node", [webreelBin, ...args], { + cwd: tempDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + function send(type: string, data: string) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type, data })}\n\n`)); + } + + child.stdout.on("data", (chunk: Buffer) => { + const lines = chunk.toString().split("\n").filter(Boolean); + for (const line of lines) { + send("stdout", line); + } + }); + + child.stderr.on("data", (chunk: Buffer) => { + const lines = chunk.toString().split("\n").filter(Boolean); + for (const line of lines) { + send("stderr", line); + } + }); + + child.on("close", (code) => { + send("exit", String(code ?? 0)); + try { + unlinkSync(configPath); + } catch {} + controller.close(); + }); + + child.on("error", (err) => { + send("error", err.message); + try { + unlinkSync(configPath); + } catch {} + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/apps/studio/src/app/api/video/route.ts b/apps/studio/src/app/api/video/route.ts new file mode 100644 index 0000000..166159b --- /dev/null +++ b/apps/studio/src/app/api/video/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server"; +import { readFile, stat } from "node:fs/promises"; +import { extname } from "node:path"; + +const MIME_TYPES: Record = { + ".mp4": "video/mp4", + ".webm": "video/webm", + ".gif": "image/gif", +}; + +export async function GET(request: NextRequest) { + const path = request.nextUrl.searchParams.get("path"); + if (!path) { + return Response.json({ error: "Missing path" }, { status: 400 }); + } + + try { + const stats = await stat(path); + if (!stats.isFile()) { + return Response.json({ error: "Not a file" }, { status: 404 }); + } + + const data = await readFile(path); + const ext = extname(path).toLowerCase(); + const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; + + return new Response(data, { + headers: { + "Content-Type": contentType, + "Content-Length": String(data.length), + "Content-Disposition": `inline; filename="${path.split("/").pop()}"`, + "Cache-Control": "no-cache", + }, + }); + } catch { + return Response.json({ error: "File not found" }, { status: 404 }); + } +} diff --git a/apps/studio/src/app/favicon.ico b/apps/studio/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/apps/studio/src/app/globals.css b/apps/studio/src/app/globals.css new file mode 100644 index 0000000..4813538 --- /dev/null +++ b/apps/studio/src/app/globals.css @@ -0,0 +1,145 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --success: #22c55e; + --warning: #eab308; +} + +.dark { + --background: #0a0a0a; + --foreground: #e5e5e5; + --card: #141414; + --card-foreground: #e5e5e5; + --popover: #141414; + --popover-foreground: #e5e5e5; + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: #1a1a1a; + --secondary-foreground: #e5e5e5; + --muted: #1a1a1a; + --muted-foreground: #737373; + --accent: #1a1a1a; + --accent-foreground: #e5e5e5; + --destructive: #ef4444; + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --sidebar: #141414; + --sidebar-foreground: #e5e5e5; + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: #1a1a1a; + --sidebar-accent-foreground: #e5e5e5; + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + --success: #22c55e; + --warning: #eab308; +} + +body { + font-family: + system-ui, + -apple-system, + sans-serif; + margin: 0; + overflow: hidden; +} + +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --color-success: var(--success); + --color-warning: var(--warning); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +button { + cursor: pointer; +} diff --git a/apps/studio/src/app/layout.tsx b/apps/studio/src/app/layout.tsx new file mode 100644 index 0000000..da67077 --- /dev/null +++ b/apps/studio/src/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Geist } from "next/font/google"; +import { cn } from "@/lib/utils"; +import { ThemeProvider } from "@/components/theme-provider"; +import { JotaiProvider } from "@/store/provider"; + +const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); + +export const metadata: Metadata = { + title: "Webreel Studio", + description: "Visual editor for webreel configurations", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/studio/src/app/page.tsx b/apps/studio/src/app/page.tsx new file mode 100644 index 0000000..c3ee1a0 --- /dev/null +++ b/apps/studio/src/app/page.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { LeftPane } from "@/components/left-pane"; +import { Preview } from "@/components/preview"; +import { PropsPane } from "@/components/props-pane"; +import { Header } from "@/components/header"; +import { CommandPalette } from "@/components/command-palette"; +import { GlobalSettings } from "@/components/global-settings"; +import { Separator } from "@/components/ui/separator"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + configJsonAtom, + savedConfigJsonAtom, + fileHandleAtom, + fileNameAtom, + commitConfigAtom, + DEFAULT_CONFIG, + renderStatusAtom, + parsedConfigAtom, + selectedVideoAtom, +} from "@/store/config"; + +export default function StudioPage() { + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + const [configJson, setConfigJson] = useAtom(configJsonAtom); + const [savedConfigJson, setSavedConfigJson] = useAtom(savedConfigJsonAtom); + const [fileHandle, setFileHandle] = useAtom(fileHandleAtom); + const [fileName, setFileName] = useAtom(fileNameAtom); + const commit = useSetAtom(commitConfigAtom); + const renderStatus = useAtomValue(renderStatusAtom); + const { config } = useAtomValue(parsedConfigAtom); + const selectedVideo = useAtomValue(selectedVideoAtom); + + const hasFileSystemAccess = + typeof window !== "undefined" && "showOpenFilePicker" in window; + + const handleSave = useCallback(async () => { + if (fileHandle) { + try { + const writable = await fileHandle.createWritable(); + await writable.write(configJson); + await writable.close(); + setSavedConfigJson(configJson); + } catch {} + } else if (hasFileSystemAccess) { + try { + const handle = await ( + window as unknown as { + showSaveFilePicker: (opts: unknown) => Promise; + } + ).showSaveFilePicker({ + suggestedName: fileName ?? "webreel.config.json", + types: [ + { description: "Webreel Config", accept: { "application/json": [".json"] } }, + ], + }); + const writable = await handle.createWritable(); + await writable.write(configJson); + await writable.close(); + setSavedConfigJson(configJson); + setFileHandle(handle); + setFileName(handle.name); + } catch {} + } else { + const blob = new Blob([configJson], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName ?? "webreel.config.json"; + a.click(); + URL.revokeObjectURL(url); + setSavedConfigJson(configJson); + } + }, [ + fileHandle, + hasFileSystemAccess, + configJson, + fileName, + setSavedConfigJson, + setFileHandle, + setFileName, + ]); + + const handleOpen = useCallback(async () => { + if (hasFileSystemAccess) { + try { + const [handle] = await ( + window as unknown as { + showOpenFilePicker: (opts: unknown) => Promise; + } + ).showOpenFilePicker({ + types: [ + { description: "Webreel Config", accept: { "application/json": [".json"] } }, + ], + multiple: false, + }); + const file = await handle.getFile(); + const text = await file.text(); + commit(text); + setSavedConfigJson(text); + setFileHandle(handle); + setFileName(file.name); + } catch {} + } + }, [hasFileSystemAccess, commit, setSavedConfigJson, setFileHandle, setFileName]); + + const handleNew = useCallback(() => { + commit(DEFAULT_CONFIG); + setSavedConfigJson(DEFAULT_CONFIG); + setFileHandle(null); + setFileName(null); + }, [commit, setSavedConfigJson, setFileHandle, setFileName]); + + const handleStartRender = useCallback(async () => { + if (renderStatus === "running" || !config || !selectedVideo) return; + // Trigger via custom event that Preview component listens to + window.dispatchEvent(new CustomEvent("webreel:start-render")); + }, [renderStatus, config, selectedVideo]); + + useKeyboardShortcuts({ + onSave: handleSave, + onOpen: handleOpen, + onCommandPalette: () => setCmdPaletteOpen(true), + onStartRender: handleStartRender, + }); + + if (isDesktop) { + return ( +
+
setSettingsOpen(true)} + onOpenCommandPalette={() => setCmdPaletteOpen(true)} + /> + + + + + + + + + + + + + + setSettingsOpen(true)} + /> + +
+ ); + } + + return ( +
+
setSettingsOpen(true)} + onOpenCommandPalette={() => setCmdPaletteOpen(true)} + /> + +
+ + Timeline + Preview + Properties + +
+ + + + + + + + + + +
+ setSettingsOpen(true)} + /> + +
+ ); +} diff --git a/apps/studio/src/components/command-palette.tsx b/apps/studio/src/components/command-palette.tsx new file mode 100644 index 0000000..cf75c57 --- /dev/null +++ b/apps/studio/src/components/command-palette.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { Command } from "cmdk"; +import { + configJsonAtom, + parsedConfigAtom, + selectedVideoAtom, + selectedStepIndexAtom, + videoNamesAtom, + commitConfigAtom, + undoAtom, + redoAtom, + canUndoAtom, + canRedoAtom, + STEP_TEMPLATES, + selectedVideoConfigAtom, +} from "@/store/config"; +import { Plus, Undo2, Redo2, Play, Video, Trash2, Copy, Sun, Moon } from "lucide-react"; +import { useTheme } from "next-themes"; +import { cn } from "@/lib/utils"; + +interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave?: () => void; + onOpen?: () => void; + onNew?: () => void; + onStartRender?: () => void; + onOpenSettings?: () => void; +} + +export function CommandPalette({ + open, + onOpenChange, + onSave, + onOpen, + onNew, + onStartRender, + onOpenSettings, +}: CommandPaletteProps) { + const [search, setSearch] = useState(""); + const videoNames = useAtomValue(videoNamesAtom); + const [selectedVideo, setSelectedVideo] = useAtom(selectedVideoAtom); + const videoConfig = useAtomValue(selectedVideoConfigAtom); + const [selectedStep, setSelectedStep] = useAtom(selectedStepIndexAtom); + const [configJson] = useAtom(configJsonAtom); + const { config } = useAtomValue(parsedConfigAtom); + const commit = useSetAtom(commitConfigAtom); + const undo = useSetAtom(undoAtom); + const redo = useSetAtom(redoAtom); + const canUndo = useAtomValue(canUndoAtom); + const canRedo = useAtomValue(canRedoAtom); + const { setTheme, resolvedTheme } = useTheme(); + + useEffect(() => { + if (open) setSearch(""); + }, [open]); + + const close = useCallback(() => onOpenChange(false), [onOpenChange]); + + const addStepFromTemplate = useCallback( + (templateIndex: number) => { + if (!config || !selectedVideo) return; + const template = STEP_TEMPLATES[templateIndex]; + if (!template) return; + const draft = JSON.parse(configJson) as Record; + const videos = draft.videos as Record>; + const video = videos[selectedVideo]; + if (!video) return; + const steps = video.steps as Record[]; + steps.push(JSON.parse(JSON.stringify(template.step))); + commit(JSON.stringify(draft, null, 2)); + setSelectedStep(steps.length - 1); + }, + [config, configJson, selectedVideo, commit, setSelectedStep], + ); + + const duplicateStep = useCallback(() => { + if (!config || !selectedVideo || !videoConfig || selectedStep < 0) return; + const step = videoConfig.steps[selectedStep]; + if (!step) return; + const draft = JSON.parse(configJson) as Record; + const videos = draft.videos as Record>; + const video = videos[selectedVideo]; + if (!video) return; + const steps = video.steps as Record[]; + steps.splice(selectedStep + 1, 0, JSON.parse(JSON.stringify(step))); + commit(JSON.stringify(draft, null, 2)); + setSelectedStep(selectedStep + 1); + }, [ + config, + configJson, + selectedVideo, + videoConfig, + selectedStep, + commit, + setSelectedStep, + ]); + + const deleteStep = useCallback(() => { + if (!config || !selectedVideo || selectedStep < 0) return; + const draft = JSON.parse(configJson) as Record; + const videos = draft.videos as Record>; + const video = videos[selectedVideo]; + if (!video) return; + const steps = video.steps as Record[]; + steps.splice(selectedStep, 1); + commit(JSON.stringify(draft, null, 2)); + setSelectedStep(Math.min(selectedStep, steps.length - 1)); + }, [config, configJson, selectedVideo, selectedStep, commit, setSelectedStep]); + + if (!open) return null; + + return ( +
+
+
+ + + + + No results found. + + + + { + close(); + onNew?.(); + }} + > + New Project + + { + close(); + onOpen?.(); + }} + > + Open File... + + { + close(); + onSave?.(); + }} + > + Save + + + + + { + undo(); + close(); + }} + > + Undo + + { + redo(); + close(); + }} + > + Redo + + {videoConfig && selectedStep >= 0 && ( + <> + { + duplicateStep(); + close(); + }} + > + Duplicate Step + + { + deleteStep(); + close(); + }} + > + Delete Step + + + )} + + + + {videoNames.map((name) => ( + { + setSelectedVideo(name); + setSelectedStep(-1); + close(); + }} + > + + ))} + + + {selectedVideo && ( + + {STEP_TEMPLATES.map((template, i) => ( + { + addStepFromTemplate(i); + close(); + }} + > + + {template.label} + + {template.description} + + + ))} + + )} + + + { + close(); + onStartRender?.(); + }} + > + Start Recording + + { + close(); + onOpenSettings?.(); + }} + > + Settings + + { + setTheme(resolvedTheme === "dark" ? "light" : "dark"); + close(); + }} + > + {resolvedTheme === "dark" ? ( + + ) : ( + + )} + Toggle Theme + + + + +
+
+ ); +} + +function CommandItem({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} diff --git a/apps/studio/src/components/diff-view.tsx b/apps/studio/src/components/diff-view.tsx new file mode 100644 index 0000000..ddd2836 --- /dev/null +++ b/apps/studio/src/components/diff-view.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useMemo } from "react"; +import { useAtomValue } from "jotai/react"; +import { configJsonAtom, savedConfigJsonAtom, isDirtyAtom } from "@/store/config"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; + +function computeDiff( + oldLines: string[], + newLines: string[], +): Array<{ type: "same" | "add" | "remove"; line: string; lineNumber: number }> { + const result: Array<{ + type: "same" | "add" | "remove"; + line: string; + lineNumber: number; + }> = []; + const maxLen = Math.max(oldLines.length, newLines.length); + let oi = 0; + let ni = 0; + let lineNum = 1; + + while (oi < oldLines.length || ni < newLines.length) { + if (oi < oldLines.length && ni < newLines.length && oldLines[oi] === newLines[ni]) { + result.push({ type: "same", line: newLines[ni], lineNumber: lineNum }); + oi++; + ni++; + } else if ( + oi < oldLines.length && + (ni >= newLines.length || oldLines[oi] !== newLines[ni]) + ) { + result.push({ type: "remove", line: oldLines[oi], lineNumber: lineNum }); + oi++; + } else { + result.push({ type: "add", line: newLines[ni], lineNumber: lineNum }); + ni++; + } + lineNum++; + if (lineNum > maxLen + 100) break; + } + + return result; +} + +export function DiffView() { + const configJson = useAtomValue(configJsonAtom); + const savedConfigJson = useAtomValue(savedConfigJsonAtom); + const isDirty = useAtomValue(isDirtyAtom); + + const diff = useMemo(() => { + if (!isDirty) return []; + const oldLines = savedConfigJson.split("\n"); + const newLines = configJson.split("\n"); + return computeDiff(oldLines, newLines); + }, [configJson, savedConfigJson, isDirty]); + + if (!isDirty) { + return ( +
+

No unsaved changes.

+
+ ); + } + + return ( + +
+
+ {diff.map((entry, i) => ( +
+ + {entry.type === "add" ? "+" : entry.type === "remove" ? "-" : " "} + + {entry.line} +
+ ))} +
+
+
+ ); +} diff --git a/apps/studio/src/components/env-vars.tsx b/apps/studio/src/components/env-vars.tsx new file mode 100644 index 0000000..494d99d --- /dev/null +++ b/apps/studio/src/components/env-vars.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo } from "react"; +import { useAtom, useAtomValue } from "jotai/react"; +import { configJsonAtom, envVarsAtom } from "@/store/config"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { AlertCircle } from "lucide-react"; + +function detectEnvVars(json: string): string[] { + const matches = json.match(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g); + if (!matches) return []; + const names = new Set(); + for (const m of matches) { + const name = m.replace(/^\$\{?/, "").replace(/\}$/, ""); + names.add(name); + } + return Array.from(names).sort(); +} + +export function EnvVarsPanel() { + const configJson = useAtomValue(configJsonAtom); + const [envVars, setEnvVars] = useAtom(envVarsAtom); + + const detectedVars = useMemo(() => detectEnvVars(configJson), [configJson]); + + if (detectedVars.length === 0) { + return ( +
+

+ No environment variables detected in config. +

+
+ ); + } + + return ( + +
+

+ Variables referenced as ${"{VAR}"} in the config. Set values here for preview + and recording. +

+ {detectedVars.map((name) => { + const hasValue = name in envVars && envVars[name] !== ""; + return ( +
+
+ + {!hasValue && } +
+ + setEnvVars((prev) => ({ ...prev, [name]: e.target.value })) + } + /> +
+ ); + })} +
+
+ ); +} diff --git a/apps/studio/src/components/global-settings.tsx b/apps/studio/src/components/global-settings.tsx new file mode 100644 index 0000000..10388b7 --- /dev/null +++ b/apps/studio/src/components/global-settings.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useCallback } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { + configJsonAtom, + parsedConfigAtom, + commitConfigAtom, + watchModeAtom, +} from "@/store/config"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +function FieldRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +export function GlobalSettings({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [configJson] = useAtom(configJsonAtom); + const { config } = useAtomValue(parsedConfigAtom); + const commit = useSetAtom(commitConfigAtom); + const [watchMode, setWatchMode] = useAtom(watchModeAtom); + + const updateRootField = useCallback( + (field: string, value: unknown) => { + if (!config) return; + const draft = JSON.parse(configJson) as Record; + if (value === "" || value === undefined) { + delete draft[field]; + } else { + draft[field] = value; + } + commit(JSON.stringify(draft, null, 2)); + }, + [config, configJson, commit], + ); + + const updateNumericField = useCallback( + (field: string, raw: string) => { + const v = parseInt(raw, 10); + updateRootField(field, isNaN(v) ? undefined : v); + }, + [updateRootField], + ); + + return ( + + + + Global Settings + + +
+
+

Schema

+ + + +
+ +
+ +
+

Output

+ + updateRootField("outDir", e.target.value)} + /> + + + updateRootField("baseUrl", e.target.value)} + /> + +
+ +
+ +
+

+ Timing Defaults +

+ + updateNumericField("defaultDelay", e.target.value)} + /> + + + updateNumericField("clickDwell", e.target.value)} + /> + +
+ +
+ +
+

Studio

+
+ + +
+

+ Auto-re-record when config changes are saved +

+
+
+ + +
+ ); +} diff --git a/apps/studio/src/components/header.tsx b/apps/studio/src/components/header.tsx new file mode 100644 index 0000000..b9f9fd1 --- /dev/null +++ b/apps/studio/src/components/header.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { + configJsonAtom, + savedConfigJsonAtom, + fileHandleAtom, + fileNameAtom, + isDirtyAtom, + canUndoAtom, + canRedoAtom, + undoAtom, + redoAtom, + DEFAULT_CONFIG, + commitConfigAtom, +} from "@/store/config"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + ChevronDown, + FileUp, + FolderOpen, + Save, + FilePlus, + Download, + Undo2, + Redo2, + Settings, + Circle, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface HeaderHandlers { + onOpenSettings?: () => void; + onOpenCommandPalette?: () => void; +} + +export function Header({ onOpenSettings }: HeaderHandlers) { + const [configJson, setConfigJson] = useAtom(configJsonAtom); + const [savedConfigJson, setSavedConfigJson] = useAtom(savedConfigJsonAtom); + const [fileHandle, setFileHandle] = useAtom(fileHandleAtom); + const [fileName, setFileName] = useAtom(fileNameAtom); + const isDirty = useAtomValue(isDirtyAtom); + const canUndo = useAtomValue(canUndoAtom); + const canRedo = useAtomValue(canRedoAtom); + const undo = useSetAtom(undoAtom); + const redo = useSetAtom(redoAtom); + const commit = useSetAtom(commitConfigAtom); + const fileInputRef = useRef(null); + + const hasFileSystemAccess = + typeof window !== "undefined" && "showOpenFilePicker" in window; + + const handleNew = useCallback(() => { + commit(DEFAULT_CONFIG); + setSavedConfigJson(DEFAULT_CONFIG); + setFileHandle(null); + setFileName(null); + }, [commit, setSavedConfigJson, setFileHandle, setFileName]); + + const handleOpen = useCallback(async () => { + if (hasFileSystemAccess) { + try { + const [handle] = await ( + window as unknown as { + showOpenFilePicker: (opts: unknown) => Promise; + } + ).showOpenFilePicker({ + types: [ + { + description: "Webreel Config", + accept: { "application/json": [".json"] }, + }, + ], + multiple: false, + }); + const file = await handle.getFile(); + const text = await file.text(); + commit(text); + setSavedConfigJson(text); + setFileHandle(handle); + setFileName(file.name); + } catch { + // User cancelled + } + } else { + fileInputRef.current?.click(); + } + }, [hasFileSystemAccess, commit, setSavedConfigJson, setFileHandle, setFileName]); + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const text = reader.result as string; + commit(text); + setSavedConfigJson(text); + setFileHandle(null); + setFileName(file.name); + }; + reader.readAsText(file); + e.target.value = ""; + }, + [commit, setSavedConfigJson, setFileHandle, setFileName], + ); + + const handleSave = useCallback(async () => { + if (fileHandle) { + try { + const writable = await fileHandle.createWritable(); + await writable.write(configJson); + await writable.close(); + setSavedConfigJson(configJson); + } catch { + // Permission denied or error + } + } else if (hasFileSystemAccess) { + try { + const handle = await ( + window as unknown as { + showSaveFilePicker: (opts: unknown) => Promise; + } + ).showSaveFilePicker({ + suggestedName: fileName ?? "webreel.config.json", + types: [ + { + description: "Webreel Config", + accept: { "application/json": [".json"] }, + }, + ], + }); + const writable = await handle.createWritable(); + await writable.write(configJson); + await writable.close(); + setSavedConfigJson(configJson); + setFileHandle(handle); + setFileName(handle.name); + } catch { + // User cancelled + } + } else { + downloadConfig(); + } + }, [ + fileHandle, + hasFileSystemAccess, + configJson, + fileName, + setSavedConfigJson, + setFileHandle, + setFileName, + ]); + + const handleSaveAs = useCallback(async () => { + if (hasFileSystemAccess) { + try { + const handle = await ( + window as unknown as { + showSaveFilePicker: (opts: unknown) => Promise; + } + ).showSaveFilePicker({ + suggestedName: fileName ?? "webreel.config.json", + types: [ + { + description: "Webreel Config", + accept: { "application/json": [".json"] }, + }, + ], + }); + const writable = await handle.createWritable(); + await writable.write(configJson); + await writable.close(); + setSavedConfigJson(configJson); + setFileHandle(handle); + setFileName(handle.name); + } catch { + // User cancelled + } + } else { + downloadConfig(); + } + }, [ + hasFileSystemAccess, + configJson, + fileName, + setSavedConfigJson, + setFileHandle, + setFileName, + ]); + + const downloadConfig = useCallback(() => { + const blob = new Blob([configJson], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName ?? "webreel.config.json"; + a.click(); + URL.revokeObjectURL(url); + setSavedConfigJson(configJson); + }, [configJson, fileName, setSavedConfigJson]); + + return ( +
+ + + + + + + + New Project + + + + Open... + Ctrl+O + + + + + Save + Ctrl+S + + + + Save As... + + + + + Export JSON + + + + + {fileName && ( +
+ {fileName} + {isDirty && } +
+ )} + {!fileName && isDirty && ( + unsaved + )} + +
+ + +
+ + +
+ + +
+ ); +} diff --git a/apps/studio/src/components/include-manager.tsx b/apps/studio/src/components/include-manager.tsx new file mode 100644 index 0000000..0428377 --- /dev/null +++ b/apps/studio/src/components/include-manager.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { + configJsonAtom, + parsedConfigAtom, + selectedVideoAtom, + selectedVideoConfigAtom, + commitConfigAtom, +} from "@/store/config"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Plus, Trash2, FileText } from "lucide-react"; + +function IncludeList({ + title, + includes, + onAdd, + onRemove, + onChange, +}: { + title: string; + includes: string[]; + onAdd: () => void; + onRemove: (index: number) => void; + onChange: (index: number, value: string) => void; +}) { + return ( +
+
+

{title}

+ +
+ {includes.length === 0 ? ( +

+ No includes. Steps from included files are prepended to the video. +

+ ) : ( +
+ {includes.map((path, i) => ( +
+ + onChange(i, e.target.value)} + /> + +
+ ))} +
+ )} +
+ ); +} + +export function IncludeManager() { + const selectedVideo = useAtomValue(selectedVideoAtom); + const videoConfig = useAtomValue(selectedVideoConfigAtom); + const [configJson] = useAtom(configJsonAtom); + const { config } = useAtomValue(parsedConfigAtom); + const commit = useSetAtom(commitConfigAtom); + + const rootIncludes = config?.include ?? []; + const videoIncludes = videoConfig?.include ?? []; + + const updateRootIncludes = useCallback( + (updater: (arr: string[]) => string[]) => { + if (!config) return; + const draft = JSON.parse(configJson) as Record; + const current = (draft.include as string[]) ?? []; + const next = updater([...current]); + if (next.length > 0) { + draft.include = next; + } else { + delete draft.include; + } + commit(JSON.stringify(draft, null, 2)); + }, + [config, configJson, commit], + ); + + const updateVideoIncludes = useCallback( + (updater: (arr: string[]) => string[]) => { + if (!config || !selectedVideo) return; + const draft = JSON.parse(configJson) as Record; + const videos = draft.videos as Record>; + const video = videos[selectedVideo]; + if (!video) return; + const current = (video.include as string[]) ?? []; + const next = updater([...current]); + if (next.length > 0) { + video.include = next; + } else { + delete video.include; + } + commit(JSON.stringify(draft, null, 2)); + }, + [config, configJson, selectedVideo, commit], + ); + + return ( + +
+ updateRootIncludes((arr) => [...arr, ""])} + onRemove={(i) => + updateRootIncludes((arr) => { + arr.splice(i, 1); + return arr; + }) + } + onChange={(i, v) => + updateRootIncludes((arr) => { + arr[i] = v; + return arr; + }) + } + /> + +
+ + {selectedVideo ? ( + updateVideoIncludes((arr) => [...arr, ""])} + onRemove={(i) => + updateVideoIncludes((arr) => { + arr.splice(i, 1); + return arr; + }) + } + onChange={(i, v) => + updateVideoIncludes((arr) => { + arr[i] = v; + return arr; + }) + } + /> + ) : ( +

+ Select a video to manage its includes. +

+ )} + +
+ +

+ Include files export a steps array that gets prepended to the video's steps + at recording time. Paths are relative to the config file directory. Supports + .json, .ts, and .js files. +

+
+ + ); +} diff --git a/apps/studio/src/components/json-editor.tsx b/apps/studio/src/components/json-editor.tsx new file mode 100644 index 0000000..b15e9b1 --- /dev/null +++ b/apps/studio/src/components/json-editor.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { useAtom, useAtomValue } from "jotai/react"; +import { configJsonAtom, parsedConfigAtom } from "@/store/config"; +import { EditorState } from "@codemirror/state"; +import { EditorView, keymap, lineNumbers } from "@codemirror/view"; +import { json } from "@codemirror/lang-json"; +import { oneDark } from "@codemirror/theme-one-dark"; +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { + bracketMatching, + foldGutter, + indentOnInput, + syntaxHighlighting, + defaultHighlightStyle, +} from "@codemirror/language"; +import { + closeBrackets, + closeBracketsKeymap, + autocompletion, +} from "@codemirror/autocomplete"; + +const editorTheme = EditorView.theme({ + "&": { + height: "100%", + fontSize: "12px", + }, + ".cm-scroller": { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace", + overflow: "auto", + }, + ".cm-gutters": { + backgroundColor: "transparent", + borderRight: "1px solid oklch(1 0 0 / 10%)", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + }, +}); + +export function JsonEditor() { + const [configJson, setConfigJson] = useAtom(configJsonAtom); + const { error } = useAtomValue(parsedConfigAtom); + const editorRef = useRef(null); + const viewRef = useRef(null); + const isInternalUpdate = useRef(false); + + const onUpdate = useCallback( + (value: string) => { + isInternalUpdate.current = true; + setConfigJson(value); + }, + [setConfigJson], + ); + + useEffect(() => { + if (!editorRef.current) return; + + const state = EditorState.create({ + doc: configJson, + extensions: [ + lineNumbers(), + history(), + foldGutter(), + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + json(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + oneDark, + editorTheme, + keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onUpdate(update.state.doc.toString()); + } + }), + EditorView.lineWrapping, + ], + }); + + const view = new EditorView({ + state, + parent: editorRef.current, + }); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (isInternalUpdate.current) { + isInternalUpdate.current = false; + return; + } + const view = viewRef.current; + if (!view) return; + const current = view.state.doc.toString(); + if (current !== configJson) { + view.dispatch({ + changes: { from: 0, to: current.length, insert: configJson }, + }); + } + }, [configJson]); + + return ( +
+ {error && ( +
+ Parse error +
+ )} +
+
+ ); +} diff --git a/apps/studio/src/components/left-pane.tsx b/apps/studio/src/components/left-pane.tsx new file mode 100644 index 0000000..98681b7 --- /dev/null +++ b/apps/studio/src/components/left-pane.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Timeline } from "@/components/timeline"; +import { JsonEditor } from "@/components/json-editor"; +import { EnvVarsPanel } from "@/components/env-vars"; +import { DiffView } from "@/components/diff-view"; +import { ValidationPanel, ValidationBadge } from "@/components/validation-panel"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + +export function LeftPane() { + return ( + +
+ + + Timeline + + + JSON + + + Diff + + + Env + + + Issues + + + +
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/studio/src/components/preview.tsx b/apps/studio/src/components/preview.tsx new file mode 100644 index 0000000..517ef1d --- /dev/null +++ b/apps/studio/src/components/preview.tsx @@ -0,0 +1,1349 @@ +"use client"; + +import { useRef, useState, useEffect, useCallback, useMemo } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai/react"; +import { + configJsonAtom, + selectedVideoConfigAtom, + parsedConfigAtom, + selectedVideoAtom, + pickModeAtom, + pickedSelectorAtom, + renderStatusAtom, + renderLogsAtom, + lastRenderOutputAtom, + videoNamesAtom, + selectedStepIndexAtom, + simulationStatusAtom, + simulationSpeedAtom, + simulationStepAtom, + highlightFoundAtom, + replayStateAtom, + replayedThroughStepAtom, +} from "@/store/config"; +import type { ThemeConfig, Step, WindowConfig, BackgroundConfig } from "@/store/config"; +import { + Globe, + RefreshCw, + ExternalLink, + Crosshair, + Copy, + X, + Play, + Pause, + Square, + Film, + Download, + SkipBack, + SkipForward, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { SimulationControls } from "@/components/simulation-controls"; +import { useTheme } from "next-themes"; +import { cn } from "@/lib/utils"; + +const CURSOR_SVG = + ''; + +function buildCursorStyle(size: number, hotspot: "top-left" | "center"): string { + const scaledSvg = CURSOR_SVG.replace( + /width="24" height="24"/, + `width="${size}" height="${size}"`, + ); + const encoded = encodeURIComponent(scaledSvg) + .replace(/%20/g, " ") + .replace(/%22/g, "'") + .replace(/%3D/g, "=") + .replace(/%3A/g, ":") + .replace(/%2F/g, "/"); + + const hx = hotspot === "center" ? Math.round(size / 2) : Math.round(size * 0.2); + const hy = hotspot === "center" ? Math.round(size / 2) : Math.round(size * 0.1); + + return `url("data:image/svg+xml,${encoded}") ${hx} ${hy}, auto`; +} + +function useContainerSize(ref: React.RefObject) { + const [size, setSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setSize({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + } + }); + + observer.observe(el); + return () => observer.disconnect(); + }, [ref]); + + return size; +} + +function resolveUrl(url: string, baseUrl?: string, configBaseUrl?: string) { + const base = baseUrl || configBaseUrl; + if (!base) return url; + try { + return new URL(url, base).href; + } catch { + return url; + } +} + +function buildThemePayload(videoTheme?: ThemeConfig, configTheme?: ThemeConfig) { + return { + cursor: { + size: videoTheme?.cursor?.size ?? configTheme?.cursor?.size ?? 24, + hotspot: videoTheme?.cursor?.hotspot ?? configTheme?.cursor?.hotspot ?? "top-left", + }, + hud: { + background: + videoTheme?.hud?.background ?? configTheme?.hud?.background ?? "rgba(0,0,0,0.5)", + color: + videoTheme?.hud?.color ?? configTheme?.hud?.color ?? "rgba(255,255,255,0.85)", + fontSize: videoTheme?.hud?.fontSize ?? configTheme?.hud?.fontSize ?? 56, + fontFamily: + videoTheme?.hud?.fontFamily ?? + configTheme?.hud?.fontFamily ?? + '"Geist", -apple-system, BlinkMacSystemFont, sans-serif', + borderRadius: videoTheme?.hud?.borderRadius ?? configTheme?.hud?.borderRadius ?? 18, + position: videoTheme?.hud?.position ?? configTheme?.hud?.position ?? "bottom", + }, + }; +} + +function RenderConsole() { + const [renderStatus, setRenderStatus] = useAtom(renderStatusAtom); + const [renderLogs, setRenderLogs] = useAtom(renderLogsAtom); + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [renderLogs]); + + if (renderStatus === "idle" && renderLogs.length === 0) return null; + + return ( +
+
+ + + {renderStatus === "running" + ? "Recording..." + : renderStatus === "done" + ? "Recording complete" + : renderStatus === "error" + ? "Recording failed" + : "Output"} + +
+ {renderLogs.length > 0 && ( + + )} +
+
+
+ {renderLogs.map((line, i) => ( +
+ {line.startsWith("[err]") ? line.slice(5) : line} +
+ ))} + {renderLogs.length === 0 && ( +

No output yet

+ )} +
+
+ ); +} + +function sendToIframe( + iframeRef: React.RefObject, + type: string, + payload?: Record, +) { + iframeRef.current?.contentWindow?.postMessage({ type, payload }, "*"); +} + +function buildBackgroundStyle(bg?: BackgroundConfig): React.CSSProperties { + if (!bg) return { background: "#e0e0e0" }; + switch (bg.type) { + case "gradient": { + const { from = "#667eea", to = "#764ba2", angle = 180 } = bg.gradient ?? {}; + return { background: `linear-gradient(${angle}deg, ${from}, ${to})` }; + } + case "image": + return { + backgroundImage: bg.image ? `url(${bg.image})` : undefined, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundColor: "#e0e0e0", + }; + case "solid": + default: + return { background: bg.color ?? "#e0e0e0" }; + } +} + +function buildShadowStyle(shadow?: WindowConfig["shadow"]): string | undefined { + if (!shadow) return undefined; + const blur = typeof shadow === "object" ? (shadow.blur ?? 40) : 40; + const color = + typeof shadow === "object" + ? (shadow.color ?? "rgba(0,0,0,0.35)") + : "rgba(0,0,0,0.35)"; + const offsetY = typeof shadow === "object" ? (shadow.offsetY ?? 10) : 10; + return `0 ${offsetY}px ${blur}px ${color}`; +} + +function formatElapsed(seconds: number) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function PreviewWithChrome({ + canvasW, + canvasH, + vpWidth, + vpHeight, + scale, + titlebarH, + titlebarVisible, + borderRadius, + windowCfg, + backgroundCfg, + iframeKey, + iframeRef, + iframeSrc, + selectedVideo, + pickMode, + previewCursor, +}: { + canvasW: number; + canvasH: number; + vpWidth: number; + vpHeight: number; + scale: number; + titlebarH: number; + titlebarVisible: boolean; + borderRadius: number; + windowCfg?: WindowConfig; + backgroundCfg?: BackgroundConfig; + iframeKey: number; + iframeRef: React.RefObject; + iframeSrc: string; + selectedVideo: string | null; + pickMode: boolean; + previewCursor: string; +}) { + const windowTotalH = vpHeight + titlebarH; + let winX: number; + let winY: number; + if ( + windowCfg?.position && + typeof windowCfg.position === "object" && + "x" in windowCfg.position + ) { + winX = windowCfg.position.x; + winY = windowCfg.position.y; + } else { + winX = Math.round((canvasW - vpWidth) / 2); + winY = Math.round((canvasH - windowTotalH) / 2); + } + + const shadow = buildShadowStyle(windowCfg?.shadow); + const tbBg = windowCfg?.titlebar?.background ?? "#e8e8e8"; + const tbStoplight = windowCfg?.titlebar?.stoplight !== false; + const tbTitle = windowCfg?.titlebar?.title ?? ""; + + return ( +
+
+
+ {titlebarVisible && titlebarH > 0 && ( +
+ {tbStoplight && ( +
+ + + +
+ )} + {tbTitle && ( + + {tbTitle} + + )} +
+ )} +