Electron:使用数据流的形式加载本地视频

1.引言

在桌面应用开发中,本地视频的高效加载与播放是提升用户体验的关键。本文介绍如何通过Electron的协议处理器与Node.js流式API,实现高性能的本地视频加载方案。

自定义协议流传输的优势

特性 文件路径 Blob 流式传输
内存占用 高(线性增长) 极高(全量驻留) 低(动态分片)
大文件支持
首帧时间
拖拽播放 卡顿 卡顿 流畅
内存泄漏风险

2. 核心实现原理

2.1 协议层架构设计

sequenceDiagram Renderer->>Main: 请求 media://id.mp4 Main->>FileSystem: 创建可读流 FileSystem->>Main: 返回文件流 Main->>Renderer: 流式传输响应

2.2 关键技术选型

  • Electron Protocol API :注册自定义协议替代file://
  • Node.js Stream:实现分块读取与传输
  • HTTP Range:支持视频播放器范围请求

3. 技术实现

3.1 安全映射机制

在主进程中实现一个map将真实路径存放起来,当使用electron dialog选择文件后,可以生成一个id返回给renderer端,renderer端通过id来获取

ts 复制代码
const mediaFileMap = new Map<string, string>();

export const addMediaFile = (id: string, path: string) => {
  mediaFileMap.set(id, path);
};

export const getMediaFile = (id: string) => {
  return mediaFileMap.get(id);
};

export const removeMediaFile = (id: string) => {
  mediaFileMap.delete(id);
};

export const clearMediaFileMap = () => {
  mediaFileMap.clear();
};

这样做的好处有两点:

  • 避免暴露真实路径
  • 如果路径中有空格等特殊字符串,经过encodeURIComponent后,会传递不到给自定义的Protocol

3.2 协议注册与配置

ts 复制代码
import { protocol } from 'electron';

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'media', // 自定义协议名
    privileges: {
      standard: true, // 标准权限
      secure: true, // 安全上下文
      stream: true, // 支持流式传输
      bypassCSP: true, // 绕过内容安全策略
    },
  },
]);

3.3 路径获取

ts 复制代码
import path from 'path';
import { existsSync } from 'fs';
import { getMediaFile } from '../utils/store/media-file-map';

const getMediaFilePath = (reqUrl: string) => {
  const filePath = decodeURIComponent(reqUrl.replace('media://', ''));
  // 渲染端通过传id.[ext]来获取对应的路径
  const [id] = filePath.split('.');
  const mediaFilePath = getMediaFile(id);

  if (!mediaFilePath) {
    return '';
  }

  const normalizedPath = path.normalize(mediaFilePath);

  if (!existsSync(normalizedPath)) {
    return '';
  }

  return normalizedPath;
};

3.4 获取range

ts 复制代码
import { promises } from 'fs';

const getRange = async (normalizedPath: string, rangeHeader: string | null) => {
  const { size } = await promises.stat(normalizedPath);
  let start = 0,
    end = size - 1;
  if (rangeHeader) {
    const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
    if (match) {
      start = match[1] ? parseInt(match[1], 10) : start;
      end = match[2] ? parseInt(match[2], 10) : end;
    }
  }
  const chunkSize = (end || size - 1) - start + 1;

  return { start, end, chunkSize, size };
};

3.5 实现流式响应

ts 复制代码
export const initMediaProtocol = () => {
  protocol.handle('media', async (req) => {
    try {
      const normalizedPath = getMediaFilePath(req.url);

      if (!normalizedPath) {
        return new Response('File not found', { status: 404 });
      }

      const rangeHeader = req.headers.get('Range');

      const { start, end, chunkSize, size } = await getRange(normalizedPath, rangeHeader);

      const stream = createReadStream(normalizedPath, { start, end });
      stream.on('error', (error) => {
        stream.destroy();
        return new Response(`Error: ${error.message}`, { status: 500 });
      });
      return new Response(stream as any, {
        status: rangeHeader ? 206 : 200,
        headers: {
          'Content-Range': `bytes ${start}-${end || size - 1}/${size}`,
          'Accept-Ranges': 'bytes',
          'Content-Length': chunkSize.toString(),
          'Content-Type': getContentType(normalizedPath),
        },
      });
    } catch (error: any) {
      console.error('Error handling media protocol:', error);
      return new Response(`Error: ${error.message}`, { status: 500 });
    }
  });
};

initMediaProtocol这个方法要在app when ready后执行。

完整代码实现

ts 复制代码
import { protocol } from 'electron';
import path from 'path';
import { getMediaFile } from '../utils/store/media-file-map';
import { getContentType } from '../utils/content-type';
import { createReadStream, existsSync, promises } from 'fs';

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'media',
    privileges: {
      standard: true, // 标准权限
      secure: true, // 安全上下文
      stream: true, // 支持流式传输
      bypassCSP: true, // 绕过内容安全策略
    },
  },
]);

const getMediaFilePath = (reqUrl: string) => {
  const filePath = decodeURIComponent(reqUrl.replace('media://', ''));
  const [id] = filePath.split('.');
  const mediaFilePath = getMediaFile(id);

  if (!mediaFilePath) {
    return '';
  }

  const normalizedPath = path.normalize(mediaFilePath);

  if (!existsSync(normalizedPath)) {
    return '';
  }

  return normalizedPath;
};

const getRange = async (normalizedPath: string, rangeHeader: string | null) => {
  const { size } = await promises.stat(normalizedPath);
  let start = 0,
    end = size - 1;
  if (rangeHeader) {
    const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
    if (match) {
      start = match[1] ? parseInt(match[1], 10) : start;
      end = match[2] ? parseInt(match[2], 10) : end;
    }
  }
  const chunkSize = (end || size - 1) - start + 1;

  return { start, end, chunkSize, size };
};

export const initMediaProtocol = () => {
  protocol.handle('media', async (req) => {
    try {
      const normalizedPath = getMediaFilePath(req.url);

      if (!normalizedPath) {
        return new Response('File not found', { status: 404 });
      }

      const rangeHeader = req.headers.get('Range');

      const { start, end, chunkSize, size } = await getRange(normalizedPath, rangeHeader);

      const stream = createReadStream(normalizedPath, { start, end });
      stream.on('error', (error) => {
        stream.destroy();
        return new Response(`Error: ${error.message}`, { status: 500 });
      });
      return new Response(stream as any, {
        status: rangeHeader ? 206 : 200,
        headers: {
          'Content-Range': `bytes ${start}-${end || size - 1}/${size}`,
          'Accept-Ranges': 'bytes',
          'Content-Length': chunkSize.toString(),
          'Content-Type': getContentType(normalizedPath),
        },
      });
    } catch (error: any) {
      console.error('Error handling media protocol:', error);
      return new Response(`Error: ${error.message}`, { status: 500 });
    }
  });
};

4. 渲染端应用

渲染端中只要拼接好对应协议即可

5. 效果

使用这种流式数据播放,能够随时拖动时间轴,每次也只会拿时间轴开始到一定范围的数据,减少内存占用。

6. 不足

如上图效果所示,缓冲区的end都是到视频最大的size,这样不太合理。前端应该拿到content-range后将通过切分做进步的优化,比如分多段来获取缓存。

相关推荐
世界哪有真情7 小时前
拿人类意识卡 AI?等于用 bug 验收正式产品
前端·人工智能·后端
Listen·Rain8 小时前
Vue3:setup详解
前端·javascript·vue.js
Patrick_Wilson9 小时前
修好 bug 只是开始:一次由监控需求反向重构日志结构的复盘
前端·监控·数据可视化
kyriewen9 小时前
面试官让我优化一个卡死的表格,我打开 Claude 五秒重写,他放下咖啡问我要不要来当组长
前端·javascript·面试
小粉粉hhh10 小时前
React(二)——dom、组件间通信、useEffect
前端·javascript·react.js
Csvn10 小时前
🤖 AI 生成前端代码的 5 个典型翻车现场及修复指南
前端
jump_jump10 小时前
Kysely 源码解剖:类型安全 SQL 如何走到 AST
node.js·orm·源码阅读
IT_陈寒11 小时前
SpringBoot自动装配坑了我一天,原来问题出在这
前端·人工智能·后端
星栈11 小时前
深度复盘:比 EventLoop 更隐蔽!Node 微任务堆积引发的线上接口雪崩事故
后端·node.js
索西引擎12 小时前
【React】key 属性:协调算法中的元素标识机制与最佳实践
前端·javascript·react.js