更新
This commit is contained in:
2907
remotion/package-lock.json
generated
Normal file
2907
remotion/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
remotion/package.json
Normal file
24
remotion/package.json
Normal 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
153
remotion/render.ts
Normal 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
30
remotion/src/Root.tsx
Normal 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
45
remotion/src/Video.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
85
remotion/src/components/Subtitles.tsx
Normal file
85
remotion/src/components/Subtitles.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
94
remotion/src/components/Title.tsx
Normal file
94
remotion/src/components/Title.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
remotion/src/components/VideoLayer.tsx
Normal file
33
remotion/src/components/VideoLayer.tsx
Normal 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
4
remotion/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
import { RemotionRoot } from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
66
remotion/src/utils/captions.ts
Normal file
66
remotion/src/utils/captions.ts
Normal 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
19
remotion/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user