Files
meijiaka-zy/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx
T
小鱼开发 aa818b75a8 refactor: 脚本提示词体系重构 — 子类从目录改为单个文件
- 删除 system/bk/ 下所有旧提示词,平铺替换为 23 个新文件
- 文件名格式统一为: 文案——描述.txt
- 后端: _meta.json 扁平化,loader.py 新增 list_prompt_files() + load_prompt_file()
- 后端: API 从 subcategory 改为 filename,按指定文件读取
- 后端: categories 接口返回文件列表(label/desc/filename)供前端展示
- 前端: ScriptCreation 分类选择改为卡片网格,展示文案+描述
- 前端: 清理 subcategoryCode,统一改为 filename
- 前端: 字幕字号调整为 64/96/80px
2026-06-01 13:44:59 +08:00

736 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 字幕烧录页面 (Step 4)
* =====================
*
* 单视频模式:不再按分镜打轴,对成品视频统一打轴后压制字幕。
* 布局:左侧操作区 + 右侧预览区(使用 step-layout 标准布局)
*/
import { useState, useRef, useEffect, useMemo } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { useProjectStore, saveMetaToLocalFile } from '../../store';
import { getCurrentProjectId } from '../../api/modules/localStorage';
import { scriptApi } from '../../api/modules/script';
import { pointsApi } from '../../api/modules/points';
import { usePointStore, useAuthStore } from '../../store';
import RechargeModal from '../../components/RechargeModal/RechargeModal';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import { useLocalVideo } from '../../hooks/useLocalVideo';
import { useCanvasSubtitleRenderer } from '../../hooks/useCanvasSubtitleRenderer';
import { generateAssFromAlignment, saveAssFile, htmlColorToAss, AssStyle, trimTrailingPunctuation } from '../../utils/assGenerator';
import { useProgressStore } from '../../store/progressStore';
import { toast } from '../../store/uiStore';
import { getFriendlyErrorMessage } from '../../utils/errorMessage';
import { usePointsCheck } from '../../hooks/usePointsCheck';
import './SubtitleBurning.css';
// 标题预设
interface TitlePreset {
id: string;
name: string;
primaryColor: string;
outlineColor: string;
backColor: string;
borderStyle: number;
outline: number;
}
// 大标题预设(可以有背景色块)
const MAIN_TITLE_PRESETS: TitlePreset[] = [
{ id: 'yellow-box', name: '黄黑撞色', primaryColor: '#000000', outlineColor: '#000000', backColor: '#FFD700', borderStyle: 3, outline: 0 },
{ id: 'beige-brown', name: '蓝黄对比', primaryColor: '#FFD700', outlineColor: '#00008B', backColor: '#00008B', borderStyle: 3, outline: 0 },
{ id: 'white-box', name: '红白高亮', primaryColor: '#FFFFFF', outlineColor: '#CC0000', backColor: '#CC0000', borderStyle: 3, outline: 0 },
{ id: 'black-box', name: '黑白极简', primaryColor: '#FFFFFF', outlineColor: '#FFFFFF', backColor: '#000000', borderStyle: 3, outline: 0 },
];
// 小标题预设(只做描边,无背景色)
const SUB_TITLE_PRESETS: TitlePreset[] = [
{ id: 'white', name: '暖黄棕边', primaryColor: '#FFF8DC', outlineColor: '#5C4033', backColor: '#000000', borderStyle: 1, outline: 4 },
{ id: 'black', name: '白灰描边', primaryColor: '#FFFFFF', outlineColor: '#555555', backColor: '#000000', borderStyle: 1, outline: 4 },
{ id: 'teal', name: '浅灰描边', primaryColor: '#D0D0D0', outlineColor: '#000000', backColor: '#000000', borderStyle: 1, outline: 4 },
{ id: 'yellow', name: '亮黄描边', primaryColor: '#FFD700', outlineColor: '#000000', backColor: '#000000', borderStyle: 1, outline: 4 },
];
// 字幕预设(固定64px,只做描边)
const SUBTITLE_PRESETS: TitlePreset[] = [
{ id: 'white', name: '经典纯白', primaryColor: '#FFFFFF', outlineColor: '#000000', backColor: '#000000', borderStyle: 1, outline: 2 },
{ id: 'black', name: '极简纯黑', primaryColor: '#000000', outlineColor: '#FFFFFF', backColor: '#000000', borderStyle: 1, outline: 2 },
{ id: 'teal', name: '银灰描边', primaryColor: '#D0D0D0', outlineColor: '#000000', backColor: '#000000', borderStyle: 1, outline: 2 },
{ id: 'yellow', name: '明黄描边', primaryColor: '#FFD700', outlineColor: '#000000', backColor: '#000000', borderStyle: 1, outline: 2 },
];
export default function SubtitleBurning() {
const projectId = getCurrentProjectId();
// 成品视频
const composedVideoUrl = useProjectStore(state => state.composedVideoUrl);
const composedVideoPath = useProjectStore(state => state.composedVideoPath);
const actualVideoUrl = composedVideoUrl;
const actualVideoPath = composedVideoPath;
// 打轴结果直接从 Step 2 复用
const alignment = useProjectStore(state => state.subtitleAlignment);
const [isBurning, setIsBurning] = useState(false);
const [previewMode, setPreviewMode] = useState<'style' | 'result'>('style');
const [isGeneratingMainTitle, setIsGeneratingMainTitle] = useState(false);
const [isGeneratingSubTitle, setIsGeneratingSubTitle] = useState(false);
const [showPointsModal, setShowPointsModal] = useState(false);
const showRechargeModal = usePointStore(state => state.showRechargeModal);
const setShowRechargeModal = usePointStore(state => state.setShowRechargeModal);
const fetchBalance = usePointStore(state => state.fetchBalance);
// 视频播放相关
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// 预览用
const { videoUrl: loadedVideoUrl } = useLocalVideo(actualVideoUrl || actualVideoPath);
const burnedVideoPath = useProjectStore(state => state.burnedVideoPath);
const stepDirtyFlags = useProjectStore(state => state.stepDirtyFlags);
const isStepDirty = (stepDirtyFlags?.[4] ?? false) && !!burnedVideoPath;
const { videoUrl: burnedVideoUrl } = useLocalVideo(burnedVideoPath || undefined);
const hasBurnedVideo = !!burnedVideoPath;
// 大标题 / 小标题(从 store 读取,非必填,无默认值)
const storeMainTitle = useProjectStore(state => state.mainTitle);
const storeSubTitle = useProjectStore(state => state.subTitle);
const storeMainTitlePreset = useProjectStore(state => state.mainTitlePreset);
const storeSubTitlePreset = useProjectStore(state => state.subTitlePreset);
const storeCaptionPreset = useProjectStore(state => state.captionPreset);
const mainTitle = storeMainTitle ?? '';
const subTitle = storeSubTitle ?? '';
const mainTitlePreset = storeMainTitlePreset || 'yellow-box';
const subTitlePreset = storeSubTitlePreset || 'white';
const captionPreset = storeCaptionPreset || 'white';
const setMainTitle = (value: string) => {
useProjectStore.setState({ mainTitle: value });
};
const setSubTitle = (value: string) => {
useProjectStore.setState({ subTitle: value });
};
const { checkBalance } = usePointsCheck();
// 智能生成标题
const handleGenerateTitle = async (titleType: 'main' | 'sub') => {
const utterances = alignment?.utterances;
if (!utterances || utterances.length === 0) {
toast.error('暂无脚本内容,请先生成脚本');
return;
}
// 前置积分检查
const titlePoints = usePointStore.getState().getRule('title')?.points || 1;
const canProceed = await checkBalance(titlePoints, '标题生成', true);
if (!canProceed) {return;}
const scriptContent = utterances.map(u => u.text).join('\n');
const maxLength = titleType === 'main' ? 8 : 10;
if (titleType === 'main') {
setIsGeneratingMainTitle(true);
} else {
setIsGeneratingSubTitle(true);
}
try {
const res = await scriptApi.generateTitle({
scriptContent,
titleType,
maxLength,
usage: 'video',
});
if (titleType === 'main') {
setMainTitle(res.title);
} else {
setSubTitle(res.title);
}
toast.success(`${titleType === 'main' ? '大标题' : '小标题'}生成成功`);
} catch (e) {
const msg = getFriendlyErrorMessage(e, '标题生成失败,请稍后重试');
if (msg.includes('积分不足')) {
setShowPointsModal(true);
return;
}
toast.error(msg);
} finally {
if (titleType === 'main') {
setIsGeneratingMainTitle(false);
} else {
setIsGeneratingSubTitle(false);
}
}
};
const setMainTitlePreset = (value: string) => {
useProjectStore.setState({ mainTitlePreset: value });
setPreviewMode('style');
};
const setSubTitlePreset = (value: string) => {
useProjectStore.setState({ subTitlePreset: value });
setPreviewMode('style');
};
const setCaptionPreset = (value: string) => {
useProjectStore.setState({ captionPreset: value });
setPreviewMode('style');
};
// 构建 ASS Style 参数
const videoDurationMs = (alignment?.duration || 0) * 1000;
const buildSubtitleStyle = (preset: TitlePreset): Partial<AssStyle> => ({
fontSize: 64,
primaryColor: htmlColorToAss(preset.primaryColor),
outlineColor: htmlColorToAss(preset.outlineColor),
backColor: htmlColorToAss(preset.backColor),
borderStyle: preset.borderStyle,
outline: preset.outline,
shadow: 0,
alignment: 2,
marginV: 480,
marginL: 160,
marginR: 160,
});
const buildMainTitleStyle = (preset: TitlePreset): Partial<AssStyle> => ({
fontSize: 96,
primaryColor: htmlColorToAss(preset.primaryColor),
outlineColor: htmlColorToAss(preset.outlineColor),
backColor: htmlColorToAss(preset.backColor),
borderStyle: preset.borderStyle,
outline: preset.outline,
shadow: 0,
alignment: 8,
marginV: 320,
marginL: 160,
marginR: 160,
});
const buildSubTitleStyle = (preset: TitlePreset): Partial<AssStyle> => ({
fontSize: 80,
primaryColor: htmlColorToAss(preset.primaryColor),
outlineColor: htmlColorToAss(preset.outlineColor),
backColor: htmlColorToAss(preset.backColor),
borderStyle: preset.borderStyle,
outline: preset.outline,
shadow: 0,
alignment: 8,
marginV: 440,
marginL: 160,
marginR: 160,
});
// 用 text-shadow 模拟 ASS 外描边(比 WebkitTextStroke 的居中描边更准确)
const getOutlineShadow = (color: string, outlineWidth: number): string => {
const w = Math.max(0.5, outlineWidth * 0.3);
return `${-w}px ${-w}px 0 ${color}, ${w}px ${-w}px 0 ${color}, ${-w}px ${w}px 0 ${color}, ${w}px ${w}px 0 ${color}, ${-w}px 0px 0 ${color}, ${w}px 0px 0 ${color}, 0px ${-w}px 0 ${color}, 0px ${w}px 0 ${color}`;
};
// Canvas 2D 统一字幕渲染(替代 assjs + CSS 标题叠加)
// 统一去除字幕末尾标点(预览和压制共用)
const processedUtterances = alignment?.utterances?.map(u => ({
text: trimTrailingPunctuation(u.text),
startTime: u.start_time,
endTime: u.end_time,
})) ?? [];
const subtitleEnabled = !!loadedVideoUrl && processedUtterances.length > 0;
const subtitleStyle = useMemo(() => {
const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset);
return preset ? buildSubtitleStyle(preset) : {};
}, [captionPreset]);
const mainTitleStyle = useMemo(() => {
const preset = MAIN_TITLE_PRESETS.find(p => p.id === mainTitlePreset);
return preset ? buildMainTitleStyle(preset) : undefined;
}, [mainTitlePreset]);
const subTitleStyle = useMemo(() => {
const preset = SUB_TITLE_PRESETS.find(p => p.id === subTitlePreset);
return preset ? buildSubTitleStyle(preset) : undefined;
}, [subTitlePreset]);
useCanvasSubtitleRenderer({
videoRef,
canvasRef,
utterances: processedUtterances,
mainTitle: mainTitle || undefined,
subTitle: subTitle || undefined,
subtitleStyle,
mainTitleStyle,
subTitleStyle,
enabled: previewMode === 'style' && subtitleEnabled,
});
// 监听 FFmpeg 进度事件(支持多步分段)
const totalDurationSeconds = (alignment?.duration || 0);
const phaseRef = useRef({ step: 1, total: 1 });
useEffect(() => {
let canceled = false;
let unlistenProgress: (() => void) | undefined;
let unlistenPhase: (() => void) | undefined;
if (isBurning) {
// 监听 phase 切换
listen('ffmpeg-phase-start', event => {
const info = event.payload as { step: number; total: number };
phaseRef.current = { step: info.step, total: info.total };
}).then(u => {
if (canceled) {
u();
} else {
unlistenPhase = u;
}
}).catch(err => console.error('Failed to listen to ffmpeg phase:', err));
// 监听进度
listen('ffmpeg-progress', event => {
const timeStr = event.payload as string; // e.g. 00:00:05.12
const parts = timeStr.split(':').map(parseFloat);
if (parts.length === 3 && totalDurationSeconds > 0) {
const elapsed = parts[0] * 3600 + parts[1] * 60 + parts[2];
const p = Math.min(99, (elapsed / totalDurationSeconds) * 100);
const { step, total } = phaseRef.current;
// 分段映射:每步占 1/total 的比例
const overall = ((step - 1) + p / 100) / total * 100;
const rounded = Math.round(overall);
useProgressStore.getState().setProgress(rounded);
useProgressStore.getState().update(`正在压制字幕... ${rounded}%`);
}
})
.then(u => {
if (canceled) {
u();
} else {
unlistenProgress = u;
}
})
.catch(err => console.error('Failed to listen to ffmpeg progress:', err));
}
return () => {
canceled = true;
if (unlistenProgress) {unlistenProgress();}
if (unlistenPhase) {unlistenPhase();}
};
}, [isBurning, totalDurationSeconds]);
// 压制字幕:单次压制全局字幕到成品视频
const handleBurn = async () => {
if (!projectId) {
toast.error('项目ID不存在');
return;
}
if (!actualVideoPath) {
toast.error('成品视频不存在');
return;
}
if (!alignment?.utterances?.length) {
toast.error('请先完成字幕打轴');
return;
}
// 积分预检:字幕烧录
const subtitlePoints = usePointStore.getState().getRule('subtitle_burn')?.points || 2;
await fetchBalance();
const currentBalance = usePointStore.getState().balance;
if (currentBalance < subtitlePoints) {
setShowPointsModal(true);
return;
}
setShowPointsModal(false);
setIsBurning(true);
useProgressStore.getState().show('字幕烧录');
useProgressStore.getState().setProgress(0);
// 先保存配置(防止压制过程中刷新丢失)
await saveMetaToLocalFile({
mainTitle: useProjectStore.getState().mainTitle,
subTitle: useProjectStore.getState().subTitle,
mainTitlePreset: useProjectStore.getState().mainTitlePreset,
subTitlePreset: useProjectStore.getState().subTitlePreset,
captionPreset: useProjectStore.getState().captionPreset,
});
try {
// 1. 生成标题 PNG(大标题/小标题走 overlay,不用 ASS
let overlayImagePath: string | undefined;
const mtPreset = MAIN_TITLE_PRESETS.find(p => p.id === mainTitlePreset);
const stPreset = SUB_TITLE_PRESETS.find(p => p.id === subTitlePreset);
const mtStyle = mtPreset ? buildMainTitleStyle(mtPreset) : undefined;
const stStyle = stPreset ? buildSubTitleStyle(stPreset) : undefined;
if ((mainTitle && mtStyle) || (subTitle && stStyle)) {
const { generateTitlePngDataUrl } = await import('../../utils/titlePngGenerator');
const pngDataUrl = await generateTitlePngDataUrl(
mainTitle || undefined,
subTitle || undefined,
mtStyle,
stStyle,
);
// 去掉 data:image/png;base64, 前缀
const base64 = pngDataUrl.replace(/^data:image\/png;base64,/, '');
const pngSaveRes = await invoke<{ code: number; data?: string; message: string }>('save_project_asset', {
projectId,
filename: 'title_overlay.png',
base64Data: base64,
});
if (pngSaveRes.code !== 200 || !pngSaveRes.data) {
throw new Error(pngSaveRes.message || '标题图片保存失败');
}
overlayImagePath = pngSaveRes.data;
}
// 2. 生成 ASS 内容(不含大标题/小标题,它们走 PNG overlay
const assForBurn = generateAssFromAlignment(
processedUtterances,
{
subtitleStyle: (() => {
const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset);
return preset ? buildSubtitleStyle(preset) : {};
})(),
videoDurationMs,
includeTitlesInAss: false,
}
);
// 3. 保存 ASS 文件
const assPath = await saveAssFile(projectId, 'subtitle_global.ass', assForBurn);
// 4. 确定输出路径
const outputRes = await invoke<{ code: number; data?: string; message: string }>('get_video_save_path', {
projectId,
filename: `burned_${Date.now()}.mp4`,
});
if (outputRes.code !== 200 || !outputRes.data) {
throw new Error(outputRes.message);
}
const outputPath = outputRes.data;
// 5. 准备视频路径
const resolvedVideoPath = actualVideoPath;
// 6. 调用 Rust 压制字幕(带可选的 overlay 图片)
const burnResult = await invoke<{ code: number; data?: string; message: string }>('burn_subtitle', {
request: {
video_path: resolvedVideoPath,
ass_path: assPath,
output_path: outputPath,
overlay_image: overlayImagePath,
},
});
if (burnResult.code !== 200 || !burnResult.data) {
throw new Error(burnResult.message);
}
// 7. 保存压制结果
useProjectStore.setState({ burnedVideoPath: outputPath });
await saveMetaToLocalFile({ burnedVideoPath: outputPath });
useProjectStore.getState().clearStepDirty(4);
useProjectStore.getState().markStepsDirty(4);
// 8. 扣费:字幕烧录(压制成功后才扣费)
const subtitleBurnPoints = usePointStore.getState().getRule('subtitle_burn')?.points || 2;
await pointsApi.consumePoints({
points: subtitleBurnPoints,
sourceType: 'subtitle_burn',
sourceId: `subtitle_burn_${useAuthStore.getState().user?.id || 'unknown'}_${Date.now()}`,
description: '字幕烧录',
});
await usePointStore.getState().fetchBalance();
useProgressStore.getState().success('字幕烧录完成', subtitleBurnPoints);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[SubtitleBurning] 压制失败:', error);
if (typeof msg === 'string' && (msg.includes('402') || msg.includes('积分不足'))) {
useProgressStore.getState().hide();
setShowPointsModal(true);
} else {
useProgressStore.getState().error(msg || '压制失败');
toast.error(msg || '压制失败');
}
} finally {
setIsBurning(false);
}
};
return (
<div className="step-layout subtitle-burning">
{isStepDirty && (
<div className="step-dirty-banner" style={{ gridColumn: '1 / -1', margin: '0 0 var(--spacing-md) 0' }}>
<span></span>
</div>
)}
<ConfirmModal
open={showPointsModal}
type="warning"
title="积分不足"
description="请先充值积分后再进行尝试"
confirmText="立即充值"
cancelText="稍后再说"
confirmButtonType="danger"
onConfirm={() => {
setShowPointsModal(false);
setShowRechargeModal(true);
}}
onCancel={() => setShowPointsModal(false)}
onClose={() => setShowPointsModal(false)}
/>
<RechargeModal
open={showRechargeModal}
onClose={() => setShowRechargeModal(false)}
onRechargeSuccess={() => {
fetchBalance();
setShowPointsModal(false);
}}
/>
{/* 左侧操作区 */}
<div className="step-panel-left">
{/* 大标题设置 */}
<div className="style-section">
<label className="panel-label"></label>
<div className="title-input-row">
<input
type="text"
className="title-input"
placeholder="输入大标题"
value={mainTitle}
onChange={(e) => setMainTitle(e.target.value)}
maxLength={8}
/>
<button
className="btn btn-ghost title-generate-btn"
onClick={() => handleGenerateTitle('main')}
disabled={isGeneratingMainTitle}
title="根据脚本内容智能生成大标题"
>
{isGeneratingMainTitle ? '生成中...' : '智能生成'}
</button>
</div>
<div className="style-presets main-title-presets">
{MAIN_TITLE_PRESETS.map(preset => (
<button
key={preset.id}
className={`preset-btn ${mainTitlePreset === preset.id ? 'active' : ''}`}
onClick={() => setMainTitlePreset(preset.id)}
title={preset.name}
>
<span
className="preset-preview"
style={{
color: preset.primaryColor,
backgroundColor: preset.borderStyle === 3 ? preset.backColor : undefined,
textShadow: preset.borderStyle === 1
? getOutlineShadow(preset.outlineColor, preset.outline)
: undefined,
padding: preset.borderStyle === 3 ? '2px 6px' : undefined,
borderRadius: '2px',
}}
>
Aa
</span>
<span className="preset-name">{preset.name}</span>
</button>
))}
</div>
</div>
{/* 小标题设置 */}
<div className="style-section">
<label className="panel-label"></label>
<div className="title-input-row">
<input
type="text"
className="title-input"
placeholder="输入小标题"
value={subTitle}
onChange={(e) => setSubTitle(e.target.value)}
maxLength={10}
/>
<button
className="btn btn-ghost title-generate-btn"
onClick={() => handleGenerateTitle('sub')}
disabled={isGeneratingSubTitle}
title="根据脚本内容智能生成小标题"
>
{isGeneratingSubTitle ? '生成中...' : '智能生成'}
</button>
</div>
<div className="style-presets sub-title-presets">
{SUB_TITLE_PRESETS.map(preset => (
<button
key={preset.id}
className={`preset-btn ${subTitlePreset === preset.id ? 'active' : ''}`}
onClick={() => setSubTitlePreset(preset.id)}
title={preset.name}
>
<span
className="preset-preview"
style={{
color: preset.primaryColor,
textShadow: preset.borderStyle === 1
? getOutlineShadow(preset.outlineColor, preset.outline)
: undefined,
backgroundColor: preset.borderStyle === 3 ? preset.backColor : undefined,
padding: preset.borderStyle === 3 ? '2px 6px' : undefined,
borderRadius: '2px',
}}
>
Aa
</span>
<span className="preset-name">{preset.name}</span>
</button>
))}
</div>
</div>
{/* 字幕样式设置 */}
<div className="style-section">
<label className="panel-label"></label>
<div className="style-presets subtitle-presets">
{SUBTITLE_PRESETS.map(preset => (
<button
key={preset.id}
className={`preset-btn ${captionPreset === preset.id ? 'active' : ''}`}
onClick={() => setCaptionPreset(preset.id)}
title={preset.name}
>
<span
className="preset-preview"
style={{
color: preset.primaryColor,
textShadow: preset.borderStyle === 1
? getOutlineShadow(preset.outlineColor, preset.outline)
: undefined,
backgroundColor: preset.borderStyle === 3 ? preset.backColor : undefined,
padding: preset.borderStyle === 3 ? '2px 6px' : undefined,
borderRadius: '2px',
}}
>
Aa
</span>
<span className="preset-name">{preset.name}</span>
</button>
))}
</div>
</div>
{/* 压制字幕按钮 - 固定在底部 */}
{hasBurnedVideo && !isBurning ? (
<div className="burn-btn-group">
<button
className="btn btn-primary burn-btn"
onClick={handleBurn}
disabled={isBurning || !alignment?.utterances?.length}
>
{usePointStore.getState().getRule('subtitle_burn')?.points || 2}
</button>
<button
className="btn btn-secondary burn-btn"
onClick={() => setPreviewMode('result')}
disabled={!burnedVideoUrl}
>
{previewMode === 'result' ? '视频预览中' : '视频预览'}
</button>
</div>
) : (
<button
className="btn btn-primary burn-btn"
onClick={handleBurn}
disabled={isBurning || !alignment?.utterances?.length}
>
{usePointStore.getState().getRule('subtitle_burn')?.points || 2}
</button>
)}
</div>
{/* 右侧预览区 */}
<div className="step-panel-right subtitle-burning-right">
<div className="video-preview-area">
<div className="video-preview-header"></div>
<div className="video-preview-wrapper">
<div className="video-preview-container">
{!loadedVideoUrl ? (
<div className="video-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
<path d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p></p>
<p className="placeholder-sub">3</p>
</div>
) : previewMode === 'result' ? (
burnedVideoUrl ? (
<video
key={burnedVideoUrl}
src={burnedVideoUrl}
className="preview-video"
controls
autoPlay
/>
) : (
<div className="video-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
<path d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p>...</p>
</div>
)
) : (
<>
<video
key={loadedVideoUrl}
ref={videoRef}
src={loadedVideoUrl}
className="preview-video"
controls
autoPlay
/>
<canvas
ref={canvasRef}
className="subtitle-canvas-overlay"
/>
{/* 无打轴数据时显示提示遮罩 */}
{!alignment?.utterances?.length && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: '48px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
backgroundColor: 'rgba(0,0,0,0.55)',
borderRadius: 'var(--radius-lg) var(--radius-lg) 0 0',
zIndex: 10,
color: '#fff',
textAlign: 'center',
padding: '80px var(--spacing-lg) var(--spacing-lg)',
}}
>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ marginBottom: 'var(--spacing-sm)' }}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
<p style={{ fontSize: 'var(--font-md)', fontWeight: 600, lineHeight: 1.4, color: '#fff' }}></p>
<p style={{ fontSize: 'var(--font-sm)', color: 'rgba(255,255,255,0.75)', marginTop: 'var(--spacing-xs)' }}>2</p>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
</div>
);
}