demuxe 0.3.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +352 -0
- package/README.md +75 -0
- package/bin/demuxe.mjs +30 -0
- package/docs/API-MIGRATION.md +54 -0
- package/docs/BETA.md +98 -0
- package/docs/BRANDING-MIGRATION.md +77 -0
- package/docs/COMPATIBILITY-EXPANSION.md +181 -0
- package/docs/LICENSING.md +83 -0
- package/docs/OPTIMIZATION-COMPLETION.md +511 -0
- package/docs/OPTIMIZATION-FLAC.md +178 -0
- package/docs/OPTIMIZATION-INTEGRATION.md +221 -0
- package/docs/OPTIMIZATION-REVIEW-FIXES.md +78 -0
- package/docs/PLAYER-COMPONENT.md +247 -0
- package/docs/PUBLIC-API-VALIDATION.md +163 -0
- package/docs/PUBLIC-API.md +245 -0
- package/docs/RELEASE.md +181 -0
- package/docs/RUNTIME-ASSETS.md +127 -0
- package/engine-build.json +8781 -0
- package/examples/custom-controls.html +14 -0
- package/examples/player-element.html +5 -0
- package/fixtures/DejaVuSans.ttf +0 -0
- package/fixtures/FONT-LICENSE.txt +187 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +46 -0
- package/player.d.ts +1 -0
- package/player.js +1 -0
- package/release-manifest.json +541 -0
- package/sources.lock.json +96 -0
- package/third_party/notices/dav1d/COPYING +23 -0
- package/third_party/notices/emscripten/LICENSE +102 -0
- package/third_party/notices/emscripten/system/lib/compiler-rt/LICENSE.TXT +311 -0
- package/third_party/notices/emscripten/system/lib/libc/musl/COPYRIGHT +193 -0
- package/third_party/notices/emscripten/system/lib/libcxx/LICENSE.TXT +311 -0
- package/third_party/notices/emscripten/system/lib/libcxxabi/LICENSE.TXT +311 -0
- package/third_party/notices/emsdk/LICENSE +27 -0
- package/third_party/notices/ffmpeg/COPYING.GPLv2 +339 -0
- package/third_party/notices/ffmpeg/COPYING.GPLv3 +674 -0
- package/third_party/notices/ffmpeg/COPYING.LGPLv2.1 +502 -0
- package/third_party/notices/ffmpeg/LICENSE.md +129 -0
- package/third_party/notices/ffmpeg-adaptation/COPYING.LGPLv2.1 +502 -0
- package/third_party/notices/ffmpeg-adaptation/LICENSE.md +127 -0
- package/third_party/notices/freetype/LICENSE.TXT +46 -0
- package/third_party/notices/freetype/docs/FTL.TXT +169 -0
- package/third_party/notices/freetype/docs/GPLv2.TXT +340 -0
- package/third_party/notices/fribidi/COPYING +504 -0
- package/third_party/notices/harfbuzz/COPYING +42 -0
- package/third_party/notices/libass/COPYING +15 -0
- package/third_party/notices/libplacebo/LICENSE +458 -0
- package/third_party/notices/libxml2/Copyright +23 -0
- package/third_party/notices/libxml2/dict.c.notice +17 -0
- package/third_party/notices/libxml2/list.c.notice +16 -0
- package/third_party/notices/mpv/Copyright +78 -0
- package/third_party/notices/mpv/LICENSE.GPL +338 -0
- package/third_party/notices/mpv/LICENSE.LGPL +501 -0
- package/third_party/notices/vulkan-headers/LICENSE.txt +202 -0
- package/third_party/notices/zimg/COPYING +14 -0
- package/third_party/notices/zlib/LICENSE +22 -0
- package/third_party/notices.json +166 -0
- package/toolchain.lock.json +204 -0
- package/web/audio-worklet.js +45 -0
- package/web/cheap-mp4-probe.js +82 -0
- package/web/engine-adaptation/manifest.json +75 -0
- package/web/engine-adaptation/remux.mjs +2 -0
- package/web/engine-adaptation/remux.wasm +0 -0
- package/web/engine-ass/manifest.json +55 -0
- package/web/engine-ass/subtitles.mjs +2 -0
- package/web/engine-ass/subtitles.wasm +0 -0
- package/web/engine-hybrid/player.mjs +2 -0
- package/web/engine-hybrid/player.wasm +0 -0
- package/web/engine-remux/remux.mjs +2 -0
- package/web/engine-remux/remux.wasm +0 -0
- package/web/engine-software-full/player.mjs +2 -0
- package/web/engine-software-full/player.wasm +0 -0
- package/web/file-reader.js +34 -0
- package/web/filter-retained-engine-worker.js +292 -0
- package/web/generated/index.d.ts +6 -0
- package/web/generated/index.js +4 -0
- package/web/generated/internal/assets.d.ts +2 -0
- package/web/generated/internal/assets.js +7 -0
- package/web/generated/internal/backend.d.ts +27 -0
- package/web/generated/internal/backend.js +1 -0
- package/web/generated/internal/errors.d.ts +13 -0
- package/web/generated/internal/errors.js +54 -0
- package/web/generated/internal/native-ass.d.ts +38 -0
- package/web/generated/internal/native-ass.js +206 -0
- package/web/generated/internal/native-player.d.ts +95 -0
- package/web/generated/internal/native-player.js +502 -0
- package/web/generated/internal/playback-plans.d.ts +526 -0
- package/web/generated/internal/playback-plans.js +110 -0
- package/web/generated/internal/selection.d.ts +45 -0
- package/web/generated/internal/selection.js +45 -0
- package/web/generated/internal/state.d.ts +8 -0
- package/web/generated/internal/state.js +56 -0
- package/web/generated/internal/wasm-player.d.ts +127 -0
- package/web/generated/internal/wasm-player.js +372 -0
- package/web/generated/player/index.d.ts +198 -0
- package/web/generated/player/index.js +873 -0
- package/web/generated/player/interaction.d.ts +6 -0
- package/web/generated/player/interaction.js +14 -0
- package/web/generated/player/styles.d.ts +1 -0
- package/web/generated/player/styles.js +86 -0
- package/web/generated/types.d.ts +227 -0
- package/web/generated/types.js +2 -0
- package/web/generated/unified-player.d.ts +129 -0
- package/web/generated/unified-player.js +1074 -0
- package/web/io-worker.js +65 -0
- package/web/native-ass-worker.js +26 -0
- package/web/native-remux-player.js +301 -0
- package/web/native-remux-source-worker.js +25 -0
- package/web/native-remux-worker.js +46 -0
- package/web/range-reader.js +130 -0
- package/web/remux-packaging.js +11 -0
- package/web/resource-loader.js +173 -0
- package/web/retained-decoder-worker.js +200 -0
- package/web/retained-video.js +15 -0
- package/web/segmented-subtitles.js +37 -0
- package/web/software-full-engine-worker.js +234 -0
- package/web/source-probe.js +28 -0
- package/web/split-mp4.js +159 -0
- package/web/streaming-manifest.js +136 -0
- package/web/subtitle-overlay.js +26 -0
- package/web/video-codec-config.js +59 -0
- package/web/vod-manifest.js +54 -0
package/web/io-worker.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {LocalFileReader} from './file-reader.js';
|
|
2
|
+
import {RangeReader} from './range-reader.js';
|
|
3
|
+
let reader,resources,header,bytes,view,extra,urlBytes,busy=false,timer,stopped=false;
|
|
4
|
+
const refreshes=new Map();
|
|
5
|
+
let nativeEpoch;
|
|
6
|
+
function synchronizeEpoch(){const current=Atomics.load(header,3);if(current!==nativeEpoch){nativeEpoch=current;reader?.beginEpoch();resources?.beginEpoch();}}
|
|
7
|
+
self.onmessage=async({data})=>{
|
|
8
|
+
try{
|
|
9
|
+
if(data.type==='init'){
|
|
10
|
+
header=new Int32Array(data.memory,data.pointer,16);bytes=new Uint8Array(data.memory,data.pointer+64,262144);view=new DataView(data.memory,data.pointer,64);
|
|
11
|
+
const refresh=data.canRefresh?(resource)=>new Promise((resolve,reject)=>{const id=crypto.randomUUID();const timeout=setTimeout(()=>{refreshes.delete(id);reject(Error('Authorization refresh timed out'));},5000);refreshes.set(id,{resolve,reject,timeout});postMessage({type:'refresh',id,resource});}):undefined;
|
|
12
|
+
if(!data.file&&data.options.format&&data.options.format!=='file'){
|
|
13
|
+
if(!['hls','dash'].includes(data.options.format))throw Error('Unknown remote source format');
|
|
14
|
+
const {ResourceLoader}=await import('./resource-loader.js');
|
|
15
|
+
if(stopped)return;
|
|
16
|
+
resources=new ResourceLoader(data.options,refresh);
|
|
17
|
+
extra=new DataView(data.memory,data.pointer+64+262144,24);
|
|
18
|
+
urlBytes=new Uint8Array(data.memory,data.pointer+64+262144+24,4096);
|
|
19
|
+
const info=await resources.open(data.options.url,{manifest:true});
|
|
20
|
+
postMessage({type:'ready',info:{...info,resource:info.id}});startPump();return;
|
|
21
|
+
}
|
|
22
|
+
reader=data.file?new LocalFileReader(data.file):new RangeReader(data.options,refresh);
|
|
23
|
+
const info=await reader.open();postMessage({type:'ready',info});startPump();
|
|
24
|
+
}else if(data.type==='epoch'){synchronizeEpoch();}
|
|
25
|
+
else if(data.type==='close'){stopped=true;if(header)Atomics.notify(header,0);reader?.close();resources?.close();clearInterval(timer);for(const r of refreshes.values()){clearTimeout(r.timeout);r.reject(Error('Closed'));}refreshes.clear();postMessage({type:'closed'});}
|
|
26
|
+
else if(data.type==='refreshed'){const r=refreshes.get(data.id);if(r){clearTimeout(r.timeout);refreshes.delete(data.id);data.error?r.reject(Error('Authorization refresh failed')):r.resolve(data.update);}}
|
|
27
|
+
}catch(error){postMessage({type:'error',message:'Source transport: '+error.message});}
|
|
28
|
+
};
|
|
29
|
+
async function pump(){
|
|
30
|
+
if(busy||(Atomics.load(header,0)&7)!==1)return;
|
|
31
|
+
synchronizeEpoch();busy=true;const requestState=Atomics.load(header,0);const serial=Atomics.load(header,1),epoch=Atomics.load(header,3);
|
|
32
|
+
try{
|
|
33
|
+
let output=new Uint8Array(),result=0,opened;
|
|
34
|
+
if(resources){
|
|
35
|
+
const operation=Atomics.load(header,15),id=extra.getInt32(0,true);
|
|
36
|
+
if(operation===1){
|
|
37
|
+
const zero=urlBytes.indexOf(0);if(zero<0)throw Error('Resource URL exceeds mailbox capacity');
|
|
38
|
+
const url=new TextDecoder('utf-8',{fatal:true}).decode(urlBytes.slice(0,zero));
|
|
39
|
+
const start=extra.getBigInt64(8,true),end=extra.getBigInt64(16,true);
|
|
40
|
+
opened=await resources.open(url,start<0?{}:{start,end});result=opened.id;
|
|
41
|
+
}else if(operation===2){output=resources.read(id,view.getBigUint64(32,true),Atomics.load(header,4));result=output.length;}
|
|
42
|
+
else if(operation===3)resources.closeHandle(id);
|
|
43
|
+
else throw Error('Invalid resource operation');
|
|
44
|
+
}else{output=await reader.read(view.getBigUint64(32,true),Atomics.load(header,4));result=output.length;}
|
|
45
|
+
if((Atomics.load(header,0)&7)!==1||Atomics.load(header,1)!==serial||Atomics.load(header,3)!==epoch){if(opened)resources.closeHandle(opened.id);return;}
|
|
46
|
+
if(opened)view.setBigInt64(40,BigInt(opened.size),true);
|
|
47
|
+
bytes.set(output);Atomics.store(header,5,result);if(Atomics.compareExchange(header,0,requestState,requestState+1)===requestState)Atomics.notify(header,0);
|
|
48
|
+
}catch(error){
|
|
49
|
+
if(Atomics.load(header,0)===requestState&&Atomics.load(header,1)===serial&&Atomics.load(header,3)===epoch){Atomics.store(header,5,-1);if(Atomics.compareExchange(header,0,requestState,requestState+2)===requestState)Atomics.notify(header,0);if(error.name!=='AbortError')postMessage({type:'error',message:'Source transport: '+error.message});}
|
|
50
|
+
}finally{busy=false;postMessage({type:'stats',stats:{...(resources??reader).stats,size:String(reader?.total??0),reads:Atomics.load(header,12),seeks:Atomics.load(header,13),interruptions:Atomics.load(header,14)}});}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function startPump(){
|
|
54
|
+
nativeEpoch=Atomics.load(header,3);
|
|
55
|
+
if(typeof Atomics.waitAsync!=='function'){timer=setInterval(pump,2);return;}
|
|
56
|
+
void (async()=>{
|
|
57
|
+
while(!stopped){
|
|
58
|
+
await pump();
|
|
59
|
+
if(stopped)break;
|
|
60
|
+
const state=Atomics.load(header,0);
|
|
61
|
+
if((state&7)===1)continue;
|
|
62
|
+
await Atomics.waitAsync(header,0,state,1000).value;
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Owns one pinned libass instance. No transport, media decoder or independent clock.
|
|
2
|
+
let engine;
|
|
3
|
+
function bytesCall(name,bytes,extra=[]){
|
|
4
|
+
const p=engine._malloc(bytes.byteLength+1);if(!p)throw Error('Subtitle allocation failed');
|
|
5
|
+
try{engine.HEAPU8.set(bytes,p);engine.HEAPU8[p+bytes.byteLength]=0;return engine.ccall(name,'number',[...extra.map(()=> 'string'),'number','number'],[...extra,p,bytes.byteLength]);}finally{engine._free(p);}
|
|
6
|
+
}
|
|
7
|
+
let chain=Promise.resolve();
|
|
8
|
+
onmessage=({data})=>{chain=chain.then(async()=>{
|
|
9
|
+
try{
|
|
10
|
+
if(data.type==='init'){
|
|
11
|
+
if(engine)throw Error('Subtitle worker already initialized');
|
|
12
|
+
const {default:create}=await import('./engine-ass/subtitles.mjs');engine=await create();
|
|
13
|
+
if(engine._subtitle_api_version?.()!==2)throw Error('Subtitle runtime interface mismatch; use matching worker and Wasm assets');
|
|
14
|
+
if(engine._subtitle_init()<0)throw Error('libass initialization failed');
|
|
15
|
+
if(data.fonts.length>17)throw Error('Font count exceeded');
|
|
16
|
+
for(const f of data.fonts)if(bytesCall('subtitle_font',new Uint8Array(f.bytes),[f.name])<0)throw Error('Font budget exceeded');
|
|
17
|
+
}else if(data.type==='load'){
|
|
18
|
+
if(bytesCall('subtitle_load',new Uint8Array(data.bytes))<0)throw Error('Invalid ASS or subtitle budget exceeded');
|
|
19
|
+
}else if(data.type==='render'){
|
|
20
|
+
const size=engine._subtitle_render(data.seconds,data.width,data.height,+data.force,data.sourceWidth,data.sourceHeight);if(size===-2){postMessage({id:data.id,unchanged:true,tiles:[],size:0});return;}if(size<0)throw Error('Subtitle render budget exceeded');
|
|
21
|
+
const tiles=engine.tiles;engine.tiles=[];
|
|
22
|
+
postMessage({id:data.id,tiles,size},tiles.map(t=>t.bytes.buffer));return;
|
|
23
|
+
}else throw Error('Unknown subtitle operation');
|
|
24
|
+
postMessage({id:data.id});
|
|
25
|
+
}catch(error){postMessage({id:data.id,error:String(error)});}
|
|
26
|
+
});};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Native execution plan. MSE stays in the window; demux/mux and
|
|
2
|
+
// bounded source reads use separate workers, so synchronous Wasm AVIO cannot
|
|
3
|
+
// block the event loop responsible for completing its reads.
|
|
4
|
+
const byteLength=value=>Array.isArray(value)?value.reduce((n,b)=>n+b.byteLength,0):value?.byteLength||0;
|
|
5
|
+
export function windowedBrowserSupported(ua=globalThis.navigator?.userAgent??''){return /Chrome\//.test(ua)&&!/Firefox|Edg\/|OPR\/|Android|Mobile/.test(ua);}
|
|
6
|
+
export class RemuxPlayer {
|
|
7
|
+
constructor(video,{bufferedSeeks=false,audioAdaptation}={}){
|
|
8
|
+
this.audioAdaptation=audioAdaptation;
|
|
9
|
+
this.bufferedSeeks=bufferedSeeks&&typeof video.requestVideoFrameCallback==='function';
|
|
10
|
+
this.video=video;this.timelineBias=1;this.generation=0;this.stopped=false;this.stats={fragments:[],generatedBytes:0,discardedBytes:0,peakQueueDepth:0,bufferedBytesUpperBound:0,peakBufferedBytesUpperBound:0,peakBufferedSeconds:0,seeks:[],sessions:[],errors:[],workers:0,recoveries:[],gapSkips:[]};
|
|
11
|
+
this.segments=[];this.sourceStats={};this.remuxStats={};this.timer=setInterval(()=>this.pump(),50);
|
|
12
|
+
}
|
|
13
|
+
async open(source,target=0){
|
|
14
|
+
if(this.source&&(this.source.file!==source.file||this.source.options?.url!==source.options?.url)){this.identity=undefined;this.duration=undefined;}
|
|
15
|
+
this.recoveryAttempts=0;this.source=source;return this.restart(target);
|
|
16
|
+
}
|
|
17
|
+
async restart(target){
|
|
18
|
+
if(this.stopped)throw Error('Remux player is destroyed');
|
|
19
|
+
if(!Number.isFinite(target)||target<0||(this.duration!==undefined&&target>=this.duration))throw Error('Seek target out of range');
|
|
20
|
+
const serial=(this.restartSerial??0)+1;this.restartSerial=serial;this.starting=true;
|
|
21
|
+
const rejected=new Set();
|
|
22
|
+
try{
|
|
23
|
+
for(;;){
|
|
24
|
+
this.packagingFailure=false;
|
|
25
|
+
try{return await this.start(target,rejected);}catch(error){
|
|
26
|
+
if(serial!==this.restartSerial||this.stopped)throw error;
|
|
27
|
+
this.stopWorkers();
|
|
28
|
+
if(!this.packagingFailure||!this.mime||rejected.has(this.mime)||rejected.size>=1)throw error;
|
|
29
|
+
rejected.add(this.mime);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}finally{if(serial===this.restartSerial)this.starting=false;}
|
|
33
|
+
}
|
|
34
|
+
async start(target,rejected=new Set()){
|
|
35
|
+
if(this.stopped)throw Error('Remux player is destroyed');
|
|
36
|
+
const begun=performance.now(),generation=++this.generation;this.stopWorkers();
|
|
37
|
+
this.windowed=false;this.headerAccepted=false;this.presentationFloor=0;this.pulling=false;this.pullId=0;this.resumingWindow=false;this.primeVideo=false;this.sbs=[];this.receipts=new Map();this.pendingUpdates=new Set();this.lastEvictions=[];
|
|
38
|
+
this.failedGeneration=undefined;this.failureError=undefined;this.stats.errors=[];this.stats.seeks.push({target,started:begun});if(this.stats.seeks.length>64)this.stats.seeks.shift();this.target=target;this.targetReady=false;this.frames=[];this.muxedFrames=false;this.lastEviction=-Infinity;this.raps=[];this.busy=true;this.eof=false;this.pending=null;this.receipt=null;this.segments=[];this.sourceStats={};this.remuxStats={};
|
|
39
|
+
this.video.pause();this.video.removeAttribute('src');this.video.load();if(this.objectURL)URL.revokeObjectURL(this.objectURL);
|
|
40
|
+
this.media=new MediaSource();this.objectURL=URL.createObjectURL(this.media);this.video.src=this.objectURL;
|
|
41
|
+
await new Promise((resolve,reject)=>{
|
|
42
|
+
const media=this.media;
|
|
43
|
+
const finish=error=>{clearTimeout(timer);media.removeEventListener('sourceopen',opened);if(this.cancelWait===cancel)this.cancelWait=null;error?reject(error):resolve();};
|
|
44
|
+
const opened=()=>finish(),cancel=error=>finish(error);
|
|
45
|
+
const timer=setTimeout(()=>finish(Error('MSE sourceopen timeout')),10000);
|
|
46
|
+
this.cancelWait=cancel;media.addEventListener('sourceopen',opened,{once:true});
|
|
47
|
+
});
|
|
48
|
+
if(generation!==this.generation)throw new DOMException('Superseded','AbortError');
|
|
49
|
+
this.mailbox=new SharedArrayBuffer(64+262144);this.sourceWorker=new Worker(new URL('./native-remux-source-worker.js',import.meta.url),{type:'module'});this.stats.workers++;
|
|
50
|
+
const ready=await new Promise((resolve,reject)=>{
|
|
51
|
+
const timer=setTimeout(()=>finish(Error('Remux source initialization timed out')),10000);
|
|
52
|
+
let settled=false;
|
|
53
|
+
const finish=(error,data)=>{if(settled)return;settled=true;clearTimeout(timer);if(this.cancelWait===cancel)this.cancelWait=null;error?reject(error):resolve(data);};
|
|
54
|
+
const cancel=error=>finish(error);this.cancelWait=cancel;this.watchWorker(this.sourceWorker,generation,'source');this.sourceWorker.onmessage=({data})=>{if(generation!==this.generation||this.stopped||this.failedGeneration===generation)return;if(data.type==='refresh'){Promise.resolve().then(()=>{if(!this.source.refreshAuthorization)throw Error('Authorization refresh unavailable');return this.source.refreshAuthorization(data.resource);}).then(update=>{if(generation===this.generation)this.sourceWorker?.postMessage({type:'refreshed',update});},error=>{if(generation===this.generation)this.sourceWorker?.postMessage({type:'refreshed',error:String(error)});});}if(data.type==='ready')finish(null,data);if(data.type==='stats')this.sourceStats=data.stats;if(data.type==='error'){const message='Source transport: '+data.message;finish(Error(message));this.fail(message);}};
|
|
55
|
+
const {refreshAuthorization,...source}=this.source;this.sourceWorker.postMessage({type:'init',mailbox:this.mailbox,...source,identity:this.identity});
|
|
56
|
+
});
|
|
57
|
+
if(generation!==this.generation)throw new DOMException('Superseded','AbortError');
|
|
58
|
+
this.identity??=ready.identity;this.total=ready.size;this.worker=new Worker(new URL('./native-remux-worker.js',import.meta.url),{type:'module'});this.stats.workers++;this.watchWorker(this.worker,generation,'mux');
|
|
59
|
+
this.cancelWait=null;const session={generation,target,sourceSize:ready.size,firstPlayableMs:null,firstPlayableSourceBytes:null};this.stats.sessions.push(session);if(this.stats.sessions.length>64)this.stats.sessions.shift();
|
|
60
|
+
this.worker.onmessage=({data})=>{
|
|
61
|
+
if(generation!==this.generation||this.stopped||this.failedGeneration===generation){this.stats.discardedBytes+=byteLength(data.buffers??data.buffer);return;}
|
|
62
|
+
if(data.type==='error'){this.fail(data.message);return;}
|
|
63
|
+
if(data.type==='log'){(this.logs??=[]).push(data.message);if(this.logs.length>32)this.logs.shift();return;}
|
|
64
|
+
if(data.type==='fragment'&&(!this.pulling||data.id!==this.pullId)){this.fail('Unexpected remux fragment operation');return;}
|
|
65
|
+
if(data.type==='ready'){if(this.headerAccepted){this.fail('Duplicate remux initialization');return;}this.headerAccepted=true;}
|
|
66
|
+
if(data.presentationFrames){this.muxedFrames=true;this.frames.push(...data.presentationFrames.map(([pts,duration])=>[pts-this.timelineBias,duration]));}else if(data.frames)this.frames.push(...data.frames);if(this.frames.length>4096)this.frames.splice(0,this.frames.length-4096);
|
|
67
|
+
if(data.stats)this.remuxStats=data.stats;this.stats.generatedBytes+=byteLength(data.buffers??data.buffer);
|
|
68
|
+
if(data.type==='negotiate'){
|
|
69
|
+
if(data.windowed&&!windowedBrowserSupported()){this.fail('Long unequal-track Native adaptation is unsupported in this browser; use Hybrid','UNSUPPORTED_TIMELINE');return;}
|
|
70
|
+
session.packaging=[];
|
|
71
|
+
for(const candidate of data.candidates){
|
|
72
|
+
const attempt={...candidate};session.packaging.push(attempt);
|
|
73
|
+
if(rejected.has(candidate.mime)){attempt.rejected='Initialization append failed';continue;}
|
|
74
|
+
if(!MediaSource.isTypeSupported(candidate.mime)){attempt.rejected='MSE type unsupported';continue;}
|
|
75
|
+
try{
|
|
76
|
+
const mimes=data.windowed?data.lanes:[candidate.mime];
|
|
77
|
+
if(!mimes?.length||mimes.some(m=>!MediaSource.isTypeSupported(m)))throw Error('Unsupported selected track packaging');
|
|
78
|
+
this.sbs=[];for(const mime of mimes)this.sbs.push(this.media.addSourceBuffer(mime));this.sb=this.sbs[0];
|
|
79
|
+
this.windowed=!!data.windowed;this.trackBounds=data.trackBounds;this.presentationFloor=this.windowed&&target>=Math.min(this.trackBounds.videoEnd,this.trackBounds.audioEnd)?Math.max(0,target-.5):0;this.primeVideo=this.windowed&&target>=this.trackBounds.videoEnd&&this.trackBounds.videoEnd<this.trackBounds.audioEnd;
|
|
80
|
+
}catch(error){for(const sb of this.sbs)this.media.removeSourceBuffer(sb);this.sbs=[];attempt.rejected=String(error);continue;}
|
|
81
|
+
attempt.selected=true;this.mime=candidate.mime;
|
|
82
|
+
this.worker.postMessage({type:'select-container',container:candidate.container});return;
|
|
83
|
+
}
|
|
84
|
+
this.fail('Unsupported MSE packaging for selected codecs');return;
|
|
85
|
+
}
|
|
86
|
+
if(data.type==='ready'){
|
|
87
|
+
try{
|
|
88
|
+
if(target<0||target>=data.duration)throw Error('Seek target out of range');
|
|
89
|
+
if(!MediaSource.isTypeSupported(data.mime))throw Error(`Unsupported MSE ${data.mime}`);
|
|
90
|
+
this.duration=data.duration;this.tracks=data.tracks;this.mime=data.mime;this.media.duration=data.duration+this.timelineBias;
|
|
91
|
+
for(const sb of this.sbs){
|
|
92
|
+
sb.mode='segments';sb.timestampOffset=0;
|
|
93
|
+
sb.addEventListener('updateend',()=>this.updateFinished(sb,generation));
|
|
94
|
+
sb.addEventListener('error',()=>{if(generation===this.generation){this.packagingFailure=true;this.fail('MSE SourceBuffer error');}});
|
|
95
|
+
}
|
|
96
|
+
try{this.append(data.buffers??[data.buffer]);}catch(error){this.packagingFailure=error.name!=='QuotaExceededError';throw error;}
|
|
97
|
+
}catch(e){this.fail(String(e));}
|
|
98
|
+
}else if(data.type==='fragment'){
|
|
99
|
+
this.pulling=false;
|
|
100
|
+
|
|
101
|
+
this.raps.push(...data.raps);if(this.raps.length>256)this.raps.splice(0,this.raps.length-256);
|
|
102
|
+
this.pending=data.buffers??[data.buffer];this.eof=!data.more;this.busy=false;this.stats.peakQueueDepth=Math.max(this.stats.peakQueueDepth,1);this.pump();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
this.worker.postMessage({type:'init',mailbox:this.mailbox,size:ready.size,target,audioAdaptation:this.audioAdaptation,videoTrack:this.source.videoTrack,audioTrack:this.source.audioTrack});
|
|
106
|
+
await new Promise((resolve,reject)=>{
|
|
107
|
+
const deadline=performance.now()+20000;const check=()=>{
|
|
108
|
+
if(generation!==this.generation){reject(new DOMException('Superseded','AbortError'));return;}
|
|
109
|
+
if(this.stats.errors.length){reject(this.failureError??Error(this.stats.errors.at(-1)));return;}
|
|
110
|
+
// Initial decoder preroll can leave a short leading gap in the playable range.
|
|
111
|
+
if(!this.primeVideo&&(!this.windowed||!this.busy&&!this.pulling&&!this.pending)&&this.hasStartupCoverage()){
|
|
112
|
+
this.acceptedSource=this.source;this.acceptedGeneration=generation;this.targetReady=true;this.video.currentTime=target+this.timelineBias;
|
|
113
|
+
session.firstPlayableMs=performance.now()-begun;session.firstPlayableSourceBytes=this.sourceStats.fetchedBytes;
|
|
114
|
+
session.generatedBytes=this.remuxStats.generatedBytes;session.fetchedBytesAtLeastSourceSize=(this.sourceStats.fetchedBytes>=ready.size);
|
|
115
|
+
resolve();return;
|
|
116
|
+
}
|
|
117
|
+
if(performance.now()>deadline){reject(Error('Remux target buffer timeout'));return;}setTimeout(check,20);
|
|
118
|
+
};check();
|
|
119
|
+
});
|
|
120
|
+
return session;
|
|
121
|
+
}
|
|
122
|
+
hasStartupCoverage(){
|
|
123
|
+
const target=this.target,margin=this.windowed?Math.min(.02,Math.max(0,this.duration-target)/2):.02;
|
|
124
|
+
return this.ranges().some(([a,b])=>a<=target+.5&&b>target+margin);
|
|
125
|
+
}
|
|
126
|
+
contains(t){t+=this.timelineBias;const b=this.sb?.buffered;if(!b)return false;for(let i=0;i<b.length;i++)if(t>=b.start(i)&&t<b.end(i))return true;return false;}
|
|
127
|
+
ranges(){const b=this.windowed?this.video.buffered:this.sb?.buffered;return b?Array.from({length:b.length},(_,i)=>[(this.windowed?Math.max(b.start(i)-this.timelineBias,this.presentationFloor??0):b.start(i)-this.timelineBias),b.end(i)-this.timelineBias]).filter(([a,b])=>b>a):[];}
|
|
128
|
+
updateFinished(sb,generation){
|
|
129
|
+
if(generation!==this.generation||!this.pendingUpdates.delete(sb))return;
|
|
130
|
+
const receipt=this.receipts.get(sb);if(receipt){receipt.end=sb.buffered.length?sb.buffered.end(sb.buffered.length-1)-this.timelineBias:Infinity;this.receipts.delete(sb);}
|
|
131
|
+
if(this.pendingUpdates.size||this.sbs.some(s=>s.updating))return;
|
|
132
|
+
this.busy=false;
|
|
133
|
+
if(this.windowed&&this.media.readyState==='open'&&this.sbs.every(s=>s.buffered.length)){
|
|
134
|
+
try{this.media.endOfStream();}catch(error){this.fail(String(error));return;}
|
|
135
|
+
}
|
|
136
|
+
if(this.primeVideo)this.primeLastVideo(generation);
|
|
137
|
+
this.resumeWindow();
|
|
138
|
+
this.pump();
|
|
139
|
+
}
|
|
140
|
+
append(buffers){
|
|
141
|
+
this.busy=false;
|
|
142
|
+
buffers.forEach((buffer,lane)=>{
|
|
143
|
+
if(!buffer.byteLength)return;
|
|
144
|
+
const sb=this.sbs[lane];if(!sb)throw Error('Unexpected fragment lane');
|
|
145
|
+
this.busy=true;const receipt={lane,bytes:buffer.byteLength,end:Infinity};this.segments.push(receipt);this.receipts.set(sb,receipt);this.pendingUpdates.add(sb);
|
|
146
|
+
this.stats.fragments.push({lane,bytes:buffer.byteLength,at:performance.now(),generation:this.generation});if(this.stats.fragments.length>256)this.stats.fragments.shift();sb.appendBuffer(buffer);
|
|
147
|
+
});
|
|
148
|
+
if(!this.busy)this.pump();
|
|
149
|
+
}
|
|
150
|
+
expectedVideoFrame(target){return this.frames?.filter(([pts])=>pts<=target+.000001).sort((a,b)=>a[0]-b[0]).at(-1)?.[0];}
|
|
151
|
+
matchesVideoFrame(target,mediaTime){
|
|
152
|
+
if(!this.muxedFrames)return undefined;
|
|
153
|
+
const epsilon=.000001;
|
|
154
|
+
return this.frames.some(([pts,duration])=>target>=pts-epsilon&&target<pts+duration+epsilon&&mediaTime>=pts+this.timelineBias-epsilon&&mediaTime<=target+this.timelineBias+epsilon);
|
|
155
|
+
}
|
|
156
|
+
primeLastVideo(generation){
|
|
157
|
+
if(this.primeFrame||!this.primeVideo||this.sbs.some(s=>s.updating))return;
|
|
158
|
+
const b=this.sb.buffered;
|
|
159
|
+
if(!b.length||b.end(b.length-1)-this.timelineBias<this.trackBounds.videoEnd-.002)return;
|
|
160
|
+
const target=b.end(b.length-1)-.001,expected=this.expectedVideoFrame(this.trackBounds.videoEnd);
|
|
161
|
+
if(expected===undefined)return;
|
|
162
|
+
const a=this.sbs[1].buffered;if(!Array.from({length:a.length},(_,i)=>[a.start(i),a.end(i)]).some(([start,end])=>target>=start&&target<end))return;
|
|
163
|
+
this.busy=true;if(this.media.readyState==='open')this.media.endOfStream();
|
|
164
|
+
let presented;
|
|
165
|
+
const clean=()=>{clearTimeout(this.primeTimeout);this.video.removeEventListener('seeked',complete);if(this.primeFrame)this.video.cancelVideoFrameCallback(this.primeFrame);this.primeFrame=0;this.cancelPrime=null;};
|
|
166
|
+
const complete=()=>{
|
|
167
|
+
if(generation!==this.generation||this.stopped||!presented||this.video.seeking||Math.abs(this.video.currentTime-target)>.002)return;
|
|
168
|
+
clean();this.primeVideo=false;
|
|
169
|
+
(this.stats.tailPrimes??=[]).push({generation,frame:presented.mediaTime-this.timelineBias,target:this.target});if(this.stats.tailPrimes.length>64)this.stats.tailPrimes.shift();
|
|
170
|
+
this.busy=false;this.pump();
|
|
171
|
+
};
|
|
172
|
+
const check=(_,metadata)=>{
|
|
173
|
+
if(generation!==this.generation||this.stopped)return;
|
|
174
|
+
if(metadata.mediaTime>=expected+this.timelineBias-.001&&metadata.mediaTime<=target+.001&&Math.abs(this.video.currentTime-target)<.002)presented=metadata;
|
|
175
|
+
complete();if(this.primeVideo)this.primeFrame=this.video.requestVideoFrameCallback(check);
|
|
176
|
+
};
|
|
177
|
+
this.cancelPrime=clean;this.video.addEventListener('seeked',complete);
|
|
178
|
+
this.primeTimeout=setTimeout(()=>{if(generation===this.generation)this.fail('Completed video preroll did not present a frame');},10000);
|
|
179
|
+
this.primeFrame=this.video.requestVideoFrameCallback(check);this.video.currentTime=target;
|
|
180
|
+
}
|
|
181
|
+
resumeWindow(){
|
|
182
|
+
if(!this.windowed||!this.recoveryPlaying||!this.video.paused||this.starting||this.stopped||this.video.seeking||this.resumingWindow)return;
|
|
183
|
+
if(this.video.ended){
|
|
184
|
+
if(this.playbackEnded)return;
|
|
185
|
+
const at=this.video.currentTime,b=this.video.buffered;
|
|
186
|
+
if(!b.length||b.end(b.length-1)<=at+.05)return;
|
|
187
|
+
// play() at an ended media element rewinds it. Clear the internal window-end
|
|
188
|
+
// state with a seek to the same position after new real coverage arrives.
|
|
189
|
+
this.video.currentTime=at;return;
|
|
190
|
+
}
|
|
191
|
+
const generation=this.generation;this.resumingWindow=true;
|
|
192
|
+
void this.video.play().catch(error=>{if(generation===this.generation&&this.recoveryPlaying&&!this.stopped)this.fail(String(error));}).finally(()=>{if(generation===this.generation)this.resumingWindow=false;});
|
|
193
|
+
}
|
|
194
|
+
pump(){
|
|
195
|
+
if(this.stopped||!this.sb||this.busy||this.pulling||(this.sbs??[this.sb]).some(s=>s.updating)||!['open',...(this.windowed?['ended']:[])].includes(this.media.readyState))return;
|
|
196
|
+
try{
|
|
197
|
+
if(this.windowed&&this.targetReady&&!this.recoveryPlaying)return;
|
|
198
|
+
this.resumeWindow();
|
|
199
|
+
const now=this.targetReady?Math.max(this.video.currentTime-this.timelineBias,this.target):this.target,ranges=this.ranges();
|
|
200
|
+
const seconds=ranges.reduce((s,[a,b])=>s+b-a,0);this.stats.peakBufferedSeconds=Math.max(this.stats.peakBufferedSeconds,seconds);
|
|
201
|
+
// Evict old complete intervals. Retain three seconds behind playback; browser
|
|
202
|
+
// codec dependencies may keep internal resources beyond the coded ranges.
|
|
203
|
+
// MSE removal can extend through dependent frames to the next RAP. Never
|
|
204
|
+
// remove through the GOP currently decoding; evict at known source RAPs.
|
|
205
|
+
if(this.windowed&&this.targetReady){
|
|
206
|
+
for(let lane=0;lane<this.sbs.length;lane++){
|
|
207
|
+
const sb=this.sbs[lane],end=lane?this.trackBounds.audioEnd:this.trackBounds.videoEnd;
|
|
208
|
+
const limit=Math.min(now-3,end-.5),cut=lane?limit:this.raps.filter(t=>t<=limit).at(-1);
|
|
209
|
+
if(cut!==undefined&&sb.buffered.length&&sb.buffered.start(0)-this.timelineBias<cut-.001&&cut>(this.lastEvictions[lane]??-Infinity)){
|
|
210
|
+
this.busy=true;this.lastEvictions[lane]=cut;this.pendingUpdates.add(sb);sb.remove(0,cut+this.timelineBias-.00001);this.segments=this.segments.filter(s=>s.lane!==lane||s.end>cut);return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const cut=this.windowed?undefined:this.raps.filter(t=>t<=now-3).at(-1);
|
|
215
|
+
if(this.targetReady&&cut!==undefined&&ranges.length&&ranges[0][0]<cut-.001&&cut>this.lastEviction){this.busy=true;this.lastEviction=cut;this.pendingUpdates?.add(this.sb);this.sb.remove(0,cut+this.timelineBias-.00001);this.segments=this.segments.filter(s=>s.end>cut);return;}
|
|
216
|
+
const bufferedBytes=this.segments.reduce((s,v)=>s+v.bytes,0);this.stats.bufferedBytesUpperBound=bufferedBytes;this.stats.peakBufferedBytesUpperBound=Math.max(this.stats.peakBufferedBytesUpperBound,bufferedBytes);
|
|
217
|
+
if(this.pending){
|
|
218
|
+
const buffers=this.pending;this.pending=null;
|
|
219
|
+
if(byteLength(buffers)){this.append(buffers);return;}
|
|
220
|
+
}
|
|
221
|
+
// Account for the same short leading codec-preroll gap admitted by start().
|
|
222
|
+
// Otherwise a sub-millisecond gap can look like zero buffered data while paused
|
|
223
|
+
// and drain the entire source before the media element advances into the range.
|
|
224
|
+
// Some valid edits leave a short hole between MSE coded ranges. Advance
|
|
225
|
+
// only while playing at the end of a range, within a half-second budget.
|
|
226
|
+
const next=ranges.find(([a])=>a>now+.001);
|
|
227
|
+
if(this.targetReady&&!this.video.paused&&this.video.readyState<3&&next&&next[0]-now<=.5){
|
|
228
|
+
this.stats.gapSkips.push({from:now,to:next[0]});if(this.stats.gapSkips.length>64)this.stats.gapSkips.shift();
|
|
229
|
+
this.video.currentTime=next[0]+this.timelineBias;
|
|
230
|
+
}
|
|
231
|
+
const ahead=ranges.find(([a,b])=>a<=now+0.5&&now<=b)?.[1]-now||0;
|
|
232
|
+
if(ahead<5&&ranges.at(-1)?.[1]>now+12){this.fail('Remux timeline gap exceeds forward buffer budget');return;}
|
|
233
|
+
if(this.eof&&!this.pending){if(this.audioAdaptation&&this.remuxStats.adaptation?.sourceEnd>0){this.duration=this.remuxStats.adaptation.sourceEnd;if(!this.windowed)this.media.duration=this.duration+this.timelineBias;}if(this.media.readyState==='open')this.media.endOfStream();return;}
|
|
234
|
+
const preparedAhead=this.audioAdaptation?Math.max(0,(this.remuxStats.adaptation?.sourceEnd??now)-now):0;
|
|
235
|
+
// MSE exposes the intersection of selected tracks. Never encode an entire
|
|
236
|
+
// longer track merely to make a shorter track's buffered range advance.
|
|
237
|
+
if(!this.eof&&preparedAhead>=5&&ahead<.25&&!this.video.paused){this.fail('Adapted track timelines cannot progress within the preparation budget; use Hybrid');return;}
|
|
238
|
+
if(this.windowed&&!this.targetReady&&!this.primeVideo&&this.hasStartupCoverage())return;
|
|
239
|
+
if(!this.eof&&ahead<5&&preparedAhead<5&&bufferedBytes<12*1024*1024){this.busy=true;this.pulling=true;this.worker.postMessage({type:'next',id:this.pullId=(this.pullId??0)+1});}
|
|
240
|
+
}catch(e){this.fail(String(e));}
|
|
241
|
+
}
|
|
242
|
+
watchWorker(worker,generation,label){
|
|
243
|
+
const failed=event=>{if(generation!==this.generation||this.stopped)return;event.preventDefault?.();this.fail(`Remux ${label} worker failed: ${event.message||event.type}`);};
|
|
244
|
+
worker.onerror=failed;worker.onmessageerror=failed;
|
|
245
|
+
}
|
|
246
|
+
fail(message,code){
|
|
247
|
+
if(this.stopped||this.failedGeneration===this.generation)return;
|
|
248
|
+
this.failedGeneration=this.generation;this.failureError=Object.assign(Error(message),code?{code}:{});
|
|
249
|
+
const target=Math.max(0,this.video.currentTime-this.timelineBias),playing=this.windowed?!!this.recoveryPlaying:!this.video.paused;
|
|
250
|
+
this.stats.errors.push(message);this.cancelWait?.(this.failureError);this.cancelWait=null;this.stopWorkers();
|
|
251
|
+
if(this.starting)return;
|
|
252
|
+
// One transient worker/MSE restart per explicit open. Codec changes and
|
|
253
|
+
// source-identity failures go directly to the owner's compatibility fallback.
|
|
254
|
+
if((this.recoveryAttempts??0)<1&&/worker failed|MSE SourceBuffer error|QuotaExceededError/.test(message)){
|
|
255
|
+
this.recoveryAttempts=(this.recoveryAttempts??0)+1;
|
|
256
|
+
const recovery={target,reason:message,attempt:this.recoveryAttempts,restored:false};this.stats.recoveries.push(recovery);if(this.stats.recoveries.length>64)this.stats.recoveries.shift();
|
|
257
|
+
this.recoveryPlaying=playing;
|
|
258
|
+
void this.restart(target).then(async()=>{if(this.stopped)return;if(this.recoveryPlaying)await this.video.play();recovery.restored=true;},error=>{if(!this.stopped)this.onError?.(String(error));}).catch(error=>{if(!this.stopped)this.onError?.(String(error));});
|
|
259
|
+
}else this.onError?.(message);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
stopWorkers(){
|
|
263
|
+
this.cancelPrime?.();clearTimeout(this.primeTimeout);if(this.primeFrame)this.video.cancelVideoFrameCallback(this.primeFrame);this.primeFrame=0;
|
|
264
|
+
if(this.worker){(this.stats.cancellations??=[]).push({adaptation:this.remuxStats.adaptation?{...this.remuxStats.adaptation}:undefined,generatedBytes:this.remuxStats.generatedBytes||0,pendingBytes:byteLength(this.pending),retainedCompressedBytesUpperBound:this.segments.reduce((n,s)=>n+s.bytes,0),sourceFetchedBytes:this.sourceStats.fetchedBytes||0,inFlightOutputUpperBound:(this.windowed?16:8)*1024*1024});if(this.stats.cancellations.length>64)this.stats.cancellations.shift();}
|
|
265
|
+
this.cancelWait?.(new DOMException('Superseded','AbortError'));this.cancelWait=null;
|
|
266
|
+
if(this.pending)this.stats.discardedBytes+=byteLength(this.pending);
|
|
267
|
+
if(this.mailbox){const h=new Int32Array(this.mailbox,0,16);Atomics.store(h,4,1);Atomics.store(h,0,3);Atomics.notify(h,0);}
|
|
268
|
+
this.sourceWorker?.postMessage({type:'close'});this.sourceWorker?.terminate();this.worker?.terminate();this.sourceWorker=this.worker=null;this.stats.workers=0;this.sb=null;this.sbs=[];
|
|
269
|
+
}
|
|
270
|
+
canSeekBuffered(t){
|
|
271
|
+
// A held final video frame produces no new compositor callback to verify a
|
|
272
|
+
// buffered seek. Regenerate its bounded real preroll instead.
|
|
273
|
+
if(this.windowed&&t>=this.trackBounds.videoEnd)return false;
|
|
274
|
+
if(!this.bufferedSeeks||!Number.isFinite(t)||t<0||this.stopped||this.starting||!this.targetReady||this.failedGeneration===this.generation||this.acceptedGeneration!==this.generation||this.acceptedSource!==this.source||!this.sb||this.sb.updating||!['open','ended'].includes(this.media?.readyState))return false;
|
|
275
|
+
// Appended history is not playable coverage. Check both the media element's
|
|
276
|
+
// intersection and the current SourceBuffer after browser eviction/removal.
|
|
277
|
+
const media=this.video.buffered;
|
|
278
|
+
const range=this.ranges().find(([a,b])=>t>=a&&t+.25<b);
|
|
279
|
+
if(!range)return false;
|
|
280
|
+
const rap=this.raps.filter(r=>r<=t&&r>=range[0]-.001).at(-1);
|
|
281
|
+
if(rap===undefined)return false;
|
|
282
|
+
for(let i=0;i<media.length;i++)if(rap+this.timelineBias>=media.start(i)-.001&&t+this.timelineBias+.25<media.end(i))return true;
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
async seek(t){
|
|
286
|
+
if(this.canSeekBuffered(t)){
|
|
287
|
+
// Keep the producer's forward position. Only consumption moves backwards;
|
|
288
|
+
// using the previous target here would evict media at the new playhead.
|
|
289
|
+
this.target=t;this.video.currentTime=t+this.timelineBias;
|
|
290
|
+
this.stats.bufferedSeeks=(this.stats.bufferedSeeks??0)+1;
|
|
291
|
+
return {kind:'buffered',generation:this.generation,target:t};
|
|
292
|
+
}
|
|
293
|
+
return this.restart(t);
|
|
294
|
+
}
|
|
295
|
+
get playbackPaused(){return this.windowed?!this.recoveryPlaying:this.video.paused;}
|
|
296
|
+
get playbackEnded(){return this.video.ended&&(!this.windowed||this.eof&&!this.pending&&!this.busy&&this.video.currentTime>=this.duration+this.timelineBias-.02);}
|
|
297
|
+
async play(){this.recoveryPlaying=true;if(this.starting)return;if(this.windowed){this.pump();if(this.video.ended&&!this.playbackEnded){this.resumeWindow();return;}}return this.video.play();}
|
|
298
|
+
pause(){this.recoveryPlaying=false;this.video.pause();}
|
|
299
|
+
snapshot(){return {stats:structuredClone(this.stats),source:{...this.sourceStats},remux:{...this.remuxStats},windowed:this.windowed,presentationFloor:this.presentationFloor,trackBounds:this.trackBounds,ranges:this.ranges(),timelineBias:this.timelineBias,position:Math.max(0,this.video.currentTime-this.timelineBias),quality:this.video.getVideoPlaybackQuality(),readyState:this.video.readyState,logs:this.logs,videoError:this.video.error?.message};}
|
|
300
|
+
async destroy(){if(this.stopped)return;this.stopped=true;++this.generation;clearInterval(this.timer);this.stopWorkers();this.video.pause();this.video.removeAttribute('src');this.video.load();if(this.objectURL)URL.revokeObjectURL(this.objectURL);this.objectURL=null;}
|
|
301
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {LocalFileReader} from './file-reader.js';
|
|
2
|
+
import {RangeReader} from './range-reader.js';
|
|
3
|
+
let reader,file,h,view,buffer,stopped=false;
|
|
4
|
+
let refreshPending;
|
|
5
|
+
self.onmessage=async({data})=>{
|
|
6
|
+
if(data.type==='refreshed'){const p=refreshPending;refreshPending=null;clearTimeout(p?.timer);if(p)data.error?p.reject(Error(data.error)):p.resolve(data.update);return;}
|
|
7
|
+
if(data.type==='close'){stopped=true;reader?.close();if(h){Atomics.store(h,4,1);Atomics.store(h,0,3);Atomics.notify(h,0);}return;}
|
|
8
|
+
if(data.type!=='init')return;
|
|
9
|
+
try{
|
|
10
|
+
h=new Int32Array(data.mailbox,0,16);view=new DataView(data.mailbox);buffer=new Uint8Array(data.mailbox,64);
|
|
11
|
+
file=data.file;
|
|
12
|
+
let size,identity;
|
|
13
|
+
if(file){reader=new LocalFileReader(file);size=Number((await reader.open()).size);}
|
|
14
|
+
else{reader=new RangeReader({...data.options,cacheBytes:2*1024*1024,blockBytes:65536},resource=>new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('Authorization refresh timeout')),5000);refreshPending={resolve,reject,timer};postMessage({type:'refresh',resource});}));if(data.identity){reader.etag=data.identity.etag;reader.total=BigInt(data.identity.size);}
|
|
15
|
+
identity=await reader.open();size=Number(identity.size);}
|
|
16
|
+
postMessage({type:'ready',size,identity});
|
|
17
|
+
while(!stopped){
|
|
18
|
+
if(Atomics.load(h,0)!==1){if(Atomics.waitAsync)await Atomics.waitAsync(h,0,Atomics.load(h,0),100).value;else await new Promise(r=>setTimeout(r,4));continue;}
|
|
19
|
+
const offset=view.getFloat64(32,true),n=Atomics.load(h,2);let bytes;
|
|
20
|
+
bytes=await reader.read(BigInt(offset),n);
|
|
21
|
+
if(stopped)break;buffer.set(bytes);Atomics.store(h,3,bytes.length);Atomics.store(h,0,2);Atomics.notify(h,0);
|
|
22
|
+
postMessage({type:'stats',stats:reader.stats});
|
|
23
|
+
}
|
|
24
|
+
}catch(e){if(h){Atomics.store(h,3,-1);Atomics.store(h,0,3);Atomics.notify(h,0);}postMessage({type:'error',message:String(e)});}
|
|
25
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {videoCodecConfig,vp9RemuxConfig} from './video-codec-config.js';
|
|
2
|
+
import {remuxPackaging} from './remux-packaging.js';
|
|
3
|
+
let engine,chunks=[],bytes=0,emptyBatches=0,negotiation,splitter,timing;
|
|
4
|
+
const stats={calls:0,remuxMs:0,generatedBytes:0,peakBatchBytes:0,heapBytes:0};
|
|
5
|
+
const flush=()=>{const output=new Uint8Array(bytes);let at=0;for(const b of chunks){output.set(b,at);at+=b.length;}chunks=[];bytes=0;stats.generatedBytes+=output.length;stats.peakBatchBytes=Math.max(stats.peakBatchBytes,output.length);return output;};
|
|
6
|
+
const adaptationABI=()=>{if(engine.preparationInterface!==2)throw Error('Audio preparation initialization interface mismatch; install matching runtime assets');};
|
|
7
|
+
const check=n=>{if(n<0)throw Error(`FFmpeg error ${n}: ${engine.UTF8ToString(engine._rm_error())}`);return n;};
|
|
8
|
+
self.onmessage=async({data})=>{
|
|
9
|
+
try{
|
|
10
|
+
const start=performance.now();
|
|
11
|
+
if(data.type==='init'||data.type==='probe'){
|
|
12
|
+
const {default:createRemux}=await import(data.audioAdaptation?'./engine-adaptation/remux.mjs':'./engine-remux/remux.mjs');
|
|
13
|
+
engine=await createRemux({printErr:message=>postMessage({type:'log',message})});engine.parseVP9=vp9RemuxConfig;engine.io=data.mailbox;engine.raps=[];engine.tracks=[];
|
|
14
|
+
if(data.type==='probe'){if(data.audioAdaptation){check(engine._rm_adapt_audio(1));adaptationABI();}check(engine._rm_probe(data.size));postMessage({type:'probed',tracks:engine.tracks,duration:engine._rm_duration(),format:engine.format});return;}
|
|
15
|
+
engine.emit=b=>{bytes+=b.length;if(bytes>8*1024*1024)throw Error('Fragment budget exceeded');chunks.push(b);};
|
|
16
|
+
if(data.audioAdaptation){if(!['flac','opus'].includes(data.audioAdaptation))throw Error('Unsupported adaptation profile');if(typeof engine._rm_adapt_audio!=='function')throw Error('Audio adaptation ABI unavailable');check(engine._rm_adapt_audio(data.audioAdaptation==='opus'?2:1));adaptationABI();}
|
|
17
|
+
check(engine._rm_open(data.size,data.videoTrack??-1,data.audioTrack??-1));let duration=engine._rm_duration();
|
|
18
|
+
const video=engine.videoConfig?videoCodecConfig({...engine.videoConfig,maxWidth:8192,maxHeight:8192}).configuration.codec:engine.UTF8ToString(engine._rm_video_codec());
|
|
19
|
+
const audio=engine.UTF8ToString(engine._rm_audio_codec());
|
|
20
|
+
const bounds=engine.trackBounds;
|
|
21
|
+
if(data.audioAdaptation&&bounds?.videoEnd>0&&bounds?.audioEnd>0)duration=Math.max(bounds.videoEnd,bounds.audioEnd);
|
|
22
|
+
if(data.audioAdaptation==='flac'&&bounds?.videoEnd>0&&bounds?.audioEnd>0&&Math.abs(bounds.videoEnd-bounds.audioEnd)>1){
|
|
23
|
+
if(!engine.tracks.some(t=>t.selected&&t.type==='audio'&&['pcm_s16le','pcm_s24le'].includes(t.codec))||!engine.tracks.some(t=>t.selected&&t.type==='video'&&t.codec==='h264'))throw Error('Unsupported unequal-tail Native configuration; qualified H264 and PCM16/24 are required');
|
|
24
|
+
const {SplitMP4}=await import('./split-mp4.js');splitter=new SplitMP4();engine.windowedTails=true;
|
|
25
|
+
}
|
|
26
|
+
const candidates=remuxPackaging(video,audio,engine.container);
|
|
27
|
+
if(!candidates.length)throw Error('Selected codecs have no common browser remux packaging');
|
|
28
|
+
negotiation={candidates,duration,target:data.target||0};stats.remuxMs+=performance.now()-start;
|
|
29
|
+
postMessage({type:'negotiate',candidates,duration,windowed:!!splitter,trackBounds:bounds,lanes:splitter?[`video/mp4; codecs="${video}"`,`audio/mp4; codecs="${audio}"`]:undefined});
|
|
30
|
+
}else if(data.type==='select-container'){
|
|
31
|
+
const selected=negotiation?.candidates.find(c=>c.container===data.container);
|
|
32
|
+
if(!selected)throw Error('Invalid remux container selection');
|
|
33
|
+
const {duration,target}=negotiation;negotiation=null;
|
|
34
|
+
if(engine.preparationInterface===2&&selected.container!=='webm'){const {MP4VideoTiming}=await import('./split-mp4.js');timing=new MP4VideoTiming();}
|
|
35
|
+
check(engine._rm_set_container(selected.container==='webm'?1:0));
|
|
36
|
+
check(engine._rm_start(target));const buffer=flush().buffer;stats.remuxMs+=performance.now()-start;stats.heapBytes=engine.HEAPU8.byteLength;
|
|
37
|
+
const presentationFrames=timing?.read(buffer),buffers=splitter?splitter.split(buffer):undefined;
|
|
38
|
+
postMessage({type:'ready',presentationFrames,duration,mime:selected.mime,tracks:engine.tracks,buffer:buffers?undefined:buffer,buffers,stats:{...stats}},buffers??[buffer]);
|
|
39
|
+
}else if(data.type==='next'){
|
|
40
|
+
const more=check(engine._rm_step()),buffer=flush().buffer;if(engine.adaptation)stats.adaptation={...engine.adaptation};stats.calls++;stats.remuxMs+=performance.now()-start;stats.heapBytes=engine.HEAPU8.byteLength;
|
|
41
|
+
emptyBatches=buffer.byteLength?0:emptyBatches+1;
|
|
42
|
+
if(more&&emptyBatches>24)throw Error('Remux random-access interval exceeds fragment production budget');
|
|
43
|
+
const frames=engine.videoFrames?.splice(0)??[],raps=engine.raps.splice(0),presentationFrames=timing?.read(buffer),buffers=splitter?splitter.split(buffer):undefined;postMessage({type:'fragment',id:data.id,more,frames,presentationFrames,buffer:buffers?undefined:buffer,buffers,raps,stats:{...stats}},buffers??[buffer]);
|
|
44
|
+
}else if(data.type==='close'){engine?._rm_close();postMessage({type:'closed'});close();}
|
|
45
|
+
}catch(e){postMessage({type:'error',message:`${e.message||e}\n${e.stack||''}`});}
|
|
46
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Original bounded, single-flight VOD range reader. No decoder dependency.
|
|
2
|
+
export class RangeReader {
|
|
3
|
+
constructor(options, refresh) {
|
|
4
|
+
this.options={credentials:'omit',cacheBytes:16*1024*1024,blockBytes:256*1024,immutable:false,...options};
|
|
5
|
+
this.allow=new Set(options.allowedOrigins || [new URL(options.url).origin]);
|
|
6
|
+
this.refresh=refresh;this.cache=new Map();this.epoch=0;this.closed=false;this.busy=false;
|
|
7
|
+
this.stats={fetchedBytes:0,requests:0,retries:0,aborts:0,cacheBytes:0,peakCacheBytes:0,activeBytes:0,peakActiveBytes:0};
|
|
8
|
+
if(options.identity){this.etag=options.identity.etag;this.total=BigInt(options.identity.size);}
|
|
9
|
+
this.checkURL(this.options.url);
|
|
10
|
+
if(!Number.isInteger(this.options.blockBytes)||this.options.blockBytes<1024||this.options.blockBytes>262144||!Number.isInteger(this.options.cacheBytes)||this.options.cacheBytes<this.options.blockBytes||this.options.cacheBytes>16777216)throw Error('Invalid range budgets');
|
|
11
|
+
}
|
|
12
|
+
checkURL(value){const u=new URL(value);if(!['http:','https:'].includes(u.protocol)||u.username||u.password||!this.allow.has(u.origin))throw Error('Media origin is not allowed');}
|
|
13
|
+
async open(){await this.read(0n,1);return {size:String(this.total),etag:this.etag};}
|
|
14
|
+
beginEpoch(){this.epoch++;this.operation?.abort(new DOMException('Superseded','AbortError'));this.controller?.abort();this.retryWake?.();this.stats.aborts++;}
|
|
15
|
+
close(){this.closed=true;this.beginEpoch();this.cache.clear();this.stats.cacheBytes=0;}
|
|
16
|
+
async read(offset,capacity){
|
|
17
|
+
if(this.closed)throw Error('Range reader closed');
|
|
18
|
+
if(this.busy)throw Error('Concurrent reads are not allowed');
|
|
19
|
+
if(typeof offset!=='bigint'||offset<0n||!Number.isInteger(capacity)||capacity<1||capacity>262144)throw Error('Invalid read');
|
|
20
|
+
if(this.total!==undefined&&offset>=this.total)return new Uint8Array();
|
|
21
|
+
this.busy=true;const epoch=this.epoch;
|
|
22
|
+
try{
|
|
23
|
+
// Anchor misses at the requested position, avoiding an unused prefix on
|
|
24
|
+
// distant index/seek reads. Cached windows can still satisfy inner reads.
|
|
25
|
+
let start=offset,key=String(start);
|
|
26
|
+
let bytes=this.cache.get(key);
|
|
27
|
+
if(!bytes)for(const [cachedKey,cachedBytes] of this.cache){
|
|
28
|
+
const cachedStart=BigInt(cachedKey);
|
|
29
|
+
if(offset>=cachedStart&&offset<cachedStart+BigInt(cachedBytes.length)){
|
|
30
|
+
start=cachedStart;key=cachedKey;bytes=cachedBytes;break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if(bytes){this.cache.delete(key);this.cache.set(key,bytes);}
|
|
34
|
+
else{
|
|
35
|
+
bytes=await this.fetchBlock(start,epoch);
|
|
36
|
+
if(epoch!==this.epoch||this.closed)throw new DOMException('Superseded','AbortError');
|
|
37
|
+
while(this.stats.cacheBytes+bytes.buffer.byteLength>this.options.cacheBytes&&this.cache.size){const oldest=this.cache.keys().next().value;this.stats.cacheBytes-=this.cache.get(oldest).buffer.byteLength;this.cache.delete(oldest);}
|
|
38
|
+
this.cache.set(key,bytes);this.stats.cacheBytes+=bytes.buffer.byteLength;this.stats.peakCacheBytes=Math.max(this.stats.peakCacheBytes,this.stats.cacheBytes);
|
|
39
|
+
}
|
|
40
|
+
if(epoch!==this.epoch||this.closed)throw new DOMException('Superseded','AbortError');
|
|
41
|
+
const at=Number(offset-start);return bytes.subarray(at,Math.min(bytes.length,at+capacity));
|
|
42
|
+
}finally{this.busy=false;this.stats.activeBytes=0;}
|
|
43
|
+
}
|
|
44
|
+
async fetchBlock(start,epoch){
|
|
45
|
+
const buffer=new Uint8Array(this.options.blockBytes);let received=0,refreshed=false;
|
|
46
|
+
const deadline=performance.now()+15000;
|
|
47
|
+
const operation=this.operation=new AbortController();
|
|
48
|
+
const timeout=()=>operation.abort(Error('Media read retry deadline exceeded'));
|
|
49
|
+
// A separate, non-resetting deadline covers headers, successful body progress,
|
|
50
|
+
// backoff and credential refresh. Race pending work too: refresh callbacks do
|
|
51
|
+
// not receive a signal and need not settle when the transport is cancelled.
|
|
52
|
+
const deadlineTimer=setTimeout(timeout,15000);
|
|
53
|
+
operation.signal.addEventListener('abort',()=>{
|
|
54
|
+
this.controller?.abort();this.retryWake?.();
|
|
55
|
+
},{once:true});
|
|
56
|
+
const check=()=>{
|
|
57
|
+
if(this.closed||epoch!==this.epoch)operation.abort(new DOMException('Superseded','AbortError'));
|
|
58
|
+
if(performance.now()>=deadline)timeout();
|
|
59
|
+
if(operation.signal.aborted)throw operation.signal.reason;
|
|
60
|
+
};
|
|
61
|
+
const waitFor=promise=>new Promise((resolve,reject)=>{
|
|
62
|
+
const signal=operation.signal;
|
|
63
|
+
const cleanup=()=>signal.removeEventListener('abort',onAbort);
|
|
64
|
+
const onAbort=()=>{cleanup();reject(signal.reason);};
|
|
65
|
+
signal.addEventListener('abort',onAbort,{once:true});
|
|
66
|
+
Promise.resolve(promise).then(value=>{
|
|
67
|
+
cleanup();try{check();resolve(value);}catch(error){reject(error);}
|
|
68
|
+
},error=>{cleanup();reject(error);});
|
|
69
|
+
if(signal.aborted)onAbort();
|
|
70
|
+
});
|
|
71
|
+
try{
|
|
72
|
+
this.stats.activeBytes=buffer.length;this.stats.peakActiveBytes=Math.max(this.stats.peakActiveBytes,buffer.length);
|
|
73
|
+
for(let attempt=0;attempt<10;attempt++){
|
|
74
|
+
check();
|
|
75
|
+
const controller=this.controller=new AbortController();let timer,reader,response;
|
|
76
|
+
const touch=()=>{clearTimeout(timer);timer=setTimeout(()=>controller.abort(),1200);};
|
|
77
|
+
try{
|
|
78
|
+
const offset=start+BigInt(received);
|
|
79
|
+
let end=start+BigInt(buffer.length)-1n;if(this.total!==undefined&&end>=this.total)end=this.total-1n;
|
|
80
|
+
const headers=new Headers(this.options.headers);headers.set('Range',`bytes=${offset}-${end}`);if(this.etag)headers.set('If-Range',this.etag);
|
|
81
|
+
this.checkURL(this.options.url);touch();this.stats.requests++;
|
|
82
|
+
response=await waitFor(fetch(this.options.url,{headers,credentials:this.options.credentials,redirect:'error',cache:'no-store',signal:controller.signal}));
|
|
83
|
+
if(response.status===401&&!refreshed&&this.refresh){
|
|
84
|
+
await waitFor(response.body?.cancel());clearTimeout(timer);const update=await waitFor(this.refresh());
|
|
85
|
+
this.checkURL(update.url||this.options.url);this.options={...this.options,...update};refreshed=true;continue;
|
|
86
|
+
}
|
|
87
|
+
if([408,429,500,502,503,504].includes(response.status)){const e=Error(`Retryable HTTP ${response.status}`);e.retry=true;throw e;}
|
|
88
|
+
if(response.status===416){
|
|
89
|
+
const match=/^bytes \*\/(\d+)$/.exec(response.headers.get('Content-Range')||'');
|
|
90
|
+
if(match&&this.total!==undefined&&BigInt(match[1])===this.total&&offset>=this.total){await waitFor(response.body?.cancel());return buffer.subarray(0,received);}
|
|
91
|
+
throw Error('Unexpected range EOF');
|
|
92
|
+
}
|
|
93
|
+
if(response.status!==206)throw Error(`Expected HTTP 206; received ${response.status}`);
|
|
94
|
+
const match=/^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('Content-Range')||'');
|
|
95
|
+
if(!match)throw Error('Missing or invalid Content-Range');
|
|
96
|
+
const [lo,hi,total]=match.slice(1).map(BigInt),expectedEnd=end<total?end:total-1n;
|
|
97
|
+
if(lo!==offset||hi!==expectedEnd||hi<lo||total<=hi||this.total!==undefined&&total!==this.total)throw Error('Incoherent media range or changed length');
|
|
98
|
+
const encoding=response.headers.get('Content-Encoding');if(encoding&&encoding!=='identity')throw Error('Encoded range representation is unsupported');
|
|
99
|
+
const etag=response.headers.get('ETag');
|
|
100
|
+
if(this.etag&&etag!==this.etag)throw Error('Media representation changed');
|
|
101
|
+
if(!this.etag){if(etag&&/^"[^\r\n]*"$/.test(etag))this.etag=etag;else if(!this.options.immutable)throw Error('A strong ETag or explicit immutable contract is required');}
|
|
102
|
+
this.total=total;
|
|
103
|
+
const expected=Number(hi-lo+1n),length=response.headers.get('Content-Length');
|
|
104
|
+
if(length!==null&&BigInt(length)!==BigInt(expected))throw Error('Content-Length does not match range');
|
|
105
|
+
reader=response.body.getReader();let bodyBytes=0;
|
|
106
|
+
while(true){touch();const {done,value}=await waitFor(reader.read());if(done)break;
|
|
107
|
+
this.stats.fetchedBytes+=value.length;
|
|
108
|
+
if(bodyBytes+value.length>expected||received+value.length>buffer.length)throw Error('Range body exceeded its bound');
|
|
109
|
+
buffer.set(value,received);received+=value.length;bodyBytes+=value.length;
|
|
110
|
+
}
|
|
111
|
+
if(bodyBytes!==expected){const e=Error('Truncated range body');e.retry=true;throw e;}
|
|
112
|
+
if(epoch!==this.epoch||this.closed)throw new DOMException('Superseded','AbortError');
|
|
113
|
+
return buffer.subarray(0,received);
|
|
114
|
+
}catch(error){
|
|
115
|
+
controller.abort();void reader?.cancel().catch(()=>{});void response?.body?.cancel().catch(()=>{});
|
|
116
|
+
check();
|
|
117
|
+
if(epoch!==this.epoch||this.closed)throw new DOMException('Superseded','AbortError');
|
|
118
|
+
const retry=error.retry||error.name==='AbortError'||error instanceof TypeError;
|
|
119
|
+
if(!retry||attempt===9||performance.now()>=deadline)throw Error(retry?'Media read retry deadline exceeded':error.message);
|
|
120
|
+
this.stats.retries++;
|
|
121
|
+
const retryAfter=response?.headers.get('Retry-After');const serverWait=retryAfter ? (/^\d+$/.test(retryAfter)?Number(retryAfter)*1000:Math.max(0,Date.parse(retryAfter)-Date.now())) : 0;
|
|
122
|
+
const backoff=80*2**Math.min(attempt,3)*(0.75+Math.random()*0.5);
|
|
123
|
+
const wait=Math.min(Math.max(0,deadline-performance.now()),Math.max(Number.isFinite(serverWait)?serverWait:0,backoff));
|
|
124
|
+
await waitFor(new Promise(resolve=>{const timer=setTimeout(done,wait);const self=this;function done(){clearTimeout(timer);self.retryWake=null;resolve();}this.retryWake=done;}));
|
|
125
|
+
}finally{clearTimeout(timer);if(this.controller===controller)this.controller=null;}
|
|
126
|
+
}
|
|
127
|
+
throw Error('Media read failed');
|
|
128
|
+
}finally{clearTimeout(deadlineTimer);if(this.operation===operation)this.operation=null;}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Packet/container contracts, independent of browser admission. Selected codecs only.
|
|
2
|
+
export function remuxPackaging(video, audio, preferred='mp4') {
|
|
3
|
+
const webmVideo=!video||/^(vp8|vp09\.|av01\.)/.test(video);
|
|
4
|
+
const webmAudio=!audio||['opus','vorbis'].includes(audio);
|
|
5
|
+
const mp4Video=!video||/^(avc1\.|hvc1\.|hev1\.|vp09\.|av01\.)/.test(video);
|
|
6
|
+
const mp4Audio=!audio||/^(mp4a\.|mp3$|opus$|flac$|ac-3$|ec-3$)/.test(audio);
|
|
7
|
+
if(!video&&!audio)return [];
|
|
8
|
+
return [preferred,...['mp4','webm'].filter(c=>c!==preferred)]
|
|
9
|
+
.filter(c=>c==='mp4'?mp4Video&&mp4Audio:c==='webm'&&webmVideo&&webmAudio)
|
|
10
|
+
.map(container=>({container,mime:`${video?'video':'audio'}/${container}; codecs="${[video,audio].filter(Boolean).join(',')}"`}));
|
|
11
|
+
}
|