This commit is contained in:
Kevin Wong
2026-02-24 16:55:29 +08:00
parent bc0fe9326a
commit 0a5a17402c
29 changed files with 879 additions and 143 deletions

View File

@@ -19,6 +19,8 @@ interface RenderOptions {
titleDisplayMode?: 'short' | 'persistent';
subtitleStyle?: Record<string, unknown>;
titleStyle?: Record<string, unknown>;
secondaryTitle?: string;
secondaryTitleStyle?: Record<string, unknown>;
outputPath: string;
fps?: number;
enableSubtitles?: boolean;
@@ -75,6 +77,16 @@ async function parseArgs(): Promise<RenderOptions> {
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;
}
}
@@ -161,6 +173,8 @@ async function main() {
titleDisplayMode: options.titleDisplayMode || 'short',
subtitleStyle: options.subtitleStyle,
titleStyle: options.titleStyle,
secondaryTitle: options.secondaryTitle,
secondaryTitleStyle: options.secondaryTitleStyle,
enableSubtitles: options.enableSubtitles !== false,
width: videoWidth,
height: videoHeight,

View File

@@ -25,9 +25,11 @@ export const RemotionRoot: React.FC = () => {
audioSrc: undefined,
captions: undefined,
title: undefined,
secondaryTitle: undefined,
titleDuration: 4,
titleDisplayMode: 'short',
enableSubtitles: true,
secondaryTitleStyle: undefined,
width: 1080,
height: 1920,
}}

View File

@@ -10,11 +10,13 @@ export interface VideoProps {
audioSrc?: string;
captions?: CaptionsData;
title?: string;
secondaryTitle?: string;
titleDuration?: number;
titleDisplayMode?: 'short' | 'persistent';
enableSubtitles?: boolean;
subtitleStyle?: SubtitleStyle;
titleStyle?: TitleStyle;
secondaryTitleStyle?: TitleStyle;
width?: number;
height?: number;
}
@@ -28,11 +30,13 @@ export const Video: React.FC<VideoProps> = ({
audioSrc,
captions,
title,
secondaryTitle,
titleDuration = 4,
titleDisplayMode = 'short',
enableSubtitles = true,
subtitleStyle,
titleStyle,
secondaryTitleStyle,
}) => {
return (
<AbsoluteFill style={{ backgroundColor: 'black' }}>
@@ -45,8 +49,15 @@ export const Video: React.FC<VideoProps> = ({
)}
{/* 顶层:标题 */}
{title && (
<Title title={title} duration={titleDuration} displayMode={titleDisplayMode} style={titleStyle} />
{(title || secondaryTitle) && (
<Title
title={title || ''}
secondaryTitle={secondaryTitle}
duration={titleDuration}
displayMode={titleDisplayMode}
style={titleStyle}
secondaryTitleStyle={secondaryTitleStyle}
/>
)}
</AbsoluteFill>
);

View File

@@ -25,10 +25,12 @@ export interface TitleStyle {
interface TitleProps {
title: string;
secondaryTitle?: string;
duration?: number; // 标题显示时长(秒)
displayMode?: 'short' | 'persistent'; // 短暂显示或常驻显示
fadeOutStart?: number; // 开始淡出的时间(秒)
style?: TitleStyle;
secondaryTitleStyle?: TitleStyle;
}
/**
@@ -48,17 +50,19 @@ const buildTextShadow = (color: string, size: number) => {
`${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)`
`0 0 ${size * 2}px rgba(0,0,0,0.5)`,
`0 2px 4px rgba(0,0,0,0.3)`
].join(',');
};
export const Title: React.FC<TitleProps> = ({
title,
secondaryTitle,
duration = 4,
displayMode = 'short',
fadeOutStart,
style,
secondaryTitleStyle,
}) => {
const frame = useCurrentFrame();
const { fps, width } = useVideoConfig();
@@ -130,9 +134,32 @@ export const Title: React.FC<TitleProps> = ({
? `'${fontFamilyName}'`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
// 副标题样式
const stStyle = secondaryTitleStyle || style;
const stFontFile = secondaryTitleStyle?.font_file ?? style?.font_file;
const stFontFamily = secondaryTitleStyle?.font_family ?? style?.font_family;
const stBaseFontSize = stStyle?.font_size ?? 48;
const stBaseStrokeSize = stStyle?.stroke_size ?? 3;
const stBaseLetterSpacing = stStyle?.letter_spacing ?? 2;
const stBaseTopMargin = secondaryTitleStyle?.top_margin;
const stFontSize = Math.max(24, Math.round(stBaseFontSize * responsiveScale));
const stColor = stStyle?.color ?? '#FFFFFF';
const stStrokeColor = stStyle?.stroke_color ?? '#000000';
const stStrokeSize = Math.max(1, Math.round(stBaseStrokeSize * responsiveScale));
const stLetterSpacing = Math.max(0, stBaseLetterSpacing * responsiveScale);
const stFontWeight = stStyle?.font_weight ?? 700;
const stFontFamilyName = stFontFamily || 'SecondaryTitleFont';
const stFontFamilyCss = stFontFile
? `'${stFontFamilyName}'`
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif';
const stMarginTop = typeof stBaseTopMargin === 'number'
? Math.max(0, Math.round(stBaseTopMargin * responsiveScale))
: Math.round(12 * responsiveScale);
return (
<AbsoluteFill
style={{
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'center',
paddingTop: typeof topMargin === 'number' ? `${topMargin}px` : '6%',
@@ -149,6 +176,16 @@ export const Title: React.FC<TitleProps> = ({
}
`}</style>
)}
{secondaryTitle && stFontFile && stFontFile !== fontFile && (
<style>{`
@font-face {
font-family: '${stFontFamilyName}';
src: url('${staticFile(stFontFile)}') format('${getFontFormat(stFontFile)}');
font-weight: 400;
font-style: normal;
}
`}</style>
)}
<h1
style={{
transform: `translateY(${translateY}px)`,
@@ -171,6 +208,31 @@ export const Title: React.FC<TitleProps> = ({
>
{title}
</h1>
{secondaryTitle && (
<h2
style={{
transform: `translateY(${translateY}px)`,
textAlign: 'center',
color: stColor,
fontSize: `${stFontSize}px`,
fontWeight: stFontWeight,
fontFamily: stFontFile && stFontFile !== fontFile ? stFontFamilyCss : fontFamilyCss,
textShadow: buildTextShadow(stStrokeColor, stStrokeSize),
margin: 0,
marginTop: `${stMarginTop}px`,
width: '100%',
boxSizing: 'border-box',
padding: '0 5%',
lineHeight: 1.3,
whiteSpace: 'normal',
wordBreak: 'break-word',
overflowWrap: 'anywhere',
letterSpacing: `${stLetterSpacing}px`,
}}
>
{secondaryTitle}
</h2>
)}
</AbsoluteFill>
);
};