ca4a0b1303
- 用户数据隔离:所有用户数据按 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
397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
/**
|
|
* Project Store - Zustand
|
|
* =======================
|
|
*/
|
|
|
|
import { create } from 'zustand';
|
|
import { immer } from 'zustand/middleware/immer';
|
|
import { persist } from 'zustand/middleware';
|
|
import { localProjectApi, generateProjectId, getCurrentProjectId, setCurrentProjectId } from '../api/modules/localStorage';
|
|
import type { ProjectState, ScriptShot, MetaOverrides } from '../types/project';
|
|
import { buildProjectMeta, migrateMeta, BLANK_META_OVERRIDES } from '../utils/projectMeta';
|
|
|
|
export type { ScriptShot } from '../types/project';
|
|
|
|
interface ProjectActions {
|
|
// 分镜操作
|
|
updateSegment: (_id: number, _data: Partial<ScriptShot>) => void;
|
|
setSegments: (_segments: ScriptShot[]) => void;
|
|
|
|
// 其他操作
|
|
setTopic: (_topic: string) => void;
|
|
setTitle: (_title: string) => void;
|
|
setCurrentStep: (_step: number) => void;
|
|
setAvatarMaterial: (_path?: string, _name?: string, _duration?: number) => void;
|
|
setSelectedVoiceId: (_voiceId: string) => void;
|
|
setFinalVideoPath: (_path: string | undefined) => void;
|
|
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;
|
|
setBgmMusic: (_id: number | undefined, _title: string | undefined, _path: string | undefined) => void;
|
|
setBgmVolume: (_volume: number) => void;
|
|
setCategoryCode: (_code: string) => void;
|
|
setFilename: (_filename: string) => void;
|
|
setIsLoading: (_loading: boolean) => void;
|
|
setHasHydrated: (_hydrated: boolean) => void;
|
|
markStepsDirty: (_fromStep: number) => void;
|
|
clearStepDirty: (_stepId: number) => void;
|
|
reset: () => void;
|
|
}
|
|
|
|
|
|
interface ProjectGetters {
|
|
hasHydrated: boolean;
|
|
}
|
|
|
|
type ProjectStore = ProjectState &
|
|
ProjectActions &
|
|
ProjectGetters;
|
|
|
|
const initialState: Omit<
|
|
ProjectStore,
|
|
keyof ProjectActions | keyof ProjectGetters
|
|
> = {
|
|
id: '',
|
|
title: '',
|
|
status: 'draft',
|
|
segments: [],
|
|
currentStep: 1,
|
|
createdAt: 0,
|
|
updatedAt: 0,
|
|
version: 1,
|
|
_isLoading: false,
|
|
voiceSpeed: 1.0,
|
|
voiceVolume: 0,
|
|
_hasHydrated: false,
|
|
};
|
|
|
|
// 元数据保存队列(避免并发覆盖)
|
|
let metaSavePromise: Promise<void> = Promise.resolve();
|
|
|
|
/**
|
|
* 立即保存项目元数据(不防抖,不保存 segments)
|
|
* 用于 coverPath / finalVideoPath / exportedAt 等关键字段的持久化
|
|
*
|
|
* 实现细节:
|
|
* 1. 先读取磁盘上的现有 meta.json,避免覆盖其他字段
|
|
* 2. 只覆盖传入的 overrides 字段(显式传 undefined 表示清除)
|
|
* 3. 通过 Promise 链串行化,避免并发写导致数据丢失
|
|
*/
|
|
export async function saveMetaToLocalFile(overrides: MetaOverrides = {}) {
|
|
const projectId = getCurrentProjectId();
|
|
if (!projectId) {
|
|
console.error('[ProjectStore] Cannot save meta: no project ID');
|
|
return;
|
|
}
|
|
|
|
const task = async () => {
|
|
try {
|
|
const rawExisting = await localProjectApi.loadMeta(projectId);
|
|
const existing = rawExisting ? migrateMeta(rawExisting) : null;
|
|
|
|
// 核心:buildProjectMeta 自动合并所有字段,无需逐个判断
|
|
const meta = buildProjectMeta(projectId, existing, overrides);
|
|
|
|
await localProjectApi.saveMeta(projectId, meta);
|
|
} catch (err) {
|
|
console.error('[ProjectStore] Save meta failed:', err);
|
|
}
|
|
};
|
|
|
|
metaSavePromise = metaSavePromise.then(task).catch((err) => {
|
|
console.error('[ProjectStore] Save meta failed:', err);
|
|
throw err;
|
|
});
|
|
await metaSavePromise;
|
|
}
|
|
|
|
// 当前项目 ID(内存中)
|
|
let currentProjectId: string = getCurrentProjectId();
|
|
|
|
export const useProjectStore = create<ProjectStore>()(
|
|
immer(
|
|
persist(
|
|
(set, get) => ({
|
|
...initialState,
|
|
|
|
get hasHydrated() {
|
|
return get()._hasHydrated;
|
|
},
|
|
|
|
// 获取当前项目 ID
|
|
get currentProjectId() {
|
|
return currentProjectId;
|
|
},
|
|
|
|
setIsLoading: (loading: boolean) =>
|
|
set(state => {
|
|
state._isLoading = loading;
|
|
}),
|
|
|
|
setHasHydrated: (hydrated: boolean) =>
|
|
set(state => {
|
|
state._hasHydrated = hydrated;
|
|
}),
|
|
|
|
updateSegment: (id, data) =>
|
|
set(state => {
|
|
const shotIndex = state.segments.findIndex(s => s.id === id);
|
|
if (shotIndex !== -1) {
|
|
// 直接修改 draft 属性,让 Immer 正确追踪数组元素变化
|
|
Object.assign(state.segments[shotIndex], data);
|
|
state.updatedAt = Date.now();
|
|
} else {
|
|
// no-op: 分镜不存在时不处理
|
|
}
|
|
// 自动保存已移除,数据将在点击下一步时统一落盘
|
|
}),
|
|
|
|
setSegments: segments =>
|
|
set(state => {
|
|
state.segments = segments;
|
|
state.updatedAt = Date.now();
|
|
// 自动保存已移除,数据将在点击下一步时统一落盘
|
|
}),
|
|
|
|
setTopic: topic =>
|
|
set(state => {
|
|
state.topic = topic;
|
|
}),
|
|
setTitle: title =>
|
|
set(state => {
|
|
state.title = title;
|
|
state.updatedAt = Date.now();
|
|
}),
|
|
|
|
setCurrentStep: step =>
|
|
set(state => {
|
|
state.currentStep = step;
|
|
}),
|
|
|
|
setAvatarMaterial: (path, name, duration) => {
|
|
set(state => {
|
|
state.avatarMaterialPath = path;
|
|
state.avatarMaterialName = name;
|
|
state.avatarMaterialDuration = duration;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ avatarMaterialPath: path, avatarMaterialName: name, avatarMaterialDuration: duration });
|
|
},
|
|
setSelectedVoiceId: voiceId => {
|
|
set(state => {
|
|
state.selectedVoiceId = voiceId;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
},
|
|
setFinalVideoPath: path => {
|
|
set(state => {
|
|
state.finalVideoPath = path;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ finalVideoPath: path });
|
|
},
|
|
setCoverPath: path => {
|
|
set(state => {
|
|
state.coverPath = path;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ coverPath: path });
|
|
},
|
|
setCoverConfig: config => {
|
|
set(state => {
|
|
state.coverConfig = config;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ coverConfig: config });
|
|
},
|
|
setExportedAt: timestamp => {
|
|
set(state => {
|
|
state.exportedAt = timestamp;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ exportedAt: timestamp });
|
|
},
|
|
setDubbingAudioDuration: duration => {
|
|
set(state => {
|
|
state.dubbingAudioDuration = duration;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ dubbingAudioDuration: duration });
|
|
},
|
|
setVoiceSpeed: speed =>
|
|
set(state => {
|
|
state.voiceSpeed = speed;
|
|
state.updatedAt = Date.now();
|
|
}),
|
|
setVoiceVolume: volume =>
|
|
set(state => {
|
|
state.voiceVolume = volume;
|
|
state.updatedAt = Date.now();
|
|
}),
|
|
setBgmMusic: (id, title, path) => {
|
|
set(state => {
|
|
state.bgmMusicId = id;
|
|
state.bgmMusicTitle = title;
|
|
state.bgmMusicPath = path;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ bgmMusicId: id, bgmMusicTitle: title, bgmMusicPath: path });
|
|
},
|
|
setBgmVolume: volume => {
|
|
set(state => {
|
|
state.bgmVolume = volume;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ bgmVolume: volume });
|
|
},
|
|
setCategoryCode: code => {
|
|
set(state => {
|
|
state.categoryCode = code;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ categoryCode: code });
|
|
},
|
|
setFilename: filename => {
|
|
set(state => {
|
|
state.filename = filename;
|
|
state.updatedAt = Date.now();
|
|
});
|
|
saveMetaToLocalFile({ filename });
|
|
},
|
|
|
|
markStepsDirty: (fromStep: number) => {
|
|
set(state => {
|
|
if (!state.stepDirtyFlags) {
|
|
state.stepDirtyFlags = {};
|
|
}
|
|
for (let i = fromStep + 1; i <= 6; i++) {
|
|
state.stepDirtyFlags[i] = true;
|
|
}
|
|
state.updatedAt = Date.now();
|
|
});
|
|
const flags = get().stepDirtyFlags || {};
|
|
const newFlags: Record<number, boolean> = {};
|
|
for (let i = fromStep + 1; i <= 6; i++) {
|
|
newFlags[i] = true;
|
|
}
|
|
saveMetaToLocalFile({ stepDirtyFlags: { ...flags, ...newFlags } });
|
|
},
|
|
|
|
clearStepDirty: (stepId: number) => {
|
|
set(state => {
|
|
if (state.stepDirtyFlags) {
|
|
state.stepDirtyFlags[stepId] = false;
|
|
}
|
|
state.updatedAt = Date.now();
|
|
});
|
|
const flags = get().stepDirtyFlags || {};
|
|
saveMetaToLocalFile({ stepDirtyFlags: { ...flags, [stepId]: false } });
|
|
},
|
|
|
|
reset: () => {
|
|
currentProjectId = '';
|
|
setCurrentProjectId('');
|
|
set({
|
|
...initialState,
|
|
...BLANK_META_OVERRIDES,
|
|
segments: [],
|
|
_hasHydrated: true,
|
|
});
|
|
},
|
|
|
|
}),
|
|
{
|
|
name: 'ai-video-project-config',
|
|
// 只保存配置,不保存项目数据(项目数据存文件)
|
|
partialize: (state) => ({
|
|
// 只保存 UI 状态,不保存项目业务数据
|
|
_isLoading: state._isLoading,
|
|
// 不保存: segments, finalVideoPath, coverPath 等
|
|
}),
|
|
onRehydrateStorage: () => state => {
|
|
if (state) {
|
|
state.setHasHydrated(true);
|
|
}
|
|
},
|
|
}
|
|
)
|
|
)
|
|
);
|
|
|
|
// 注:移除自动保存,改为在"进入第二步"时手动创建项目
|
|
// 符合文档逻辑:第一步完成进入第二步时创建项目文件
|
|
|
|
/**
|
|
* 初始化加载项目
|
|
* 应用启动时调用
|
|
*/
|
|
export async function initProjectStore(projectId?: string): Promise<void> {
|
|
const id = projectId || getCurrentProjectId();
|
|
currentProjectId = id;
|
|
setCurrentProjectId(id);
|
|
|
|
|
|
const { meta: rawMeta, segments } = await localProjectApi.loadProject(id);
|
|
|
|
if (rawMeta || segments.length > 0) {
|
|
const meta = rawMeta ? migrateMeta(rawMeta) : null;
|
|
|
|
// 先清空旧数据,再加载新项目数据,防止字段残留
|
|
useProjectStore.setState({
|
|
...BLANK_META_OVERRIDES,
|
|
segments: segments as ScriptShot[],
|
|
...(meta || {}),
|
|
_hasHydrated: true,
|
|
});
|
|
} else {
|
|
// 新项目
|
|
useProjectStore.setState({
|
|
...BLANK_META_OVERRIDES,
|
|
segments: [],
|
|
_hasHydrated: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 创建新项目
|
|
* @param topic 创作灵感主题,用于生成标题
|
|
* @param segments 可选的初始分镜数据(从第一步带入第二步时)
|
|
*/
|
|
export async function createNewProject(topic?: string, segments?: ScriptShot[]): Promise<string> {
|
|
const newId = generateProjectId();
|
|
currentProjectId = newId;
|
|
setCurrentProjectId(newId);
|
|
|
|
// 彻底重置 Store,防止旧项目数据残留
|
|
useProjectStore.setState({
|
|
...initialState,
|
|
...BLANK_META_OVERRIDES,
|
|
segments: segments || [],
|
|
_hasHydrated: true,
|
|
});
|
|
|
|
// 使用 buildProjectMeta 自动构建 meta(包含 title 生成逻辑)
|
|
const meta = buildProjectMeta(newId, null, { topic });
|
|
await localProjectApi.saveMeta(newId, meta);
|
|
|
|
return newId;
|
|
}
|
|
|
|
/**
|
|
* 切换项目
|
|
*/
|
|
export async function switchProject(projectId: string): Promise<void> {
|
|
// 切换项目 ID
|
|
currentProjectId = projectId;
|
|
setCurrentProjectId(projectId);
|
|
|
|
// 加载新项目
|
|
await initProjectStore(projectId);
|
|
}
|
|
|
|
|