This commit is contained in:
Kevin Wong
2026-02-07 14:29:57 +08:00
parent 945262a7fc
commit 1e52346eb4
29 changed files with 955 additions and 590 deletions

View File

@@ -5,7 +5,9 @@
"scripts": {
"start": "remotion studio",
"build": "remotion bundle",
"render": "npx ts-node render.ts"
"build:render": "npx tsc render.ts --outDir dist --esModuleInterop --skipLibCheck",
"render": "npx ts-node render.ts",
"render:fast": "node dist/render.js"
},
"dependencies": {
"remotion": "^4.0.0",

View File

@@ -16,6 +16,8 @@ interface RenderOptions {
captionsPath?: string;
title?: string;
titleDuration?: number;
subtitleStyle?: Record<string, unknown>;
titleStyle?: Record<string, unknown>;
outputPath: string;
fps?: number;
enableSubtitles?: boolean;
@@ -53,6 +55,20 @@ async function parseArgs(): Promise<RenderOptions> {
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;
}
}
@@ -84,20 +100,22 @@ async function main() {
let videoWidth = 1280;
let videoHeight = 720;
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' }
// 使用 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(ffprobeOutput.trim());
const durationInSeconds = parseFloat(durationOutput.trim());
durationInFrames = Math.ceil(durationInSeconds * fps);
console.log(`Video duration: ${durationInSeconds}s (${durationInFrames} frames at ${fps}fps)`);
// 使用 ffprobe 获取视频尺寸
const dimensionsOutput = execSync(
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "${options.videoPath}"`,
{ encoding: 'utf-8' }
// 获取视频尺寸
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) {
@@ -131,6 +149,8 @@ async function main() {
captions,
title: options.title,
titleDuration: options.titleDuration || 3,
subtitleStyle: options.subtitleStyle,
titleStyle: options.titleStyle,
enableSubtitles: options.enableSubtitles !== false,
},
});
@@ -153,6 +173,8 @@ async function main() {
captions,
title: options.title,
titleDuration: options.titleDuration || 3,
subtitleStyle: options.subtitleStyle,
titleStyle: options.titleStyle,
enableSubtitles: options.enableSubtitles !== false,
},
onProgress: ({ progress }) => {

View File

@@ -1,8 +1,8 @@
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 { Title, TitleStyle } from './components/Title';
import { Subtitles, SubtitleStyle } from './components/Subtitles';
import { CaptionsData } from './utils/captions';
export interface VideoProps {
@@ -12,6 +12,8 @@ export interface VideoProps {
title?: string;
titleDuration?: number;
enableSubtitles?: boolean;
subtitleStyle?: SubtitleStyle;
titleStyle?: TitleStyle;
}
/**
@@ -25,6 +27,8 @@ export const Video: React.FC<VideoProps> = ({
title,
titleDuration = 3,
enableSubtitles = true,
subtitleStyle,
titleStyle,
}) => {
return (
<AbsoluteFill style={{ backgroundColor: 'black' }}>
@@ -33,12 +37,12 @@ export const Video: React.FC<VideoProps> = ({
{/* 中层:字幕 */}
{enableSubtitles && captions && (
<Subtitles captions={captions} />
<Subtitles captions={captions} style={subtitleStyle} />
)}
{/* 顶层:标题 */}
{title && (
<Title title={title} duration={titleDuration} />
<Title title={title} duration={titleDuration} style={titleStyle} />
)}
</AbsoluteFill>
);

View File

@@ -1,28 +1,59 @@
import React from 'react';
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion';
import { AbsoluteFill, useCurrentFrame, useVideoConfig, staticFile } from 'remotion';
import {
CaptionsData,
getCurrentSegment,
getCurrentWordIndex,
} from '../utils/captions';
export interface SubtitleStyle {
font_file?: string;
fontFamily?: string;
font_family?: string;
fontSize?: number;
font_size?: number;
highlightColor?: string;
highlight_color?: string;
normalColor?: string;
normal_color?: string;
strokeColor?: string;
stroke_color?: string;
strokeSize?: number;
stroke_size?: number;
letterSpacing?: number;
letter_spacing?: number;
bottomMargin?: number;
bottom_margin?: number;
}
interface SubtitlesProps {
captions: CaptionsData;
highlightColor?: string;
normalColor?: string;
fontSize?: number;
style?: SubtitleStyle;
}
/**
* 逐字高亮字幕组件
* 根据时间戳逐字高亮显示字幕(无背景,纯文字描边)
*/
export const Subtitles: React.FC<SubtitlesProps> = ({
captions,
highlightColor = '#FFFF00',
normalColor = '#FFFFFF',
fontSize = 52,
}) => {
const getFontFormat = (fontFile?: string) => {
if (!fontFile) return 'truetype';
const ext = fontFile.split('.').pop()?.toLowerCase();
if (ext === 'otf') return 'opentype';
return 'truetype';
};
const buildTextShadow = (color: string, size: number) => {
return [
`-${size}px -${size}px 0 ${color}`,
`${size}px -${size}px 0 ${color}`,
`-${size}px ${size}px 0 ${color}`,
`${size}px ${size}px 0 ${color}`,
`0 0 ${size * 4}px rgba(0,0,0,0.9)`,
`0 4px 8px rgba(0,0,0,0.6)`
].join(',');
};
export const Subtitles: React.FC<SubtitlesProps> = ({ captions, style }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
@@ -38,45 +69,62 @@ export const Subtitles: React.FC<SubtitlesProps> = ({
// 获取当前高亮字的索引
const currentWordIndex = getCurrentWordIndex(currentSegment, currentTimeInSeconds);
const fontFile = style?.font_file;
const fontFamily = style?.fontFamily || style?.font_family;
const fontSize = style?.fontSize || style?.font_size || 52;
const highlightColor = style?.highlightColor || style?.highlight_color || '#FFFF00';
const normalColor = style?.normalColor || style?.normal_color || '#FFFFFF';
const strokeColor = style?.strokeColor || style?.stroke_color || '#000000';
const strokeSize = style?.strokeSize || style?.stroke_size || 3;
const letterSpacing = style?.letterSpacing || style?.letter_spacing || 2;
const bottomMargin = style?.bottomMargin || style?.bottom_margin;
const fontFamilyName = fontFamily || 'SubtitleFont';
const fontFamilyCss = fontFile
? `'${fontFamilyName}'`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
return (
<AbsoluteFill
style={{
justifyContent: 'flex-end',
alignItems: 'center',
paddingBottom: '6%',
paddingBottom: typeof bottomMargin === 'number' ? `${bottomMargin}px` : '6%',
}}
>
{fontFile && (
<style>{`
@font-face {
font-family: '${fontFamilyName}';
src: url('${staticFile(fontFile)}') format('${getFontFormat(fontFile)}');
font-weight: 400;
font-style: normal;
}
`}</style>
)}
<p
style={{
margin: 0,
fontSize: `${fontSize}px`,
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
fontFamily: fontFamilyCss,
fontWeight: 800,
lineHeight: 1.4,
textAlign: 'center',
maxWidth: '90%',
wordBreak: 'keep-all',
letterSpacing: '2px',
letterSpacing: `${letterSpacing}px`,
}}
>
{currentSegment.words.map((word, index) => {
const isHighlighted = index <= currentWordIndex;
return (
<span
key={`${word.word}-${index}`}
style={{
color: isHighlighted ? highlightColor : normalColor,
textShadow: `
-3px -3px 0 #000,
3px -3px 0 #000,
-3px 3px 0 #000,
3px 3px 0 #000,
0 0 12px rgba(0,0,0,0.9),
0 4px 8px rgba(0,0,0,0.6)
`,
transition: 'color 0.05s ease',
}}
>
key={`${word.word}-${index}`}
style={{
color: isHighlighted ? highlightColor : normalColor,
textShadow: buildTextShadow(strokeColor, strokeSize),
transition: 'color 0.05s ease',
}}
>
{word.word}
</span>
);

View File

@@ -4,22 +4,62 @@ import {
interpolate,
useCurrentFrame,
useVideoConfig,
staticFile,
} from 'remotion';
export interface TitleStyle {
font_file?: string;
fontFamily?: string;
font_family?: string;
fontSize?: number;
font_size?: number;
color?: string;
strokeColor?: string;
stroke_color?: string;
strokeSize?: number;
stroke_size?: number;
letterSpacing?: number;
letter_spacing?: number;
topMargin?: number;
top_margin?: number;
fontWeight?: number;
font_weight?: number;
}
interface TitleProps {
title: string;
duration?: number; // 标题显示时长(秒)
fadeOutStart?: number; // 开始淡出的时间(秒)
style?: TitleStyle;
}
/**
* 片头标题组件
* 在视频顶部显示标题,带淡入淡出效果
*/
const getFontFormat = (fontFile?: string) => {
if (!fontFile) return 'truetype';
const ext = fontFile.split('.').pop()?.toLowerCase();
if (ext === 'otf') return 'opentype';
return 'truetype';
};
const buildTextShadow = (color: string, size: number) => {
return [
`-${size}px -${size}px 0 ${color}`,
`${size}px -${size}px 0 ${color}`,
`-${size}px ${size}px 0 ${color}`,
`${size}px ${size}px 0 ${color}`,
`0 0 ${size * 2}px rgba(0,0,0,0.7)`,
`0 4px 8px rgba(0,0,0,0.6)`
].join(',');
};
export const Title: React.FC<TitleProps> = ({
title,
duration = 3,
fadeOutStart = 2,
style,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
@@ -57,33 +97,52 @@ export const Title: React.FC<TitleProps> = ({
{ extrapolateRight: 'clamp' }
);
const fontFile = style?.font_file;
const fontFamily = style?.fontFamily || style?.font_family;
const fontSize = style?.fontSize || style?.font_size || 72;
const color = style?.color || '#FFFFFF';
const strokeColor = style?.strokeColor || style?.stroke_color || '#000000';
const strokeSize = style?.strokeSize || style?.stroke_size || 8;
const letterSpacing = style?.letterSpacing || style?.letter_spacing || 4;
const topMargin = style?.topMargin || style?.top_margin;
const fontWeight = style?.fontWeight || style?.font_weight || 900;
const fontFamilyName = fontFamily || 'TitleFont';
const fontFamilyCss = fontFile
? `'${fontFamilyName}'`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
return (
<AbsoluteFill
style={{
justifyContent: 'flex-start',
alignItems: 'center',
paddingTop: '6%',
paddingTop: typeof topMargin === 'number' ? `${topMargin}px` : '6%',
opacity,
}}
>
{fontFile && (
<style>{`
@font-face {
font-family: '${fontFamilyName}';
src: url('${staticFile(fontFile)}') format('${getFontFormat(fontFile)}');
font-weight: 400;
font-style: normal;
}
`}</style>
)}
<h1
style={{
transform: `translateY(${translateY}px)`,
textAlign: 'center',
color: '#FFFFFF',
fontSize: '72px',
fontWeight: 900,
fontFamily: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
textShadow: `
0 0 10px rgba(0,0,0,0.9),
0 0 20px rgba(0,0,0,0.7),
0 4px 8px rgba(0,0,0,0.8),
0 8px 16px rgba(0,0,0,0.5)
`,
color,
fontSize: `${fontSize}px`,
fontWeight,
fontFamily: fontFamilyCss,
textShadow: buildTextShadow(strokeColor, strokeSize),
margin: 0,
padding: '0 5%',
lineHeight: 1.3,
letterSpacing: '4px',
letterSpacing: `${letterSpacing}px`,
}}
>
{title}