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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {validateVODManifest} from './vod-manifest.js';
|
|
2
|
+
import {prepareDASH,selectHLS} from './streaming-manifest.js';
|
|
3
|
+
import {subtitleSegments,mergeWebVTT} from './segmented-subtitles.js';
|
|
4
|
+
// Bounded resource transport. FFmpeg, not this loader, interprets media timelines.
|
|
5
|
+
const MiB=1024*1024;
|
|
6
|
+
const abort=()=>new DOMException('Resource request cancelled','AbortError');
|
|
7
|
+
export class ResourceLoader {
|
|
8
|
+
constructor(options,refresh){
|
|
9
|
+
this.options={credentials:'omit',...options};this.refresh=refresh;
|
|
10
|
+
this.allow=new Set(options.allowedOrigins??[new URL(options.url).origin]);
|
|
11
|
+
this.virtual=new Map();this.subtitlePlaylists=new Set();this.subtitleMedia=new Set();
|
|
12
|
+
this.handles=new Map();this.nextId=1;this.epoch=0;this.closed=false;this.busy=false;
|
|
13
|
+
this.stats={opens:0,requests:0,retries:0,aborts:0,handles:0,retainedBytes:0,peakRetainedBytes:0,fetchedBytes:0};
|
|
14
|
+
this.resolve(options.url);
|
|
15
|
+
}
|
|
16
|
+
resolve(value,base=this.options.url){
|
|
17
|
+
const u=new URL(value,base);
|
|
18
|
+
if(u.href.length>4095||!['http:','https:'].includes(u.protocol)||u.username||u.password||!this.allow.has(u.origin))throw Error('Resource URL is not allowed');
|
|
19
|
+
u.hash='';return u.href;
|
|
20
|
+
}
|
|
21
|
+
storeVirtual(additions){
|
|
22
|
+
const next=new Map([...this.virtual,...additions]);
|
|
23
|
+
if(next.size>1024||[...next.values()].reduce((n,b)=>n+b.byteLength,0)>4*MiB)throw Error('Virtual resource budget exceeded');
|
|
24
|
+
this.virtual=next;
|
|
25
|
+
}
|
|
26
|
+
beginEpoch(){this.epoch++;this.controller?.abort();this.retryWake?.();this.stats.aborts++;}
|
|
27
|
+
closeHandle(id){const item=this.handles.get(id);if(!item)return;this.stats.retainedBytes-=item.bytes.byteLength;this.handles.delete(id);this.stats.handles=this.handles.size;}
|
|
28
|
+
close(){this.closed=true;this.beginEpoch();for(const id of this.handles.keys())this.closeHandle(id);this.subtitlePlaylists.clear();this.subtitleMedia.clear();this.virtual.clear();}
|
|
29
|
+
read(id,offset,capacity){
|
|
30
|
+
if(this.closed)throw Error('Resource loader closed');
|
|
31
|
+
const item=this.handles.get(id);
|
|
32
|
+
if(!item||typeof offset!=='bigint'||offset<item.start||!Number.isInteger(capacity)||capacity<1||capacity>262144)throw Error('Invalid resource read');
|
|
33
|
+
const at=offset-item.start;if(at>=BigInt(item.bytes.length))return new Uint8Array();
|
|
34
|
+
return item.bytes.subarray(Number(at),Number(at)+capacity);
|
|
35
|
+
}
|
|
36
|
+
async open(value,{start,end,manifest=false,base}={}){
|
|
37
|
+
if(this.closed)throw Error('Resource loader closed');
|
|
38
|
+
if(this.busy)throw Error('Concurrent resource opens are not allowed');
|
|
39
|
+
if(this.handles.size>=16||(!this.options.streaming?.live&&this.stats.opens>=10000))throw Error('Resource count limit exceeded');
|
|
40
|
+
if(start!==undefined&&(typeof start!=='bigint'||start<0n||typeof end!=='bigint'||end<=start))throw Error('Invalid resource range');
|
|
41
|
+
if(start===undefined&&end!==undefined)throw Error('Invalid resource range');
|
|
42
|
+
const virtual=this.virtual.get(this.resolve(value,base));
|
|
43
|
+
if(virtual){
|
|
44
|
+
const total=BigInt(virtual.byteLength),offset=start??0n;
|
|
45
|
+
if(offset>=total||(end!==undefined&&end>total))throw Error('Invalid virtual resource range');
|
|
46
|
+
const bytes=start===undefined?virtual:virtual.subarray(Number(start),Number(end));
|
|
47
|
+
if(this.stats.retainedBytes+bytes.byteLength>16*MiB)throw Error('Resource memory budget exceeded');
|
|
48
|
+
const id=this.nextId++,url=this.resolve(value,base);this.handles.set(id,{id,url,bytes,start:offset,total});
|
|
49
|
+
this.stats.opens++;this.stats.retainedBytes+=bytes.byteLength;this.stats.handles=this.handles.size;this.stats.peakRetainedBytes=Math.max(this.stats.peakRetainedBytes,this.stats.retainedBytes);
|
|
50
|
+
return {id,url,size:String(total),start:String(offset),length:bytes.byteLength};
|
|
51
|
+
}
|
|
52
|
+
manifest=manifest||/\.(m3u8?|mpd)$/i.test(new URL(this.resolve(value,base)).pathname);
|
|
53
|
+
const isSubtitlePlaylist=this.subtitlePlaylists.has(this.resolve(value,base));
|
|
54
|
+
const isSubtitleMedia=this.subtitleMedia.has(this.resolve(value,base));
|
|
55
|
+
const limit=manifest||isSubtitleMedia?MiB:8*MiB;
|
|
56
|
+
if(start!==undefined&&end-start>BigInt(limit))throw Error('Resource size limit exceeded');
|
|
57
|
+
let url=this.resolve(value,base);manifest=manifest||/\.(m3u8?|mpd)$/i.test(new URL(url).pathname);const epoch=this.epoch;this.busy=true;this.stats.opens++;
|
|
58
|
+
const controller=this.controller=new AbortController();
|
|
59
|
+
const timeout=setTimeout(()=>controller.abort(),15000);
|
|
60
|
+
let refreshed=false;
|
|
61
|
+
try{
|
|
62
|
+
for(let attempt=0;attempt<4;attempt++){
|
|
63
|
+
if(this.closed||epoch!==this.epoch||controller.signal.aborted)throw abort();
|
|
64
|
+
let response;
|
|
65
|
+
try{
|
|
66
|
+
const headers=new Headers(this.options.headers);
|
|
67
|
+
// Transport owns range semantics, never inherit an unrelated caller range.
|
|
68
|
+
headers.delete('Range');headers.delete('If-Range');
|
|
69
|
+
if(start!==undefined)headers.set('Range',`bytes=${start}-${end-1n}`);
|
|
70
|
+
this.stats.requests++;
|
|
71
|
+
response=await fetch(url,{headers,credentials:this.options.credentials,redirect:'error',cache:'no-store',signal:controller.signal});
|
|
72
|
+
if(response.status===401&&this.refresh&&!refreshed){
|
|
73
|
+
await response.body?.cancel();refreshed=true;
|
|
74
|
+
const update=await new Promise((resolve,reject)=>{
|
|
75
|
+
const cancel=()=>reject(abort());controller.signal.addEventListener('abort',cancel,{once:true});
|
|
76
|
+
Promise.resolve().then(()=>this.refresh({url})).then(resolve,reject).finally(()=>controller.signal.removeEventListener('abort',cancel));
|
|
77
|
+
});
|
|
78
|
+
if(this.closed||epoch!==this.epoch||controller.signal.aborted)throw abort();
|
|
79
|
+
if(update?.url)url=this.resolve(update.url);
|
|
80
|
+
if(update?.headers)this.options.headers={...this.options.headers,...update.headers};
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if([408,429,500,502,503,504].includes(response.status)){
|
|
84
|
+
await response.body?.cancel();throw Object.assign(Error(`Retryable resource HTTP ${response.status}`),{retryable:true});
|
|
85
|
+
}
|
|
86
|
+
if(response.status!==(start===undefined?200:206))throw Error(`Unexpected resource HTTP ${response.status}`);
|
|
87
|
+
const encoding=response.headers.get('Content-Encoding');
|
|
88
|
+
if(encoding&&encoding!=='identity')throw Error('Encoded resource bodies are unsupported');
|
|
89
|
+
let total,expected;
|
|
90
|
+
if(start!==undefined){
|
|
91
|
+
const match=/^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('Content-Range')??'');
|
|
92
|
+
if(!match||BigInt(match[1])!==start||BigInt(match[2])+1n!==end||BigInt(match[3])<end)throw Error('Invalid resource Content-Range');
|
|
93
|
+
total=BigInt(match[3]);expected=Number(end-start);
|
|
94
|
+
}
|
|
95
|
+
const length=response.headers.get('Content-Length');
|
|
96
|
+
if(length!==null){
|
|
97
|
+
if(!/^\d+$/.test(length)||BigInt(length)>BigInt(limit))throw Error('Resource size limit exceeded');
|
|
98
|
+
if(expected!==undefined&&Number(length)!==expected)throw Error('Resource length mismatch');
|
|
99
|
+
expected=Number(length);
|
|
100
|
+
}
|
|
101
|
+
if(!response.body)throw Error('Missing resource body');
|
|
102
|
+
const chunks=[];let count=0;
|
|
103
|
+
const body=response.body.getReader();
|
|
104
|
+
try{
|
|
105
|
+
for(;;){
|
|
106
|
+
const {value,done}=await body.read();if(done)break;
|
|
107
|
+
count+=value.byteLength;this.stats.fetchedBytes+=value.byteLength;
|
|
108
|
+
if(count>limit||(expected!==undefined&&count>expected))throw Error('Resource size limit exceeded');
|
|
109
|
+
chunks.push(value);
|
|
110
|
+
}
|
|
111
|
+
}finally{await body.cancel().catch(()=>{});}
|
|
112
|
+
if(expected!==undefined&&count!==expected)throw Object.assign(Error('Truncated resource body'),{retryable:true});
|
|
113
|
+
if(!count)throw Error('Empty resource body');
|
|
114
|
+
if(this.closed||epoch!==this.epoch||controller.signal.aborted)throw abort();
|
|
115
|
+
if(this.stats.retainedBytes+count>16*MiB)throw Error('Resource memory budget exceeded');
|
|
116
|
+
let bytes=new Uint8Array(count);let at=0;for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}
|
|
117
|
+
const prefix=new TextDecoder().decode(bytes.subarray(0,512)).trimStart();
|
|
118
|
+
const looksManifest=prefix.startsWith('#EXTM3U')||/^<\?xml\b|^<MPD\b/.test(prefix);
|
|
119
|
+
if(manifest||looksManifest){
|
|
120
|
+
if(count>MiB)throw Error('Manifest size limit exceeded');
|
|
121
|
+
const format=prefix.startsWith('#EXTM3U')?'hls':'dash';
|
|
122
|
+
if(format==='dash'){
|
|
123
|
+
const prepared=prepareDASH(new TextDecoder().decode(bytes),url,this.options.streaming);
|
|
124
|
+
bytes=prepared.bytes;if(prepared.url)url=prepared.url;
|
|
125
|
+
this.storeVirtual(prepared.resources);
|
|
126
|
+
}else bytes=new TextEncoder().encode(selectHLS(new TextDecoder().decode(bytes),this.options.streaming));
|
|
127
|
+
count=bytes.byteLength;if(count>MiB||this.stats.retainedBytes+count>16*MiB)throw Error('Manifest memory budget exceeded');
|
|
128
|
+
const preparedFormat=new TextDecoder().decode(bytes.subarray(0,7))==='#EXTM3U'?'hls':'dash';
|
|
129
|
+
const policy=validateVODManifest(bytes,preparedFormat,this.options.streaming);
|
|
130
|
+
if(policy){
|
|
131
|
+
const additions=policy.subtitlePlaylists.map(uri=>this.resolve(uri,url));
|
|
132
|
+
if(new Set([...this.subtitlePlaylists,...additions]).size>16)throw Error('HLS subtitle track limit exceeded');
|
|
133
|
+
for(const uri of additions)this.subtitlePlaylists.add(uri);
|
|
134
|
+
if(isSubtitlePlaylist){
|
|
135
|
+
const parsed=subtitleSegments(new TextDecoder().decode(bytes),url);
|
|
136
|
+
const parts=[];let totalBytes=0;
|
|
137
|
+
const nested=new ResourceLoader({...this.options,format:'file'},this.refresh);
|
|
138
|
+
const cancelled=()=>nested.close();controller.signal.addEventListener('abort',cancelled,{once:true});
|
|
139
|
+
try{
|
|
140
|
+
for(const segment of parsed.segments){
|
|
141
|
+
const info=await nested.open(this.resolve(segment.url));const body=nested.handles.get(info.id).bytes;
|
|
142
|
+
totalBytes+=body.byteLength;if(totalBytes>MiB)throw Error('Subtitle window exceeds 1 MiB');
|
|
143
|
+
parts.push({...segment,text:new TextDecoder('utf-8',{fatal:true}).decode(body)});nested.closeHandle(info.id);
|
|
144
|
+
}
|
|
145
|
+
}finally{controller.signal.removeEventListener('abort',cancelled);nested.close();this.stats.requests+=nested.stats.requests;this.stats.fetchedBytes+=nested.stats.fetchedBytes;}
|
|
146
|
+
const subtitle=mergeWebVTT(parts),virtualURL=new URL(url);virtualURL.searchParams.set('__demuxe_subtitles','1');
|
|
147
|
+
const uri=virtualURL.href;this.storeVirtual([[uri,new TextEncoder().encode(subtitle)]]);
|
|
148
|
+
bytes=new TextEncoder().encode(`#EXTM3U\n#EXT-X-TARGETDURATION:${Math.ceil(parsed.duration)}\n#EXTINF:${parsed.duration},\n${uri}\n#EXT-X-ENDLIST\n`);count=bytes.byteLength;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if(isSubtitleMedia){
|
|
153
|
+
const subtitle=new TextDecoder('utf-8',{fatal:true}).decode(bytes);
|
|
154
|
+
if(!subtitle.startsWith('WEBVTT')||subtitle.includes('X-TIMESTAMP-MAP'))throw Error('Only full-timeline WebVTT without timestamp maps is supported');
|
|
155
|
+
}
|
|
156
|
+
if(this.closed||epoch!==this.epoch||controller.signal.aborted)throw abort();
|
|
157
|
+
const id=this.nextId++;const item={id,url,bytes,start:start??0n,total:total??BigInt(count)};
|
|
158
|
+
this.handles.set(id,item);this.stats.handles=this.handles.size;this.stats.retainedBytes+=count;
|
|
159
|
+
this.stats.peakRetainedBytes=Math.max(this.stats.peakRetainedBytes,this.stats.retainedBytes);
|
|
160
|
+
return {id,url,size:String(item.total),start:String(item.start),length:count};
|
|
161
|
+
}catch(error){
|
|
162
|
+
// Abort response consumption even when validation fails before getReader().
|
|
163
|
+
await response?.body?.cancel().catch(()=>{});
|
|
164
|
+
if(this.closed||epoch!==this.epoch||controller.signal.aborted)throw abort();
|
|
165
|
+
if(attempt===3||!(error.retryable||error instanceof TypeError))throw error;
|
|
166
|
+
this.stats.retries++;
|
|
167
|
+
await new Promise(resolve=>{const timer=setTimeout(done,100*(attempt+1));const self=this;function done(){clearTimeout(timer);self.retryWake=null;resolve();}this.retryWake=done;});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
throw Error('Resource request attempts exhausted');
|
|
171
|
+
}finally{clearTimeout(timeout);this.busy=false;if(this.controller===controller)this.controller=null;}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {videoCodecConfig,vp9PacketConfig} from './video-codec-config.js';
|
|
2
|
+
let pendingConfiguration;
|
|
3
|
+
let packetPrefix,needsKey=true;
|
|
4
|
+
let allowSharedPackets=true;
|
|
5
|
+
const noCopy=true;
|
|
6
|
+
// Dedicated service: native decoder pthread waits never block this event loop.
|
|
7
|
+
let memory,pointer,header,view,decoder,configuration,queue=[],generation=0,busy=false;
|
|
8
|
+
let draining=false,flushed=false,failure=null,submitted=0,consumed=0,outputWaitSince=null;
|
|
9
|
+
let faultAfter=0,disabled=false,copiesInFlight=0;
|
|
10
|
+
const copying=new Set(),closed=new WeakSet();
|
|
11
|
+
const packetOffset=80,frameOffset=80+8*1024*1024;
|
|
12
|
+
// errno values are from the pinned Emscripten WASI ABI.
|
|
13
|
+
const AGAIN=-6,EOF=-541478725,IO=-29;
|
|
14
|
+
const stats={packetBytes:0,ownedPacketBytes:0,sharedPacketInputs:0,sharedPacketFallbacks:0,submitted:0,frames:0,receivedFrames:0,closedFrames:0,peakOutstanding:0,peakFrames:0,resets:0,errors:0,copyMs:0};
|
|
15
|
+
const color={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,'bt2020-ncl':9,smpte2084:16,'iec61966-2-1':13};
|
|
16
|
+
function closeFrame(frame){if(closed.has(frame))return;closed.add(frame);frame.close();stats.closedFrames++;}
|
|
17
|
+
function clear(){generation++;if(decoder&&decoder.state!=='closed')decoder.close();decoder=null;for(const frame of queue)closeFrame(frame);for(const frame of copying)closeFrame(frame);queue=[];draining=flushed=false;submitted=consumed=0;outputWaitSince=null;failure=null;}
|
|
18
|
+
async function checkConfiguration(valid){
|
|
19
|
+
// Keep only descriptive fields: initialization bytes can be large and are not
|
|
20
|
+
// useful in a UI error. Retain the exact codec string passed to WebCodecs.
|
|
21
|
+
const describe=config=>({codec:config.codec,codedWidth:config.codedWidth,codedHeight:config.codedHeight,descriptionBytes:config.description?.byteLength??0,hardwareAcceleration:config.hardwareAcceleration,optimizeForLatency:config.optimizeForLatency});
|
|
22
|
+
const requested=describe(configuration);
|
|
23
|
+
stats.supportCheck={requested,source:{...stats.input}};
|
|
24
|
+
let support;
|
|
25
|
+
try{support=await VideoDecoder.isConfigSupported(configuration);}
|
|
26
|
+
catch(error){
|
|
27
|
+
if(!valid())return false;
|
|
28
|
+
stats.supportCheck.error=String(error);
|
|
29
|
+
throw Error(`Browser decoder configuration check failed: ${String(error)}\nRequested configuration: ${JSON.stringify(requested)}`);
|
|
30
|
+
}
|
|
31
|
+
if(!valid())return false;
|
|
32
|
+
stats.supportCheck.supported=support.supported;
|
|
33
|
+
if(support.config)stats.supportCheck.recognized=describe(support.config);
|
|
34
|
+
if(!support.supported){
|
|
35
|
+
const family=({1:'H.264 / AVC',2:'H.265 / HEVC',3:'VP8',4:'VP9',5:'AV1'})[stats.input.kind]??'video';
|
|
36
|
+
const depth=stats.input.depth>0?`${stats.input.depth}-bit`:'not reported by the source';
|
|
37
|
+
const profile=stats.input.profile>=0?stats.input.profile:'unknown';
|
|
38
|
+
const level=stats.input.level>=0?stats.input.level:'unknown';
|
|
39
|
+
throw Error(`Unsupported browser configuration (${family}).\nCodec string: ${requested.codec}\nResolution: ${requested.codedWidth} × ${requested.codedHeight}\nSource bit depth: ${depth}\nSource profile / level IDs: ${profile} / ${level}\nDecoder initialization data: ${requested.descriptionBytes} bytes\nHardware acceleration policy: ${requested.hardwareAcceleration}\nWebCodecs reported supported=false without a specific rejection reason. These details do not establish whether browser support or demuxe's configuration mapping caused the rejection.`);
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
function configure(){
|
|
44
|
+
if(copiesInFlight)throw Error('Previous copy still pending; use software');
|
|
45
|
+
const current=generation;
|
|
46
|
+
decoder=new VideoDecoder({error:error=>{if(current===generation){failure=String(error);stats.errors++;postMessage({wakeup:true});}},output:frame=>{
|
|
47
|
+
stats.receivedFrames++;
|
|
48
|
+
if(current!==generation){closeFrame(frame);return;}
|
|
49
|
+
// Decode completion may release a reorder burst after decodeQueueSize falls.
|
|
50
|
+
// Stop submitting at eight queued frames; retain bounded burst headroom.
|
|
51
|
+
if(queue.length>=32){closeFrame(frame);failure='Frame queue limit';stats.errors++;return;}
|
|
52
|
+
queue.push(frame);postMessage({wakeup:true});stats.peakFrames=Math.max(stats.peakFrames,queue.length);outputWaitSince=null;
|
|
53
|
+
}});
|
|
54
|
+
decoder.addEventListener('dequeue',()=>{if(current===generation)postMessage({wakeup:true});});
|
|
55
|
+
needsKey=true;decoder.configure(configuration);outputWaitSince=null;
|
|
56
|
+
}
|
|
57
|
+
self.onmessage=({data})=>{
|
|
58
|
+
if(data.type==='cancel'){
|
|
59
|
+
disabled=true;clear();
|
|
60
|
+
if(header){const ticket=Atomics.load(header,0);if((ticket&3)===1){header[3]=IO;Atomics.store(header,0,ticket+1);Atomics.notify(header,0);}}
|
|
61
|
+
postMessage({stats:{...stats,outstanding:0,queued:0,active:false}});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
memory=data.memory;pointer=data.pointer;header=new Int32Array(memory,pointer,16);view=new DataView(memory,pointer);
|
|
65
|
+
faultAfter=data.faultAfter??0;disabled=!!data.disabled;
|
|
66
|
+
if(typeof Atomics.waitAsync==='function')void (async()=>{
|
|
67
|
+
const channel=new MessageChannel();
|
|
68
|
+
const yieldTask=()=>new Promise(resolve=>{channel.port1.onmessage=resolve;channel.port2.postMessage(0);});
|
|
69
|
+
for(;;){
|
|
70
|
+
void pump();
|
|
71
|
+
const state=Atomics.load(header,0);
|
|
72
|
+
// The native thread can publish its next request before this load. Do not
|
|
73
|
+
// wait for another notification when that request is already ready.
|
|
74
|
+
if((state&3)===1&&!busy){await yieldTask();continue;}
|
|
75
|
+
await Atomics.waitAsync(header,0,state,1000).value;
|
|
76
|
+
await yieldTask();
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
else setInterval(pump,1);
|
|
80
|
+
postMessage({ready:true});
|
|
81
|
+
};
|
|
82
|
+
async function pump(){
|
|
83
|
+
if(!header)return;
|
|
84
|
+
const ticket=Atomics.load(header,0);if((ticket&3)!==1)return;
|
|
85
|
+
if(busy){
|
|
86
|
+
if(header[2]===5||header[2]===6){clear();header[3]=header[2]===5?0:IO;Atomics.store(header,0,ticket+1);Atomics.notify(header,0);}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
busy=true;let result=0;
|
|
90
|
+
const operation=header[2],current=generation;
|
|
91
|
+
const valid=()=>Atomics.load(header,0)===ticket;
|
|
92
|
+
try{
|
|
93
|
+
if(operation===1){
|
|
94
|
+
clear();if(disabled||typeof VideoDecoder==='undefined')throw Error('VideoDecoder unavailable');
|
|
95
|
+
const size=header[4],w=header[5],h=header[6];
|
|
96
|
+
if(size<0||size>65536)throw Error('Invalid decoder configuration size');
|
|
97
|
+
const description=new Uint8Array(memory,pointer+packetOffset,size).slice();
|
|
98
|
+
stats.input={kind:header[13]||1,width:w,height:h,profile:header[14],level:header[15],depth:header[8],descriptionBytes:size};
|
|
99
|
+
pendingConfiguration=null;
|
|
100
|
+
if(stats.input.kind===4&&(stats.input.profile<0||!stats.input.depth)){
|
|
101
|
+
pendingConfiguration={...stats.input,description};configuration=null;return;
|
|
102
|
+
}
|
|
103
|
+
const adapted=videoCodecConfig({...stats.input,description,depth:header[8]||8});
|
|
104
|
+
configuration=adapted.configuration;packetPrefix=adapted.prefix;stats.codec=configuration.codec;
|
|
105
|
+
if(!await checkConfiguration(valid)||!valid())return;
|
|
106
|
+
configure();
|
|
107
|
+
}else if(operation===5){clear();}
|
|
108
|
+
else if(operation===6){clear();if(configuration)configure();stats.resets++;}
|
|
109
|
+
else{
|
|
110
|
+
if(failure)throw Error(failure);
|
|
111
|
+
if(!decoder&&pendingConfiguration){
|
|
112
|
+
if(operation===4){result=AGAIN;return;}
|
|
113
|
+
if(operation!==2)throw Error('VP9 source ended before initialization');
|
|
114
|
+
const bytes=new Uint8Array(memory,pointer+packetOffset,header[4]);
|
|
115
|
+
const packetConfig=vp9PacketConfig(bytes);
|
|
116
|
+
stats.input={...stats.input,...packetConfig};
|
|
117
|
+
const adapted=videoCodecConfig({...pendingConfiguration,...packetConfig});
|
|
118
|
+
configuration=adapted.configuration;packetPrefix=adapted.prefix;stats.codec=configuration.codec;
|
|
119
|
+
if(!await checkConfiguration(valid)||!valid())return;
|
|
120
|
+
configure();pendingConfiguration=null;
|
|
121
|
+
}
|
|
122
|
+
if(!decoder)throw Error('Decoder is closed');
|
|
123
|
+
if(operation===2){
|
|
124
|
+
if(decoder.decodeQueueSize+queue.length>=8)result=AGAIN;
|
|
125
|
+
else{
|
|
126
|
+
const size=header[4];if(size<1||size>8*1024*1024)throw Error('Packet size limit');
|
|
127
|
+
let bytes=new Uint8Array(memory,pointer+packetOffset,size);stats.packetBytes+=size;
|
|
128
|
+
if(!allowSharedPackets){bytes=bytes.slice();stats.ownedPacketBytes+=bytes.length;}
|
|
129
|
+
if(needsKey&&header[7]&&packetPrefix?.length){const joined=new Uint8Array(packetPrefix.length+bytes.length);joined.set(packetPrefix);joined.set(bytes,packetPrefix.length);bytes=joined;stats.ownedPacketBytes+=joined.length;}
|
|
130
|
+
const timestamp=view.getFloat64(64,true),duration=view.getFloat64(72,true);
|
|
131
|
+
if(!Number.isSafeInteger(timestamp)||!Number.isSafeInteger(duration)||duration<0)throw Error(`Invalid timestamps: ${timestamp}, duration ${duration}`);
|
|
132
|
+
const init={type:header[7]?'key':'delta',timestamp,...(duration?{duration}:{}),data:bytes};
|
|
133
|
+
let chunk;
|
|
134
|
+
// EncodedVideoChunk synchronously copies input when no transfer list is
|
|
135
|
+
// supplied. The producer still owns this mailbox until pump acknowledges
|
|
136
|
+
// it below. Never transfer or detach the shared Wasm heap.
|
|
137
|
+
try{chunk=new EncodedVideoChunk(init);if(bytes.buffer===memory)stats.sharedPacketInputs++;}
|
|
138
|
+
catch(error){
|
|
139
|
+
if(error?.name!=='TypeError'||bytes.buffer!==memory)throw error;
|
|
140
|
+
allowSharedPackets=false;stats.sharedPacketFallbacks++;
|
|
141
|
+
bytes=bytes.slice();stats.ownedPacketBytes+=bytes.length;chunk=new EncodedVideoChunk({...init,data:bytes});
|
|
142
|
+
}
|
|
143
|
+
decoder.decode(chunk);
|
|
144
|
+
needsKey=false;submitted++;stats.submitted++;stats.peakOutstanding=Math.max(stats.peakOutstanding,decoder.decodeQueueSize);
|
|
145
|
+
}
|
|
146
|
+
}else if(operation===3){
|
|
147
|
+
draining=true;const epoch=generation;
|
|
148
|
+
decoder.flush().then(()=>{if(epoch===generation){flushed=true;postMessage({wakeup:true});}},error=>{if(epoch===generation){failure=String(error);postMessage({wakeup:true});}});
|
|
149
|
+
}else if(operation===4){
|
|
150
|
+
if(faultAfter&&stats.frames>=faultAfter)throw Error('Injected decoder failure');
|
|
151
|
+
if(queue.length){
|
|
152
|
+
const frame=queue.shift();
|
|
153
|
+
try{
|
|
154
|
+
const actualWidth=frame.visibleRect.width,actualHeight=frame.visibleRect.height;
|
|
155
|
+
stats.actualWidth=actualWidth;stats.actualHeight=actualHeight;stats.pixelFormat=frame.format;
|
|
156
|
+
const w=noCopy?2:actualWidth,h=noCopy?2:actualHeight;
|
|
157
|
+
if((!noCopy&&!['I420','NV12'].includes(frame.format))||actualWidth<1||actualHeight<1||actualWidth>(noCopy?8192:1920)||actualHeight>(noCopy?8192:1080)||actualWidth*actualHeight>33554432||w<1||h<1||w>1920||h>1080||(w&1)||(h&1))throw Error('Unsupported decoded frame');
|
|
158
|
+
const nv12=frame.format==='NV12';
|
|
159
|
+
const layout=nv12?[{offset:0,stride:w},{offset:w*h,stride:w}]:[{offset:0,stride:w},{offset:w*h,stride:w/2},{offset:w*h*5/4,stride:w/2}];
|
|
160
|
+
if(!noCopy){
|
|
161
|
+
const bytes=new Uint8Array(w*h*3/2),started=performance.now();
|
|
162
|
+
copying.add(frame);copiesInFlight++;
|
|
163
|
+
try{await frame.copyTo(bytes,{layout,rect:frame.visibleRect});}
|
|
164
|
+
finally{copiesInFlight--;copying.delete(frame);}
|
|
165
|
+
stats.copyMs+=performance.now()-started;
|
|
166
|
+
if(!valid()||current!==generation)return;
|
|
167
|
+
new Uint8Array(memory,pointer+frameOffset,bytes.length).set(bytes);
|
|
168
|
+
stats.pixelCopies=(stats.pixelCopies??0)+1;
|
|
169
|
+
}else{
|
|
170
|
+
new Uint8Array(memory,pointer+frameOffset,6).set([16,16,16,16,128,128]);
|
|
171
|
+
stats.placeholderFrames=(stats.placeholderFrames??0)+1;
|
|
172
|
+
}
|
|
173
|
+
header[5]=w;header[6]=h;header[8]=+nv12;
|
|
174
|
+
header[9]=color[frame.colorSpace.primaries]??2;header[10]=color[frame.colorSpace.transfer]??2;
|
|
175
|
+
header[11]=color[frame.colorSpace.matrix]??2;header[12]=+!!frame.colorSpace.fullRange;
|
|
176
|
+
view.setFloat64(64,frame.timestamp,true);view.setFloat64(72,frame.duration??0,true);
|
|
177
|
+
postMessage({retainedFrame:frame,pts:frame.timestamp,generation},[frame]);
|
|
178
|
+
stats.transferredFrames=(stats.transferredFrames??0)+1;
|
|
179
|
+
consumed++;stats.frames++;outputWaitSince=null;result=1;
|
|
180
|
+
}finally{closeFrame(frame);}
|
|
181
|
+
}else if(draining)result=flushed?EOF:0;
|
|
182
|
+
else result=decoder.decodeQueueSize+queue.length>=8?0:AGAIN;
|
|
183
|
+
// Measure an actual blocked receive, not wall time since the last frame:
|
|
184
|
+
// paused/idle periods and packet reordering do not spend the output budget.
|
|
185
|
+
const waiting=!queue.length&&!flushed&&(draining||decoder.decodeQueueSize>=8)&&submitted>consumed;
|
|
186
|
+
if(!waiting)outputWaitSince=null;
|
|
187
|
+
else if(outputWaitSince===null)outputWaitSince=performance.now();
|
|
188
|
+
else if(performance.now()-outputWaitSince>3000){
|
|
189
|
+
stats.watchdog={waitingMs:Math.round(performance.now()-outputWaitSince),decodeQueueSize:decoder.decodeQueueSize,queuedFrames:queue.length,submitted,consumed,draining,flushed,codec:configuration?.codec};
|
|
190
|
+
throw Error(`Decoder output watchdog: ${JSON.stringify(stats.watchdog)}`);
|
|
191
|
+
}
|
|
192
|
+
}else throw Error('Unknown decoder operation');
|
|
193
|
+
}
|
|
194
|
+
}catch(error){failure=String(error);stats.errors++;result=IO;postMessage({error:failure});}
|
|
195
|
+
finally{
|
|
196
|
+
if(valid()){header[3]=result;Atomics.store(header,0,ticket+1);Atomics.notify(header,0);}
|
|
197
|
+
if(operation!==4||stats.frames%30===0)postMessage({stats:{...stats,outstanding:decoder?.decodeQueueSize??0,queued:queue.length,active:!!decoder}});
|
|
198
|
+
busy=false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Canvas video geometry is independent of the bounded diagnostic sample history.
|
|
2
|
+
export function drawRetainedVideo(context, frame, canvas, track) {
|
|
3
|
+
const par = Number(track?.['demux-par']);
|
|
4
|
+
const width = Number.isFinite(par) && par > 0 ? frame.visibleRect.width * par : frame.displayWidth;
|
|
5
|
+
const height = Number.isFinite(par) && par > 0 ? frame.visibleRect.height : frame.displayHeight;
|
|
6
|
+
const rotation = ((Number(track?.['demux-rotation']) || 0) % 360 + 360) % 360;
|
|
7
|
+
const radians = rotation * Math.PI / 180;
|
|
8
|
+
const sin = Math.abs(Math.sin(radians)), cos = Math.abs(Math.cos(radians));
|
|
9
|
+
const scale = Math.min(canvas.width / (width * cos + height * sin), canvas.height / (width * sin + height * cos));
|
|
10
|
+
context.save();
|
|
11
|
+
context.fillStyle = '#000';context.fillRect(0, 0, canvas.width, canvas.height);
|
|
12
|
+
context.translate(canvas.width / 2, canvas.height / 2);context.rotate(radians);
|
|
13
|
+
context.drawImage(frame, -width * scale / 2, -height * scale / 2, width * scale, height * scale);
|
|
14
|
+
context.restore();
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Convert a bounded HLS WebVTT window to one indexed subtitle resource. Preserve
|
|
2
|
+
// cue settings and payload; normalize MPEGTS/LOCAL maps and timestamp wrap.
|
|
3
|
+
const timestamp=s=>{const p=s.split(':').map(Number);if(p.some(n=>!Number.isFinite(n))||p.length<2||p.length>3)throw Error('Invalid WebVTT timestamp');return p.reduce((n,v)=>n*60+v,0);};
|
|
4
|
+
const clock=t=>{t=Math.max(0,Math.round(t*1000));const h=Math.floor(t/3600000),m=Math.floor(t/60000)%60,s=Math.floor(t/1000)%60;return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(t%1000).padStart(3,'0')}`;};
|
|
5
|
+
export function subtitleSegments(text,base){
|
|
6
|
+
const segments=[];let duration,at=0,discontinuity=false;
|
|
7
|
+
for(const raw of text.split(/\r?\n/)){
|
|
8
|
+
const line=raw.trim();if(line.startsWith('#EXTINF:'))duration=Number(line.slice(8).split(',')[0]);
|
|
9
|
+
else if(line==='#EXT-X-DISCONTINUITY')discontinuity=true;
|
|
10
|
+
else if(line&&!line.startsWith('#')){if(!(duration>0)||segments.length>=10000)throw Error('Invalid subtitle segment duration');segments.push({url:new URL(line,base).href,start:at,duration,discontinuity});at+=duration;duration=undefined;discontinuity=false;}
|
|
11
|
+
}
|
|
12
|
+
return {segments,duration:at};
|
|
13
|
+
}
|
|
14
|
+
export function mergeWebVTT(parts){
|
|
15
|
+
let firstMap,previousMap,discontinuityOffset=0;const cues=[],seen=new Set(),styles=new Set();
|
|
16
|
+
for(const part of parts){
|
|
17
|
+
const text=part.text.replace(/^\uFEFF/,'').replaceAll('\r\n','\n');if(!text.startsWith('WEBVTT'))throw Error('Invalid HLS WebVTT');
|
|
18
|
+
const map=/X-TIMESTAMP-MAP\s*=([^\n]+)/.exec(text)?.[1];let shift=0;
|
|
19
|
+
if(map){
|
|
20
|
+
const local=/LOCAL:([^,\s]+)/.exec(map)?.[1],mpeg=/MPEGTS:(\d+)/.exec(map)?.[1];if(!local||!mpeg)throw Error('Invalid WebVTT timestamp map');
|
|
21
|
+
let offset=Number(mpeg)/90000-timestamp(local);const wrap=2**33/90000;
|
|
22
|
+
if(previousMap!==undefined)offset+=Math.round((previousMap-offset)/wrap)*wrap;
|
|
23
|
+
firstMap??=offset;if(part.discontinuity)discontinuityOffset=firstMap+part.start-offset;
|
|
24
|
+
shift=offset+discontinuityOffset;previousMap=offset;
|
|
25
|
+
}
|
|
26
|
+
for(const block of text.split(/\n\s*\n/).slice(1)){
|
|
27
|
+
if(/^(STYLE|REGION)(?:\n|$)/.test(block)){styles.add(block);continue;}
|
|
28
|
+
if(/^NOTE(?:\s|$)/.test(block)||!block.trim())continue;
|
|
29
|
+
const lines=block.split('\n'),i=lines.findIndex(l=>l.includes('-->'));if(i<0)continue;
|
|
30
|
+
const m=/^(\S+)\s+-->\s+(\S+)(.*)$/.exec(lines[i]);if(!m)throw Error('Invalid WebVTT cue');
|
|
31
|
+
const start=timestamp(m[1])+shift,end=timestamp(m[2])+shift;if(end<=start)throw Error('Invalid WebVTT cue interval');
|
|
32
|
+
lines[i]=`${clock(start)} --> ${clock(end)}${m[3]}`;const cue=lines.join('\n');if(!seen.has(cue)){seen.add(cue);cues.push({start,cue});}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const output='WEBVTT\n\n'+[...styles,...cues.sort((a,b)=>a.start-b.start).map(c=>c.cue)].join('\n\n')+'\n';
|
|
36
|
+
if(new TextEncoder().encode(output).byteLength>1024*1024)throw Error('Combined subtitles exceed 1 MiB');return output;
|
|
37
|
+
}
|