Files
meijiaka-zy/tauri-app/src/pages/VideoCreation/CoverDesign.tsx
T
小鱼开发 ca4a0b1303 feat: 用户数据隔离、动态分辨率、字幕缩放、多项体验优化
- 用户数据隔离:所有用户数据按 users/{user_id}/ 隔离,Rust IPC 命令自治读取 auth.json
- 安全加固:delete_local_product/rename_local_product/export_product 增加前缀校验
- 移除音调(pitch)功能:从 VoiceSynthesis、projectStore、types 等完全移除
- 动态视频分辨率:根据素材最小高度自动选择 720p/1080p,9:16 比例强校验
- ASS 字幕按目标分辨率等比例缩放(720p 和 1080p 视觉一致)
- Canvas 预览支持参数化 playResY,预览与压制效果一致
- 配音合成增加台词字数校验弹窗(语速>1.0时要求更多字)
- BGM 默认音量从 25% 调至 15%
- 素材选择提示文案更新(9:16 比例,5-60秒)
- 视频校验从严格 1080x1920 改为 9:16 比例判断
- 背景图片弹窗宽度从 440px 放大到 560px
2026-06-04 17:30:54 +08:00

733 lines
26 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 5) - Fabric.js 版
* ================================
*
* 使用 Fabric.js Canvas 实时预览封面:
* - 背景图:点击卡片弹窗选图(6 张随机)
* - 封面形象:点击卡片弹窗选人
* - 模板:双标题居中
* - 导出:PNG 高清导出
*/
import { useState, useEffect, useRef } from 'react';
interface FileWithPath extends File {
path?: string;
}
import { useCoverAvatarStore } from '../../store';
import { invoke } from '@tauri-apps/api/core';
import { save } from '@tauri-apps/plugin-dialog';
import { useProjectStore } from '../../store';
import { getCurrentProjectId } from '../../api/modules/localStorage';
import { useCoverFabric } from '../../hooks/useCoverFabric';
import { useProgressStore } from '../../store/progressStore';
import { scriptApi } from '../../api/modules/script';
import { coverApi, CoverBackground } from '../../api/modules/cover';
import { pointsApi } from '../../api/modules/points';
import { usePointStore, useAuthStore } from '../../store';
import RechargeModal from '../../components/RechargeModal/RechargeModal';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import Modal from '../../components/Modal/Modal';
import { toast } from '../../store/uiStore';
import { getFriendlyErrorMessage } from '../../utils/errorMessage';
import { usePointsCheck } from '../../hooks/usePointsCheck';
import './CoverDesign.css';
interface BgImage {
id: string;
src: string;
name: string;
}
function shuffleArray<T>(arr: T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
function pickSeven(all: BgImage[], lastIds: Set<string>): BgImage[] {
if (all.length <= 7) {return shuffleArray(all);}
let shuffled = shuffleArray(all);
for (let i = 0; i < 10; i++) {
const picked = shuffled.slice(0, 7);
const pickedIds = new Set(picked.map((b) => b.id));
const allSame =
pickedIds.size === 7 &&
lastIds.size === 7 &&
[...pickedIds].every((id) => lastIds.has(id));
if (!allSame) {return picked;}
shuffled = shuffleArray(all);
}
return shuffled.slice(0, 7);
}
export default function CoverDesign() {
const coverConfig = useProjectStore((state) => state.coverConfig);
const coverPath = useProjectStore((state) => state.coverPath);
const setCoverPath = useProjectStore((state) => state.setCoverPath);
const setCoverConfig = useProjectStore((state) => state.setCoverConfig);
const stepDirtyFlags = useProjectStore((state) => state.stepDirtyFlags);
const isStepDirty = (stepDirtyFlags?.[5] ?? false) && !!coverPath;
const projectId = getCurrentProjectId();
const [config, setConfig] = useState<{
template: 'dual-title';
backgroundImage: string | null;
mainTitle: string;
subtitle: string;
avatarImage: string | null;
}>({
template: 'dual-title',
backgroundImage: null,
mainTitle: '',
subtitle: '',
avatarImage: null,
});
const [isGeneratingMainTitle, setIsGeneratingMainTitle] = useState(false);
const [isGeneratingSubTitle, setIsGeneratingSubTitle] = useState(false);
const [isDesigning, setIsDesigning] = 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 [bgModalOpen, setBgModalOpen] = useState(false);
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
// 背景图配置
const [modalBgs, setModalBgs] = useState<BgImage[]>([]);
const [lastModalBgIds, setLastModalBgIds] = useState<Set<string>>(new Set());
const { canvasRef, initCanvas, renderCover, exportPng } = useCoverFabric();
const { checkBalance } = usePointsCheck();
// 智能生成标题
const handleGenerateTitle = async (titleType: 'main' | 'sub') => {
const projectStore = useProjectStore.getState();
const utterances = projectStore.subtitleAlignment?.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' ? 6 : 26;
if (titleType === 'main') {
setIsGeneratingMainTitle(true);
} else {
setIsGeneratingSubTitle(true);
}
try {
const res = await scriptApi.generateTitle({
scriptContent,
titleType,
maxLength,
usage: 'cover',
});
setConfig((prev) => ({
...prev,
[titleType === 'main' ? 'mainTitle' : 'subtitle']: 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 localBgRef = useRef<HTMLInputElement | null>(null);
const blobUrlsRef = useRef<Set<string>>(new Set());
const [bgList, setBgList] = useState<BgImage[]>([]);
useEffect(() => {
const loadBackgrounds = async () => {
try {
const backgrounds = await coverApi.getBackgrounds('bk');
const list: BgImage[] = backgrounds.map((bg: CoverBackground) => ({
id: String(bg.id),
src: bg.url,
name: bg.title || '背景图',
}));
setBgList(list);
} catch (err) {
console.error('[CoverDesign] 加载背景图失败:', err);
}
};
loadBackgrounds();
}, []);
// 本地上传背景图
const handleLocalBgUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {return;}
// 释放旧的 Blob URL
const oldBg = config.backgroundImage;
if (oldBg && oldBg.startsWith('blob:')) {
URL.revokeObjectURL(oldBg);
blobUrlsRef.current.delete(oldBg);
}
const path = (file as FileWithPath).path || (file as FileWithPath).webkitRelativePath || '';
if (!path) {
// Tauri 文件选择器通常有 path,如果没有则使用 Object URL
const url = URL.createObjectURL(file);
blobUrlsRef.current.add(url);
setConfig(prev => ({ ...prev, backgroundImage: url }));
} else {
setConfig(prev => ({ ...prev, backgroundImage: path }));
}
setBgModalOpen(false);
// 清空 input 以便下次选择同一文件
e.target.value = '';
};
// 弹窗展示 7 张背景图 + 1 个上传入口
const openBgModal = () => {
const picked = pickSeven(bgList, lastModalBgIds);
setModalBgs(picked);
setLastModalBgIds(new Set(picked.map(b => b.id)));
setBgModalOpen(true);
};
// 弹窗换一组
const shuffleModalBgs = () => {
const picked = pickSeven(bgList, lastModalBgIds);
setModalBgs(picked);
setLastModalBgIds(new Set(picked.map(b => b.id)));
};
// 组件卸载时释放所有 Blob URL
useEffect(() => {
const urls = blobUrlsRef.current;
return () => {
urls.forEach(url => URL.revokeObjectURL(url));
urls.clear();
};
}, []);
// 初始化 Canvas
useEffect(() => {
initCanvas();
}, [initCanvas]);
// 加载之前保存的封面配置
useEffect(() => {
if (!coverConfig) {return;}
setConfig((prev) => ({
template: coverConfig.template || prev.template,
backgroundImage: coverConfig.backgroundImage ?? prev.backgroundImage,
mainTitle: coverConfig.mainTitle ?? coverConfig.caption ?? prev.mainTitle,
subtitle: coverConfig.subtitle ?? prev.subtitle,
avatarImage: coverConfig.avatarImage ?? prev.avatarImage,
}));
}, [coverConfig]);
// 背景图/形象变化时自动保存 coverConfig(防抖,只监听视觉素材)
useEffect(() => {
if (!projectId) {return;}
const timer = setTimeout(() => {
useProjectStore.getState().setCoverConfig({
template: config.template,
backgroundImage: config.backgroundImage,
mainTitle: config.mainTitle,
subtitle: config.subtitle,
avatarImage: config.avatarImage,
});
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.backgroundImage, config.avatarImage, projectId]);
// 配置变化时重新渲染 Canvas
useEffect(() => {
const timer = setTimeout(() => {
renderCover(config);
}, 50);
return () => clearTimeout(timer);
}, [config, renderCover]);
// 生成封面图
const handleGenerate = async () => {
if (!projectId) {return;}
if (!config.mainTitle.trim()) {return;}
const coverPoints = usePointStore.getState().getRule('cover_design')?.points || 2;
await fetchBalance();
const currentBalance = usePointStore.getState().balance;
if (currentBalance < coverPoints) {
setShowPointsModal(true);
return;
}
setShowPointsModal(false);
setIsDesigning(true);
useProgressStore.getState().show('封面设计');
try {
useProgressStore.getState().update('正在设计封面...');
const oldCoverPath = useProjectStore.getState().coverPath;
if (oldCoverPath) {
try {
await invoke<{ code: number; message: string }>('delete_project_file', {
projectId,
filePath: oldCoverPath,
});
} catch {
// no-op
}
}
const dataUrl = exportPng();
if (!dataUrl) {
throw new Error('封面设计失败');
}
const base64 = dataUrl.split(',')[1];
const result = await invoke<{
code: number;
data?: string;
message: string;
}>('save_project_asset', {
projectId,
filename: `cover_${Date.now()}.png`,
base64Data: base64,
});
if (result.code !== 200 || !result.data) {
throw new Error(result.message);
}
const coverDesignPoints = usePointStore.getState().getRule('cover_design')?.points || 2;
await pointsApi.consumePoints({
points: coverDesignPoints,
sourceType: 'cover_design',
sourceId: `cover_design_${useAuthStore.getState().user?.id || 'unknown'}_${Date.now()}`,
description: '封面设计',
});
await usePointStore.getState().fetchBalance();
setCoverPath(result.data);
setCoverConfig({
template: config.template,
backgroundImage: config.backgroundImage,
mainTitle: config.mainTitle.trim(),
subtitle: config.subtitle.trim(),
avatarImage: config.avatarImage,
});
useProjectStore.getState().clearStepDirty(5);
useProjectStore.getState().markStepsDirty(5);
useProgressStore.getState().success('封面设计完成', coverDesignPoints);
} catch (error: unknown) {
const message = (error instanceof Error ? error.message : String(error)) || '封面设计失败';
console.error('封面设计失败:', error);
if (message.includes('402') || message.includes('积分不足')) {
useProgressStore.getState().hide();
setShowPointsModal(true);
} else {
useProgressStore.getState().error(message);
}
} finally {
setIsDesigning(false);
}
};
// 封面形象库
const { coverAvatars, loadCoverAvatars } = useCoverAvatarStore();
useEffect(() => {
loadCoverAvatars();
}, [loadCoverAvatars]);
const currentAvatar = coverAvatars.find(a => a.imageUrl === config.avatarImage);
const canGenerate = !!config.mainTitle.trim();
return (
<div className="step-layout cover-design-variant cover-design">
{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);
}}
/>
{/* 背景图弹窗 */}
<Modal
open={bgModalOpen}
onClose={() => setBgModalOpen(false)}
title="选择背景图片"
width="560px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="modal-bg-grid">
{/* 第 1 个卡片:本地上传 */}
<button
className="modal-bg-thumb modal-bg-upload"
onClick={() => localBgRef.current?.click()}
title="使用本地图片作为背景"
>
<div className="upload-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17,8 12,3 7,8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span></span>
</div>
</button>
<input
ref={localBgRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleLocalBgUpload}
/>
{modalBgs.map((bg) => (
<button
key={bg.id}
className={`modal-bg-thumb ${config.backgroundImage === bg.src ? 'active' : ''}`}
onClick={() => {
setConfig((prev) => ({ ...prev, backgroundImage: bg.src }));
setBgModalOpen(false);
}}
title={bg.name}
>
<img
src={bg.src}
alt={bg.name}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {parent.classList.add('modal-bg-error');}
}}
/>
</button>
))}
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button
className="btn btn-secondary btn-sm"
onClick={shuffleModalBgs}
disabled={bgList.length <= 3}
>
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setBgModalOpen(false)}></button>
</div>
</div>
</Modal>
{/* 封面形象弹窗 */}
<Modal
open={avatarModalOpen}
onClose={() => setAvatarModalOpen(false)}
title="选择封面形象"
width="480px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="modal-avatar-grid">
<button
className={`modal-avatar-thumb ${config.avatarImage === null ? 'active' : ''}`}
onClick={() => {
setConfig((prev) => ({ ...prev, avatarImage: null }));
setAvatarModalOpen(false);
}}
>
<div className="avatar-placeholder">使</div>
</button>
{coverAvatars.map((avatar) => (
<button
key={avatar.id}
className={`modal-avatar-thumb ${config.avatarImage === avatar.imageUrl ? 'active' : ''}`}
onClick={() => {
setConfig((prev) => ({ ...prev, avatarImage: avatar.imageUrl }));
setAvatarModalOpen(false);
}}
title={avatar.name}
>
<img
src={avatar.imageUrl}
alt={avatar.name}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {parent.classList.add('avatar-thumb-error');}
}}
/>
</button>
))}
</div>
{coverAvatars.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 'var(--font-xs)', padding: '8px 0' }}>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAvatarModalOpen(false)}></button>
</div>
</div>
</Modal>
{/* 左侧:配置区域 */}
<div className="step-panel-left">
<div
className="cover-config-scroll"
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-lg)',
}}
>
{/* 视觉素材:背景图 + 封面形象 同一行 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="visual-assets-row">
{/* 背景图卡片 */}
<button
className="visual-asset-card"
onClick={openBgModal}
title="点击更换背景"
>
<div className="visual-asset-image-wrap">
<div className="visual-asset-label"></div>
{config.backgroundImage ? (
<img
src={config.backgroundImage}
alt="背景"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="visual-asset-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span></span>
</div>
)}
<div className="visual-asset-overlay">
<span className="visual-asset-overlay-text">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</span>
</div>
</div>
</button>
{/* 封面形象卡片 */}
<button
className="visual-asset-card"
onClick={() => setAvatarModalOpen(true)}
title="点击更换形象"
>
<div className="visual-asset-image-wrap">
<div className="visual-asset-label"></div>
{currentAvatar ? (
<img
src={currentAvatar.imageUrl}
alt={currentAvatar.name}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="visual-asset-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span></span>
</div>
)}
<div className="visual-asset-overlay">
<span className="visual-asset-overlay-text">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</span>
</div>
</div>
</button>
</div>
</div>
{/* 文案配置 */}
<div className="panel-section">
<label className="panel-label"></label>
<div className="title-input-row">
<input
type="text"
className="input"
placeholder="输入主标题"
value={config.mainTitle}
maxLength={6}
onChange={(e) =>
setConfig((prev) => ({ ...prev, mainTitle: e.target.value }))
}
/>
<button
className="btn btn-ghost title-generate-btn"
onClick={() => handleGenerateTitle('main')}
disabled={isGeneratingMainTitle}
title="根据脚本内容智能生成主标题"
>
{isGeneratingMainTitle ? '生成中...' : '智能生成'}
</button>
</div>
<div className="title-input-row" style={{ marginTop: 8 }}>
<textarea
className="input subtitle-textarea"
placeholder="输入副标题"
value={config.subtitle}
maxLength={52}
rows={2}
onChange={(e) =>
setConfig((prev) => ({ ...prev, subtitle: e.target.value }))
}
/>
<button
className="btn btn-ghost title-generate-btn"
onClick={() => handleGenerateTitle('sub')}
disabled={isGeneratingSubTitle}
title="根据脚本内容智能生成副标题"
>
{isGeneratingSubTitle ? '生成中...' : '智能生成'}
</button>
</div>
</div>
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 12, marginTop: 'auto', flexShrink: 0 }}>
<button
className="btn btn-primary"
disabled={isDesigning || !canGenerate}
onClick={handleGenerate}
style={{ flex: 1 }}
>
{isDesigning
? '生成中...'
: coverPath
? `重新生成(${usePointStore.getState().getRule('cover_design')?.points || 2}积分)`
: `生成封面(${usePointStore.getState().getRule('cover_design')?.points || 2}积分)`}
</button>
{coverPath && (
<button
className="btn btn-secondary"
onClick={async () => {
try {
const filename = coverPath.split(/[\\/]/).pop() || 'cover.png';
const targetPath = await save({
defaultPath: filename,
filters: [{ name: '图片', extensions: ['png'] }],
});
if (!targetPath) {return;}
const res = await invoke<{ code: number; data?: string; message: string }>('export_product', {
sourcePath: coverPath,
targetPath,
});
if (res.code === 200) {
toast.success('封面导出成功');
} else {
toast.error(res.message || '导出失败');
}
} catch {
toast.error('导出失败');
}
}}
style={{ flex: 1 }}
>
</button>
)}
</div>
</div>
{/* 右侧:竖屏预览 */}
<div className="step-panel-right video-gen-right">
<div className="video-preview-area">
<div className="video-preview-header"></div>
<div className="video-preview-wrapper">
<div className="video-preview-container">
<canvas
ref={canvasRef}
style={{
width: '337px',
height: '600px',
display: 'block',
}}
/>
{!config.backgroundImage && (
<div className="cover-preview-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span></span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}