From 7b65b7c2d19529f9baf940781cceab112b89a7ee Mon Sep 17 00:00:00 2001 From: liquid Date: Thu, 8 Jan 2026 20:12:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/cleanup-legacy-cache_e81b6b84.md | 116 +++++ apps/web/src/app/app.tsx | 10 +- .../components/task-queue/DialogTaskList.tsx | 8 +- .../components/task-queue/TaskQueuePanel.tsx | 10 +- .../toolbar/popup-toolbar/popup-toolbar.tsx | 15 +- packages/drawnix/src/data/image.ts | 9 +- packages/drawnix/src/data/video.ts | 4 +- packages/drawnix/src/hooks/useMediaCache.ts | 79 ++-- packages/drawnix/src/index.ts | 6 +- .../src/services/media-cache-service.ts | 400 ------------------ .../drawnix/src/services/photo-wall/index.ts | 8 +- .../src/services/unified-cache-service.ts | 97 +++++ .../drawnix/src/services/url-cache-service.ts | 376 ---------------- .../src/services/video-merge-webcodecs.ts | 6 +- .../src/services/video-recovery-service.ts | 19 +- 15 files changed, 309 insertions(+), 854 deletions(-) create mode 100644 .codebuddy/plans/cleanup-legacy-cache_e81b6b84.md delete mode 100644 packages/drawnix/src/services/media-cache-service.ts delete mode 100644 packages/drawnix/src/services/url-cache-service.ts diff --git a/.codebuddy/plans/cleanup-legacy-cache_e81b6b84.md b/.codebuddy/plans/cleanup-legacy-cache_e81b6b84.md new file mode 100644 index 00000000..ab14b43a --- /dev/null +++ b/.codebuddy/plans/cleanup-legacy-cache_e81b6b84.md @@ -0,0 +1,116 @@ +--- +name: cleanup-legacy-cache +overview: 清理旧的图片/视频缓存方案代码,将所有使用 `mediaCacheService` 和 `urlCacheService` 的地方迁移到统一缓存服务 `unifiedCacheService`,然后删除旧的缓存服务文件。 +todos: + - id: scan-old-services + content: 使用 [subagent:code-explorer] 扫描所有使用 mediaCacheService 和 urlCacheService 的代码位置 + status: completed + - id: analyze-api-mapping + content: 分析 unifiedCacheService 的 API,确认与旧服务的映射关系 + status: completed + dependencies: + - scan-old-services + - id: migrate-media-cache + content: 将所有 mediaCacheService 调用迁移到 unifiedCacheService + status: completed + dependencies: + - analyze-api-mapping + - id: migrate-url-cache + content: 将所有 urlCacheService 调用迁移到 unifiedCacheService + status: completed + dependencies: + - analyze-api-mapping + - id: verify-migration + content: 验证迁移完成,确保无编译错误和遗漏 + status: completed + dependencies: + - migrate-media-cache + - migrate-url-cache + - id: delete-old-files + content: 删除旧的缓存服务文件 media-cache-service.ts 和 url-cache-service.ts + status: completed + dependencies: + - verify-migration +--- + +## Product Overview + +清理旧的图片/视频缓存方案代码,将所有使用 `mediaCacheService` 和 `urlCacheService` 的地方迁移到统一缓存服务 `unifiedCacheService`,完成迁移后删除旧的缓存服务文件。 + +## Core Features + +- 识别所有使用旧缓存服务的代码位置 +- 将 `mediaCacheService` 的调用迁移到 `unifiedCacheService` +- 将 `urlCacheService` 的调用迁移到 `unifiedCacheService` +- 删除旧的缓存服务文件 `media-cache-service.ts` 和 `url-cache-service.ts` +- 确保迁移后功能正常运行 + +## 技术方案 + +### 迁移策略 + +采用渐进式迁移策略,逐个文件替换旧服务调用,确保每次修改后代码可正常编译运行。 + +### 数据流变更 + +```mermaid +flowchart LR + subgraph 迁移前 + A[业务代码] --> B[mediaCacheService] + A --> C[urlCacheService] + end + subgraph 迁移后 + D[业务代码] --> E[unifiedCacheService] + end + B -.->|迁移| E + C -.->|迁移| E +``` + +### 实现细节 + +#### 涉及修改的文件 + +根据代码分析,需要修改以下使用旧服务的文件: + +``` +src/ +├── services/ +│ ├── media-cache-service.ts # 待删除 +│ └── url-cache-service.ts # 待删除 +├── components/ +│ └── [使用旧服务的组件] # 需迁移 +├── pages/ +│ └── [使用旧服务的页面] # 需迁移 +└── utils/ + └── [使用旧服务的工具] # 需迁移 +``` + +#### API 映射关系 + +- `mediaCacheService.getCache()` → `unifiedCacheService.get()` +- `mediaCacheService.setCache()` → `unifiedCacheService.set()` +- `urlCacheService.getCachedUrl()` → `unifiedCacheService.getUrl()` +- `urlCacheService.cacheUrl()` → `unifiedCacheService.setUrl()` + +### 技术考量 + +#### 兼容性检查 + +- 确认 `unifiedCacheService` 提供的 API 能覆盖旧服务的所有功能 +- 检查参数类型和返回值类型是否一致 +- 处理可能的异步/同步差异 + +#### 清理步骤 + +1. 先完成所有迁移工作 +2. 确认无编译错误 +3. 删除旧服务文件 +4. 清理可能存在的旧服务相关类型定义 + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose: 搜索项目中所有使用 `mediaCacheService` 和 `urlCacheService` 的文件和代码位置 +- Expected outcome: 获取完整的旧服务使用清单,包括文件路径、行号和具体调用方式 \ No newline at end of file diff --git a/apps/web/src/app/app.tsx b/apps/web/src/app/app.tsx index 457e4bbe..83f8583c 100644 --- a/apps/web/src/app/app.tsx +++ b/apps/web/src/app/app.tsx @@ -6,7 +6,7 @@ import { isWorkspaceMigrationCompleted, Board, BoardChangeData, - mediaCacheService, + unifiedCacheService, } from '@drawnix/drawnix'; import { PlaitBoard, PlaitElement, PlaitTheme, Viewport } from '@plait/core'; @@ -206,13 +206,13 @@ async function recoverVideoUrlsInElements( setTimeout(() => reject(new Error('Timeout')), TIMEOUT_MS) ); - const cached = await Promise.race([ - mediaCacheService.getCachedMedia(taskId), + const cachedBlob = await Promise.race([ + unifiedCacheService.getCachedBlob(taskId), timeoutPromise, ]); - if (cached && cached.blob) { - const newBlobUrl = URL.createObjectURL(cached.blob); + if (cachedBlob) { + const newBlobUrl = URL.createObjectURL(cachedBlob); const newUrl = `${newBlobUrl}#${taskId}`; // 返回更新后的元素 diff --git a/packages/drawnix/src/components/task-queue/DialogTaskList.tsx b/packages/drawnix/src/components/task-queue/DialogTaskList.tsx index d8de4e4f..e3f556de 100644 --- a/packages/drawnix/src/components/task-queue/DialogTaskList.tsx +++ b/packages/drawnix/src/components/task-queue/DialogTaskList.tsx @@ -16,7 +16,7 @@ import { MessagePlugin, Dialog, Button, Input } from 'tdesign-react'; import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from 'tdesign-icons-react'; import { sanitizeFilename } from '@aitu/utils'; import { downloadMediaFile, downloadFromBlob } from '../../utils/download-utils'; -import { mediaCacheService } from '../../services/media-cache-service'; +import { unifiedCacheService } from '../../services/unified-cache-service'; import { useMediaUrl } from '../../hooks/useMediaCache'; import { CharacterCreateDialog } from '../character/CharacterCreateDialog'; import './dialog-task-list.scss'; @@ -130,10 +130,10 @@ export const DialogTaskList: React.FC = ({ try { // 1. 优先从本地 IndexedDB 缓存获取 - const cachedMedia = await mediaCacheService.getCachedMedia(taskId); - if (cachedMedia?.blob) { + const cachedBlob = await unifiedCacheService.getCachedBlob(task.result.url); + if (cachedBlob) { console.log('[Download] Using cached blob for task:', taskId); - downloadFromBlob(cachedMedia.blob, filename); + downloadFromBlob(cachedBlob, filename); MessagePlugin.success('下载成功'); return; } diff --git a/packages/drawnix/src/components/task-queue/TaskQueuePanel.tsx b/packages/drawnix/src/components/task-queue/TaskQueuePanel.tsx index 24e16718..05abb71a 100644 --- a/packages/drawnix/src/components/task-queue/TaskQueuePanel.tsx +++ b/packages/drawnix/src/components/task-queue/TaskQueuePanel.tsx @@ -12,12 +12,12 @@ import { TaskItem } from './TaskItem'; import { useTaskQueue } from '../../hooks/useTaskQueue'; import { Task, TaskType, TaskStatus } from '../../types/task.types'; import { useMediaUrl } from '../../hooks/useMediaCache'; +import { unifiedCacheService } from '../../services/unified-cache-service'; import { useDrawnix, DialogType } from '../../hooks/use-drawnix'; import { insertImageFromUrl } from '../../data/image'; import { insertVideoFromUrl } from '../../data/video'; import { sanitizeFilename } from '@aitu/utils'; import { downloadMediaFile, downloadFromBlob } from '../../utils/download-utils'; -import { mediaCacheService } from '../../services/media-cache-service'; import { SideDrawer } from '../side-drawer'; import { CharacterCreateDialog } from '../character/CharacterCreateDialog'; import { CharacterList } from '../character/CharacterList'; @@ -109,7 +109,7 @@ export const TaskQueuePanel: React.FC = ({ // Initialize media cache status on component mount useEffect(() => { - mediaCacheService.initCacheStatus(); + unifiedCacheService.initCacheStatus(); }, []); // Filter and sort tasks @@ -198,10 +198,10 @@ export const TaskQueuePanel: React.FC = ({ try { // 1. 优先从本地 IndexedDB 缓存获取 - const cachedMedia = await mediaCacheService.getCachedMedia(taskId); - if (cachedMedia?.blob) { + const cachedBlob = await unifiedCacheService.getCachedBlob(task.result.url); + if (cachedBlob) { console.log('[Download] Using cached blob for task:', taskId); - downloadFromBlob(cachedMedia.blob, filename); + downloadFromBlob(cachedBlob, filename); MessagePlugin.success('下载成功'); onTaskAction?.('download', taskId); return; diff --git a/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx b/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx index 3bd98186..6db3732c 100644 --- a/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx +++ b/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx @@ -492,7 +492,7 @@ export const PopupToolbar = () => { const loadingInstance = MessagePlugin.loading(loadingMsg, 0); try { // 特殊处理 blob: URL(可能是从IndexedDB缓存的视频) - const { mediaCacheService } = await import('../../../services/media-cache-service'); + const { unifiedCacheService } = await import('../../../services/unified-cache-service'); const { downloadFromBlob } = await import('../../../utils/download-utils'); const processedItems: BatchDownloadItem[] = []; @@ -507,14 +507,15 @@ export const PopupToolbar = () => { if (taskId && taskId.startsWith('merged-video-')) { // 从 IndexedDB 获取缓存的视频 - const cached = await mediaCacheService.getCachedMedia(taskId); - if (cached && cached.blob) { - const ext = cached.mimeType.startsWith('video/mp4') ? 'mp4' : - cached.mimeType.startsWith('video/webm') ? 'webm' : 'bin'; + const cachedBlob = await unifiedCacheService.getCachedBlob(taskId); + if (cachedBlob) { + const mimeType = cachedBlob.type || 'video/webm'; + const ext = mimeType.startsWith('video/mp4') ? 'mp4' : + mimeType.startsWith('video/webm') ? 'webm' : 'bin'; const filename = `merged-video-${Date.now()}.${ext}`; - console.log('[Download] Downloading from IndexedDB cache:', { taskId, ext, size: cached.blob.size }); - downloadFromBlob(cached.blob, filename); + console.log('[Download] Downloading from IndexedDB cache:', { taskId, ext, size: cachedBlob.size }); + downloadFromBlob(cachedBlob, filename); continue; } else { console.warn('[Download] Cache not found for taskId:', taskId); diff --git a/packages/drawnix/src/data/image.ts b/packages/drawnix/src/data/image.ts index 643dd643..d27f8f7b 100644 --- a/packages/drawnix/src/data/image.ts +++ b/packages/drawnix/src/data/image.ts @@ -11,7 +11,7 @@ import { MindElement, MindTransforms } from '@plait/mind'; import { DrawTransforms } from '@plait/draw'; import { getElementOfFocusedImage } from '@plait/common'; import { getInsertionPointForSelectedElements, getInsertionPointBelowBottommostElement, scrollToPointIfNeeded } from '../utils/selection-utils'; -import { urlCacheService } from '../services/url-cache-service'; +import { unifiedCacheService } from '../services/unified-cache-service'; /** * 从保存的选中元素IDs计算插入点 @@ -242,11 +242,12 @@ export const insertImageFromUrl = async ( // 使用缓存服务获取图片的 Base64 数据 // 这样可以避免重复下载,并且存储 Base64 确保 URL 过期后图片仍然可用 - const dataURL = await urlCacheService.getImageAsBase64(imageUrl); - const image = await loadHTMLImageElement(dataURL, false); // Base64 不需要 crossOrigin + const imageData = await unifiedCacheService.getImageForAI(imageUrl); + const dataURL = imageData.type === 'base64' ? imageData.value : imageUrl; + const image = await loadHTMLImageElement(dataURL as DataURL, false); // Base64 不需要 crossOrigin const imageItem = buildImage( image, - dataURL, + dataURL as DataURL, defaultImageWidth, true, referenceDimensions diff --git a/packages/drawnix/src/data/video.ts b/packages/drawnix/src/data/video.ts index 9ea5165b..e10c5613 100644 --- a/packages/drawnix/src/data/video.ts +++ b/packages/drawnix/src/data/video.ts @@ -4,7 +4,7 @@ import { getRectangleByElements, } from '@plait/core'; import { getInsertionPointForSelectedElements, getInsertionPointBelowBottommostElement, scrollToPointIfNeeded } from '../utils/selection-utils'; -import { urlCacheService } from '../services/url-cache-service'; +import { unifiedCacheService } from '../services/unified-cache-service'; /** * 从保存的选中元素IDs计算插入点 @@ -267,7 +267,7 @@ export const insertVideoFromUrl = async ( // 缓存视频到IndexedDB (后台任务,不阻塞插入) // 这样即使URL过期,也可以从缓存恢复 - urlCacheService.getVideoAsBlob(videoUrl).catch(error => { + unifiedCacheService.cacheImage(videoUrl).catch(error => { console.warn('Failed to cache video in background:', error); }); diff --git a/packages/drawnix/src/hooks/useMediaCache.ts b/packages/drawnix/src/hooks/useMediaCache.ts index 0ccb8d54..72815664 100644 --- a/packages/drawnix/src/hooks/useMediaCache.ts +++ b/packages/drawnix/src/hooks/useMediaCache.ts @@ -5,7 +5,7 @@ */ import { useState, useEffect, useCallback, useRef } from 'react'; -import { mediaCacheService, CacheStatus } from '../services/media-cache-service'; +import { unifiedCacheService, CacheStatus } from '../services/unified-cache-service'; interface UseMediaCacheResult { /** Cache status for the task */ @@ -39,49 +39,57 @@ export function useMediaCache( // Check initial cache status useEffect(() => { const checkCacheStatus = async () => { - const cached = await mediaCacheService.isCached(taskId); + if (!originalUrl) { + setCacheStatus('none'); + return; + } + const cached = await unifiedCacheService.isCached(originalUrl); setCacheStatus(cached ? 'cached' : 'none'); }; checkCacheStatus(); // Subscribe to cache status changes - const unsubscribe = mediaCacheService.subscribe(() => { - const status = mediaCacheService.getCacheStatus(taskId); - setCacheStatus(status); + const unsubscribe = unifiedCacheService.subscribe(() => { + if (originalUrl) { + const status = unifiedCacheService.getCacheStatus(originalUrl); + setCacheStatus(status); + } }); return unsubscribe; - }, [taskId]); + }, [taskId, originalUrl]); // Cache media const cacheMedia = useCallback(async () => { if (!originalUrl) return false; setCacheProgress(0); - const success = await mediaCacheService.cacheMedia( + const success = await unifiedCacheService.cacheImage(originalUrl, { taskId, - originalUrl, - type, prompt, - (progress) => setCacheProgress(progress) - ); + }); + setCacheProgress(100); return success; - }, [taskId, originalUrl, type, prompt]); + }, [taskId, originalUrl, prompt]); // Delete cache const deleteCache = useCallback(async () => { - const success = await mediaCacheService.deleteCache(taskId); - if (success) { + if (!originalUrl) return false; + try { + await unifiedCacheService.deleteCache(originalUrl); setCacheStatus('none'); + return true; + } catch { + return false; } - return success; - }, [taskId]); + }, [originalUrl]); // Get cached URL const getCachedUrl = useCallback(async () => { - return mediaCacheService.getCachedUrl(taskId); - }, [taskId]); + if (!originalUrl) return null; + return unifiedCacheService.getCachedUrl(originalUrl); + }, [originalUrl]); return { cacheStatus, @@ -114,8 +122,15 @@ export function useMediaUrl( const loadUrl = useCallback(async () => { setIsLoading(true); + if (!originalUrl) { + setUrl(null); + setIsFromCache(false); + setIsLoading(false); + return; + } + // First try to get cached URL - const cachedUrl = await mediaCacheService.getCachedUrl(taskId); + const cachedUrl = await unifiedCacheService.getCachedUrl(originalUrl); if (cachedUrl) { // Revoke old blob URL if exists @@ -134,19 +149,23 @@ export function useMediaUrl( } setIsLoading(false); - }, [taskId, originalUrl, blobUrl]); + }, [originalUrl, blobUrl]); // Initial load useEffect(() => { loadUrl(); // Initialize last status - lastStatusRef.current = mediaCacheService.getCacheStatus(taskId); + if (originalUrl) { + lastStatusRef.current = unifiedCacheService.getCacheStatus(originalUrl); + } }, [taskId, originalUrl]); // Subscribe to cache status changes - only react to THIS task's changes useEffect(() => { - const unsubscribe = mediaCacheService.subscribe(() => { - const currentStatus = mediaCacheService.getCacheStatus(taskId); + const unsubscribe = unifiedCacheService.subscribe(() => { + if (!originalUrl) return; + + const currentStatus = unifiedCacheService.getCacheStatus(originalUrl); const previousStatus = lastStatusRef.current; // Only reload if THIS task's status actually changed @@ -161,7 +180,7 @@ export function useMediaUrl( }); return unsubscribe; - }, [taskId, loadUrl]); + }, [originalUrl, loadUrl]); // Cleanup blob URL on unmount useEffect(() => { @@ -184,22 +203,22 @@ export function useCacheStats() { useEffect(() => { const loadStats = async () => { - const size = await mediaCacheService.getTotalCacheSize(); - const ids = await mediaCacheService.getAllCachedTaskIds(); - setTotalSize(size); - setCachedCount(ids.length); + const usage = await unifiedCacheService.getStorageUsage(); + const urls = await unifiedCacheService.getAllCachedUrls(); + setTotalSize(usage.used); + setCachedCount(urls.length); }; loadStats(); // Subscribe to changes - const unsubscribe = mediaCacheService.subscribe(loadStats); + const unsubscribe = unifiedCacheService.subscribe(loadStats); return unsubscribe; }, []); return { totalSize, cachedCount, - formattedSize: mediaCacheService.formatSize(totalSize), + formattedSize: unifiedCacheService.formatSize(totalSize), }; } diff --git a/packages/drawnix/src/index.ts b/packages/drawnix/src/index.ts index e7f45614..de7b0b3f 100644 --- a/packages/drawnix/src/index.ts +++ b/packages/drawnix/src/index.ts @@ -12,6 +12,6 @@ export * from './services/workspace-service'; export * from './services/workspace-migration'; export * from './types/workspace.types'; -// Export media cache service -export { mediaCacheService } from './services/media-cache-service'; -export type { CachedMedia, CacheStatus } from './services/media-cache-service'; +// Export unified cache service +export { unifiedCacheService } from './services/unified-cache-service'; +export type { CachedMedia, CacheStatus } from './services/unified-cache-service'; diff --git a/packages/drawnix/src/services/media-cache-service.ts b/packages/drawnix/src/services/media-cache-service.ts deleted file mode 100644 index e869f41d..00000000 --- a/packages/drawnix/src/services/media-cache-service.ts +++ /dev/null @@ -1,400 +0,0 @@ -/** - * Media Cache Service - * - * Manages caching of generated images and videos using IndexedDB. - * Allows media to persist even after original URLs expire. - */ - -// Database constants -const DB_NAME = 'aitu-media-cache'; -const DB_VERSION = 1; -const STORE_NAME = 'media'; - -// Cached media interface -export interface CachedMedia { - taskId: string; - type: 'image' | 'video'; - blob: Blob; - mimeType: string; - size: number; - cachedAt: number; - prompt?: string; - originalUrl?: string; -} - -// Cache status -export type CacheStatus = 'none' | 'caching' | 'cached' | 'error'; - -// Cache progress callback -export type CacheProgressCallback = (progress: number) => void; - -/** - * Media Cache Service - * Singleton service for managing media cache in IndexedDB - */ -class MediaCacheService { - private db: IDBDatabase | null = null; - private dbPromise: Promise | null = null; - private cacheStatusMap: Map = new Map(); - private listeners: Set<() => void> = new Set(); - - constructor() { - // Initialize database on service creation - this.initDB(); - } - - /** - * Initialize IndexedDB database - */ - private async initDB(): Promise { - if (this.db) return this.db; - if (this.dbPromise) return this.dbPromise; - - this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = () => { - console.error('[MediaCache] Failed to open database:', request.error); - reject(request.error); - }; - - request.onsuccess = () => { - this.db = request.result; - console.log('[MediaCache] Database opened successfully'); - resolve(this.db); - }; - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // Create object store if it doesn't exist - if (!db.objectStoreNames.contains(STORE_NAME)) { - const store = db.createObjectStore(STORE_NAME, { keyPath: 'taskId' }); - store.createIndex('cachedAt', 'cachedAt', { unique: false }); - store.createIndex('type', 'type', { unique: false }); - console.log('[MediaCache] Object store created'); - } - }; - }); - - return this.dbPromise; - } - - /** - * Cache media from Blob (直接缓存 Blob 对象) - */ - async cacheMediaFromBlob( - taskId: string, - blob: Blob, - type: 'image' | 'video', - mimeType: string, - prompt?: string - ): Promise { - try { - // Update status - this.setCacheStatus(taskId, 'caching'); - - // Store in IndexedDB - const db = await this.initDB(); - const cachedMedia: CachedMedia = { - taskId, - type, - blob, - mimeType, - size: blob.size, - cachedAt: Date.now(), - prompt, - }; - - await this.storeMedia(db, cachedMedia); - - // Update status - this.setCacheStatus(taskId, 'cached'); - console.log(`[MediaCache] Cached ${type} from Blob for task ${taskId}, size: ${blob.size} bytes`); - - // Return IndexedDB-backed URL - return URL.createObjectURL(blob); - } catch (error) { - console.error(`[MediaCache] Failed to cache Blob for task ${taskId}:`, error); - this.setCacheStatus(taskId, 'error'); - throw error; - } - } - - /** - * Cache media from URL - */ - async cacheMedia( - taskId: string, - url: string, - type: 'image' | 'video', - prompt?: string, - onProgress?: CacheProgressCallback - ): Promise { - try { - // Update status - this.setCacheStatus(taskId, 'caching'); - - // Fetch media as blob with progress tracking - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch media: ${response.status}`); - } - - const contentLength = response.headers.get('content-length'); - const total = contentLength ? parseInt(contentLength, 10) : 0; - - let loaded = 0; - const reader = response.body?.getReader(); - const chunks: Uint8Array[] = []; - - if (reader) { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - chunks.push(value); - loaded += value.length; - - if (total && onProgress) { - onProgress(Math.round((loaded / total) * 100)); - } - } - } - - // Create blob from chunks - const blob = new Blob(chunks, { - type: response.headers.get('content-type') || (type === 'image' ? 'image/png' : 'video/mp4') - }); - - // Store in IndexedDB - const db = await this.initDB(); - const cachedMedia: CachedMedia = { - taskId, - type, - blob, - mimeType: blob.type, - size: blob.size, - cachedAt: Date.now(), - prompt, - originalUrl: url, - }; - - await this.storeMedia(db, cachedMedia); - - // Update status - this.setCacheStatus(taskId, 'cached'); - console.log(`[MediaCache] Cached ${type} for task ${taskId}, size: ${blob.size} bytes`); - - return true; - } catch (error) { - console.error(`[MediaCache] Failed to cache media for task ${taskId}:`, error); - this.setCacheStatus(taskId, 'error'); - return false; - } - } - - /** - * Store media in IndexedDB - */ - private storeMedia(db: IDBDatabase, media: CachedMedia): Promise { - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.put(media); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - } - - /** - * Get cached media by task ID - */ - async getCachedMedia(taskId: string): Promise { - try { - const db = await this.initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.get(taskId); - - request.onsuccess = () => { - resolve(request.result || null); - }; - request.onerror = () => reject(request.error); - }); - } catch (error) { - console.error(`[MediaCache] Failed to get cached media for task ${taskId}:`, error); - return null; - } - } - - /** - * Get cached media URL (creates object URL from blob) - */ - async getCachedUrl(taskId: string): Promise { - const cached = await this.getCachedMedia(taskId); - if (cached?.blob) { - return URL.createObjectURL(cached.blob); - } - return null; - } - - /** - * Check if media is cached - */ - async isCached(taskId: string): Promise { - const cached = await this.getCachedMedia(taskId); - return cached !== null; - } - - /** - * Delete cached media - */ - async deleteCache(taskId: string): Promise { - try { - const db = await this.initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.delete(taskId); - - request.onsuccess = () => { - this.setCacheStatus(taskId, 'none'); - console.log(`[MediaCache] Deleted cache for task ${taskId}`); - resolve(true); - }; - request.onerror = () => reject(request.error); - }); - } catch (error) { - console.error(`[MediaCache] Failed to delete cache for task ${taskId}:`, error); - return false; - } - } - - /** - * Get all cached task IDs - */ - async getAllCachedTaskIds(): Promise { - try { - const db = await this.initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.getAllKeys(); - - request.onsuccess = () => { - resolve(request.result as string[]); - }; - request.onerror = () => reject(request.error); - }); - } catch (error) { - console.error('[MediaCache] Failed to get all cached task IDs:', error); - return []; - } - } - - /** - * Get total cache size - */ - async getTotalCacheSize(): Promise { - try { - const db = await this.initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.getAll(); - - request.onsuccess = () => { - const items = request.result as CachedMedia[]; - const totalSize = items.reduce((sum, item) => sum + (item.size || 0), 0); - resolve(totalSize); - }; - request.onerror = () => reject(request.error); - }); - } catch (error) { - console.error('[MediaCache] Failed to get total cache size:', error); - return 0; - } - } - - /** - * Clear all cached media - */ - async clearAllCache(): Promise { - try { - const db = await this.initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.clear(); - - request.onsuccess = () => { - this.cacheStatusMap.clear(); - this.notifyListeners(); - console.log('[MediaCache] All cache cleared'); - resolve(true); - }; - request.onerror = () => reject(request.error); - }); - } catch (error) { - console.error('[MediaCache] Failed to clear all cache:', error); - return false; - } - } - - /** - * Set cache status for a task - */ - private setCacheStatus(taskId: string, status: CacheStatus): void { - this.cacheStatusMap.set(taskId, status); - this.notifyListeners(); - } - - /** - * Get cache status for a task - */ - getCacheStatus(taskId: string): CacheStatus { - return this.cacheStatusMap.get(taskId) || 'none'; - } - - /** - * Subscribe to cache status changes - */ - subscribe(callback: () => void): () => void { - this.listeners.add(callback); - return () => this.listeners.delete(callback); - } - - /** - * Notify all listeners of status change - */ - private notifyListeners(): void { - this.listeners.forEach(callback => callback()); - } - - /** - * Initialize cache status from IndexedDB - * Call this on app startup to sync status - */ - async initCacheStatus(): Promise { - const cachedIds = await this.getAllCachedTaskIds(); - cachedIds.forEach(id => { - this.cacheStatusMap.set(id, 'cached'); - }); - this.notifyListeners(); - } - - /** - * Format file size for display - */ - formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - } -} - -// Export singleton instance -export const mediaCacheService = new MediaCacheService(); diff --git a/packages/drawnix/src/services/photo-wall/index.ts b/packages/drawnix/src/services/photo-wall/index.ts index efaaf2db..141abf11 100644 --- a/packages/drawnix/src/services/photo-wall/index.ts +++ b/packages/drawnix/src/services/photo-wall/index.ts @@ -19,7 +19,7 @@ import type { } from '../../types/photo-wall.types'; import { GRID_IMAGE_PROMPT_TEMPLATE, GRID_IMAGE_DEFAULTS as DEFAULTS } from '../../types/photo-wall.types'; import { defaultGeminiClient } from '../../utils/gemini-api'; -import { urlCacheService } from '../url-cache-service'; +import { unifiedCacheService } from '../unified-cache-service'; import { getInsertionPointBelowBottommostElement } from '../../utils/selection-utils'; /** @@ -86,7 +86,8 @@ export class GridImageService { console.log('[GridImageService] Got original image URL'); // 3. 将图片转换为 base64(确保可以被 Canvas 处理) - const imageDataUrl = await urlCacheService.getImageAsBase64(originalImageUrl); + const imageData = await unifiedCacheService.getImageForAI(originalImageUrl); + const imageDataUrl = imageData.type === 'base64' ? imageData.value : originalImageUrl; // 4. 分割图片 console.log('[GridImageService] Splitting image into grid...'); @@ -137,7 +138,8 @@ export class GridImageService { try { // 1. 将图片转换为 base64(确保可以被 Canvas 处理) - const imageDataUrl = await urlCacheService.getImageAsBase64(imageUrl); + const imageData = await unifiedCacheService.getImageForAI(imageUrl); + const imageDataUrl = imageData.type === 'base64' ? imageData.value : imageUrl; // 2. 分割图片 console.log('[GridImageService] Splitting image into grid...'); diff --git a/packages/drawnix/src/services/unified-cache-service.ts b/packages/drawnix/src/services/unified-cache-service.ts index 3c926afd..461bf0e7 100644 --- a/packages/drawnix/src/services/unified-cache-service.ts +++ b/packages/drawnix/src/services/unified-cache-service.ts @@ -35,6 +35,12 @@ export const CACHE_CONSTANTS = { QUOTA_WARNING_THRESHOLD: 0.9, // 90% } as const; +/** 缓存状态(兼容旧 API) */ +export type CacheStatus = 'none' | 'caching' | 'cached' | 'error'; + +/** 缓存进度回调 */ +export type CacheProgressCallback = (progress: number) => void; + // ==================== 类型定义 ==================== /** 缓存媒体类型 */ @@ -758,6 +764,97 @@ class UnifiedCacheService { return !!item; } + // ==================== 兼容旧 API ==================== + + /** + * 从 Blob 缓存媒体(兼容 mediaCacheService.cacheMediaFromBlob) + */ + async cacheMediaFromBlob( + url: string, + blob: Blob, + type: CacheMediaType, + metadata?: { taskId?: string; prompt?: string; model?: string } + ): Promise { + try { + // 1. 将 blob 放入 Cache API(通过创建 Response) + if (typeof caches !== 'undefined') { + const cache = await caches.open(IMAGE_CACHE_NAME); + const response = new Response(blob, { + headers: { + 'Content-Type': blob.type || 'application/octet-stream', + 'Content-Length': blob.size.toString(), + }, + }); + await cache.put(url, response); + } + + // 2. 存储元数据到 IndexedDB + const now = Date.now(); + const item: CachedMedia = { + url, + type, + mimeType: blob.type || (type === 'video' ? 'video/mp4' : 'image/png'), + size: blob.size, + cachedAt: now, + lastUsed: now, + metadata: metadata || {}, + }; + + await this.putItem(item); + this.cachedUrls.add(url); + this.notifyListeners(); + + console.log('[UnifiedCache] Media cached from blob:', { url, type, size: blob.size }); + return url; + } catch (error) { + this.handleQuotaError(error); + console.error('[UnifiedCache] Failed to cache media from blob:', error); + throw error; + } + } + + /** + * 获取缓存的 Blob(兼容 urlCacheService.getVideoAsBlob) + */ + async getCachedBlob(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + return null; + } + return await response.blob(); + } catch (error) { + console.error('[UnifiedCache] Failed to get cached blob:', error); + return null; + } + } + + /** + * 获取缓存状态(兼容 mediaCacheService.getCacheStatus) + */ + getCacheStatus(url: string): CacheStatus { + if (this.cachedUrls.has(url)) { + return 'cached'; + } + return 'none'; + } + + /** + * 获取缓存的 URL(兼容 mediaCacheService.getCachedUrl) + * 如果已缓存返回原 URL,否则返回 null + */ + async getCachedUrl(url: string): Promise { + const cached = await this.isCached(url); + return cached ? url : null; + } + + /** + * 初始化缓存状态(兼容旧 API,实际上在构造函数中已自动初始化) + */ + async initCacheStatus(): Promise { + await this.refreshCacheState(); + } + // ==================== 事件订阅 ==================== /** diff --git a/packages/drawnix/src/services/url-cache-service.ts b/packages/drawnix/src/services/url-cache-service.ts deleted file mode 100644 index 03e580db..00000000 --- a/packages/drawnix/src/services/url-cache-service.ts +++ /dev/null @@ -1,376 +0,0 @@ -/** - * URL Cache Service - * - * 混合缓存策略:内存缓存 + IndexedDB 持久化 - * - 图片:转换为 Base64 存储,永久有效 - * - 视频:存储 Blob,通过 ObjectURL 访问 - */ - -import { DataURL } from '../types'; -import { getDataURL } from '../data/blob'; - -// IndexedDB 配置 -const DB_NAME = 'aitu-url-cache'; -const DB_VERSION = 1; -const STORE_NAME = 'media-cache'; - -// 缓存条目接口 -interface CacheEntry { - url: string; // 原始 URL(作为 key) - type: 'image' | 'video'; - data: string; // Base64 for images, stored as string - blob?: Blob; // Blob for videos - mimeType: string; - size: number; - cachedAt: number; -} - -/** - * URL 缓存服务 - * 单例模式,提供统一的缓存管理 - */ -class UrlCacheService { - // 内存缓存层 - private memoryCache: Map = new Map(); - private videoBlobCache: Map = new Map(); - - // IndexedDB 实例 - private db: IDBDatabase | null = null; - private dbPromise: Promise | null = null; - - // 正在进行的下载任务(防止重复下载) - private pendingDownloads: Map> = new Map(); - - constructor() { - this.initDB(); - } - - /** - * 初始化 IndexedDB - */ - private async initDB(): Promise { - if (this.db) return this.db; - if (this.dbPromise) return this.dbPromise; - - this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = () => { - console.error('[UrlCache] Failed to open database:', request.error); - reject(request.error); - }; - - request.onsuccess = () => { - this.db = request.result; - console.log('[UrlCache] Database opened successfully'); - resolve(this.db); - }; - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - const store = db.createObjectStore(STORE_NAME, { keyPath: 'url' }); - store.createIndex('cachedAt', 'cachedAt', { unique: false }); - store.createIndex('type', 'type', { unique: false }); - console.log('[UrlCache] Object store created'); - } - }; - }); - - return this.dbPromise; - } - - /** - * 获取图片的 Base64 数据(带缓存) - * @param imageUrl 图片 URL - * @returns Base64 DataURL - */ - async getImageAsBase64(imageUrl: string): Promise { - // 1. 检查内存缓存 - const memoryCached = this.memoryCache.get(imageUrl); - if (memoryCached) { - console.log('[UrlCache] Memory cache hit for:', imageUrl.substring(0, 50)); - return memoryCached; - } - - // 2. 检查是否已经有进行中的下载 - const pending = this.pendingDownloads.get(imageUrl); - if (pending) { - console.log('[UrlCache] Waiting for pending download:', imageUrl.substring(0, 50)); - return pending; - } - - // 3. 检查 IndexedDB 缓存 - const dbCached = await this.getFromIndexedDB(imageUrl); - if (dbCached && dbCached.type === 'image') { - const dataURL = dbCached.data as DataURL; - this.memoryCache.set(imageUrl, dataURL); - console.log('[UrlCache] IndexedDB cache hit for:', imageUrl.substring(0, 50)); - return dataURL; - } - - // 4. 下载并缓存 - const downloadPromise = this.downloadAndCacheImage(imageUrl); - this.pendingDownloads.set(imageUrl, downloadPromise); - - try { - const dataURL = await downloadPromise; - return dataURL; - } finally { - this.pendingDownloads.delete(imageUrl); - } - } - - /** - * 下载图片并转换为 Base64,存入缓存 - */ - private async downloadAndCacheImage(imageUrl: string): Promise { - console.log('[UrlCache] Downloading image:', imageUrl.substring(0, 50)); - - try { - const response = await fetch(imageUrl); - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.status}`); - } - - const blob = await response.blob(); - const dataURL = await getDataURL(blob); - - // 存入内存缓存 - this.memoryCache.set(imageUrl, dataURL); - - // 存入 IndexedDB - const entry: CacheEntry = { - url: imageUrl, - type: 'image', - data: dataURL, - mimeType: blob.type, - size: blob.size, - cachedAt: Date.now(), - }; - await this.saveToIndexedDB(entry); - - console.log('[UrlCache] Image cached successfully:', { - url: imageUrl.substring(0, 50), - size: `${(blob.size / 1024).toFixed(1)} KB`, - }); - - return dataURL; - } catch (error) { - console.error('[UrlCache] Failed to download image:', error); - throw error; - } - } - - /** - * 获取视频的 Blob(带缓存) - * @param videoUrl 视频 URL - * @returns Blob 和 ObjectURL - */ - async getVideoAsBlob(videoUrl: string): Promise<{ blob: Blob; objectUrl: string }> { - // 1. 检查内存缓存 - const memoryCached = this.videoBlobCache.get(videoUrl); - if (memoryCached) { - console.log('[UrlCache] Video memory cache hit for:', videoUrl.substring(0, 50)); - return { - blob: memoryCached, - objectUrl: URL.createObjectURL(memoryCached), - }; - } - - // 2. 检查 IndexedDB 缓存 - const dbCached = await this.getFromIndexedDB(videoUrl); - if (dbCached && dbCached.type === 'video' && dbCached.blob) { - this.videoBlobCache.set(videoUrl, dbCached.blob); - console.log('[UrlCache] Video IndexedDB cache hit for:', videoUrl.substring(0, 50)); - return { - blob: dbCached.blob, - objectUrl: URL.createObjectURL(dbCached.blob), - }; - } - - // 3. 下载并缓存 - return this.downloadAndCacheVideo(videoUrl); - } - - /** - * 下载视频并存入缓存 - */ - private async downloadAndCacheVideo(videoUrl: string): Promise<{ blob: Blob; objectUrl: string }> { - console.log('[UrlCache] Downloading video:', videoUrl.substring(0, 50)); - - try { - const response = await fetch(videoUrl); - if (!response.ok) { - throw new Error(`Failed to fetch video: ${response.status}`); - } - - const blob = await response.blob(); - - // 存入内存缓存 - this.videoBlobCache.set(videoUrl, blob); - - // 存入 IndexedDB - const entry: CacheEntry = { - url: videoUrl, - type: 'video', - data: '', // 视频不存 Base64 - blob: blob, - mimeType: blob.type, - size: blob.size, - cachedAt: Date.now(), - }; - await this.saveToIndexedDB(entry); - - console.log('[UrlCache] Video cached successfully:', { - url: videoUrl.substring(0, 50), - size: `${(blob.size / 1024 / 1024).toFixed(1)} MB`, - }); - - return { - blob, - objectUrl: URL.createObjectURL(blob), - }; - } catch (error) { - console.error('[UrlCache] Failed to download video:', error); - throw error; - } - } - - /** - * 从 IndexedDB 获取缓存 - */ - private async getFromIndexedDB(url: string): Promise { - try { - const db = await this.initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.get(url); - - request.onsuccess = () => resolve(request.result || null); - request.onerror = () => reject(request.error); - }); - } catch (error) { - console.error('[UrlCache] Failed to get from IndexedDB:', error); - return null; - } - } - - /** - * 保存到 IndexedDB - */ - private async saveToIndexedDB(entry: CacheEntry): Promise { - try { - const db = await this.initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.put(entry); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - } catch (error) { - console.error('[UrlCache] Failed to save to IndexedDB:', error); - } - } - - /** - * 检查 URL 是否已缓存 - */ - async isCached(url: string): Promise { - if (this.memoryCache.has(url) || this.videoBlobCache.has(url)) { - return true; - } - const cached = await this.getFromIndexedDB(url); - return cached !== null; - } - - /** - * 清除指定 URL 的缓存 - */ - async clearCache(url: string): Promise { - this.memoryCache.delete(url); - this.videoBlobCache.delete(url); - - try { - const db = await this.initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.delete(url); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - } catch (error) { - console.error('[UrlCache] Failed to clear cache:', error); - } - } - - /** - * 清除所有缓存 - */ - async clearAllCache(): Promise { - this.memoryCache.clear(); - this.videoBlobCache.clear(); - - try { - const db = await this.initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.clear(); - - request.onsuccess = () => { - console.log('[UrlCache] All cache cleared'); - resolve(); - }; - request.onerror = () => reject(request.error); - }); - } catch (error) { - console.error('[UrlCache] Failed to clear all cache:', error); - } - } - - /** - * 获取缓存统计信息 - */ - async getCacheStats(): Promise<{ - memoryImageCount: number; - memoryVideoCount: number; - indexedDBCount: number; - totalSize: number; - }> { - let indexedDBCount = 0; - let totalSize = 0; - - try { - const db = await this.initDB(); - const entries = await new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.getAll(); - - request.onsuccess = () => resolve(request.result || []); - request.onerror = () => reject(request.error); - }); - - indexedDBCount = entries.length; - totalSize = entries.reduce((sum, entry) => sum + (entry.size || 0), 0); - } catch (error) { - console.error('[UrlCache] Failed to get cache stats:', error); - } - - return { - memoryImageCount: this.memoryCache.size, - memoryVideoCount: this.videoBlobCache.size, - indexedDBCount, - totalSize, - }; - } -} - -// 导出单例实例 -export const urlCacheService = new UrlCacheService(); diff --git a/packages/drawnix/src/services/video-merge-webcodecs.ts b/packages/drawnix/src/services/video-merge-webcodecs.ts index b11c5619..0415ba82 100644 --- a/packages/drawnix/src/services/video-merge-webcodecs.ts +++ b/packages/drawnix/src/services/video-merge-webcodecs.ts @@ -305,13 +305,13 @@ async function mergeVideosWithMediaRecorder( // 缓存到 IndexedDB try { - const { mediaCacheService } = await import('./media-cache-service'); + const { unifiedCacheService } = await import('./unified-cache-service'); const taskId = `merged-video-${Date.now()}`; - const cachedUrl = await mediaCacheService.cacheMediaFromBlob( + const cachedUrl = await unifiedCacheService.cacheMediaFromBlob( taskId, finalBlob, 'video', - simpleMimeType // 使用简化的 MIME 类型 + { taskId } ); console.log(`[WebCodecs] Video cached to IndexedDB: ${taskId}, mimeType: ${simpleMimeType}`); diff --git a/packages/drawnix/src/services/video-recovery-service.ts b/packages/drawnix/src/services/video-recovery-service.ts index 032a31a8..01d7e0ef 100644 --- a/packages/drawnix/src/services/video-recovery-service.ts +++ b/packages/drawnix/src/services/video-recovery-service.ts @@ -5,7 +5,7 @@ * 从 IndexedDB 中重新创建 Blob URL 并更新画布元素 */ -import { mediaCacheService } from './media-cache-service'; +import { unifiedCacheService } from './unified-cache-service'; import type { PlaitBoard } from '@plait/core'; import { Transforms } from '@plait/core'; @@ -46,26 +46,21 @@ export async function recoverExpiredVideoUrls(board: PlaitBoard | null): Promise // 对于合并视频,刷新后 Blob URL 一定会失效,直接恢复即可 // 从 IndexedDB 恢复 try { - const cached = await mediaCacheService.getCachedMedia(taskId); - if (cached && cached.blob) { - const newBlobUrl = URL.createObjectURL(cached.blob); + const cachedBlob = await unifiedCacheService.getCachedBlob(taskId); + if (cachedBlob) { + const newBlobUrl = URL.createObjectURL(cachedBlob); const newUrl = `${newBlobUrl}#${taskId}`; // 使用 Transforms.setNode 更新元素(Plait 元素是只读的) - Transforms.setNode( - board, - { url: newUrl } as any, - { - at: board.children.indexOf(element), - } - ); + const elementIndex = board.children.indexOf(element); + Transforms.setNode(board, { url: newUrl } as any, [elementIndex]); recoveredCount++; console.log('[VideoRecovery] Video URL recovered:', { taskId, oldUrl: url, newUrl, - size: cached.blob.size, + size: cachedBlob.size, }); } else { console.warn('[VideoRecovery] Cache not found for taskId:', taskId);