Files

19 KiB
Raw Permalink Blame History

Research: 素材管理库 (Media Library) - Technical Decisions

Feature: 009-media-library Date: 2025-12-11 Status: Completed

Overview

本文档记录素材管理库功能的技术研究和决策包括存储策略、状态管理、UI架构和最佳实践。

1. IndexedDB Storage Strategy

Decision

使用 localforage 库作为 IndexedDB 的包装器进行素材数据持久化存储。

Rationale

  1. 简化API: localforage 提供类似 localStorage 的简单 API同时利用 IndexedDB 的强大功能
  2. 跨浏览器兼容: 自动降级到 WebSQL 或 localStorage如果 IndexedDB 不可用)
  3. Promise-based: 原生支持 async/await易于与 React 集成
  4. 项目已使用: 现有代码库已经使用 localforagemedia-cache-service.ts, storage-service.ts
  5. Blob 支持: 原生支持存储 Blob 对象,适合媒体文件

Alternatives Considered

  • 直接使用 IndexedDB API: 过于底层,需要更多样板代码
  • Dexie.js: 功能强大但增加bundle大小对于我们的用例过度设计
  • idb: 轻量但缺少 localforage 的自动降级特性

Implementation Details

// 存储配置
const assetStore = localforage.createInstance({
  name: 'aitu-assets',
  storeName: 'assets',
  description: 'Media library assets storage'
});

// 数据结构
interface StoredAsset {
  id: string;
  type: AssetType;
  source: AssetSource;
  name: string;
  mimeType: string;
  createdAt: number;
  blobData: Blob;  // 存储实际文件数据
}

Best Practices

  1. 分离元数据和 Blob: 考虑使用两个storemetadata + blobs以提高查询性能
  2. 错误处理: 包装所有 storage 操作在 try-catch 中,处理 QuotaExceededError
  3. 清理策略: 提供手动删除和下载功能,让用户管理空间
  4. 批量操作: 对于初始加载,批量读取元数据而不是逐个加载

2. Storage Quota Management

Decision

实现主动的存储配额监控,在达到 80% 时警告用户。

Rationale

  1. 用户体验: 主动警告优于突然的存储失败
  2. 规范支持: Storage API 的 navigator.storage.estimate() 广泛支持
  3. 可操作性: 用户可以在空间不足前采取行动(删除或下载旧素材)

Implementation Details

// 存储配额检查
async function checkStorageQuota(): Promise<{
  usage: number;
  quota: number;
  percentUsed: number;
}> {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    const usage = estimate.usage || 0;
    const quota = estimate.quota || 0;
    return {
      usage,
      quota,
      percentUsed: (usage / quota) * 100
    };
  }
  return { usage: 0, quota: 0, percentUsed: 0 };
}

// 在添加素材前检查
async function canAddAsset(fileSize: number): Promise<boolean> {
  const { usage, quota } = await checkStorageQuota();
  return (usage + fileSize) < quota * 0.95; // 保留5%缓冲
}

Best Practices

  1. 定期检查: 在组件挂载和每次添加素材后检查配额
  2. 视觉反馈: 使用进度条直观显示使用量
  3. 警告阈值: 80% 显示警告95% 阻止新上传
  4. 降级处理: 如果 API 不可用,显示估计值或隐藏进度条

3. React State Management with Context

Decision

使用 React Context API 进行素材库状态管理而不是引入Redux或其他状态管理库。

Rationale

  1. 简单性: 素材库状态相对独立,不需要全局状态管理的复杂性
  2. 项目一致性: 现有代码使用 DrawnixContext 模式
  3. 性能: 使用 useMemo 和 useCallback 优化,避免不必要的重渲染
  4. 局部性: 状态只在素材库弹窗中使用,不需要跨应用共享

Alternatives Considered

  • Redux: 过度设计增加复杂性和bundle大小
  • Zustand: 轻量但增加新依赖Context足够
  • 组件状态: 无法在多个组件间共享,难以维护

Implementation Pattern

interface AssetContextValue {
  assets: Asset[];
  loading: boolean;
  error: string | null;
  addAsset: (file: File | Blob, type: AssetType, source: AssetSource) => Promise<void>;
  removeAsset: (id: string) => Promise<void>;
  renameAsset: (id: string, newName: string) => Promise<void>;
  loadAssets: () => Promise<void>;
}

export const AssetContext = createContext<AssetContextValue | null>(null);

export function AssetProvider({ children }: { children: ReactNode }) {
  const [assets, setAssets] = useState<Asset[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // 实现各种操作...

  const value = useMemo(() => ({
    assets,
    loading,
    error,
    addAsset,
    removeAsset,
    renameAsset,
    loadAssets
  }), [assets, loading, error]);

  return <AssetContext.Provider value={value}>{children}</AssetContext.Provider>;
}

export function useAssets() {
  const context = useContext(AssetContext);
  if (!context) throw new Error('useAssets must be used within AssetProvider');
  return context;
}

Best Practices

  1. 拆分Context: 如果状态变大考虑拆分为多个ContextAssetsContext, FiltersContext
  2. Memoization: 对过滤/搜索结果使用 useMemo
  3. 错误边界: 包装Provider在ErrorBoundary中
  4. 加载状态: 提供细粒度的加载状态loading, error, data

4. Component Architecture

Decision

采用容器/展示组件模式,将素材库分解为职责明确的小组件。

Rationale

  1. 500行限制: 符合项目宪章的文件大小约束
  2. 可维护性: 每个组件职责单一,易于理解和测试
  3. 可复用性: 小组件可以在其他上下文中复用
  4. 性能: 细粒度的组件边界便于React.memo优化

Component Hierarchy

MediaLibraryModal (容器)
├── MediaLibrarySidebar (展示)
│   ├── FilterSection (展示)
│   └── StorageBar (展示)
├── MediaLibraryGrid (容器)
│   ├── MediaLibraryEmpty (展示)
│   └── AssetGridItem (展示) × N
└── MediaLibraryInspector (容器)
    ├── AssetPreview (展示)
    ├── AssetMetadata (展示)
    └── AssetActions (展示)

Best Practices

  1. 容器组件: 处理状态和逻辑使用hooks
  2. 展示组件: 只接收props使用React.memo包装
  3. Props drilling: 最多3层超过则考虑Context或组合模式
  4. 事件处理: 在容器组件中定义用useCallback包装后传递

5. Integration with AI Generation Dialogs

Decision

在AI生成对话框中添加下拉菜单或按钮组,让用户选择素材来源(素材库 vs 本地文件)。

Rationale

  1. 用户控制: 明确的选择点,用户知道他们在做什么
  2. 渐进式增强: 保留现有本地文件选择功能
  3. 清晰的UX: 两个选项并排,易于发现
  4. 最小侵入: 不需要重构现有对话框结构

Implementation Approach

// 在 ai-image-generation.tsx 中
function ImageUploadSection() {
  const [uploadSource, setUploadSource] = useState<'local' | 'library'>('local');
  const [showMediaLibrary, setShowMediaLibrary] = useState(false);

  return (
    <>
      <div className="upload-source-selector">
        <Button
          variant={uploadSource === 'local' ? 'base' : 'outline'}
          onClick={() => setUploadSource('local')}
        >
          从本地选择
        </Button>
        <Button
          variant={uploadSource === 'library' ? 'base' : 'outline'}
          onClick={() => {
            setUploadSource('library');
            setShowMediaLibrary(true);
          }}
        >
          从素材库选择
        </Button>
      </div>

      {showMediaLibrary && (
        <MediaLibraryModal
          isOpen={showMediaLibrary}
          onClose={() => setShowMediaLibrary(false)}
          onSelect={(asset) => {
            setReferenceImage(asset);
            setShowMediaLibrary(false);
          }}
          filterType="IMAGE"  // 只显示图片
        />
      )}
    </>
  );
}

Best Practices

  1. 类型筛选: 传递 filterType prop 限制显示的素材类型
  2. 回调清晰: onSelect 回调接收Asset对象包含所有必要信息
  3. 状态同步: 选择后自动关闭弹窗,更新引用图片预览
  4. 错误处理: 处理素材已被删除的情况

6. Auto-save AI Generated Content

Decision

在任务队列服务中监听AI生成任务完成事件自动将成功的结果添加到素材库。

Rationale

  1. 自动化: 用户不需要手动保存,减少操作步骤
  2. 集成点清晰: 任务队列是唯一的生成任务管理点
  3. 错误处理: 只保存成功的生成结果,失败任务不添加
  4. 可追溯: 保留提示词信息作为素材名称

Implementation Approach

// 在 asset-integration-service.ts 中
import { taskQueueService } from './task-queue-service';

function initializeAutoSave() {
  // 订阅任务完成事件
  taskQueueService.tasks$.subscribe((tasks) => {
    tasks.forEach(async (task) => {
      if (task.status === 'completed' && task.resultUrl && !task.savedToLibrary) {
        // 根据任务类型确定素材类型
        const assetType = task.type === 'image-generation'
          ? AssetType.IMAGE
          : AssetType.VIDEO;

        // 获取结果 Blob
        const response = await fetch(task.resultUrl);
        const blob = await response.blob();

        // 生成名称使用提示词前20字符
        const name = task.prompt
          ? `${task.prompt.substring(0, 20)}...`
          : `AI${assetType}-${new Date().toISOString()}`;

        // 添加到素材库
        await assetStorageService.addAsset({
          type: assetType,
          source: AssetSource.AI_GENERATED,
          name,
          blob,
          mimeType: blob.type
        });

        // 标记已保存
        taskQueueService.markAsSaved(task.id);
      }
    });
  });
}

Best Practices

  1. 幂等性: 使用 savedToLibrary 标志避免重复添加
  2. 错误容忍: 保存失败不应影响任务队列正常运行
  3. 命名策略: 提供有意义的默认名称,用户可以后续修改
  4. 元数据: 考虑保存提示词、模型名称等额外元数据

7. File Validation and Security

Decision

实现多层文件验证MIME类型检查、文件扩展名检查和Magic Number验证。

Rationale

  1. 安全性: 防止恶意文件伪装成图片/视频
  2. 用户体验: 早期验证,提供清晰的错误消息
  3. 存储效率: 避免存储无效文件
  4. 类型安全: 确保所有素材都是有效的媒体文件

Implementation Details

const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg'];

async function validateFile(file: File): Promise<{
  valid: boolean;
  error?: string;
}> {
  // 1. MIME类型检查
  const allowedTypes = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_VIDEO_TYPES];
  if (!allowedTypes.includes(file.type)) {
    return {
      valid: false,
      error: `不支持的文件类型: ${file.type}。只支持图片JPG, PNG, GIF, WebP和视频MP4, WebM, OGG。`
    };
  }

  // 2. 文件大小检查(警告但不阻止)
  const maxSize = 100 * 1024 * 1024; // 100MB
  if (file.size > maxSize) {
    console.warn(`文件较大 (${(file.size / 1024 / 1024).toFixed(2)}MB),可能影响性能`);
  }

  // 3. Magic Number验证可选但推荐
  const isValidMagicNumber = await validateMagicNumber(file);
  if (!isValidMagicNumber) {
    return {
      valid: false,
      error: '文件内容与声明的类型不匹配。'
    };
  }

  return { valid: true };
}

async function validateMagicNumber(file: File): Promise<boolean> {
  // 读取文件前几个字节检查magic number
  const buffer = await file.slice(0, 12).arrayBuffer();
  const bytes = new Uint8Array(buffer);

  // 检查常见格式的magic number
  // JPEG: FF D8 FF
  if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return true;
  // PNG: 89 50 4E 47
  if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return true;
  // MP4: 根据 ftyp box
  // ... 更多格式检查

  return false;
}

Best Practices

  1. 用户友好错误: 提供具体的错误消息和支持的格式列表
  2. 渐进增强: Magic number验证可选基础MIME检查必须
  3. 性能: 只读取必要的字节数进行验证
  4. 日志记录: 记录验证失败,但不要暴露给用户敏感信息

8. Performance Optimization Strategies

Decision

实施多层性能优化组件memo化、虚拟滚动如需要、图片懒加载和渐进式加载。

Rationale

  1. 初始加载: 100个素材需要快速渲染
  2. 交互响应: 筛选和搜索必须流畅
  3. 内存效率: 避免同时加载所有Blob数据
  4. 用户体验: 平滑的滚动和即时的反馈

Optimization Techniques

1. Component Memoization

export const AssetGridItem = React.memo<AssetGridItemProps>(({
  asset,
  isSelected,
  onSelect,
  onDoubleClick
}) => {
  // 组件实现
}, (prevProps, nextProps) => {
  // 自定义比较函数
  return prevProps.asset.id === nextProps.asset.id &&
         prevProps.isSelected === nextProps.isSelected;
});

2. Lazy Loading Images

function AssetThumbnail({ url }: { url: string }) {
  const [src, setSrc] = useState<string>('');
  const imgRef = useRef<HTMLImageElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setSrc(url);
        observer.disconnect();
      }
    });

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, [url]);

  return <img ref={imgRef} src={src} alt="" />;
}

3. Filtered List Memoization

const filteredAssets = useMemo(() => {
  return assets
    .filter(asset => {
      const matchesType = activeType === 'ALL' || asset.type === activeType;
      const matchesSource = activeSource === 'ALL' || asset.source === activeSource;
      const matchesSearch = asset.name.toLowerCase().includes(searchQuery.toLowerCase());
      return matchesType && matchesSource && matchesSearch;
    })
    .sort((a, b) => {
      if (sortBy === 'DATE_DESC') return b.createdAt - a.createdAt;
      if (sortBy === 'DATE_ASC') return a.createdAt - b.createdAt;
      if (sortBy === 'NAME_ASC') return a.name.localeCompare(b.name);
      return 0;
    });
}, [assets, activeType, activeSource, searchQuery, sortBy]);

4. Virtual Scrolling (如需要)

// 如果素材数量>200考虑使用 react-window
import { FixedSizeGrid } from 'react-window';

function VirtualizedGrid({ assets }: { assets: Asset[] }) {
  const COLUMN_COUNT = 5;
  const ROW_HEIGHT = 200;
  const COLUMN_WIDTH = 180;

  return (
    <FixedSizeGrid
      columnCount={COLUMN_COUNT}
      columnWidth={COLUMN_WIDTH}
      height={600}
      rowCount={Math.ceil(assets.length / COLUMN_COUNT)}
      rowHeight={ROW_HEIGHT}
      width={900}
    >
      {({ columnIndex, rowIndex, style }) => {
        const index = rowIndex * COLUMN_COUNT + columnIndex;
        const asset = assets[index];
        return asset ? (
          <div style={style}>
            <AssetGridItem asset={asset} />
          </div>
        ) : null;
      }}
    </FixedSizeGrid>
  );
}

Best Practices

  1. 测量先行: 使用 React DevTools Profiler 识别瓶颈
  2. 渐进优化: 先实现基本功能,再根据实际性能问题优化
  3. 避免过早优化: 虚拟滚动只在>200素材时考虑
  4. 监控指标: 跟踪初始加载时间、筛选响应时间

9. Error Handling and User Feedback

Decision

实施分层错误处理存储层错误、网络错误、验证错误和UI错误状态。

Rationale

  1. 用户体验: 清晰的错误消息帮助用户理解问题
  2. 可恢复性: 区分可重试和永久性错误
  3. 调试: 详细的错误日志帮助定位问题
  4. 安全性: 不向用户暴露敏感信息

Error Categories and Handling

1. Storage Errors

async function handleStorageError(error: Error): Promise<void> {
  if (error.name === 'QuotaExceededError') {
    MessagePlugin.error({
      content: '存储空间已满。请删除一些旧素材或下载后删除以释放空间。',
      duration: 5000
    });
  } else if (error.name === 'NotFoundError') {
    MessagePlugin.warning({
      content: '素材未找到,可能已被删除。',
      duration: 3000
    });
  } else {
    console.error('Storage error:', error);
    MessagePlugin.error({
      content: '存储操作失败,请刷新页面重试。',
      duration: 3000
    });
  }
}

2. Validation Errors

function showValidationError(message: string) {
  MessagePlugin.warning({
    content: message,
    duration: 3000,
    theme: 'warning'
  });
}

3. Global Error Boundary

class MediaLibraryErrorBoundary extends React.Component<
  { children: ReactNode },
  { hasError: boolean; error?: Error }
> {
  state = { hasError: false, error: undefined };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('MediaLibrary error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-state">
          <h3>素材库加载失败</h3>
          <p>请刷新页面重试</p>
          <Button onClick={() => window.location.reload()}>
            刷新页面
          </Button>
        </div>
      );
    }

    return this.props.children;
  }
}

Best Practices

  1. 用户语言: 使用非技术术语的错误消息
  2. 可操作: 提供具体的解决步骤
  3. 日志记录: 详细记录错误到控制台供开发调试
  4. 优雅降级: 错误时显示有用的回退UI

Summary of Decisions

领域 决策 理由
存储 localforage + IndexedDB 简单API已在项目中使用Blob支持好
配额管理 主动监控80%警告) 改善用户体验,避免突然失败
状态管理 React Context 简单、局部、符合项目模式
组件架构 容器/展示分离 符合500行限制可维护性高
AI集成 在对话框中添加选择器 最小侵入,保留现有功能
自动保存 订阅任务队列事件 自动化,集成点清晰
文件验证 MIME + Magic Number 安全性和用户体验平衡
性能优化 Memo + 懒加载 + 虚拟滚动 多层优化,渐进式应用
错误处理 分层处理 + Error Boundary 用户友好,便于调试

Next Steps

  1. Research完成
  2. → 进入 Phase 1: 设计数据模型和API契约
  3. → 生成 quickstart.md 开发指南
  4. → 更新 agent context
  5. → 进入 Phase 2: 生成任务分解 (tasks.md)

研究完成日期: 2025-12-11 准备进入: Phase 1 - Design & Contracts