[{"data":1,"prerenderedAt":1531},["ShallowReactive",2],{"blog-en-browser-streaming-remux-mp4-isobmff":3,"blog-en-browser-streaming-remux-mp4-isobmff-surround":1519},{"id":4,"title":5,"author":6,"body":7,"category":1503,"date":1504,"description":1505,"extension":1506,"image":1507,"lastModified":1504,"meta":1508,"navigation":285,"path":1509,"readingTime":504,"seo":1510,"stem":1511,"tags":1512,"__hash__":1518},"blog\u002Fblog\u002Fbrowser-streaming-remux-mp4-isobmff.md","Browser-Side Video Remuxing: fMP4, ISOBMFF, and Why We Don't Transcode","FlowPick Team",{"type":8,"value":9,"toc":1486},"minimark",[10,14,17,20,25,28,39,53,76,80,83,89,106,120,125,131,140,151,155,158,181,188,597,606,610,617,628,653,656,690,696,699,1143,1146,1150,1157,1163,1169,1175,1178,1182,1192,1202,1208,1218,1237,1244,1249,1255,1261,1284,1293,1296,1300,1303,1320,1323,1326,1330,1377,1381,1384,1396,1404,1407,1411,1449,1453,1482],[11,12,13],"p",{},"When FlowPick merges 300 HLS segments into a single MP4 in your browser, it's not transcoding. It's not re-encoding. It's not even looking at the pixels. It's remuxing — rewriting the container without touching the codec bitstream.",[11,15,16],{},"This matters because remuxing is roughly 50x faster than transcoding, runs in real-time even on a 5-year-old laptop, and produces zero quality loss. The catch: you have to actually understand what an MP4 file is, not treat it as a black box.",[11,18,19],{},"This article is the black-box removal.",[21,22,24],"h2",{"id":23},"what-an-mp4-file-actually-is","What an MP4 file actually is",[11,26,27],{},"MP4 is a brand name for ISO Base Media File Format (ISOBMFF), defined in ISO\u002FIEC 14496-12. It's a tree of nested \"boxes\" (also called \"atoms\"), each with a 4-character type and a length prefix:",[29,30,35],"pre",{"className":31,"code":33,"language":34},[32],"language-text","ftyp   (file type — identifies this as MP4, declares brand)\nmoov   (movie metadata — codec config, track layout, timing)\n  mvhd   (movie header — timescale, duration)\n  trak   (track — one per video\u002Faudio\u002Fsubtitle stream)\n    tkhd   (track header)\n    mdia   (media info)\n      mdhd   (media header — track timescale)\n      hdlr   (handler — declares \"this is video\" or \"this is audio\")\n      minf   (media info)\n        stbl  (sample table — where the actual frames live)\n          stsd  (sample descriptions — codec config like SPS\u002FPPS)\n          stts  (time-to-sample — PTS for each frame)\n          stsc  (sample-to-chunk — which chunk has which samples)\n          stsz  (sample sizes)\n          stco  (chunk offsets — where each chunk sits in the file)\nmdat   (media data — the actual H.264\u002FAAC bitstream)\n","text",[36,37,33],"code",{"__ignoreMap":38},"",[11,40,41,42,45,46,49,50,52],{},"The ",[36,43,44],{},"moov"," box tells players how to interpret the ",[36,47,48],{},"mdat"," box. Without ",[36,51,44],{},", you have a pile of compressed bytes that no player can decode.",[11,54,55,56,58,59,61,62,65,66,65,69,65,72,75],{},"A regular MP4 has all of ",[36,57,44],{}," upfront (or at the end, requiring a second seek), and the ",[36,60,48],{}," is one contiguous chunk. To add a frame, you'd have to rewrite ",[36,63,64],{},"stts",", ",[36,67,68],{},"stsc",[36,70,71],{},"stsz",[36,73,74],{},"stco"," and shift bytes around. This is why \"edit an MP4\" was historically painful.",[21,77,79],{"id":78},"fragmented-mp4-the-streaming-friendly-variant","Fragmented MP4: the streaming-friendly variant",[11,81,82],{},"Fragmented MP4 (fMP4) splits the file into self-contained \"fragments\":",[29,84,87],{"className":85,"code":86,"language":34},[32],"ftyp\nmoov   (still has codec config — stsd, but stts\u002Fstsc\u002Fstsz are empty)\nmoof   (movie fragment — sample table for THIS fragment only)\n  tfhd  (track header for this fragment)\n  trun  (track run — sample sizes, PTS, durations for this fragment)\nmdat   (media data — samples for THIS fragment)\nmoof   (next fragment)\n  ...\nmdat\n...\n",[36,88,86],{"__ignoreMap":38},[11,90,91,92,95,96,98,99,102,103,105],{},"Each ",[36,93,94],{},"moof","+",[36,97,48],{}," pair is a complete, self-describing unit. To concatenate two fMP4 fragments, you just append the bytes — no rewriting, no offset math. The ",[36,100,101],{},"trun"," box inside each ",[36,104,94],{}," carries the sample table for that fragment's samples only.",[11,107,108,109,95,111,113,114,95,117,119],{},"This is why HLS and DASH both moved to fMP4. Each segment is one ",[36,110,94],{},[36,112,48],{}," pair (plus an init segment with ",[36,115,116],{},"ftyp",[36,118,44],{},"). Concatenating segments produces a valid fMP4 file.",[121,122,124],"h3",{"id":123},"what-the-init-segment-is","What the init segment is",[29,126,129],{"className":127,"code":128,"language":34},[32],"ftyp\nmoov\n  mvhd\n  trak (video)\n    tkhd\n    mdia\n      mdhd\n      hdlr (vide)\n      minf\n        stbl\n          stsd  ← avc1 config (SPS\u002FPPS for H.264)\n          ...   (empty sample tables — they'll be filled by moof)\n  trak (audio)\n    ...\n",[36,130,128],{"__ignoreMap":38},[11,132,133,134,136,137,139],{},"The init segment is the \"header\" — it declares codecs, track layout, and timescale, but contains no actual frames. Without it, the ",[36,135,94],{},"\u002F",[36,138,48],{}," segments are uninterpretable.",[11,141,142,143,146,147,150],{},"This is why DASH's ",[36,144,145],{},"\u003CSegmentTemplate initialization=\"init.mp4\">"," and HLS's ",[36,148,149],{},"#EXT-X-MAP:URI=\"init.mp4\""," both exist — they're pointing at the fMP4 init segment.",[21,152,154],{"id":153},"the-remux-algorithm","The remux algorithm",[11,156,157],{},"To merge fMP4 segments (DASH case — already fMP4):",[159,160,161,170,178],"ol",{},[162,163,164,165,95,167,169],"li",{},"Write the init segment bytes (your output's ",[36,166,116],{},[36,168,44],{},")",[162,171,172,173,95,175,177],{},"For each media segment, write its ",[36,174,94],{},[36,176,48],{}," bytes in order",[162,179,180],{},"Done",[11,182,183,184,187],{},"That's it. The output is a valid fMP4 file. No codec touched, no re-encoding, no quality loss. CPU usage is roughly \"memory bandwidth limited\" — you're literally just ",[36,185,186],{},"memcpy","-ing bytes.",[29,189,193],{"className":190,"code":191,"language":192,"meta":38,"style":38},"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 Compute total length\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",[36,194,195,230,280,287,306,331,373,379,384,391,447,468,482,502,524,540,545,591],{"__ignoreMap":38},[196,197,200,204,207,211,215,219,222,225,227],"span",{"class":198,"line":199},"line",1,[196,201,203],{"class":202},"spNyl","async",[196,205,206],{"class":202}," function",[196,208,210],{"class":209},"s2Zo4"," mergeFmp4",[196,212,214],{"class":213},"sMK4o","(",[196,216,218],{"class":217},"sHdIc","initUrl",[196,220,221],{"class":213},",",[196,223,224],{"class":217}," segmentUrls",[196,226,169],{"class":213},[196,228,229],{"class":213}," {\n",[196,231,233,236,240,243,246,249,252,256,259,261,264,266,268,271,274,277],{"class":198,"line":232},2,[196,234,235],{"class":202},"  const",[196,237,239],{"class":238},"sTEyZ"," init",[196,241,242],{"class":213}," =",[196,244,245],{"class":213}," new",[196,247,248],{"class":209}," Uint8Array",[196,250,214],{"class":251},"swJcz",[196,253,255],{"class":254},"s7zQu","await",[196,257,258],{"class":251}," (",[196,260,255],{"class":254},[196,262,263],{"class":209}," fetch",[196,265,214],{"class":251},[196,267,218],{"class":238},[196,269,270],{"class":251},"))",[196,272,273],{"class":213},".",[196,275,276],{"class":209},"arrayBuffer",[196,278,279],{"class":251},"())\n",[196,281,283],{"class":198,"line":282},3,[196,284,286],{"emptyLinePlaceholder":285},true,"\n",[196,288,290,292,295,297,300,303],{"class":198,"line":289},4,[196,291,235],{"class":202},[196,293,294],{"class":238}," chunks",[196,296,242],{"class":213},[196,298,299],{"class":251}," [",[196,301,302],{"class":238},"init",[196,304,305],{"class":251},"]\n",[196,307,309,312,314,317,320,323,325,328],{"class":198,"line":308},5,[196,310,311],{"class":254},"  for",[196,313,258],{"class":251},[196,315,316],{"class":202},"const",[196,318,319],{"class":238}," url",[196,321,322],{"class":213}," of",[196,324,224],{"class":238},[196,326,327],{"class":251},") ",[196,329,330],{"class":213},"{\n",[196,332,334,337,339,342,344,347,349,351,353,355,357,359,361,364,366,368,370],{"class":198,"line":333},6,[196,335,336],{"class":238},"    chunks",[196,338,273],{"class":213},[196,340,341],{"class":209},"push",[196,343,214],{"class":251},[196,345,346],{"class":213},"new",[196,348,248],{"class":209},[196,350,214],{"class":251},[196,352,255],{"class":254},[196,354,258],{"class":251},[196,356,255],{"class":254},[196,358,263],{"class":209},[196,360,214],{"class":251},[196,362,363],{"class":238},"url",[196,365,270],{"class":251},[196,367,273],{"class":213},[196,369,276],{"class":209},[196,371,372],{"class":251},"()))\n",[196,374,376],{"class":198,"line":375},7,[196,377,378],{"class":213},"  }\n",[196,380,382],{"class":198,"line":381},8,[196,383,286],{"emptyLinePlaceholder":285},[196,385,387],{"class":198,"line":386},9,[196,388,390],{"class":389},"sHwdD","  \u002F\u002F Compute total length\n",[196,392,394,396,399,401,403,405,408,410,412,415,417,420,422,425,428,431,433,435,438,440,444],{"class":198,"line":393},10,[196,395,235],{"class":202},[196,397,398],{"class":238}," total",[196,400,242],{"class":213},[196,402,294],{"class":238},[196,404,273],{"class":213},[196,406,407],{"class":209},"reduce",[196,409,214],{"class":251},[196,411,214],{"class":213},[196,413,414],{"class":217},"s",[196,416,221],{"class":213},[196,418,419],{"class":217}," c",[196,421,169],{"class":213},[196,423,424],{"class":202}," =>",[196,426,427],{"class":238}," s",[196,429,430],{"class":213}," +",[196,432,419],{"class":238},[196,434,273],{"class":213},[196,436,437],{"class":238},"byteLength",[196,439,221],{"class":213},[196,441,443],{"class":442},"sbssI"," 0",[196,445,446],{"class":251},")\n",[196,448,450,452,455,457,459,461,463,466],{"class":198,"line":449},11,[196,451,235],{"class":202},[196,453,454],{"class":238}," out",[196,456,242],{"class":213},[196,458,245],{"class":213},[196,460,248],{"class":209},[196,462,214],{"class":251},[196,464,465],{"class":238},"total",[196,467,446],{"class":251},[196,469,471,474,477,479],{"class":198,"line":470},12,[196,472,473],{"class":202},"  let",[196,475,476],{"class":238}," offset",[196,478,242],{"class":213},[196,480,481],{"class":442}," 0\n",[196,483,485,487,489,491,494,496,498,500],{"class":198,"line":484},13,[196,486,311],{"class":254},[196,488,258],{"class":251},[196,490,316],{"class":202},[196,492,493],{"class":238}," chunk",[196,495,322],{"class":213},[196,497,294],{"class":238},[196,499,327],{"class":251},[196,501,330],{"class":213},[196,503,505,508,510,513,515,518,520,522],{"class":198,"line":504},14,[196,506,507],{"class":238},"    out",[196,509,273],{"class":213},[196,511,512],{"class":209},"set",[196,514,214],{"class":251},[196,516,517],{"class":238},"chunk",[196,519,221],{"class":213},[196,521,476],{"class":238},[196,523,446],{"class":251},[196,525,527,530,533,535,537],{"class":198,"line":526},15,[196,528,529],{"class":238},"    offset",[196,531,532],{"class":213}," +=",[196,534,493],{"class":238},[196,536,273],{"class":213},[196,538,539],{"class":238},"byteLength\n",[196,541,543],{"class":198,"line":542},16,[196,544,378],{"class":213},[196,546,548,551,553,556,559,562,565,567,570,573,576,579,583,586,589],{"class":198,"line":547},17,[196,549,550],{"class":254},"  return",[196,552,245],{"class":213},[196,554,555],{"class":209}," Blob",[196,557,558],{"class":251},"([",[196,560,561],{"class":238},"out",[196,563,564],{"class":251},"]",[196,566,221],{"class":213},[196,568,569],{"class":213}," {",[196,571,572],{"class":251}," type",[196,574,575],{"class":213},":",[196,577,578],{"class":213}," '",[196,580,582],{"class":581},"sfazB","video\u002Fmp4",[196,584,585],{"class":213},"'",[196,587,588],{"class":213}," }",[196,590,446],{"class":251},[196,592,594],{"class":198,"line":593},18,[196,595,596],{"class":213},"}\n",[11,598,599,600,605],{},"This 12 lines of code is most of what FlowPick does for DASH streams. The complexity isn't the merge — it's parsing the MPD to get the URLs in the first place (covered in the ",[601,602,604],"a",{"href":603},"\u002Fblog\u002Fdash-mpd-deep-dive-segmenttemplate-contentprotection","DASH deep dive",") and handling the edge cases below.",[21,607,609],{"id":608},"the-hls-case-mpeg-ts-to-mp4","The HLS case: MPEG-TS to MP4",[11,611,612,613,616],{},"HLS traditionally uses MPEG-TS, not fMP4. MPEG-TS is a different container — 188-byte packets, multiplexes audio and video together, has its own timing (PCR\u002FPTS). You can't just concat ",[36,614,615],{},".ts"," files into an MP4; you have to demux the TS, extract PES packets, parse PTS, and write fMP4.",[11,618,619,620,623,624,627],{},"This is where FFmpeg WASM earns its keep. FFmpeg's ",[36,621,622],{},"mpegts"," demuxer + ",[36,625,626],{},"mp4"," muxer handles:",[159,629,630,633,636,639,642],{},[162,631,632],{},"Reading 188-byte TS packets, filtering by PID (video PID, audio PID)",[162,634,635],{},"Reassembling PES packets from TS payloads",[162,637,638],{},"Extracting PTS\u002FDTS from PES headers",[162,640,641],{},"Grouping access units (one video frame = one AAC frame for audio)",[162,643,644,645,136,647,649,650,652],{},"Writing ",[36,646,94],{},[36,648,48],{}," boxes with correct ",[36,651,101],{}," sample tables",[11,654,655],{},"The command-line equivalent:",[29,657,661],{"className":658,"code":659,"language":660,"meta":38,"style":38},"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",[36,662,663],{"__ignoreMap":38},[196,664,665,669,672,675,678,681,684,687],{"class":198,"line":199},[196,666,668],{"class":667},"sBMFI","ffmpeg",[196,670,671],{"class":581}," -i",[196,673,674],{"class":581}," input.ts",[196,676,677],{"class":581}," -c",[196,679,680],{"class":581}," copy",[196,682,683],{"class":581}," -f",[196,685,686],{"class":581}," mp4",[196,688,689],{"class":581}," output.mp4\n",[11,691,692,695],{},[36,693,694],{},"-c copy"," is the key — it tells FFmpeg to copy the codec bitstream verbatim, not decode and re-encode. CPU usage is minimal.",[11,697,698],{},"In the browser, FFmpeg WASM does the same thing:",[29,700,702],{"className":190,"code":701,"language":192,"meta":38,"style":38},"import { FFmpeg } from '@ffmpeg\u002Fffmpeg'\n\nconst ffmpeg = new FFmpeg()\nawait ffmpeg.load()\n\n\u002F\u002F Write segments to FFmpeg's virtual FS\nfor (let i = 0; i \u003C segments.length; i++) {\n  await ffmpeg.writeFile(`seg${i}.ts`, segments[i])\n}\n\n\u002F\u002F Concat list\nconst concatList = segments.map((_, i) => `file 'seg${i}.ts'`).join('\\n')\nawait ffmpeg.writeFile('list.txt', new TextEncoder().encode(concatList))\n\n\u002F\u002F Remux\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",[36,703,704,727,731,748,762,766,771,816,861,865,869,874,940,977,981,986,1000,1057,1077,1103,1108,1113],{"__ignoreMap":38},[196,705,706,709,711,714,716,719,721,724],{"class":198,"line":199},[196,707,708],{"class":254},"import",[196,710,569],{"class":213},[196,712,713],{"class":238}," FFmpeg",[196,715,588],{"class":213},[196,717,718],{"class":254}," from",[196,720,578],{"class":213},[196,722,723],{"class":581},"@ffmpeg\u002Fffmpeg",[196,725,726],{"class":213},"'\n",[196,728,729],{"class":198,"line":232},[196,730,286],{"emptyLinePlaceholder":285},[196,732,733,735,738,741,743,745],{"class":198,"line":282},[196,734,316],{"class":202},[196,736,737],{"class":238}," ffmpeg ",[196,739,740],{"class":213},"=",[196,742,245],{"class":213},[196,744,713],{"class":209},[196,746,747],{"class":238},"()\n",[196,749,750,752,755,757,760],{"class":198,"line":289},[196,751,255],{"class":254},[196,753,754],{"class":238}," ffmpeg",[196,756,273],{"class":213},[196,758,759],{"class":209},"load",[196,761,747],{"class":238},[196,763,764],{"class":198,"line":308},[196,765,286],{"emptyLinePlaceholder":285},[196,767,768],{"class":198,"line":333},[196,769,770],{"class":389},"\u002F\u002F Write segments to FFmpeg's virtual FS\n",[196,772,773,776,778,781,784,786,788,791,793,796,799,801,804,806,809,812,814],{"class":198,"line":375},[196,774,775],{"class":254},"for",[196,777,258],{"class":238},[196,779,780],{"class":202},"let",[196,782,783],{"class":238}," i ",[196,785,740],{"class":213},[196,787,443],{"class":442},[196,789,790],{"class":213},";",[196,792,783],{"class":238},[196,794,795],{"class":213},"\u003C",[196,797,798],{"class":238}," segments",[196,800,273],{"class":213},[196,802,803],{"class":238},"length",[196,805,790],{"class":213},[196,807,808],{"class":238}," i",[196,810,811],{"class":213},"++",[196,813,327],{"class":238},[196,815,330],{"class":213},[196,817,818,821,823,825,828,830,833,836,839,842,845,847,849,851,853,856,858],{"class":198,"line":381},[196,819,820],{"class":254},"  await",[196,822,754],{"class":238},[196,824,273],{"class":213},[196,826,827],{"class":209},"writeFile",[196,829,214],{"class":251},[196,831,832],{"class":213},"`",[196,834,835],{"class":581},"seg",[196,837,838],{"class":213},"${",[196,840,841],{"class":238},"i",[196,843,844],{"class":213},"}",[196,846,615],{"class":581},[196,848,832],{"class":213},[196,850,221],{"class":213},[196,852,798],{"class":238},[196,854,855],{"class":251},"[",[196,857,841],{"class":238},[196,859,860],{"class":251},"])\n",[196,862,863],{"class":198,"line":386},[196,864,596],{"class":213},[196,866,867],{"class":198,"line":393},[196,868,286],{"emptyLinePlaceholder":285},[196,870,871],{"class":198,"line":449},[196,872,873],{"class":389},"\u002F\u002F Concat list\n",[196,875,876,878,881,883,885,887,890,892,894,897,899,901,903,905,908,911,913,915,917,920,922,924,926,929,931,933,936,938],{"class":198,"line":470},[196,877,316],{"class":202},[196,879,880],{"class":238}," concatList ",[196,882,740],{"class":213},[196,884,798],{"class":238},[196,886,273],{"class":213},[196,888,889],{"class":209},"map",[196,891,214],{"class":238},[196,893,214],{"class":213},[196,895,896],{"class":217},"_",[196,898,221],{"class":213},[196,900,808],{"class":217},[196,902,169],{"class":213},[196,904,424],{"class":202},[196,906,907],{"class":213}," `",[196,909,910],{"class":581},"file 'seg",[196,912,838],{"class":213},[196,914,841],{"class":238},[196,916,844],{"class":213},[196,918,919],{"class":581},".ts'",[196,921,832],{"class":213},[196,923,169],{"class":238},[196,925,273],{"class":213},[196,927,928],{"class":209},"join",[196,930,214],{"class":238},[196,932,585],{"class":213},[196,934,935],{"class":238},"\\n",[196,937,585],{"class":213},[196,939,446],{"class":238},[196,941,942,944,946,948,950,952,954,957,959,961,963,966,969,971,974],{"class":198,"line":484},[196,943,255],{"class":254},[196,945,754],{"class":238},[196,947,273],{"class":213},[196,949,827],{"class":209},[196,951,214],{"class":238},[196,953,585],{"class":213},[196,955,956],{"class":581},"list.txt",[196,958,585],{"class":213},[196,960,221],{"class":213},[196,962,245],{"class":213},[196,964,965],{"class":209}," TextEncoder",[196,967,968],{"class":238},"()",[196,970,273],{"class":213},[196,972,973],{"class":209},"encode",[196,975,976],{"class":238},"(concatList))\n",[196,978,979],{"class":198,"line":504},[196,980,286],{"emptyLinePlaceholder":285},[196,982,983],{"class":198,"line":526},[196,984,985],{"class":389},"\u002F\u002F Remux\n",[196,987,988,990,992,994,997],{"class":198,"line":542},[196,989,255],{"class":254},[196,991,754],{"class":238},[196,993,273],{"class":213},[196,995,996],{"class":209},"exec",[196,998,999],{"class":238},"([\n",[196,1001,1002,1005,1008,1010,1012,1014,1017,1019,1021,1023,1026,1028,1030,1032,1035,1037,1039,1041,1044,1046,1048,1050,1052,1054],{"class":198,"line":547},[196,1003,1004],{"class":213},"  '",[196,1006,1007],{"class":581},"-f",[196,1009,585],{"class":213},[196,1011,221],{"class":213},[196,1013,578],{"class":213},[196,1015,1016],{"class":581},"concat",[196,1018,585],{"class":213},[196,1020,221],{"class":213},[196,1022,578],{"class":213},[196,1024,1025],{"class":581},"-safe",[196,1027,585],{"class":213},[196,1029,221],{"class":213},[196,1031,578],{"class":213},[196,1033,1034],{"class":581},"0",[196,1036,585],{"class":213},[196,1038,221],{"class":213},[196,1040,578],{"class":213},[196,1042,1043],{"class":581},"-i",[196,1045,585],{"class":213},[196,1047,221],{"class":213},[196,1049,578],{"class":213},[196,1051,956],{"class":581},[196,1053,585],{"class":213},[196,1055,1056],{"class":213},",\n",[196,1058,1059,1061,1064,1066,1068,1070,1073,1075],{"class":198,"line":593},[196,1060,1004],{"class":213},[196,1062,1063],{"class":581},"-c",[196,1065,585],{"class":213},[196,1067,221],{"class":213},[196,1069,578],{"class":213},[196,1071,1072],{"class":581},"copy",[196,1074,585],{"class":213},[196,1076,1056],{"class":213},[196,1078,1080,1082,1084,1086,1088,1090,1092,1094,1096,1098,1101],{"class":198,"line":1079},19,[196,1081,1004],{"class":213},[196,1083,1007],{"class":581},[196,1085,585],{"class":213},[196,1087,221],{"class":213},[196,1089,578],{"class":213},[196,1091,626],{"class":581},[196,1093,585],{"class":213},[196,1095,221],{"class":213},[196,1097,578],{"class":213},[196,1099,1100],{"class":581},"out.mp4",[196,1102,726],{"class":213},[196,1104,1106],{"class":198,"line":1105},20,[196,1107,860],{"class":238},[196,1109,1111],{"class":198,"line":1110},21,[196,1112,286],{"emptyLinePlaceholder":285},[196,1114,1116,1118,1121,1123,1126,1128,1130,1133,1135,1137,1139,1141],{"class":198,"line":1115},22,[196,1117,316],{"class":202},[196,1119,1120],{"class":238}," output ",[196,1122,740],{"class":213},[196,1124,1125],{"class":254}," await",[196,1127,754],{"class":238},[196,1129,273],{"class":213},[196,1131,1132],{"class":209},"readFile",[196,1134,214],{"class":238},[196,1136,585],{"class":213},[196,1138,1100],{"class":581},[196,1140,585],{"class":213},[196,1142,446],{"class":238},[11,1144,1145],{},"For a 30-minute 1080p video with 300 segments, this runs in ~15-30 seconds on a modern laptop. The same operation via transcoding would take 10-20 minutes.",[21,1147,1149],{"id":1148},"why-remux-instead-of-transcode","Why remux instead of transcode",[11,1151,1152,1156],{},[1153,1154,1155],"strong",{},"Speed."," Remuxing is memory-bandwidth-bound; transcoding is CPU-bound. A 4-core x86 transcodes 1080p H.264 at roughly 1-3x real-time (so a 30-min video takes 10-30 mins). Remux runs at 50-100x real-time.",[11,1158,1159,1162],{},[1153,1160,1161],{},"Quality."," Transcoding introduces generation loss — every re-encode degrades quality, even at \"high\" bitrates. Remux preserves the original bitstream byte-for-byte.",[11,1164,1165,1168],{},[1153,1166,1167],{},"Battery."," Transcoding burns CPU; remux doesn't. On a laptop, transcoding a 4K stream will drain your battery in 20 minutes. Remux barely registers.",[11,1170,1171,1174],{},[1153,1172,1173],{},"Predictability."," Transcoding time depends on the source's complexity (dark scenes encode faster than detailed ones). Remux time depends only on file size — linear and predictable.",[11,1176,1177],{},"The only reason to transcode is if you actually need a different codec — H.265 source to H.264 target for older devices, for instance. FlowPick doesn't do this because (a) every modern device supports H.264\u002FAAC, and (b) the latency cost isn't worth it.",[21,1179,1181],{"id":1180},"edge-cases-that-actually-break-naive-concatenation","Edge cases that actually break naive concatenation",[11,1183,1184,1187,1188,1191],{},[1153,1185,1186],{},"1. Discontinuities."," HLS ad breaks use ",[36,1189,1190],{},"#EXT-X-DISCONTINUITY"," to signal that PTS resets. Concatenating segments across a discontinuity produces an MP4 where the player jumps backward in time. FFmpeg handles this; naive byte concatenation doesn't.",[11,1193,1194,1197,1198,1201],{},[1153,1195,1196],{},"2. Codec changes between periods."," DASH multi-period MPDs (ad + main content) can use different H.264 profiles per period. Concatenating these fMP4 fragments produces a file where the init segment's ",[36,1199,1200],{},"stsd"," doesn't match later fragments. Players either fail or show corruption.",[11,1203,1204,1207],{},[1153,1205,1206],{},"3. Init segment mismatch."," If you fetch the wrong init segment (e.g., for a different Representation), the codec config doesn't match your segments. Output file is unplayable.",[11,1209,1210,1213,1214,1217],{},[1153,1211,1212],{},"4. Segment ordering."," With parallel fetching, segments arrive out of order. You must reorder before concatenation. The ",[601,1215,1216],{"href":603},"DASH parallel fetch pattern"," handles this with an indexed array.",[11,1219,1220,1223,1224,1226,1227,1229,1230,1233,1234,1236],{},[1153,1221,1222],{},"5. Moov atom at end of file."," \"Progressive download\" MP4s (rare in streaming, common in direct downloads) put ",[36,1225,44],{}," at the end. Without ",[36,1228,44],{},", you can't play the file until it's fully downloaded. FFmpeg's ",[36,1231,1232],{},"-movflags +faststart"," moves ",[36,1235,44],{}," to the front. Always use this flag when muxing for download.",[21,1238,1240,1241,1243],{"id":1239},"whats-actually-in-an-moof-box","What's actually in an ",[36,1242,94],{}," box",[11,1245,1246,1247,575],{},"If you want to go deep, here's the byte layout of a typical ",[36,1248,94],{},[29,1250,1253],{"className":1251,"code":1252,"language":34},[32],"moof (size: 0x00000400)\n  mfhd (size: 0x00000010) — movie fragment header\n    sequence_number: 0x00000001\n  traf (size: 0x000003e0) — track fragment\n    tfhd (size: 0x00000018) — track fragment header\n      track_id: 1\n      default_sample_duration: 0x000003e8 (1000 in timescale units)\n    tfdt (size: 0x00000014) — track fragment decode time\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",[36,1254,1252],{"__ignoreMap":38},[11,1256,41,1257,1260],{},[36,1258,1259],{},"flags"," field encodes whether each sample is:",[1262,1263,1264,1271,1278],"ul",{},[162,1265,1266,1267,1270],{},"A keyframe (",[36,1268,1269],{},"0x02000000"," = sync sample)",[162,1272,1273,1274,1277],{},"A non-keyframe (",[36,1275,1276],{},"0x00010000"," = non-sync)",[162,1279,1280,1281,169],{},"Discontinuity (",[36,1282,1283],{},"0x00000100",[11,1285,41,1286,1289,1290,1292],{},[36,1287,1288],{},"tfdt"," box gives the absolute decode time of the first sample in this fragment. Adding up durations in ",[36,1291,101],{}," gives you each sample's PTS within the fragment.",[11,1294,1295],{},"This is what FFmpeg parses when it remuxes. If you ever want to write your own remuxer (don't, just use FFmpeg), this is the structure you'd need to write.",[21,1297,1299],{"id":1298},"original-opinion-dont-write-your-own-muxer","Original opinion: don't write your own muxer",[11,1301,1302],{},"There's a cottage industry of \"lightweight\" browser muxers that try to skip FFmpeg and write ISOBMFF by hand. I've looked at the source of most of them. Without exception, they handle ~80% of cases and break on:",[1262,1304,1305,1308,1311,1314,1317],{},[162,1306,1307],{},"HEVC streams (different NAL unit packaging)",[162,1309,1310],{},"Audio with gapless playback metadata",[162,1312,1313],{},"Streams with B-frames (DTS != PTS, ordering matters)",[162,1315,1316],{},"Variable framerate content",[162,1318,1319],{},"HDR metadata (Content Light Level, Mastering Display Color Volume)",[11,1321,1322],{},"FFmpeg is ~30 years of accumulated edge-case handling. Reimplementing even 50% of it in JS would take years. The cost of pulling in FFmpeg WASM (~25MB gzipped) is dramatically less than the cost of getting muxing wrong on real content.",[11,1324,1325],{},"The exception: if you're building a minimal \"concat fMP4 segments\" tool with zero edge cases, the 12-line merge function above works. FlowPick has both paths — simple concat for DASH, full FFmpeg remux for HLS with discontinuities.",[21,1327,1329],{"id":1328},"references","References",[1262,1331,1332,1341,1349,1361,1369],{},[162,1333,1334,1340],{},[601,1335,1339],{"href":1336,"rel":1337},"https:\u002F\u002Fwww.iso.org\u002Fstandard\u002F83102.html",[1338],"nofollow","ISO\u002FIEC 14496-12 — ISOBMFF specification"," — The actual spec",[162,1342,1343,1348],{},[601,1344,1347],{"href":1345,"rel":1346},"https:\u002F\u002Fmp4ra.org\u002F",[1338],"MP4RA — MP4 Registration Authority"," — Box type registry",[162,1350,1351,1356,1357,1360],{},[601,1352,1355],{"href":1353,"rel":1354},"https:\u002F\u002Fffmpeg.org\u002Fffmpeg-formats.html#mov_002c-mp4_002c-ismv",[1338],"FFmpeg MP4 muxer docs"," — Includes ",[36,1358,1359],{},"+faststart"," and fragment options",[162,1362,1363,1368],{},[601,1364,1367],{"href":1365,"rel":1366},"https:\u002F\u002Fgithub.com\u002Faxiomatic-systems\u002FBento4",[1338],"Bento4 Source"," — Read this if you want to understand ISOBMFF deeply; the AP4_Atom.cpp file is a masterclass in box parsing",[162,1370,1371,1376],{},[601,1372,1375],{"href":1373,"rel":1374},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FVideoEncoder",[1338],"WebCodecs VideoEncoder docs"," — The browser-native alternative to FFmpeg WASM, covered in the next article in this series",[21,1378,1380],{"id":1379},"summary","Summary",[11,1382,1383],{},"The \"merge segments into MP4\" step looks like magic from the outside. It isn't — it's a structured remux that takes fMP4 fragments (which are designed to concatenate) and writes them sequentially with an init segment as the header. For HLS, you additionally demux MPEG-TS into fMP4 via FFmpeg WASM. Either way, you're copying codec bytes, not re-encoding them, which is why it's fast and lossless.",[11,1385,1386,1387,1391,1392,1395],{},"The hard parts aren't the remux — they're parsing the manifest, handling discontinuities, getting the init segment right, and dealing with DRM. Those are covered in the ",[601,1388,1390],{"href":1389},"\u002Fblog\u002Fhls-m3u8-deep-dive-encryption-multitrack","HLS"," and ",[601,1393,1394],{"href":603},"DASH"," deep dives.",[11,1397,1398,1399,1403],{},"The next article covers the lower-level browser APIs — ",[601,1400,1402],{"href":1401},"\u002Fblog\u002Fwebcodecs-web-workers-opfs-video-processing","WebCodecs, Web Workers, and OPFS"," — for when FFmpeg WASM isn't the right tool and you want to do real video processing in the browser.",[1405,1406],"hr",{},[21,1408,1410],{"id":1409},"related-articles","Related articles",[1262,1412,1413,1422,1433,1441],{},[162,1414,1415,1421],{},[1153,1416,1417],{},[601,1418,1420],{"href":1419},"\u002Fblog\u002Fhow-flowpick-merges-video-segments-in-browser","How FlowPick Merges Hundreds of Video Segments in Your Browser"," — FlowPick's specific implementation of the patterns in this article",[162,1423,1424,1429,1430,1432],{},[1153,1425,1426],{},[601,1427,1428],{"href":1389},"HLS Deep Dive: Encryption, Multi-Track, EXT-X Tags"," — Where the ",[36,1431,615],{}," segments come from",[162,1434,1435,1440],{},[1153,1436,1437],{},[601,1438,1439],{"href":603},"DASH Deep Dive: SegmentTemplate, ContentProtection"," — Where the fMP4 segments come from",[162,1442,1443,1448],{},[1153,1444,1445],{},[601,1446,1447],{"href":1401},"WebCodecs + Web Workers + OPFS: Practical Video Processing"," — The lower-level browser alternative to FFmpeg WASM",[21,1450,1452],{"id":1451},"recommended-reading","Recommended reading",[1262,1454,1455,1464,1473],{},[162,1456,1457,1463],{},[1153,1458,1459],{},[601,1460,1462],{"href":1461},"\u002Fblog\u002Fbrowser-video-processing-performance-benchmarks","Browser Video Processing Performance Benchmarks"," — Real numbers on FFmpeg WASM vs WebCodecs vs native",[162,1465,1466,1472],{},[1153,1467,1468],{},[601,1469,1471],{"href":1470},"\u002Fblog\u002Fwhat-is-dash-mpd-streaming","What Is DASH Streaming?"," — The DASH beginner guide",[162,1474,1475,1481],{},[1153,1476,1477],{},[601,1478,1480],{"href":1479},"\u002Fblog\u002Fgetting-started-with-hls-m3u8-download","How to Download M3U8\u002FHLS Streams"," — The HLS beginner guide",[1483,1484,1485],"style",{},"html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}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 .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}",{"title":38,"searchDepth":232,"depth":232,"links":1487},[1488,1489,1492,1493,1494,1495,1496,1498,1499,1500,1501,1502],{"id":23,"depth":232,"text":24},{"id":78,"depth":232,"text":79,"children":1490},[1491],{"id":123,"depth":282,"text":124},{"id":153,"depth":232,"text":154},{"id":608,"depth":232,"text":609},{"id":1148,"depth":232,"text":1149},{"id":1180,"depth":232,"text":1181},{"id":1239,"depth":232,"text":1497},"What's actually in an moof box",{"id":1298,"depth":232,"text":1299},{"id":1328,"depth":232,"text":1329},{"id":1379,"depth":232,"text":1380},{"id":1409,"depth":232,"text":1410},{"id":1451,"depth":232,"text":1452},"tips","2026-08-04","What actually happens when you 'merge' streaming segments into an MP4 in the browser — the ISOBMFF box structure, why fragmented MP4 concatenates but regular MP4 doesn't, and why remuxing is 50x faster than transcoding.","md","\u002Fscreenshots\u002Fformat-conversion.png",{},"\u002Fblog\u002Fbrowser-streaming-remux-mp4-isobmff",{"title":5,"description":1505},"blog\u002Fbrowser-streaming-remux-mp4-isobmff",[626,1513,1514,1515,1516,668,1517],"isobmff","fmp4","remux","wasm","deep-dive","Z9Q0SpCMkobrLRRGZmoRW-JLAZrX0k86tS7ScedipGo",[1520,1527],{"title":1521,"path":1522,"stem":1523,"description":1524,"date":1525,"category":1526,"children":-1},"How to Batch Download Images from Any Website","\u002Fblog\u002Fbatch-download-images-from-any-website","blog\u002Fbatch-download-images-from-any-website","Use FlowPick's built-in image downloader to grab dozens of images from a webpage in one go, with filtering by file size and format — no desktop software required.","2026-07-01","tutorials",{"title":1528,"path":1461,"stem":1529,"description":1530,"date":1504,"category":1503,"children":-1},"Browser Video Processing Performance Benchmarks: FFmpeg WASM vs WebCodecs vs Native","blog\u002Fbrowser-video-processing-performance-benchmarks","Real numbers from real hardware — 1080p\u002F4K\u002F8K remux and decode benchmarks across FFmpeg WASM, WebCodecs, and native FFmpeg. With methodology, raw data, and what the numbers actually mean.",1787670588208]