8GB 视频只读取 66KB?聊聊浏览器端大文件低内存元数据修改的实现原理

项目地址:github.com/AsinnnY/sta...
浏览器里处理一个 8GB 视频,一定要把 8GB 全部读进内存吗?

不一定。

如果我们真正需要修改的只是文件里的几 KB 元数据,那么完全可以只读取需要的部分,让剩余几个 GB 的媒体数据保持原样。

本文介绍一种比较特殊的浏览器端大文件处理思路:随机读取 + 容器级解析 + 局部重写 + 原始数据分片复用。

这也是 stamp-js 采用的核心架构。


一、先看一个非常直观的问题

假设现在有一个:

text 复制代码
8 GB.mp4

我们只是想给它增加:

text 复制代码
title = "My Video"
artist = "Alice"
comment = "Recorded on device"

传统思路通常比较直接:

text 复制代码
8 GB MP4
   ↓
读取整个文件
   ↓
ArrayBuffer
   ↓
解析 MP4
   ↓
修改 metadata
   ↓
重新生成文件

如果使用:

js 复制代码
const buffer = await file.arrayBuffer();

那么浏览器至少需要面对一个非常大的 ArrayBuffer。

如果修改过程中又产生新的 Buffer、Blob 或中间对象,峰值内存甚至可能达到原文件大小的数倍。

于是:

text 复制代码
500 MB

可能已经开始让浏览器吃力。

到了:

text 复制代码
2 GB
4 GB
8 GB

问题就更加明显。


二、但我们真的需要读取整个视频吗?

这是整个问题最关键的地方。

一个 MP4 文件并不是:

text 复制代码
[一大坨无法理解的二进制数据]

它实际上是一个结构化容器。

可以简单理解成:

text 复制代码
┌────────┬────────────┬────────────────────────────┐
│  ftyp  │    moov    │            mdat            │
└────────┴────────────┴────────────────────────────┘
                     ↑
                     │
                  媒体数据

其中:

  • ftyp:文件类型信息
  • moov:媒体结构、索引、元数据等
  • mdat:真正的大量音视频媒体数据

如果我们的目标只是:

text 复制代码
修改 title
修改 artist
修改 comment

那么真正需要理解的其实主要是:

text 复制代码
moov

而不是:

text 复制代码
mdat

mdat 里面可能有几个 GB 的 H.264、HEVC、AAC 等媒体数据。

我们为什么要读取它?

答案是:

不需要。

这就是 stamp-js 的核心设计出发点。


三、浏览器其实已经提供了"随机读取"

很多人看到"大文件处理"时,第一反应是:

浏览器不是只能顺序读取文件吗?

实际上 File 本身继承自 Blob,而 Blob 有一个非常重要的 API:

js 复制代码
file.slice(start, end)

例如:

js 复制代码
const part = file.slice(1024 * 1024, 1024 * 1024 + 1024);

意思就是:

给我文件中从 1MB 开始的 1KB。

我们完全不需要:

js 复制代码
file.arrayBuffer()

把整个文件加载出来。

因此:

text 复制代码
8 GB 文件
│
├── 读取前 8 bytes
│
├── 跳到某个 offset
│
├── 读取 16 bytes
│
├── 跳到另一个 offset
│
├── 读取 moov
│
└── 完成修改

这就是所谓的:

Random Access / 随机访问


四、MP4 为什么可以这样定位?

因为 MP4 本身是 Box/Atom 结构。

一个简单的 Box 可以抽象成:

text 复制代码
┌──────────┬──────────┬────────────────────┐
│   size   │   type   │       payload      │
└──────────┴──────────┴────────────────────┘

例如:

text 复制代码
[00000020][ftyp][................]
[00123456][moov][................]
[xxxxxxxx][mdat][................]

程序只需要读取一个 Box 的头部,就可以知道:

text 复制代码
它是什么
它有多大
它的 payload 从哪里开始

于是我们可以:

text 复制代码
offset = 0

读取 8 bytes
    ↓
得到 size + type
    ↓
跳过当前 box
    ↓
读取下一个 box
    ↓
直到找到 moov

注意:

跳过并不意味着读取。

例如:

text 复制代码
mdat = 8GB

程序可以知道:

text 复制代码
mdat 起点 = 123456
mdat 长度 = 8589934592

然后直接:

text 复制代码
offset += mdatSize

跳到后面。

完全没有必要把这 8GB 数据读取进 JavaScript。


五、那如果 moov 在文件最后呢?

MP4 可能是:

text 复制代码
[ftyp][moov][mdat]

也可能是:

text 复制代码
[ftyp][mdat.........................][moov]

第二种情况非常常见。

如果:

text 复制代码
文件大小 = 8GB

而:

text 复制代码
moov = 文件最后 1MB

那么我们可以直接从尾部读取:

js 复制代码
file.slice(file.size - N, file.size)

然后寻找:

text 复制代码
moov

一旦知道:

text 复制代码
moovOffset
moovSize

再精确读取:

js 复制代码
file.slice(moovOffset, moovOffset + moovSize)

于是:

text 复制代码
8 GB

最终可能只读取:

text 复制代码
几十 KB / 几 MB

而不是:

text 复制代码
8 GB

六、这就是"低内存"的真正含义

这里需要特别澄清一个非常容易被误解的地方。

我们不能说:

stamp-js 永远只占几 KB 内存。

这是不严谨的。

更准确的说法应该是:

内存占用与媒体 payload 大小解耦。

例如:

text 复制代码
视频 A
文件:500 MB
moov:500 KB

和:

text 复制代码
视频 B
文件:8 GB
moov:500 KB

两者的 metadata 处理过程可以拥有非常接近的内存需求。

因为真正的大头:

text 复制代码
mdat

根本没有被整体加载。

真正需要进入内存的是:

text 复制代码
moov
+
需要修改的 metadata
+
少量解析结构

所以更准确的关系是:

text 复制代码
Memory ≈ O(metadata / container structure)

而不是:

text 复制代码
Memory ≈ O(entire media file)

七、真正重要的第二个技术:不要重新复制 mdat

仅仅做到"只读取 metadata"还不够。

因为最后我们还需要产生一个新文件。

例如原文件:

text 复制代码
[ftyp][moov][mdat]

修改以后:

text 复制代码
[ftyp][new moov][mdat]

最危险的做法是:

js 复制代码
const output = new Uint8Array(file.size + something);

然后复制:

text 复制代码
复制 ftyp
复制 new moov
复制整个 mdat

这样虽然读取阶段很省内存,最后输出阶段又把问题重新带回来了。

所以需要另外一个关键思想:

Reference Slice Assembly

也就是:

只创建新的部分,原来的媒体数据继续使用原始文件的 Slice。

概念上:

text 复制代码
原文件:

[ ftyp ][ old moov ][ mdat............................. ]
             ↓
          不再使用


新文件:

[ ftyp ][ new moov ][ mdat............................. ]
                    ↑
                    │
               original.slice()

对于 Blob/File:

js 复制代码
new Blob([
  originalSlice1,
  newMetadata,
  originalSlice2
]);

我们并没有:

text 复制代码
8GB → Uint8Array → 8GB

而是把:

text 复制代码
原始 Blob 的不同片段

和:

text 复制代码
新生成的小片段

组合起来。

这也是 stamp-js 和很多传统"读进 Buffer → 修改 Buffer → 输出 Buffer"方案最核心的区别之一。


八、所以它不是传统意义上的 Transform Stream

很多人看到 stream,第一反应是:

text 复制代码
input stream
   ↓
transform stream
   ↓
output stream

但 stamp-js 的核心不是这个。

它更接近:

text 复制代码
Random Access Source
        ↓
Container Probe
        ↓
Metadata Parser
        ↓
Rewrite Planner
        ↓
Reference Slices
        ↓
Output

对于:

text 复制代码
Blob / File

可以直接组合:

text 复制代码
original slices
+
new bytes

形成新的 Blob。

对于:

text 复制代码
HTTP Range
NodeFileSource

这种不能直接拥有本地完整 Blob 的数据源,则可以通过:

text 复制代码
ReadableStream

按计划输出。

所以:

Streaming 是输出手段,而不是核心算法。

核心算法其实是:

Random-access metadata surgery。


九、还有一个真正棘手的问题:moov 变大以后怎么办?

假设:

text 复制代码
原来的 moov:

1 MB

我们加入 metadata:

text 复制代码
新的 moov:

1 MB + 2 KB

那么:

text 复制代码
mdat

的位置就发生变化。

原来:

text 复制代码
mdat offset = 1000000

修改以后可能变成:

text 复制代码
mdat offset = 1002048

那么 MP4 内部保存的 sample offset 也需要同步调整。

这就是:

text 复制代码
stco
co64

发挥作用的地方。


十、stco / co64 是什么?

可以简单理解:

MP4 内部需要知道每个媒体 sample 在文件中的位置。

这些位置可能使用:

text 复制代码
stco

保存 32-bit offset。

也可能使用:

text 复制代码
co64

保存 64-bit offset。

如果修改 metadata 导致某些 offset 超过 32-bit 范围,就不能继续使用普通的:

text 复制代码
stco

需要升级为:

text 复制代码
co64

因此:

text 复制代码
metadata 修改
       ↓
moov 大小改变
       ↓
mdat 起点改变
       ↓
sample offset 改变
       ↓
stco / co64 rebasing
       ↓
必要时 stco → co64

这也是为什么"写个 metadata"实际上会涉及到容器格式内部结构。


十一、但我们仍然不需要碰 mdat

这是整个设计最漂亮的地方。

虽然我们需要修改:

text 复制代码
stco / co64

但我们修改的是:

text 复制代码
moov 中记录的 offset

而不是:

text 复制代码
mdat 中的媒体数据。

所以:

text 复制代码
H.264
HEVC
AAC
AV1

这些编码数据到底是什么,实际上不是重点。

stamp-js 不需要理解:

text 复制代码
视频帧
音频帧
I-frame
B-frame
P-frame

因为它不是视频处理器。

它只是:

容器结构编辑器。


十二、这也是为什么"Codec 不重要"

对于传统媒体处理:

text 复制代码
MP4
 ↓
解码
 ↓
视频帧
 ↓
修改
 ↓
重新编码

你必须理解 codec。

但是 metadata surgery:

text 复制代码
MP4
 ↓
容器结构
 ↓
metadata

我们甚至不需要知道 mdat 里面到底是什么。

所以只要容器结构符合预期:

text 复制代码
H.264
HEVC
AV1
AAC

都可以保持原样。

这也是一种非常重要的设计思想:

不要解析你不需要理解的数据。


十三、JPEG、PNG 也是类似思路

这个设计并不只适用于 MP4。

JPEG 可以包含:

text 复制代码
SOI
APP segments
EXIF
XMP
...
SOS
JPEG image data

如果我们只需要修改:

text 复制代码
EXIF
XMP

就没必要解码整张图片。

PNG 同样具有:

text 复制代码
IHDR
iTXt
eXIf
IDAT
IEND

如果只修改 metadata:

text 复制代码
IDAT

完全可以保持不动。

因此,这套思路可以抽象成:

text 复制代码
Container
    ↓
Locate metadata
    ↓
Parse metadata
    ↓
Rewrite metadata
    ↓
Preserve payload

而不是:

text 复制代码
Decode everything
    ↓
Modify
    ↓
Encode everything

十四、真正的工程难点并不是"随机读取"

其实说到这里,可以发现:

js 复制代码
file.slice(offset, end)

本身并不复杂。

真正困难的是:

如何保证你永远只修改应该修改的东西。

真实世界里的文件并没有那么规整。

例如 MP4 可能出现:

text 复制代码
moov 在前
moov 在后
stco
co64
64-bit box size
unknown box
multiple trak
QuickTime metadata
mdta metadata
udta metadata

甚至还会遇到:

text 复制代码
损坏文件
截断文件
malformed box
fragmented MP4

这时候最危险的事情不是:

"程序报错。"

而是:

程序觉得自己成功了,但输出文件已经被破坏。

因此,一个真正可靠的二进制编辑器必须非常保守。


十五、一个重要原则:不确定就拒绝

如果遇到:

text 复制代码
无法安全解析
无法确定 offset
容器结构异常
不支持的布局

宁可:

text 复制代码
return error

也不要:

text 复制代码
best effort

更不要:

text 复制代码
生成一个看起来能用,但实际上已经损坏的文件。

所以类似的 API 应该返回结构化结果:

js 复制代码
const result = await writeTags(source, tags);

if (!result.ok) {
  console.log(result.report.error);
}

而不是简单吞掉异常。


十六、还有一个很容易被忽略的指标:字节保真

对于这种工具:

"metadata 写成功了"

并不是唯一的验证标准。

更重要的是:

没有要求修改的数据,是不是仍然保持原样?

因此测试时,一个非常关键的思路就是:

text 复制代码
输入:

[metadata][payload]

        ↓

stamp-js

        ↓

输出:

[new metadata][payload]

我们希望:

text 复制代码
payload_before === payload_after

而不是:

text 复制代码
payload_before ≈ payload_after

更不是:

text 复制代码
重新编码以后看起来差不多。

这也是为什么对媒体 payload 做字节级验证非常重要。


十七、一个有意思的结果:8GB 文件可能只需要读取几十 KB

在项目的实际验证中,一个 8 GiB MP4 可以在只读取约:

text 复制代码
66 KiB

的数据后完成 metadata 写入。

换算一下:

text 复制代码
8 GiB ≈ 8,589,934,592 bytes
66 KiB ≈ 67,584 bytes

读取比例大约只有:

text 复制代码
0.00077%

这不是:

"8GB 文件被压缩成 66KB。"

更不是:

"8GB 文件只存在 66KB 内存里。"

准确含义是:

为了完成这次 metadata 操作,程序只需要从 8GB 文件中读取大约 66KB 的数据。

剩余的媒体 payload:

根本没有被读取。

这才是这个数字真正有意义的地方。


十八、这套方案到底适合什么场景?

1. 浏览器本地媒体管理

例如:

text 复制代码
选择一个 10GB 视频
      ↓
修改标题
      ↓
修改作者
      ↓
直接下载

整个过程中不需要把 10GB 上传服务器。

2. 浏览器扩展

例如:

text 复制代码
下载视频
      ↓
自动写入 metadata
      ↓
保存

尤其适合:

text 复制代码
Tampermonkey
Chrome Extension
Edge Extension

3. 隐私敏感场景

如果只是修改:

text 复制代码
title
artist
date
comment

却把整个视频上传服务器处理,其实有些浪费。

本地修改可以让原始视频一直留在本地。

4. 超大文件

普通 20MB 文件,是否低内存其实没那么重要。

真正体现价值的是:

text 复制代码
1GB
4GB
8GB
20GB

这种文件。

文件越大:

"不读取 payload"这个设计的价值越明显。


十九、为什么以前很少看到这种库?

既然:

js 复制代码
Blob.slice()

这么简单,为什么不是所有 metadata 库都这么做?

答案是:

因为很多库解决的是另外一个问题。

传统媒体工具的思路往往是:

text 复制代码
输入
 ↓
完整解析
 ↓
转换
 ↓
重新封装

这种方式适合:

text 复制代码
转码
剪辑
抽帧
重新封装
压缩

但如果我们的需求只是:

text 复制代码
修改 metadata

那么完整处理整个媒体其实是过度设计。

因此可以把两类工具简单区分成:

text 复制代码
Media Transformation
媒体转换

        VS

Container Surgery
容器手术

前者需要理解媒体。

后者只需要理解:

我要修改的那一小块结构。


二十、stamp-js 最核心的设计理念

如果用一句话总结:

Don't process the media. Process the container.

不要处理媒体本身。

处理媒体容器。

也就是:

text 复制代码
巨大 payload
      │
      │ 不碰
      ▼
┌───────────────────┐
│ Container         │
│                   │
│ metadata ← 修改   │
│ indexes  ← 必要时调整│
└───────────────────┘

这会让很多"大文件处理"问题突然变得非常小。


二十一、从一个 metadata writer 开始,但可能不止是 metadata writer

目前 stamp-js 的定位是 JPEG、PNG、MP4、MOV 的 metadata 写入。

但从架构上看,它实际上可以继续向更底层的方向发展:

text 复制代码
Random Access Source
        ↓
Container Parser
        ↓
Rewrite Planner
        ↓
Reference-Slice Writer

这套架构并不天然绑定:

text 复制代码
title
artist
comment

它更像一种:

浏览器端局部二进制文件修改模型。

未来如果继续扩展,可以覆盖更多图片、音频、视频甚至其他结构化容器格式。

当然,这需要针对不同格式重新设计解析器和安全边界,并不是"加几个 if"就能完成。


二十二、这个方案最大的边界是什么?

低内存并不意味着:

所有文件都可以无限大。

例如:

text 复制代码
8GB media
+
100MB moov

这时候:

text 复制代码
mdat

仍然没有被读取。

但:

text 复制代码
moov

本身就是:

text 复制代码
100MB

那么你还是需要处理这 100MB 的结构。

因此真正准确的模型是:

text 复制代码
Memory
   │
   ├── 不随 mdat 大小线性增长
   │
   └── 会受到 metadata/container structure 大小影响

所以:

低内存 ≠ 恒定内存。

这是任何严肃技术文档都应该讲清楚的。


二十三、我认为这个思路最有价值的地方

如果只看:

text 复制代码
"一个写 metadata 的 npm 包"

它似乎并没有那么特殊。

但如果换一个角度:

text 复制代码
8GB 文件
        ↓
随机访问
        ↓
只读取必要结构
        ↓
只修改必要字节
        ↓
原始 payload 保持不动
        ↓
reference slices 重新组装

你会发现:

这其实是一种非常适合浏览器的"大文件局部修改"架构。

浏览器并不是不能处理 GB 级文件。

很多时候,真正的问题是:

我们过去习惯了把文件变成一个巨大的 ArrayBuffer。

一旦不再这样做,问题就完全不同了。


二十四、最后

stamp-js 做的事情其实非常简单:

text 复制代码
找到需要修改的地方
        ↓
读取它
        ↓
修改它
        ↓
其他地方不要碰

但这个简单思路背后,是几个 Web API 和容器格式能力的组合:

text 复制代码
Blob.slice()
     +
Random Access
     +
Container Parsing
     +
Metadata Rewrite
     +
Offset Rebasing
     +
Reference-Slice Assembly
     +
ReadableStream

最终得到的效果就是:

text 复制代码
传统方式:

8 GB
 ↓
8 GB Buffer
 ↓
修改
 ↓
重新输出


局部修改:

8 GB
 ↓
读取几十 KB
 ↓
修改 metadata
 ↓
复用原始 payload
 ↓
输出

这也是浏览器端大文件处理里一个很值得关注的方向:

不是想办法让"读取整个文件"变得更快,而是想办法证明------我们根本不需要读取整个文件。


项目

stamp-js 是一个面向 Browser / Node.js / Deno / Bun / Web Worker / Tampermonkey 的低内存媒体元数据写入库。

当前支持:

  • JPEG
  • PNG
  • MP4
  • MOV
  • XMP
  • EXIF
  • iTunes / QuickTime / mdta metadata
  • 大文件随机读取
  • HTTP Range
  • Blob / File
  • NodeFileSource
  • ReadableStream 输出

核心目标只有一个:

尽可能只处理需要改变的字节,让巨大的媒体 payload 保持原样。


写在最后

做这个项目以后,我最大的感受反而不是:

"浏览器可以处理 8GB 文件。"

而是:

很多时候,文件很大并不是问题。真正的问题是我们的程序把一个很小的修改需求,错误地转换成了"处理整个文件"的问题。

当需求只是:

text 复制代码
改几个 metadata

那么最理想的算法可能根本不是:

text 复制代码
更快地读取 8GB

而是:

text 复制代码
想办法只读取几十 KB。

这可能才是浏览器端大文件处理值得继续探索的方向。

项目地址

GitHub:github.com/AsinnnY/sta...

欢迎查看源码、Issue 与后续版本更新。

相关推荐
OpsEye42 分钟前
怎么判断企业采购的大模型,实际投入产出值不值得?
javascript·ai编程
一条溺水的鱼44 分钟前
一次讲透 JS Promise —— 状态、用法和 4 个静态方法
javascript
沙蒿同学1 小时前
Wails v2 实战:用 Go + Vue3 做一个真正能用的 AI 桌面应用
前端·javascript·后端
三十而立洋5 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁5 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
李少兄6 天前
JavaScript 隐式全局变量解析
javascript
沙蒿同学6 天前
我用 Go 搭了一条 AI Agent 流水线:从 1 张商品图到一整套淘宝详情页
前端·javascript·后端
paopaokaka_luck6 天前
智慧社区综合服务小程序(人脸识别、AI问答、Echarts图形化分析)
前端·javascript·spring boot·spring·数据分析·echarts
Hilaku6 天前
为什么死磕 1px 的团队,用户体验反而更差?
前端·javascript·程序员