197 lines
6.0 KiB
TypeScript
197 lines
6.0 KiB
TypeScript
/**
|
||
* 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;
|
||
subtitleStyle?: Record<string, unknown>;
|
||
titleStyle?: 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 '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;
|
||
}
|
||
}
|
||
|
||
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 || 3,
|
||
subtitleStyle: options.subtitleStyle,
|
||
titleStyle: options.titleStyle,
|
||
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);
|
||
});
|