From 1c7c75279b9a3c2823ae310e2e523754a9287587 Mon Sep 17 00:00:00 2001 From: jsongo Date: Tue, 10 Feb 2026 20:05:05 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20configurable=20ti?= =?UTF-8?q?meout=20and=20fix=20render=20hanging=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --timeout CLI option and OPEN_MOTION_RENDER_TIMEOUT env var - Set default timeout to 300,000ms (5 minutes) - Propagate timeout to Playwright's goto, waitForLoadState, and waitForFunction - Use page.setDefaultTimeout and page.setDefaultNavigationTimeout in renderer - Fix render hanging by only waiting for networkidle on first frame - Fix init template by explicitly setting __OPEN_MOTION_READY__ in rendering mode - Auto-detect pnpm for init template - Bump version to 0.1.9 Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/package.json | 2 +- packages/cli/src/index.ts | 43 ++++++++++++++++++++++++++-------- packages/core/package.json | 2 +- packages/encoder/package.json | 2 +- packages/renderer/package.json | 2 +- packages/renderer/src/index.ts | 27 +++++++++++++++++---- 6 files changed, 59 insertions(+), 19 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 7a86faa..1bdbe0b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@open-motion/cli", - "version": "0.1.6", + "version": "0.1.9", "bin": { "open-motion": "dist/bin.js" }, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 28f0c3a..a95878e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,16 @@ import path from 'path'; import fs from 'fs'; import { Command } from 'commander'; import cliProgress from 'cli-progress'; +import { execSync } from 'child_process'; + +const getPackageManager = () => { + try { + execSync('pnpm -v', { stdio: 'ignore' }); + return 'pnpm'; + } catch (e) { + return 'npm'; + } +}; export const runInit = async (projectName: string) => { const targetDir = path.join(process.cwd(), projectName); @@ -13,7 +23,8 @@ export const runInit = async (projectName: string) => { process.exit(1); } - console.log(`Initializing OpenMotion project: ${projectName}...`); + const pm = getPackageManager(); + console.log(`Initializing OpenMotion project: ${projectName} using ${pm}...`); // Basic template structure const dirs = ['', 'src']; @@ -32,7 +43,7 @@ export const runInit = async (projectName: string) => { dev: 'vite', build: 'vite build', preview: 'vite preview', - render: 'npm run build && (npx http-server dist -p 5173 -a 127.0.0.1 > /dev/null 2>&1 & sleep 2 && open-motion render -u http://127.0.0.1:5173 --composition main -o ./out.mp4 --concurrency 4 && pkill -f http-server)' + render: `${pm} run build && (npx http-server dist -p 5173 -a 127.0.0.1 > /dev/null 2>&1 & sleep 2 && open-motion render -u http://127.0.0.1:5173 --composition main -o ./out.mp4 --concurrency 4 && pkill -f http-server)` }, dependencies: { 'react': '^18.2.0', @@ -80,6 +91,12 @@ const Root = () => { const config = { width: 1920, height: 1080, fps: 30, durationInFrames: 120 }; const isRendering = typeof (window as any).__OPEN_MOTION_FRAME__ === 'number'; + React.useEffect(() => { + if (isRendering) { + (window as any).__OPEN_MOTION_READY__ = true; + } + }, [isRendering]); + if (isRendering) { return ( @@ -137,11 +154,11 @@ export const App = () => { fs.writeFileSync(path.join(targetDir, name), content); } - console.log(`Success! Project \${projectName} initialized.`); + console.log(`Success! Project ${projectName} initialized.`); console.log(`Next steps:`); - console.log(` cd \${projectName}`); - console.log(` npm install (or pnpm install)`); - console.log(` npm run dev`); + console.log(` cd ${projectName}`); + console.log(` ${pm} install`); + console.log(` ${pm} run dev`); }; export const runRender = async (options: { @@ -157,7 +174,10 @@ export const runRender = async (options: { publicDir?: string; format?: 'mp4' | 'gif' | 'webp' | 'webm' | 'auto'; chromiumPath?: string; + timeout?: number; }) => { + const timeout = options.timeout || parseInt(process.env.OPEN_MOTION_RENDER_TIMEOUT || '300000', 10); + if (options.chromiumPath) { process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = options.chromiumPath; } @@ -187,7 +207,7 @@ export const runRender = async (options: { if (options.compositionId) { // If ID is provided, we can skip the heavy discovery if we want, // but for now let's just make it non-fatal if discovery fails but ID is present - const compositions = await getCompositions(options.url, { inputProps }).catch(() => []); + const compositions = await getCompositions(options.url, { inputProps, timeout }).catch(() => []); selectedComp = compositions.find((c: any) => c.id === options.compositionId); if (!selectedComp) { @@ -201,7 +221,7 @@ export const runRender = async (options: { }; } } else { - const compositions = await getCompositions(options.url, { inputProps }); + const compositions = await getCompositions(options.url, { inputProps, timeout }); if (compositions.length === 0) { console.error('No compositions found in the provided URL.'); process.exit(1); @@ -234,7 +254,8 @@ export const runRender = async (options: { inputProps, concurrency: options.concurrency || 1, publicDir: options.publicDir ? path.join(process.cwd(), options.publicDir) : undefined, - onProgress: (frame) => renderBar.update(frame) + onProgress: (frame) => renderBar.update(frame), + timeout }); renderBar.update(config.durationInFrames); @@ -372,6 +393,7 @@ export const main = () => { .option('--public-dir ', 'Public directory path for static assets (default: "./public")') .option('--format ', 'Output format (mp4, webm, gif, webp, auto)', 'auto') .option('--chromium-path ', 'Custom path to Chromium executable') + .option('--timeout ', 'Timeout for browser operations in milliseconds', parseInt) .action(async (options) => { try { await runRender({ @@ -386,7 +408,8 @@ export const main = () => { duration: options.duration, publicDir: options.publicDir, format: options.format, - chromiumPath: options.chromiumPath + chromiumPath: options.chromiumPath, + timeout: options.timeout }); } catch (err) { console.error('Render failed:', err); diff --git a/packages/core/package.json b/packages/core/package.json index 215fe3f..067deb7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@open-motion/core", - "version": "0.1.6", + "version": "0.1.9", "main": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", diff --git a/packages/encoder/package.json b/packages/encoder/package.json index 74c0686..a558f69 100644 --- a/packages/encoder/package.json +++ b/packages/encoder/package.json @@ -1,6 +1,6 @@ { "name": "@open-motion/encoder", - "version": "0.1.6", + "version": "0.1.9", "main": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", diff --git a/packages/renderer/package.json b/packages/renderer/package.json index f88d8a6..010336b 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -1,6 +1,6 @@ { "name": "@open-motion/renderer", - "version": "0.1.6", + "version": "0.1.9", "main": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index ffce6f4..5fbec6e 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -72,25 +72,33 @@ export interface RenderOptions { concurrency?: number; onProgress?: (frame: number) => void; publicDir?: string; + timeout?: number; } export interface GetCompositionsOptions { inputProps?: any; chromiumOptions?: any; + timeout?: number; } export const getCompositions = async (url: string, options: GetCompositionsOptions = {}) => { - const { inputProps = {} } = options; + const { inputProps = {}, timeout = 30000 } = options; const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined, }); const page = await browser.newPage(); + + if (timeout) { + page.setDefaultTimeout(timeout); + page.setDefaultNavigationTimeout(timeout); + } + await page.goto(url); await page.waitForLoadState('networkidle'); // Wait for React to mount and all compositions to register // We wait for the variable to exist AND for a small stabilization period - await page.waitForFunction(() => (window as any).__OPEN_MOTION_COMPOSITIONS__ !== undefined, { timeout: 10000 }).catch(() => {}); + await page.waitForFunction(() => (window as any).__OPEN_MOTION_COMPOSITIONS__ !== undefined, { timeout }).catch(() => {}); await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 500))); const compositions = await page.evaluate(() => { @@ -116,7 +124,7 @@ export const getCompositions = async (url: string, options: GetCompositionsOptio return processedCompositions; }; -export const renderFrames = async ({ url, config, outputDir, compositionId, inputProps = {}, concurrency = 1, publicDir, onProgress }: RenderOptions) => { +export const renderFrames = async ({ url, config, outputDir, compositionId, inputProps = {}, concurrency = 1, publicDir, onProgress, timeout = 300000 }: RenderOptions) => { if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } @@ -133,6 +141,11 @@ export const renderFrames = async ({ url, config, outputDir, compositionId, inpu viewport: { width: config.width, height: config.height } }); + if (timeout) { + page.setDefaultTimeout(timeout); + page.setDefaultNavigationTimeout(timeout); + } + const workerAudioAssets: any[] = []; const videoCache = new Map(); // Path to local resolved path @@ -184,8 +197,12 @@ export const renderFrames = async ({ url, config, outputDir, compositionId, inpu const ready = (window as any).__OPEN_MOTION_READY__ === true; const delayCount = (window as any).__OPEN_MOTION_DELAY_RENDER_COUNT__ || 0; return ready && delayCount === 0; - }, { timeout: 120000 }); // Increased from 60s to 120s for complex scenes - await page.waitForLoadState('networkidle'); + }, { timeout }); + + // Only wait for networkidle on the first frame to avoid hanging on persistent requests + if (i === startFrame) { + await page.waitForLoadState('networkidle'); + } // Check for OffthreadVideo assets const videoAssets = await page.evaluate(() => (window as any).__OPEN_MOTION_VIDEO_ASSETS__ || []); From 07cbbb5a57c20de7b5357dc4b3a846da671ccf1f Mon Sep 17 00:00:00 2001 From: jsongo Date: Tue, 17 Feb 2026 13:01:52 +0800 Subject: [PATCH 2/4] chore: bump cli version to 0.2.0 Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 1bdbe0b..c2a5fff 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@open-motion/cli", - "version": "0.1.9", + "version": "0.2.0", "bin": { "open-motion": "dist/bin.js" }, From f4a663201f958fdc76d6b4fa4d5b4d8513f2ab15 Mon Sep 17 00:00:00 2001 From: jsongo Date: Tue, 17 Feb 2026 13:06:49 +0800 Subject: [PATCH 3/4] fix: resolve syntax error in init template render script Remove parentheses and use ; instead of && before pkill to ensure the server is killed even if the render command fails. Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a95878e..66fb8ee 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -43,7 +43,7 @@ export const runInit = async (projectName: string) => { dev: 'vite', build: 'vite build', preview: 'vite preview', - render: `${pm} run build && (npx http-server dist -p 5173 -a 127.0.0.1 > /dev/null 2>&1 & sleep 2 && open-motion render -u http://127.0.0.1:5173 --composition main -o ./out.mp4 --concurrency 4 && pkill -f http-server)` + render: `${pm} run build && npx http-server dist -p 5173 -a 127.0.0.1 > /dev/null 2>&1 & sleep 2 && open-motion render -u http://127.0.0.1:5173 --composition main -o ./out.mp4 --concurrency 4; pkill -f http-server` }, dependencies: { 'react': '^18.2.0', From 5a12c8451a69f60e1b10386a1d7a67f780d0c9f6 Mon Sep 17 00:00:00 2001 From: jsongo Date: Tue, 17 Feb 2026 13:07:54 +0800 Subject: [PATCH 4/4] feat: add quick start and examples to cli help This adds global quick start instructions and specific examples for the render command to the CLI help output using commander's addHelpText method. Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/index.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 66fb8ee..b6c966b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -364,7 +364,19 @@ export const main = () => { program .name('open-motion') .description('CLI for OpenMotion') - .version(pkg.version); + .version(pkg.version) + .addHelpText('after', ` +Quick Start: + 1. Initialize project: $ open-motion init my-video + 2. Enter directory: $ cd my-video + 3. Install deps: $ pnpm install + 4. Start dev server: $ pnpm dev + 5. Render video: $ pnpm render + +Example Usage: + $ open-motion init my-project + $ open-motion render -u http://localhost:5173 -o output.mp4 --composition main +`); program .command('init ') @@ -378,7 +390,7 @@ export const main = () => { } }); - program + const renderCommand = program .command('render') .description('Render a video') .requiredOption('-u, --url ', 'URL of the OpenMotion app') @@ -417,5 +429,12 @@ export const main = () => { } }); + renderCommand.addHelpText('after', ` +Examples: + $ open-motion render -u http://localhost:5173 -o out.mp4 + $ open-motion render -u http://localhost:5173 -o out.mp4 --composition main --concurrency 4 + $ open-motion render -u http://localhost:3000 -o banner.gif --format gif --width 1200 --height 630 +`); + program.parse(process.argv); };