diff --git a/scripts/fix_cover_background_url.sql b/scripts/fix_cover_background_url.sql new file mode 100644 index 0000000..f167ea1 --- /dev/null +++ b/scripts/fix_cover_background_url.sql @@ -0,0 +1,15 @@ +-- 修复封面背景图 URL:补充 script_code 子目录 +-- ================================================ +-- 问题:数据库中 url 字段存的路径缺少按 script_code 分组的子目录 +-- 例如:meijiaka-zy/cover_templete/xxx.jpg +-- 应为:meijiaka-zy/cover_templete/bk/xxx.jpg(script_code='bk' 时) +-- +-- 执行前建议先备份或预览受影响记录: +-- SELECT id, script_code, url FROM mjk_cover_backgrounds +-- WHERE url LIKE '%/cover_templete/%' +-- AND url NOT LIKE '%/cover_templete/' || script_code || '/%'; + +UPDATE mjk_cover_backgrounds +SET url = REPLACE(url, '/cover_templete/', '/cover_templete/' || script_code || '/') +WHERE url LIKE '%/cover_templete/%' + AND url NOT LIKE '%/cover_templete/' || script_code || '/%'; diff --git a/tauri-app/src/hooks/useCoverFabric.ts b/tauri-app/src/hooks/useCoverFabric.ts index 2cfba3e..7452501 100644 --- a/tauri-app/src/hooks/useCoverFabric.ts +++ b/tauri-app/src/hooks/useCoverFabric.ts @@ -203,6 +203,8 @@ export function useCoverFabric() { fabricImg.set({ left: (CANVAS_WIDTH - scaledWidth) / 2, top: (CANVAS_HEIGHT - scaledHeight) / 2, + originX: 'left', + originY: 'top', selectable: false, evented: false, }); diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.css b/tauri-app/src/pages/VideoCreation/CoverDesign.css index 8f31cd5..5c01c73 100644 --- a/tauri-app/src/pages/VideoCreation/CoverDesign.css +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.css @@ -233,6 +233,19 @@ border: 1px solid var(--border-light); } +.cover-preview-placeholder { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + color: rgba(255, 255, 255, 0.4); + font-size: 14px; + pointer-events: none; +} + .video-placeholder { width: 100%; height: 100%; @@ -320,6 +333,34 @@ display: block; } +.bg-thumb-error { + background: var(--bg-secondary, #f5f5f5); + display: flex; + align-items: center; + justify-content: center; +} + +.bg-thumb-error::after { + content: '图片加载失败'; + font-size: 12px; + color: #999; + padding: 0 8px; + text-align: center; +} + +.bg-thumb-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary, #f5f5f5); + font-size: 12px; + color: #999; + text-align: center; + padding: 0 8px; +} + /* 标题输入框行(输入框 + 生成按钮) */ .title-input-row { diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx index 5f4272e..79eacb1 100644 --- a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx @@ -59,6 +59,7 @@ function pickThree(all: BgImage[], lastIds: Set): BgImage[] { 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 projectId = getCurrentProjectId(); @@ -76,6 +77,7 @@ export default function CoverDesign() { }); 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); @@ -227,6 +229,7 @@ export default function CoverDesign() { } setShowPointsModal(false); + setIsDesigning(true); useProgressStore.getState().show('封面设计'); try { @@ -296,6 +299,8 @@ export default function CoverDesign() { } else { useProgressStore.getState().error(message); } + } finally { + setIsDesigning(false); } }; @@ -371,7 +376,21 @@ export default function CoverDesign() { onClick={() => setConfig((prev) => ({ ...prev, backgroundImage: bg.src }))} title={bg.name} > - {bg.name} + {bg.src ? ( + {bg.name} { + (e.target as HTMLImageElement).style.display = 'none'; + const parent = (e.target as HTMLImageElement).parentElement; + if (parent) { + parent.classList.add('bg-thumb-error'); + } + }} + /> + ) : ( +
{bg.name}
+ )} )) )} @@ -433,11 +452,11 @@ export default function CoverDesign() { {/* 生成按钮 */} @@ -455,6 +474,16 @@ export default function CoverDesign() { display: 'block', }} /> + {!config.backgroundImage && ( +
+ + + + + + 请先选择背景图片 +
+ )} diff --git a/tauri-app/src/pages/VideoCreation/ScriptCreation.tsx b/tauri-app/src/pages/VideoCreation/ScriptCreation.tsx index 153aab2..fd46055 100644 --- a/tauri-app/src/pages/VideoCreation/ScriptCreation.tsx +++ b/tauri-app/src/pages/VideoCreation/ScriptCreation.tsx @@ -373,38 +373,19 @@ export default function ScriptCreation() { onClick={handleGenerate} disabled={generating} > - {generating ? ( - <> - - - - 脚本生成中... - - ) : ( - <> - - - - {generated ? `重新生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)` : `生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)`} - - )} + + + + {generated ? `重新生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)` : `生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)`} {/* 生成进度由全局 ProgressModal 统一展示 */} diff --git a/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx b/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx index 484db3a..0f2c4e6 100644 --- a/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx +++ b/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx @@ -616,7 +616,7 @@ export default function SubtitleBurning() { @@ -634,7 +634,7 @@ export default function SubtitleBurning() { onClick={handleBurn} disabled={isBurning || !alignment?.utterances?.length} > - {isBurning ? '压制中...' : `压制字幕(${usePointStore.getState().getRule('subtitle_burn')?.points || 2}积分)`} + 压制字幕({usePointStore.getState().getRule('subtitle_burn')?.points || 2}积分) )} diff --git a/tauri-app/src/pages/VideoCreation/VideoCompose.tsx b/tauri-app/src/pages/VideoCreation/VideoCompose.tsx index 021b785..efd5b89 100644 --- a/tauri-app/src/pages/VideoCreation/VideoCompose.tsx +++ b/tauri-app/src/pages/VideoCreation/VideoCompose.tsx @@ -328,38 +328,19 @@ export default function VideoCompose() { onClick={handleStart} disabled={compositing || !coverPath || !burnedVideoPath} > - {compositing ? ( - <> - - - - 合成中... - - ) : ( - <> - - - - 合成视频({usePointStore.getState().getRule('compose')?.points || 5}积分) - - )} + + + + 合成视频({usePointStore.getState().getRule('compose')?.points || 5}积分) ) : (
diff --git a/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx b/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx index 93098c7..c274832 100644 --- a/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx +++ b/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx @@ -340,14 +340,32 @@ export default function VoiceSynthesis() { name: `配音合成-${segments.length}段`, voiceId: currentVoiceId || 'tianxin_xiaoling', duration: 0, }); + // 获取音频时长(用于后续视频生成积分预估,确保前后端口径一致) + const audioDuration = await new Promise((resolve, reject) => { + const audio = new Audio(qiniuUrl); + const timeoutId = setTimeout(() => { + reject(new Error('读取音频时长超时')); + }, 10000); + audio.onloadedmetadata = () => { + clearTimeout(timeoutId); + resolve(audio.duration); + }; + audio.onerror = () => { + clearTimeout(timeoutId); + reject(new Error('读取音频时长失败')); + }; + }); + // 更新 projectStore 和 meta.json(项目级配音信息) useProjectStore.setState({ dubbingAudioUrl: qiniuUrl, dubbingAudioPath: meta.filePath, + dubbingAudioDuration: audioDuration, }); await saveMetaToLocalFile({ dubbingAudioUrl: qiniuUrl, dubbingAudioPath: meta.filePath, + dubbingAudioDuration: audioDuration, }); // dubbingAudioUrl 已通过 store 持久化,无需再存到组件本地 state @@ -528,12 +546,12 @@ export default function VoiceSynthesis() {
{!hasGeneratedAudio ? ( ) : (
); diff --git a/tauri-app/src/pages/VideoGeneration/hooks/useVideoGeneration.ts b/tauri-app/src/pages/VideoGeneration/hooks/useVideoGeneration.ts index 5bf6cc4..3aa4d19 100644 --- a/tauri-app/src/pages/VideoGeneration/hooks/useVideoGeneration.ts +++ b/tauri-app/src/pages/VideoGeneration/hooks/useVideoGeneration.ts @@ -115,7 +115,7 @@ export function useVideoGeneration({ // ========== Step 1: 对每个 segment 提交视频生成任务 ========== for (let i = 0; i < segmentShots.length; i++) { const shot = segmentShots[i]; - progress.update(`正在提交视频生成任务 (${i + 1}/${segmentShots.length})...`); + progress.update('正在提交视频生成任务...'); const duration = shot.actualDuration!; const durStr = typeof shot.duration === 'string' ? shot.duration : '5s'; @@ -274,7 +274,7 @@ export function useVideoGeneration({ const duration = (assigned.assignedEnd - assigned.assignedStart) / 1000; if (shot.type === 'empty_shot') { - progress.update(`正在处理空镜片段 (${i + 1}/${currentSegmentsForCompose.length})...`); + progress.update('正在处理空镜片段...'); const matched = materialMatchMap[String(shot.id)]; if (!matched) { throw new Error(`空镜 ${shot.id} 未匹配到素材`); @@ -299,7 +299,7 @@ export function useVideoGeneration({ clipPaths.push(clipPathRes.data); updateSegment(shot.id, { clipVideoPath: clipPathRes.data }); } else { - progress.update(`正在处理分镜片段 (${i + 1}/${currentSegmentsForCompose.length})...`); + progress.update('正在处理分镜片段...'); const segDuration = shot.actualDuration!; // 优先使用视频生成后的结果(已经是精确片段,startTime=0) @@ -355,21 +355,29 @@ export function useVideoGeneration({ //(callback 更新后端 registry),但前端不会再执行扣费逻辑。 // 当前接受此风险:桌面应用用户通常会等待操作完成,关闭概率较低。 // 后续若需根治,应将扣费移到后端 vidu_callback 中(每个分镜独立扣费)。 - const audioUrl = await getLocalFileUrl(dubbingAudioPath); - const totalAudioDuration = await new Promise((resolve, reject) => { - const audio = new Audio(audioUrl); - const timeoutId = setTimeout(() => { - reject(new Error('读取音频时长超时,请检查音频文件')); - }, 15000); - audio.onloadedmetadata = () => { - clearTimeout(timeoutId); - resolve(audio.duration); - }; - audio.onerror = () => { - clearTimeout(timeoutId); - reject(new Error('读取音频时长失败')); - }; - }); + // + // 优先使用 store 中已保存的音频时长(与按钮预估完全一致),降级时实时读取 + const storeAudioDuration = useProjectStore.getState().dubbingAudioDuration; + let totalAudioDuration: number; + if (storeAudioDuration && storeAudioDuration > 0) { + totalAudioDuration = storeAudioDuration; + } else { + const audioUrl = await getLocalFileUrl(dubbingAudioPath); + totalAudioDuration = await new Promise((resolve, reject) => { + const audio = new Audio(audioUrl); + const timeoutId = setTimeout(() => { + reject(new Error('读取音频时长超时,请检查音频文件')); + }, 15000); + audio.onloadedmetadata = () => { + clearTimeout(timeoutId); + resolve(audio.duration); + }; + audio.onerror = () => { + clearTimeout(timeoutId); + reject(new Error('读取音频时长失败')); + }; + }); + } const actualVideoPoints = Math.ceil(totalAudioDuration) * videoMultiplier; await pointsApi.consumePoints({ points: actualVideoPoints, diff --git a/tauri-app/src/pages/VideoGeneration/index.tsx b/tauri-app/src/pages/VideoGeneration/index.tsx index ead1b75..91df465 100644 --- a/tauri-app/src/pages/VideoGeneration/index.tsx +++ b/tauri-app/src/pages/VideoGeneration/index.tsx @@ -58,19 +58,24 @@ export default function VideoGeneration() { }); }, []); - // 视频生成积分(秒数向上取整 × multiplier 积分/秒,优先用 actualDuration) + const dubbingAudioDuration = useProjectStore(state => state.dubbingAudioDuration); + + // 视频生成积分:统一用完整配音音频时长作为唯一口径(与后端预检、实际扣费保持一致) const estimatedVideoPoints = useMemo(() => { + // 优先使用已保存的配音音频实际时长,确保预估 = 实际扣费 + if (dubbingAudioDuration && dubbingAudioDuration > 0) { + return Math.ceil(dubbingAudioDuration) * videoMultiplier; + } + // 降级:按分镜 actualDuration 累加(配音后已更新) const totalDur = shots.reduce((sum, shot) => { - // 优先使用实际时长(配音合成后已更新) if (shot.actualDuration && shot.actualDuration > 0) { return sum + shot.actualDuration; } - // 回退到脚本的预估时长 const durStr = typeof shot.duration === 'string' ? shot.duration : '5s'; return sum + parseFloat(durStr.replace(/[^0-9.]/g, '') || '0'); }, 0); return Math.ceil(totalDur) * videoMultiplier; - }, [shots, videoMultiplier]); + }, [shots, videoMultiplier, dubbingAudioDuration]); const [activeScene, setActiveScene] = useState(1); diff --git a/tauri-app/src/store/projectStore.ts b/tauri-app/src/store/projectStore.ts index 4156e55..2fb5b6c 100644 --- a/tauri-app/src/store/projectStore.ts +++ b/tauri-app/src/store/projectStore.ts @@ -27,6 +27,7 @@ interface ProjectActions { setCoverPath: (_path: string | undefined) => void; setCoverConfig: (_config: ProjectState['coverConfig']) => void; setExportedAt: (_timestamp: number | undefined) => void; + setDubbingAudioDuration: (_duration: number | undefined) => void; setVoiceSpeed: (_speed: number) => void; setVoiceVolume: (_volume: number) => void; setVoicePitch: (_pitch: number) => void; @@ -210,6 +211,13 @@ export const useProjectStore = create()( }); saveMetaToLocalFile({ exportedAt: timestamp }); }, + setDubbingAudioDuration: duration => { + set(state => { + state.dubbingAudioDuration = duration; + state.updatedAt = Date.now(); + }); + saveMetaToLocalFile({ dubbingAudioDuration: duration }); + }, setVoiceSpeed: speed => set(state => { state.voiceSpeed = speed; diff --git a/tauri-app/src/types/project.ts b/tauri-app/src/types/project.ts index 6703286..f2195a3 100644 --- a/tauri-app/src/types/project.ts +++ b/tauri-app/src/types/project.ts @@ -93,6 +93,7 @@ export interface ProjectMeta { // === 配音 === dubbingAudioUrl?: string; dubbingAudioPath?: string; + dubbingAudioDuration?: number; voiceSpeed?: number; voiceVolume?: number; voicePitch?: number;