Files
ViGent2/remotion/render.ts
Kevin Wong 48bc78fe38 更新
2026-03-02 16:35:16 +08:00

303 lines
10 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;
concurrency?: 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;
case 'concurrency':
options.concurrency = parseInt(value, 10);
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
// 修复: 使用 process.cwd() 解析 src/index.ts确保在 dist/render.js 和 ts-node 下都能找到
// 假设脚本总是在 remotion 根目录下运行 (由 python service 保证)
const entryPoint = path.resolve(process.cwd(), 'src/index.ts');
// Bundle 缓存逻辑:通过 src 目录 mtime hash 判断是否需要重新打包
const BUNDLE_CACHE_DIR = path.resolve(process.cwd(), '.remotion-bundle-cache');
const hashFile = path.join(BUNDLE_CACHE_DIR, '.hash');
function getSourceHash(): string {
// 收集 src 目录下所有文件的 mtime 作为缓存 key
const srcDir = path.resolve(process.cwd(), 'src');
const mtimes: string[] = [];
function walkDir(dir: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else {
mtimes.push(`${fullPath}:${fs.statSync(fullPath).mtimeMs}`);
}
}
}
walkDir(srcDir);
mtimes.sort();
return mtimes.join('|');
}
const currentHash = getSourceHash();
let bundleLocation: string;
// 辅助函数: 确保文件在缓存 public 目录中可访问 (硬链接 > 复制)
function ensureInCachedPublic(cachedPublicDir: string, srcAbsPath: string, fileName: string) {
const cachedPath = path.join(cachedPublicDir, fileName);
// 已存在且大小一致,跳过
try {
if (fs.existsSync(cachedPath)) {
const srcStat = fs.statSync(srcAbsPath);
const cachedStat = fs.statSync(cachedPath);
if (srcStat.size === cachedStat.size && srcStat.ino === cachedStat.ino) return;
}
} catch { /* file doesn't exist or broken, will recreate */ }
// 移除旧的文件/链接
try { fs.unlinkSync(cachedPath); } catch { /* doesn't exist, fine */ }
// 优先硬链接(零拷贝,对应用透明),跨文件系统时回退为复制
try {
fs.linkSync(srcAbsPath, cachedPath);
console.log(`Hardlinked into cached bundle: ${fileName}`);
} catch {
fs.copyFileSync(srcAbsPath, cachedPath);
console.log(`Copied into cached bundle: ${fileName}`);
}
}
if (fs.existsSync(hashFile) && fs.readFileSync(hashFile, 'utf-8') === currentHash) {
bundleLocation = BUNDLE_CACHE_DIR;
console.log('Using cached bundle');
// 确保当前渲染所需的文件在缓存 bundle 的 public 目录中可访问
const cachedPublicDir = path.join(BUNDLE_CACHE_DIR, 'public');
if (!fs.existsSync(cachedPublicDir)) {
fs.mkdirSync(cachedPublicDir, { recursive: true });
}
// 1) 视频文件
ensureInCachedPublic(cachedPublicDir, path.resolve(options.videoPath), videoFileName);
// 2) 字体文件 (从 subtitleStyle / titleStyle / secondaryTitleStyle 中提取)
const styleSources = [options.subtitleStyle, options.titleStyle, options.secondaryTitleStyle];
for (const style of styleSources) {
const fontFile = (style as Record<string, unknown>)?.font_file as string | undefined;
if (fontFile) {
const fontSrcPath = path.join(publicDir, fontFile);
if (fs.existsSync(fontSrcPath)) {
ensureInCachedPublic(cachedPublicDir, path.resolve(fontSrcPath), fontFile);
}
}
}
} else {
console.log('Bundling Remotion project...');
console.log(`Entry point: ${entryPoint}`);
const freshBundle = await bundle({
entryPoint,
webpackOverride: (config) => config,
publicDir,
});
// 复制到缓存目录
if (fs.existsSync(BUNDLE_CACHE_DIR)) {
fs.rmSync(BUNDLE_CACHE_DIR, { recursive: true });
}
fs.cpSync(freshBundle, BUNDLE_CACHE_DIR, { recursive: true });
fs.writeFileSync(hashFile, currentHash);
bundleLocation = BUNDLE_CACHE_DIR;
console.log('Bundle cached for future use');
}
// 统一 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 || 4;
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);
});