优化缓存
This commit is contained in:
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: 获取完整的旧服务使用清单,包括文件路径、行号和具体调用方式
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user