refactor(mediabunny): [PKG-MB-05] own video iterators, frame pools and rendering

This commit is contained in:
Harvey Zhao committed 2026-09-12 19:47:46 +08:00
1 parent 5d0b128483
commit 9d037dcbf9
27 files changed
+3652 -595

No files matched your search

@@ -19,18 +19,22 @@ declarations, the factory signature, canvas forwarding, or package entrypoints.
| `playback.ts` | Playback intent, cancellable play/seek/replacement operations and shared pending audio resume |
| `readiness.ts` | One metadata publication after both participants, guarded event sequences |
| `hls-selection.ts` | Source-bound track queries, pairing and active replacement errors |
| `VideoEngine.js` / `AudioEngine.js` | Current native decoding, rendering, seeking, Web Audio clock and scheduling |
| `VideoEngine.ts` | Video capability/setup, source and seek ownership, first frames and terminal cleanup |
| `video-frames.ts` | Shared pending iterator reads, idempotent iterator release and existing frame timing policy |
| `video-renderer.ts` | Owned RAF, stale frame rejection, clock-driven draw/stall/end events |
| `video-poster.ts` | Per-request image callbacks, source guards and handler cleanup |
| `AudioEngine.js` | Remaining Web Audio clock, audio decoding and scheduling implementation |
| `VideoShim.ts` / `EventTarget.ts` | Video-like descriptors, synchronous/Promise forwarding and owned event lifecycle |
| `shim-values.ts` | Existing volume coercion and synthetic TimeRanges values |
| `shim-frames.ts` | Synthetic frame callbacks, per-instance RAF ownership and terminal cleanup |
| `engine-types.ts` / `engine-ports.ts` | Internal shim, coordinator and decoder interfaces |
| `AudioEngine.d.ts` / `VideoEngine.d.ts` | Temporary boundaries for JS decoders, removed in MB-06/05 |
| `AudioEngine.d.ts` | Temporary boundary for the remaining JS audio engine, removed in MB-06 |
| `m3u8.js` | Current ArtPlayer control/setting integration |
The sixteen production TypeScript modules use strict checking with `skipLibCheck: false`
and no ambient Node types. The coordinator is an actual checked implementation. The two
adjacent decoder declarations are temporary boundaries, not checking of their JavaScript
implementations. Remaining JavaScript is scheduled in PKG-MB-05/06/07/08.
The twenty production TypeScript modules use strict checking with `skipLibCheck: false`
and no ambient Node types. The coordinator and video engine are checked implementations.
The adjacent audio declaration is a temporary boundary, not checking of its JavaScript
implementation. Remaining JavaScript is scheduled in PKG-MB-06/07/08.
Do not describe the whole package as migrated yet.
The shim keeps its existing own-property order and direct prototype surface because
@@ -82,7 +86,7 @@ for the seek completion signal, resolved before optional resume to avoid a cycle
A pending AudioContext resume is shared across play-pause-play; only the current operation
starts video and emits play/playing. Source change and destroy invalidate both operations.
Cancellation settles the public operation while observing the underlying late result;
actual late decoder work still belongs to MB-05/06.
video frame ownership is detailed below; remaining audio work belongs to MB-06.
Normal play, pause, seek/resume and HLS replacement event order is retained. Before each
event the coordinator rechecks ownership, since a listener may synchronously pause, seek,
@@ -99,6 +103,43 @@ This corrects the old false-ready state; normal valid input event order is prese
Existing-track unsupported-codec handling remains a decoder capability task, not evidence
that every capability failure has been fixed.
## Video frame ownership
VideoEngine keeps source/load generation separate from seek iteration and renderer activity.
A new source calls cancelPending before track selection starts, so an old seek cannot draw
while the new source is still parsing. Seek during capability detection waits for that load
without canceling its metadata; only the latest seek may install or draw its frames.
Ordinary paused seek still displays its first target frame and queues the second.
Iterator detachment is synchronous. The old return Promise is observed without blocking a
new load on obsolete reads; repeated release calls return the same cleanup Promise. Late
completion cannot clear the current iterator or its fetching flag. Stop/start shares an
already pending next() for the same iterator instead of issuing concurrent reads. Pause
invalidates render work; it does not cancel a legitimate paused seek's target frame.
Each seek after the initial iterator creates a fresh CanvasSink with its own two-canvas pool.
Concurrent obsolete decoders can otherwise overwrite the pixels of a queued frame even when
the proxy discards their returned objects. Sink identity is internal and may change on seek;
public canvas identity stays stable. Tests intercept sink prototypes to follow new instances.
CanvasSink retains poolSize 2, contain fit and track transparency. Its implementation closes
VideoSample after drawing into the pooled canvas. The proxy releases iterators and references;
WrappedCanvas is not a VideoFrame and must not receive close(). Default dropLateFrames remains
false, with the same tolerance/rate formula when enabled. Start preserves the queued second
frame until it is consumed instead of replacing it through premature prefetch. There is no
added frame copy.
Each renderer owns one RAF, including ID zero. Stop/destroy and event-listener reentry make
queued callbacks inert; an instance that never scheduled a frame cannot cancel another
instance's frame. Normal timeupdate, stall recovery and ended ordering is preserved, while
reentrant pause/source/destroy/restart stops the obsolete event sequence. Poster image
callbacks have source and request identity and are detached on replacement, draw or destroy.
Background decoder failure stops rendering and reports one media error through the coordinator,
even if audio cleanup fails. Iterator cleanup failures are observed and warned; explicit
stopIterator still rejects. Missing 2D context fails at use with a descriptive error, rather
than becoming falsely ready. Capability handling for existing undecodable tracks remains
under MB-CAP-01/MB-READY-01; the current no-video fallback is preserved for that separate review.
## Types and dependencies
The locked runtime SDK remains MediaBunny 1.56.1. Its pinned WebCodecs declaration
@@ -118,6 +159,7 @@ node node_modules/typescript/bin/tsc -p packages/artplayer-proxy-mediabunny/tsco
yarn build artplayer-proxy-mediabunny
yarn test:browser test/browser/mediabunny-inputs.spec.js test/browser/mediabunny.spec.js test/browser/mediabunny-load.spec.js
yarn test:browser test/browser/mediabunny-shim.spec.js
yarn test:browser test/browser/mediabunny-video.spec.js
```
Node tests use actual SDK parsing and controlled lifecycle interleavings. Browser tests
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=e=>{throw TypeError(e)},r=(e,t,r)=>t.has(e)||i("Cannot "+r),a=(e,t,i)=>(r(e,t,"read from private field"),i?i.call(e):t.get(e)),s=(e,t,r)=>t.has(e)?i("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,r),n=(e,t,i,a)=>(r(e,t,"write to private field"),a?a.call(e,i):t.set(e,i),i);function o(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class c{constructor(){s(this,e,!1),this.listeners=new Map}addEventListener(t,i){a(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(a(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{a(this,e)||t(r)})}destroy(){n(this,e,!0),this.listeners.clear()}}function l(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const u=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},d=e=>e&&e[e.length-1],h=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},f=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},m=e=>{const t=h(e);return 0===(1&t)?-(t>>1):t+1>>1},p=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),g=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),k=new TextDecoder,y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),b={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},w=y(b),T={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},S=y(T),v={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},P=y(v),I=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,x={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},C=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class B{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const E=/^[0-9a-fA-F]+$/,_=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),R=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,F=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},A=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},D=(e,t,i)=>{const r=A(e,i(t),i);e.splice(r+1,0,t)},M=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},N=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},O=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},L=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},z=e=>{throw new Error(`Unexpected value: ${e}`)},U=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},q=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},W=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),H=(e,t,i)=>Math.max(t,Math.min(i,e)),V="und",j=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},K=(e,t)=>Math.round(e/t)*t,X=(e,t)=>Math.round(e*t)/t,$=(e,t)=>Math.floor(e/t)*t,G=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Q=/^[a-z]{3}$/,Y=e=>Q.test(e),J=1e6*(1+Number.EPSILON),Z=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?ee(e.headers):{},a=t.headers?ee(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},ee=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catch(n){if(a())throw n;s++;const e=r(s,n,t);if(null===e)throw n;if(xe._error("Retrying failed fetch. Error:",n),!Number.isFinite(e)||e<0)throw new TypeError("Retry delayLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,a,s,n,o,c,l,u,d,h,f,m,p,g,k,y,b,w=e=>{throw TypeError(e)},T=(e,t,i)=>t.has(e)||w("Cannot "+i),S=(e,t,i)=>(T(e,t,"read from private field"),i?i.call(e):t.get(e)),v=(e,t,i)=>t.has(e)?w("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),P=(e,t,i,r)=>(T(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),I=(e,t,i)=>(T(e,t,"access private method"),i),x=(e,t,i,r)=>({set _(r){P(e,t,r,i)},get _(){return S(e,t,r)}});function C(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class B{constructor(){v(this,e,!1),this.listeners=new Map}addEventListener(t,i){S(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(S(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{S(this,e)||t(r)})}destroy(){P(this,e,!0),this.listeners.clear()}}function E(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const _=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},R=e=>e&&e[e.length-1],F=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},A=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},D=e=>{const t=F(e);return 0===(1&t)?-(t>>1):t+1>>1},M=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),N=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),O=new TextDecoder,z=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),L={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},U=z(L),q={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},W=z(q),H={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},V=z(H),j=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,K={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},X=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ${constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const G=/^[0-9a-fA-F]+$/,Q=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),Y=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,J=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},Z=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},ee=(e,t,i)=>{const r=Z(e,i(t),i);e.splice(r+1,0,t)},te=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},ie=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},re=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ae=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},se=e=>{throw new Error(`Unexpected value: ${e}`)},ne=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},oe=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},ce=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),le=(e,t,i)=>Math.max(t,Math.min(i,e)),ue="und",de=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},he=(e,t)=>Math.round(e/t)*t,fe=(e,t)=>Math.round(e*t)/t,me=(e,t)=>Math.floor(e/t)*t,pe=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},ge=/^[a-z]{3}$/,ke=e=>ge.test(e),ye=1e6*(1+Number.EPSILON),be=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?we(e.headers):{},a=t.headers?we(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},we=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},Te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catcLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,s=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,l=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),c=e=>{throw TypeError(e)},u=Math.pow,d=(e,t,r)=>t in e?i(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,h=(e,t)=>{for(var i in t||(t={}))a.call(t,i)&&d(e,i,t[i]);if(s)for(var i of s(t))o.call(t,i)&&d(e,i,t[i]);return e},f=(e,t)=>r(e,n(t)),m=(e,t,i)=>t.has(e)||c("Cannot "+i),p=(e,t,i)=>(m(e,t,"read from private field"),i?i.call(e):t.get(e)),g=(e,t,i)=>t.has(e)?c("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),k=(e,t,i,r)=>(m(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),y=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),b=function(e,t){this[0]=e,this[1]=t},T=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof b,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[l("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},v=e=>{var t,i=e[l("asyncIterator")],r=!1,n={};return null==i?(i=e[l("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new b(new Promise(r=>{var n=i[e](t);n instanceof Object||c("Object expected"),r(n)}),1)}}),n[l("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},S=(e,t,i)=>(t=e[l("asyncIterator")])?t.call(e):(e=e[l("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function w(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class P{constructor(){g(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;p(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(p(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{p(this,e)||t(n)})}destroy(){k(this,e,!0),this.listeners.clear()}}function I(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const x=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},C=e=>e&&e[e.length-1],B=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},E=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-u(2,r))},_=e=>{const t=B(e);return 0===(1&t)?-(t>>1):t+1>>1},R=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),F=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),A=new TextDecoder,D=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),M={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},N=D(M),O={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},L=D(O),z={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},U=D(z),q=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,W={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},H=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class V{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return y(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const j=/^[0-9a-fA-F]+$/,K=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),X=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,$=(e,t,i)=>{let r=0,n=e.length-1,s=-1;for(;r<=n;){const a=r+n>>1,o=i(e[a]);o===t?(s=a,n=a-1):o<t?r=a+1:n=a-1}retuLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,n,s,a,o,l,c,u,d,h,f,m,p,g,k,y,b,T=Object.defineProperty,v=Object.defineProperties,S=Object.getOwnPropertyDescriptors,w=Object.getOwnPropertySymbols,P=Object.prototype.hasOwnProperty,I=Object.prototype.propertyIsEnumerable,x=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),C=e=>{throw TypeError(e)},B=Math.pow,E=(e,t,i)=>t in e?T(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,_=(e,t)=>{for(var i in t||(t={}))P.call(t,i)&&E(e,i,t[i]);if(w)for(var i of w(t))I.call(t,i)&&E(e,i,t[i]);return e},R=(e,t)=>v(e,S(t)),F=(e,t,i)=>t.has(e)||C("Cannot "+i),A=(e,t,i)=>(F(e,t,"read from private field"),i?i.call(e):t.get(e)),D=(e,t,i)=>t.has(e)?C("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),M=(e,t,i,r)=>(F(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),N=(e,t,i)=>(F(e,t,"access private method"),i),O=(e,t,i,r)=>({set _(r){M(e,t,r,i)},get _(){return A(e,t,r)}}),z=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),L=function(e,t){this[0]=e,this[1]=t},U=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof L,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[x("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},q=e=>{var t,i=e[x("asyncIterator")],r=!1,n={};return null==i?(i=e[x("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new L(new Promise(r=>{var n=i[e](t);n instanceof Object||C("Object expected"),r(n)}),1)}}),n[x("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},W=(e,t,i)=>(t=e[x("asyncIterator")])?t.call(e):(e=e[x("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function H(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class V{constructor(){D(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;A(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(A(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{A(this,e)||t(n)})}destroy(){M(this,e,!0),this.listeners.clear()}}function j(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const K=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},X=e=>e&&e[e.length-1],$=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},G=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-B(2,r))},Q=e=>{const t=$(e);return 0===(1&t)?-(t>>1):t+1>>1},Y=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),J=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),Z=new TextDecoder,ee=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),te={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},ie=ee(te),re={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ne=ee(re),se={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},ae=ee(se),oe=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,le={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ce=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ue{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return z(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const de=/^[0-9a-fA-F]+$/,he=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),fe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&167Line truncated
@@ -11,7 +11,16 @@ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
var _closed, _playback;
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
var __privateWrapper = (obj, member, setter, getter) => ({
set _(value) {
__privateSet(obj, member, value, setter);
},
get _() {
return __privateGet(obj, member, getter);
}
});
var _closed, _generation, _iteration, _loading, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn, reset_fn, _playback;
const $audio = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>';
const $quality = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>';
function uniqBy(array, property) {
@@ -24741,17 +24750,240 @@ async function preflightRange(url, enabled, events, signal, isCurrent = () => tr
return true;
}
}
const closing = /* @__PURE__ */ new WeakMap();
const reading = /* @__PURE__ */ new WeakMap();
function releaseIterator(iterator) {
if (!iterator)
return Promise.resolve();
const previous = closing.get(iterator);
if (previous)
return previous;
const pending = Promise.resolve().then(() => iterator.return()).then(() => {
});
closing.set(iterator, pending);
return pending;
}
function readCanvas(iterator) {
const previous = reading.get(iterator);
if (previous)
return previous;
const pending = Promise.resolve().then(() => iterator.next()).then((result) => result.value ?? null);
reading.set(iterator, pending);
const clear = () => {
if (reading.get(iterator) === pending)
reading.delete(iterator);
};
pending.then(clear, clear);
return pending;
}
function frameAction(timestamp, time, dropLateFrames, avSyncTolerance, playbackRate) {
const tolerance = dropLateFrames ? Math.max(0.06, avSyncTolerance / Math.max(1, playbackRate)) : 0;
if (dropLateFrames && timestamp < time - tolerance)
return "skip";
return timestamp <= time + tolerance ? "draw" : "queue";
}
class Poster {
constructor() {
this.image = null;
}
cancel() {
if (!this.image)
return;
this.image.onload = null;
this.image.onerror = null;
this.image = null;
}
draw(source, current, draw, failure) {
this.cancel();
const image = new Image();
this.image = image;
image.onload = () => {
if (this.image !== image || !current())
return;
this.cancel();
try {
draw(image);
} catch (error) {
failure(error);
}
};
image.onerror = () => {
if (this.image === image)
this.cancel();
};
image.src = source;
}
}
class Renderer {
constructor(host, ready, draw, failure) {
this.host = host;
this.ready = ready;
this.draw = draw;
this.failure = failure;
this.stopped = false;
this.closed = false;
this.scheduled = null;
this.fetching = null;
}
current(id) {
return !this.closed && !this.stopped && this.host.asyncId === id;
}
async update(id) {
const iterator = this.host.videoIterator;
if (!iterator || !this.ready() || !this.current(id) || this.host.isFetching)
return;
const request = {};
this.fetching = request;
this.host.isFetching = true;
const current = () => this.current(id) && this.host.videoIterator === iterator;
try {
while (current()) {
const frame = await readCanvas(iterator);
if (!current() || !frame)
return;
const time = this.host.audioClock?.currentTime;
if (time === void 0)
return;
const action = frameAction(frame.timestamp, time, this.host.dropLateFrames, this.host.avSyncTolerance, this.host.playbackRate);
if (action === "skip")
continue;
if (action === "draw") {
this.draw(frame);
} else {
this.host.nextFrame = frame;
return;
}
}
} catch (error) {
if (current())
throw error;
} finally {
if (this.fetching === request) {
this.fetching = null;
this.host.isFetching = false;
}
}
}
request(id) {
void this.update(id).catch((error) => {
if (!this.current(id))
return;
this.stop();
this.report(error);
});
}
report(error) {
try {
this.failure(error);
} catch (failure) {
console.warn("MediaBunny video error listener:", failure);
}
}
cancelFrame() {
if (this.scheduled === null)
return;
cancelAnimationFrame(this.scheduled);
this.scheduled = null;
}
schedule(id) {
if (!this.current(id))
return;
this.cancelFrame();
const frame = requestAnimationFrame(() => {
if (this.scheduled !== frame || !this.current(id))
return;
this.scheduled = null;
try {
this.render();
} catch (error) {
this.stop();
this.report(error);
}
});
this.scheduled = frame;
this.host.rafId = frame;
}
render() {
const id = this.host.asyncId;
const current = () => this.current(id);
if (!current() || !this.host.audioClock)
return;
const time = this.host.audioClock.currentTime;
const now = Date.now();
if (now - this.host.lastTimeUpdate >= this.host.timeupdateInterval) {
this.host.events.emit("timeupdate");
if (!current())
return;
this.host.lastTimeUpdate = now;
}
if (!this.ready()) {
this.schedule(id);
return;
}
if (Number.isFinite(this.host.duration) && time >= this.host.duration) {
this.stop();
this.host.stalled = false;
const ended = this.host.asyncId;
publish(this.host.events, () => !this.closed && this.host.asyncId === ended, ["ended", "pause", "canplay"]);
return;
}
if (this.host.nextFrame && this.host.nextFrame.timestamp <= time) {
this.draw(this.host.nextFrame);
this.host.nextFrame = null;
this.request(id);
if (this.host.stalled) {
publish(this.host.events, current, ["canplay", "playing"]);
if (!current())
return;
this.host.stalled = false;
}
} else if (!this.host.nextFrame) {
this.request(id);
if (Number.isFinite(this.host.duration) && time < this.host.duration && !this.host.stalled) {
this.host.stalled = true;
this.host.events.emit("waiting");
}
}
this.schedule(id);
}
start(clock) {
if (this.closed)
return;
this.stop();
this.stopped = false;
this.host.audioClock = clock;
this.host.stalled = false;
if (!this.host.nextFrame)
this.request(this.host.asyncId);
this.schedule(this.host.asyncId);
}
stop() {
this.stopped = true;
this.host.asyncId++;
this.cancelFrame();
this.fetching = null;
this.host.isFetching = false;
}
destroy() {
if (this.closed)
return;
this.stop();
this.closed = true;
}
}
class VideoEngine {
constructor({
canvas,
ctx,
events,
timeupdateInterval = 250,
avSyncTolerance = 0.12,
dropLateFrames = false,
poster = "",
preflightRange: preflightRange2 = false
}) {
constructor({ canvas, ctx, events, timeupdateInterval = 250, avSyncTolerance = 0.12, dropLateFrames = false, poster = "", preflightRange: preflightRange2 = false, onError }) {
__privateAdd(this, _VideoEngine_instances);
__privateAdd(this, _generation, 0);
__privateAdd(this, _iteration, 0);
__privateAdd(this, _loading, null);
__privateAdd(this, _createSink, null);
__privateAdd(this, _usedSink, null);
__privateAdd(this, _destroyed, false);
__privateAdd(this, _preparing, false);
__privateAdd(this, _poster, new Poster());
__privateAdd(this, _renderer);
__privateAdd(this, _onError);
this.canvas = canvas;
this.ctx = ctx;
this.events = events;
@@ -24775,33 +25007,171 @@ class VideoEngine {
this.playbackRate = 1;
this.posterDrawn = false;
this.isFetching = false;
__privateSet(this, _onError, onError);
__privateSet(this, _renderer, new Renderer(this, () => !__privateGet(this, _preparing), (frame) => __privateMethod(this, _VideoEngine_instances, draw_fn).call(this, frame, true), (error) => __privateMethod(this, _VideoEngine_instances, report_fn).call(this, error)));
}
async preflight(url, signal, isCurrent) {
return preflightRange(url, this.preflightRange, this.events, signal, isCurrent);
async preflight(source, signal, current) {
return preflightRange(source, this.preflightRange, this.events, signal, current);
}
drawPoster() {
if (!this.poster || this.posterDrawn)
if (__privateGet(this, _destroyed) || !this.poster || this.posterDrawn)
return;
const img = new Image();
img.onload = () => {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.canvas.width = img.naturalWidth || this.canvas.width;
this.canvas.height = img.naturalHeight || this.canvas.height;
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
const generation = __privateGet(this, _generation);
__privateGet(this, _poster).draw(this.poster, () => __privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation), (image) => {
this.clear();
this.canvas.width = image.naturalWidth || this.canvas.width;
this.canvas.height = image.naturalHeight || this.canvas.height;
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(image, 0, 0, this.canvas.width, this.canvas.height);
this.posterDrawn = true;
};
img.src = this.poster;
}, (error) => __privateMethod(this, _VideoEngine_instances, report_fn).call(this, error));
}
async stopIterator() {
await this.videoIterator?.return();
const iterator = this.videoIterator;
this.videoIterator = null;
this.nextFrame = null;
await releaseIterator(iterator);
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).clearRect(0, 0, this.canvas.width, this.canvas.height);
}
cancelPending() {
__privateWrapper(this, _generation)._++;
__privateWrapper(this, _iteration)._++;
this.stop();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, false);
__privateSet(this, _loading, null);
__privateSet(this, _createSink, null);
__privateSet(this, _usedSink, null);
this.videoSink = null;
this.input = null;
}
async load(media, onMetadata) {
++this.asyncId;
await this.stopIterator();
if (__privateGet(this, _destroyed))
return;
this.cancelPending();
const generation = __privateGet(this, _generation);
const iteration = __privateGet(this, _iteration);
__privateSet(this, _preparing, true);
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn).call(this, media, generation, iteration, onMetadata));
__privateSet(this, _loading, pending);
try {
await pending;
} finally {
if (__privateGet(this, _loading) === pending)
__privateSet(this, _loading, null);
}
}
handleNoVideoTrack() {
if (__privateGet(this, _destroyed))
return;
this.videoSink = null;
this.width = 0;
this.height = 0;
this.canvas.width = 0;
this.canvas.height = 0;
this.clear();
this.drawPoster();
}
async resetIterator(time) {
if (__privateGet(this, _destroyed))
return;
const generation = __privateGet(this, _generation);
const iteration = ++__privateWrapper(this, _iteration)._;
const loading = __privateGet(this, _loading);
this.stop();
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, true);
try {
if (loading)
await loading;
await __privateMethod(this, _VideoEngine_instances, reset_fn).call(this, time, generation, iteration);
} catch (error) {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration) {
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
throw error;
}
} finally {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration)
__privateSet(this, _preparing, false);
}
}
async updateNextFrame(localId) {
await __privateGet(this, _renderer).update(localId);
}
render() {
__privateGet(this, _renderer).render();
}
start(audio) {
__privateGet(this, _renderer).start(audio);
}
stop() {
__privateGet(this, _renderer).stop();
}
async seek(time) {
await this.resetIterator(time);
}
setPlaybackRate(rate) {
this.playbackRate = Math.max(0.1, Number(rate) || 1);
}
destroy() {
if (__privateGet(this, _destroyed))
return;
__privateSet(this, _destroyed, true);
__privateWrapper(this, _generation)._++;
__privateGet(this, _renderer).destroy();
__privateGet(this, _poster).cancel();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
this.posterDrawn = false;
__privateSet(this, _createSink, null);
__privateSet(this, _usedSink, null);
this.videoSink = null;
this.input = null;
this.audioClock = null;
}
}
_generation = new WeakMap();
_iteration = new WeakMap();
_loading = new WeakMap();
_createSink = new WeakMap();
_usedSink = new WeakMap();
_destroyed = new WeakMap();
_preparing = new WeakMap();
_poster = new WeakMap();
_renderer = new WeakMap();
_onError = new WeakMap();
_VideoEngine_instances = new WeakSet();
current_fn = function(generation) {
return !__privateGet(this, _destroyed) && __privateGet(this, _generation) === generation;
};
context_fn = function() {
if (!this.ctx)
throw new Error("Canvas 2D context is unavailable.");
return this.ctx;
};
report_fn = function(error) {
if (__privateGet(this, _destroyed))
return;
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
if (__privateGet(this, _onError))
__privateGet(this, _onError).call(this, error);
else
this.events.emit("error", error);
};
release_fn = function() {
void this.stopIterator().catch((error) => console.warn("MediaBunny iterator cleanup error:", error));
};
draw_fn = function(frame, clear) {
__privateGet(this, _poster).cancel();
if (clear)
this.clear();
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(frame.canvas, 0, 0);
};
load_fn = async function(media, generation, iteration, onMetadata) {
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
try {
this.clear();
this.posterDrawn = false;
const { input, videoTrack, duration } = media;
@@ -24812,139 +25182,61 @@ class VideoEngine {
onMetadata?.();
return;
}
if (videoTrack.codec === null || !await videoTrack.canDecode()) {
const decodable = videoTrack.codec !== null && await videoTrack.canDecode();
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
if (!decodable) {
this.handleNoVideoTrack();
onMetadata?.();
return;
}
const transparent = await videoTrack.canBeTransparent();
this.videoSink = new CanvasSink(videoTrack, {
poolSize: 2,
fit: "contain",
alpha: transparent
});
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
__privateSet(this, _createSink, () => new CanvasSink(videoTrack, { poolSize: 2, fit: "contain", alpha: transparent }));
this.videoSink = __privateGet(this, _createSink).call(this);
this.width = videoTrack.displayWidth;
this.height = videoTrack.displayHeight;
this.canvas.width = this.width;
this.canvas.height = this.height;
onMetadata?.();
await this.resetIterator(0);
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
await __privateMethod(this, _VideoEngine_instances, reset_fn).call(this, 0, generation, iteration);
} catch (error) {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation)) {
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
throw error;
}
} finally {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration)
__privateSet(this, _preparing, false);
}
handleNoVideoTrack() {
this.videoSink = null;
this.width = 0;
this.height = 0;
this.canvas.width = 0;
this.canvas.height = 0;
this.clear();
};
reset_fn = async function(time, generation, iteration) {
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) || __privateGet(this, _iteration) !== iteration)
return;
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
if (__privateGet(this, _createSink) && this.videoSink === __privateGet(this, _usedSink))
this.videoSink = __privateGet(this, _createSink).call(this);
const sink = this.videoSink;
if (!sink || !__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
__privateSet(this, _usedSink, sink);
const iterator = sink.canvases(time);
this.videoIterator = iterator;
const current = () => __privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration && this.videoIterator === iterator;
const first = await readCanvas(iterator);
if (!current())
return;
const second = await readCanvas(iterator);
if (!current())
return;
this.nextFrame = second;
if (first)
__privateMethod(this, _VideoEngine_instances, draw_fn).call(this, first, false);
else
this.drawPoster();
}
async resetIterator(time) {
await this.stopIterator();
if (!this.videoSink)
return;
this.videoIterator = this.videoSink.canvases(time);
const first = (await this.videoIterator.next()).value ?? null;
const second = (await this.videoIterator.next()).value ?? null;
this.nextFrame = second;
if (first) {
this.ctx.drawImage(first.canvas, 0, 0);
} else {
this.drawPoster();
}
}
async updateNextFrame(localId) {
if (!this.videoIterator || this.isFetching)
return;
this.isFetching = true;
try {
while (true) {
const frame = (await this.videoIterator.next()).value ?? null;
if (!frame || localId !== this.asyncId)
return;
const t = this.audioClock.currentTime;
const tolerance = this.dropLateFrames ? Math.max(0.06, this.avSyncTolerance / Math.max(1, this.playbackRate)) : 0;
if (this.dropLateFrames && frame.timestamp < t - tolerance) {
continue;
}
if (frame.timestamp <= t + tolerance) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(frame.canvas, 0, 0);
if (!this.dropLateFrames && frame.timestamp > t) {
this.nextFrame = null;
return;
}
} else {
this.nextFrame = frame;
return;
}
}
} finally {
this.isFetching = false;
}
}
render() {
if (!this.audioClock)
return;
const t = this.audioClock.currentTime;
const now = Date.now();
if (now - this.lastTimeUpdate >= this.timeupdateInterval) {
this.events.emit("timeupdate");
this.lastTimeUpdate = now;
}
if (Number.isFinite(this.duration) && t >= this.duration) {
this.stop();
this.stalled = false;
this.events.emit("ended");
this.events.emit("pause");
this.events.emit("canplay");
return;
}
if (this.nextFrame && this.nextFrame.timestamp <= t) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(this.nextFrame.canvas, 0, 0);
this.nextFrame = null;
this.updateNextFrame(this.asyncId);
if (this.stalled) {
this.events.emit("canplay");
this.events.emit("playing");
this.stalled = false;
}
} else if (!this.nextFrame) {
this.updateNextFrame(this.asyncId);
if (!this.nextFrame && Number.isFinite(this.duration) && t < this.duration && !this.stalled) {
this.stalled = true;
this.events.emit("waiting");
}
}
this.rafId = requestAnimationFrame(() => this.render());
}
start(audioEngine) {
this.audioClock = audioEngine;
this.asyncId++;
this.stalled = false;
this.updateNextFrame(this.asyncId);
this.rafId = requestAnimationFrame(() => this.render());
}
stop() {
cancelAnimationFrame(this.rafId);
}
async seek(time) {
this.asyncId++;
await this.resetIterator(time);
}
setPlaybackRate(rate) {
this.playbackRate = Math.max(0.1, Number(rate) || 1);
}
destroy() {
this.asyncId++;
this.stop();
this.stopIterator();
this.posterDrawn = false;
this.videoSink = null;
this.input = null;
}
}
};
class MediaBunnyEngine {
constructor({ canvas, ctx, events, option = {} }) {
__privateAdd(this, _playback);
@@ -24959,7 +25251,15 @@ class MediaBunnyEngine {
avSyncTolerance: option.avSyncTolerance ?? 0.12,
dropLateFrames: option.dropLateFrames ?? false,
poster: option.poster ?? "",
preflightRange: option.preflightRange ?? false
preflightRange: option.preflightRange ?? false,
onError: (error) => {
try {
this.pause();
} catch (failure) {
console.warn("MediaBunny video failure cleanup:", failure);
}
this.reportError(error);
}
});
this.paused = true;
this.ended = false;
@@ -24986,6 +25286,7 @@ class MediaBunnyEngine {
return;
const id = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
this.loadSession?.cancel();
if (id !== this.loadSeq || this.destroyed)
return;
@@ -25009,6 +25310,7 @@ class MediaBunnyEngine {
return;
const failed = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
session.cancel();
if (failed !== this.loadSeq || this.destroyed)
return;
@@ -45,6 +45,15 @@ export default class MediaBunnyEngine implements EnginePort {
dropLateFrames: option.dropLateFrames ?? false,
poster: option.poster ?? '',
preflightRange: option.preflightRange ?? false,
onError: (error) => {
try {
this.pause()
}
catch (failure) {
console.warn('MediaBunny video failure cleanup:', failure)
}
this.reportError(error)
},
})
this.paused = true
this.ended = false
@@ -72,6 +81,7 @@ export default class MediaBunnyEngine implements EnginePort {
return
const id = ++this.loadSeq
this.#playback.invalidate()
this.video.cancelPending()
this.loadSession?.cancel()
if (id !== this.loadSeq || this.destroyed)
return
@@ -96,6 +106,7 @@ export default class MediaBunnyEngine implements EnginePort {
return
const failed = ++this.loadSeq
this.#playback.invalidate()
this.video.cancelPending()
session.cancel()
if (failed !== this.loadSeq || this.destroyed)
return
@@ -1,5 +0,0 @@
import type { VideoOptions, VideoPort } from './engine-ports'
// Exact coordinator-facing port; implementation migration and frame ownership are MB-05.
declare const VideoEngine: new (options: VideoOptions) => VideoPort
export default VideoEngine
@@ -1,272 +0,0 @@
/**
* Video Engine for MediaBunny
* Handles video frame rendering and synchronization
*/
import {
CanvasSink,
} from 'mediabunny'
import { preflightRange } from './preflight'
export default class VideoEngine {
constructor({
canvas,
ctx,
events,
timeupdateInterval = 250,
avSyncTolerance = 0.12,
dropLateFrames = false,
poster = '',
preflightRange = false,
}) {
this.canvas = canvas
this.ctx = ctx
this.events = events
this.timeupdateInterval = timeupdateInterval
this.avSyncTolerance = avSyncTolerance
this.dropLateFrames = dropLateFrames
this.poster = poster
this.preflightRange = preflightRange
// MediaBunny instances
this.input = null
this.videoSink = null
this.videoIterator = null
// Frame rendering
this.nextFrame = null
this.rafId = 0
this.asyncId = 0
// Video properties
this.width = 0
this.height = 0
this.duration = Number.NaN
// Playback state
this.audioClock = null
this.lastTimeUpdate = 0
this.stalled = false
this.playbackRate = 1
this.posterDrawn = false
this.isFetching = false
}
async preflight(url, signal, isCurrent) {
return preflightRange(url, this.preflightRange, this.events, signal, isCurrent)
}
drawPoster() {
if (!this.poster || this.posterDrawn)
return
const img = new Image()
img.onload = () => {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.canvas.width = img.naturalWidth || this.canvas.width
this.canvas.height = img.naturalHeight || this.canvas.height
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height)
this.posterDrawn = true
}
img.src = this.poster
}
async stopIterator() {
await this.videoIterator?.return()
this.videoIterator = null
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
async load(media, onMetadata) {
++this.asyncId
await this.stopIterator()
this.clear()
this.posterDrawn = false
const { input, videoTrack, duration } = media
this.input = input
this.duration = duration
if (!videoTrack) {
this.handleNoVideoTrack()
onMetadata?.()
return
}
if (videoTrack.codec === null || !(await videoTrack.canDecode())) {
this.handleNoVideoTrack()
onMetadata?.()
return
}
const transparent = await videoTrack.canBeTransparent()
this.videoSink = new CanvasSink(videoTrack, {
poolSize: 2,
fit: 'contain',
alpha: transparent,
})
this.width = videoTrack.displayWidth
this.height = videoTrack.displayHeight
this.canvas.width = this.width
this.canvas.height = this.height
onMetadata?.()
await this.resetIterator(0)
}
handleNoVideoTrack() {
this.videoSink = null
this.width = 0
this.height = 0
this.canvas.width = 0
this.canvas.height = 0
this.clear()
this.drawPoster()
}
async resetIterator(time) {
await this.stopIterator()
if (!this.videoSink)
return
this.videoIterator = this.videoSink.canvases(time)
const first = (await this.videoIterator.next()).value ?? null
const second = (await this.videoIterator.next()).value ?? null
this.nextFrame = second
if (first) {
this.ctx.drawImage(first.canvas, 0, 0)
}
else {
this.drawPoster()
}
}
async updateNextFrame(localId) {
if (!this.videoIterator || this.isFetching)
return
this.isFetching = true
try {
while (true) {
const frame = (await this.videoIterator.next()).value ?? null
if (!frame || localId !== this.asyncId)
return
const t = this.audioClock.currentTime
const tolerance = this.dropLateFrames
? Math.max(0.06, this.avSyncTolerance / Math.max(1, this.playbackRate))
: 0
if (this.dropLateFrames && frame.timestamp < t - tolerance) {
// Skip late frame
continue
}
if (frame.timestamp <= t + tolerance) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.drawImage(frame.canvas, 0, 0)
if (!this.dropLateFrames && frame.timestamp > t) {
this.nextFrame = null
return
}
}
else {
this.nextFrame = frame
return
}
}
}
finally {
this.isFetching = false
}
}
render() {
if (!this.audioClock)
return
const t = this.audioClock.currentTime
const now = Date.now()
// Emit timeupdate event
if (now - this.lastTimeUpdate >= this.timeupdateInterval) {
this.events.emit('timeupdate')
this.lastTimeUpdate = now
}
// Check if reached end
if (Number.isFinite(this.duration) && t >= this.duration) {
this.stop()
this.stalled = false
this.events.emit('ended')
this.events.emit('pause')
this.events.emit('canplay')
return
}
// Render next frame if ready
if (this.nextFrame && this.nextFrame.timestamp <= t) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.drawImage(this.nextFrame.canvas, 0, 0)
this.nextFrame = null
this.updateNextFrame(this.asyncId)
if (this.stalled) {
this.events.emit('canplay')
this.events.emit('playing')
this.stalled = false
}
}
else if (!this.nextFrame) {
this.updateNextFrame(this.asyncId)
if (!this.nextFrame && Number.isFinite(this.duration) && t < this.duration && !this.stalled) {
this.stalled = true
this.events.emit('waiting')
}
}
this.rafId = requestAnimationFrame(() => this.render())
}
start(audioEngine) {
this.audioClock = audioEngine
this.asyncId++
this.stalled = false
this.updateNextFrame(this.asyncId)
this.rafId = requestAnimationFrame(() => this.render())
}
stop() {
cancelAnimationFrame(this.rafId)
}
async seek(time) {
this.asyncId++
await this.resetIterator(time)
}
setPlaybackRate(rate) {
this.playbackRate = Math.max(0.1, Number(rate) || 1)
}
destroy() {
this.asyncId++
this.stop()
this.stopIterator()
this.posterDrawn = false
this.videoSink = null
this.input = null
}
}
@@ -0,0 +1,316 @@
import type { Input, WrappedCanvas } from 'mediabunny'
import type { VideoOptions, VideoPort } from './engine-ports'
import type EventTarget from './EventTarget'
import type { PlaybackMedia } from './media'
import type { FrameIterator } from './video-frames'
import { CanvasSink } from 'mediabunny'
import { preflightRange } from './preflight'
import { readCanvas, releaseIterator } from './video-frames'
import Poster from './video-poster'
import Renderer from './video-renderer'
export default class VideoEngine implements VideoPort {
declare canvas: HTMLCanvasElement
declare ctx: CanvasRenderingContext2D | null
declare events: EventTarget
declare timeupdateInterval: number
declare avSyncTolerance: number
declare dropLateFrames: boolean
declare poster: string
declare preflightRange: boolean
declare input: Input | null
declare videoSink: CanvasSink | null
declare videoIterator: FrameIterator | null
declare nextFrame: WrappedCanvas | null
declare rafId: number
declare asyncId: number
declare width: number
declare height: number
declare duration: number
declare audioClock: { readonly currentTime: number } | null
declare lastTimeUpdate: number
declare stalled: boolean
declare playbackRate: number
declare posterDrawn: boolean
declare isFetching: boolean
#generation = 0
#iteration = 0
#loading: Promise<void> | null = null
#createSink: (() => CanvasSink) | null = null
#usedSink: CanvasSink | null = null
#destroyed = false
#preparing = false
#poster = new Poster()
#renderer: Renderer
#onError: ((error: unknown) => void) | undefined
constructor({ canvas, ctx, events, timeupdateInterval = 250, avSyncTolerance = 0.12, dropLateFrames = false, poster = '', preflightRange = false, onError }: VideoOptions) {
this.canvas = canvas
this.ctx = ctx
this.events = events
this.timeupdateInterval = timeupdateInterval
this.avSyncTolerance = avSyncTolerance
this.dropLateFrames = dropLateFrames
this.poster = poster
this.preflightRange = preflightRange
this.input = null
this.videoSink = null
this.videoIterator = null
this.nextFrame = null
this.rafId = 0
this.asyncId = 0
this.width = 0
this.height = 0
this.duration = Number.NaN
this.audioClock = null
this.lastTimeUpdate = 0
this.stalled = false
this.playbackRate = 1
this.posterDrawn = false
this.isFetching = false
this.#onError = onError
this.#renderer = new Renderer(this, () => !this.#preparing, frame => this.#draw(frame, true), error => this.#report(error))
}
#current(generation: number): boolean {
return !this.#destroyed && this.#generation === generation
}
#context(): CanvasRenderingContext2D {
if (!this.ctx)
throw new Error('Canvas 2D context is unavailable.')
return this.ctx
}
#report(error: unknown): void {
if (this.#destroyed)
return
this.#release()
if (this.#onError)
this.#onError(error)
else
this.events.emit('error', error)
}
#release(): void {
void this.stopIterator().catch(error => console.warn('MediaBunny iterator cleanup error:', error))
}
#draw(frame: WrappedCanvas, clear: boolean): void {
this.#poster.cancel()
if (clear)
this.clear()
this.#context().drawImage(frame.canvas, 0, 0)
}
async preflight(source: unknown, signal?: AbortSignal, current?: () => boolean): Promise<boolean> {
return preflightRange(source, this.preflightRange, this.events, signal, current)
}
drawPoster(): void {
if (this.#destroyed || !this.poster || this.posterDrawn)
return
const generation = this.#generation
this.#poster.draw(this.poster, () => this.#current(generation), (image) => {
this.clear()
this.canvas.width = image.naturalWidth || this.canvas.width
this.canvas.height = image.naturalHeight || this.canvas.height
this.#context().drawImage(image, 0, 0, this.canvas.width, this.canvas.height)
this.posterDrawn = true
}, error => this.#report(error))
}
async stopIterator(): Promise<void> {
const iterator = this.videoIterator
this.videoIterator = null
this.nextFrame = null
await releaseIterator(iterator)
}
clear(): void {
this.#context().clearRect(0, 0, this.canvas.width, this.canvas.height)
}
cancelPending(): void {
this.#generation++
this.#iteration++
this.stop()
this.#release()
this.#poster.cancel()
this.#preparing = false
this.#loading = null
this.#createSink = null
this.#usedSink = null
this.videoSink = null
this.input = null
}
async load(media: PlaybackMedia, onMetadata?: () => void): Promise<void> {
if (this.#destroyed)
return
this.cancelPending()
const generation = this.#generation
const iteration = this.#iteration
this.#preparing = true
const pending = Promise.resolve().then(() => this.#load(media, generation, iteration, onMetadata))
this.#loading = pending
try {
await pending
}
finally {
if (this.#loading === pending)
this.#loading = null
}
}
async #load(media: PlaybackMedia, generation: number, iteration: number, onMetadata?: () => void): Promise<void> {
if (!this.#current(generation))
return
try {
this.clear()
this.posterDrawn = false
const { input, videoTrack, duration } = media
this.input = input
this.duration = duration
if (!videoTrack) {
this.handleNoVideoTrack()
onMetadata?.()
return
}
const decodable = videoTrack.codec !== null && await videoTrack.canDecode()
if (!this.#current(generation))
return
if (!decodable) {
this.handleNoVideoTrack()
onMetadata?.()
return
}
const transparent = await videoTrack.canBeTransparent()
if (!this.#current(generation))
return
this.#createSink = () => new CanvasSink(videoTrack, { poolSize: 2, fit: 'contain', alpha: transparent })
this.videoSink = this.#createSink()
this.width = videoTrack.displayWidth
this.height = videoTrack.displayHeight
this.canvas.width = this.width
this.canvas.height = this.height
onMetadata?.()
if (this.#current(generation))
await this.#reset(0, generation, iteration)
}
catch (error) {
if (this.#current(generation)) {
this.#release()
throw error
}
}
finally {
if (this.#current(generation) && this.#iteration === iteration)
this.#preparing = false
}
}
handleNoVideoTrack(): void {
if (this.#destroyed)
return
this.videoSink = null
this.width = 0
this.height = 0
this.canvas.width = 0
this.canvas.height = 0
this.clear()
this.drawPoster()
}
async #reset(time: number, generation: number, iteration: number): Promise<void> {
if (!this.#current(generation) || this.#iteration !== iteration)
return
this.#release()
if (this.#createSink && this.videoSink === this.#usedSink)
this.videoSink = this.#createSink()
const sink = this.videoSink
if (!sink || !this.#current(generation))
return
this.#usedSink = sink
const iterator = sink.canvases(time)
this.videoIterator = iterator
const current = () => this.#current(generation) && this.#iteration === iteration && this.videoIterator === iterator
const first = await readCanvas(iterator)
if (!current())
return
const second = await readCanvas(iterator)
if (!current())
return
this.nextFrame = second
if (first)
this.#draw(first, false)
else
this.drawPoster()
}
async resetIterator(time: number): Promise<void> {
if (this.#destroyed)
return
const generation = this.#generation
const iteration = ++this.#iteration
const loading = this.#loading
this.stop()
this.#poster.cancel()
this.#preparing = true
try {
if (loading)
await loading
await this.#reset(time, generation, iteration)
}
catch (error) {
if (this.#current(generation) && this.#iteration === iteration) {
this.#release()
throw error
}
}
finally {
if (this.#current(generation) && this.#iteration === iteration)
this.#preparing = false
}
}
async updateNextFrame(localId: number): Promise<void> {
await this.#renderer.update(localId)
}
render(): void {
this.#renderer.render()
}
start(audio: { readonly currentTime: number }): void {
this.#renderer.start(audio)
}
stop(): void {
this.#renderer.stop()
}
async seek(time: number): Promise<void> {
await this.resetIterator(time)
}
setPlaybackRate(rate: number): void {
this.playbackRate = Math.max(0.1, Number(rate) || 1)
}
destroy(): void {
if (this.#destroyed)
return
this.#destroyed = true
this.#generation++
this.#renderer.destroy()
this.#poster.cancel()
this.#release()
this.posterDrawn = false
this.#createSink = null
this.#usedSink = null
this.videoSink = null
this.input = null
this.audioClock = null
}
}
@@ -18,6 +18,7 @@ export interface AudioPort {
}
export interface VideoPort {
cancelPending: () => void
width: number
height: number
duration: number
@@ -32,6 +33,7 @@ export interface VideoPort {
}
export interface VideoOptions {
onError?: (error: unknown) => void
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D | null
events: EventTarget
@@ -0,0 +1,37 @@
import type { WrappedCanvas } from 'mediabunny'
export type FrameIterator = Pick<AsyncGenerator<WrappedCanvas, void, unknown>, 'next' | 'return'>
const closing = new WeakMap<FrameIterator, Promise<void>>()
const reading = new WeakMap<FrameIterator, Promise<WrappedCanvas | null>>()
export function releaseIterator(iterator: FrameIterator | null): Promise<void> {
if (!iterator)
return Promise.resolve()
const previous = closing.get(iterator)
if (previous)
return previous
const pending = Promise.resolve().then(() => iterator.return()).then(() => {})
closing.set(iterator, pending)
return pending
}
export function readCanvas(iterator: FrameIterator): Promise<WrappedCanvas | null> {
const previous = reading.get(iterator)
if (previous)
return previous
const pending = Promise.resolve().then(() => iterator.next()).then(result => result.value ?? null)
reading.set(iterator, pending)
const clear = () => {
if (reading.get(iterator) === pending)
reading.delete(iterator)
}
pending.then(clear, clear)
return pending
}
export function frameAction(timestamp: number, time: number, dropLateFrames: boolean, avSyncTolerance: number, playbackRate: number): 'skip' | 'draw' | 'queue' {
const tolerance = dropLateFrames ? Math.max(0.06, avSyncTolerance / Math.max(1, playbackRate)) : 0
if (dropLateFrames && timestamp < time - tolerance)
return 'skip'
return timestamp <= time + tolerance ? 'draw' : 'queue'
}
@@ -0,0 +1,33 @@
export default class Poster {
private image: HTMLImageElement | null = null
cancel(): void {
if (!this.image)
return
this.image.onload = null
this.image.onerror = null
this.image = null
}
draw(source: string, current: () => boolean, draw: (image: HTMLImageElement) => void, failure: (error: unknown) => void): void {
this.cancel()
const image = new Image()
this.image = image
image.onload = () => {
if (this.image !== image || !current())
return
this.cancel()
try {
draw(image)
}
catch (error) {
failure(error)
}
}
image.onerror = () => {
if (this.image === image)
this.cancel()
}
image.src = source
}
}
@@ -0,0 +1,192 @@
import type { WrappedCanvas } from 'mediabunny'
import type EventTarget from './EventTarget'
import type { FrameIterator } from './video-frames'
import { publish } from './readiness'
import { frameAction, readCanvas } from './video-frames'
export interface RenderHost {
events: EventTarget
audioClock: { readonly currentTime: number } | null
videoIterator: FrameIterator | null
nextFrame: WrappedCanvas | null
rafId: number
asyncId: number
duration: number
lastTimeUpdate: number
timeupdateInterval: number
avSyncTolerance: number
dropLateFrames: boolean
playbackRate: number
stalled: boolean
isFetching: boolean
}
export default class Renderer {
private stopped = false
private closed = false
private scheduled: number | null = null
private fetching: object | null = null
constructor(private host: RenderHost, private ready: () => boolean, private draw: (frame: WrappedCanvas) => void, private failure: (error: unknown) => void) {}
private current(id: number): boolean {
return !this.closed && !this.stopped && this.host.asyncId === id
}
async update(id: number): Promise<void> {
const iterator = this.host.videoIterator
if (!iterator || !this.ready() || !this.current(id) || this.host.isFetching)
return
const request = {}
this.fetching = request
this.host.isFetching = true
const current = () => this.current(id) && this.host.videoIterator === iterator
try {
while (current()) {
const frame = await readCanvas(iterator)
if (!current() || !frame)
return
const time = this.host.audioClock?.currentTime
if (time === undefined)
return
const action = frameAction(frame.timestamp, time, this.host.dropLateFrames, this.host.avSyncTolerance, this.host.playbackRate)
if (action === 'skip')
continue
if (action === 'draw') {
this.draw(frame)
}
else {
this.host.nextFrame = frame
return
}
}
}
catch (error) {
if (current())
throw error
}
finally {
if (this.fetching === request) {
this.fetching = null
this.host.isFetching = false
}
}
}
private request(id: number): void {
void this.update(id).catch((error) => {
if (!this.current(id))
return
this.stop()
this.report(error)
})
}
private report(error: unknown): void {
try {
this.failure(error)
}
catch (failure) {
console.warn('MediaBunny video error listener:', failure)
}
}
private cancelFrame(): void {
if (this.scheduled === null)
return
cancelAnimationFrame(this.scheduled)
this.scheduled = null
}
private schedule(id: number): void {
if (!this.current(id))
return
this.cancelFrame()
const frame = requestAnimationFrame(() => {
if (this.scheduled !== frame || !this.current(id))
return
this.scheduled = null
try {
this.render()
}
catch (error) {
this.stop()
this.report(error)
}
})
this.scheduled = frame
this.host.rafId = frame
}
render(): void {
const id = this.host.asyncId
const current = () => this.current(id)
if (!current() || !this.host.audioClock)
return
const time = this.host.audioClock.currentTime
const now = Date.now()
if (now - this.host.lastTimeUpdate >= this.host.timeupdateInterval) {
this.host.events.emit('timeupdate')
if (!current())
return
this.host.lastTimeUpdate = now
}
if (!this.ready()) {
this.schedule(id)
return
}
if (Number.isFinite(this.host.duration) && time >= this.host.duration) {
this.stop()
this.host.stalled = false
const ended = this.host.asyncId
publish(this.host.events, () => !this.closed && this.host.asyncId === ended, ['ended', 'pause', 'canplay'])
return
}
if (this.host.nextFrame && this.host.nextFrame.timestamp <= time) {
this.draw(this.host.nextFrame)
this.host.nextFrame = null
this.request(id)
if (this.host.stalled) {
publish(this.host.events, current, ['canplay', 'playing'])
if (!current())
return
this.host.stalled = false
}
}
else if (!this.host.nextFrame) {
this.request(id)
if (Number.isFinite(this.host.duration) && time < this.host.duration && !this.host.stalled) {
this.host.stalled = true
this.host.events.emit('waiting')
}
}
this.schedule(id)
}
start(clock: { readonly currentTime: number }): void {
if (this.closed)
return
this.stop()
this.stopped = false
this.host.audioClock = clock
this.host.stalled = false
if (!this.host.nextFrame)
this.request(this.host.asyncId)
this.schedule(this.host.asyncId)
}
stop(): void {
this.stopped = true
this.host.asyncId++
this.cancelFrame()
this.fetching = null
this.host.isFetching = false
}
destroy(): void {
if (this.closed)
return
this.stop()
this.closed = true
}
}