优化缓存

This commit is contained in:
liquid committed 2026-01-08 23:08:37 +08:00
1 parent 767f8c8d9a
commit 7b65b7c2d1
15 files changed
+309 -854

No files matched your search

@@ -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: 获取完整的旧服务使用清单,包括文件路径、行号和具体调用方式
+5 -5
View File
@@ -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}`;
// 返回更新后的元素
@@ -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<DialogTaskListProps> = ({
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;
}
@@ -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<TaskQueuePanelProps> = ({
// 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<TaskQueuePanelProps> = ({
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;
@@ -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);
+5 -4
View File
@@ -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
+2 -2
View File
@@ -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);
});
+49 -30
View File
@@ -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),
};
}
+3 -3
View File
@@ -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';
@@ -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<IDBDatabase> | null = null;
private cacheStatusMap: Map<string, CacheStatus> = new Map();
private listeners: Set<() => void> = new Set();
constructor() {
// Initialize database on service creation
this.initDB();
}
/**
* Initialize IndexedDB database
*/
private async initDB(): Promise<IDBDatabase> {
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<string> {
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<boolean> {
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<void> {
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<CachedMedia | null> {
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<string | null> {
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<boolean> {
const cached = await this.getCachedMedia(taskId);
return cached !== null;
}
/**
* Delete cached media
*/
async deleteCache(taskId: string): Promise<boolean> {
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<string[]> {
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<number> {
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<boolean> {
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<void> {
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();
@@ -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...');
@@ -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<string> {
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<Blob | null> {
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<string | null> {
const cached = await this.isCached(url);
return cached ? url : null;
}
/**
* 初始化缓存状态(兼容旧 API,实际上在构造函数中已自动初始化)
*/
async initCacheStatus(): Promise<void> {
await this.refreshCacheState();
}
// ==================== 事件订阅 ====================
/**
@@ -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<string, DataURL> = new Map();
private videoBlobCache: Map<string, Blob> = new Map();
// IndexedDB 实例
private db: IDBDatabase | null = null;
private dbPromise: Promise<IDBDatabase> | null = null;
// 正在进行的下载任务(防止重复下载)
private pendingDownloads: Map<string, Promise<DataURL>> = new Map();
constructor() {
this.initDB();
}
/**
* 初始化 IndexedDB
*/
private async initDB(): Promise<IDBDatabase> {
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<DataURL> {
// 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<DataURL> {
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<CacheEntry | null> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<CacheEntry[]>((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();
@@ -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}`);
@@ -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);