录屏损坏恢复实战:卡机重启导致 moov 丢失的 MP4 修复
场景:电脑卡机强制重启,Windows 截图工具的录屏没保存下来。文件 16.8GB 数据完好,但打不开。
结论:数据一点没丢,缺的是 MP4 的「目录索引」(moov atom)。通过解析文件结构 + 提取原始 H.264 流 + ffmpeg 重新封装,完整恢复出 2 小时 8 分的视频。
工具:Python 3 + ffmpeg(imageio-ffmpeg 自带)。
MP4 文件不是一整块连续数据,而是由多个 box(atom) 堆叠而成。录制时最关键的是三个:
| box | 作用 |
|---|---|
ftyp |
文件类型声明(mp42 等) |
mdat |
真正的音视频数据(占 99% 体积) |
moov |
索引/目录:每个 sample 的大小(stsz)、偏移(stco)、时间戳(stts)等 |
moov 是在录制结束时才写入文件末尾的。正常录制流程是:
1 | 开始录制 → 持续写 mdat(数据) → 停止录制 → 写入 moov(索引) → 保存 |
如果卡机重启打断了最后一步,就会得到一个「有 mdat、没 moov」的文件:
1 | [ftyp][uuid][mdat(16.8GB 数据)........][moov 缺失 ✗] |
数据都在,但播放器/系统不知道每一帧在哪、多长,所以打不开。修复 = 重建 moov 索引。
文件位置
Windows 11 自带截图工具录屏时,视频先缓存在临时目录,停止后才搬到 视频\屏幕录制:
1 | C:\Users\<用户名>\AppData\Local\Packages\Microsoft.ScreenSketch_8wekyb3d8bbwe\TempState\Recordings\ |
卡机后这里会残留一个文件名形如 20260815-0200-11.3019503.mp4 的大文件。文件名里的 0200 是 UTC 时间,对应北京时间 10:00(UTC+8),和实际开始录制时间吻合。
也就是先看 Videos\屏幕录制\(正常保存位置)有没有今天的文件;没有就去上面的 TempState\Recordings\ 找残留。
三、诊断:确认 moov 缺失
3.1 看文件头
1 | f = open(path, "rb") |
3.2 扫描顶层 atom,找 moov
1 | import struct |
对正常文件扫出来是:ftyp → uuid → mdat → moov(moov 在末尾)。
对损坏文件扫出来是:ftyp → uuid → mdat,没有 moov —— 这就是打不开的原因。
3.3 确认 mdat 的 size 字段
损坏文件的 mdat 头是 00 00 00 00 6d 64 61 74:
00 00 00 00= size 0,在 MP4 规范里表示「这个 box 一直延伸到文件末尾」6d 64 61 74= “mdat”
这个 size=0 是合法写法,但很多修复工具(比如 untrunc)不认,会误判 mdat 只有几字节——这是后面 untrunc 失败的根因之一。
—
四、深入理解:这个文件的「特殊」封装
拿到 moov 缺失的文件后,靠参考文件(同款工具录的完好视频)反推出内部数据格式,是恢复的关键。
4.1 参考文件的选择
同源参考文件(同一台电脑、同一工具录的)才能复用编码参数。用它的 moov 解析出:
1 | 视频: h264 (Main), yuv420p, 2560x1600, 30 fps, 17203 kb/s |
4.2 视频数据是 AVCC 格式(长度前缀),不是 Annex-B
这是本次最核心的坑。H.264 有两种裸流封装:
| 格式 | 分隔方式 | 常见于 |
|---|---|---|
| Annex-B | 00 00 00 01 起始码 |
裸流、相机 |
| AVCC | 每 NAL 前 4 字节长度前缀 | MP4 内部 |
Windows 截图工具的 mdat 里是 AVCC 格式。每个视频 sample(一帧)的结构:
1 | [00 00 00 02] [09 10] ← AUD:长度2 + NAL头0x09 + payload |
关键特征:每个 sample 都以 00 00 00 02 09 开头(长度 2 的 AUD 分隔符)。
4.3 视频/音频 chunk 交错存储
mdat 里视频和音频不是分开堆的,而是按约 0.3 秒交替:
1 | 视频 chunk (9 帧, 约 630~770KB) → 音频 chunk (15 帧, 约 7680B) → 视频 chunk → ... |
4.4 两个隐藏的坑
- mdat 开头有 8 字节额外头部:视频第一个 chunk 在 offset 80,而不是 72。这 8 字节
00 00 00 00 00 00 00 10不属于任何 chunk。 - 音频 sample 大小是 VBR 的:不是固定的 512 字节,而是 503~516 字节之间波动(AAC 的 bit reservoir 导致)。如果误当固定 512 切分,边界会累积漂移,最终把视频数据误判成音频。
—
五、恢复方案:提取原始流 + 重新封装
5.1 为什么 untrunc 不行
untrunc 是专门修 moov 缺失的工具,但在这个文件上会失败,原因有二:
- 它不认
mdat size=0,把 mdat 结束位置误算成 offset 96(实际 16.8GB) - 它期望 mdat 里是 Annex-B H.264(找
00 00 00 01起始码),而这里是 AVCC(长度前缀),加上开头 8 字节头部,导致 NAL 解析错位
1 | Error: unable to find correct codec -> premature end (~1.428e-07%) |
5.2 思路:不重建 moov,直接「拆流重装」
既然 moov 重建复杂,换个思路——把 mdat 里的 H.264 原始流抽出来,转成 Annex-B,用 ffmpeg 重新封装成新 mp4。相当于「把内容倒出来换个新盒子」。
步骤:
- 从参考文件提取 SPS/PPS(编码参数)
- 扫描 mdat,解析 AVCC 格式,抽每个视频帧 → Annex-B
- 每个关键帧前插 SPS/PPS(保证任意位置能解码)
- ffmpeg 封装
—
六、完整脚本
6.1 从参考文件提取 SPS/PPS
AVCC 里 SPS/PPS 存在 moov 的 avcC box 里,不在每个帧里。提取出来转成 Annex-B 前缀:
1 | import struct, os |
### 6.2 主提取脚本(AVCC → Annex-B)
```python**********
import struct, os, time
src = r”损坏文件.mp4”
video_out = r”video.h264”
SPS_PPS = open(r”sps_pps.h264”, “rb”).read()
src_size = os.path.getsize(src)
def is_aud(f, pos):
“””精确识别 AUD:长度2 + NAL头0x09 + payload低5位=0x10”””
f.seek(pos)
b = f.read(6)
if len(b) < 6 or b[0:4] != b”\x00\x00\x00\x02” or b[4] != 0x09:
return False
return (b[5] & 0x1F) == 0x10
def parse_video_sample(f, pos, limit):
“””解析一个 AVCC 视频 sample,返回 (size, is_key, annexb)”””
start = pos
nals, is_key = [], False
f.seek(pos)
l0 = struct.unpack(“>I”, f.read(4))[0]
if l0 != 2:
return None
f.read(2) # 跳过 AUD 的 2 字节
pos += 6
while pos + 4 <= limit:************
************f.seek(pos)************
************ln = struct.unpack(“>I”, f.read(4))[0]
if ln == 0 or ln > 4000000:
break
hdr = f.read(1)[0]
if (hdr & 0x1f) == 5: # NAL type 5 = IDR 关键帧
is_key = True
nals.append(bytes([hdr]) + f.read(ln - 1))
pos += 4 + ln
if is_aud(f, pos): # 遇到下一个 AUD,本帧结束
break
annexb = b””.join(b”\x00\x00\x00\x01” + n for n in nals)
return (pos - start, is_key, annexb)
f = open(src, “rb”)
fv = open(video_out, “wb”)
fv.write(SPS_PPS) # 开头写一次 SPS+PPS
pos = 80 # 跳过 ftyp(24)+uuid(40)+mdat头(8)+8字节额外头部
frames = keyframes = 0
while pos < src_size - 8:
if is_aud(f, pos):
r = parse_video_sample(f, pos, src_size)
if r is None: break
size, is_key, annexb = r
if is_key:
fv.write(SPS_PPS) # 关键帧前也插,保证 seek 后能解码
fv.write(annexb)
frames += 1
keyframes += is_key
pos += size
else:
pos += 1 # 跳过音频等其他数据(本场景只取视频)
if frames % 10000 == 0:
print(f”{frames} 帧, {keyframes} 关键帧, {pos/1024/1024:.0f}MB”)
print(f”完成: {frames} 帧 ({keyframes} 关键帧)”)
************```**********
### 6.3 ffmpeg 封装成标准 mp4
bash************ ************ffmpeg -y -f h264 -framerate 30 -i video.h264 -c copy output.mp4************ **************
- -f h264:告诉 ffmpeg 输入是 Annex-B H.264 裸流
- -framerate 30:关键,裸流没有时间戳,必须显式指定帧率,否则 ffmpeg 默认 25fps 会导致播放变速
- -c copy:只重封装(remux),不重新编码,速度快且无损
—
## 七、验证
bash************ ************ffmpeg -i output.mp4 # 看 Duration / Stream************ ************
正常输出:
************ ************Duration: 02:08:50.62************ ************Stream #0:0: Video: h264 (Main), 2560x1600, 30 fps, 17203 kb/s************ ************
再用 signalstats 抽查画面亮度,确认不是黑屏/花屏:
bash************ ************ffmpeg -ss 3600 -i output.mp4 -frames:v 1 -vf "signalstats,metadata=print" -f null -************ ************# lavfi.signalstats.YAVG=96.89 ← 平均亮度正常(黑屏≈0,白屏≈255)************ ************
—
## 八、总结与扩展
### 核心要点
**1. MP4 卡机损坏 = moov 丢失,数据(mdat)几乎都在,别慌。**
**2. 先定位残留临时文件,再判断缺什么(用 atom 扫描)。**
**3. 同一工具的完好文件是最强参考:能拿到编码参数(SPS/PPS、分辨率、帧率)。**
**4. MP4 内部 H.264 是 AVCC 格式(长度前缀),别和 Annex-B(起始码)搞混。**
**5. 恢复两条路:重建 moov(untrunc 等工具,但格式兼容性差);或拆流重装*(抽原始流 + ffmpeg 封装,通用性更强)。*****
### 坑位速查
| 坑 | 现象 | 对策 |
|—-|——|——|
| mdat size=0 | 修复工具误判数据长度 | 自己扫描,或 -sm/-range 绕过 |
| AVCC vs Annex-B | untrunc 找不到 NAL 边界 | 自己按长度前缀解析 |
| mdat 开头多余头部 | 数据偏移 8 字节 | 用参考文件 stco 定位真实 chunk 起点 |
| 音频 VBR 帧大小 | 固定大小切分会漂移 | 用视频边界锚定音频范围 |
| 裸流无时间戳 | 封装后变速 | ffmpeg -framerate 显式指定 |
### 扩展
**********- 本案例只恢复了视频。音频是 raw AAC(无 ADTS 头),需解析 AAC 语法确定帧边界后加 ADTS 头,再 -f aac 封装。**********
- 若缺音频但想保留,可参考 AAC 帧边界启发式:每个帧首字节高 3 位 = 1(CPE,stereo),间隔约 512 字节。
- 这套「拆流重装」思路同样适用于行车记录仪、运动相机、手机录像等因断电/卡机导致的 MP4/MOV 损坏。