项目地址: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 与后续版本更新。