Files
meijiaka-zy/tauri-app/src/store/projectStore.ts
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

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);
}