Files
ViGent2/remotion/render.ts
Kevin Wong 0a5a17402c 更新
2026-02-24 16:55:29 +08:00

218 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>;
titleStyle?: Record<string, unknown>;
secondaryTitle?: string;
secondaryTitleStyle?: Record<string, unknown>;
outputPath: string;
fps?: number;
enableSubtitles?: boolean;
width?: number;
height?: number;
}
async function parseArgs(): Promise<RenderOptions> {
const args = process.argv.slice(2);
const options: Partial<RenderOptions> = {};
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;
}
}
if (!options.videoPath || !options.outputPath) {
console.error('Usage: npx ts-node render.ts --video <path> --output <path> [--captions <path>] [--title <text>] [--fps <number>]');
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
console.log('Rendering video...');
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: 'h264',
outputLocation: options.outputPath,
inputProps,
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);
});