fix: 视频创作按钮状态管理、积分计算、封面背景图渲染

- 统一6个步骤任务按钮状态:生成中只disabled,不做文字变化
- 封面设计新增isDesigning loading state
- 进度弹窗去掉(x/y)数量显示
- 视频生成积分统一用配音音频时长口径,新增dubbingAudioDuration
- 封面背景图URL数据修复SQL
- 修复Fabric.js 7.x中originX/originY默认CENTER导致图片位置偏移
- 未选背景图时预览区显示提示
- 背景图加载失败时显示占位文字
This commit is contained in:
小鱼开发
2026-05-18 14:09:24 +08:00
parent ddec4a607b
commit 51fc7641b8
13 changed files with 186 additions and 131 deletions
+15
View File
@@ -0,0 +1,15 @@
-- 修复封面背景图 URL:补充 script_code 子目录
-- ================================================
-- 问题:数据库中 url 字段存的路径缺少按 script_code 分组的子目录
-- 例如:meijiaka-zy/cover_templete/xxx.jpg
-- 应为:meijiaka-zy/cover_templete/bk/xxx.jpgscript_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 || '/%';
+2
View File
@@ -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,
});
@@ -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 {
@@ -59,6 +59,7 @@ function pickThree(all: BgImage[], lastIds: Set<string>): 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}
>
<img src={bg.src} alt={bg.name} />
{bg.src ? (
<img
src={bg.src}
alt={bg.name}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
parent.classList.add('bg-thumb-error');
}
}}
/>
) : (
<div className="bg-thumb-placeholder">{bg.name}</div>
)}
</button>
))
)}
@@ -433,11 +452,11 @@ export default function CoverDesign() {
{/* 生成按钮 */}
<button
className="btn btn-primary cover-generate-btn"
disabled={!canGenerate}
disabled={isDesigning || !canGenerate}
onClick={handleGenerate}
style={{ marginTop: 'auto', flexShrink: 0 }}
>
{usePointStore.getState().getRule('cover_design')?.points || 2}
{coverPath ? '重新设计封面' : '立即设计封面'}{usePointStore.getState().getRule('cover_design')?.points || 2}
</button>
</div>
@@ -455,6 +474,16 @@ export default function CoverDesign() {
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>
@@ -373,38 +373,19 @@ export default function ScriptCreation() {
onClick={handleGenerate}
disabled={generating}
>
{generating ? (
<>
<svg
width="18"
height="18"
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>
...
</>
) : (
<>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
{generated ? `重新生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)` : `生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)`}
</>
)}
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
{generated ? `重新生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)` : `生成脚本(${usePointStore.getState().getRule('script')?.points || 5}积分)`}
</button>
{/* 生成进度由全局 ProgressModal 统一展示 */}
@@ -616,7 +616,7 @@ export default function SubtitleBurning() {
<button
className="btn btn-primary burn-btn"
onClick={handleBurn}
disabled={!alignment?.utterances?.length}
disabled={isBurning || !alignment?.utterances?.length}
>
{usePointStore.getState().getRule('subtitle_burn')?.points || 2}
</button>
@@ -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}
</button>
)}
</div>
@@ -328,38 +328,19 @@ export default function VideoCompose() {
onClick={handleStart}
disabled={compositing || !coverPath || !burnedVideoPath}
>
{compositing ? (
<>
<svg
width="18"
height="18"
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>
...
</>
) : (
<>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
{usePointStore.getState().getRule('compose')?.points || 5}
</>
)}
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
{usePointStore.getState().getRule('compose')?.points || 5}
</button>
) : (
<div style={{ display: 'flex', gap: 'var(--spacing-md)', width: '100%' }}>
@@ -340,14 +340,32 @@ export default function VoiceSynthesis() {
name: `配音合成-${segments.length}`, voiceId: currentVoiceId || 'tianxin_xiaoling', duration: 0,
});
// 获取音频时长(用于后续视频生成积分预估,确保前后端口径一致)
const audioDuration = await new Promise<number>((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() {
<div className="voice-generate-wrap">
{!hasGeneratedAudio ? (
<button className="btn btn-primary generate-btn" onClick={handleGenerate} disabled={isGenerating || !mergedText.trim()}>
{isGenerating ? '合成中...' : `合成配音(预计消耗 ${estimatedTtsPoints.min}~${estimatedTtsPoints.max} 积分)`}
{estimatedTtsPoints.min}~{estimatedTtsPoints.max}
</button>
) : (
<div className="voice-generate-btns">
<button className="btn btn-secondary generate-btn" onClick={handleGenerate} disabled={isGenerating || !mergedText.trim()}>
{isGenerating ? '合成中...' : `重新生成(预计消耗 ${estimatedTtsPoints.min}~${estimatedTtsPoints.max} 积分)`}
{estimatedTtsPoints.min}~{estimatedTtsPoints.max}
</button>
<button
className="btn btn-primary generate-btn"
@@ -22,12 +22,6 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
onGenerate,
onPreview,
}) => {
const spinnerSvg = (
<svg width="18" height="18" 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>
);
const generateIcon = (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3" />
@@ -51,22 +45,8 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
onClick={onGenerate}
disabled={isDisabled}
>
{isComposing ? (
<>
{spinnerSvg}
...
</>
) : !videoMultiplierLoaded ? (
<>
{spinnerSvg}
...
</>
) : (
<>
{refreshIcon}
{estimatedVideoPoints}
</>
)}
{refreshIcon}
{estimatedVideoPoints}
</button>
<button
className="btn btn-secondary"
@@ -88,22 +68,8 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
onClick={onGenerate}
disabled={isDisabled}
>
{isComposing ? (
<>
{spinnerSvg}
...
</>
) : !videoMultiplierLoaded ? (
<>
{spinnerSvg}
...
</>
) : (
<>
{generateIcon}
{estimatedVideoPoints}
</>
)}
{generateIcon}
{estimatedVideoPoints}
</button>
</div>
);
@@ -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<number>((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<number>((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,
@@ -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<number>(1);
+8
View File
@@ -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<ProjectStore>()(
});
saveMetaToLocalFile({ exportedAt: timestamp });
},
setDubbingAudioDuration: duration => {
set(state => {
state.dubbingAudioDuration = duration;
state.updatedAt = Date.now();
});
saveMetaToLocalFile({ dubbingAudioDuration: duration });
},
setVoiceSpeed: speed =>
set(state => {
state.voiceSpeed = speed;
+1
View File
@@ -93,6 +93,7 @@ export interface ProjectMeta {
// === 配音 ===
dubbingAudioUrl?: string;
dubbingAudioPath?: string;
dubbingAudioDuration?: number;
voiceSpeed?: number;
voiceVolume?: number;
voicePitch?: number;