194 lines
5.8 KiB
TypeScript
194 lines
5.8 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...');
|
|
const bundleLocation = await bundle({
|
|
entryPoint: path.resolve(__dirname, './src/index.ts'),
|
|
webpackOverride: (config) => config,
|
|
publicDir,
|
|
});
|
|
|
|
// Select the composition
|
|
const composition = await selectComposition({
|
|
serveUrl: bundleLocation,
|
|
id: 'ViGentVideo',
|
|
inputProps: {
|
|
videoSrc: videoFileName,
|
|
captions,
|
|
title: options.title,
|
|
titleDuration: options.titleDuration || 3,
|
|
subtitleStyle: options.subtitleStyle,
|
|
titleStyle: options.titleStyle,
|
|
enableSubtitles: options.enableSubtitles !== false,
|
|
},
|
|
});
|
|
|
|
// Override duration and dimensions
|
|
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: {
|
|
videoSrc: videoFileName,
|
|
captions,
|
|
title: options.title,
|
|
titleDuration: options.titleDuration || 3,
|
|
subtitleStyle: options.subtitleStyle,
|
|
titleStyle: options.titleStyle,
|
|
enableSubtitles: options.enableSubtitles !== false,
|
|
},
|
|
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);
|
|
});
|