aa818b75a8
- 删除 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
736 lines
29 KiB
TypeScript
736 lines
29 KiB
TypeScript
/**
|
||
* 字幕烧录页面 (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>
|
||
);
|
||
}
|