[ misc 知识点 ] 视频修复:moov 丢失
2026-08-22 20:05:38

录屏损坏恢复实战:卡机重启导致 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
2
3
4
5
f = open(path, "rb")
print(f.read(64).hex())
# 00000018 66747970 6d703432 ... ← ftyp
# 00000028 75756964 ... ← uuid
# 00000000 6d646174 ... ← mdat (size=0!)

3.2 扫描顶层 atom,找 moov

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import struct

def scan_atoms(path, end=None):
f = open(path, "rb")
size = end or os.path.getsize(path)
pos = 0
atoms = []
while pos + 8 <= size:
f.seek(pos)
hdr = f.read(8)
sz, typ = struct.unpack(">I", hdr[0:4]), hdr[4:8].decode("latin1")
if sz == 1: # 64 位 size
sz = struct.unpack(">Q", f.read(8))[0]
elif sz == 0: # size=0 → 延伸到文件末尾
sz = size - pos
atoms.append((pos, sz, typ))
pos += sz
return atoms

对正常文件扫出来是: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
2
视频: h264 (Main), yuv420p, 2560x1600, 30 fps, 17203 kb/s
音频: aac (LC), 48000 Hz, stereo, 192 kb/s

4.2 视频数据是 AVCC 格式(长度前缀),不是 Annex-B

这是本次最核心的坑。H.264 有两种裸流封装:

格式 分隔方式 常见于
Annex-B 00 00 00 01 起始码 裸流、相机
AVCC 每 NAL 前 4 字节长度前缀 MP4 内部

Windows 截图工具的 mdat 里是 AVCC 格式。每个视频 sample(一帧)的结构:

1
2
3
[00 00 00 02] [09 10]        ← AUD:长度2 + NAL头0x09 + payload
[00 00 c5 31] [65 88 ...] ← IDR关键帧:长度50481 + NAL头0x65(type5)
[00 00 52 c7] [0c ...] ← filler:长度21191 + NAL头0x0c(type12)

关键特征:每个 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 两个隐藏的坑

  1. mdat 开头有 8 字节额外头部:视频第一个 chunk 在 offset 80,而不是 72。这 8 字节 00 00 00 00 00 00 00 10 不属于任何 chunk。
  2. 音频 sample 大小是 VBR 的:不是固定的 512 字节,而是 503~516 字节之间波动(AAC 的 bit reservoir 导致)。如果误当固定 512 切分,边界会累积漂移,最终把视频数据误判成音频。

五、恢复方案:提取原始流 + 重新封装

5.1 为什么 untrunc 不行

untrunc 是专门修 moov 缺失的工具,但在这个文件上会失败,原因有二:

  1. 它不认 mdat size=0,把 mdat 结束位置误算成 offset 96(实际 16.8GB)
  2. 它期望 mdat 里是 Annex-B H.264(找 00 00 00 01 起始码),而这里是 AVCC(长度前缀),加上开头 8 字节头部,导致 NAL 解析错位
1
2
Error: unable to find correct codec -> premature end (~1.428e-07%)
mdat->file_end: 96 ← 它以为 mdat 只有 32 字节

5.2 思路:不重建 moov,直接「拆流重装」

既然 moov 重建复杂,换个思路——把 mdat 里的 H.264 原始流抽出来,转成 Annex-B,用 ffmpeg 重新封装成新 mp4。相当于「把内容倒出来换个新盒子」。

步骤:

  1. 从参考文件提取 SPS/PPS(编码参数)
  2. 扫描 mdat,解析 AVCC 格式,抽每个视频帧 → Annex-B
  3. 每个关键帧前插 SPS/PPS(保证任意位置能解码)
  4. ffmpeg 封装

六、完整脚本

6.1 从参考文件提取 SPS/PPS

AVCC 里 SPS/PPS 存在 moov 的 avcC box 里,不在每个帧里。提取出来转成 Annex-B 前缀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import struct, os

ref = r"D:\参考文件.mp4"
size = os.path.getsize(ref)
f = open(ref, "rb")
f.seek(size - 20000) # moov 在文件末尾
tail = f.read(20000)
f.close()

i = tail.find(b"avcC")
c = tail[i+4:] # 跳过 "avcC" 这 4 字节
version, profile, compat, level = c[0], c[1], c[2], c[3]

p = 4 # 跳过 version/profile/compat/level
assert c[p] == 0xFF
p += 1
num_sps = c[p] & 0x1F
p += 1

sps_list = []
for _ in range(num_sps):
n = struct.unpack(">H", c[p:p+2])[0]
p += 2
sps_list.append(c[p:p+n]); p += n

num_pps = c[p]; p += 1
pps_list = []
for _ in range(num_pps):
n = struct.unpack(">H", c[p:p+2])[0]
p += 2
pps_list.append(c[p:p+n]); p += n

with open(r"D:\sps_pps.h264", "wb") as out:
for s in sps_list: out.write(b"\x00\x00\x00\x01" + s)
for s in pps_list: out.write(b"\x00\x00\x00\x01" + s)

### 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 损坏。

上一页
2026-08-22 20:05:38
下一页