/** * Remotion 服务端渲染脚本 * 用于从命令行渲染视频 * * 使用方式: * npx ts-node render.ts --video /path/to/video.mp4 --captions /path/to/captions.json --title "视频标题" --output /path/to/output.mp4 */ import { bundle } from '@remotion/bundler'; import { renderMedia, selectComposition } from '@remotion/renderer'; import path from 'path'; import fs from 'fs'; interface RenderOptions { videoPath: string; captionsPath?: string; title?: string; titleDuration?: number; titleDisplayMode?: 'short' | 'persistent'; subtitleStyle?: Record; titleStyle?: Record; secondaryTitle?: string; secondaryTitleStyle?: Record; outputPath: string; fps?: number; enableSubtitles?: boolean; width?: number; height?: number; concurrency?: number; } async function parseArgs(): Promise { const args = process.argv.slice(2); const options: Partial = {}; for (let i = 0; i < args.length; i += 2) { const key = args[i].replace('--', ''); const value = args[i + 1]; switch (key) { case 'video': options.videoPath = value; break; case 'captions': options.captionsPath = value; break; case 'title': options.title = value; break; case 'titleDuration': options.titleDuration = parseFloat(value); break; case 'titleDisplayMode': if (value === 'short' || value === 'persistent') { options.titleDisplayMode = value; } break; case 'output': options.outputPath = value; break; case 'fps': options.fps = parseInt(value, 10); break; case 'enableSubtitles': options.enableSubtitles = value === 'true'; break; case 'subtitleStyle': try { options.subtitleStyle = JSON.parse(value); } catch (e) { console.warn('Invalid subtitleStyle JSON'); } break; case 'titleStyle': try { options.titleStyle = JSON.parse(value); } catch (e) { console.warn('Invalid titleStyle JSON'); } break; case 'secondaryTitle': options.secondaryTitle = value; break; case 'secondaryTitleStyle': try { options.secondaryTitleStyle = JSON.parse(value); } catch (e) { console.warn('Invalid secondaryTitleStyle JSON'); } break; case 'concurrency': options.concurrency = parseInt(value, 10); break; } } if (!options.videoPath || !options.outputPath) { console.error('Usage: npx ts-node render.ts --video --output [--captions ] [--title ] [--fps ]'); process.exit(1); } return options as RenderOptions; } async function main() { const options = await parseArgs(); const fps = options.fps || 25; console.log('Starting Remotion render...'); console.log('Options:', JSON.stringify(options, null, 2)); // 读取字幕数据 let captions = undefined; if (options.captionsPath && fs.existsSync(options.captionsPath)) { const captionsContent = fs.readFileSync(options.captionsPath, 'utf-8'); captions = JSON.parse(captionsContent); console.log(`Loaded captions with ${captions.segments?.length || 0} segments`); } // 获取视频时长和尺寸 let durationInFrames = 300; // 默认 12 秒 let videoWidth = 1280; let videoHeight = 720; try { // 使用 promisified exec 异步获取视频信息,避免阻塞主线程 const { promisify } = require('util'); const { exec } = require('child_process'); const execAsync = promisify(exec); // 获取视频时长 const { stdout: durationOutput } = await execAsync( `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${options.videoPath}"` ); const durationInSeconds = parseFloat(durationOutput.trim()); durationInFrames = Math.ceil(durationInSeconds * fps); console.log(`Video duration: ${durationInSeconds}s (${durationInFrames} frames at ${fps}fps)`); // 获取视频尺寸 const { stdout: dimensionsOutput } = await execAsync( `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${options.videoPath}"` ); const [width, height] = dimensionsOutput.trim().split('x').map(Number); if (width && height) { videoWidth = width; videoHeight = height; console.log(`Video dimensions: ${videoWidth}x${videoHeight}`); } } catch (e) { console.warn('Could not get video duration, using default:', e); } // 设置 publicDir 为视频文件所在目录,使用文件名作为 videoSrc const publicDir = path.dirname(path.resolve(options.videoPath)); const videoFileName = path.basename(options.videoPath); console.log(`Public dir: ${publicDir}, Video file: ${videoFileName}`); // Bundle the Remotion project console.log('Bundling Remotion project...'); // 修复: 使用 process.cwd() 解析 src/index.ts,确保在 dist/render.js 和 ts-node 下都能找到 // 假设脚本总是在 remotion 根目录下运行 (由 python service 保证) const entryPoint = path.resolve(process.cwd(), 'src/index.ts'); console.log(`Entry point: ${entryPoint}`); const bundleLocation = await bundle({ entryPoint, webpackOverride: (config) => config, publicDir, }); // 统一 inputProps,包含视频尺寸供 calculateMetadata 使用 const inputProps = { videoSrc: videoFileName, captions, title: options.title, titleDuration: options.titleDuration || 4, titleDisplayMode: options.titleDisplayMode || 'short', subtitleStyle: options.subtitleStyle, titleStyle: options.titleStyle, secondaryTitle: options.secondaryTitle, secondaryTitleStyle: options.secondaryTitleStyle, enableSubtitles: options.enableSubtitles !== false, width: videoWidth, height: videoHeight, }; // Select the composition const composition = await selectComposition({ serveUrl: bundleLocation, id: 'ViGentVideo', inputProps, }); // Override duration and dimensions (保险:确保与 ffprobe 检测值一致) composition.durationInFrames = durationInFrames; composition.fps = fps; composition.width = videoWidth; composition.height = videoHeight; // Render the video const concurrency = options.concurrency || 16; console.log(`Rendering video (concurrency=${concurrency})...`); await renderMedia({ composition, serveUrl: bundleLocation, codec: 'h264', outputLocation: options.outputPath, inputProps, concurrency, onProgress: ({ progress }) => { const percent = Math.round(progress * 100); process.stdout.write(`\rRendering: ${percent}%`); }, }); console.log('\nRender complete!'); console.log(`Output: ${options.outputPath}`); } main().catch((err) => { console.error('Render failed:', err); process.exit(1); });