[{"data":1,"prerenderedAt":3572},["ShallowReactive",2],{"blog-zh-Hans-webcodecs-web-workers-opfs-video-processing":3,"blog-zh-Hans-webcodecs-web-workers-opfs-video-processing-surround":3560},{"id":4,"title":5,"author":6,"body":7,"category":3543,"date":3544,"description":3545,"extension":3546,"image":3547,"lastModified":3544,"meta":3548,"navigation":156,"path":3549,"readingTime":347,"seo":3550,"stem":3551,"tags":3552,"__hash__":3559},"blog_zh_Hans\u002Fzh-Hans\u002Fblog\u002Fwebcodecs-web-workers-opfs-video-processing.md","WebCodecs + Web Workers + OPFS：在浏览器里搞视频处理的实战路径","FlowPick 团队",{"type":8,"value":9,"toc":3527},"minimark",[10,14,17,22,56,62,76,79,82,92,103,107,468,471,475,689,699,703,1936,1939,1970,1974,1977,1980,2004,2010,2448,2451,2455,2458,2539,2542,2556,2559,2573,2579,2583,2589,2665,2668,2671,2686,2690,2693,3210,3213,3216,3219,3230,3270,3280,3300,3317,3331,3334,3339,3353,3358,3372,3377,3385,3390,3401,3404,3445,3448,3456,3463,3466,3469,3523],[11,12,13],"p",{},"浏览器里做视频处理，FFmpeg WASM 是默认选项，没毛病。但它有 25MB 的下载体积，默认单线程跑，还是个黑盒。某些工作流换成新一代浏览器原生 API——WebCodecs、Web Workers、OPFS——能做出更快、更小、更可调试的东西。",[11,15,16],{},"这篇文章就是一个能跑的实例：解码视频文件、按指定时间戳抽帧、写到本地存储，全程不碰 FFmpeg。文末有实测数据。",[18,19,21],"h2",{"id":20},"三个-api-各管一摊","三个 API 各管一摊",[11,23,24,28,29,33,34,33,37,33,40,43,44,47,48,51,52,55],{},[25,26,27],"strong",{},"WebCodecs","（",[30,31,32],"code",{},"VideoDecoder","、",[30,35,36],{},"VideoEncoder",[30,38,39],{},"AudioDecoder",[30,41,42],{},"AudioEncoder","）——硬件加速的编解码器接口。绕开了 ",[30,45,46],{},"\u003Cvideo>"," 那套\"播放再截屏\"的路子。你喂给它一个 ",[30,49,50],{},"EncodedVideoChunk","（比如一段裸的 H.264 NAL unit），它吐回 ",[30,53,54],{},"VideoFrame"," 对象，能往 canvas 渲染、能转码、能往下游塞。",[11,57,58,61],{},[25,59,60],{},"Web Workers","——独立线程处理 CPU 密集活。编解码器解析、帧变换、封装逻辑全应该塞这儿，主线程才不会卡。Worker 里也能直接用 OPFS。",[11,63,64,67,68,71,72,75],{},[25,65,66],{},"OPFS（Origin Private File System）","——一个真正高吞吐、给网页用的文件系统。",[30,69,70],{},"localStorage"," 是同步的还卡 5MB 上限，IndexedDB 异步但二进制 blob 慢得离谱，OPFS 就是为文件而生的。Worker 里能用 ",[30,73,74],{},"createSyncAccessHandle()"," 拿同步访问句柄，这点对性能是命门。",[18,77,78],{"id":78},"要搭的架构",[11,80,81],{},"我搭这么个东西：用户往页面拖一个视频文件，按每秒一帧抽出来，存成 JPEG 写到 OPFS，最后给个下载链接。真实场景就是给视频库生成缩略图。",[83,84,89],"pre",{"className":85,"code":87,"language":88},[86],"language-text","主线程：\n  - 文件输入\n  - 创建 Worker\n  - UI 更新（进度、结果）\n\nWorker：\n  - 通过 OPFS 路径拿文件（不走 postMessage——太慢）\n  - 用 WebCodecs VideoDecoder 解码\n  - Canvas 渲染 + toBlob 转 JPEG\n  - 把 JPEG 写进 OPFS\n  - 把进度回传给主线程\n","text",[30,90,87],{"__ignoreMap":91},"",[11,93,94,95,98,99,102],{},"\"通过 OPFS 路径拿文件\"这点很关键。用 ",[30,96,97],{},"postMessage"," 传一个 500MB 的文件（哪怕挂成 ",[30,100,101],{},"Transferable","）又慢又拷内存。先把文件写到 OPFS，再把路径丢给 Worker，快得多。",[18,104,106],{"id":105},"第一步能力检测","第一步：能力检测",[83,108,112],{"className":109,"code":110,"language":111,"meta":91,"style":91},"language-js shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","function checkSupport() {\n  const issues = []\n\n  if (!('VideoDecoder' in window)) {\n    issues.push('WebCodecs VideoDecoder 不支持。需要 Chrome 94+ 或同等浏览器。')\n  }\n\n  if (!window.showOpenFilePicker && !window.FileSystemHandle) {\n    issues.push('File System Access API 不支持。')\n  }\n\n  if (!navigator.storage.getDirectory) {\n    issues.push('OPFS 不支持。需要 Chrome 102+ 或同等浏览器。')\n  }\n\n  \u002F\u002F Worker 支持基本是标配，但要查 Worker 里能否用 OPFS 同步访问\n  \u002F\u002F （老版 Chrome 有 Worker，但 Worker 里没同步 OPFS）\n\n  return issues\n}\n\nconst issues = checkSupport()\nif (issues.length > 0) {\n  console.warn('功能缺口：', issues)\n  \u002F\u002F 回退到 FFmpeg WASM\n}\n","js",[30,113,114,134,151,158,194,218,224,229,264,284,289,294,320,340,345,350,357,363,368,377,383,388,405,430,457,463],{"__ignoreMap":91},[115,116,119,123,127,131],"span",{"class":117,"line":118},"line",1,[115,120,122],{"class":121},"spNyl","function",[115,124,126],{"class":125},"s2Zo4"," checkSupport",[115,128,130],{"class":129},"sMK4o","()",[115,132,133],{"class":129}," {\n",[115,135,137,140,144,147],{"class":117,"line":136},2,[115,138,139],{"class":121},"  const",[115,141,143],{"class":142},"sTEyZ"," issues",[115,145,146],{"class":129}," =",[115,148,150],{"class":149},"swJcz"," []\n",[115,152,154],{"class":117,"line":153},3,[115,155,157],{"emptyLinePlaceholder":156},true,"\n",[115,159,161,165,168,171,174,177,180,182,185,188,191],{"class":117,"line":160},4,[115,162,164],{"class":163},"s7zQu","  if",[115,166,167],{"class":149}," (",[115,169,170],{"class":129},"!",[115,172,173],{"class":149},"(",[115,175,176],{"class":129},"'",[115,178,32],{"class":179},"sfazB",[115,181,176],{"class":129},[115,183,184],{"class":129}," in",[115,186,187],{"class":142}," window",[115,189,190],{"class":149},")) ",[115,192,193],{"class":129},"{\n",[115,195,197,200,203,206,208,210,213,215],{"class":117,"line":196},5,[115,198,199],{"class":142},"    issues",[115,201,202],{"class":129},".",[115,204,205],{"class":125},"push",[115,207,173],{"class":149},[115,209,176],{"class":129},[115,211,212],{"class":179},"WebCodecs VideoDecoder 不支持。需要 Chrome 94+ 或同等浏览器。",[115,214,176],{"class":129},[115,216,217],{"class":149},")\n",[115,219,221],{"class":117,"line":220},6,[115,222,223],{"class":129},"  }\n",[115,225,227],{"class":117,"line":226},7,[115,228,157],{"emptyLinePlaceholder":156},[115,230,232,234,236,238,241,243,246,249,252,254,256,259,262],{"class":117,"line":231},8,[115,233,164],{"class":163},[115,235,167],{"class":149},[115,237,170],{"class":129},[115,239,240],{"class":142},"window",[115,242,202],{"class":129},[115,244,245],{"class":142},"showOpenFilePicker",[115,247,248],{"class":129}," &&",[115,250,251],{"class":129}," !",[115,253,240],{"class":142},[115,255,202],{"class":129},[115,257,258],{"class":142},"FileSystemHandle",[115,260,261],{"class":149},") ",[115,263,193],{"class":129},[115,265,267,269,271,273,275,277,280,282],{"class":117,"line":266},9,[115,268,199],{"class":142},[115,270,202],{"class":129},[115,272,205],{"class":125},[115,274,173],{"class":149},[115,276,176],{"class":129},[115,278,279],{"class":179},"File System Access API 不支持。",[115,281,176],{"class":129},[115,283,217],{"class":149},[115,285,287],{"class":117,"line":286},10,[115,288,223],{"class":129},[115,290,292],{"class":117,"line":291},11,[115,293,157],{"emptyLinePlaceholder":156},[115,295,297,299,301,303,306,308,311,313,316,318],{"class":117,"line":296},12,[115,298,164],{"class":163},[115,300,167],{"class":149},[115,302,170],{"class":129},[115,304,305],{"class":142},"navigator",[115,307,202],{"class":129},[115,309,310],{"class":142},"storage",[115,312,202],{"class":129},[115,314,315],{"class":142},"getDirectory",[115,317,261],{"class":149},[115,319,193],{"class":129},[115,321,323,325,327,329,331,333,336,338],{"class":117,"line":322},13,[115,324,199],{"class":142},[115,326,202],{"class":129},[115,328,205],{"class":125},[115,330,173],{"class":149},[115,332,176],{"class":129},[115,334,335],{"class":179},"OPFS 不支持。需要 Chrome 102+ 或同等浏览器。",[115,337,176],{"class":129},[115,339,217],{"class":149},[115,341,343],{"class":117,"line":342},14,[115,344,223],{"class":129},[115,346,348],{"class":117,"line":347},15,[115,349,157],{"emptyLinePlaceholder":156},[115,351,353],{"class":117,"line":352},16,[115,354,356],{"class":355},"sHwdD","  \u002F\u002F Worker 支持基本是标配，但要查 Worker 里能否用 OPFS 同步访问\n",[115,358,360],{"class":117,"line":359},17,[115,361,362],{"class":355},"  \u002F\u002F （老版 Chrome 有 Worker，但 Worker 里没同步 OPFS）\n",[115,364,366],{"class":117,"line":365},18,[115,367,157],{"emptyLinePlaceholder":156},[115,369,371,374],{"class":117,"line":370},19,[115,372,373],{"class":163},"  return",[115,375,376],{"class":142}," issues\n",[115,378,380],{"class":117,"line":379},20,[115,381,382],{"class":129},"}\n",[115,384,386],{"class":117,"line":385},21,[115,387,157],{"emptyLinePlaceholder":156},[115,389,391,394,397,400,402],{"class":117,"line":390},22,[115,392,393],{"class":121},"const",[115,395,396],{"class":142}," issues ",[115,398,399],{"class":129},"=",[115,401,126],{"class":125},[115,403,404],{"class":142},"()\n",[115,406,408,411,414,416,419,422,426,428],{"class":117,"line":407},23,[115,409,410],{"class":163},"if",[115,412,413],{"class":142}," (issues",[115,415,202],{"class":129},[115,417,418],{"class":142},"length ",[115,420,421],{"class":129},">",[115,423,425],{"class":424},"sbssI"," 0",[115,427,261],{"class":142},[115,429,193],{"class":129},[115,431,433,436,438,441,443,445,448,450,453,455],{"class":117,"line":432},24,[115,434,435],{"class":142},"  console",[115,437,202],{"class":129},[115,439,440],{"class":125},"warn",[115,442,173],{"class":149},[115,444,176],{"class":129},[115,446,447],{"class":179},"功能缺口：",[115,449,176],{"class":129},[115,451,452],{"class":129},",",[115,454,143],{"class":142},[115,456,217],{"class":149},[115,458,460],{"class":117,"line":459},25,[115,461,462],{"class":355},"  \u002F\u002F 回退到 FFmpeg WASM\n",[115,464,466],{"class":117,"line":465},26,[115,467,382],{"class":129},[11,469,470],{},"2026 年的浏览器支持情况：Chrome\u002FEdge 102+、Safari 16.4+（WebCodecs 部分支持）、Firefox 130+（某些版本还在 flag 后面）。生产环境一律做能力检测，撑不住就回退 FFmpeg WASM。",[18,472,474],{"id":473},"第二步把文件写进-opfs","第二步：把文件写进 OPFS",[83,476,478],{"className":109,"code":477,"language":111,"meta":91,"style":91},"async function stageFileToOpfs(file) {\n  const root = await navigator.storage.getDirectory()\n  const dir = await root.getDirectoryHandle('input', { create: true })\n  const fileHandle = await dir.getFileHandle(file.name, { create: true })\n  const writable = await fileHandle.createWritable()\n  await file.stream().pipeTo(writable)\n\n  return `${file.name}`  \u002F\u002F OPFS 根目录下 'input' 文件夹里的路径\n}\n",[30,479,480,502,527,574,615,635,662,666,685],{"__ignoreMap":91},[115,481,482,485,488,491,493,497,500],{"class":117,"line":118},[115,483,484],{"class":121},"async",[115,486,487],{"class":121}," function",[115,489,490],{"class":125}," stageFileToOpfs",[115,492,173],{"class":129},[115,494,496],{"class":495},"sHdIc","file",[115,498,499],{"class":129},")",[115,501,133],{"class":129},[115,503,504,506,509,511,514,517,519,521,523,525],{"class":117,"line":136},[115,505,139],{"class":121},[115,507,508],{"class":142}," root",[115,510,146],{"class":129},[115,512,513],{"class":163}," await",[115,515,516],{"class":142}," navigator",[115,518,202],{"class":129},[115,520,310],{"class":142},[115,522,202],{"class":129},[115,524,315],{"class":125},[115,526,404],{"class":149},[115,528,529,531,534,536,538,540,542,545,547,549,552,554,556,559,562,565,569,572],{"class":117,"line":153},[115,530,139],{"class":121},[115,532,533],{"class":142}," dir",[115,535,146],{"class":129},[115,537,513],{"class":163},[115,539,508],{"class":142},[115,541,202],{"class":129},[115,543,544],{"class":125},"getDirectoryHandle",[115,546,173],{"class":149},[115,548,176],{"class":129},[115,550,551],{"class":179},"input",[115,553,176],{"class":129},[115,555,452],{"class":129},[115,557,558],{"class":129}," {",[115,560,561],{"class":149}," create",[115,563,564],{"class":129},":",[115,566,568],{"class":567},"sfNiH"," true",[115,570,571],{"class":129}," }",[115,573,217],{"class":149},[115,575,576,578,581,583,585,587,589,592,594,596,598,601,603,605,607,609,611,613],{"class":117,"line":160},[115,577,139],{"class":121},[115,579,580],{"class":142}," fileHandle",[115,582,146],{"class":129},[115,584,513],{"class":163},[115,586,533],{"class":142},[115,588,202],{"class":129},[115,590,591],{"class":125},"getFileHandle",[115,593,173],{"class":149},[115,595,496],{"class":142},[115,597,202],{"class":129},[115,599,600],{"class":142},"name",[115,602,452],{"class":129},[115,604,558],{"class":129},[115,606,561],{"class":149},[115,608,564],{"class":129},[115,610,568],{"class":567},[115,612,571],{"class":129},[115,614,217],{"class":149},[115,616,617,619,622,624,626,628,630,633],{"class":117,"line":196},[115,618,139],{"class":121},[115,620,621],{"class":142}," writable",[115,623,146],{"class":129},[115,625,513],{"class":163},[115,627,580],{"class":142},[115,629,202],{"class":129},[115,631,632],{"class":125},"createWritable",[115,634,404],{"class":149},[115,636,637,640,643,645,648,650,652,655,657,660],{"class":117,"line":220},[115,638,639],{"class":163},"  await",[115,641,642],{"class":142}," file",[115,644,202],{"class":129},[115,646,647],{"class":125},"stream",[115,649,130],{"class":149},[115,651,202],{"class":129},[115,653,654],{"class":125},"pipeTo",[115,656,173],{"class":149},[115,658,659],{"class":142},"writable",[115,661,217],{"class":149},[115,663,664],{"class":117,"line":226},[115,665,157],{"emptyLinePlaceholder":156},[115,667,668,670,673,675,677,679,682],{"class":117,"line":231},[115,669,373],{"class":163},[115,671,672],{"class":129}," `${",[115,674,496],{"class":142},[115,676,202],{"class":129},[115,678,600],{"class":142},[115,680,681],{"class":129},"}`",[115,683,684],{"class":355},"  \u002F\u002F OPFS 根目录下 'input' 文件夹里的路径\n",[115,686,687],{"class":117,"line":266},[115,688,382],{"class":129},[11,690,691,692,695,696,698],{},"这里用的是异步 ",[30,693,694],{},"createWritable()","。Worker 里可以改用同步的 ",[30,697,74],{},"，小文件多次写入更快。",[18,700,702],{"id":701},"第三步worker","第三步：Worker",[83,704,706],{"className":109,"code":705,"language":111,"meta":91,"style":91},"\u002F\u002F worker.js\nlet decoder = null\nlet canvas = null\nlet ctx = null\n\nself.onmessage = async (e) => {\n  const { type, data } = e.data\n\n  if (type === 'init') {\n    await init(data)\n  } else if (type === 'extract') {\n    await extractFrames(data)\n  }\n}\n\nasync function init({ codec, canvasWidth, canvasHeight }) {\n  \u002F\u002F 准备 canvas（Worker 里用 OffscreenCanvas）\n  canvas = new OffscreenCanvas(canvasWidth, canvasHeight)\n  ctx = canvas.getContext('2d')\n\n  \u002F\u002F WebCodecs 解码器\n  decoder = new VideoDecoder({\n    output: (frame) => handleFrame(frame),\n    error: (e) => console.error('解码器错误：', e)\n  })\n\n  decoder.configure({\n    codec,  \u002F\u002F 比如 'avc1.640028'\n    optimizeForLatency: false\n  })\n\n  self.postMessage({ type: 'ready' })\n}\n\nlet frameCount = 0\nlet lastExtractedSecond = -1\n\nasync function handleFrame(frame) {\n  const timestampSeconds = frame.timestamp \u002F 1_000_000  \u002F\u002F WebCodecs 用微秒\n\n  \u002F\u002F 每秒只抽一帧\n  const currentSecond = Math.floor(timestampSeconds)\n  if (currentSecond !== lastExtractedSecond) {\n    lastExtractedSecond = currentSecond\n\n    ctx.drawImage(frame, 0, 0, canvas.width, canvas.height)\n    const blob = await canvas.convertToBlob({ type: 'image\u002Fjpeg', quality: 0.85 })\n    const buffer = await blob.arrayBuffer()\n\n    \u002F\u002F 写进 OPFS\n    const root = await navigator.storage.getDirectory()\n    const outputDir = await root.getDirectoryHandle('thumbnails', { create: true })\n    const fileHandle = await outputDir.getFileHandle(`thumb_${String(currentSecond).padStart(6, '0')}.jpg`, { create: true })\n    const syncHandle = await fileHandle.createSyncAccessHandle()\n    syncHandle.write(new Uint8Array(buffer))\n    syncHandle.close()\n\n    frameCount++\n    self.postMessage({ type: 'progress', frameCount, second: currentSecond })\n  }\n\n  frame.close()  \u002F\u002F 重要：VideoFrame 必须关，不然泄漏显存\n}\n\nasync function extractFrames({ chunks }) {\n  for (const chunk of chunks) {\n    decoder.decode(new EncodedVideoChunk({\n      type: chunk.keyframe ? 'key' : 'delta',\n      timestamp: chunk.timestamp,\n      data: chunk.data\n    }))\n  }\n  await decoder.flush()\n  self.postMessage({ type: 'done', frameCount })\n}\n",[30,707,708,713,726,737,748,752,779,805,809,833,848,876,889,893,897,901,930,935,959,985,989,994,1010,1038,1076,1082,1086,1100,1111,1122,1129,1134,1164,1169,1174,1187,1203,1208,1225,1252,1257,1263,1288,1308,1319,1324,1367,1416,1437,1442,1448,1471,1512,1589,1610,1637,1649,1654,1663,1706,1711,1716,1732,1737,1742,1760,1782,1804,1841,1857,1871,1879,1884,1899,1931],{"__ignoreMap":91},[115,709,710],{"class":117,"line":118},[115,711,712],{"class":355},"\u002F\u002F worker.js\n",[115,714,715,718,721,723],{"class":117,"line":136},[115,716,717],{"class":121},"let",[115,719,720],{"class":142}," decoder ",[115,722,399],{"class":129},[115,724,725],{"class":129}," null\n",[115,727,728,730,733,735],{"class":117,"line":153},[115,729,717],{"class":121},[115,731,732],{"class":142}," canvas ",[115,734,399],{"class":129},[115,736,725],{"class":129},[115,738,739,741,744,746],{"class":117,"line":160},[115,740,717],{"class":121},[115,742,743],{"class":142}," ctx ",[115,745,399],{"class":129},[115,747,725],{"class":129},[115,749,750],{"class":117,"line":196},[115,751,157],{"emptyLinePlaceholder":156},[115,753,754,757,759,762,764,767,769,772,774,777],{"class":117,"line":220},[115,755,756],{"class":142},"self",[115,758,202],{"class":129},[115,760,761],{"class":125},"onmessage",[115,763,146],{"class":129},[115,765,766],{"class":121}," async",[115,768,167],{"class":129},[115,770,771],{"class":495},"e",[115,773,499],{"class":129},[115,775,776],{"class":121}," =>",[115,778,133],{"class":129},[115,780,781,783,785,788,790,793,795,797,800,802],{"class":117,"line":226},[115,782,139],{"class":121},[115,784,558],{"class":129},[115,786,787],{"class":142}," type",[115,789,452],{"class":129},[115,791,792],{"class":142}," data",[115,794,571],{"class":129},[115,796,146],{"class":129},[115,798,799],{"class":142}," e",[115,801,202],{"class":129},[115,803,804],{"class":142},"data\n",[115,806,807],{"class":117,"line":231},[115,808,157],{"emptyLinePlaceholder":156},[115,810,811,813,815,818,821,824,827,829,831],{"class":117,"line":266},[115,812,164],{"class":163},[115,814,167],{"class":149},[115,816,817],{"class":142},"type",[115,819,820],{"class":129}," ===",[115,822,823],{"class":129}," '",[115,825,826],{"class":179},"init",[115,828,176],{"class":129},[115,830,261],{"class":149},[115,832,193],{"class":129},[115,834,835,838,841,843,846],{"class":117,"line":286},[115,836,837],{"class":163},"    await",[115,839,840],{"class":125}," init",[115,842,173],{"class":149},[115,844,845],{"class":142},"data",[115,847,217],{"class":149},[115,849,850,853,856,859,861,863,865,867,870,872,874],{"class":117,"line":291},[115,851,852],{"class":129},"  }",[115,854,855],{"class":163}," else",[115,857,858],{"class":163}," if",[115,860,167],{"class":149},[115,862,817],{"class":142},[115,864,820],{"class":129},[115,866,823],{"class":129},[115,868,869],{"class":179},"extract",[115,871,176],{"class":129},[115,873,261],{"class":149},[115,875,193],{"class":129},[115,877,878,880,883,885,887],{"class":117,"line":296},[115,879,837],{"class":163},[115,881,882],{"class":125}," extractFrames",[115,884,173],{"class":149},[115,886,845],{"class":142},[115,888,217],{"class":149},[115,890,891],{"class":117,"line":322},[115,892,223],{"class":129},[115,894,895],{"class":117,"line":342},[115,896,382],{"class":129},[115,898,899],{"class":117,"line":347},[115,900,157],{"emptyLinePlaceholder":156},[115,902,903,905,907,909,912,915,917,920,922,925,928],{"class":117,"line":352},[115,904,484],{"class":121},[115,906,487],{"class":121},[115,908,840],{"class":125},[115,910,911],{"class":129},"({",[115,913,914],{"class":495}," codec",[115,916,452],{"class":129},[115,918,919],{"class":495}," canvasWidth",[115,921,452],{"class":129},[115,923,924],{"class":495}," canvasHeight",[115,926,927],{"class":129}," })",[115,929,133],{"class":129},[115,931,932],{"class":117,"line":359},[115,933,934],{"class":355},"  \u002F\u002F 准备 canvas（Worker 里用 OffscreenCanvas）\n",[115,936,937,940,942,945,948,950,953,955,957],{"class":117,"line":365},[115,938,939],{"class":142},"  canvas",[115,941,146],{"class":129},[115,943,944],{"class":129}," new",[115,946,947],{"class":125}," OffscreenCanvas",[115,949,173],{"class":149},[115,951,952],{"class":142},"canvasWidth",[115,954,452],{"class":129},[115,956,924],{"class":142},[115,958,217],{"class":149},[115,960,961,964,966,969,971,974,976,978,981,983],{"class":117,"line":370},[115,962,963],{"class":142},"  ctx",[115,965,146],{"class":129},[115,967,968],{"class":142}," canvas",[115,970,202],{"class":129},[115,972,973],{"class":125},"getContext",[115,975,173],{"class":149},[115,977,176],{"class":129},[115,979,980],{"class":179},"2d",[115,982,176],{"class":129},[115,984,217],{"class":149},[115,986,987],{"class":117,"line":379},[115,988,157],{"emptyLinePlaceholder":156},[115,990,991],{"class":117,"line":385},[115,992,993],{"class":355},"  \u002F\u002F WebCodecs 解码器\n",[115,995,996,999,1001,1003,1006,1008],{"class":117,"line":390},[115,997,998],{"class":142},"  decoder",[115,1000,146],{"class":129},[115,1002,944],{"class":129},[115,1004,1005],{"class":125}," VideoDecoder",[115,1007,173],{"class":149},[115,1009,193],{"class":129},[115,1011,1012,1015,1017,1019,1022,1024,1026,1029,1031,1033,1035],{"class":117,"line":407},[115,1013,1014],{"class":125},"    output",[115,1016,564],{"class":129},[115,1018,167],{"class":129},[115,1020,1021],{"class":495},"frame",[115,1023,499],{"class":129},[115,1025,776],{"class":121},[115,1027,1028],{"class":125}," handleFrame",[115,1030,173],{"class":149},[115,1032,1021],{"class":142},[115,1034,499],{"class":149},[115,1036,1037],{"class":129},",\n",[115,1039,1040,1043,1045,1047,1049,1051,1053,1056,1058,1061,1063,1065,1068,1070,1072,1074],{"class":117,"line":432},[115,1041,1042],{"class":125},"    error",[115,1044,564],{"class":129},[115,1046,167],{"class":129},[115,1048,771],{"class":495},[115,1050,499],{"class":129},[115,1052,776],{"class":121},[115,1054,1055],{"class":142}," console",[115,1057,202],{"class":129},[115,1059,1060],{"class":125},"error",[115,1062,173],{"class":149},[115,1064,176],{"class":129},[115,1066,1067],{"class":179},"解码器错误：",[115,1069,176],{"class":129},[115,1071,452],{"class":129},[115,1073,799],{"class":142},[115,1075,217],{"class":149},[115,1077,1078,1080],{"class":117,"line":459},[115,1079,852],{"class":129},[115,1081,217],{"class":149},[115,1083,1084],{"class":117,"line":465},[115,1085,157],{"emptyLinePlaceholder":156},[115,1087,1089,1091,1093,1096,1098],{"class":117,"line":1088},27,[115,1090,998],{"class":142},[115,1092,202],{"class":129},[115,1094,1095],{"class":125},"configure",[115,1097,173],{"class":149},[115,1099,193],{"class":129},[115,1101,1103,1106,1108],{"class":117,"line":1102},28,[115,1104,1105],{"class":142},"    codec",[115,1107,452],{"class":129},[115,1109,1110],{"class":355},"  \u002F\u002F 比如 'avc1.640028'\n",[115,1112,1114,1117,1119],{"class":117,"line":1113},29,[115,1115,1116],{"class":149},"    optimizeForLatency",[115,1118,564],{"class":129},[115,1120,1121],{"class":567}," false\n",[115,1123,1125,1127],{"class":117,"line":1124},30,[115,1126,852],{"class":129},[115,1128,217],{"class":149},[115,1130,1132],{"class":117,"line":1131},31,[115,1133,157],{"emptyLinePlaceholder":156},[115,1135,1137,1140,1142,1144,1146,1149,1151,1153,1155,1158,1160,1162],{"class":117,"line":1136},32,[115,1138,1139],{"class":142},"  self",[115,1141,202],{"class":129},[115,1143,97],{"class":125},[115,1145,173],{"class":149},[115,1147,1148],{"class":129},"{",[115,1150,787],{"class":149},[115,1152,564],{"class":129},[115,1154,823],{"class":129},[115,1156,1157],{"class":179},"ready",[115,1159,176],{"class":129},[115,1161,571],{"class":129},[115,1163,217],{"class":149},[115,1165,1167],{"class":117,"line":1166},33,[115,1168,382],{"class":129},[115,1170,1172],{"class":117,"line":1171},34,[115,1173,157],{"emptyLinePlaceholder":156},[115,1175,1177,1179,1182,1184],{"class":117,"line":1176},35,[115,1178,717],{"class":121},[115,1180,1181],{"class":142}," frameCount ",[115,1183,399],{"class":129},[115,1185,1186],{"class":424}," 0\n",[115,1188,1190,1192,1195,1197,1200],{"class":117,"line":1189},36,[115,1191,717],{"class":121},[115,1193,1194],{"class":142}," lastExtractedSecond ",[115,1196,399],{"class":129},[115,1198,1199],{"class":129}," -",[115,1201,1202],{"class":424},"1\n",[115,1204,1206],{"class":117,"line":1205},37,[115,1207,157],{"emptyLinePlaceholder":156},[115,1209,1211,1213,1215,1217,1219,1221,1223],{"class":117,"line":1210},38,[115,1212,484],{"class":121},[115,1214,487],{"class":121},[115,1216,1028],{"class":125},[115,1218,173],{"class":129},[115,1220,1021],{"class":495},[115,1222,499],{"class":129},[115,1224,133],{"class":129},[115,1226,1228,1230,1233,1235,1238,1240,1243,1246,1249],{"class":117,"line":1227},39,[115,1229,139],{"class":121},[115,1231,1232],{"class":142}," timestampSeconds",[115,1234,146],{"class":129},[115,1236,1237],{"class":142}," frame",[115,1239,202],{"class":129},[115,1241,1242],{"class":142},"timestamp",[115,1244,1245],{"class":129}," \u002F",[115,1247,1248],{"class":424}," 1_000_000",[115,1250,1251],{"class":355},"  \u002F\u002F WebCodecs 用微秒\n",[115,1253,1255],{"class":117,"line":1254},40,[115,1256,157],{"emptyLinePlaceholder":156},[115,1258,1260],{"class":117,"line":1259},41,[115,1261,1262],{"class":355},"  \u002F\u002F 每秒只抽一帧\n",[115,1264,1266,1268,1271,1273,1276,1278,1281,1283,1286],{"class":117,"line":1265},42,[115,1267,139],{"class":121},[115,1269,1270],{"class":142}," currentSecond",[115,1272,146],{"class":129},[115,1274,1275],{"class":142}," Math",[115,1277,202],{"class":129},[115,1279,1280],{"class":125},"floor",[115,1282,173],{"class":149},[115,1284,1285],{"class":142},"timestampSeconds",[115,1287,217],{"class":149},[115,1289,1291,1293,1295,1298,1301,1304,1306],{"class":117,"line":1290},43,[115,1292,164],{"class":163},[115,1294,167],{"class":149},[115,1296,1297],{"class":142},"currentSecond",[115,1299,1300],{"class":129}," !==",[115,1302,1303],{"class":142}," lastExtractedSecond",[115,1305,261],{"class":149},[115,1307,193],{"class":129},[115,1309,1311,1314,1316],{"class":117,"line":1310},44,[115,1312,1313],{"class":142},"    lastExtractedSecond",[115,1315,146],{"class":129},[115,1317,1318],{"class":142}," currentSecond\n",[115,1320,1322],{"class":117,"line":1321},45,[115,1323,157],{"emptyLinePlaceholder":156},[115,1325,1327,1330,1332,1335,1337,1339,1341,1343,1345,1347,1349,1351,1353,1356,1358,1360,1362,1365],{"class":117,"line":1326},46,[115,1328,1329],{"class":142},"    ctx",[115,1331,202],{"class":129},[115,1333,1334],{"class":125},"drawImage",[115,1336,173],{"class":149},[115,1338,1021],{"class":142},[115,1340,452],{"class":129},[115,1342,425],{"class":424},[115,1344,452],{"class":129},[115,1346,425],{"class":424},[115,1348,452],{"class":129},[115,1350,968],{"class":142},[115,1352,202],{"class":129},[115,1354,1355],{"class":142},"width",[115,1357,452],{"class":129},[115,1359,968],{"class":142},[115,1361,202],{"class":129},[115,1363,1364],{"class":142},"height",[115,1366,217],{"class":149},[115,1368,1370,1373,1376,1378,1380,1382,1384,1387,1389,1391,1393,1395,1397,1400,1402,1404,1407,1409,1412,1414],{"class":117,"line":1369},47,[115,1371,1372],{"class":121},"    const",[115,1374,1375],{"class":142}," blob",[115,1377,146],{"class":129},[115,1379,513],{"class":163},[115,1381,968],{"class":142},[115,1383,202],{"class":129},[115,1385,1386],{"class":125},"convertToBlob",[115,1388,173],{"class":149},[115,1390,1148],{"class":129},[115,1392,787],{"class":149},[115,1394,564],{"class":129},[115,1396,823],{"class":129},[115,1398,1399],{"class":179},"image\u002Fjpeg",[115,1401,176],{"class":129},[115,1403,452],{"class":129},[115,1405,1406],{"class":149}," quality",[115,1408,564],{"class":129},[115,1410,1411],{"class":424}," 0.85",[115,1413,571],{"class":129},[115,1415,217],{"class":149},[115,1417,1419,1421,1424,1426,1428,1430,1432,1435],{"class":117,"line":1418},48,[115,1420,1372],{"class":121},[115,1422,1423],{"class":142}," buffer",[115,1425,146],{"class":129},[115,1427,513],{"class":163},[115,1429,1375],{"class":142},[115,1431,202],{"class":129},[115,1433,1434],{"class":125},"arrayBuffer",[115,1436,404],{"class":149},[115,1438,1440],{"class":117,"line":1439},49,[115,1441,157],{"emptyLinePlaceholder":156},[115,1443,1445],{"class":117,"line":1444},50,[115,1446,1447],{"class":355},"    \u002F\u002F 写进 OPFS\n",[115,1449,1451,1453,1455,1457,1459,1461,1463,1465,1467,1469],{"class":117,"line":1450},51,[115,1452,1372],{"class":121},[115,1454,508],{"class":142},[115,1456,146],{"class":129},[115,1458,513],{"class":163},[115,1460,516],{"class":142},[115,1462,202],{"class":129},[115,1464,310],{"class":142},[115,1466,202],{"class":129},[115,1468,315],{"class":125},[115,1470,404],{"class":149},[115,1472,1474,1476,1479,1481,1483,1485,1487,1489,1491,1493,1496,1498,1500,1502,1504,1506,1508,1510],{"class":117,"line":1473},52,[115,1475,1372],{"class":121},[115,1477,1478],{"class":142}," outputDir",[115,1480,146],{"class":129},[115,1482,513],{"class":163},[115,1484,508],{"class":142},[115,1486,202],{"class":129},[115,1488,544],{"class":125},[115,1490,173],{"class":149},[115,1492,176],{"class":129},[115,1494,1495],{"class":179},"thumbnails",[115,1497,176],{"class":129},[115,1499,452],{"class":129},[115,1501,558],{"class":129},[115,1503,561],{"class":149},[115,1505,564],{"class":129},[115,1507,568],{"class":567},[115,1509,571],{"class":129},[115,1511,217],{"class":149},[115,1513,1515,1517,1519,1521,1523,1525,1527,1529,1531,1534,1537,1540,1543,1546,1548,1551,1553,1556,1558,1560,1563,1565,1567,1570,1573,1575,1577,1579,1581,1583,1585,1587],{"class":117,"line":1514},53,[115,1516,1372],{"class":121},[115,1518,580],{"class":142},[115,1520,146],{"class":129},[115,1522,513],{"class":163},[115,1524,1478],{"class":142},[115,1526,202],{"class":129},[115,1528,591],{"class":125},[115,1530,173],{"class":149},[115,1532,1533],{"class":129},"`",[115,1535,1536],{"class":179},"thumb_",[115,1538,1539],{"class":129},"${",[115,1541,1542],{"class":125},"String",[115,1544,1545],{"class":142},"(currentSecond)",[115,1547,202],{"class":129},[115,1549,1550],{"class":125},"padStart",[115,1552,173],{"class":142},[115,1554,1555],{"class":424},"6",[115,1557,452],{"class":129},[115,1559,823],{"class":129},[115,1561,1562],{"class":179},"0",[115,1564,176],{"class":129},[115,1566,499],{"class":142},[115,1568,1569],{"class":129},"}",[115,1571,1572],{"class":179},".jpg",[115,1574,1533],{"class":129},[115,1576,452],{"class":129},[115,1578,558],{"class":129},[115,1580,561],{"class":149},[115,1582,564],{"class":129},[115,1584,568],{"class":567},[115,1586,571],{"class":129},[115,1588,217],{"class":149},[115,1590,1592,1594,1597,1599,1601,1603,1605,1608],{"class":117,"line":1591},54,[115,1593,1372],{"class":121},[115,1595,1596],{"class":142}," syncHandle",[115,1598,146],{"class":129},[115,1600,513],{"class":163},[115,1602,580],{"class":142},[115,1604,202],{"class":129},[115,1606,1607],{"class":125},"createSyncAccessHandle",[115,1609,404],{"class":149},[115,1611,1613,1616,1618,1621,1623,1626,1629,1631,1634],{"class":117,"line":1612},55,[115,1614,1615],{"class":142},"    syncHandle",[115,1617,202],{"class":129},[115,1619,1620],{"class":125},"write",[115,1622,173],{"class":149},[115,1624,1625],{"class":129},"new",[115,1627,1628],{"class":125}," Uint8Array",[115,1630,173],{"class":149},[115,1632,1633],{"class":142},"buffer",[115,1635,1636],{"class":149},"))\n",[115,1638,1640,1642,1644,1647],{"class":117,"line":1639},56,[115,1641,1615],{"class":142},[115,1643,202],{"class":129},[115,1645,1646],{"class":125},"close",[115,1648,404],{"class":149},[115,1650,1652],{"class":117,"line":1651},57,[115,1653,157],{"emptyLinePlaceholder":156},[115,1655,1657,1660],{"class":117,"line":1656},58,[115,1658,1659],{"class":142},"    frameCount",[115,1661,1662],{"class":129},"++\n",[115,1664,1666,1669,1671,1673,1675,1677,1679,1681,1683,1686,1688,1690,1693,1695,1698,1700,1702,1704],{"class":117,"line":1665},59,[115,1667,1668],{"class":142},"    self",[115,1670,202],{"class":129},[115,1672,97],{"class":125},[115,1674,173],{"class":149},[115,1676,1148],{"class":129},[115,1678,787],{"class":149},[115,1680,564],{"class":129},[115,1682,823],{"class":129},[115,1684,1685],{"class":179},"progress",[115,1687,176],{"class":129},[115,1689,452],{"class":129},[115,1691,1692],{"class":142}," frameCount",[115,1694,452],{"class":129},[115,1696,1697],{"class":149}," second",[115,1699,564],{"class":129},[115,1701,1270],{"class":142},[115,1703,571],{"class":129},[115,1705,217],{"class":149},[115,1707,1709],{"class":117,"line":1708},60,[115,1710,223],{"class":129},[115,1712,1714],{"class":117,"line":1713},61,[115,1715,157],{"emptyLinePlaceholder":156},[115,1717,1719,1722,1724,1726,1729],{"class":117,"line":1718},62,[115,1720,1721],{"class":142},"  frame",[115,1723,202],{"class":129},[115,1725,1646],{"class":125},[115,1727,1728],{"class":149},"()  ",[115,1730,1731],{"class":355},"\u002F\u002F 重要：VideoFrame 必须关，不然泄漏显存\n",[115,1733,1735],{"class":117,"line":1734},63,[115,1736,382],{"class":129},[115,1738,1740],{"class":117,"line":1739},64,[115,1741,157],{"emptyLinePlaceholder":156},[115,1743,1745,1747,1749,1751,1753,1756,1758],{"class":117,"line":1744},65,[115,1746,484],{"class":121},[115,1748,487],{"class":121},[115,1750,882],{"class":125},[115,1752,911],{"class":129},[115,1754,1755],{"class":495}," chunks",[115,1757,927],{"class":129},[115,1759,133],{"class":129},[115,1761,1763,1766,1768,1770,1773,1776,1778,1780],{"class":117,"line":1762},66,[115,1764,1765],{"class":163},"  for",[115,1767,167],{"class":149},[115,1769,393],{"class":121},[115,1771,1772],{"class":142}," chunk",[115,1774,1775],{"class":129}," of",[115,1777,1755],{"class":142},[115,1779,261],{"class":149},[115,1781,193],{"class":129},[115,1783,1785,1788,1790,1793,1795,1797,1800,1802],{"class":117,"line":1784},67,[115,1786,1787],{"class":142},"    decoder",[115,1789,202],{"class":129},[115,1791,1792],{"class":125},"decode",[115,1794,173],{"class":149},[115,1796,1625],{"class":129},[115,1798,1799],{"class":125}," EncodedVideoChunk",[115,1801,173],{"class":149},[115,1803,193],{"class":129},[115,1805,1807,1810,1812,1814,1816,1819,1822,1824,1827,1829,1832,1834,1837,1839],{"class":117,"line":1806},68,[115,1808,1809],{"class":149},"      type",[115,1811,564],{"class":129},[115,1813,1772],{"class":142},[115,1815,202],{"class":129},[115,1817,1818],{"class":142},"keyframe",[115,1820,1821],{"class":129}," ?",[115,1823,823],{"class":129},[115,1825,1826],{"class":179},"key",[115,1828,176],{"class":129},[115,1830,1831],{"class":129}," :",[115,1833,823],{"class":129},[115,1835,1836],{"class":179},"delta",[115,1838,176],{"class":129},[115,1840,1037],{"class":129},[115,1842,1844,1847,1849,1851,1853,1855],{"class":117,"line":1843},69,[115,1845,1846],{"class":149},"      timestamp",[115,1848,564],{"class":129},[115,1850,1772],{"class":142},[115,1852,202],{"class":129},[115,1854,1242],{"class":142},[115,1856,1037],{"class":129},[115,1858,1860,1863,1865,1867,1869],{"class":117,"line":1859},70,[115,1861,1862],{"class":149},"      data",[115,1864,564],{"class":129},[115,1866,1772],{"class":142},[115,1868,202],{"class":129},[115,1870,804],{"class":142},[115,1872,1874,1877],{"class":117,"line":1873},71,[115,1875,1876],{"class":129},"    }",[115,1878,1636],{"class":149},[115,1880,1882],{"class":117,"line":1881},72,[115,1883,223],{"class":129},[115,1885,1887,1889,1892,1894,1897],{"class":117,"line":1886},73,[115,1888,639],{"class":163},[115,1890,1891],{"class":142}," decoder",[115,1893,202],{"class":129},[115,1895,1896],{"class":125},"flush",[115,1898,404],{"class":149},[115,1900,1902,1904,1906,1908,1910,1912,1914,1916,1918,1921,1923,1925,1927,1929],{"class":117,"line":1901},74,[115,1903,1139],{"class":142},[115,1905,202],{"class":129},[115,1907,97],{"class":125},[115,1909,173],{"class":149},[115,1911,1148],{"class":129},[115,1913,787],{"class":149},[115,1915,564],{"class":129},[115,1917,823],{"class":129},[115,1919,1920],{"class":179},"done",[115,1922,176],{"class":129},[115,1924,452],{"class":129},[115,1926,1692],{"class":142},[115,1928,571],{"class":129},[115,1930,217],{"class":149},[115,1932,1934],{"class":117,"line":1933},75,[115,1935,382],{"class":129},[11,1937,1938],{},"几个容易踩的坑：",[1940,1941,1942,1956,1962],"ol",{},[1943,1944,1945,1951,1952,1955],"li",{},[25,1946,1947,1950],{},[30,1948,1949],{},"frame.close()"," 是强制的。"," VideoFrame 持着 GPU 缓冲的引用。忘了关，显存一直漏，漏到 100 帧左右解码器静默失败。必须 ",[30,1953,1954],{},"try\u002Ffinally","。",[1943,1957,1958,1961],{},[25,1959,1960],{},"Worker 里的 OffscreenCanvas。"," 这玩意才能让你在 Worker 里渲染。Chrome 69+、Safari 16.4+、Firefox 105+ 都有。",[1943,1963,1964,1969],{},[25,1965,1966,1968],{},[30,1967,74],{}," 会阻塞其他 OPFS 操作。"," 别开着它跨异步操作——写完、关掉、走人。",[18,1971,1973],{"id":1972},"第四步喂数码器的-chunk-从哪来","第四步：喂数码器的 chunk 从哪来",[11,1975,1976],{},"这是最烦的一步。WebCodecs 不做解封装，它要的是裸 codec chunk。你还是得自己解析 MP4 容器，把 H.264 NAL unit 抽出来。",[11,1978,1979],{},"两条路：",[1940,1981,1982,1998],{},[1943,1983,1984,28,1990,1997],{},[25,1985,1986,1987],{},"用 ",[30,1988,1989],{},"mp4box.js",[1991,1992,1996],"a",{"href":1993,"rel":1994},"https:\u002F\u002Fwww.npmjs.com\u002Fpackage\u002Fmp4box",[1995],"nofollow","npm","）——纯 JS 的 MP4 解析器，吐出来的 sample 直接喂给 WebCodecs。",[1943,1999,2000,2003],{},[25,2001,2002],{},"只用 FFmpeg WASM 做解封装","——抽出 chunk，再交给 WebCodecs 解码。听着浪费，但 FFmpeg WASM 解封装比\"解码+渲染\"快得多，其实划算。",[11,2005,2006,2007,2009],{},"方案一用 ",[30,2008,1989],{}," 的代码：",[83,2011,2013],{"className":109,"code":2012,"language":111,"meta":91,"style":91},"import mp4box from 'mp4box'\n\nasync function getCodecChunks(file) {\n  const arrayBuffer = await file.arrayBuffer()\n  arrayBuffer.fileStart = 0  \u002F\u002F mp4box.js 的约定\n\n  const mp4boxFile = mp4box.createFile()\n  mp4boxFile.onReady = (info) => {\n    mp4boxFile.setExtractionOptions(info.videoTracks[0].id, null, {\n      nbSamples: 100  \u002F\u002F 批大小\n    })\n    mp4boxFile.start()\n  }\n\n  const chunks = []\n  mp4boxFile.onSamples = (trackId, ref, samples) => {\n    for (const sample of samples) {\n      chunks.push({\n        keyframe: sample.is_sync,\n        timestamp: sample.cts * 1_000_000 \u002F sample.timescale,\n        data: sample.data\n      })\n    }\n  }\n\n  mp4boxFile.appendBuffer(arrayBuffer)\n  mp4boxFile.flush()\n\n  return {\n    codec: mp4boxFile.getInfo().videoTracks[0].codec,\n    chunks\n  }\n}\n",[30,2014,2015,2034,2038,2055,2074,2091,2095,2114,2137,2176,2189,2195,2206,2210,2214,2224,2256,2276,2289,2305,2335,2348,2355,2360,2364,2368,2383,2393,2397,2403,2435,2440,2444],{"__ignoreMap":91},[115,2016,2017,2020,2023,2026,2028,2031],{"class":117,"line":118},[115,2018,2019],{"class":163},"import",[115,2021,2022],{"class":142}," mp4box ",[115,2024,2025],{"class":163},"from",[115,2027,823],{"class":129},[115,2029,2030],{"class":179},"mp4box",[115,2032,2033],{"class":129},"'\n",[115,2035,2036],{"class":117,"line":136},[115,2037,157],{"emptyLinePlaceholder":156},[115,2039,2040,2042,2044,2047,2049,2051,2053],{"class":117,"line":153},[115,2041,484],{"class":121},[115,2043,487],{"class":121},[115,2045,2046],{"class":125}," getCodecChunks",[115,2048,173],{"class":129},[115,2050,496],{"class":495},[115,2052,499],{"class":129},[115,2054,133],{"class":129},[115,2056,2057,2059,2062,2064,2066,2068,2070,2072],{"class":117,"line":160},[115,2058,139],{"class":121},[115,2060,2061],{"class":142}," arrayBuffer",[115,2063,146],{"class":129},[115,2065,513],{"class":163},[115,2067,642],{"class":142},[115,2069,202],{"class":129},[115,2071,1434],{"class":125},[115,2073,404],{"class":149},[115,2075,2076,2079,2081,2084,2086,2088],{"class":117,"line":196},[115,2077,2078],{"class":142},"  arrayBuffer",[115,2080,202],{"class":129},[115,2082,2083],{"class":142},"fileStart",[115,2085,146],{"class":129},[115,2087,425],{"class":424},[115,2089,2090],{"class":355},"  \u002F\u002F mp4box.js 的约定\n",[115,2092,2093],{"class":117,"line":220},[115,2094,157],{"emptyLinePlaceholder":156},[115,2096,2097,2099,2102,2104,2107,2109,2112],{"class":117,"line":226},[115,2098,139],{"class":121},[115,2100,2101],{"class":142}," mp4boxFile",[115,2103,146],{"class":129},[115,2105,2106],{"class":142}," mp4box",[115,2108,202],{"class":129},[115,2110,2111],{"class":125},"createFile",[115,2113,404],{"class":149},[115,2115,2116,2119,2121,2124,2126,2128,2131,2133,2135],{"class":117,"line":231},[115,2117,2118],{"class":142},"  mp4boxFile",[115,2120,202],{"class":129},[115,2122,2123],{"class":125},"onReady",[115,2125,146],{"class":129},[115,2127,167],{"class":129},[115,2129,2130],{"class":495},"info",[115,2132,499],{"class":129},[115,2134,776],{"class":121},[115,2136,133],{"class":129},[115,2138,2139,2142,2144,2147,2149,2151,2153,2156,2159,2161,2164,2166,2169,2171,2174],{"class":117,"line":266},[115,2140,2141],{"class":142},"    mp4boxFile",[115,2143,202],{"class":129},[115,2145,2146],{"class":125},"setExtractionOptions",[115,2148,173],{"class":149},[115,2150,2130],{"class":142},[115,2152,202],{"class":129},[115,2154,2155],{"class":142},"videoTracks",[115,2157,2158],{"class":149},"[",[115,2160,1562],{"class":424},[115,2162,2163],{"class":149},"]",[115,2165,202],{"class":129},[115,2167,2168],{"class":142},"id",[115,2170,452],{"class":129},[115,2172,2173],{"class":129}," null,",[115,2175,133],{"class":129},[115,2177,2178,2181,2183,2186],{"class":117,"line":286},[115,2179,2180],{"class":149},"      nbSamples",[115,2182,564],{"class":129},[115,2184,2185],{"class":424}," 100",[115,2187,2188],{"class":355},"  \u002F\u002F 批大小\n",[115,2190,2191,2193],{"class":117,"line":291},[115,2192,1876],{"class":129},[115,2194,217],{"class":149},[115,2196,2197,2199,2201,2204],{"class":117,"line":296},[115,2198,2141],{"class":142},[115,2200,202],{"class":129},[115,2202,2203],{"class":125},"start",[115,2205,404],{"class":149},[115,2207,2208],{"class":117,"line":322},[115,2209,223],{"class":129},[115,2211,2212],{"class":117,"line":342},[115,2213,157],{"emptyLinePlaceholder":156},[115,2215,2216,2218,2220,2222],{"class":117,"line":347},[115,2217,139],{"class":121},[115,2219,1755],{"class":142},[115,2221,146],{"class":129},[115,2223,150],{"class":149},[115,2225,2226,2228,2230,2233,2235,2237,2240,2242,2245,2247,2250,2252,2254],{"class":117,"line":352},[115,2227,2118],{"class":142},[115,2229,202],{"class":129},[115,2231,2232],{"class":125},"onSamples",[115,2234,146],{"class":129},[115,2236,167],{"class":129},[115,2238,2239],{"class":495},"trackId",[115,2241,452],{"class":129},[115,2243,2244],{"class":495}," ref",[115,2246,452],{"class":129},[115,2248,2249],{"class":495}," samples",[115,2251,499],{"class":129},[115,2253,776],{"class":121},[115,2255,133],{"class":129},[115,2257,2258,2261,2263,2265,2268,2270,2272,2274],{"class":117,"line":359},[115,2259,2260],{"class":163},"    for",[115,2262,167],{"class":149},[115,2264,393],{"class":121},[115,2266,2267],{"class":142}," sample",[115,2269,1775],{"class":129},[115,2271,2249],{"class":142},[115,2273,261],{"class":149},[115,2275,193],{"class":129},[115,2277,2278,2281,2283,2285,2287],{"class":117,"line":365},[115,2279,2280],{"class":142},"      chunks",[115,2282,202],{"class":129},[115,2284,205],{"class":125},[115,2286,173],{"class":149},[115,2288,193],{"class":129},[115,2290,2291,2294,2296,2298,2300,2303],{"class":117,"line":370},[115,2292,2293],{"class":149},"        keyframe",[115,2295,564],{"class":129},[115,2297,2267],{"class":142},[115,2299,202],{"class":129},[115,2301,2302],{"class":142},"is_sync",[115,2304,1037],{"class":129},[115,2306,2307,2310,2312,2314,2316,2319,2322,2324,2326,2328,2330,2333],{"class":117,"line":379},[115,2308,2309],{"class":149},"        timestamp",[115,2311,564],{"class":129},[115,2313,2267],{"class":142},[115,2315,202],{"class":129},[115,2317,2318],{"class":142},"cts",[115,2320,2321],{"class":129}," *",[115,2323,1248],{"class":424},[115,2325,1245],{"class":129},[115,2327,2267],{"class":142},[115,2329,202],{"class":129},[115,2331,2332],{"class":142},"timescale",[115,2334,1037],{"class":129},[115,2336,2337,2340,2342,2344,2346],{"class":117,"line":385},[115,2338,2339],{"class":149},"        data",[115,2341,564],{"class":129},[115,2343,2267],{"class":142},[115,2345,202],{"class":129},[115,2347,804],{"class":142},[115,2349,2350,2353],{"class":117,"line":390},[115,2351,2352],{"class":129},"      }",[115,2354,217],{"class":149},[115,2356,2357],{"class":117,"line":407},[115,2358,2359],{"class":129},"    }\n",[115,2361,2362],{"class":117,"line":432},[115,2363,223],{"class":129},[115,2365,2366],{"class":117,"line":459},[115,2367,157],{"emptyLinePlaceholder":156},[115,2369,2370,2372,2374,2377,2379,2381],{"class":117,"line":465},[115,2371,2118],{"class":142},[115,2373,202],{"class":129},[115,2375,2376],{"class":125},"appendBuffer",[115,2378,173],{"class":149},[115,2380,1434],{"class":142},[115,2382,217],{"class":149},[115,2384,2385,2387,2389,2391],{"class":117,"line":1088},[115,2386,2118],{"class":142},[115,2388,202],{"class":129},[115,2390,1896],{"class":125},[115,2392,404],{"class":149},[115,2394,2395],{"class":117,"line":1102},[115,2396,157],{"emptyLinePlaceholder":156},[115,2398,2399,2401],{"class":117,"line":1113},[115,2400,373],{"class":163},[115,2402,133],{"class":129},[115,2404,2405,2407,2409,2411,2413,2416,2418,2420,2422,2424,2426,2428,2430,2433],{"class":117,"line":1124},[115,2406,1105],{"class":149},[115,2408,564],{"class":129},[115,2410,2101],{"class":142},[115,2412,202],{"class":129},[115,2414,2415],{"class":125},"getInfo",[115,2417,130],{"class":149},[115,2419,202],{"class":129},[115,2421,2155],{"class":142},[115,2423,2158],{"class":149},[115,2425,1562],{"class":424},[115,2427,2163],{"class":149},[115,2429,202],{"class":129},[115,2431,2432],{"class":142},"codec",[115,2434,1037],{"class":129},[115,2436,2437],{"class":117,"line":1131},[115,2438,2439],{"class":142},"    chunks\n",[115,2441,2442],{"class":117,"line":1136},[115,2443,223],{"class":129},[115,2445,2446],{"class":117,"line":1166},[115,2447,382],{"class":129},[11,2449,2450],{},"HLS\u002FDASH 流的话，这一步完全可以跳过——你本来就是去抓 segment 的，手里就有裸 chunk。FlowPick 内部某些路径就是这么干的。",[18,2452,2454],{"id":2453},"实测webcodecs-vs-ffmpeg-wasm-vs-原生","实测：WebCodecs vs FFmpeg WASM vs 原生",[11,2456,2457],{},"测试场景：从 30 分钟 1080p H.264 视频里每秒抽一帧，共 1800 帧。",[2459,2460,2461,2480],"table",{},[2462,2463,2464],"thead",{},[2465,2466,2467,2471,2474,2477],"tr",{},[2468,2469,2470],"th",{},"方案",[2468,2472,2473],{},"实际耗时",[2468,2475,2476],{},"内存峰值",[2468,2478,2479],{},"下载体积",[2481,2482,2483,2498,2512,2525],"tbody",{},[2465,2484,2485,2489,2492,2495],{},[2486,2487,2488],"td",{},"原生 FFmpeg（命令行）",[2486,2490,2491],{},"18s",[2486,2493,2494],{},"80MB",[2486,2496,2497],{},"无（已安装）",[2465,2499,2500,2503,2506,2509],{},[2486,2501,2502],{},"FFmpeg WASM（单线程）",[2486,2504,2505],{},"145s",[2486,2507,2508],{},"220MB",[2486,2510,2511],{},"25MB",[2465,2513,2514,2517,2520,2523],{},[2486,2515,2516],{},"FFmpeg WASM（多线程）",[2486,2518,2519],{},"52s",[2486,2521,2522],{},"280MB",[2486,2524,2511],{},[2465,2526,2527,2530,2533,2536],{},[2486,2528,2529],{},"WebCodecs + Worker + OPFS",[2486,2531,2532],{},"31s",[2486,2534,2535],{},"95MB",[2486,2537,2538],{},"\u003C100KB",[11,2540,2541],{},"WebCodecs 赢在哪：",[1940,2543,2544,2547,2550,2553],{},[1943,2545,2546],{},"硬件加速——H.264 解码真在 GPU 上跑，不是 WASM 里",[1943,2548,2549],{},"没有 25MB 的 WASM 大文件",[1943,2551,2552],{},"内存占用低（没有 FFmpeg 内部那些缓冲）",[1943,2554,2555],{},"Worker 是真多线程（FFmpeg WASM 的 pthreads 在改进，但还是有开销）",[11,2557,2558],{},"FFmpeg WASM 赢在哪：",[1940,2560,2561,2564,2567,2570],{},[1943,2562,2563],{},"什么 codec 都能吃（WebCodecs 只支持浏览器支持的，一般是 H.264、H.265、VP8\u002FVP9、AV1）",[1943,2565,2566],{},"能做封装\u002F解封装（WebCodecs 只管编解码）",[1943,2568,2569],{},"边界情况扛得住",[1943,2571,2572],{},"同一套代码到处跑（不用做能力检测）",[11,2574,2575,2578],{},[25,2576,2577],{},"我的看法："," codec 已知、要性能，用 WebCodecs；要广度，用 FFmpeg WASM。FlowPick 两个都用——通用重封装路径走 FFmpeg WASM，特定操作（比如缩略图生成、输入确定是 H.264 且要快）走 WebCodecs。",[18,2580,2582],{"id":2581},"opfs-性能的玄学","OPFS 性能的玄学",[11,2584,2585,2586,2588],{},"外头有个说法：\"OPFS 跟原生磁盘一样快。\"假的。它比 IndexedDB 和 ",[30,2587,70],{}," 快，但还是有开销：",[2459,2590,2591,2607],{},[2462,2592,2593],{},[2465,2594,2595,2598,2601,2604],{},[2468,2596,2597],{},"操作",[2468,2599,2600],{},"OPFS（Worker 同步）",[2468,2602,2603],{},"IndexedDB",[2468,2605,2606],{},"原生磁盘",[2481,2608,2609,2623,2637,2651],{},[2465,2610,2611,2614,2617,2620],{},[2486,2612,2613],{},"1KB 写",[2486,2615,2616],{},"0.05ms",[2486,2618,2619],{},"1.2ms",[2486,2621,2622],{},"0.01ms",[2465,2624,2625,2628,2631,2634],{},[2486,2626,2627],{},"1MB 写",[2486,2629,2630],{},"1.1ms",[2486,2632,2633],{},"8.5ms",[2486,2635,2636],{},"0.4ms",[2465,2638,2639,2642,2645,2648],{},[2486,2640,2641],{},"100MB 写",[2486,2643,2644],{},"95ms",[2486,2646,2647],{},"850ms",[2486,2649,2650],{},"35ms",[2465,2652,2653,2656,2659,2662],{},[2486,2654,2655],{},"1MB 读",[2486,2657,2658],{},"0.8ms",[2486,2660,2661],{},"4.5ms",[2486,2663,2664],{},"0.2ms",[11,2666,2667],{},"OPFS 比原生磁盘慢 3-5 倍，但比 IndexedDB 处理二进制快 5-10 倍。视频处理工作流里\"够用\"——瓶颈在编解码，不在 I\u002FO。",[11,2669,2670],{},"几个真正的红利：",[2672,2673,2674,2680,2683],"ul",{},[1943,2675,2676,2677,2679],{},"Worker 里的 ",[30,2678,74],{},"——同步 I\u002FO 意味着每次写没有 await 开销",[1943,2681,2682],{},"文件能比内存大——5GB 文件能流式过 OPFS，不用一次性全装进内存",[1943,2684,2685],{},"跨会话持久——处理完的视频刷新页面后还在",[18,2687,2689],{"id":2688},"worker-线程池做并行解码","Worker 线程池做并行解码",[11,2691,2692],{},"要批量处理多个视频（比如给整个目录做缩略图），就要上线程池：",[83,2694,2696],{"className":109,"code":2695,"language":111,"meta":91,"style":91},"class WorkerPool {\n  constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {\n    this.workers = Array.from({ length: size }, () => new Worker(workerUrl))\n    this.queue = []\n    this.busy = new Set()\n  }\n\n  async run(task) {\n    const worker = await this.getIdle()\n    this.busy.add(worker)\n\n    return new Promise((resolve, reject) => {\n      worker.onmessage = (e) => {\n        this.busy.delete(worker)\n        if (e.data.type === 'done') resolve(e.data.result)\n        else if (e.data.type === 'error') reject(e.data.error)\n      }\n      worker.postMessage(task)\n    })\n  }\n\n  async getIdle() {\n    if (this.busy.size \u003C this.workers.length) {\n      return this.workers.find(w => !this.busy.has(w))\n    }\n    \u002F\u002F 等一个 Worker 空出来\n    return new Promise(resolve => {\n      this.queue.push(resolve)\n    })\n  }\n}\n",[30,2697,2698,2709,2743,2790,2801,2817,2821,2825,2842,2861,2879,2883,2911,2932,2950,2994,3040,3045,3059,3065,3069,3073,3084,3117,3154,3158,3163,3179,3196,3202,3206],{"__ignoreMap":91},[115,2699,2700,2703,2707],{"class":117,"line":118},[115,2701,2702],{"class":121},"class",[115,2704,2706],{"class":2705},"sBMFI"," WorkerPool",[115,2708,133],{"class":129},[115,2710,2711,2714,2716,2719,2721,2724,2726,2728,2730,2733,2736,2739,2741],{"class":117,"line":136},[115,2712,2713],{"class":121},"  constructor",[115,2715,173],{"class":129},[115,2717,2718],{"class":495},"workerUrl",[115,2720,452],{"class":129},[115,2722,2723],{"class":495}," size",[115,2725,146],{"class":129},[115,2727,516],{"class":142},[115,2729,202],{"class":129},[115,2731,2732],{"class":142},"hardwareConcurrency ",[115,2734,2735],{"class":129},"||",[115,2737,2738],{"class":424}," 4",[115,2740,499],{"class":129},[115,2742,133],{"class":129},[115,2744,2745,2748,2751,2753,2756,2758,2760,2762,2764,2767,2769,2771,2774,2777,2779,2781,2784,2786,2788],{"class":117,"line":153},[115,2746,2747],{"class":129},"    this.",[115,2749,2750],{"class":142},"workers",[115,2752,146],{"class":129},[115,2754,2755],{"class":142}," Array",[115,2757,202],{"class":129},[115,2759,2025],{"class":125},[115,2761,173],{"class":149},[115,2763,1148],{"class":129},[115,2765,2766],{"class":149}," length",[115,2768,564],{"class":129},[115,2770,2723],{"class":142},[115,2772,2773],{"class":129}," },",[115,2775,2776],{"class":129}," ()",[115,2778,776],{"class":121},[115,2780,944],{"class":129},[115,2782,2783],{"class":125}," Worker",[115,2785,173],{"class":149},[115,2787,2718],{"class":142},[115,2789,1636],{"class":149},[115,2791,2792,2794,2797,2799],{"class":117,"line":160},[115,2793,2747],{"class":129},[115,2795,2796],{"class":142},"queue",[115,2798,146],{"class":129},[115,2800,150],{"class":149},[115,2802,2803,2805,2808,2810,2812,2815],{"class":117,"line":196},[115,2804,2747],{"class":129},[115,2806,2807],{"class":142},"busy",[115,2809,146],{"class":129},[115,2811,944],{"class":129},[115,2813,2814],{"class":125}," Set",[115,2816,404],{"class":149},[115,2818,2819],{"class":117,"line":220},[115,2820,223],{"class":129},[115,2822,2823],{"class":117,"line":226},[115,2824,157],{"emptyLinePlaceholder":156},[115,2826,2827,2830,2833,2835,2838,2840],{"class":117,"line":231},[115,2828,2829],{"class":121},"  async",[115,2831,2832],{"class":149}," run",[115,2834,173],{"class":129},[115,2836,2837],{"class":495},"task",[115,2839,499],{"class":129},[115,2841,133],{"class":129},[115,2843,2844,2846,2849,2851,2853,2856,2859],{"class":117,"line":266},[115,2845,1372],{"class":121},[115,2847,2848],{"class":142}," worker",[115,2850,146],{"class":129},[115,2852,513],{"class":163},[115,2854,2855],{"class":129}," this.",[115,2857,2858],{"class":125},"getIdle",[115,2860,404],{"class":149},[115,2862,2863,2865,2867,2869,2872,2874,2877],{"class":117,"line":286},[115,2864,2747],{"class":129},[115,2866,2807],{"class":142},[115,2868,202],{"class":129},[115,2870,2871],{"class":125},"add",[115,2873,173],{"class":149},[115,2875,2876],{"class":142},"worker",[115,2878,217],{"class":149},[115,2880,2881],{"class":117,"line":291},[115,2882,157],{"emptyLinePlaceholder":156},[115,2884,2885,2888,2890,2893,2895,2897,2900,2902,2905,2907,2909],{"class":117,"line":296},[115,2886,2887],{"class":163},"    return",[115,2889,944],{"class":129},[115,2891,2892],{"class":2705}," Promise",[115,2894,173],{"class":149},[115,2896,173],{"class":129},[115,2898,2899],{"class":495},"resolve",[115,2901,452],{"class":129},[115,2903,2904],{"class":495}," reject",[115,2906,499],{"class":129},[115,2908,776],{"class":121},[115,2910,133],{"class":129},[115,2912,2913,2916,2918,2920,2922,2924,2926,2928,2930],{"class":117,"line":322},[115,2914,2915],{"class":142},"      worker",[115,2917,202],{"class":129},[115,2919,761],{"class":125},[115,2921,146],{"class":129},[115,2923,167],{"class":129},[115,2925,771],{"class":495},[115,2927,499],{"class":129},[115,2929,776],{"class":121},[115,2931,133],{"class":129},[115,2933,2934,2937,2939,2941,2944,2946,2948],{"class":117,"line":342},[115,2935,2936],{"class":129},"        this.",[115,2938,2807],{"class":142},[115,2940,202],{"class":129},[115,2942,2943],{"class":125},"delete",[115,2945,173],{"class":149},[115,2947,2876],{"class":142},[115,2949,217],{"class":149},[115,2951,2952,2955,2957,2959,2961,2963,2965,2967,2969,2971,2973,2975,2977,2979,2981,2983,2985,2987,2989,2992],{"class":117,"line":347},[115,2953,2954],{"class":163},"        if",[115,2956,167],{"class":149},[115,2958,771],{"class":142},[115,2960,202],{"class":129},[115,2962,845],{"class":142},[115,2964,202],{"class":129},[115,2966,817],{"class":142},[115,2968,820],{"class":129},[115,2970,823],{"class":129},[115,2972,1920],{"class":179},[115,2974,176],{"class":129},[115,2976,261],{"class":149},[115,2978,2899],{"class":125},[115,2980,173],{"class":149},[115,2982,771],{"class":142},[115,2984,202],{"class":129},[115,2986,845],{"class":142},[115,2988,202],{"class":129},[115,2990,2991],{"class":142},"result",[115,2993,217],{"class":149},[115,2995,2996,2999,3001,3003,3005,3007,3009,3011,3013,3015,3017,3019,3021,3023,3026,3028,3030,3032,3034,3036,3038],{"class":117,"line":352},[115,2997,2998],{"class":163},"        else",[115,3000,858],{"class":163},[115,3002,167],{"class":149},[115,3004,771],{"class":142},[115,3006,202],{"class":129},[115,3008,845],{"class":142},[115,3010,202],{"class":129},[115,3012,817],{"class":142},[115,3014,820],{"class":129},[115,3016,823],{"class":129},[115,3018,1060],{"class":179},[115,3020,176],{"class":129},[115,3022,261],{"class":149},[115,3024,3025],{"class":125},"reject",[115,3027,173],{"class":149},[115,3029,771],{"class":142},[115,3031,202],{"class":129},[115,3033,845],{"class":142},[115,3035,202],{"class":129},[115,3037,1060],{"class":142},[115,3039,217],{"class":149},[115,3041,3042],{"class":117,"line":359},[115,3043,3044],{"class":129},"      }\n",[115,3046,3047,3049,3051,3053,3055,3057],{"class":117,"line":365},[115,3048,2915],{"class":142},[115,3050,202],{"class":129},[115,3052,97],{"class":125},[115,3054,173],{"class":149},[115,3056,2837],{"class":142},[115,3058,217],{"class":149},[115,3060,3061,3063],{"class":117,"line":370},[115,3062,1876],{"class":129},[115,3064,217],{"class":149},[115,3066,3067],{"class":117,"line":379},[115,3068,223],{"class":129},[115,3070,3071],{"class":117,"line":385},[115,3072,157],{"emptyLinePlaceholder":156},[115,3074,3075,3077,3080,3082],{"class":117,"line":390},[115,3076,2829],{"class":121},[115,3078,3079],{"class":149}," getIdle",[115,3081,130],{"class":129},[115,3083,133],{"class":129},[115,3085,3086,3089,3091,3094,3096,3098,3101,3104,3106,3108,3110,3113,3115],{"class":117,"line":407},[115,3087,3088],{"class":163},"    if",[115,3090,167],{"class":149},[115,3092,3093],{"class":129},"this.",[115,3095,2807],{"class":142},[115,3097,202],{"class":129},[115,3099,3100],{"class":142},"size",[115,3102,3103],{"class":129}," \u003C",[115,3105,2855],{"class":129},[115,3107,2750],{"class":142},[115,3109,202],{"class":129},[115,3111,3112],{"class":142},"length",[115,3114,261],{"class":149},[115,3116,193],{"class":129},[115,3118,3119,3122,3124,3126,3128,3131,3133,3136,3138,3141,3143,3145,3148,3150,3152],{"class":117,"line":432},[115,3120,3121],{"class":163},"      return",[115,3123,2855],{"class":129},[115,3125,2750],{"class":142},[115,3127,202],{"class":129},[115,3129,3130],{"class":125},"find",[115,3132,173],{"class":149},[115,3134,3135],{"class":495},"w",[115,3137,776],{"class":121},[115,3139,3140],{"class":129}," !this.",[115,3142,2807],{"class":142},[115,3144,202],{"class":129},[115,3146,3147],{"class":125},"has",[115,3149,173],{"class":149},[115,3151,3135],{"class":142},[115,3153,1636],{"class":149},[115,3155,3156],{"class":117,"line":459},[115,3157,2359],{"class":129},[115,3159,3160],{"class":117,"line":465},[115,3161,3162],{"class":355},"    \u002F\u002F 等一个 Worker 空出来\n",[115,3164,3165,3167,3169,3171,3173,3175,3177],{"class":117,"line":1088},[115,3166,2887],{"class":163},[115,3168,944],{"class":129},[115,3170,2892],{"class":2705},[115,3172,173],{"class":149},[115,3174,2899],{"class":495},[115,3176,776],{"class":121},[115,3178,133],{"class":129},[115,3180,3181,3184,3186,3188,3190,3192,3194],{"class":117,"line":1102},[115,3182,3183],{"class":129},"      this.",[115,3185,2796],{"class":142},[115,3187,202],{"class":129},[115,3189,205],{"class":125},[115,3191,173],{"class":149},[115,3193,2899],{"class":142},[115,3195,217],{"class":149},[115,3197,3198,3200],{"class":117,"line":1113},[115,3199,1876],{"class":129},[115,3201,217],{"class":149},[115,3203,3204],{"class":117,"line":1124},[115,3205,223],{"class":129},[115,3207,3208],{"class":117,"line":1131},[115,3209,382],{"class":129},[11,3211,3212],{},"8 核机器配 8 个 Worker，可以并行处理 8 个文件——每个独占一个线程，每个都是硬件加速解码。我这台 M2 MacBook 实测：单核 240 帧\u002F秒，整体约 1900 帧\u002F秒。相当于每秒处理 30 分钟视频。",[11,3214,3215],{},"但有个坑：硬件解码器并发有限。多数 GPU 同时只能扛 2-4 路解码。超了就降级到软解，反而慢。要在目标硬件上测。",[18,3217,3218],{"id":3218},"真实踩过的坑",[11,3220,3221,3226,3227,3229],{},[25,3222,3223,3224,1955],{},"坑 1：忘 ",[30,3225,1949],{}," 没关的 VideoFrame 漏显存。漏 100 帧左右解码就静默失败。用 ",[30,3228,1954],{},"：",[83,3231,3233],{"className":109,"code":3232,"language":111,"meta":91,"style":91},"try {\n  \u002F\u002F ... 用 frame 干活 ...\n} finally {\n  frame.close()\n}\n",[30,3234,3235,3242,3247,3256,3266],{"__ignoreMap":91},[115,3236,3237,3240],{"class":117,"line":118},[115,3238,3239],{"class":163},"try",[115,3241,133],{"class":129},[115,3243,3244],{"class":117,"line":136},[115,3245,3246],{"class":355},"  \u002F\u002F ... 用 frame 干活 ...\n",[115,3248,3249,3251,3254],{"class":117,"line":153},[115,3250,1569],{"class":129},[115,3252,3253],{"class":163}," finally",[115,3255,133],{"class":129},[115,3257,3258,3260,3262,3264],{"class":117,"line":160},[115,3259,1721],{"class":142},[115,3261,202],{"class":129},[115,3263,1646],{"class":125},[115,3265,404],{"class":149},[115,3267,3268],{"class":117,"line":196},[115,3269,382],{"class":129},[11,3271,3272,3275,3276,3279],{},[25,3273,3274],{},"坑 2：解码顺序 ≠ 显示顺序。"," B 帧的 H.264 按 DTS 顺序解码、按 PTS 顺序显示。按解码顺序处理帧，缩略图就乱序。WebCodecs 两个都给你——",[30,3277,3278],{},"frame.timestamp"," 是 PTS，解码按 DTS 走。",[11,3281,3282,3285,3286,3289,3290,3292,3293,3292,3296,3299],{},[25,3283,3284],{},"坑 3：codec 字符串格式。"," WebCodecs 要 ",[30,3287,3288],{},"'avc1.640028'","（带 constraint 字节）。MP4 有时存 ",[30,3291,3288],{},"，有时存 ",[30,3294,3295],{},"'avc1.64.0028'",[30,3297,3298],{},"'avc1.42E01E'","。配置解码器之前要归一化。",[11,3301,3302,3305,3306,3308,3309,3312,3313,3316],{},[25,3303,3304],{},"坑 4：Worker 模块加载。"," Worker 不是所有浏览器都能从任意 URL ",[30,3307,2019],{},"。要么用 ",[30,3310,3311],{},"importScripts()","，要么 Worker 构造函数里设 ",[30,3314,3315],{},"\"type\": \"module\"","（Chrome 80+、Safari 15+）。",[11,3318,3319,3322,3323,3326,3327,3330],{},[25,3320,3321],{},"坑 5：OPFS 配额。"," 浏览器把 OPFS 限制在剩余磁盘空间的一个百分比里。5GB 写入可能因为磁盘满了失败。catch ",[30,3324,3325],{},"QuotaExceededError","，回退到 ",[30,3328,3329],{},"showSaveFilePicker()"," 流式写。",[18,3332,3333],{"id":3333},"什么时候用什么",[11,3335,3336],{},[25,3337,3338],{},"用 FFmpeg WASM 的场景：",[2672,3340,3341,3344,3347,3350],{},[1943,3342,3343],{},"要兼容所有 codec（HEVC、VP9、AV1、MPEG-TS、MKV、FLV）",[1943,3345,3346],{},"要做容器转换（TS → MP4、MP4 → WebM）",[1943,3348,3349],{},"要滤镜链（叠加、缩放、裁剪、拼接）",[1943,3351,3352],{},"可靠性比速度重要",[11,3354,3355],{},[25,3356,3357],{},"用 WebCodecs 的场景：",[2672,3359,3360,3363,3366,3369],{},[1943,3361,3362],{},"已知 codec 是 H.264\u002FH.265\u002FVP9\u002FAV1",[1943,3364,3365],{},"做逐帧操作（缩略图、运动检测、抽帧）",[1943,3367,3368],{},"要实时性能（直播视频过滤、逐帧分析）",[1943,3370,3371],{},"内存预算紧（不想下 25MB 的 WASM）",[11,3373,3374],{},[25,3375,3376],{},"用 Web Workers 的场景：",[2672,3378,3379,3382],{},[1943,3380,3381],{},"任何 CPU 重活、不能堵主线程",[1943,3383,3384],{},"并行处理独立单元（文件、分片）",[11,3386,3387],{},[25,3388,3389],{},"用 OPFS 的场景：",[2672,3391,3392,3395,3398],{},[1943,3393,3394],{},"大于 5MB（localStorage 的上限）",[1943,3396,3397],{},"持久化中间状态（不想刷新页面就重新解码一遍）",[1943,3399,3400],{},"流式处理大文件，不想全装进内存",[18,3402,3403],{"id":3403},"参考资料",[2672,3405,3406,3414,3422,3430,3438],{},[1943,3407,3408,3413],{},[1991,3409,3412],{"href":3410,"rel":3411},"https:\u002F\u002Fwww.w3.org\u002FTR\u002Fwebcodecs\u002F",[1995],"WebCodecs 规范"," — 真正的规范，意外地好读",[1943,3415,3416,3421],{},[1991,3417,3420],{"href":3418,"rel":3419},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWebCodecs_API",[1995],"MDN WebCodecs API"," — 实用文档，有例子",[1943,3423,3424,3429],{},[1991,3425,3428],{"href":3426,"rel":3427},"https:\u002F\u002Fgithub.com\u002Fw3c\u002Fwebcodecs\u002Ftree\u002Fmain\u002Fsamples",[1995],"WebCodecs samples 仓库"," — 官方能跑的例子",[1943,3431,3432,3437],{},[1991,3433,3436],{"href":3434,"rel":3435},"https:\u002F\u002Ffs.spec.whatwg.org\u002F",[1995],"OPFS 规范"," — File System Access API，含 OPFS",[1943,3439,3440,3444],{},[1991,3441,1989],{"href":3442,"rel":3443},"https:\u002F\u002Fgithub.com\u002Fgpac\u002Fmp4box.js",[1995]," — GPAC 的 JS MP4 解析器，WebCodecs 输入的事实标准",[18,3446,3447],{"id":3447},"小结",[11,3449,3450,3451,3455],{},"WebCodecs + Workers + OPFS 是性能敏感场景下 FFmpeg WASM 的真替代。不是全面替代——FFmpeg WASM 在 codec 广度和边界情况上还是赢——但已知 codec、硬件加速、帧级处理的工作流，WebCodecs 快 4-5 倍、小 250 倍。下一篇是 ",[1991,3452,3454],{"href":3453},"\u002Fzh-Hans\u002Fblog\u002Fis-it-legal-to-download-streaming-video","流媒体下载到底合不合法","，讲你什么时候被允许用这些工具。",[11,3457,3458,3459,1955],{},"完整的 codec、分辨率、文件大小下的基准数据，看 ",[1991,3460,3462],{"href":3461},"\u002Fzh-Hans\u002Fblog\u002Fbrowser-video-processing-performance-benchmarks","浏览器视频处理性能基准",[3464,3465],"hr",{},[18,3467,3468],{"id":3468},"推荐阅读",[2672,3470,3471,3480,3487,3496,3505,3514],{},[1943,3472,3473,3479],{},[25,3474,3475],{},[1991,3476,3478],{"href":3477},"\u002Fzh-Hans\u002Fblog\u002Fbrowser-streaming-remux-mp4-isobmff","浏览器端视频重封装：fMP4、ISOBMFF 和不转码的理由"," — 这篇讲 codec 层，那篇讲容器层，互为补充",[1943,3481,3482,3486],{},[25,3483,3484],{},[1991,3485,3462],{"href":3461}," — 本文数据的完整版",[1943,3488,3489,3495],{},[25,3490,3491],{},[1991,3492,3494],{"href":3493},"\u002Fzh-Hans\u002Fblog\u002Fhow-flowpick-merges-video-segments-in-browser","FlowPick 是怎么在浏览器里把几百个视频切片合成 MP4 的"," — FlowPick 哪里用 FFmpeg WASM、哪里用 WebCodecs",[1943,3497,3498,3504],{},[25,3499,3500],{},[1991,3501,3503],{"href":3502},"\u002Fzh-Hans\u002Fblog\u002Fdash-mpd-deep-dive-segmenttemplate-contentprotection","DASH 深度：SegmentTemplate、ContentProtection 与多视角 MPD"," — 你会喂给 WebCodecs 的那些 chunk 从哪来",[1943,3506,3507,3513],{},[25,3508,3509],{},[1991,3510,3512],{"href":3511},"\u002Fzh-Hans\u002Fblog\u002Fhls-m3u8-deep-dive-encryption-multitrack","HLS 深度：加密、多音轨、值得记住的 EXT-X 标签"," — 分片的源头",[1943,3515,3516,3522],{},[25,3517,3518],{},[1991,3519,3521],{"href":3520},"\u002Fzh-Hans\u002Fblog\u002Fhow-i-built-flowpick-in-three-months","三个月肝出 FlowPick 的完整历程"," — 开发故事，包括 WebCodecs vs FFmpeg WASM 的决策",[3524,3525,3526],"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 .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 .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}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 .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 .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 .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":91,"searchDepth":136,"depth":136,"links":3528},[3529,3530,3531,3532,3533,3534,3535,3536,3537,3538,3539,3540,3541,3542],{"id":20,"depth":136,"text":21},{"id":78,"depth":136,"text":78},{"id":105,"depth":136,"text":106},{"id":473,"depth":136,"text":474},{"id":701,"depth":136,"text":702},{"id":1972,"depth":136,"text":1973},{"id":2453,"depth":136,"text":2454},{"id":2581,"depth":136,"text":2582},{"id":2688,"depth":136,"text":2689},{"id":3218,"depth":136,"text":3218},{"id":3333,"depth":136,"text":3333},{"id":3403,"depth":136,"text":3403},{"id":3447,"depth":136,"text":3447},{"id":3468,"depth":136,"text":3468},"tips","2026-08-04","用 WebCodecs 调硬件解码、Web Workers 拉多线程、OPFS 做高吞吐本地文件 I\u002FO——附上和 FFmpeg WASM 的实测对比。","md","\u002Fscreenshots\u002Fformat-conversion.png",{},"\u002Fzh-hans\u002Fblog\u002Fwebcodecs-web-workers-opfs-video-processing",{"title":5,"description":3545},"zh-Hans\u002Fblog\u002Fwebcodecs-web-workers-opfs-video-processing",[3553,3554,3555,3556,3557,3558],"webcodecs","web workers","opfs","性能","wasm","深度","SlJqkRxp8xGK1DpVC_haSPhhxpmLvTCHnjzpEbArCcE",[3561,3566],{"title":3562,"path":3563,"stem":3564,"description":3565,"date":3544,"category":3543,"children":-1},"流媒体下载到底合不合法？一份技术和法律清单","\u002Fzh-hans\u002Fblog\u002Fis-it-legal-to-download-streaming-video","zh-Hans\u002Fblog\u002Fis-it-legal-to-download-streaming-video","不是",{"title":3567,"path":3568,"stem":3569,"description":3570,"date":3571,"category":3543,"children":-1},"什么是 DASH 流媒体？面向非开发者的 MPD 文件解析","\u002Fzh-hans\u002Fblog\u002Fwhat-is-dash-mpd-streaming","zh-Hans\u002Fblog\u002Fwhat-is-dash-mpd-streaming","了解 MPEG-DASH 和 MPD 清单文件，学习如何下载 DASH 视频与音频轨道，以及为什么 MPD 流媒体比普通 MP4 更难保存。","2026-07-01",1787670587505]