fix: 视频创作按钮状态管理、积分计算、封面背景图渲染
- 统一6个步骤任务按钮状态:生成中只disabled,不做文字变化 - 封面设计新增isDesigning loading state - 进度弹窗去掉(x/y)数量显示 - 视频生成积分统一用配音音频时长口径,新增dubbingAudioDuration - 封面背景图URL数据修复SQL - 修复Fabric.js 7.x中originX/originY默认CENTER导致图片位置偏移 - 未选背景图时预览区显示提示 - 背景图加载失败时显示占位文字
This commit is contained in:
@@ -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 || '/%';
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -93,6 +93,7 @@ export interface ProjectMeta {
|
||||
// === 配音 ===
|
||||
dubbingAudioUrl?: string;
|
||||
dubbingAudioPath?: string;
|
||||
dubbingAudioDuration?: number;
|
||||
voiceSpeed?: number;
|
||||
voiceVolume?: number;
|
||||
voicePitch?: number;
|
||||
|
||||
Reference in New Issue
Block a user