310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
import type { RefObject } from "react";
|
||
import { Eye } from "lucide-react";
|
||
|
||
interface SubtitleStyleOption {
|
||
id: string;
|
||
label: string;
|
||
font_family?: string;
|
||
font_file?: string;
|
||
font_size?: number;
|
||
highlight_color?: string;
|
||
normal_color?: string;
|
||
stroke_color?: string;
|
||
stroke_size?: number;
|
||
letter_spacing?: number;
|
||
bottom_margin?: number;
|
||
is_default?: boolean;
|
||
}
|
||
|
||
interface TitleStyleOption {
|
||
id: string;
|
||
label: string;
|
||
font_family?: string;
|
||
font_file?: string;
|
||
font_size?: number;
|
||
color?: string;
|
||
stroke_color?: string;
|
||
stroke_size?: number;
|
||
letter_spacing?: number;
|
||
font_weight?: number;
|
||
top_margin?: number;
|
||
is_default?: boolean;
|
||
}
|
||
|
||
interface TitleSubtitlePanelProps {
|
||
showStylePreview: boolean;
|
||
onTogglePreview: () => void;
|
||
videoTitle: string;
|
||
onTitleChange: (value: string) => void;
|
||
titleStyles: TitleStyleOption[];
|
||
selectedTitleStyleId: string;
|
||
onSelectTitleStyle: (id: string) => void;
|
||
titleFontSize: number;
|
||
onTitleFontSizeChange: (value: number) => void;
|
||
subtitleStyles: SubtitleStyleOption[];
|
||
selectedSubtitleStyleId: string;
|
||
onSelectSubtitleStyle: (id: string) => void;
|
||
subtitleFontSize: number;
|
||
onSubtitleFontSizeChange: (value: number) => void;
|
||
enableSubtitles: boolean;
|
||
onToggleSubtitles: (value: boolean) => void;
|
||
resolveAssetUrl: (path?: string | null) => string | null;
|
||
getFontFormat: (fontFile?: string) => string;
|
||
buildTextShadow: (color: string, size: number) => string;
|
||
previewScale?: number;
|
||
previewAspectRatio?: string;
|
||
previewBaseWidth?: number;
|
||
previewBaseHeight?: number;
|
||
previewContainerRef?: RefObject<HTMLDivElement | null>;
|
||
}
|
||
|
||
export function TitleSubtitlePanel({
|
||
showStylePreview,
|
||
onTogglePreview,
|
||
videoTitle,
|
||
onTitleChange,
|
||
titleStyles,
|
||
selectedTitleStyleId,
|
||
onSelectTitleStyle,
|
||
titleFontSize,
|
||
onTitleFontSizeChange,
|
||
subtitleStyles,
|
||
selectedSubtitleStyleId,
|
||
onSelectSubtitleStyle,
|
||
subtitleFontSize,
|
||
onSubtitleFontSizeChange,
|
||
enableSubtitles,
|
||
onToggleSubtitles,
|
||
resolveAssetUrl,
|
||
getFontFormat,
|
||
buildTextShadow,
|
||
previewScale = 1,
|
||
previewAspectRatio = '16 / 9',
|
||
previewBaseWidth = 1280,
|
||
previewBaseHeight = 720,
|
||
previewContainerRef,
|
||
}: TitleSubtitlePanelProps) {
|
||
const activeSubtitleStyle = subtitleStyles.find((s) => s.id === selectedSubtitleStyleId)
|
||
|| subtitleStyles.find((s) => s.is_default)
|
||
|| subtitleStyles[0];
|
||
|
||
const activeTitleStyle = titleStyles.find((s) => s.id === selectedTitleStyleId)
|
||
|| titleStyles.find((s) => s.is_default)
|
||
|| titleStyles[0];
|
||
|
||
const previewTitleText = videoTitle.trim() || "这里是标题预览";
|
||
const subtitleHighlightText = "最近,一个叫Cloudbot";
|
||
const subtitleNormalText = "的开源项目在GitHub上彻底火了";
|
||
|
||
const subtitleHighlightColor = activeSubtitleStyle?.highlight_color || "#FFE600";
|
||
const subtitleNormalColor = activeSubtitleStyle?.normal_color || "#FFFFFF";
|
||
const subtitleStrokeColor = activeSubtitleStyle?.stroke_color || "#000000";
|
||
const subtitleStrokeSize = activeSubtitleStyle?.stroke_size ?? 3;
|
||
const subtitleLetterSpacing = activeSubtitleStyle?.letter_spacing ?? 2;
|
||
const subtitleBottomMargin = activeSubtitleStyle?.bottom_margin ?? 0;
|
||
const subtitleFontFamilyName = `SubtitlePreview-${activeSubtitleStyle?.id || "default"}`;
|
||
const subtitleFontUrl = activeSubtitleStyle?.font_file
|
||
? resolveAssetUrl(`fonts/${activeSubtitleStyle.font_file}`)
|
||
: null;
|
||
|
||
const titleColor = activeTitleStyle?.color || "#FFFFFF";
|
||
const titleStrokeColor = activeTitleStyle?.stroke_color || "#000000";
|
||
const titleStrokeSize = activeTitleStyle?.stroke_size ?? 8;
|
||
const titleLetterSpacing = activeTitleStyle?.letter_spacing ?? 4;
|
||
const titleTopMargin = activeTitleStyle?.top_margin ?? 0;
|
||
const titleFontWeight = activeTitleStyle?.font_weight ?? 900;
|
||
const titleFontFamilyName = `TitlePreview-${activeTitleStyle?.id || "default"}`;
|
||
const titleFontUrl = activeTitleStyle?.font_file
|
||
? resolveAssetUrl(`fonts/${activeTitleStyle.font_file}`)
|
||
: null;
|
||
|
||
return (
|
||
<div className="bg-white/5 rounded-2xl p-4 sm:p-6 border border-white/10 backdrop-blur-sm">
|
||
<div className="flex items-center justify-between mb-4 gap-2">
|
||
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||
🎬 标题与字幕
|
||
</h2>
|
||
<button
|
||
onClick={onTogglePreview}
|
||
className="px-2 py-1 text-xs bg-white/10 hover:bg-white/20 rounded text-gray-300 flex items-center gap-1"
|
||
>
|
||
<Eye className="h-3.5 w-3.5" />
|
||
{showStylePreview ? "收起预览" : "预览样式"}
|
||
</button>
|
||
</div>
|
||
|
||
{showStylePreview && (
|
||
<div
|
||
ref={previewContainerRef}
|
||
className="mb-4 rounded-xl border border-white/10 bg-black/40 relative overflow-hidden"
|
||
style={{ aspectRatio: previewAspectRatio, minHeight: '180px' }}
|
||
>
|
||
{(titleFontUrl || subtitleFontUrl) && (
|
||
<style>{`
|
||
${titleFontUrl ? `@font-face { font-family: '${titleFontFamilyName}'; src: url('${titleFontUrl}') format('${getFontFormat(activeTitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||
${subtitleFontUrl ? `@font-face { font-family: '${subtitleFontFamilyName}'; src: url('${subtitleFontUrl}') format('${getFontFormat(activeSubtitleStyle?.font_file)}'); font-weight: 400; font-style: normal; }` : ''}
|
||
`}</style>
|
||
)}
|
||
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-purple-500/40 via-transparent to-pink-500/30" />
|
||
<div
|
||
className="absolute top-0 left-0"
|
||
style={{
|
||
width: `${previewBaseWidth}px`,
|
||
height: `${previewBaseHeight}px`,
|
||
transform: `scale(${previewScale})`,
|
||
transformOrigin: 'top left',
|
||
}}
|
||
>
|
||
<div
|
||
className="w-full text-center"
|
||
style={{
|
||
position: 'absolute',
|
||
top: `${titleTopMargin}px`,
|
||
left: 0,
|
||
right: 0,
|
||
color: titleColor,
|
||
fontSize: `${titleFontSize}px`,
|
||
fontWeight: titleFontWeight,
|
||
fontFamily: titleFontUrl
|
||
? `'${titleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||
textShadow: buildTextShadow(titleStrokeColor, titleStrokeSize),
|
||
letterSpacing: `${titleLetterSpacing}px`,
|
||
lineHeight: 1.2,
|
||
opacity: videoTitle.trim() ? 1 : 0.7,
|
||
padding: '0 5%',
|
||
}}
|
||
>
|
||
{previewTitleText}
|
||
</div>
|
||
|
||
<div
|
||
className="w-full text-center"
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: `${subtitleBottomMargin}px`,
|
||
left: 0,
|
||
right: 0,
|
||
fontSize: `${subtitleFontSize}px`,
|
||
fontFamily: subtitleFontUrl
|
||
? `'${subtitleFontFamilyName}', "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif`
|
||
: '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif',
|
||
textShadow: buildTextShadow(subtitleStrokeColor, subtitleStrokeSize),
|
||
letterSpacing: `${subtitleLetterSpacing}px`,
|
||
lineHeight: 1.35,
|
||
padding: '0 6%',
|
||
}}
|
||
>
|
||
{enableSubtitles ? (
|
||
<>
|
||
<span style={{ color: subtitleHighlightColor }}>{subtitleHighlightText}</span>
|
||
<span style={{ color: subtitleNormalColor }}>{subtitleNormalText}</span>
|
||
</>
|
||
) : (
|
||
<span className="text-gray-400 text-sm">字幕已关闭</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mb-4">
|
||
<label className="text-sm text-gray-300 mb-2 block">片头标题(可选)</label>
|
||
<input
|
||
type="text"
|
||
value={videoTitle}
|
||
onChange={(e) => onTitleChange(e.target.value)}
|
||
placeholder="输入视频标题,将在片头显示"
|
||
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base bg-black/30 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
|
||
/>
|
||
</div>
|
||
|
||
{titleStyles.length > 0 && (
|
||
<div className="mb-4">
|
||
<label className="text-sm text-gray-300 mb-2 block">标题样式</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{titleStyles.map((style) => (
|
||
<button
|
||
key={style.id}
|
||
onClick={() => onSelectTitleStyle(style.id)}
|
||
className={`p-2 rounded-lg border transition-all text-left ${selectedTitleStyleId === style.id
|
||
? "border-purple-500 bg-purple-500/20"
|
||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||
}`}
|
||
>
|
||
<div className="text-white text-sm truncate">{style.label}</div>
|
||
<div className="text-xs text-gray-400 truncate">
|
||
{style.font_family || style.font_file || ""}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mt-3">
|
||
<label className="text-xs text-gray-400 mb-2 block">标题字号: {titleFontSize}px</label>
|
||
<input
|
||
type="range"
|
||
min="48"
|
||
max="110"
|
||
step="1"
|
||
value={titleFontSize}
|
||
onChange={(e) => onTitleFontSizeChange(parseInt(e.target.value, 10))}
|
||
className="w-full accent-purple-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{enableSubtitles && subtitleStyles.length > 0 && (
|
||
<div className="mt-4">
|
||
<label className="text-sm text-gray-300 mb-2 block">字幕样式</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{subtitleStyles.map((style) => (
|
||
<button
|
||
key={style.id}
|
||
onClick={() => onSelectSubtitleStyle(style.id)}
|
||
className={`p-2 rounded-lg border transition-all text-left ${selectedSubtitleStyleId === style.id
|
||
? "border-purple-500 bg-purple-500/20"
|
||
: "border-white/10 bg-white/5 hover:border-white/30"
|
||
}`}
|
||
>
|
||
<div className="text-white text-sm truncate">{style.label}</div>
|
||
<div className="text-xs text-gray-400 truncate">
|
||
{style.font_family || style.font_file || ""}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mt-3">
|
||
<label className="text-xs text-gray-400 mb-2 block">字幕字号: {subtitleFontSize}px</label>
|
||
<input
|
||
type="range"
|
||
min="32"
|
||
max="90"
|
||
step="1"
|
||
value={subtitleFontSize}
|
||
onChange={(e) => onSubtitleFontSizeChange(parseInt(e.target.value, 10))}
|
||
className="w-full accent-purple-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||
<div>
|
||
<span className="text-sm text-gray-300">逐字高亮字幕</span>
|
||
<p className="text-xs text-gray-500 mt-1">自动生成卡拉OK效果字幕</p>
|
||
</div>
|
||
<label className="relative inline-flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enableSubtitles}
|
||
onChange={(e) => onToggleSubtitles(e.target.checked)}
|
||
className="sr-only peer"
|
||
/>
|
||
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|