素材包类型包含(图片、视频、音频、spine等文件) 整体精简方案

123 阅读6分钟

一、方案背景

目前我们系统使用的素材包类型包含(图片、视频、音频、spine等文件)

整体系统的数据结构设计,多有深层嵌套和频繁修改,新增、删除(相关文件类型)字段等、所以在开发中,需要频繁的对齐每次改动,很容易出现遗漏和冗余,所以我们制定了一套比较适合于我们系统的整体素材包整合方案

二、存在问题

● 结构多变:需频繁改动和对齐改动,开发层面 - 易遗漏和冗余-开发成本高。

● 冗余重复:同一素材多次上传、引用,导致后续打包素材巨大-打包速度慢-下载体积大-整体体验差

● 素材本身体积过大:导致整体包体积过大

三、核心目标

1.  整合:设置一套全局的素材整合方案,无需关心数据结构和改动

2.  精简:精简包体积,避免冗余素材

3.  压缩:优化文件体积,平衡质量与存储需求

四、整体方案

一、整合

1. 发布订阅模式-监听所有的上传事件,素材库接收上传文件
// 创建事件总线(Event Bus)
class EventBus {
  constructor() {
    this.events = {};
  }

  // 订阅事件
  on(eventName, callback) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(callback);
  }

  // 发布事件
  emit(eventName, data) {
    if (this.events[eventName]) {
      this.events[eventName].forEach(callback => {
        callback(data);
      });
    }
  }

  // 取消订阅
  off(eventName, callback) {
    if (this.events[eventName]) {
      this.events[eventName] = this.events[eventName].filter(
        cb => cb !== callback
      );
    }
  }
}

// 创建全局事件总线实例
const eventBus = new EventBus();

// 素材库模块 - 订阅上传事件
eventBus.on('fileUploaded', (fileData) => {
  console.log('素材库接收到上传文件:', fileData);
  // 处理上传的文件,如保存到数据库等

});

// 上传组件 - 触发上传事件
function uploadFile(file) {
    // cos上传 TODO
  
    // 发布上传完成事件
    eventBus.emit('fileUploaded', fileData);
    
    console.log('文件上传完成:', fileData);
  }, 1000);
}

 

二、 去重

1、文件上传去重复

1.思路

1.  哈希生成:使用SHA-256算法为上传的文件生成哈希值。

2.  哈希库查找:将生成的哈希值与存储在哈希库中的值进行比对。

3.  匹配处理:

a.  如果哈希值匹配,则直接返回对应素材的URL。

b.  如果不匹配,则将素材上传到对象存储服务(如COS)。

4.  上传与存储:上传成功后,将新素材的哈希值和URL存储到哈希库中。

2.详细实现步骤

● 计算文件哈希:使用FileReader读取文件内容,并通过CryptoJS.SHA256计算哈希值。

● 查询已存在的文件:通过API查询哈希库,找出已存在的文件信息。

● 上传文件匹配:校验文件唯一性,找出需要上传的新文件。

● 上传到COS:使用putCos.uploadFiles方法上传新文件,并处理上传进度和结果。

3.注意事项

● 哈希匹配:对于普通文件(图片、视频、音频),只需匹配单个文件的哈希值。对于spine文件,由于它包含多个内部文件,需要进行二维数组hash的全匹配(无序)。

● 文件名处理:哈希值不受文件名影响,因此匹配时不需要考虑文件名。但在实际应用中,可能需要记录文件名以便后续使用。

● 错误处理:在文件读取、哈希计算、API查询和文件上传过程中,处理可能出现的错误。

1.  上传侧处理:

a.  生成哈希值:在用户上传素材时,首先根据素材文件的内容生成一个唯一的哈希值(例如使用SHA-256算法)。

2.  哈希库查找:

a.  查询哈希库:将生成的哈希值与存储在素材维度哈希库中的哈希值进行比对。

b.  匹配成功:如果哈希库中存在相同的哈希值,则直接返回对应素材的URL,避免重复上传。

c.  匹配失败:如果哈希库中没有匹配的哈希值,则将素材上传到对象存储服务(如COS)。

3.  上传与存储:

a.  COS上传成功后:将新上传素材的哈希值和对应的URL存储到哈希库中,以便后续查询使用

// 文件上传去重处理器
class FileUploadDeduplicator {
  constructor(cosService, hashLibraryService) {
    this.cosService = cosService; // COS上传服务
    this.hashLibraryService = hashLibraryService; // 哈希库服务
  }

  /**
   * 处理文件上传(带去重逻辑)
   * @param {File} file - 要上传的文件
   * @returns {Promise<{url: string, isNewUpload: boolean}>} - 返回文件URL和是否是新上传的标志
   */
  async uploadFileWithDeduplication(file) {
    try {
      // 1. 计算文件哈希值
      const fileHash = await this.calculateFileHash(file);
      
      // 2. 查询哈希库
      const existingFile = await this.hashLibraryService.queryByHash(fileHash);
      
      if (existingFile) {
        // 3. 如果已存在,直接返回已有URL
        console.log(`文件已存在,直接返回URL: ${existingFile.url}`);
        return { url: existingFile.url, isNewUpload: false };
      } else {
        // 4. 如果不存在,上传到COS
        console.log(`文件不存在,开始上传: ${file.name}`);
        const uploadResult = await this.cosService.uploadFile(file);
        
        // 5. 将新文件信息存入哈希库
        await this.hashLibraryService.addHashRecord({
          hash: fileHash,
          url: uploadResult.url,
          fileName: file.name,
          fileType: file.type,
          size: file.size
        });
        
        return { url: uploadResult.url, isNewUpload: true };
      }
    } catch (error) {
      console.error('文件上传去重处理失败:', error);
      throw error;
    }
  }

  /**
   * 计算文件SHA-256哈希值
   * @param {File} file - 要计算哈希的文件
   * @returns {Promise<string>} - 返回文件的SHA-256哈希值
   */
  async calculateFileHash(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      
      reader.onload = async (event) => {
        try {
          const fileContent = event.target.result;
          // 使用浏览器内置的crypto API计算SHA-256
          const hashBuffer = await crypto.subtle.digest('SHA-256', fileContent);
          const hashArray = Array.from(new Uint8Array(hashBuffer));
          const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
          resolve(hashHex);
        } catch (err) {
          reject(err);
        }
      };
      
      reader.onerror = () => {
        reject(new Error('文件读取失败'));
      };
      
      reader.readAsArrayBuffer(file);
    });
  }
}

// COS上传服务模拟实现
class CosService {
  /**
   * 模拟COS文件上传
   * @param {File} file - 要上传的文件
   * @returns {Promise<{url: string}>} - 返回上传后的URL
   */
  async uploadFile(file) {
    // 这里应该是实际的COS上传逻辑
    console.log(`上传文件到COS: ${file.name}`);
    // 模拟上传延迟
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    // 生成模拟URL
    const randomId = Math.random().toString(36).substring(2, 10);
    const url = ` https://cos.example.com/files/${randomId}/${file.name}`; 
    
    return { url };
  }
}

// 哈希库服务模拟实现
class HashLibraryService {
  constructor() {
    // 模拟哈希库存储
    this.hashRecords = new Map();
  }

  /**
   * 根据哈希值查询文件记录
   * @param {string} hash - 文件哈希值
   * @returns {Promise<{url: string} | null>} - 返回文件记录或null
   */
  async queryByHash(hash) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 200));
    return this.hashRecords.get(hash) || null;
  }

  /**
   * 添加哈希记录
   * @param {object} record - 文件记录
   * @returns {Promise<void>}
   */
  async addHashRecord(record) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 200));
    this.hashRecords.set(record.hash, record);
  }
}

// 使用示例
async function main() {
  // 初始化服务
  const cosService = new CosService();
  const hashLibraryService = new HashLibraryService();
  const uploadProcessor = new FileUploadDeduplicator(cosService, hashLibraryService);

  // 模拟文件上传
  const mockFile = new File(['file content'], 'example.jpg', { type: 'image/jpeg' });
  
  // 第一次上传(应该是新上传)
  const result1 = await uploadProcessor.uploadFileWithDeduplication(mockFile);
  console.log('第一次上传结果:', result1);
  
  // 第二次上传相同内容文件(应该返回已有URL)
  const mockFileSameContent = new File(['file content'], 'different-name.jpg', { type: 'image/jpeg' });
  const result2 = await uploadProcessor.uploadFileWithDeduplication(mockFileSameContent);
  console.log('第二次上传结果:', result2);
}

main().catch(console.error);
特殊文件:spine文件处理
/**
   * 处理Spine文件上传(包含多个内部文件)
   * @param {File[]} files - Spine文件及其相关文件
   * @returns {Promise<{urls: string[], isNewUpload: boolean}>}
   */
  async uploadSpineFilesWithDeduplication(files) {
    // 1. 计算所有文件的哈希值(无序)
    const fileHashes = await Promise.all(
      files.map(file => this.calculateFileHash(file))
    );
    
    // 2. 查询哈希库(需要所有哈希值都匹配)
    const allExist = await this.hashLibraryService.querySpineFiles(fileHashes);
    
    if (allExist) {
      // 3. 如果全部已存在,直接返回已有URL
      return { urls: allExist.urls, isNewUpload: false };
    } else {
      // 4. 上传所有文件到COS
      const uploadResults = await Promise.all(
        files.map(file => this.cosService.uploadFile(file))
      );
      
      // 5. 将新文件信息存入哈希库
      await this.hashLibraryService.addSpineRecords({
        hashes: fileHashes,
        urls: uploadResults.map(r => r.url),
        fileNames: files.map(f => f.name)
      });
      
      return { urls: uploadResults.map(r => r.url), isNewUpload: true };
    }
  }

 

2. 保存数据时去重

1.思路

对保存时候的整体数据结构,和素材包保存的数据进行 对比, 从素材包中移除保存时候没有的数据

2.详细实现步骤

反序列化保存数据结构(因为保存的数据结构不定,大多会存在深层嵌套)

map 整体素材包结构

从素材包中移除 保存时候没有的数据

/**
 * 素材包清理工具
 */
class MaterialPackageCleaner {
  /**
   * 清理素材包中未被引用的素材
   * @param {Object|string} savedData - 保存的数据结构(可以是对象或JSON字符串)
   * @param {Object} materialPackage - 素材包数据 {key: cdnPath}
   * @returns {Object} 清理后的素材包
   */
  cleanUnusedMaterials(savedData, materialPackage) {
    // 1. 反序列化保存数据
    const parsedData = this._parseSavedData(savedData);
    
    // 2. 收集保存数据中所有引用的素材key
    const usedMaterialKeys = this._collectMaterialKeys(parsedData);
    
    // 3. 过滤素材包,只保留被引用的素材
    return this._filterMaterialPackage(materialPackage, usedMaterialKeys);
  }

  /**
   * 解析保存的数据
   * @private
   * @param {Object|string} data - 输入数据
   * @returns {Object} 解析后的对象
   */
  _parseSavedData(data) {
    if (typeof data === 'string') {
      try {
        return JSON.parse(data);
      } catch (e) {
        console.error('数据解析失败:', e);
        return {};
      }
    }
    return data || {};
  }

  /**
   * 递归收集所有素材key
   * @private
   * @param {Object} data - 要遍历的数据
   * @param {Set} [keys=new Set()] - 存储key的集合
   * @returns {Set<string>} 所有素材key的集合
   */
  _collectMaterialKeys(data, keys = new Set()) {
    if (!data || typeof data !== 'object') {
      return keys;
    }

    // 处理数组
    if (Array.isArray(data)) {
      data.forEach(item => this._collectMaterialKeys(item, keys));
      return keys;
    }

    // 处理对象
    for (const key in data) {
      if (Object.prototype.hasOwnProperty.call(data, key)) {
        const value = data[key];
        
        // 假设包含materialKey字段的是素材引用
        if (key === 'materialKey' && typeof value === 'string') {
          keys.add(value);
        }
        
        // 递归处理嵌套结构
        if (typeof value === 'object') {
          this._collectMaterialKeys(value, keys);
        }
      }
    }
    
    return keys;
  }

  /**
   * 过滤素材包
   * @private
   * @param {Object} materialPackage - 原始素材包
   * @param {Set<string>} usedKeys - 被引用的key集合
   * @returns {Object} 过滤后的素材包
   */
  _filterMaterialPackage(materialPackage, usedKeys) {
    const cleanedPackage = {};
    
    for (const key in materialPackage) {
      if (usedKeys.has(key)) {
        cleanedPackage[key] = materialPackage[key];
      } else {
        console.log(`移除未使用的素材: ${key}`);
      }
    }
    
    return cleanedPackage;
  }
}

// 使用示例
const cleaner = new MaterialPackageCleaner();

// 模拟保存的数据结构(包含深层嵌套)
const savedData = {
  pages: [
    {
      elements: [
        { type: 'image', materialKey: 'img1' },
        { 
          type: 'group',
          children: [
            { type: 'text', materialKey: 'text1' },
            { type: 'video', materialKey: 'video1' }
          ]
        }
      ]
    }
  ],
  settings: {
    background: { materialKey: 'bg1' }
  }
};

// 模拟素材包数据
const materialPackage = {
  img1: ' https://cdn.example.com/img1.jpg', 
  img2: ' https://cdn.example.com/img2.jpg',  // 未被引用
  text1: ' https://cdn.example.com/text1.json', 
  video1: ' https://cdn.example.com/video1.mp4', 
  bg1: ' https://cdn.example.com/bg1.png', 
  audio1: ' https://cdn.example.com/audio1.mp3'  // 未被引用
};

// 执行清理
const cleanedPackage = cleaner.cleanUnusedMaterials(savedData, materialPackage);
console.log('清理后的素材包:', cleanedPackage);

三、 压缩

我们使用的上传为cos上传,图片开启了cos的极智压缩,将图片转为webp格式

五、总结

1.  发布订阅模式:通过事件总线机制实现素材上传的统一管理,解耦上传组件与素材库,使系统更易扩展和维护。

2.  智能去重机制:

a.  文件级去重:基于SHA-256哈希算法识别重复文件,避免重复上传和存储

b.  Spine文件特殊处理:支持多文件组合的去重判断

c.  数据结构去重:清理素材包中未被引用的冗余素材

3.  高效压缩策略:

a.  图片自动转换为WebP格式

b.  利用COS极智压缩功能优化存储空间