From 6b61d3674a973822a299987c584b82f27fed0513 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 4 Feb 2026 21:29:19 +0100 Subject: [PATCH 1/7] bkp --- package.json | 4 +- pnpm-lock.yaml | 46 +++- src/app.d.ts | 15 ++ .../components/editor/export-dialog.svelte | 144 +++++++++++- .../editor/panels/properties-panel.svelte | 2 - src/lib/components/editor/toolbar.svelte | 1 + src/lib/components/ui/radio-group/index.ts | 10 + .../ui/radio-group/radio-group-item.svelte | 31 +++ .../ui/radio-group/radio-group.svelte | 19 ++ src/lib/server/render-token.ts | 63 ++++++ src/lib/server/video-renderer.ts | 213 ++++++++++++++++++ src/routes/api/export/[id]/+server.ts | 90 ++++++++ src/routes/render/[id]/+page.server.ts | 27 +++ src/routes/render/[id]/+page.svelte | 122 ++++++++++ 14 files changed, 769 insertions(+), 18 deletions(-) create mode 100644 src/lib/components/ui/radio-group/index.ts create mode 100644 src/lib/components/ui/radio-group/radio-group-item.svelte create mode 100644 src/lib/components/ui/radio-group/radio-group.svelte create mode 100644 src/lib/server/render-token.ts create mode 100644 src/lib/server/video-renderer.ts create mode 100644 src/routes/api/export/[id]/+server.ts create mode 100644 src/routes/render/[id]/+page.server.ts create mode 100644 src/routes/render/[id]/+page.svelte diff --git a/package.json b/package.json index bfd6373..285d3ee 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,13 @@ "@eslint/js": "^9.39.2", "@inlang/paraglide-js": "^2.9.1", "@internationalized/date": "^3.10.1", - "@lucide/svelte": "^0.563.1", + "@lucide/svelte": "^0.561.0", "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.50.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/typography": "^0.5.18", "@tailwindcss/vite": "^4.1.13", + "@types/fluent-ffmpeg": "^2.1.28", "@types/node": "^25.0.10", "@vitest/browser": "^3.2.4", "bits-ui": "^2.15.4", @@ -42,6 +43,7 @@ "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.14.0", + "fluent-ffmpeg": "^2.1.3", "globals": "^17.1.0", "mdsvex": "^0.12.6", "paneforge": "^1.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef4383d..76e3aec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,8 +85,8 @@ importers: specifier: ^3.10.1 version: 3.10.1 '@lucide/svelte': - specifier: ^0.563.1 - version: 0.563.1(svelte@5.48.2) + specifier: ^0.561.0 + version: 0.561.0(svelte@5.48.2) '@sveltejs/adapter-node': specifier: ^5.5.2 version: 5.5.2(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1))) @@ -102,6 +102,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.13 version: 4.1.14(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)) + '@types/fluent-ffmpeg': + specifier: ^2.1.28 + version: 2.1.28 '@types/node': specifier: ^25.0.10 version: 25.0.10 @@ -129,6 +132,9 @@ importers: eslint-plugin-svelte: specifier: ^3.14.0 version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.48.2) + fluent-ffmpeg: + specifier: ^2.1.3 + version: 2.1.3 globals: specifier: ^17.1.0 version: 17.1.0 @@ -687,8 +693,8 @@ packages: '@lix-js/server-protocol-schema@0.1.1': resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} - '@lucide/svelte@0.563.1': - resolution: {integrity: sha512-Kt+MbnE5D9RsuI/csmf7M+HWxALe57x3A0DhQ8pPnnUpneh7zuldrYjlT+veWtk+tVnp5doQtaAAxLujzIlhBw==} + '@lucide/svelte@0.561.0': + resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==} peerDependencies: svelte: ^5 @@ -1218,6 +1224,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/fluent-ffmpeg@2.1.28': + resolution: {integrity: sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1430,6 +1439,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@0.2.10: + resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2029,6 +2041,11 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fluent-ffmpeg@2.1.3: + resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} + engines: {node: '>=18'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3221,6 +3238,10 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3662,7 +3683,7 @@ snapshots: '@lix-js/server-protocol-schema@0.1.1': {} - '@lucide/svelte@0.563.1(svelte@5.48.2)': + '@lucide/svelte@0.561.0(svelte@5.48.2)': dependencies: svelte: 5.48.2 @@ -4183,6 +4204,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/fluent-ffmpeg@2.1.28': + dependencies: + '@types/node': 25.0.10 + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -4432,6 +4457,8 @@ snapshots: assertion-error@2.0.1: {} + async@0.2.10: {} + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -5004,6 +5031,11 @@ snapshots: flatted@3.3.3: {} + fluent-ffmpeg@2.1.3: + dependencies: + async: 0.2.10 + which: 1.3.1 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -6078,6 +6110,10 @@ snapshots: whatwg-mimetype@4.0.0: {} + which@1.3.1: + dependencies: + isexe: 2.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/src/app.d.ts b/src/app.d.ts index c77bfc5..6bfcec2 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,6 +3,17 @@ import type { Session, User } from 'better-auth'; // for information about these interfaces +interface DevMotionAPI { + ready: Promise; + seek: (time: number) => void; + getConfig: () => { + width: number; + height: number; + duration: number; + fps: number; + }; +} + declare global { namespace App { // interface Error {} @@ -14,6 +25,10 @@ declare global { // interface PageState {} // interface Platform {} } + + interface Window { + __DEVMOTION__?: DevMotionAPI; + } } declare module 'svelte/elements' { diff --git a/src/lib/components/editor/export-dialog.svelte b/src/lib/components/editor/export-dialog.svelte index aae5d60..afc8c62 100644 --- a/src/lib/components/editor/export-dialog.svelte +++ b/src/lib/components/editor/export-dialog.svelte @@ -13,16 +13,25 @@ import { Progress } from '$lib/components/ui/progress'; import { projectStore } from '$lib/stores/project.svelte'; import { VideoCapture } from '$lib/utils/video-capture'; - import { Loader2, AlertCircle } from 'lucide-svelte'; + import { Loader2, AlertCircle, Monitor, Server } from 'lucide-svelte'; interface Props { open: boolean; onOpenChange: (open: boolean) => void; getCanvasElement: () => HTMLDivElement | undefined; isRecording?: boolean; + projectId?: string | null; } - let { open, onOpenChange, getCanvasElement, isRecording = $bindable(false) }: Props = $props(); + let { + open, + onOpenChange, + getCanvasElement, + isRecording = $bindable(false), + projectId = null + }: Props = $props(); + + type ExportMode = 'browser' | 'server'; let isExporting = $state(false); let isConverting = $state(false); @@ -30,6 +39,7 @@ let exportProgress = $state(0); let errorMessage = $state(null); let videoCapture = new VideoCapture(); + let exportMode = $derived(projectId ? 'server' : 'browser'); let exportSettings = $derived({ format: 'webm', @@ -38,7 +48,69 @@ height: projectStore.project.height }); + // Server export requires saved project + const canUseServerExport = $derived(!!projectId); + async function handleExport() { + if (exportMode === 'server' && canUseServerExport) { + await handleServerExport(); + } else { + await handleBrowserExport(); + } + } + + async function handleServerExport() { + if (!projectId) { + errorMessage = 'Please save the project first to use server-side export.'; + return; + } + + isExporting = true; + exportProgress = 0; + errorMessage = null; + + try { + const response = await fetch(`/api/export/${projectId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + width: exportSettings.width, + height: exportSettings.height, + fps: exportSettings.fps + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Export failed with status ${response.status}`); + } + + // Get the video blob from response + const blob = await response.blob(); + const filename = `${projectStore.project.name || 'video'}.mp4`; + + // Download the file + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + onOpenChange(false); + } catch (error) { + console.error('Server export failed:', error); + errorMessage = + error instanceof Error ? error.message : 'Server export failed. Please try again.'; + } finally { + isExporting = false; + exportProgress = 0; + } + } + + async function handleBrowserExport() { isExporting = true; exportProgress = 0; errorMessage = null; @@ -229,6 +301,48 @@ {:else if !isExporting}
+ +
+ +
+ + +
+
+
-
-

Instructions:

-
    -
  1. Click Export to start the capture
  2. -
  3. Select "This tab" when prompted by the browser
  4. -
  5. The animation will play and be recorded
  6. -
-
+ {#if exportMode === 'browser'} +
+

Instructions:

+
    +
  1. Click Export to start the capture
  2. +
  3. Select "This tab" when prompted by the browser
  4. +
  5. The animation will play and be recorded
  6. +
+
+ {:else} +
+

Server-side rendering

+

+ The video will be rendered on the server with perfect frame accuracy. This may take a + few moments. +

+
+ {/if}
diff --git a/src/lib/components/editor/panels/properties-panel.svelte b/src/lib/components/editor/panels/properties-panel.svelte index 4e57f03..b4a39ac 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -367,7 +367,6 @@ min={min !== undefined ? parseFloat(min) : undefined} max={max !== undefined ? parseFloat(max) : undefined} onchange={onInput} - class="flex-1" /> {/snippet} @@ -788,7 +787,6 @@ min={0} max={projectStore.project.duration} onchange={(v) => updateKeyframeTime(keyframe.id, v)} - class="h-5 w-14 text-[10px] [&_.scrub-grip]:pl-0.5 [&_input]:h-5 [&_input]:pr-4 [&_input]:pl-4 [&_input]:text-[10px]" /> (showExportDialog = open)} {getCanvasElement} bind:isRecording + {projectId} /> diff --git a/src/lib/components/ui/radio-group/index.ts b/src/lib/components/ui/radio-group/index.ts new file mode 100644 index 0000000..919676b --- /dev/null +++ b/src/lib/components/ui/radio-group/index.ts @@ -0,0 +1,10 @@ +import Root from './radio-group.svelte'; +import Item from './radio-group-item.svelte'; + +export { + Root, + Item, + // + Root as RadioGroup, + Item as RadioGroupItem +}; diff --git a/src/lib/components/ui/radio-group/radio-group-item.svelte b/src/lib/components/ui/radio-group/radio-group-item.svelte new file mode 100644 index 0000000..fe4bc4b --- /dev/null +++ b/src/lib/components/ui/radio-group/radio-group-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} +
+ {#if checked} + + {/if} +
+ {/snippet} +
diff --git a/src/lib/components/ui/radio-group/radio-group.svelte b/src/lib/components/ui/radio-group/radio-group.svelte new file mode 100644 index 0000000..ee6a890 --- /dev/null +++ b/src/lib/components/ui/radio-group/radio-group.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/server/render-token.ts b/src/lib/server/render-token.ts new file mode 100644 index 0000000..2db2d02 --- /dev/null +++ b/src/lib/server/render-token.ts @@ -0,0 +1,63 @@ +import { nanoid } from 'nanoid'; + +// Simple in-memory token store with expiration +// In production, this should be Redis or similar +const tokenStore = new Map(); + +const TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Generate a temporary render token for a project + */ +export function generateRenderToken(projectId: string): string { + const token = nanoid(32); + const expiresAt = Date.now() + TOKEN_TTL_MS; + + tokenStore.set(token, { projectId, expiresAt }); + + // Cleanup expired tokens periodically + cleanupExpiredTokens(); + + return token; +} + +/** + * Validate a render token + */ +export function validateRenderToken(token: string, projectId: string): boolean { + const entry = tokenStore.get(token); + + if (!entry) { + return false; + } + + if (Date.now() > entry.expiresAt) { + tokenStore.delete(token); + return false; + } + + if (entry.projectId !== projectId) { + return false; + } + + return true; +} + +/** + * Invalidate a token after use + */ +export function invalidateRenderToken(token: string): void { + tokenStore.delete(token); +} + +/** + * Remove expired tokens + */ +function cleanupExpiredTokens(): void { + const now = Date.now(); + for (const [token, entry] of tokenStore.entries()) { + if (now > entry.expiresAt) { + tokenStore.delete(token); + } + } +} diff --git a/src/lib/server/video-renderer.ts b/src/lib/server/video-renderer.ts new file mode 100644 index 0000000..02691cd --- /dev/null +++ b/src/lib/server/video-renderer.ts @@ -0,0 +1,213 @@ +import { chromium, type Browser, type Page } from 'playwright'; +import ffmpeg from 'fluent-ffmpeg'; +import { PassThrough } from 'stream'; +import { generateRenderToken, invalidateRenderToken } from './render-token'; + +interface RenderConfig { + projectId: string; + width: number; + height: number; + fps: number; + duration: number; + baseUrl: string; +} + +interface RenderProgress { + phase: 'initializing' | 'capturing' | 'encoding' | 'done'; + currentFrame: number; + totalFrames: number; + percent: number; +} + +type ProgressCallback = (progress: RenderProgress) => void; + +/** + * Render a project to video using Playwright screenshots and FFmpeg + * Returns a Buffer containing the video data + */ +export async function renderProjectToVideo( + config: RenderConfig, + onProgress?: ProgressCallback +): Promise { + const { projectId, width, height, fps, duration, baseUrl } = config; + const totalFrames = Math.ceil(fps * duration); + + // Generate render token + const token = generateRenderToken(projectId); + const renderUrl = `${baseUrl}/render/${projectId}?token=${token}`; + + let browser: Browser | null = null; + let page: Page | null = null; + + try { + onProgress?.({ + phase: 'initializing', + currentFrame: 0, + totalFrames, + percent: 0 + }); + + // Launch browser (fresh instance per render for stability) + browser = await chromium.launch({ + headless: true, + args: [ + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-setuid-sandbox', + '--no-sandbox', + '--disable-web-security', + '--disable-features=VizDisplayCompositor' + ] + }); + + page = await browser.newPage({ + viewport: { width, height }, + deviceScaleFactor: 1 + }); + + // Navigate and wait for ready + await page.goto(renderUrl, { waitUntil: 'networkidle' }); + await page.waitForFunction(() => window.__DEVMOTION__?.ready, { timeout: 30000 }); + await page.evaluate(() => window.__DEVMOTION__?.ready); + + // Get actual config from page (in case it differs) + const pageConfig = await page.evaluate(() => window.__DEVMOTION__?.getConfig()); + const actualFps = pageConfig?.fps || fps; + const actualDuration = pageConfig?.duration || duration; + const actualTotalFrames = Math.ceil(actualFps * actualDuration); + + onProgress?.({ + phase: 'capturing', + currentFrame: 0, + totalFrames: actualTotalFrames, + percent: 0 + }); + + // Capture all frames first + const frames: Buffer[] = []; + + for (let frameIndex = 0; frameIndex < actualTotalFrames; frameIndex++) { + // Check if page is still valid + if (page.isClosed()) { + throw new Error('Page was closed during rendering'); + } + + const time = frameIndex / actualFps; + + // Seek to frame time + await page.evaluate((t) => window.__DEVMOTION__?.seek(t), time); + + // Small delay to ensure render is complete + await new Promise((resolve) => setTimeout(resolve, 16)); + + // Take screenshot + const screenshot = await page.screenshot({ + type: 'png', + clip: { x: 0, y: 0, width, height } + }); + + frames.push(screenshot); + + // Report progress + const percent = Math.round(((frameIndex + 1) / actualTotalFrames) * 100); + onProgress?.({ + phase: 'capturing', + currentFrame: frameIndex + 1, + totalFrames: actualTotalFrames, + percent + }); + } + + // Close page early - we have all frames + await page.close(); + page = null; + await browser.close(); + browser = null; + invalidateRenderToken(token); + + onProgress?.({ + phase: 'encoding', + currentFrame: actualTotalFrames, + totalFrames: actualTotalFrames, + percent: 100 + }); + + // Encode frames to video + const videoBuffer = await encodeFramesToVideo(frames, actualFps, width, height); + + onProgress?.({ + phase: 'done', + currentFrame: actualTotalFrames, + totalFrames: actualTotalFrames, + percent: 100 + }); + + return videoBuffer; + } finally { + // Cleanup + try { + if (page && !page.isClosed()) { + await page.close(); + } + } catch { + // Ignore close errors + } + try { + if (browser?.isConnected()) { + await browser.close(); + } + } catch { + // Ignore close errors + } + invalidateRenderToken(token); + } +} + +/** + * Encode frames to MP4 video using FFmpeg + */ +async function encodeFramesToVideo( + frames: Buffer[], + fps: number, + width: number, + height: number +): Promise { + return new Promise((resolve, reject) => { + const frameStream = new PassThrough(); + const outputChunks: Buffer[] = []; + + const ffmpegCommand = ffmpeg() + .input(frameStream) + .inputFormat('image2pipe') + .inputFPS(fps) + .outputOptions([ + '-c:v libx264', + '-preset fast', + '-crf 18', + '-pix_fmt yuv420p', + `-s ${width}x${height}`, + '-movflags +faststart+frag_keyframe+empty_moov' + ]) + .format('mp4') + .on('error', (err) => { + console.error('FFmpeg error:', err); + reject(err); + }); + + // Collect output chunks + const outputStream = ffmpegCommand.pipe(); + outputStream.on('data', (chunk: Buffer) => { + outputChunks.push(chunk); + }); + outputStream.on('end', () => { + resolve(Buffer.concat(outputChunks)); + }); + outputStream.on('error', reject); + + // Write all frames + for (const frame of frames) { + frameStream.write(frame); + } + frameStream.end(); + }); +} diff --git a/src/routes/api/export/[id]/+server.ts b/src/routes/api/export/[id]/+server.ts new file mode 100644 index 0000000..e96a158 --- /dev/null +++ b/src/routes/api/export/[id]/+server.ts @@ -0,0 +1,90 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { project } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { renderProjectToVideo } from '$lib/server/video-renderer'; +import { PUBLIC_BASE_URL } from '$env/static/public'; +import type { ProjectData } from '$lib/schemas/animation'; + +export const POST: RequestHandler = async ({ params, request }) => { + const { id } = params; + + // Fetch project from DB + const dbProject = await db.query.project.findFirst({ + where: eq(project.id, id) + }); + + if (!dbProject) { + error(404, 'Project not found'); + } + + const projectData = dbProject.data as ProjectData; + + // Parse optional config from request body + const config = { + width: projectData.width, + height: projectData.height, + fps: projectData.fps, + duration: projectData.duration + }; + + try { + const body = await request.json(); + if (body.width) config.width = body.width; + if (body.height) config.height = body.height; + if (body.fps) config.fps = body.fps; + } catch { + // No body or invalid JSON, use defaults + } + + // Determine base URL for internal rendering + // In development, use localhost; in production, use PUBLIC_BASE_URL + const baseUrl = PUBLIC_BASE_URL || 'http://localhost:5173'; + + try { + // Render video and get buffer + const videoBuffer = await renderProjectToVideo({ + projectId: id, + width: config.width, + height: config.height, + fps: config.fps, + duration: config.duration, + baseUrl + }); + + // Return video response (convert Buffer to Uint8Array for Response compatibility) + return new Response(new Uint8Array(videoBuffer), { + headers: { + 'Content-Type': 'video/mp4', + 'Content-Length': videoBuffer.byteLength.toString(), + 'Content-Disposition': `attachment; filename="${projectData.name || 'video'}.mp4"`, + 'Cache-Control': 'no-cache' + } + }); + } catch (err) { + console.error('Video rendering error:', err); + error(500, `Failed to render video: ${err instanceof Error ? err.message : 'Unknown error'}`); + } +}; + +// GET for checking status (optional, for future queue implementation) +export const GET: RequestHandler = async ({ params }) => { + const { id } = params; + + const dbProject = await db.query.project.findFirst({ + where: eq(project.id, id), + columns: { id: true, name: true } + }); + + if (!dbProject) { + error(404, 'Project not found'); + } + + return json({ + projectId: id, + name: dbProject.name, + status: 'ready', + message: 'Use POST to start rendering' + }); +}; diff --git a/src/routes/render/[id]/+page.server.ts b/src/routes/render/[id]/+page.server.ts new file mode 100644 index 0000000..3e8c089 --- /dev/null +++ b/src/routes/render/[id]/+page.server.ts @@ -0,0 +1,27 @@ +import { db } from '$lib/server/db'; +import { project } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { validateRenderToken } from '$lib/server/render-token'; + +export const load: PageServerLoad = async ({ params, url }) => { + const token = url.searchParams.get('token'); + + // Validate render token (internal use only) + if (!token || !validateRenderToken(token, params.id)) { + error(403, 'Invalid or missing render token'); + } + + const result = await db.query.project.findFirst({ + where: eq(project.id, params.id) + }); + + if (!result) { + error(404, 'Project not found'); + } + + return { + project: result.data + }; +}; diff --git a/src/routes/render/[id]/+page.svelte b/src/routes/render/[id]/+page.svelte new file mode 100644 index 0000000..00caf8a --- /dev/null +++ b/src/routes/render/[id]/+page.svelte @@ -0,0 +1,122 @@ + + + + Render - {project.name} + + + + +
+ +
+ {#each project.layers as layer (layer.id)} + {@const { transform, style, customProps } = getLayerRenderData(layer)} + {@const component = getLayerComponent(layer.type)} + + + {/each} +
+
+ + From 6011986ca08b7cde65d83378b5bbea7967609e02 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 4 Feb 2026 21:29:23 +0100 Subject: [PATCH 2/7] stream --- src/lib/server/video-renderer.ts | 60 +++++++++++++-------------- src/routes/api/export/[id]/+server.ts | 12 +++--- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/lib/server/video-renderer.ts b/src/lib/server/video-renderer.ts index 02691cd..ca0072e 100644 --- a/src/lib/server/video-renderer.ts +++ b/src/lib/server/video-renderer.ts @@ -1,4 +1,4 @@ -import { chromium, type Browser, type Page } from 'playwright'; +import { chromium } from 'playwright'; import ffmpeg from 'fluent-ffmpeg'; import { PassThrough } from 'stream'; import { generateRenderToken, invalidateRenderToken } from './render-token'; @@ -12,18 +12,19 @@ interface RenderConfig { baseUrl: string; } -interface RenderProgress { - phase: 'initializing' | 'capturing' | 'encoding' | 'done'; +export interface RenderProgress { + phase: 'initializing' | 'capturing' | 'encoding' | 'done' | 'error'; currentFrame: number; totalFrames: number; percent: number; + error?: string; } type ProgressCallback = (progress: RenderProgress) => void; /** * Render a project to video using Playwright screenshots and FFmpeg - * Returns a Buffer containing the video data + * Calls onProgress with updates and returns the final video buffer */ export async function renderProjectToVideo( config: RenderConfig, @@ -36,8 +37,8 @@ export async function renderProjectToVideo( const token = generateRenderToken(projectId); const renderUrl = `${baseUrl}/render/${projectId}?token=${token}`; - let browser: Browser | null = null; - let page: Page | null = null; + let browser = null; + let page = null; try { onProgress?.({ @@ -47,7 +48,7 @@ export async function renderProjectToVideo( percent: 0 }); - // Launch browser (fresh instance per render for stability) + // Launch browser browser = await chromium.launch({ headless: true, args: [ @@ -70,7 +71,7 @@ export async function renderProjectToVideo( await page.waitForFunction(() => window.__DEVMOTION__?.ready, { timeout: 30000 }); await page.evaluate(() => window.__DEVMOTION__?.ready); - // Get actual config from page (in case it differs) + // Get actual config from page const pageConfig = await page.evaluate(() => window.__DEVMOTION__?.getConfig()); const actualFps = pageConfig?.fps || fps; const actualDuration = pageConfig?.duration || duration; @@ -83,11 +84,10 @@ export async function renderProjectToVideo( percent: 0 }); - // Capture all frames first + // Capture frames const frames: Buffer[] = []; for (let frameIndex = 0; frameIndex < actualTotalFrames; frameIndex++) { - // Check if page is still valid if (page.isClosed()) { throw new Error('Page was closed during rendering'); } @@ -97,7 +97,7 @@ export async function renderProjectToVideo( // Seek to frame time await page.evaluate((t) => window.__DEVMOTION__?.seek(t), time); - // Small delay to ensure render is complete + // Wait for render await new Promise((resolve) => setTimeout(resolve, 16)); // Take screenshot @@ -108,8 +108,8 @@ export async function renderProjectToVideo( frames.push(screenshot); - // Report progress - const percent = Math.round(((frameIndex + 1) / actualTotalFrames) * 100); + // Report progress (capturing is 0-80%) + const percent = Math.round(((frameIndex + 1) / actualTotalFrames) * 80); onProgress?.({ phase: 'capturing', currentFrame: frameIndex + 1, @@ -118,7 +118,7 @@ export async function renderProjectToVideo( }); } - // Close page early - we have all frames + // Close browser before encoding await page.close(); page = null; await browser.close(); @@ -129,7 +129,7 @@ export async function renderProjectToVideo( phase: 'encoding', currentFrame: actualTotalFrames, totalFrames: actualTotalFrames, - percent: 100 + percent: 85 }); // Encode frames to video @@ -143,23 +143,24 @@ export async function renderProjectToVideo( }); return videoBuffer; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + onProgress?.({ + phase: 'error', + currentFrame: 0, + totalFrames, + percent: 0, + error: errorMessage + }); + throw err; } finally { - // Cleanup + invalidateRenderToken(token); try { - if (page && !page.isClosed()) { - await page.close(); - } - } catch { - // Ignore close errors - } + if (page && !page.isClosed()) await page.close(); + } catch { /* ignore */ } try { - if (browser?.isConnected()) { - await browser.close(); - } - } catch { - // Ignore close errors - } - invalidateRenderToken(token); + if (browser?.isConnected()) await browser.close(); + } catch { /* ignore */ } } } @@ -194,7 +195,6 @@ async function encodeFramesToVideo( reject(err); }); - // Collect output chunks const outputStream = ffmpegCommand.pipe(); outputStream.on('data', (chunk: Buffer) => { outputChunks.push(chunk); diff --git a/src/routes/api/export/[id]/+server.ts b/src/routes/api/export/[id]/+server.ts index e96a158..46e2461 100644 --- a/src/routes/api/export/[id]/+server.ts +++ b/src/routes/api/export/[id]/+server.ts @@ -43,8 +43,8 @@ export const POST: RequestHandler = async ({ params, request }) => { const baseUrl = PUBLIC_BASE_URL || 'http://localhost:5173'; try { - // Render video and get buffer - const videoBuffer = await renderProjectToVideo({ + // Render video and get stream + const videoStream = await renderProjectToVideo({ projectId: id, width: config.width, height: config.height, @@ -53,13 +53,13 @@ export const POST: RequestHandler = async ({ params, request }) => { baseUrl }); - // Return video response (convert Buffer to Uint8Array for Response compatibility) - return new Response(new Uint8Array(videoBuffer), { + // Return streaming response + return new Response(videoStream, { headers: { 'Content-Type': 'video/mp4', - 'Content-Length': videoBuffer.byteLength.toString(), 'Content-Disposition': `attachment; filename="${projectData.name || 'video'}.mp4"`, - 'Cache-Control': 'no-cache' + 'Cache-Control': 'no-cache', + 'Transfer-Encoding': 'chunked' } }); } catch (err) { From 5d1dedcfda04fb80f67d93c634e4c0757d828886 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 4 Feb 2026 21:45:12 +0100 Subject: [PATCH 3/7] feat: implement server-side video rendering with streaming output and real-time progress updates via Server-Sent Events. --- Dockerfile | 37 ++- .../components/editor/export-dialog.svelte | 53 +++- src/lib/server/video-renderer.ts | 296 ++++++++---------- src/routes/api/export/[id]/+server.ts | 75 +++-- 4 files changed, 252 insertions(+), 209 deletions(-) diff --git a/Dockerfile b/Dockerfile index 885ca9f..8c71d5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM node:20-alpine AS builder +FROM node:20-slim AS builder WORKDIR /app @@ -15,20 +15,37 @@ RUN pnpm install --frozen-lockfile # Copy source code COPY . . -# Prepare the app -RUN pnpm run prepare - # Build the app RUN pnpm run build # Production stage -FROM nginx:alpine +# Playwright official image includes all browser dependencies +FROM mcr.microsoft.com/playwright:v1.50.0-jammy + +WORKDIR /app + +# Install FFmpeg +RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* + +# Copy built application +COPY --from=builder /app/build ./build +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml + +# Install only production dependencies +RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile + +# Playwright images usually come with browsers, but let's ensure chromium is there +RUN npx playwright install chromium -# Copy built static files to nginx -COPY --from=builder /app/build /usr/share/nginx/html +# Environment variables +ENV NODE_ENV=production +ENV PORT=3000 +# Use 0.0.0.0 to allow access from outside the container +ENV HOST=0.0.0.0 # Expose port -EXPOSE 80 +EXPOSE 3000 -# Start nginx -CMD ["nginx", "-g", "daemon off;"] +# Start the Node.js server +CMD ["node", "build/index.js"] diff --git a/src/lib/components/editor/export-dialog.svelte b/src/lib/components/editor/export-dialog.svelte index afc8c62..80200d4 100644 --- a/src/lib/components/editor/export-dialog.svelte +++ b/src/lib/components/editor/export-dialog.svelte @@ -39,7 +39,6 @@ let exportProgress = $state(0); let errorMessage = $state(null); let videoCapture = new VideoCapture(); - let exportMode = $derived(projectId ? 'server' : 'browser'); let exportSettings = $derived({ format: 'webm', @@ -48,9 +47,15 @@ height: projectStore.project.height }); + let exportMode = $derived(projectId ? 'server' : 'browser'); + // Server export requires saved project const canUseServerExport = $derived(!!projectId); + let serverPhase = $state<'initializing' | 'capturing' | 'encoding' | 'done' | 'error' | 'ready'>( + 'ready' + ); + async function handleExport() { if (exportMode === 'server' && canUseServerExport) { await handleServerExport(); @@ -65,12 +70,39 @@ return; } + const renderId = crypto.randomUUID(); isExporting = true; exportProgress = 0; errorMessage = null; + serverPhase = 'initializing'; + + // Start SSE for progress + const eventSource = new EventSource(`/api/export/${projectId}?renderId=${renderId}`); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + exportProgress = data.percent; + serverPhase = data.phase; + + if (data.phase === 'done' || data.phase === 'error') { + eventSource.close(); + if (data.phase === 'error') { + errorMessage = data.error || 'Server export failed.'; + } + } + } catch (err) { + console.error('Error parsing SSE:', err); + } + }; + + eventSource.onerror = () => { + console.error('SSE connection failed'); + eventSource.close(); + }; try { - const response = await fetch(`/api/export/${projectId}`, { + const response = await fetch(`/api/export/${projectId}?renderId=${renderId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -85,11 +117,10 @@ throw new Error(errorText || `Export failed with status ${response.status}`); } - // Get the video blob from response + // In a streaming response, we have to read it as a blob if we want to trigger a download window const blob = await response.blob(); const filename = `${projectStore.project.name || 'video'}.mp4`; - // Download the file const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -104,7 +135,9 @@ console.error('Server export failed:', error); errorMessage = error instanceof Error ? error.message : 'Server export failed. Please try again.'; + serverPhase = 'error'; } finally { + eventSource.close(); isExporting = false; exportProgress = 0; } @@ -408,6 +441,18 @@
{#if isPreparing} Preparing frames for recording... + {:else if exportMode === 'server'} + {#if serverPhase === 'initializing'} + Initializing render engine... + {:else if serverPhase === 'capturing'} + Capturing animation frames... + {:else if serverPhase === 'encoding'} + Encoding high-quality video... + {:else if serverPhase === 'done'} + Download starting... + {:else} + Rendering video... + {/if} {:else} Recording video... Please wait. {/if} diff --git a/src/lib/server/video-renderer.ts b/src/lib/server/video-renderer.ts index ca0072e..b24c992 100644 --- a/src/lib/server/video-renderer.ts +++ b/src/lib/server/video-renderer.ts @@ -1,10 +1,12 @@ import { chromium } from 'playwright'; import ffmpeg from 'fluent-ffmpeg'; -import { PassThrough } from 'stream'; +import { PassThrough, Readable } from 'stream'; +import { EventEmitter } from 'events'; import { generateRenderToken, invalidateRenderToken } from './render-token'; interface RenderConfig { projectId: string; + renderId: string; // Unique ID for this specific render session width: number; height: number; fps: number; @@ -20,194 +22,144 @@ export interface RenderProgress { error?: string; } -type ProgressCallback = (progress: RenderProgress) => void; +// Global emitter for render progress +export const renderEmitter = new EventEmitter(); /** - * Render a project to video using Playwright screenshots and FFmpeg - * Calls onProgress with updates and returns the final video buffer + * Render a project to video using Playwright screenshots and FFmpeg. + * Returns a Readable stream that will yield the MP4 data. */ -export async function renderProjectToVideo( - config: RenderConfig, - onProgress?: ProgressCallback -): Promise { - const { projectId, width, height, fps, duration, baseUrl } = config; +export async function renderProjectToVideoStream(config: RenderConfig): Promise { + const { projectId, renderId, width, height, fps, duration, baseUrl } = config; const totalFrames = Math.ceil(fps * duration); - // Generate render token + const videoStream = new PassThrough(); const token = generateRenderToken(projectId); const renderUrl = `${baseUrl}/render/${projectId}?token=${token}`; - let browser = null; - let page = null; - - try { - onProgress?.({ - phase: 'initializing', - currentFrame: 0, - totalFrames, - percent: 0 - }); - - // Launch browser - browser = await chromium.launch({ - headless: true, - args: [ - '--disable-gpu', - '--disable-dev-shm-usage', - '--disable-setuid-sandbox', - '--no-sandbox', - '--disable-web-security', - '--disable-features=VizDisplayCompositor' - ] - }); - - page = await browser.newPage({ - viewport: { width, height }, - deviceScaleFactor: 1 - }); - - // Navigate and wait for ready - await page.goto(renderUrl, { waitUntil: 'networkidle' }); - await page.waitForFunction(() => window.__DEVMOTION__?.ready, { timeout: 30000 }); - await page.evaluate(() => window.__DEVMOTION__?.ready); - - // Get actual config from page - const pageConfig = await page.evaluate(() => window.__DEVMOTION__?.getConfig()); - const actualFps = pageConfig?.fps || fps; - const actualDuration = pageConfig?.duration || duration; - const actualTotalFrames = Math.ceil(actualFps * actualDuration); - - onProgress?.({ - phase: 'capturing', - currentFrame: 0, - totalFrames: actualTotalFrames, - percent: 0 - }); - - // Capture frames - const frames: Buffer[] = []; - - for (let frameIndex = 0; frameIndex < actualTotalFrames; frameIndex++) { - if (page.isClosed()) { - throw new Error('Page was closed during rendering'); - } - - const time = frameIndex / actualFps; + // Helper to emit progress + const emitProgress = (progress: RenderProgress) => { + renderEmitter.emit(`progress:${renderId}`, progress); + }; - // Seek to frame time - await page.evaluate((t) => window.__DEVMOTION__?.seek(t), time); + // Run the render process in the background + (async () => { + let browser = null; + let page = null; + let ffmpegCommand: ffmpeg.FfmpegCommand | null = null; - // Wait for render - await new Promise((resolve) => setTimeout(resolve, 16)); - - // Take screenshot - const screenshot = await page.screenshot({ - type: 'png', - clip: { x: 0, y: 0, width, height } + try { + emitProgress({ phase: 'initializing', currentFrame: 0, totalFrames, percent: 0 }); + + browser = await chromium.launch({ + headless: true, + args: [ + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-setuid-sandbox', + '--no-sandbox', + '--disable-web-security' + ] }); - frames.push(screenshot); + page = await browser.newPage({ + viewport: { width, height }, + deviceScaleFactor: 1 + }); - // Report progress (capturing is 0-80%) - const percent = Math.round(((frameIndex + 1) / actualTotalFrames) * 80); - onProgress?.({ + await page.goto(renderUrl, { waitUntil: 'networkidle' }); + await page.waitForFunction(() => window.__DEVMOTION__?.ready, { timeout: 30000 }); + + const pageConfig = await page.evaluate(() => window.__DEVMOTION__?.getConfig()); + const actualFps = pageConfig?.fps || fps; + const actualDuration = pageConfig?.duration || duration; + const actualTotalFrames = Math.ceil(actualFps * actualDuration); + + // Initialize FFmpeg + const frameStream = new PassThrough(); + + ffmpegCommand = ffmpeg() + .input(frameStream) + .inputFormat('image2pipe') + .inputFPS(actualFps) + .outputOptions([ + '-c:v libx264', + '-preset ultrafast', // Ultrafast for streaming efficiency + '-crf 18', + '-pix_fmt yuv420p', + `-s ${width}x${height}`, + '-movflags +faststart+frag_keyframe+empty_moov' + ]) + .format('mp4') + .on('error', (err) => { + console.error('FFmpeg error:', err); + emitProgress({ + phase: 'error', + currentFrame: 0, + totalFrames: actualTotalFrames, + percent: 0, + error: err.message + }); + videoStream.destroy(err); + }) + .on('end', () => { + emitProgress({ + phase: 'done', + currentFrame: actualTotalFrames, + totalFrames: actualTotalFrames, + percent: 100 + }); + }); + + ffmpegCommand.pipe(videoStream); + + emitProgress({ phase: 'capturing', - currentFrame: frameIndex + 1, + currentFrame: 0, totalFrames: actualTotalFrames, - percent + percent: 0 }); - } - // Close browser before encoding - await page.close(); - page = null; - await browser.close(); - browser = null; - invalidateRenderToken(token); - - onProgress?.({ - phase: 'encoding', - currentFrame: actualTotalFrames, - totalFrames: actualTotalFrames, - percent: 85 - }); - - // Encode frames to video - const videoBuffer = await encodeFramesToVideo(frames, actualFps, width, height); - - onProgress?.({ - phase: 'done', - currentFrame: actualTotalFrames, - totalFrames: actualTotalFrames, - percent: 100 - }); - - return videoBuffer; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - onProgress?.({ - phase: 'error', - currentFrame: 0, - totalFrames, - percent: 0, - error: errorMessage - }); - throw err; - } finally { - invalidateRenderToken(token); - try { - if (page && !page.isClosed()) await page.close(); - } catch { /* ignore */ } - try { - if (browser?.isConnected()) await browser.close(); - } catch { /* ignore */ } - } -} - -/** - * Encode frames to MP4 video using FFmpeg - */ -async function encodeFramesToVideo( - frames: Buffer[], - fps: number, - width: number, - height: number -): Promise { - return new Promise((resolve, reject) => { - const frameStream = new PassThrough(); - const outputChunks: Buffer[] = []; - - const ffmpegCommand = ffmpeg() - .input(frameStream) - .inputFormat('image2pipe') - .inputFPS(fps) - .outputOptions([ - '-c:v libx264', - '-preset fast', - '-crf 18', - '-pix_fmt yuv420p', - `-s ${width}x${height}`, - '-movflags +faststart+frag_keyframe+empty_moov' - ]) - .format('mp4') - .on('error', (err) => { - console.error('FFmpeg error:', err); - reject(err); - }); + // Capture frames and pipe to FFmpeg + for (let frameIndex = 0; frameIndex < actualTotalFrames; frameIndex++) { + const time = frameIndex / actualFps; + await page.evaluate((t) => window.__DEVMOTION__?.seek(t), time); + + // Brief wait for any JS/canvas updates + await new Promise((resolve) => setTimeout(resolve, 32)); + + const screenshot = await page.screenshot({ + type: 'png', + clip: { x: 0, y: 0, width, height } + }); + + if (frameStream.destroyed) break; + frameStream.write(screenshot); + + const percent = Math.round(((frameIndex + 1) / actualTotalFrames) * 95); + emitProgress({ + phase: 'capturing', + currentFrame: frameIndex + 1, + totalFrames: actualTotalFrames, + percent + }); + } - const outputStream = ffmpegCommand.pipe(); - outputStream.on('data', (chunk: Buffer) => { - outputChunks.push(chunk); - }); - outputStream.on('end', () => { - resolve(Buffer.concat(outputChunks)); - }); - outputStream.on('error', reject); - - // Write all frames - for (const frame of frames) { - frameStream.write(frame); + frameStream.end(); + + await page.close(); + await browser.close(); + browser = null; + invalidateRenderToken(token); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + emitProgress({ phase: 'error', currentFrame: 0, totalFrames, percent: 0, error: msg }); + videoStream.destroy(err as Error); + } finally { + invalidateRenderToken(token); + if (browser) await browser.close(); } - frameStream.end(); - }); + })(); + + return videoStream; } diff --git a/src/routes/api/export/[id]/+server.ts b/src/routes/api/export/[id]/+server.ts index 46e2461..26858d9 100644 --- a/src/routes/api/export/[id]/+server.ts +++ b/src/routes/api/export/[id]/+server.ts @@ -1,14 +1,21 @@ -import { json, error } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; import { project } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; -import { renderProjectToVideo } from '$lib/server/video-renderer'; +import { renderProjectToVideoStream, renderEmitter } from '$lib/server/video-renderer'; +import type { RenderProgress } from '$lib/server/video-renderer'; import { PUBLIC_BASE_URL } from '$env/static/public'; import type { ProjectData } from '$lib/schemas/animation'; +import { Readable } from 'stream'; -export const POST: RequestHandler = async ({ params, request }) => { +export const POST: RequestHandler = async ({ params, request, url }) => { const { id } = params; + const renderId = url.searchParams.get('renderId'); + + if (!renderId) { + error(400, 'Missing renderId'); + } // Fetch project from DB const dbProject = await db.query.project.findFirst({ @@ -38,14 +45,13 @@ export const POST: RequestHandler = async ({ params, request }) => { // No body or invalid JSON, use defaults } - // Determine base URL for internal rendering - // In development, use localhost; in production, use PUBLIC_BASE_URL const baseUrl = PUBLIC_BASE_URL || 'http://localhost:5173'; try { - // Render video and get stream - const videoStream = await renderProjectToVideo({ + // Start rendering and get stream + const videoStream = await renderProjectToVideoStream({ projectId: id, + renderId, width: config.width, height: config.height, fps: config.fps, @@ -53,8 +59,10 @@ export const POST: RequestHandler = async ({ params, request }) => { baseUrl }); - // Return streaming response - return new Response(videoStream, { + // Return the stream as response + const webStream = Readable.toWeb(videoStream) as ReadableStream; + + return new Response(webStream, { headers: { 'Content-Type': 'video/mp4', 'Content-Disposition': `attachment; filename="${projectData.name || 'video'}.mp4"`, @@ -68,23 +76,44 @@ export const POST: RequestHandler = async ({ params, request }) => { } }; -// GET for checking status (optional, for future queue implementation) -export const GET: RequestHandler = async ({ params }) => { +/** + * SSE endpoint for tracking progress + */ +export const GET: RequestHandler = async ({ params, url }) => { const { id } = params; + const renderId = url.searchParams.get('renderId'); - const dbProject = await db.query.project.findFirst({ - where: eq(project.id, id), - columns: { id: true, name: true } - }); - - if (!dbProject) { - error(404, 'Project not found'); + if (!renderId) { + error(400, 'Missing renderId'); } - return json({ - projectId: id, - name: dbProject.name, - status: 'ready', - message: 'Use POST to start rendering' + const body = new ReadableStream({ + start(controller) { + const onProgress = (progress: RenderProgress) => { + controller.enqueue(`data: ${JSON.stringify(progress)}\n\n`); + if (progress.phase === 'done' || progress.phase === 'error') { + cleanup(); + controller.close(); + } + }; + + const cleanup = () => { + renderEmitter.removeListener(`progress:${renderId}`, onProgress); + }; + + renderEmitter.on(`progress:${renderId}`, onProgress); + }, + cancel() { + // In case of client disconnect, thePOST might still be running, + // but we should detach the listener. + } + }); + + return new Response(body, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } }); }; From 1954fada885d65ab9a5ec4fbd7b788d0db491cf2 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 4 Feb 2026 23:16:55 +0100 Subject: [PATCH 4/7] coderabbit fixes --- Dockerfile | 1 + package.json | 6 +-- pnpm-lock.yaml | 53 ++++++------------- src/lib/components/ai/ai-chat.svelte | 2 +- src/lib/components/editor/Logo.svelte | 2 +- .../editor/canvas/canvas-controls.svelte | 2 +- .../components/editor/canvas/canvas.svelte | 2 +- .../editor/canvas/playback-controls.svelte | 2 +- .../components/editor/editor-layout.svelte | 6 +-- .../components/editor/export-dialog.svelte | 3 +- .../editor/panels/background-picker.svelte | 2 +- .../editor/panels/input-wrapper.svelte | 2 +- .../editor/panels/layers-panel.svelte | 2 +- .../editor/panels/properties-panel.svelte | 2 +- .../editor/panels/scrub-input.svelte | 2 +- .../components/editor/panels/scrub-xyz.svelte | 2 +- .../editor/project-settings-dialog.svelte | 2 +- .../components/editor/project-switcher.svelte | 2 +- src/lib/components/editor/toolbar.svelte | 4 +- src/lib/components/layout/app-header.svelte | 2 +- src/lib/components/ui/button/button.svelte | 6 +-- src/lib/layers/components/BrowserLayer.svelte | 2 +- src/lib/layers/components/ButtonLayer.svelte | 2 +- src/lib/layers/components/CodeLayer.svelte | 2 +- src/lib/layers/components/HtmlLayer.svelte | 2 +- src/lib/layers/components/IconLayer.svelte | 4 +- src/lib/layers/components/ImageLayer.svelte | 2 +- src/lib/layers/components/MouseLayer.svelte | 4 +- src/lib/layers/components/PhoneLayer.svelte | 2 +- .../layers/components/ProgressLayer.svelte | 2 +- src/lib/layers/components/ShapeLayer.svelte | 2 +- .../layers/components/TerminalLayer.svelte | 2 +- src/lib/layers/components/TextLayer.svelte | 2 +- src/lib/layers/registry.ts | 4 +- src/lib/server/video-renderer.ts | 52 ++++++++++++++---- src/routes/(app)/+error.svelte | 2 +- src/routes/(app)/p/[id]/og.png/og.svelte | 2 +- src/routes/(marketing)/+layout.svelte | 2 +- src/routes/(marketing)/gallery/+page.svelte | 2 +- src/routes/+error.svelte | 2 +- src/routes/api/export/[id]/+server.ts | 35 +++++++----- src/routes/og.png/og.svelte | 2 +- 42 files changed, 132 insertions(+), 106 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8c71d5f..033f206 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/build ./build COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml # Install only production dependencies RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile diff --git a/package.json b/package.json index 285d3ee..0a04c25 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@eslint/js": "^9.39.2", "@inlang/paraglide-js": "^2.9.1", "@internationalized/date": "^3.10.1", - "@lucide/svelte": "^0.561.0", + "@lucide/svelte": "^0.563.1", "@sveltejs/adapter-node": "^5.5.2", "@sveltejs/kit": "^2.50.1", "@sveltejs/vite-plugin-svelte": "^6.2.4", @@ -70,17 +70,15 @@ "@openrouter/ai-sdk-provider": "^2.0.2", "@resvg/resvg-js": "^2.6.2", "@sveltejs/adapter-static": "^3.0.10", - "@types/dompurify": "^3.0.5", "@vercel/mcp-adapter": "^1.0.0", "ai": "^6.0.49", "better-auth": "^1.3.27", "bezier-easing": "^2.1.0", "dompurify": "^3.3.1", - "lucide-svelte": "^0.563.0", "mediabunny": "^1.30.1", "nanoid": "^5.1.6", "postgres": "^3.4.7", - "runed": "^0.34.0", + "runed": "^0.37.1", "schema-dts": "^1.1.5", "svelte-component-to-image": "^2.0.5", "svelte-sonner": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76e3aec..71ec047 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: '@sveltejs/adapter-static': specifier: ^3.0.10 version: 3.0.10(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1))) - '@types/dompurify': - specifier: ^3.0.5 - version: 3.2.0 '@vercel/mcp-adapter': specifier: ^1.0.0 version: 1.0.0(@modelcontextprotocol/sdk@1.25.2(hono@4.11.7)(zod@4.3.6)) @@ -41,9 +38,6 @@ importers: dompurify: specifier: ^3.3.1 version: 3.3.1 - lucide-svelte: - specifier: ^0.563.0 - version: 0.563.0(svelte@5.48.2) mediabunny: specifier: ^1.30.1 version: 1.30.1 @@ -54,8 +48,8 @@ importers: specifier: ^3.4.7 version: 3.4.7 runed: - specifier: ^0.34.0 - version: 0.34.0(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2) + specifier: ^0.37.1 + version: 0.37.1(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(zod@4.3.6) schema-dts: specifier: ^1.1.5 version: 1.1.5 @@ -85,8 +79,8 @@ importers: specifier: ^3.10.1 version: 3.10.1 '@lucide/svelte': - specifier: ^0.561.0 - version: 0.561.0(svelte@5.48.2) + specifier: ^0.563.1 + version: 0.563.1(svelte@5.48.2) '@sveltejs/adapter-node': specifier: ^5.5.2 version: 5.5.2(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1))) @@ -693,8 +687,8 @@ packages: '@lix-js/server-protocol-schema@0.1.1': resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} - '@lucide/svelte@0.561.0': - resolution: {integrity: sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==} + '@lucide/svelte@0.563.1': + resolution: {integrity: sha512-Kt+MbnE5D9RsuI/csmf7M+HWxALe57x3A0DhQ8pPnnUpneh7zuldrYjlT+veWtk+tVnp5doQtaAAxLujzIlhBw==} peerDependencies: svelte: ^5 @@ -1217,10 +1211,6 @@ packages: '@types/dom-webcodecs@0.1.13': resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} - '@types/dompurify@3.2.0': - resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} - deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2354,11 +2344,6 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - lucide-svelte@0.563.0: - resolution: {integrity: sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==} - peerDependencies: - svelte: ^3 || ^4 || ^5.0.0-next.42 - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2772,8 +2757,8 @@ packages: peerDependencies: svelte: ^5.7.0 - runed@0.34.0: - resolution: {integrity: sha512-hdDCoxWCuOCa7HnuU2ihu2tXuAOacNXtvTDDZ02km+rguHZBtglzAoo3dVYtssZjFsooY9xawvYX9HmDJqaPTA==} + runed@0.35.1: + resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} peerDependencies: '@sveltejs/kit': ^2.21.0 svelte: ^5.7.0 @@ -2781,14 +2766,17 @@ packages: '@sveltejs/kit': optional: true - runed@0.35.1: - resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} + runed@0.37.1: + resolution: {integrity: sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==} peerDependencies: '@sveltejs/kit': ^2.21.0 svelte: ^5.7.0 + zod: ^4.1.0 peerDependenciesMeta: '@sveltejs/kit': optional: true + zod: + optional: true sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} @@ -3683,7 +3671,7 @@ snapshots: '@lix-js/server-protocol-schema@0.1.1': {} - '@lucide/svelte@0.561.0(svelte@5.48.2)': + '@lucide/svelte@0.563.1(svelte@5.48.2)': dependencies: svelte: 5.48.2 @@ -4198,10 +4186,6 @@ snapshots: '@types/dom-webcodecs@0.1.13': {} - '@types/dompurify@3.2.0': - dependencies: - dompurify: 3.3.1 - '@types/estree@1.0.8': {} '@types/fluent-ffmpeg@2.1.28': @@ -5282,10 +5266,6 @@ snapshots: loupe@3.2.1: {} - lucide-svelte@0.563.0(svelte@5.48.2): - dependencies: - svelte: 5.48.2 - lz-string@1.5.0: {} magic-string@0.30.19: @@ -5625,7 +5605,7 @@ snapshots: esm-env: 1.2.2 svelte: 5.48.2 - runed@0.34.0(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2): + runed@0.35.1(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2): dependencies: dequal: 2.0.3 esm-env: 1.2.2 @@ -5634,7 +5614,7 @@ snapshots: optionalDependencies: '@sveltejs/kit': 2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)) - runed@0.35.1(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2): + runed@0.37.1(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(zod@4.3.6): dependencies: dequal: 2.0.3 esm-env: 1.2.2 @@ -5642,6 +5622,7 @@ snapshots: svelte: 5.48.2 optionalDependencies: '@sveltejs/kit': 2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.2)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.48.2)(typescript@5.9.3)(vite@7.1.9(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.1)) + zod: 4.3.6 sade@1.8.1: dependencies: diff --git a/src/lib/components/ai/ai-chat.svelte b/src/lib/components/ai/ai-chat.svelte index 1120837..2ca7aeb 100644 --- a/src/lib/components/ai/ai-chat.svelte +++ b/src/lib/components/ai/ai-chat.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/editor/canvas/canvas-controls.svelte b/src/lib/components/editor/canvas/canvas-controls.svelte index b8c5515..d708946 100644 --- a/src/lib/components/editor/canvas/canvas-controls.svelte +++ b/src/lib/components/editor/canvas/canvas-controls.svelte @@ -1,6 +1,6 @@ diff --git a/src/lib/layers/components/BrowserLayer.svelte b/src/lib/layers/components/BrowserLayer.svelte index 74fe985..875bebd 100644 --- a/src/lib/layers/components/BrowserLayer.svelte +++ b/src/lib/layers/components/BrowserLayer.svelte @@ -1,7 +1,7 @@ From 96a3dbb052bbdb83f39c2a7c65fb11e8e10048eb Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 4 Feb 2026 23:21:02 +0100 Subject: [PATCH 5/7] docker --- .env.example | 5 +++-- docker-compose.yml | 20 +++++++++----------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index f11f398..3d0722b 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,9 @@ PRIVATE_DATABASE_URL="postgres://root:mysecretpassword@localhost:5432/local" PRIVATE_BETTER_AUTH_SECRET=mysecretpassword # Google OAuth -GOOGLE_CLIENT_ID=your_google_client_id_here -GOOGLE_CLIENT_SECRET=your_google_client_secret_here +PRIVATE_GOOGLE_CLIENT_ID=your_google_client_id_here +PRIVATE_GOOGLE_CLIENT_SECRET=your_google_client_secret_here # AI Generation (OpenRouter) OPENROUTER_API_KEY=your_openrouter_api_key_here + diff --git a/docker-compose.yml b/docker-compose.yml index 5ec5392..cf4726d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,12 @@ services: - db: - image: postgres - restart: always + app: + build: . ports: - - 5432:5432 + - '3000:3000' environment: - POSTGRES_USER: root - POSTGRES_PASSWORD: mysecretpassword - POSTGRES_DB: local - volumes: - - pgdata:/var/lib/postgresql/data -volumes: - pgdata: + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL} + - PRIVATE_DATABASE_URL=${PRIVATE_DATABASE_URL} + - PRIVATE_BETTER_AUTH_SECRET=${PRIVATE_BETTER_AUTH_SECRET} + - PRIVATE_GOOGLE_CLIENT_ID=${PRIVATE_GOOGLE_CLIENT_ID} + - PRIVATE_GOOGLE_CLIENT_SECRET=${PRIVATE_GOOGLE_CLIENT_SECRET} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} From 5f82de592d53954153268f576c7865149b484385 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 4 Feb 2026 23:29:27 +0100 Subject: [PATCH 6/7] fixes --- Dockerfile | 3 --- .../components/editor/export-dialog.svelte | 16 ++++++++++++-- src/lib/server/video-renderer.ts | 21 +++++++++++++++---- src/routes/api/export/[id]/+server.ts | 9 ++++++-- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 033f206..c32ae70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,9 +36,6 @@ COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml # Install only production dependencies RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile -# Playwright images usually come with browsers, but let's ensure chromium is there -RUN npx playwright install chromium - # Environment variables ENV NODE_ENV=production ENV PORT=3000 diff --git a/src/lib/components/editor/export-dialog.svelte b/src/lib/components/editor/export-dialog.svelte index 722ddfc..bfb7a05 100644 --- a/src/lib/components/editor/export-dialog.svelte +++ b/src/lib/components/editor/export-dialog.svelte @@ -39,6 +39,7 @@ let exportProgress = $state(0); let errorMessage = $state(null); let videoCapture = new VideoCapture(); + let serverExportAbortController = $state(null); let exportSettings = $derived({ format: 'webm', @@ -103,6 +104,7 @@ }; try { + serverExportAbortController = new AbortController(); const response = await fetch(`/api/export/${projectId}?renderId=${renderId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -110,7 +112,8 @@ width: exportSettings.width, height: exportSettings.height, fps: exportSettings.fps - }) + }), + signal: serverExportAbortController.signal }); if (!response.ok) { @@ -133,6 +136,9 @@ onOpenChange(false); } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } console.error('Server export failed:', error); errorMessage = error instanceof Error ? error.message : 'Server export failed. Please try again.'; @@ -141,6 +147,7 @@ eventSource.close(); isExporting = false; exportProgress = 0; + serverExportAbortController = null; } } @@ -287,7 +294,12 @@ function handleCancel() { if (isExporting) { - videoCapture.stopCapture(); + if (exportMode === 'server') { + serverExportAbortController?.abort(); + } else { + videoCapture.stopCapture(); + } + projectStore.pause(); projectStore.setCurrentTime(0); diff --git a/src/lib/server/video-renderer.ts b/src/lib/server/video-renderer.ts index 6453a61..0f9bec3 100644 --- a/src/lib/server/video-renderer.ts +++ b/src/lib/server/video-renderer.ts @@ -48,6 +48,19 @@ export async function renderProjectToVideoStream(config: RenderConfig): Promise< let page = null; let ffmpegCommand: ffmpeg.FfmpegCommand | null = null; + const MAX_RENDER_DURATION_MS = 10 * 60 * 1000; // 10 minutes max + const renderTimeout = setTimeout(() => { + console.error('Render timeout exceeded'); + emitProgress({ + phase: 'error', + currentFrame: 0, + totalFrames, + percent: 0, + error: 'Render timeout exceeded' + }); + videoStream.destroy(new Error('Render timeout')); + }, MAX_RENDER_DURATION_MS); + try { emitProgress({ phase: 'initializing', currentFrame: 0, totalFrames, percent: 0 }); @@ -138,8 +151,9 @@ export async function renderProjectToVideoStream(config: RenderConfig): Promise< const time = frameIndex / actualFps; await page.evaluate((t) => window.__DEVMOTION__?.seek(t), time); - // Brief wait for any JS/canvas updates - await new Promise((resolve) => setTimeout(resolve, 32)); + // Wait ~2 frames at 60fps for JS/canvas updates to settle after seek + const FRAME_SETTLE_DELAY_MS = 32; + await new Promise((resolve) => setTimeout(resolve, FRAME_SETTLE_DELAY_MS)); const screenshot = await page.screenshot({ type: 'png', @@ -181,15 +195,14 @@ export async function renderProjectToVideoStream(config: RenderConfig): Promise< frameStream.end(); - await page.close(); await browser.close(); browser = null; - invalidateRenderToken(token); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); emitProgress({ phase: 'error', currentFrame: 0, totalFrames, percent: 0, error: msg }); videoStream.destroy(err as Error); } finally { + clearTimeout(renderTimeout); invalidateRenderToken(token); if (browser) await browser.close(); } diff --git a/src/routes/api/export/[id]/+server.ts b/src/routes/api/export/[id]/+server.ts index ea5324a..fadeda3 100644 --- a/src/routes/api/export/[id]/+server.ts +++ b/src/routes/api/export/[id]/+server.ts @@ -68,8 +68,13 @@ export const POST: RequestHandler = async ({ params, request, url, locals }) => // Return the stream as response const webStream = Readable.toWeb(videoStream) as ReadableStream; - // Sanitize filename to prevent header injection - const sanitizedName = (dbProject.name || 'video').replace(/[;="\r\n]/g, '_').substring(0, 100); + // Sanitize filename to prevent header injection and filesystem issues + const sanitizedName = + (dbProject.name || 'video') + .replace(/[;="\r\n\\/:<>|?*]/g, '_') + .replace(/_{2,}/g, '_') + .trim() + .substring(0, 100) || 'video'; return new Response(webStream, { headers: { From aa11b5fe4382797e6bb8af31f8cdfc8b0ad0827c Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 4 Feb 2026 23:30:50 +0100 Subject: [PATCH 7/7] fix --- src/routes/api/export/[id]/+server.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/routes/api/export/[id]/+server.ts b/src/routes/api/export/[id]/+server.ts index fadeda3..d542d3d 100644 --- a/src/routes/api/export/[id]/+server.ts +++ b/src/routes/api/export/[id]/+server.ts @@ -10,6 +10,12 @@ import type { ProjectData } from '$lib/schemas/animation'; import { Readable } from 'stream'; export const POST: RequestHandler = async ({ params, request, url, locals }) => { + // Check authorization + const isLogged = !!locals.user?.id; + if (!isLogged) { + error(403, 'Forbidden'); + } + const { id } = params; const renderId = url.searchParams.get('renderId'); @@ -26,12 +32,6 @@ export const POST: RequestHandler = async ({ params, request, url, locals }) => error(404, 'Project not found'); } - // Check authorization - const isLogged = !!locals.user?.id; - if (!isLogged) { - error(403, 'Forbidden'); - } - const projectData = dbProject.data as ProjectData; // Parse optional config from request body @@ -95,7 +95,13 @@ export const POST: RequestHandler = async ({ params, request, url, locals }) => /** * SSE endpoint for tracking progress */ -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, locals }) => { + // Check authorization + const isLogged = !!locals.user?.id; + if (!isLogged) { + error(403, 'Forbidden'); + } + const renderId = url.searchParams.get('renderId'); if (!renderId) {