[{"data":1,"prerenderedAt":1514},["ShallowReactive",2],{"blog-zh-Hans-browser-streaming-remux-mp4-isobmff":3,"blog-zh-Hans-browser-streaming-remux-mp4-isobmff-surround":1501},{"id":4,"title":5,"author":6,"body":7,"category":1485,"date":1486,"description":1487,"extension":1488,"image":1489,"lastModified":1486,"meta":1490,"navigation":289,"path":1491,"readingTime":508,"seo":1492,"stem":1493,"tags":1494,"__hash__":1500},"blog_zh_Hans\u002Fzh-Hans\u002Fblog\u002Fbrowser-streaming-remux-mp4-isobmff.md","浏览器端视频重封装：fMP4、ISOBMFF 和不转码的理由","FlowPick 团队",{"type":8,"value":9,"toc":1469},"minimark",[10,19,22,25,30,33,44,57,80,84,87,93,110,124,129,135,144,155,158,161,184,191,601,610,614,621,632,657,660,694,700,703,1147,1150,1153,1159,1165,1171,1177,1180,1183,1193,1203,1209,1219,1238,1244,1250,1256,1262,1285,1294,1297,1301,1304,1321,1324,1327,1330,1377,1380,1383,1395,1403,1406,1409,1465],[11,12,13,14,18],"p",{},"FlowPick 在你浏览器里把 300 个 HLS 分片合成一个 MP4 时，它没在转码。没在重编码。连像素都没看一眼。它做的是",[15,16,17],"strong",{},"重封装（remux）","——重写容器，不碰 codec 的码流。",[11,20,21],{},"这件事之所以重要：重封装比转码快 50 倍，5 年前的笔记本也能实时跑，零质量损失。代价是：你得真的搞懂 MP4 文件是什么，不能把它当黑盒。",[11,23,24],{},"这篇文章就是来拆黑盒的。",[26,27,29],"h2",{"id":28},"mp4-文件到底是什么","MP4 文件到底是什么",[11,31,32],{},"MP4 是 ISO Base Media File Format（ISOBMFF，ISO\u002FIEC 14496-12）的品牌名。它是一棵嵌套的\"box\"（也叫 atom）树，每个 box 有 4 字符的类型和长度前缀：",[34,35,40],"pre",{"className":36,"code":38,"language":39},[37],"language-text","ftyp   (文件类型 — 标识这是 MP4，声明 brand)\nmoov   (电影元数据 — codec 配置、轨道布局、时序)\n  mvhd   (电影头 — timescale、时长)\n  trak   (轨道 — 视频\u002F音频\u002F字幕每路一个)\n    tkhd   (轨道头)\n    mdia   (媒体信息)\n      mdhd   (媒体头 — 轨道 timescale)\n      hdlr   (handler — 声明\"这是视频\"或\"这是音频\")\n      minf   (媒体信息)\n        stbl  (sample 表 — 实际帧数据存放处)\n          stsd  (sample 描述 — codec 配置如 SPS\u002FPPS)\n          stts  (时间到 sample — 每帧 PTS)\n          stsc  (sample 到 chunk — 哪个 chunk 含哪些 sample)\n          stsz  (sample 大小)\n          stco  (chunk 偏移 — 每个 chunk 在文件里的位置)\nmdat   (媒体数据 — 实际 H.264\u002FAAC 码流)\n","text",[41,42,38],"code",{"__ignoreMap":43},"",[11,45,46,49,50,53,54,56],{},[41,47,48],{},"moov"," 告诉播放器怎么解读 ",[41,51,52],{},"mdat","。没有 ",[41,55,48],{},"，你手里就是一堆没人能解的压缩字节。",[11,58,59,60,62,63,65,66,69,70,69,73,69,76,79],{},"普通 MP4 把整个 ",[41,61,48],{}," 放前面（或者放后面，要二次 seek），",[41,64,52],{}," 是一整块连续数据。想加一帧？得重写 ",[41,67,68],{},"stts","、",[41,71,72],{},"stsc",[41,74,75],{},"stsz",[41,77,78],{},"stco"," 还要挪字节。这是为什么\"编辑 MP4\"历史上一直很痛。",[26,81,83],{"id":82},"fragmented-mp4对流媒体友好的变体","Fragmented MP4：对流媒体友好的变体",[11,85,86],{},"Fragmented MP4（fMP4）把文件切成自包含的\"片段\"：",[34,88,91],{"className":89,"code":90,"language":39},[37],"ftyp\nmoov   (还是有 codec 配置 — stsd，但 stts\u002Fstsc\u002Fstsz 是空的)\nmoof   (电影片段 — 只针对这一段的 sample 表)\n  tfhd  (这段的轨道头)\n  trun  (track run — 这段的 sample 大小、PTS、时长)\nmdat   (媒体数据 — 只针对这一段)\nmoof   (下一段)\n  ...\nmdat\n...\n",[41,92,90],{"__ignoreMap":43},[11,94,95,96,99,100,102,103,105,106,109],{},"每个 ",[41,97,98],{},"moof","+",[41,101,52],{}," 对是一个完整、自描述的单元。两个 fMP4 片段要拼接？直接把字节追加上去——不重写、不算偏移。每个 ",[41,104,98],{}," 里的 ",[41,107,108],{},"trun"," 装的就是这一段 sample 的表。",[11,111,112,113,99,115,117,118,99,121,123],{},"这就是为什么 HLS 和 DASH 都转向了 fMP4。每个分片就是一个 ",[41,114,98],{},[41,116,52],{}," 对（外加一个含 ",[41,119,120],{},"ftyp",[41,122,48],{}," 的 init 分片）。分片拼起来就是一个合法的 fMP4 文件。",[125,126,128],"h3",{"id":127},"init-分片里有什么","init 分片里有什么",[34,130,133],{"className":131,"code":132,"language":39},[37],"ftyp\nmoov\n  mvhd\n  trak (视频)\n    tkhd\n    mdia\n      mdhd\n      hdlr (vide)\n      minf\n        stbl\n          stsd  ← avc1 配置 (H.264 的 SPS\u002FPPS)\n          ...   (空的 sample 表 — 由 moof 填)\n  trak (音频)\n    ...\n",[41,134,132],{"__ignoreMap":43},[11,136,137,138,140,141,143],{},"init 分片是\"文件头\"——声明 codec、轨道布局、timescale，但不含实际帧。没有它，",[41,139,98],{},"\u002F",[41,142,52],{}," 分片没人能解。",[11,145,146,147,150,151,154],{},"这就是为什么 DASH 的 ",[41,148,149],{},"\u003CSegmentTemplate initialization=\"init.mp4\">"," 和 HLS 的 ",[41,152,153],{},"#EXT-X-MAP:URI=\"init.mp4\""," 都存在——它们都是指向 fMP4 的 init 分片。",[26,156,157],{"id":157},"重封装算法",[11,159,160],{},"合并 fMP4 分片（DASH 场景——本来就是 fMP4）：",[162,163,164,173,181],"ol",{},[165,166,167,168,99,170,172],"li",{},"写 init 分片字节（输出的 ",[41,169,120],{},[41,171,48],{},"）",[165,174,175,176,99,178,180],{},"按顺序写每个媒体分片的 ",[41,177,98],{},[41,179,52],{}," 字节",[165,182,183],{},"完",[11,185,186,187,190],{},"就这样。输出是合法的 fMP4 文件。codec 一个字节没碰，没重编码，没质量损失。CPU 占用基本是\"内存带宽极限\"——你就是在那做 ",[41,188,189],{},"memcpy","。",[34,192,196],{"className":193,"code":194,"language":195,"meta":43,"style":43},"language-js shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","async function mergeFmp4(initUrl, segmentUrls) {\n  const init = new Uint8Array(await (await fetch(initUrl)).arrayBuffer())\n\n  const chunks = [init]\n  for (const url of segmentUrls) {\n    chunks.push(new Uint8Array(await (await fetch(url)).arrayBuffer()))\n  }\n\n  \u002F\u002F 算总长度\n  const total = chunks.reduce((s, c) => s + c.byteLength, 0)\n  const out = new Uint8Array(total)\n  let offset = 0\n  for (const chunk of chunks) {\n    out.set(chunk, offset)\n    offset += chunk.byteLength\n  }\n  return new Blob([out], { type: 'video\u002Fmp4' })\n}\n","js",[41,197,198,234,284,291,310,335,377,383,388,395,451,472,486,506,528,544,549,595],{"__ignoreMap":43},[199,200,203,207,210,214,218,222,225,228,231],"span",{"class":201,"line":202},"line",1,[199,204,206],{"class":205},"spNyl","async",[199,208,209],{"class":205}," function",[199,211,213],{"class":212},"s2Zo4"," mergeFmp4",[199,215,217],{"class":216},"sMK4o","(",[199,219,221],{"class":220},"sHdIc","initUrl",[199,223,224],{"class":216},",",[199,226,227],{"class":220}," segmentUrls",[199,229,230],{"class":216},")",[199,232,233],{"class":216}," {\n",[199,235,237,240,244,247,250,253,256,260,263,265,268,270,272,275,278,281],{"class":201,"line":236},2,[199,238,239],{"class":205},"  const",[199,241,243],{"class":242},"sTEyZ"," init",[199,245,246],{"class":216}," =",[199,248,249],{"class":216}," new",[199,251,252],{"class":212}," Uint8Array",[199,254,217],{"class":255},"swJcz",[199,257,259],{"class":258},"s7zQu","await",[199,261,262],{"class":255}," (",[199,264,259],{"class":258},[199,266,267],{"class":212}," fetch",[199,269,217],{"class":255},[199,271,221],{"class":242},[199,273,274],{"class":255},"))",[199,276,277],{"class":216},".",[199,279,280],{"class":212},"arrayBuffer",[199,282,283],{"class":255},"())\n",[199,285,287],{"class":201,"line":286},3,[199,288,290],{"emptyLinePlaceholder":289},true,"\n",[199,292,294,296,299,301,304,307],{"class":201,"line":293},4,[199,295,239],{"class":205},[199,297,298],{"class":242}," chunks",[199,300,246],{"class":216},[199,302,303],{"class":255}," [",[199,305,306],{"class":242},"init",[199,308,309],{"class":255},"]\n",[199,311,313,316,318,321,324,327,329,332],{"class":201,"line":312},5,[199,314,315],{"class":258},"  for",[199,317,262],{"class":255},[199,319,320],{"class":205},"const",[199,322,323],{"class":242}," url",[199,325,326],{"class":216}," of",[199,328,227],{"class":242},[199,330,331],{"class":255},") ",[199,333,334],{"class":216},"{\n",[199,336,338,341,343,346,348,351,353,355,357,359,361,363,365,368,370,372,374],{"class":201,"line":337},6,[199,339,340],{"class":242},"    chunks",[199,342,277],{"class":216},[199,344,345],{"class":212},"push",[199,347,217],{"class":255},[199,349,350],{"class":216},"new",[199,352,252],{"class":212},[199,354,217],{"class":255},[199,356,259],{"class":258},[199,358,262],{"class":255},[199,360,259],{"class":258},[199,362,267],{"class":212},[199,364,217],{"class":255},[199,366,367],{"class":242},"url",[199,369,274],{"class":255},[199,371,277],{"class":216},[199,373,280],{"class":212},[199,375,376],{"class":255},"()))\n",[199,378,380],{"class":201,"line":379},7,[199,381,382],{"class":216},"  }\n",[199,384,386],{"class":201,"line":385},8,[199,387,290],{"emptyLinePlaceholder":289},[199,389,391],{"class":201,"line":390},9,[199,392,394],{"class":393},"sHwdD","  \u002F\u002F 算总长度\n",[199,396,398,400,403,405,407,409,412,414,416,419,421,424,426,429,432,435,437,439,442,444,448],{"class":201,"line":397},10,[199,399,239],{"class":205},[199,401,402],{"class":242}," total",[199,404,246],{"class":216},[199,406,298],{"class":242},[199,408,277],{"class":216},[199,410,411],{"class":212},"reduce",[199,413,217],{"class":255},[199,415,217],{"class":216},[199,417,418],{"class":220},"s",[199,420,224],{"class":216},[199,422,423],{"class":220}," c",[199,425,230],{"class":216},[199,427,428],{"class":205}," =>",[199,430,431],{"class":242}," s",[199,433,434],{"class":216}," +",[199,436,423],{"class":242},[199,438,277],{"class":216},[199,440,441],{"class":242},"byteLength",[199,443,224],{"class":216},[199,445,447],{"class":446},"sbssI"," 0",[199,449,450],{"class":255},")\n",[199,452,454,456,459,461,463,465,467,470],{"class":201,"line":453},11,[199,455,239],{"class":205},[199,457,458],{"class":242}," out",[199,460,246],{"class":216},[199,462,249],{"class":216},[199,464,252],{"class":212},[199,466,217],{"class":255},[199,468,469],{"class":242},"total",[199,471,450],{"class":255},[199,473,475,478,481,483],{"class":201,"line":474},12,[199,476,477],{"class":205},"  let",[199,479,480],{"class":242}," offset",[199,482,246],{"class":216},[199,484,485],{"class":446}," 0\n",[199,487,489,491,493,495,498,500,502,504],{"class":201,"line":488},13,[199,490,315],{"class":258},[199,492,262],{"class":255},[199,494,320],{"class":205},[199,496,497],{"class":242}," chunk",[199,499,326],{"class":216},[199,501,298],{"class":242},[199,503,331],{"class":255},[199,505,334],{"class":216},[199,507,509,512,514,517,519,522,524,526],{"class":201,"line":508},14,[199,510,511],{"class":242},"    out",[199,513,277],{"class":216},[199,515,516],{"class":212},"set",[199,518,217],{"class":255},[199,520,521],{"class":242},"chunk",[199,523,224],{"class":216},[199,525,480],{"class":242},[199,527,450],{"class":255},[199,529,531,534,537,539,541],{"class":201,"line":530},15,[199,532,533],{"class":242},"    offset",[199,535,536],{"class":216}," +=",[199,538,497],{"class":242},[199,540,277],{"class":216},[199,542,543],{"class":242},"byteLength\n",[199,545,547],{"class":201,"line":546},16,[199,548,382],{"class":216},[199,550,552,555,557,560,563,566,569,571,574,577,580,583,587,590,593],{"class":201,"line":551},17,[199,553,554],{"class":258},"  return",[199,556,249],{"class":216},[199,558,559],{"class":212}," Blob",[199,561,562],{"class":255},"([",[199,564,565],{"class":242},"out",[199,567,568],{"class":255},"]",[199,570,224],{"class":216},[199,572,573],{"class":216}," {",[199,575,576],{"class":255}," type",[199,578,579],{"class":216},":",[199,581,582],{"class":216}," '",[199,584,586],{"class":585},"sfazB","video\u002Fmp4",[199,588,589],{"class":216},"'",[199,591,592],{"class":216}," }",[199,594,450],{"class":255},[199,596,598],{"class":201,"line":597},18,[199,599,600],{"class":216},"}\n",[11,602,603,604,609],{},"这 12 行代码就是 FlowPick 处理 DASH 流的大部分工作。复杂度不在合并——在于先解析 MPD 拿到 URL（",[605,606,608],"a",{"href":607},"\u002Fzh-Hans\u002Fblog\u002Fdash-mpd-deep-dive-segmenttemplate-contentprotection","DASH 深度那篇"," 讲了），还有下面这些边界情况。",[26,611,613],{"id":612},"hls-这边mpeg-ts-转-mp4","HLS 这边：MPEG-TS 转 MP4",[11,615,616,617,620],{},"HLS 传统上用 MPEG-TS，不是 fMP4。MPEG-TS 是另一种容器——188 字节定长包，音视频复用在一起，自带时序（PCR\u002FPTS）。",[41,618,619],{},".ts"," 文件不能直接拼成 MP4；你得把 TS 解复用、抽出 PES 包、解析 PTS、写成 fMP4。",[11,622,623,624,627,628,631],{},"这就是 FFmpeg WASM 撑场面的地方。FFmpeg 的 ",[41,625,626],{},"mpegts"," 解封装器 + ",[41,629,630],{},"mp4"," 封装器要处理：",[162,633,634,637,640,643,646],{},[165,635,636],{},"读 188 字节 TS 包，按 PID 过滤（视频 PID、音频 PID）",[165,638,639],{},"从 TS payload 重组 PES 包",[165,641,642],{},"从 PES 头抽 PTS\u002FDTS",[165,644,645],{},"分组访问单元（一帧视频 = 一个 AAC 音频帧）",[165,647,648,649,140,651,653,654,656],{},"写 ",[41,650,98],{},[41,652,52],{}," box，",[41,655,108],{}," 的 sample 表要正确",[11,658,659],{},"命令行等价物：",[34,661,665],{"className":662,"code":663,"language":664,"meta":43,"style":43},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","ffmpeg -i input.ts -c copy -f mp4 output.mp4\n","bash",[41,666,667],{"__ignoreMap":43},[199,668,669,673,676,679,682,685,688,691],{"class":201,"line":202},[199,670,672],{"class":671},"sBMFI","ffmpeg",[199,674,675],{"class":585}," -i",[199,677,678],{"class":585}," input.ts",[199,680,681],{"class":585}," -c",[199,683,684],{"class":585}," copy",[199,686,687],{"class":585}," -f",[199,689,690],{"class":585}," mp4",[199,692,693],{"class":585}," output.mp4\n",[11,695,696,699],{},[41,697,698],{},"-c copy"," 是关键——告诉 FFmpeg 把 codec 码流原样复制，不解码也不重编码。CPU 占用极小。",[11,701,702],{},"浏览器里，FFmpeg WASM 做同样的事：",[34,704,706],{"className":193,"code":705,"language":195,"meta":43,"style":43},"import { FFmpeg } from '@ffmpeg\u002Fffmpeg'\n\nconst ffmpeg = new FFmpeg()\nawait ffmpeg.load()\n\n\u002F\u002F 把分片写进 FFmpeg 的虚拟 FS\nfor (let i = 0; i \u003C segments.length; i++) {\n  await ffmpeg.writeFile(`seg${i}.ts`, segments[i])\n}\n\n\u002F\u002F 拼接列表\nconst concatList = segments.map((_, i) => `file 'seg${i}.ts'`).join('\\n')\nawait ffmpeg.writeFile('list.txt', new TextEncoder().encode(concatList))\n\n\u002F\u002F 重封装\nawait ffmpeg.exec([\n  '-f', 'concat', '-safe', '0', '-i', 'list.txt',\n  '-c', 'copy',\n  '-f', 'mp4', 'out.mp4'\n])\n\nconst output = await ffmpeg.readFile('out.mp4')\n",[41,707,708,731,735,752,766,770,775,820,865,869,873,878,944,981,985,990,1004,1061,1081,1107,1112,1117],{"__ignoreMap":43},[199,709,710,713,715,718,720,723,725,728],{"class":201,"line":202},[199,711,712],{"class":258},"import",[199,714,573],{"class":216},[199,716,717],{"class":242}," FFmpeg",[199,719,592],{"class":216},[199,721,722],{"class":258}," from",[199,724,582],{"class":216},[199,726,727],{"class":585},"@ffmpeg\u002Fffmpeg",[199,729,730],{"class":216},"'\n",[199,732,733],{"class":201,"line":236},[199,734,290],{"emptyLinePlaceholder":289},[199,736,737,739,742,745,747,749],{"class":201,"line":286},[199,738,320],{"class":205},[199,740,741],{"class":242}," ffmpeg ",[199,743,744],{"class":216},"=",[199,746,249],{"class":216},[199,748,717],{"class":212},[199,750,751],{"class":242},"()\n",[199,753,754,756,759,761,764],{"class":201,"line":293},[199,755,259],{"class":258},[199,757,758],{"class":242}," ffmpeg",[199,760,277],{"class":216},[199,762,763],{"class":212},"load",[199,765,751],{"class":242},[199,767,768],{"class":201,"line":312},[199,769,290],{"emptyLinePlaceholder":289},[199,771,772],{"class":201,"line":337},[199,773,774],{"class":393},"\u002F\u002F 把分片写进 FFmpeg 的虚拟 FS\n",[199,776,777,780,782,785,788,790,792,795,797,800,803,805,808,810,813,816,818],{"class":201,"line":379},[199,778,779],{"class":258},"for",[199,781,262],{"class":242},[199,783,784],{"class":205},"let",[199,786,787],{"class":242}," i ",[199,789,744],{"class":216},[199,791,447],{"class":446},[199,793,794],{"class":216},";",[199,796,787],{"class":242},[199,798,799],{"class":216},"\u003C",[199,801,802],{"class":242}," segments",[199,804,277],{"class":216},[199,806,807],{"class":242},"length",[199,809,794],{"class":216},[199,811,812],{"class":242}," i",[199,814,815],{"class":216},"++",[199,817,331],{"class":242},[199,819,334],{"class":216},[199,821,822,825,827,829,832,834,837,840,843,846,849,851,853,855,857,860,862],{"class":201,"line":385},[199,823,824],{"class":258},"  await",[199,826,758],{"class":242},[199,828,277],{"class":216},[199,830,831],{"class":212},"writeFile",[199,833,217],{"class":255},[199,835,836],{"class":216},"`",[199,838,839],{"class":585},"seg",[199,841,842],{"class":216},"${",[199,844,845],{"class":242},"i",[199,847,848],{"class":216},"}",[199,850,619],{"class":585},[199,852,836],{"class":216},[199,854,224],{"class":216},[199,856,802],{"class":242},[199,858,859],{"class":255},"[",[199,861,845],{"class":242},[199,863,864],{"class":255},"])\n",[199,866,867],{"class":201,"line":390},[199,868,600],{"class":216},[199,870,871],{"class":201,"line":397},[199,872,290],{"emptyLinePlaceholder":289},[199,874,875],{"class":201,"line":453},[199,876,877],{"class":393},"\u002F\u002F 拼接列表\n",[199,879,880,882,885,887,889,891,894,896,898,901,903,905,907,909,912,915,917,919,921,924,926,928,930,933,935,937,940,942],{"class":201,"line":474},[199,881,320],{"class":205},[199,883,884],{"class":242}," concatList ",[199,886,744],{"class":216},[199,888,802],{"class":242},[199,890,277],{"class":216},[199,892,893],{"class":212},"map",[199,895,217],{"class":242},[199,897,217],{"class":216},[199,899,900],{"class":220},"_",[199,902,224],{"class":216},[199,904,812],{"class":220},[199,906,230],{"class":216},[199,908,428],{"class":205},[199,910,911],{"class":216}," `",[199,913,914],{"class":585},"file 'seg",[199,916,842],{"class":216},[199,918,845],{"class":242},[199,920,848],{"class":216},[199,922,923],{"class":585},".ts'",[199,925,836],{"class":216},[199,927,230],{"class":242},[199,929,277],{"class":216},[199,931,932],{"class":212},"join",[199,934,217],{"class":242},[199,936,589],{"class":216},[199,938,939],{"class":242},"\\n",[199,941,589],{"class":216},[199,943,450],{"class":242},[199,945,946,948,950,952,954,956,958,961,963,965,967,970,973,975,978],{"class":201,"line":488},[199,947,259],{"class":258},[199,949,758],{"class":242},[199,951,277],{"class":216},[199,953,831],{"class":212},[199,955,217],{"class":242},[199,957,589],{"class":216},[199,959,960],{"class":585},"list.txt",[199,962,589],{"class":216},[199,964,224],{"class":216},[199,966,249],{"class":216},[199,968,969],{"class":212}," TextEncoder",[199,971,972],{"class":242},"()",[199,974,277],{"class":216},[199,976,977],{"class":212},"encode",[199,979,980],{"class":242},"(concatList))\n",[199,982,983],{"class":201,"line":508},[199,984,290],{"emptyLinePlaceholder":289},[199,986,987],{"class":201,"line":530},[199,988,989],{"class":393},"\u002F\u002F 重封装\n",[199,991,992,994,996,998,1001],{"class":201,"line":546},[199,993,259],{"class":258},[199,995,758],{"class":242},[199,997,277],{"class":216},[199,999,1000],{"class":212},"exec",[199,1002,1003],{"class":242},"([\n",[199,1005,1006,1009,1012,1014,1016,1018,1021,1023,1025,1027,1030,1032,1034,1036,1039,1041,1043,1045,1048,1050,1052,1054,1056,1058],{"class":201,"line":551},[199,1007,1008],{"class":216},"  '",[199,1010,1011],{"class":585},"-f",[199,1013,589],{"class":216},[199,1015,224],{"class":216},[199,1017,582],{"class":216},[199,1019,1020],{"class":585},"concat",[199,1022,589],{"class":216},[199,1024,224],{"class":216},[199,1026,582],{"class":216},[199,1028,1029],{"class":585},"-safe",[199,1031,589],{"class":216},[199,1033,224],{"class":216},[199,1035,582],{"class":216},[199,1037,1038],{"class":585},"0",[199,1040,589],{"class":216},[199,1042,224],{"class":216},[199,1044,582],{"class":216},[199,1046,1047],{"class":585},"-i",[199,1049,589],{"class":216},[199,1051,224],{"class":216},[199,1053,582],{"class":216},[199,1055,960],{"class":585},[199,1057,589],{"class":216},[199,1059,1060],{"class":216},",\n",[199,1062,1063,1065,1068,1070,1072,1074,1077,1079],{"class":201,"line":597},[199,1064,1008],{"class":216},[199,1066,1067],{"class":585},"-c",[199,1069,589],{"class":216},[199,1071,224],{"class":216},[199,1073,582],{"class":216},[199,1075,1076],{"class":585},"copy",[199,1078,589],{"class":216},[199,1080,1060],{"class":216},[199,1082,1084,1086,1088,1090,1092,1094,1096,1098,1100,1102,1105],{"class":201,"line":1083},19,[199,1085,1008],{"class":216},[199,1087,1011],{"class":585},[199,1089,589],{"class":216},[199,1091,224],{"class":216},[199,1093,582],{"class":216},[199,1095,630],{"class":585},[199,1097,589],{"class":216},[199,1099,224],{"class":216},[199,1101,582],{"class":216},[199,1103,1104],{"class":585},"out.mp4",[199,1106,730],{"class":216},[199,1108,1110],{"class":201,"line":1109},20,[199,1111,864],{"class":242},[199,1113,1115],{"class":201,"line":1114},21,[199,1116,290],{"emptyLinePlaceholder":289},[199,1118,1120,1122,1125,1127,1130,1132,1134,1137,1139,1141,1143,1145],{"class":201,"line":1119},22,[199,1121,320],{"class":205},[199,1123,1124],{"class":242}," output ",[199,1126,744],{"class":216},[199,1128,1129],{"class":258}," await",[199,1131,758],{"class":242},[199,1133,277],{"class":216},[199,1135,1136],{"class":212},"readFile",[199,1138,217],{"class":242},[199,1140,589],{"class":216},[199,1142,1104],{"class":585},[199,1144,589],{"class":216},[199,1146,450],{"class":242},[11,1148,1149],{},"30 分钟 1080p 视频、300 个分片，现代笔记本跑 15-30 秒。同样操作转码要 10-20 分钟。",[26,1151,1152],{"id":1152},"为什么重封装不转码",[11,1154,1155,1158],{},[15,1156,1157],{},"速度。"," 重封装是内存带宽瓶颈；转码是 CPU 瓶颈。4 核 x86 转 1080p H.264 大概 1-3 倍实时（30 分钟视频要 10-30 分钟）。重封装 50-100 倍实时。",[11,1160,1161,1164],{},[15,1162,1163],{},"质量。"," 转码有代际损失——每次重编码都掉画质，\"高码率\"也掉。重封装原样保留码流，一个字节不差。",[11,1166,1167,1170],{},[15,1168,1169],{},"电量。"," 转码烧 CPU，重封装不烧。笔记本上转 4K 流 20 分钟电池见底。重封装几乎察觉不到。",[11,1172,1173,1176],{},[15,1174,1175],{},"可预测。"," 转码时长取决于源内容复杂度（暗场比细节场编码快）。重封装时长只看文件大小——线性可预测。",[11,1178,1179],{},"转码的唯一理由：你真要换 codec——比如 H.265 源转 H.264 给老设备播。FlowPick 不做这个，因为（a）现代设备都支持 H.264\u002FAAC，（b）延迟代价不值。",[26,1181,1182],{"id":1182},"真的能让无脑拼接翻车的边界情况",[11,1184,1185,1188,1189,1192],{},[15,1186,1187],{},"1. 不连续性。"," HLS 广告位用 ",[41,1190,1191],{},"#EXT-X-DISCONTINUITY"," 信号 PTS 重置。跨不连续点拼接，MP4 播放器会\"时间倒流\"。FFmpeg 处理得了；裸字节拼接不行。",[11,1194,1195,1198,1199,1202],{},[15,1196,1197],{},"2. Period 之间换 codec。"," DASH 多 Period MPD（广告 + 正片）每个 Period 可能用不同 H.264 profile。fMP4 拼起来后 init 分片的 ",[41,1200,1201],{},"stsd"," 跟后续片段对不上。播放器要么报错要么花屏。",[11,1204,1205,1208],{},[15,1206,1207],{},"3. init 分片不匹配。"," 下错 init 分片（比如下了另一个 Representation 的），codec 配置跟你的分片对不上。输出文件不能播。",[11,1210,1211,1214,1215,1218],{},[15,1212,1213],{},"4. 分片顺序。"," 并发抓取时分片乱序到达。拼接前必须重排。",[605,1216,1217],{"href":607},"DASH 并发抓取模式"," 用索引数组处理这个。",[11,1220,1221,1224,1225,1227,1228,1230,1231,1234,1235,1237],{},[15,1222,1223],{},"5. moov atom 在文件末尾。"," \"渐进下载\" MP4（流媒体少见，直链下载常见）把 ",[41,1226,48],{}," 放在末尾。没 ",[41,1229,48],{},"，文件没下完就没法播。FFmpeg 的 ",[41,1232,1233],{},"-movflags +faststart"," 把 ",[41,1236,48],{}," 挪到前面。给下载用的封装一定加这个 flag。",[26,1239,1241,1243],{"id":1240},"moof-box-里到底有什么",[41,1242,98],{}," box 里到底有什么",[11,1245,1246,1247,1249],{},"想往深里钻，这是一个典型 ",[41,1248,98],{}," 的字节布局：",[34,1251,1254],{"className":1252,"code":1253,"language":39},[37],"moof (size: 0x00000400)\n  mfhd (size: 0x00000010) — 电影片段头\n    sequence_number: 0x00000001\n  traf (size: 0x000003e0) — 轨道片段\n    tfhd (size: 0x00000018) — 轨道片段头\n      track_id: 1\n      default_sample_duration: 0x000003e8 (1000，timescale 单位)\n    tfdt (size: 0x00000014) — 轨道片段解码时间\n      baseMediaDecodeTime: 0x00000000\n    trun (size: 0x000003c0) — track run\n      sample_count: 60\n      sample 0: duration=1000, size=12345, flags=0x02000000\n      sample 1: duration=1000, size=11876, flags=0x02000000\n      ...\n",[41,1255,1253],{"__ignoreMap":43},[11,1257,1258,1261],{},[41,1259,1260],{},"flags"," 字段编码这个 sample 是不是：",[1263,1264,1265,1272,1279],"ul",{},[165,1266,1267,1268,1271],{},"关键帧（",[41,1269,1270],{},"0x02000000"," = sync sample）",[165,1273,1274,1275,1278],{},"非关键帧（",[41,1276,1277],{},"0x00010000"," = non-sync）",[165,1280,1281,1282,172],{},"不连续（",[41,1283,1284],{},"0x00000100",[11,1286,1287,1290,1291,1293],{},[41,1288,1289],{},"tfdt"," box 给这段第一个 sample 的绝对解码时间。把 ",[41,1292,108],{}," 里的时长累加起来，就是这段每个 sample 的 PTS。",[11,1295,1296],{},"这就是 FFmpeg 重封装时解析的结构。想自己写封装器（别写，用 FFmpeg 就行），这就是你要写的结构。",[26,1298,1300],{"id":1299},"我的看法别自己写封装器","我的看法：别自己写封装器",[11,1302,1303],{},"有一票\"轻量级\"浏览器封装器想跳过 FFmpeg，纯手撸 ISOBMFF。我看过其中大多数的源码。无一例外，覆盖 80% 的场景，挂在：",[1263,1305,1306,1309,1312,1315,1318],{},[165,1307,1308],{},"HEVC 流（NAL unit 打包方式不同）",[165,1310,1311],{},"带 gapless playback 元数据的音频",[165,1313,1314],{},"有 B 帧的流（DTS ≠ PTS，顺序很重要）",[165,1316,1317],{},"可变帧率内容",[165,1319,1320],{},"HDR 元数据（Content Light Level、Mastering Display Color Volume）",[11,1322,1323],{},"FFmpeg 是 30 年积累的边界情况处理。JS 里重写哪怕 50% 也要好几年。引入 FFmpeg WASM（25MB gzip）的代价远比\"在真实内容上把封装搞错\"的代价小。",[11,1325,1326],{},"例外：你做的是\"只拼 fMP4 分片、零边界情况\"的最小工具，上面那 12 行 merge 函数够用。FlowPick 两条路都有——DASH 走简单拼接，HLS 有不连续性时走完整 FFmpeg 重封装。",[26,1328,1329],{"id":1329},"参考资料",[1263,1331,1332,1341,1349,1361,1369],{},[165,1333,1334,1340],{},[605,1335,1339],{"href":1336,"rel":1337},"https:\u002F\u002Fwww.iso.org\u002Fstandard\u002F83102.html",[1338],"nofollow","ISO\u002FIEC 14496-12 — ISOBMFF 规范"," — 正式规范",[165,1342,1343,1348],{},[605,1344,1347],{"href":1345,"rel":1346},"https:\u002F\u002Fmp4ra.org\u002F",[1338],"MP4RA — MP4 注册机构"," — Box 类型注册表",[165,1350,1351,1356,1357,1360],{},[605,1352,1355],{"href":1353,"rel":1354},"https:\u002F\u002Fffmpeg.org\u002Fffmpeg-formats.html#mov_002c-mp4_002c-ismv",[1338],"FFmpeg MP4 封装器文档"," — 含 ",[41,1358,1359],{},"+faststart"," 和分片选项",[165,1362,1363,1368],{},[605,1364,1367],{"href":1365,"rel":1366},"https:\u002F\u002Fgithub.com\u002Faxiomatic-systems\u002FBento4",[1338],"Bento4 源码"," — 想深搞 ISOBMFF 就读它；AP4_Atom.cpp 是 box 解析的大师课",[165,1370,1371,1376],{},[605,1372,1375],{"href":1373,"rel":1374},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FVideoEncoder",[1338],"WebCodecs VideoEncoder 文档"," — FFmpeg WASM 的浏览器原生替代，下一篇讲",[26,1378,1379],{"id":1379},"小结",[11,1381,1382],{},"\"把分片合成 MP4\"这一步从外面看像魔法。其实不是——它是一个结构化的重封装，把为拼接而生的 fMP4 片段按顺序写出来，前面挂一个 init 分片当文件头。HLS 还要先用 FFmpeg WASM 把 MPEG-TS 解封装成 fMP4。无论哪种，你都在复制 codec 字节，不在重编码，所以快、所以无损。",[11,1384,1385,1386,1390,1391,1394],{},"难的不在重封装——在解析清单、处理不连续性、init 分片拿对、处理 DRM。这些在 ",[605,1387,1389],{"href":1388},"\u002Fzh-Hans\u002Fblog\u002Fhls-m3u8-deep-dive-encryption-multitrack","HLS"," 和 ",[605,1392,1393],{"href":607},"DASH"," 深度篇里讲。",[11,1396,1397,1398,1402],{},"下一篇讲更底层的浏览器 API——",[605,1399,1401],{"href":1400},"\u002Fzh-Hans\u002Fblog\u002Fwebcodecs-web-workers-opfs-video-processing","WebCodecs、Web Workers 和 OPFS","——FFmpeg WASM 不是对的工具、想在浏览器里真做视频处理时用。",[1404,1405],"hr",{},[26,1407,1408],{"id":1408},"推荐阅读",[1263,1410,1411,1420,1431,1439,1447,1456],{},[165,1412,1413,1419],{},[15,1414,1415],{},[605,1416,1418],{"href":1417},"\u002Fzh-Hans\u002Fblog\u002Fhow-flowpick-merges-video-segments-in-browser","FlowPick 是怎么在浏览器里把几百个视频切片合成 MP4 的"," — 这篇里讲的模式在 FlowPick 里怎么落地",[165,1421,1422,1427,1428,1430],{},[15,1423,1424],{},[605,1425,1426],{"href":1388},"HLS 深度：加密、多音轨、值得记住的 EXT-X 标签"," — ",[41,1429,619],{}," 分片从哪来",[165,1432,1433,1438],{},[15,1434,1435],{},[605,1436,1437],{"href":607},"DASH 深度：SegmentTemplate、ContentProtection 与多视角 MPD"," — fMP4 分片从哪来",[165,1440,1441,1446],{},[15,1442,1443],{},[605,1444,1445],{"href":1400},"WebCodecs + Web Workers + OPFS：浏览器视频处理实战"," — FFmpeg WASM 的更底层替代",[165,1448,1449,1455],{},[15,1450,1451],{},[605,1452,1454],{"href":1453},"\u002Fzh-Hans\u002Fblog\u002Fbrowser-video-processing-performance-benchmarks","浏览器视频处理性能基准"," — FFmpeg WASM、WebCodecs、原生的实测对比",[165,1457,1458,1464],{},[15,1459,1460],{},[605,1461,1463],{"href":1462},"\u002Fzh-Hans\u002Fblog\u002Fgetting-started-with-hls-m3u8-download","如何下载 M3U8\u002FHLS 流：完整入门指南"," — HLS 入门指南",[1466,1467,1468],"style",{},"html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":43,"searchDepth":236,"depth":236,"links":1470},[1471,1472,1475,1476,1477,1478,1479,1481,1482,1483,1484],{"id":28,"depth":236,"text":29},{"id":82,"depth":236,"text":83,"children":1473},[1474],{"id":127,"depth":286,"text":128},{"id":157,"depth":236,"text":157},{"id":612,"depth":236,"text":613},{"id":1152,"depth":236,"text":1152},{"id":1182,"depth":236,"text":1182},{"id":1240,"depth":236,"text":1480},"moof box 里到底有什么",{"id":1299,"depth":236,"text":1300},{"id":1329,"depth":236,"text":1329},{"id":1379,"depth":236,"text":1379},{"id":1408,"depth":236,"text":1408},"tips","2026-08-04","FlowPick 在浏览器里把几百个 HLS 分片合成一个 MP4 时，到底发生了什么——ISOBMFF 的 box 结构、为什么 fragmented MP4 能直接拼而普通 MP4 不行、为什么重封装比转码快 50 倍。","md","\u002Fscreenshots\u002Fformat-conversion.png",{},"\u002Fzh-hans\u002Fblog\u002Fbrowser-streaming-remux-mp4-isobmff",{"title":5,"description":1487},"zh-Hans\u002Fblog\u002Fbrowser-streaming-remux-mp4-isobmff",[630,1495,1496,1497,1498,672,1499],"isobmff","fmp4","重封装","wasm","深度","2T-bxOX55HQDWia3Gx1bzvxjfQVtT_gFOtrMjq75WtY",[1502,1509],{"title":1503,"path":1504,"stem":1505,"description":1506,"date":1507,"category":1508,"children":-1},"如何批量下载网页上的图片","\u002Fzh-hans\u002Fblog\u002Fbatch-download-images-from-any-website","zh-Hans\u002Fblog\u002Fbatch-download-images-from-any-website","用 FlowPick 内置图片下载器批量提取网页图片，支持 JPG、PNG、WebP 等格式，并按文件大小和资源类型过滤，适合整理图库和网页素材。","2026-07-01","tutorials",{"title":1510,"path":1511,"stem":1512,"description":1513,"date":1486,"category":1485,"children":-1},"浏览器视频处理性能基准：FFmpeg WASM vs WebCodecs vs 原生","\u002Fzh-hans\u002Fblog\u002Fbrowser-video-processing-performance-benchmarks","zh-Hans\u002Fblog\u002Fbrowser-video-processing-performance-benchmarks","真实硬件上的真实数据——1080p\u002F4K\u002F8K 重封装和解码基准，横跨 FFmpeg WASM、WebCodecs、原生 FFmpeg。附方法论、原始数据、数字到底意味着什么。",1787670586708]