| 1 | import type { DraftContentType, VideoModelType } from '@/api/ai/ai.types' |
| 2 | import type { MaterialGenerationParams } from '@/api/materials/material.types' |
| 3 | import type { PlatType } from '@/app/config/platConfig' |
| 4 | import isEqual from 'lodash/isEqual' |
| 5 | import { stripCaptionPromptSystemRequirement, stripDraftPromptLimits } from '@/components/draft-box/utils/promptLimits' |
| 6 | import { generateUUID } from '@/utils/common' |
| 7 | import { getOssUrl } from '@/utils/oss' |
| 8 | import { createPersistStore } from '@/utils/storage/createPersistStore' |
| 9 | |
| 10 | /** 可序列化的媒体信息(持久化用) */ |
| 11 | export interface IPersistedMedia { |
| 12 | id: string |
| 13 | url: string |
| 14 | type: 'image' | 'video' | 'audio' |
| 15 | name?: string |
| 16 | /** 媒体时长,用于恢复后的时长校验 */ |
| 17 | duration?: number |
| 18 | } |
| 19 | |
| 20 | export type ModelSelectionMode = 'single' | 'multiple' |
| 21 | |
| 22 | export interface VideoModelParams { |
| 23 | resolution?: string |
| 24 | duration?: number |
| 25 | aspectRatio?: string |
| 26 | } |
| 27 | |
| 28 | /** 单个草稿箱的生成配置 */ |
| 29 | export interface DraftBoxConfig { |
| 30 | aspectRatio: string |
| 31 | duration: number |
| 32 | resolution: string |
| 33 | quantity: number |
| 34 | modelType: VideoModelType |
| 35 | selectedVideoModels: VideoModelType[] |
| 36 | videoModelSelectionMode: ModelSelectionMode |
| 37 | videoModelResolutions: Record<string, string> |
| 38 | videoModelParams: Record<string, VideoModelParams> |
| 39 | contentType: DraftContentType |
| 40 | imageModel: string |
| 41 | selectedImageModels: string[] |
| 42 | imageModelSelectionMode: ModelSelectionMode |
| 43 | imageCount: number |
| 44 | imageSize: string |
| 45 | selectedPlatforms: PlatType[] |
| 46 | /** 用户上传的媒体持久化 */ |
| 47 | persistedMedias: IPersistedMedia[] |
| 48 | /** AI 生成描述 */ |
| 49 | promptValue: string |
| 50 | /** 是否为草稿模式(true=生成完整草稿,false=仅生成视频/图片) */ |
| 51 | isDraftMode: boolean |
| 52 | /** 文案生成提示词,用于标题、描述和话题 */ |
| 53 | captionPrompt: string |
| 54 | /** 文案要求区域是否展开 */ |
| 55 | captionPromptOpen: boolean |
| 56 | /** 系统内置文案要求,默认根据平台限制动态生成,支持用户覆盖 */ |
| 57 | captionSystemPrompt: string |
| 58 | /** 最近一次系统内置文案要求默认值,用于判断是否跟随动态默认值更新 */ |
| 59 | captionSystemPromptDefault: string |
| 60 | } |
| 61 | |
| 62 | /** 默认配置(与原 systemStore 一致) */ |
| 63 | const DEFAULT_CONFIG: DraftBoxConfig = { |
| 64 | aspectRatio: '9:16', |
| 65 | duration: 8, |
| 66 | resolution: '', |
| 67 | quantity: 1, |
| 68 | modelType: '' as VideoModelType, |
| 69 | selectedVideoModels: [], |
| 70 | videoModelSelectionMode: 'single', |
| 71 | videoModelResolutions: {}, |
| 72 | videoModelParams: {}, |
| 73 | contentType: 'video', |
| 74 | imageModel: 'nb2', |
| 75 | selectedImageModels: [], |
| 76 | imageModelSelectionMode: 'single', |
| 77 | imageCount: 3, |
| 78 | imageSize: '1K', |
| 79 | selectedPlatforms: [], |
| 80 | persistedMedias: [], |
| 81 | promptValue: '', |
| 82 | isDraftMode: true, |
| 83 | captionPrompt: '', |
| 84 | captionPromptOpen: true, |
| 85 | captionSystemPrompt: '', |
| 86 | captionSystemPromptDefault: '', |
| 87 | } |
| 88 | |
| 89 | interface IDraftBoxConfigStore { |
| 90 | configs: Record<string, DraftBoxConfig> |
| 91 | } |
| 92 | |
| 93 | const state: IDraftBoxConfigStore = { |
| 94 | configs: {}, |
| 95 | } |
| 96 | |
| 97 | const pendingConfigUpdates = new Map<string, Partial<DraftBoxConfig>>() |
| 98 | let pendingFlushScheduled = false |
| 99 | |
| 100 | function normalizePersistedMedias(params: MaterialGenerationParams): IPersistedMedia[] { |
| 101 | const dedupedMedias = new Map<string, IPersistedMedia>() |
| 102 | |
| 103 | params.imageUrls?.forEach((url) => { |
| 104 | const normalizedUrl = getOssUrl(url) |
| 105 | dedupedMedias.set(`image:${normalizedUrl}`, { |
| 106 | id: generateUUID(), |
| 107 | url: normalizedUrl, |
| 108 | type: 'image', |
| 109 | }) |
| 110 | }) |
| 111 | |
| 112 | params.videoUrls?.forEach((url) => { |
| 113 | const normalizedUrl = getOssUrl(url) |
| 114 | dedupedMedias.set(`video:${normalizedUrl}`, { |
| 115 | id: generateUUID(), |
| 116 | url: normalizedUrl, |
| 117 | type: 'video', |
| 118 | }) |
| 119 | }) |
| 120 | |
| 121 | params.audioUrls?.forEach((url) => { |
| 122 | const normalizedUrl = getOssUrl(url) |
| 123 | dedupedMedias.set(`audio:${normalizedUrl}`, { |
| 124 | id: generateUUID(), |
| 125 | url: normalizedUrl, |
| 126 | type: 'audio', |
| 127 | }) |
| 128 | }) |
| 129 | |
| 130 | return [...dedupedMedias.values()] |
| 131 | } |
| 132 | |
| 133 | function inferContentType(params: MaterialGenerationParams, current: DraftBoxConfig): DraftContentType { |
| 134 | if (params.imageModel || params.imageCount || params.imageSize) { |
| 135 | return 'image_text' |
| 136 | } |
| 137 | |
| 138 | if (params.model || params.resolution || params.videoUrls?.length || params.audioUrls?.length) { |
| 139 | return 'video' |
| 140 | } |
| 141 | |
| 142 | return current.contentType |
| 143 | } |
| 144 | |
| 145 | function queueConfigUpdate( |
| 146 | get: () => IDraftBoxConfigStore, |
| 147 | set: ( |
| 148 | partial: |
| 149 | | IDraftBoxConfigStore |
| 150 | | Partial<IDraftBoxConfigStore> |
| 151 | | ((state: IDraftBoxConfigStore) => IDraftBoxConfigStore | Partial<IDraftBoxConfigStore>), |
| 152 | ) => void, |
| 153 | groupId: string, |
| 154 | partial: Partial<DraftBoxConfig>, |
| 155 | ) { |
| 156 | const configs = get().configs |
| 157 | const current = configs[groupId] ?? { ...DEFAULT_CONFIG } |
| 158 | const pendingPartial = pendingConfigUpdates.get(groupId) ?? {} |
| 159 | const mergedCurrent = { ...current, ...pendingPartial } |
| 160 | const hasChanges = Object.entries(partial).some(([key, value]) => { |
| 161 | return !isEqual(mergedCurrent[key as keyof DraftBoxConfig], value) |
| 162 | }) |
| 163 | |
| 164 | if (!hasChanges) { |
| 165 | return |
| 166 | } |
| 167 | |
| 168 | pendingConfigUpdates.set(groupId, { ...pendingPartial, ...partial }) |
| 169 | |
| 170 | if (pendingFlushScheduled) { |
| 171 | return |
| 172 | } |
| 173 | |
| 174 | pendingFlushScheduled = true |
| 175 | queueMicrotask(() => { |
| 176 | pendingFlushScheduled = false |
| 177 | |
| 178 | const latestConfigs = get().configs |
| 179 | let hasAnyConfigChanged = false |
| 180 | const nextConfigs = { ...latestConfigs } |
| 181 | |
| 182 | pendingConfigUpdates.forEach((queuedPartial, queuedGroupId) => { |
| 183 | const latestCurrent = latestConfigs[queuedGroupId] ?? { ...DEFAULT_CONFIG } |
| 184 | const queuedHasChanges = Object.entries(queuedPartial).some(([key, value]) => { |
| 185 | return !isEqual(latestCurrent[key as keyof DraftBoxConfig], value) |
| 186 | }) |
| 187 | |
| 188 | if (!queuedHasChanges) { |
| 189 | return |
| 190 | } |
| 191 | nextConfigs[queuedGroupId] = { ...latestCurrent, ...queuedPartial } |
| 192 | hasAnyConfigChanged = true |
| 193 | }) |
| 194 | |
| 195 | pendingConfigUpdates.clear() |
| 196 | |
| 197 | if (!hasAnyConfigChanged) { |
| 198 | return |
| 199 | } |
| 200 | |
| 201 | set({ |
| 202 | configs: nextConfigs, |
| 203 | }) |
| 204 | }) |
| 205 | } |
| 206 | |
| 207 | export const useDraftBoxConfigStore = createPersistStore( |
| 208 | { ...state }, |
| 209 | (set, get) => ({ |
| 210 | /** 获取指定 groupId 的配置,不存在时返回默认值 */ |
| 211 | getConfig(groupId: string): DraftBoxConfig { |
| 212 | return get().configs[groupId] ?? { ...DEFAULT_CONFIG } |
| 213 | }, |
| 214 | |
| 215 | /** 合并更新指定 groupId 的配置 */ |
| 216 | updateConfig(groupId: string, partial: Partial<DraftBoxConfig>) { |
| 217 | queueConfigUpdate(get, set, groupId, partial) |
| 218 | }, |
| 219 | |
| 220 | /** 将历史生成参数回填到当前草稿箱输入配置 */ |
| 221 | applyGenerationParams(groupId: string, params: MaterialGenerationParams) { |
| 222 | const current = get().configs[groupId] ?? { ...DEFAULT_CONFIG } |
| 223 | const nextContentType = inferContentType(params, current) |
| 224 | const nextDraftMode = params.draftType |
| 225 | ? params.draftType === 'draft' |
| 226 | : current.isDraftMode |
| 227 | const nextCaptionPrompt = stripCaptionPromptSystemRequirement(params.captionPrompt ?? '') |
| 228 | |
| 229 | queueConfigUpdate(get, set, groupId, { |
| 230 | promptValue: params.prompt ? stripDraftPromptLimits(params.prompt) : '', |
| 231 | aspectRatio: params.aspectRatio ?? current.aspectRatio, |
| 232 | duration: params.duration ?? current.duration, |
| 233 | resolution: nextContentType === 'video' |
| 234 | ? (params.resolution ?? current.resolution) |
| 235 | : current.resolution, |
| 236 | contentType: nextContentType, |
| 237 | modelType: nextContentType === 'video' |
| 238 | ? (params.model ?? current.modelType) |
| 239 | : current.modelType, |
| 240 | selectedVideoModels: nextContentType === 'video' && params.model |
| 241 | ? [params.model] |
| 242 | : current.selectedVideoModels, |
| 243 | videoModelResolutions: nextContentType === 'video' && params.model && params.resolution |
| 244 | ? { ...current.videoModelResolutions, [params.model]: params.resolution } |
| 245 | : current.videoModelResolutions, |
| 246 | videoModelParams: nextContentType === 'video' && params.model |
| 247 | ? { |
| 248 | ...current.videoModelParams, |
| 249 | [params.model]: { |
| 250 | resolution: params.resolution ?? current.videoModelParams[params.model]?.resolution, |
| 251 | duration: params.duration ?? current.videoModelParams[params.model]?.duration, |
| 252 | aspectRatio: params.aspectRatio ?? current.videoModelParams[params.model]?.aspectRatio, |
| 253 | }, |
| 254 | } |
| 255 | : current.videoModelParams, |
| 256 | imageModel: nextContentType === 'image_text' |
| 257 | ? (params.imageModel ?? current.imageModel) |
| 258 | : current.imageModel, |
| 259 | selectedImageModels: nextContentType === 'image_text' && params.imageModel |
| 260 | ? [params.imageModel] |
| 261 | : current.selectedImageModels, |
| 262 | imageCount: nextContentType === 'image_text' |
| 263 | ? (params.imageCount ?? current.imageCount) |
| 264 | : current.imageCount, |
| 265 | imageSize: nextContentType === 'image_text' |
| 266 | ? (params.imageSize ?? current.imageSize) |
| 267 | : current.imageSize, |
| 268 | selectedPlatforms: params.platforms ? [...params.platforms] : [], |
| 269 | persistedMedias: normalizePersistedMedias(params), |
| 270 | isDraftMode: nextDraftMode, |
| 271 | captionPrompt: nextCaptionPrompt, |
| 272 | captionPromptOpen: nextDraftMode ? (Boolean(nextCaptionPrompt) || current.captionPromptOpen) : false, |
| 273 | }) |
| 274 | }, |
| 275 | |
| 276 | /** 追加持久化媒体,按 type + url 去重,保留原有顺序 */ |
| 277 | appendPersistedMedias(groupId: string, medias: IPersistedMedia[]) { |
| 278 | const current = get().configs[groupId] ?? { ...DEFAULT_CONFIG } |
| 279 | const existingMedias = current.persistedMedias ?? [] |
| 280 | if (medias.length === 0) { |
| 281 | return { added: 0, total: existingMedias.length } |
| 282 | } |
| 283 | |
| 284 | const mergedMedias = [...existingMedias] |
| 285 | const existingKeys = new Set(existingMedias.map(media => `${media.type}:${media.url}`)) |
| 286 | let added = 0 |
| 287 | |
| 288 | medias.forEach((media) => { |
| 289 | const mediaKey = `${media.type}:${media.url}` |
| 290 | if (existingKeys.has(mediaKey)) { |
| 291 | return |
| 292 | } |
| 293 | |
| 294 | mergedMedias.push(media) |
| 295 | existingKeys.add(mediaKey) |
| 296 | added += 1 |
| 297 | }) |
| 298 | |
| 299 | if (added === 0) { |
| 300 | return { added: 0, total: mergedMedias.length } |
| 301 | } |
| 302 | |
| 303 | set({ |
| 304 | configs: { |
| 305 | ...get().configs, |
| 306 | [groupId]: { |
| 307 | ...current, |
| 308 | persistedMedias: mergedMedias, |
| 309 | }, |
| 310 | }, |
| 311 | }) |
| 312 | |
| 313 | return { added, total: mergedMedias.length } |
| 314 | }, |
| 315 | |
| 316 | /** 重置指定 groupId 的配置为默认值 */ |
| 317 | resetConfig(groupId: string) { |
| 318 | set({ |
| 319 | configs: { |
| 320 | ...get().configs, |
| 321 | [groupId]: { ...DEFAULT_CONFIG }, |
| 322 | }, |
| 323 | }) |
| 324 | }, |
| 325 | }), |
| 326 | { |
| 327 | name: 'DraftBoxConfig', |
| 328 | version: 13, |
| 329 | migrate(persistedState: any, version: number) { |
| 330 | if (version < 2) { |
| 331 | // v1 → v2: 给已有配置补 persistedMedias 字段 |
| 332 | const configs = persistedState.configs ?? {} |
| 333 | for (const key of Object.keys(configs)) { |
| 334 | if (!configs[key].persistedMedias) { |
| 335 | configs[key].persistedMedias = [] |
| 336 | } |
| 337 | } |
| 338 | persistedState.configs = configs |
| 339 | } |
| 340 | if (version < 4) { |
| 341 | // v3 → v4: 给已有配置补 promptValue 字段 |
| 342 | const configs = persistedState.configs ?? {} |
| 343 | for (const key of Object.keys(configs)) { |
| 344 | if (configs[key].promptValue === undefined) { |
| 345 | configs[key].promptValue = '' |
| 346 | } |
| 347 | } |
| 348 | persistedState.configs = configs |
| 349 | } |
| 350 | if (version < 6) { |
| 351 | // v5 → v6: 给已有配置补 isDraftMode 字段 |
| 352 | const configs = persistedState.configs ?? {} |
| 353 | for (const key of Object.keys(configs)) { |
| 354 | if (configs[key].isDraftMode === undefined) { |
| 355 | configs[key].isDraftMode = true |
| 356 | } |
| 357 | } |
| 358 | persistedState.configs = configs |
| 359 | } |
| 360 | if (version < 8) { |
| 361 | // v7 → v8: 给已有配置补 resolution 字段 |
| 362 | const configs = persistedState.configs ?? {} |
| 363 | for (const key of Object.keys(configs)) { |
| 364 | if (configs[key].resolution === undefined) { |
| 365 | configs[key].resolution = '' |
| 366 | } |
| 367 | } |
| 368 | persistedState.configs = configs |
| 369 | } |
| 370 | if (version < 9) { |
| 371 | // v8 → v9: 给已有配置补 captionPrompt 字段 |
| 372 | const configs = persistedState.configs ?? {} |
| 373 | for (const key of Object.keys(configs)) { |
| 374 | if (configs[key].captionPrompt === undefined) { |
| 375 | configs[key].captionPrompt = '' |
| 376 | } |
| 377 | } |
| 378 | persistedState.configs = configs |
| 379 | } |
| 380 | if (version < 10) { |
| 381 | // v9 → v10: 从旧单模型字段迁移到多模型选择 |
| 382 | const configs = persistedState.configs ?? {} |
| 383 | for (const key of Object.keys(configs)) { |
| 384 | if (!Array.isArray(configs[key].selectedVideoModels)) { |
| 385 | configs[key].selectedVideoModels = configs[key].modelType ? [configs[key].modelType] : [] |
| 386 | } |
| 387 | if (!Array.isArray(configs[key].selectedImageModels)) { |
| 388 | configs[key].selectedImageModels = configs[key].imageModel ? [configs[key].imageModel] : [] |
| 389 | } |
| 390 | } |
| 391 | persistedState.configs = configs |
| 392 | } |
| 393 | if (version < 11) { |
| 394 | // v10 → v11: 给已有配置补文案要求展开和系统内置文案要求字段 |
| 395 | const configs = persistedState.configs ?? {} |
| 396 | for (const key of Object.keys(configs)) { |
| 397 | if (configs[key].captionPromptOpen === undefined) { |
| 398 | configs[key].captionPromptOpen = true |
| 399 | } |
| 400 | if (configs[key].captionSystemPrompt === undefined) { |
| 401 | configs[key].captionSystemPrompt = '' |
| 402 | } |
| 403 | if (configs[key].captionSystemPromptDefault === undefined) { |
| 404 | configs[key].captionSystemPromptDefault = '' |
| 405 | } |
| 406 | } |
| 407 | persistedState.configs = configs |
| 408 | } |
| 409 | if (version < 12) { |
| 410 | // v11 → v12: 按当前配置白名单清理废弃字段 |
| 411 | const currentConfigKeys = new Set(Object.keys(DEFAULT_CONFIG)) |
| 412 | const configs = persistedState.configs ?? {} |
| 413 | for (const key of Object.keys(configs)) { |
| 414 | configs[key] = { |
| 415 | ...DEFAULT_CONFIG, |
| 416 | ...Object.fromEntries( |
| 417 | Object.entries(configs[key]).filter(([configKey]) => currentConfigKeys.has(configKey)), |
| 418 | ), |
| 419 | } |
| 420 | } |
| 421 | persistedState.configs = configs |
| 422 | } |
| 423 | if (version < 13) { |
| 424 | // v12 → v13: 给视频多选补按模型隔离的分辨率、时长和比例参数 |
| 425 | const configs = persistedState.configs ?? {} |
| 426 | for (const key of Object.keys(configs)) { |
| 427 | if (configs[key].videoModelSelectionMode === undefined) { |
| 428 | configs[key].videoModelSelectionMode = 'single' |
| 429 | } |
| 430 | if (configs[key].imageModelSelectionMode === undefined) { |
| 431 | configs[key].imageModelSelectionMode = 'single' |
| 432 | } |
| 433 | if (configs[key].videoModelResolutions === undefined) { |
| 434 | configs[key].videoModelResolutions = configs[key].modelType && configs[key].resolution |
| 435 | ? { [configs[key].modelType]: configs[key].resolution } |
| 436 | : {} |
| 437 | } |
| 438 | const modelType = configs[key].modelType |
| 439 | const existingParams = configs[key].videoModelParams ?? {} |
| 440 | configs[key].videoModelParams = modelType && existingParams[modelType] === undefined |
| 441 | ? { |
| 442 | ...existingParams, |
| 443 | [modelType]: { |
| 444 | resolution: configs[key].videoModelResolutions?.[modelType] ?? configs[key].resolution, |
| 445 | duration: configs[key].duration, |
| 446 | aspectRatio: configs[key].aspectRatio, |
| 447 | }, |
| 448 | } |
| 449 | : existingParams |
| 450 | } |
| 451 | persistedState.configs = configs |
| 452 | } |
| 453 | return persistedState |
| 454 | }, |
| 455 | }, |
| 456 | 'indexedDB', |
| 457 | ) |
| 458 |