Files
meijiaka-zy/tauri-app/src/pages/VideoCreation/ScriptCreation.tsx
T

605 lines
22 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.
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { scriptApi, ScriptShot, CategoryItem } from '../../api/modules/script';
import { createTask, getTaskStatus } from '../../api/modules/task';
import { adaptScriptShots } from '../../api/adapters/scriptAdapter';
import { useProjectStore, usePointStore } from '../../store';
import { toast } from '../../store/uiStore';
import { useProgressStore } from '../../store/progressStore';
import { ShotStats } from '../../components/ShotStats';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import { usePointsCheck } from '../../hooks/usePointsCheck';
import { getFriendlyErrorMessage } from '../../utils/errorMessage';
import './ScriptCreation.css';
/**
* 脚本生成页面
*/
export default function ScriptCreation() {
// 使用 selector 模式订阅 store,确保组件正确响应状态变化
const segments = useProjectStore(state => state.segments);
const setSegments = useProjectStore(state => state.setSegments);
const markStepsDirty = useProjectStore(state => state.markStepsDirty);
const updateSegment = useProjectStore(state => state.updateSegment);
const categoryCode = useProjectStore(state => state.categoryCode);
const filename = useProjectStore(state => state.filename);
const setTopic = useProjectStore(state => state.setTopic);
const setCategoryCode = useProjectStore(state => state.setCategoryCode);
const setFilename = useProjectStore(state => state.setFilename);
const [generating, setGenerating] = useState(false);
const requestLock = useRef(false);
// 分类列表(从后端动态加载)
const [categories, setCategories] = useState<CategoryItem[]>([]);
// 选中的大类和文件名
const [selectedCategory, setSelectedCategory] = useState<string>(categoryCode || '');
const [selectedFilename, setSelectedFilename] = useState<string>(filename || '');
// 加载分类列表(带本地缓存 + 静默刷新)
useEffect(() => {
const { cached, refresh } = scriptApi.getCategoriesCached();
if (cached) {
setCategories(cached);
}
refresh
.then(data => {
setCategories(data);
})
.catch((err: Error) => {
console.error('[分类加载失败]', err);
if (!cached) {
// 始终显示详细错误,方便跨平台诊断(Windows WebView2 网络问题需要具体信息)
toast.error(`加载分类列表失败: ${err.message}`);
}
});
}, []);
// 分类列表加载后,恢复之前保存的选中状态
useEffect(() => {
if (categories.length === 0) {return;}
if (categoryCode && filename) {
// 验证保存的文件名是否在当前分类列表中有效
const catExists = categories.some(c => c.code === categoryCode);
const fileExists = categories.some(c =>
c.code === categoryCode && c.files.some(f => f.filename === filename)
);
if (catExists && fileExists) {
setSelectedCategory(categoryCode);
setSelectedFilename(filename);
}
}
}, [categories, categoryCode, filename]);
// 编辑状态:存储正在编辑的字段键(如 "1-scene", "2-voiceover"
const [editingFields, setEditingFields] = useState<Set<string>>(new Set());
// 润色状态
const [polishingState, setPolishingState] = useState<{
id: number;
type: 'scene' | 'prompt' | 'voiceover';
} | null>(null);
// 展开/收起状态
const [expandedSegments, setExpandedSegments] = useState<Set<number>>(new Set());
// 标记是否已初始化展开状态(防止收起后被 useEffect 重新展开)
const hasInitExpandedRef = useRef(false);
// 重新生成确认弹窗
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
const shots = useMemo(() => segments || [], [segments]);
const generated = shots.length > 0;
// 检测是否已有后续步骤数据
const hasSubsequentData = useMemo(() => {
return shots.some(s => s.videoPath || s.alignmentResult || s.burnedVideoPath);
}, [shots]);
// 首次有脚本内容时默认展开所有镜头(只执行一次,不覆盖用户手动收起)
useEffect(() => {
if (!hasInitExpandedRef.current && segments.length > 0) {
hasInitExpandedRef.current = true;
setExpandedSegments(new Set(segments.map(s => s.id)));
}
}, [segments]);
// 使用 useMemo 缓存统计计算,避免每次渲染重复计算
const stats = useMemo(
() => ({
totalWords: shots.reduce(
(a, s) => a + (typeof s.voiceover === 'string' ? s.voiceover.length : 0),
0
),
totalDuration: shots.reduce((a, s) => {
const durationStr = typeof s.duration === 'string' ? s.duration : '5s';
const seconds = parseFloat(durationStr.replace(/[^0-9.]/g, '') || '0');
return a + seconds;
}, 0),
segmentCount: shots.filter(s => s.type !== 'empty_shot').length,
emptyShotCount: shots.filter(s => s.type === 'empty_shot').length,
}),
[shots]
);
// stats 直接传递给 ShotStats 组件,无需解构
/**
* 轮询任务状态直到完成或失败
*/
const pollTask = async (taskId: string, onProgress: (msg: string) => void): Promise<unknown> => {
const interval = 2000; // 2 秒
const timeout = 300_000; // 5 分钟
const start = Date.now();
while (Date.now() - start < timeout) {
const status = await getTaskStatus(taskId);
onProgress(status.message || `进度 ${status.progress}%`);
if (status.status === 'completed') {
return status.result;
}
if (status.status === 'failed') {
throw new Error(status.error || '任务执行失败');
}
// pending / running / waiting 继续轮询
await new Promise(r => setTimeout(r, interval));
}
throw new Error('任务超时,请稍后到任务列表查看结果');
};
/**
* 执行生成脚本(异步任务 + 轮询)
*/
const doGenerate = async () => {
if (!selectedCategory || !selectedFilename) {
toast.warning('请选择创作主题');
return;
}
// 前置积分检查:脚本生成
const scriptPoints = usePointStore.getState().getRule('script')?.points || 5;
const ok = await checkBalance(scriptPoints, '脚本生成');
if (!ok) {return;}
// 请求去重锁:防止网络延迟期间快速点击发起多个请求
if (requestLock.current) {
return;
}
requestLock.current = true;
setGenerating(true);
const progress = useProgressStore.getState();
progress.show('脚本生成');
try {
// 1. 创建异步任务
const { taskId } = await createTask('script', {
category: selectedCategory,
filename: selectedFilename,
});
// 2. 轮询任务状态
const result = await pollTask(taskId, (msg) => progress.update(msg));
if (!result) {
progress.error('任务结果为空');
return;
}
// 3. 解析结果:异步任务返回 { title, scenes, total_duration, style, shot_count }
const resultObj = result as Record<string, unknown>;
const scenes = resultObj.scenes as ScriptShot[] | undefined;
if (!scenes || scenes.length === 0) {
progress.error('生成分镜为空');
return;
}
let normalizedShots: ScriptShot[];
try {
normalizedShots = adaptScriptShots(scenes);
} catch (e) {
console.error('[ScriptCreation] 数据适配失败:', e, '原始数据:', scenes);
progress.error('数据解析失败');
return;
}
if (normalizedShots.length === 0) {
progress.error('生成分镜为空');
return;
}
setSegments(normalizedShots);
setExpandedSegments(new Set(normalizedShots.map(s => s.id)));
markStepsDirty(1);
progress.success('脚本生成成功', scriptPoints);
// 设置标题
const title = typeof resultObj.title === 'string' ? resultObj.title : '';
if (title) {
setTopic(title);
}
} catch (error) {
if (handleError(error, '脚本生成', 5)) {
progress.hide();
return;
}
progress.error(getFriendlyErrorMessage(error, '脚本生成失败,请稍后重试'));
} finally {
requestLock.current = false;
setGenerating(false);
}
};
/**
* 生成脚本入口(带确认弹窗)
*/
const handleGenerate = () => {
if (hasSubsequentData) {
setShowRegenerateConfirm(true);
return;
}
doGenerate();
};
/**
* 切换字段编辑状态
*/
const toggleEditField = useCallback((id: number, field: 'scene' | 'prompt' | 'voiceover') => {
const key = `${id}-${field}`;
setEditingFields(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
/**
* 处理字段更新
*/
const handleFieldChange = useCallback(
(id: number, field: 'scene' | 'prompt' | 'voiceover' | 'duration', value: string) => {
if (field === 'duration') {
const actualDuration = parseFloat(value.replace(/[^0-9.]/g, '')) || 5;
updateSegment(id, { [field]: value, actualDuration });
} else {
updateSegment(id, { [field]: value });
}
},
[updateSegment]
);
/**
* 切换分镜展开/收起
*/
const toggleExpand = useCallback((id: number) => {
setExpandedSegments(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
/**
* 展开所有镜头
*/
const expandAll = useCallback(() => {
setExpandedSegments(new Set(shots.map(s => s.id)));
}, [shots]);
/**
* 收起所有镜头
*/
const collapseAll = useCallback(() => {
setExpandedSegments(new Set());
}, []);
// 字段编辑的key生成函数
/**
* 处理润色
*/
const { checkBalance, handleError, PointsModal } = usePointsCheck();
const handlePolish = async (
id: number,
content: string,
type: 'scene' | 'prompt' | 'voiceover'
) => {
// 前置积分检查:润色
const polishPoints = usePointStore.getState().getRule('polish')?.points || 1;
const ok = await checkBalance(polishPoints, type === 'voiceover' ? '文案润色' : '画面润色');
if (!ok) {return;}
setPolishingState({ id, type });
try {
// 后端润色接口:scene=画面描述, voiceover=配音文本
const polishType = type === 'scene' || type === 'prompt' ? 'scene' : 'voiceover';
// 获取当前镜头的类型(分镜/空镜)
const shot = shots.find(s => s.id === id);
const shotType = shot?.type === 'empty_shot' ? 'empty_shot' : 'segment';
const result = await scriptApi.polish(id, content, polishType, shotType);
handleFieldChange(id, type, result);
} catch (error) {
if (handleError(error, type === 'voiceover' ? '文案润色' : '画面润色', 1)) {return;}
console.error('润色失败:', error);
toast.error('润色失败,请重试');
} finally {
setPolishingState(null);
}
};
return (
<div className="step-layout script-creation">
{/* Left Panel - 参数配置 */}
<div className="step-panel-left">
{/* 创作主题 */}
<div className="panel-section">
<label className="panel-label"></label>
{categories.map((cat) => (
<div key={cat.code}>
<div className="topic-groups">
{cat.files.map((file) => (
<button
key={file.filename}
className={`option-card ${selectedCategory === cat.code && selectedFilename === file.filename ? 'selected' : ''}`}
onClick={() => {
setSelectedCategory(cat.code);
setSelectedFilename(file.filename);
setTopic(file.label);
setCategoryCode(cat.code);
setFilename(file.filename);
}}
title={file.desc}
>
<div className="option-card-label">{file.label}</div>
<div className="option-card-desc">{file.desc}</div>
</button>
))}
</div>
</div>
))}
</div>
{/* 生成按钮 */}
<button
className="btn btn-primary"
style={{ width: '100%' }}
onClick={handleGenerate}
disabled={generating}
>
{generated ? `重新生成脚本(${usePointStore.getState().getRule('script')?.points || 5} 积分)` : `生成脚本(${usePointStore.getState().getRule('script')?.points || 5} 积分)`}
</button>
{/* 生成进度由全局 ProgressModal 统一展示 */}
</div>
{/* Right Panel - 分镜编辑 */}
<div className="step-panel-right">
{generated ? (
<div className="script-editor">
{/* 统计和操作栏 */}
<div className="script-editor-header">
<label className="panel-label"></label>
<div className="script-editor-actions">
<button className="btn btn-ghost btn-sm" onClick={expandAll}>
</button>
<button className="btn btn-ghost btn-sm" onClick={collapseAll}>
</button>
</div>
</div>
{/* 🆕 使用通用 ShotStats 组件 */}
<ShotStats stats={stats} />
{/* 镜头列表 */}
{shots.map(seg => {
const isEmptyShot = seg.type === 'empty_shot';
return (
<div
key={seg.id}
className={`script-segment ${expandedSegments.has(seg.id) ? 'expanded' : ''}`}
>
<div className="segment-header" onClick={() => toggleExpand(seg.id)}>
<div className="segment-title-group">
<span className="segment-title">
{`镜头 ${seg.id}`}
<span
className="shot-type-badge"
style={{
backgroundColor: 'rgba(34, 197, 94, 0.15)',
color: 'rgb(34, 197, 94)',
marginLeft: '8px',
fontSize: '10px',
padding: 'var(--spacing-2xs) var(--spacing-xs)',
borderRadius: '4px',
fontWeight: 600,
}}
>
{isEmptyShot ? '空镜' : '分镜'}
</span>
</span>
<span className="segment-duration">
{typeof seg.duration === 'string' ? seg.duration : '5s'}
</span>
</div>
<div className="segment-header-actions">
{/* 展开/收起指示器 */}
<span className="expand-indicator">
{expandedSegments.has(seg.id) ? (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="18 15 12 9 6 15" />
</svg>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
)}
</span>
</div>
</div>
{expandedSegments.has(seg.id) && (
<div className="segment-body">
{/* 画面描述(分镜:scene,空镜:prompt)—— 只读 */}
<div className="segment-field">
<div className="segment-field-header">
<span className="segment-field-label"></span>
</div>
<p className="segment-field-value">
{typeof seg.scene === 'string' ? seg.scene : '未设置画面描述'}
</p>
</div>
{/* 配音文本/画外音(两种类型都有) */}
<div className="segment-field">
<div className="segment-field-header">
<span className="segment-field-label"></span>
<div className="segment-field-actions">
<button
className="btn btn-ghost btn-xs"
disabled={!!polishingState}
onClick={() =>
handlePolish(
seg.id,
typeof seg.voiceover === 'string' ? seg.voiceover : '',
'voiceover'
)
}
>
{polishingState?.id === seg.id && polishingState?.type === 'voiceover' ? (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ animation: 'spin 1s linear infinite' }}
>
<path d="M21 12a9 9 0 11-6.219-8.56" />
</svg>
) : null}
</button>
<button
className="btn btn-ghost btn-xs"
onClick={() => toggleEditField(seg.id, 'voiceover')}
>
{editingFields.has(`${seg.id}-voiceover`) ? '完成' : '编辑'}
</button>
</div>
</div>
{editingFields.has(`${seg.id}-voiceover`) ? (
<textarea
value={typeof seg.voiceover === 'string' ? seg.voiceover : ''}
onChange={e => handleFieldChange(seg.id, 'voiceover', e.target.value)}
rows={3}
autoFocus
placeholder="输入配音文本..."
/>
) : (
<p
className="segment-field-value"
onClick={() => toggleEditField(seg.id, 'voiceover')}
style={{ minHeight: '60px' }}
>
{typeof seg.voiceover === 'string' ? (
seg.voiceover || (
<span
style={{ color: 'var(--text-tertiary)', fontStyle: 'italic' }}
>
...
</span>
)
) : (
<span style={{ color: 'var(--text-tertiary)', fontStyle: 'italic' }}>
...
</span>
)}
</p>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="empty-state">
<div className="empty-state-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p className="empty-state-title"></p>
<p className="empty-state-desc">
{'"'}{'"'}
</p>
</div>
)}
</div>
<ConfirmModal
open={showRegenerateConfirm}
onClose={() => setShowRegenerateConfirm(false)}
onConfirm={() => {
setShowRegenerateConfirm(false);
doGenerate();
}}
onCancel={() => setShowRegenerateConfirm(false)}
type="warning"
title="重新生成脚本"
description={
'该操作将覆盖视频、字幕等现有数据,\n' +
'且消费积分不返还。是否继续?'
}
confirmText="继续生成"
cancelText="取消"
confirmButtonType="danger"
/>
<PointsModal />
</div>
);
}