This commit is contained in:
Kevin Wong
2026-01-29 17:54:43 +08:00
parent 661a8f357c
commit b74bacb0b5
18 changed files with 3923 additions and 7 deletions

2907
remotion/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
remotion/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "vigent-remotion",
"version": "1.0.0",
"description": "Remotion video composition for ViGent2 subtitles and titles",
"scripts": {
"start": "remotion studio",
"build": "remotion bundle",
"render": "npx ts-node render.ts"
},
"dependencies": {
"remotion": "^4.0.0",
"@remotion/renderer": "^4.0.0",
"@remotion/cli": "^4.0.0",
"@remotion/media-utils": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"typescript": "^5.0.0",
"ts-node": "^10.9.0"
}
}

153
remotion/render.ts Normal file
View File

@@ -0,0 +1,153 @@
/**
* 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;
outputPath: string;
fps?: number;
enableSubtitles?: boolean;
}
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;
}
}
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 秒
try {
// 使用 ffprobe 获取视频时长
const { execSync } = require('child_process');
const ffprobeOutput = execSync(
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${options.videoPath}"`,
{ encoding: 'utf-8' }
);
const durationInSeconds = parseFloat(ffprobeOutput.trim());
durationInFrames = Math.ceil(durationInSeconds * fps);
console.log(`Video duration: ${durationInSeconds}s (${durationInFrames} frames at ${fps}fps)`);
} 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,
enableSubtitles: options.enableSubtitles !== false,
},
});
// Override duration
composition.durationInFrames = durationInFrames;
composition.fps = fps;
// 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,
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);
});

30
remotion/src/Root.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Composition } from 'remotion';
import { Video, VideoProps } from './Video';
/**
* Remotion 根组件
* 定义视频合成配置
*/
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="ViGentVideo"
component={Video}
durationInFrames={300} // 默认值,会被 render.ts 覆盖
fps={25}
width={1280}
height={720}
defaultProps={{
videoSrc: '',
audioSrc: undefined,
captions: undefined,
title: undefined,
titleDuration: 3,
enableSubtitles: true,
}}
/>
</>
);
};

45
remotion/src/Video.tsx Normal file
View File

@@ -0,0 +1,45 @@
import React from 'react';
import { AbsoluteFill, Composition } from 'remotion';
import { VideoLayer } from './components/VideoLayer';
import { Title } from './components/Title';
import { Subtitles } from './components/Subtitles';
import { CaptionsData } from './utils/captions';
export interface VideoProps {
videoSrc: string;
audioSrc?: string;
captions?: CaptionsData;
title?: string;
titleDuration?: number;
enableSubtitles?: boolean;
}
/**
* 主视频组件
* 组合视频层、标题层和字幕层
*/
export const Video: React.FC<VideoProps> = ({
videoSrc,
audioSrc,
captions,
title,
titleDuration = 3,
enableSubtitles = true,
}) => {
return (
<AbsoluteFill style={{ backgroundColor: 'black' }}>
{/* 底层:视频 */}
<VideoLayer videoSrc={videoSrc} audioSrc={audioSrc} />
{/* 中层:字幕 */}
{enableSubtitles && captions && (
<Subtitles captions={captions} />
)}
{/* 顶层:标题 */}
{title && (
<Title title={title} duration={titleDuration} />
)}
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion';
import {
CaptionsData,
getCurrentSegment,
getCurrentWordIndex,
} from '../utils/captions';
interface SubtitlesProps {
captions: CaptionsData;
highlightColor?: string;
normalColor?: string;
fontSize?: number;
}
/**
* 逐字高亮字幕组件
* 根据时间戳逐字高亮显示字幕
*/
export const Subtitles: React.FC<SubtitlesProps> = ({
captions,
highlightColor = '#FFFFFF',
normalColor = 'rgba(255, 255, 255, 0.5)',
fontSize = 36,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTimeInSeconds = frame / fps;
// 获取当前段落
const currentSegment = getCurrentSegment(captions, currentTimeInSeconds);
if (!currentSegment || currentSegment.words.length === 0) {
return null;
}
// 获取当前高亮字的索引
const currentWordIndex = getCurrentWordIndex(currentSegment, currentTimeInSeconds);
return (
<AbsoluteFill
style={{
justifyContent: 'flex-end',
alignItems: 'center',
paddingBottom: '60px',
}}
>
<div
style={{
background: 'rgba(0, 0, 0, 0.6)',
padding: '12px 24px',
borderRadius: '12px',
maxWidth: '80%',
textAlign: 'center',
}}
>
<p
style={{
margin: 0,
fontSize: `${fontSize}px`,
fontFamily: '"Noto Sans SC", "Microsoft YaHei", sans-serif',
fontWeight: 500,
lineHeight: 1.5,
}}
>
{currentSegment.words.map((word, index) => (
<span
key={`${word.word}-${index}`}
style={{
color: index <= currentWordIndex ? highlightColor : normalColor,
transition: 'color 0.1s ease',
textShadow: index <= currentWordIndex
? '0 2px 10px rgba(255,255,255,0.3)'
: 'none',
}}
>
{word.word}
</span>
))}
</p>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,94 @@
import React from 'react';
import {
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
interface TitleProps {
title: string;
duration?: number; // 标题显示时长(秒)
fadeOutStart?: number; // 开始淡出的时间(秒)
}
/**
* 片头标题组件
* 在视频开头显示标题,带淡入淡出效果
*/
export const Title: React.FC<TitleProps> = ({
title,
duration = 3,
fadeOutStart = 2,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTimeInSeconds = frame / fps;
// 如果超过显示时长,不渲染
if (currentTimeInSeconds > duration) {
return null;
}
// 淡入效果 (0-0.5秒)
const fadeInOpacity = interpolate(
currentTimeInSeconds,
[0, 0.5],
[0, 1],
{ extrapolateRight: 'clamp' }
);
// 淡出效果
const fadeOutOpacity = interpolate(
currentTimeInSeconds,
[fadeOutStart, duration],
[1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
const opacity = Math.min(fadeInOpacity, fadeOutOpacity);
// 轻微的缩放动画
const scale = interpolate(
currentTimeInSeconds,
[0, 0.5],
[0.95, 1],
{ extrapolateRight: 'clamp' }
);
return (
<AbsoluteFill
style={{
justifyContent: 'center',
alignItems: 'center',
opacity,
}}
>
<div
style={{
transform: `scale(${scale})`,
textAlign: 'center',
padding: '40px 60px',
background: 'linear-gradient(135deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.5) 100%)',
borderRadius: '20px',
backdropFilter: 'blur(10px)',
}}
>
<h1
style={{
color: 'white',
fontSize: '48px',
fontWeight: 'bold',
fontFamily: '"Noto Sans SC", "Microsoft YaHei", sans-serif',
textShadow: '0 4px 20px rgba(0,0,0,0.5)',
margin: 0,
lineHeight: 1.4,
}}
>
{title}
</h1>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { AbsoluteFill, OffthreadVideo, Audio, staticFile } from 'remotion';
interface VideoLayerProps {
videoSrc: string;
audioSrc?: string;
}
/**
* 视频图层组件
* 渲染底层视频和音频
*/
export const VideoLayer: React.FC<VideoLayerProps> = ({
videoSrc,
audioSrc,
}) => {
// 使用 staticFile 从 publicDir 加载视频
const videoUrl = staticFile(videoSrc);
return (
<AbsoluteFill>
<OffthreadVideo
src={videoUrl}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
/>
{audioSrc && <Audio src={staticFile(audioSrc)} />}
</AbsoluteFill>
);
};

4
remotion/src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);

View File

@@ -0,0 +1,66 @@
/**
* 字幕数据类型定义和处理工具
*/
export interface WordTimestamp {
word: string;
start: number;
end: number;
}
export interface Segment {
text: string;
start: number;
end: number;
words: WordTimestamp[];
}
export interface CaptionsData {
segments: Segment[];
}
/**
* 根据当前时间获取应该显示的字幕段落
*/
export function getCurrentSegment(
captions: CaptionsData,
currentTimeInSeconds: number
): Segment | null {
for (const segment of captions.segments) {
if (currentTimeInSeconds >= segment.start && currentTimeInSeconds <= segment.end) {
return segment;
}
}
return null;
}
/**
* 根据当前时间获取当前高亮的字的索引
*/
export function getCurrentWordIndex(
segment: Segment,
currentTimeInSeconds: number
): number {
for (let i = 0; i < segment.words.length; i++) {
const word = segment.words[i];
if (currentTimeInSeconds >= word.start && currentTimeInSeconds <= word.end) {
return i;
}
// 如果当前时间在两个字之间,返回前一个字
if (i < segment.words.length - 1) {
const nextWord = segment.words[i + 1];
if (currentTimeInSeconds > word.end && currentTimeInSeconds < nextWord.start) {
return i;
}
}
}
// 如果超过最后一个字的结束时间,返回最后一个字
if (segment.words.length > 0) {
const lastWord = segment.words[segment.words.length - 1];
if (currentTimeInSeconds >= lastWord.end) {
return segment.words.length - 1;
}
}
return -1;
}

19
remotion/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["src/**/*", "render.ts"],
"exclude": ["node_modules", "dist"]
}