背景

由于网络原因,在下载视频之前我们往往会希望能够先生成一些视频的缩略图,大致浏览视频内容,再确定是否应花时间下载。如何能够快速得到视频多个帧的缩略图的同时尽量少的下载视频的内容,是一个值得研究的问题。

思路

众所周知,不考虑音频、字幕的话,视频是由多个图像帧拼接而成的,因此我们的目标也就是尽量只下载视频中我们想下载的帧图片,而忽略其他的信息,那么就需要获得对应帧在文件中所在的位置、大小、以及编码格式,为此,首先需要了解视频容器的格式,由于日常生活中h264编码的mp4格式用得比较多,所以这里只分析采用h264编码的mp4格式。

mp4格式的主要组织格式是box,box之间可以相互嵌套,每个box的前8个字节包括box的名字和这个box大小。在我看来,这些box主要分为三类,第一类存储的是整个视频的元信息,像视频的作者、分辨率、帧率等信息,这些信息基本对解码没有影响,所以可以跳过,第二类存储的是编码信息,包括视频的关键帧、帧的大小、视频分块的位置和包含的帧数等信息,这个是我们最关心的信息,需要完整地下载下来。第三类存储的是实际数据,这些我们不用完整下载,可以通过前面的编码信息计算我们所需要的帧在数据box中的位置,然后只下载帧对应的数据就行了,这个操作也是比较简单的。因此,重中之重就是如何分析视频的编码信息。

存储视频编码信息的box叫stbl,其下包括以下几个box:

  • stsd:包含了h264编码的一些信息,后面再谈它的作用。
  • stts:包含时间和帧的转换信息,这里我们不需要考虑它。
  • stss:关键帧编号,这个比较重要,因为h264是一种压缩率非常高的编码格式,它把帧分为三类,其中只有一类是完全独立的,另外两类都需要通过周围的帧推算出来。而这里的关键帧,在寻址过后得到的刚好就是h264中的独立帧,也是我们比较需要的。由于关键帧之间的时间差基本一致,所以我们在需要缩略图尽可能在视频中分布均匀的时候,就可以在关键帧列表中均匀地取一定数量的关键帧,只对这些帧进行寻址。
  • stsz:每个帧的数据大小,重要性不言而喻。
  • stsc:每个分块包含帧的数目,注意这个box存储格式是比较简洁的,它会把帧数目相同的连续分块划为一段,只记录每段的起始分块和每个分块包含帧的数目,因此需要自行处理分析。
  • stco:每个分块数据在整个文件中的位置,重要性不言而喻。

这样一来,找到一个关键帧的过程大概可以分为以下几步:

  1. 通过stsc box,不断累加每个分块的帧数,得到所需关键帧在哪个分块中
  2. 通过stsc box和stsz box,得到所需关键帧在对应分块中的偏移量,需要将分块中排在所需关键帧前面的帧的大小累加起来
  3. 通过stco box得到分块的偏移量,加上刚才计算得到的所需关键帧的块中偏移量,就是所需关键帧在文件中的偏移量,通过stsz box获得所需关键帧的大小,就能得到完整的所需关键帧数据了。

接下来需要考虑的就是得到的关键帧数据,怎么转换为图片。mp4里的视频画面数据用的编码格式是h264,由于h264解码算法非常复杂,因此我使用ffmpeg程序来进行这一操作。但是,直接从mp4里提取出来的关键帧数据并不能被ffmpeg的h264解码器所识别。这是因为h264的格式分为两类,一类叫Annex-B,称为h264裸流,可以直接被各类解码器、播放器处理和播放;一类叫AVCC,通常使用h264编码的mp4、avi等视频容器中的画面数据用的就是这个格式。也就是说,提取出来的关键帧数据是AVCC格式,需要将其转换为Annex-B格式,才能被ffmpeg转码成png之类的格式。

那么这两个格式有什么不同吗?h264格式的视频流都是由一个个NAL包组成的,每个NAL包可能是帧数据,也可能是其他一些数据,在Annex-B中,其他数据的NAL包头部为00 00 00 01四个字节,帧数据的NAL包头部为由00 00 00 01分隔的PPS和SPS字节串,这两种字节串里存储了帧的分辨率等信息,使解码器能够正确解析h264流。而AVCC的NAL包的前四个字节一律为该NAL包的大小。那么AVCC格式的h264流在mp4容器里是怎么被正确解码的呢?原来它的SPS和PPS字节串存在前面所述的stsd box中,所以将AVCC转换成Annex-B的方法就是将其每个NAL包的前四个字节进行替换,如果该包存储其他数据,就将前四个字节改为00 00 00 01,否则改成由00 00 00 01分隔的PPS和SPS字节串。通常存储帧数据的NAL包第5个字节为0x65,可以由此判断。

于是整个程序的流程就很清晰了,每次下载当前box的名字和大小(前8个字节),如果这个box是编码相关box的父box就进入,否则就跳过,当到达对应的box时则下载需要的信息。接着在关键帧列表中尽量均匀地选取关键帧,然后找到这些关键帧对应的数据位置把它们下载下来,将数据由AVCC转换成Annex-B,然后运行ffmpeg将数据帧转换成图片格式。下载文件片段可以使用HTTP请求里的Range头,为了加快速度,一些能够并行的操作可以用多线程来处理。

代码

import sys
import struct
import requests
import subprocess
from multiprocessing.dummy import Pool img_num = 240 # 缩略图数量
url = 'https://...' # 视频地址
path = 'D:\\tmp\\' # 下载路径
now = 0
small = set(['moov', 'trak', 'mdia', 'minf', 'stbl']) # 需要“进入”的box名
sample2chunk = [] # stsc box中每一段的起始chunk和帧数
chunk_offset = [] # 分块在文件中的地址
sample_size = [] # 帧大小
key_sample = [] # 选取的关键帧
frames = [] # 选取的关键帧在文件中的偏移和大小
cnt = 5 # 待下载的box的数目
sps = b''
pps = b''
requestnum = 0 def getrange(start, length):
global requestnum
requestnum += 1
while True:
r = os.system(f'curl -H "Range: bytes={start}-{start + length - 1}" {url} --output {path}temp{start}.bin -m {60 + length // 4000} -f') # 这里运行curl来下载,指定了下载重试时间
if r == 0: # 如果下载成功(curl返回值为0)
break
f = open(f'{path}temp{start}.bin', 'rb')
s = f.read()
f.close()
os.system(f'del {path}temp{start}.bin')
return s while cnt > 0:
s = getrange(now, 8)
name = s[4:].decode() # box名
length, = struct.unpack('>l', s[:4]) # box大小
print(name, length)
if name in small:
now += 8 # “进入”该box
elif name == 'stsd': # 获取sps和pps
s = getrange(now + 7 * 16 + 4, length - 7 * 16 - 4)
spsl, = struct.unpack('>H', s[0:2])
sps = s[2:2 + spsl]
ppsl, = struct.unpack('>H', s[2 + spsl + 1:spsl + 5])
pps = s[spsl + 5:spsl + 5 + ppsl]
now += length
cnt -= 1
elif name == 'stsc':
s = getrange(now, length)
count, = struct.unpack('>l', s[12:16])
for i in range(count):
fc, = struct.unpack('>l', s[16 + i * 12:20 + i * 12])
spc, = struct.unpack('>l', s[20 + i * 12:24 + i * 12])
sample2chunk.append((fc, spc))
now += length
cnt -= 1
elif name == 'stco':
fraglength = length // 12
frag = [(now + i * fraglength, fraglength) for i in range(11)] # 由于这个box比较大,所以对这个box进行分段然后用多线程来处理
frag.append((now + 11 * fraglength, length - 11 * fraglength)) # 最后一段
pool = Pool(12)
r = pool.map(lambda x: getrange(*x), frag)
pool.close()
pool.join()
s = b''.join(r)
count, = struct.unpack('>l', s[12:16])
for i in range(count):
chunk_offset.append(struct.unpack('>l', s[16 + i * 4:20 + i * 4])[0])
now += length
cnt -= 1
elif name == 'stsz':
fraglength = length // 12
frag = [(now + i * fraglength, fraglength) for i in range(11)] # 同上,分段多线程
frag.append((now + 11 * fraglength, length - 11 * fraglength))
pool = Pool(12)
r = pool.map(lambda x: getrange(*x), frag)
pool.close()
pool.join()
s = b''.join(r)
count, = struct.unpack('>l', s[16:20])
for i in range(count):
sample_size.append(struct.unpack('>l', s[20 + i * 4:24 + i * 4])[0])
now += length
cnt -= 1
elif name == 'stss':
s = getrange(now, length)
count, = struct.unpack('>l', s[12:16])
gap = count // img_num # 选取关键帧的间隔大小
num = count % img_num # 为了尽可能均匀选取,前num个关键帧间隔为gap,后面的关键帧间隔为gap-1
gap += 1
for i in range(1, img_num + 1):
if i <= num:
key_sample.append(struct.unpack('>l', s[12 + i * gap * 4:16 + i * gap * 4])[0])
else:
t = (i - num) * (gap - 1)
key_sample.append(struct.unpack('>l', s[12 + (num * gap + t) * 4:16 + (num * gap + t) * 4])[0])
now += length
cnt -= 1
else: # 跳过该box
now += length
sample2chunk.append((len(chunk_offset) + 1, 0)) # 添加一个边界
for i in key_sample:
sample = 0
first_chunk = (0, 0)
for j in range(len(sample2chunk) - 1): # 获得帧对应的是第几段
if sample + (sample2chunk[j + 1][0] - sample2chunk[j][0]) * sample2chunk[j][1] >= i:
first_chunk = sample2chunk[j]
break
else:
sample += (sample2chunk[j + 1][0] - sample2chunk[j][0]) * sample2chunk[j][1]
true_chunk = (i - sample - 1) // first_chunk[1] + first_chunk[0] # 获得帧对应的分块编号
sample += (i - sample - 1) // first_chunk[1] * first_chunk[1] # 该分块的起始帧编号
offset = 0
for j in sample_size[sample:i - 1]: # 累加帧在分块中的偏移
offset += j
frames.append((chunk_offset[true_chunk - 1] + offset, sample_size[i - 1])) def process_frame(frame):
i, j = frame
avcc = getrange(*j)
anexb = b''
now = 0
sp = b'\x00\x00\x00\x01'
while now < j[1]:
length, = struct.unpack('>l', avcc[now:now + 4])
if avcc[now + 4] != 0x65: # 其他包
anexb += sp + avcc[now + 4:now + 4 + length]
now += 4 + length
else: # 帧数据包
anexb += sp + sps + sp + pps + sp + avcc[now + 4:now + 4 + length]
now += 4 + length
f = open(f'{path}img{i:03d}.bin', 'wb')
f.write(anexb)
f.close()
os.system(f'ffmpeg -i {path}img{i:03d}.bin {path}img{i:03d}.jpg') # 转码
os.system(f'del {path}img{i:03d}.bin') pool = Pool(12)
pool.map(process_frame, list(enumerate(frames))) # 获取帧数据和转换过程用多线程并行
pool.close()
pool.join()

后记

注意该程序默认视频图像流在音频流前面,因为图片流和音频流的父box名都是trak,所以如果音频流在前面可能程序会运行出错。

本篇文章没有对mp4的详细格式进行完整介绍,如果想了解可以参考网上的其他博客,最好的方法是下载一个mp4格式检查器(如mp4 Inspector),它可以自动显示mp4里的box结构,并以十六进制显示box里内容,这样看的更清楚明白。

快速生成网络mp4视频缩略图技术的更多相关文章

  1. Python 下载网络mp4视频资源

    最近着迷化学, 特别是古代的冶炼技术,感叹古人的聪明. 春秋时期的炼铁方法是块炼铁,即在较低的冶炼温度下,将铁矿石固态还原获得海绵铁,再经锻打成的铁块.冶炼块炼铁,一般采用地炉.平地筑炉和竖炉3种.铁 ...

  2. 使用AVFoundation仅仅生成缩略图,不进行播放视频(本地和网络文件都可以创建视频缩略图)

    使用MPMoviePlayerController来生成缩略图足够简单,但是如果仅仅是是为了生成缩略图而不进行视频播放的话,此刻使用 MPMoviePlayerController就有点大材小用了.其 ...

  3. IOS 视频缩略图的生成

    使用AVFoundation框架可以生成视频缩略图,用到的类: >>AVAsset: 用于获取多媒体的相关信息,如多媒体的画面和声音等. >>AVURLAsset: AVAss ...

  4. 利用FFmpeg生成视频缩略图 2.1.6

    利用FFmpeg生成视频缩略图 1.下载FFmpeg文件包,解压包里的\bin\下的文件解压到 D:\ffmpeg\ 目录下. 下载地址 http://ffmpeg.zeranoe.com/build ...

  5. 网络语音视频技术浅议 Visual Studio 2010(转)

    我们在开发实践中常常会涉及到网络语音视频技术.诸如即时通讯.视频会议.远程医疗.远程教育.网络监控等等,这些网络多媒体应用系统都离不开网络语音视频技术.本人才疏学浅,对于网络语音视频技术也仅仅是略知皮 ...

  6. 网络语音视频技术浅议(附多个demo源码下载)

    我们在开发实践中常常会涉及到网络语音视频技术.诸如即时通讯.视频会议.远程医疗.远程教育.网络监控等等,这些网络多媒体应用系统都离不开网络语音视频技术.本人才疏学浅,对于网络语音视频技术也仅仅是略知皮 ...

  7. 视频直播技术-视频-编码-传输-秒开等<转>

    转载地址:http://mp.weixin.qq.com/s?__biz=MzAwMDU1MTE1OQ==&mid=2653547042&idx=1&sn=26d8728548 ...

  8. android 获取视频缩略图终极解决方案(ffmpeg)

    http://blog.csdn.net/u010499721/article/details/50338623 前些天有个师弟(在做一个仿LinkInEyes行车记录仪的app)问我怎么获取视频缩略 ...

  9. 零基础快速掌握Python系统管理视频课程【猎豹网校】

    点击了解更多Python课程>>> 零基础快速掌握Python系统管理视频课程[猎豹网校] 课程目录 01.第01章 Python简介.mp4 02.第02章 IPython基础.m ...

随机推荐

  1. C#设计模式之14-命令模式

    命令模式(Command Pattern) 该文章的最新版本已迁移至个人博客[比特飞],单击链接 https://www.byteflying.com/archives/413 访问. 命令模式属于行 ...

  2. IT技术人,“三十而已”

    最近电视剧<三十而已>热播,我家的电视机自然也是被霸屏,我还是跟着妹纸看了看,开头和结局完整看完,中间看了一点,大部分都是在微信公众号上通过别人的文章看完的.我个人也已经30+了,今天也和 ...

  3. 使用 .NET Core 3.x 构建RESTful Api(第三部分)

    关于HTTP HEAD 和 HTTP GET: 从执行性能来说,这两种其实并没有什么区别.最大的不同就是对于HTTP HEAD 来说,Api消费者请求接口数据时,如果是通过HTTP HEAD的方式去请 ...

  4. JavaScript 基础四

    遍历对象的属性 for...in 语句用于对数组或者对象的属性进行循环操作. for (变量 in 对象名字) { 在此执行代码 } 这个变量是自定义 符合命名规范 但是一般我们 都写为 k 或则 k ...

  5. PAT 2-13. 两个有序序列的中位数(25)

    题目链接:http://www.patest.cn/contests/ds/2-13 解题思路及代码如下: /* 解题思路: 分别求出序列A 和B 的中位数,设为a 和b,求序列A 和B 的中位数过程 ...

  6. 从零开始讲解JavaScript中作用域链的概念及用途

    从零开始讲解JavaScript中作用域链的概念及用途 引言 正文 一.执行环境 二.作用域链 三.块级作用域 四.其他情况 五.总结 结束语 引言 先点赞,再看博客,顺手可以点个关注. 微信公众号搜 ...

  7. 二叉搜索树及java实现

    二叉搜索树(Binary Search Tree) 二叉搜索树是二叉树的一种,是应用非常广泛的一种二叉树,英文简称为 BST 又被称为:二叉查找树.二叉排序树 任意一个节点的值都大于其左子树所有节 ...

  8. Java之Annotation(注解)——注解处理器

    如果没有用来读取注解的方法和工作,那么注解也就不会比注释更有用处了.使用注解的过程中,很重要的一部分就是创建于使用注解处理器.Java SE5扩展了反射机制的API,以帮助程序员快速的构造自定义注解处 ...

  9. JavaScript学习系列博客_1_JavaScript简介

    这个系列博客主要用来记录本人学习JavaScript的笔记,从0开始,即使有些知识我也是知道的.但是会经常忘记,干脆就写成博客,没事的时候翻来看一看,留下一点学习的痕迹也好.可能写博客的水平暂时不太好 ...

  10. 多主机搭建etcd集群

    下载https://github.com/etcd-io/etcd/releases/download/v3.4.10/etcd-v3.4.10-linux-amd64.tar.gz分别放到两台主机上 ...