refactor(mediabunny): [PKG-MB-06] own audio clocks, buffers and context lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 20:21:25 +08:00
1 parent 9d037dcbf9
commit ccee9157a7
30 files changed
+16182 -736

No files matched your search

+1 -1
View File
@@ -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,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
!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,h,d,f,m,p,g,k,y,b,w,T,S,v,P,I,x,C,B,E,_,R,F,A=e=>{throw TypeError(e)},M=(e,t,i)=>t.has(e)||A("Cannot "+i),D=(e,t,i)=>(M(e,t,"read from private field"),i?i.call(e):t.get(e)),N=(e,t,i)=>t.has(e)?A("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),O=(e,t,i,r)=>(M(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),z=(e,t,i)=>(M(e,t,"access private method"),i),L=(e,t,i,r)=>({set _(r){O(e,t,r,i)},get _(){return D(e,t,r)}});function U(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 q{constructor(){N(this,e,!1),this.listeners=new Map}addEventListener(t,i){D(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(D(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{D(this,e)||t(r)})}destroy(){O(this,e,!0),this.listeners.clear()}}function W(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const H=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},V=e=>e&&e[e.length-1],j=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)},K=(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)},X=e=>{const t=j(e);return 0===(1&t)?-(t>>1):t+1>>1},$=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),Q=new TextDecoder,Y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),J={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},Z=Y(J),ee={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},te=Y(ee),ie={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},re=Y(ie),ae=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,se={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ne=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class oe{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 ce=/^[0-9a-fA-F]+$/,le=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),ue=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,he=(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},de=(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},fe=(e,t,i)=>{const r=de(e,i(t),i);e.splice(r+1,0,t)},me=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},pe=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},ge=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ke=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},ye=e=>{throw new Error(`Unexpected value: ${e}`)},be=(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},we=(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)},Te=(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}}),Se=(e,t,i)=>Math.max(t,Math.min(i,e)),ve="und",Pe=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},Ie=(e,t)=>Math.round(e/t)*t,xe=(e,t)=>Math.round(e*t)/t,Ce=(e,t)=>Math.floor(e/t)*t,Be=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Ee=/^[a-z]{3}$/,_e=e=>Ee.test(e),Re=1e6*(1+Number.EPSILON),Fe=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?Ae(e.headers):{},a=t.headers?Ae(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},Ae=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},Me=async(e,t,i,r,a)=>{lLine 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,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
!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,v,w,S,P,I,x,C,B,E,_,R,F,A=Object.defineProperty,M=Object.defineProperties,D=Object.getOwnPropertyDescriptors,N=Object.getOwnPropertySymbols,O=Object.prototype.hasOwnProperty,z=Object.prototype.propertyIsEnumerable,L=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),U=e=>{throw TypeError(e)},q=Math.pow,W=(e,t,i)=>t in e?A(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,H=(e,t)=>{for(var i in t||(t={}))O.call(t,i)&&W(e,i,t[i]);if(N)for(var i of N(t))z.call(t,i)&&W(e,i,t[i]);return e},V=(e,t)=>M(e,D(t)),j=(e,t,i)=>t.has(e)||U("Cannot "+i),K=(e,t,i)=>(j(e,t,"read from private field"),i?i.call(e):t.get(e)),X=(e,t,i)=>t.has(e)?U("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),$=(e,t,i,r)=>(j(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),G=(e,t,i)=>(j(e,t,"access private method"),i),Q=(e,t,i,r)=>({set _(r){$(e,t,r,i)},get _(){return K(e,t,r)}}),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())}),J=function(e,t){this[0]=e,this[1]=t},Z=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof J,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},ee=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 J(new Promise(r=>{var n=i[e](t);n instanceof Object||U("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},te=(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 ie(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 re{constructor(){X(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;K(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(K(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{K(this,e)||t(n)})}destroy(){$(this,e,!0),this.listeners.clear()}}function ne(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const se=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},ae=e=>e&&e[e.length-1],oe=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)},le=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-q(2,r))},ce=e=>{const t=oe(e);return 0===(1&t)?-(t>>1):t+1>>1},ue=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),de=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),he=new TextDecoder,fe=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),me={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},pe=fe(me),ge={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ke=fe(ge),ye={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},be=fe(ye),Te=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,ve={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},we=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class Se{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 Pe=/^[0-9a-fA-F]+$/,Ie=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),xe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<Line truncated
+545 -200
View File
@@ -20,7 +20,7 @@ var __privateWrapper = (obj, member, setter, getter) => ({
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;
var _closed, _closed2, _generation, _contexts, _nodes, _pump, _playTask, _playing, _loading, _onError, _AudioEngine_instances, load_fn, startPump_fn, play_fn, _generation2, _iteration, _loading2, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError2, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn2, 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) {
@@ -5317,13 +5317,13 @@ class IsobmffDemuxer extends Demuxer {
const entryCount = readU32Be(slice);
for (let i = 0; i < entryCount; i++) {
const segmentDuration = version === 1 ? readU64Be(slice) : readU32Be(slice);
const mediaTime = version === 1 ? readI64Be(slice) : readI32Be(slice);
const mediaTime2 = version === 1 ? readI64Be(slice) : readI32Be(slice);
const mediaRate = readFixed_16_16(slice);
if (relevantEntryFound) {
Logging._warn("Unsupported edit list: multiple edits are not currently supported. Only using first edit.");
break;
}
if (mediaTime === -1) {
if (mediaTime2 === -1) {
previousSegmentDurations += segmentDuration;
continue;
}
@@ -5332,7 +5332,7 @@ class IsobmffDemuxer extends Demuxer {
break;
}
track.editListPreviousSegmentDurations = previousSegmentDurations;
track.editListOffset = mediaTime;
track.editListOffset = mediaTime2;
relevantEntryFound = true;
}
}
@@ -24076,8 +24076,344 @@ class Id3V2Reader {
return this.readId3V2Text(encoding, until);
}
}
function mediaTime(clock) {
if (clock.paused || !clock.audioContext)
return clock.playbackTimeAtStart;
return (clock.audioContext.currentTime - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
}
function bufferTiming(clock, timestamp, duration, now) {
const startAt = clock.audioContextStartTime + (timestamp - clock.playbackTimeAtStart) / clock.playbackRate;
const endAt = startAt + duration / clock.playbackRate;
const endMediaTime = (endAt - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
return { startAt: Math.max(startAt, now), offset: startAt < now ? (now - startAt) * clock.playbackRate : null, endMediaTime };
}
class ContextOwner {
constructor(host) {
this.host = host;
this.closed = false;
this.resumes = /* @__PURE__ */ new WeakMap();
}
ensure(sampleRate) {
if (this.closed)
return null;
if (this.host.audioContext)
return this.host.audioContext;
const Context = window.AudioContext || window.webkitAudioContext;
let context;
try {
context = new Context({ sampleRate });
} catch {
context = new Context();
}
if (this.closed) {
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
return null;
}
let gain = null;
try {
gain = context.createGain();
gain.connect(context.destination);
const volume = this.host.muted ? 0 : this.host.volume;
gain.gain.value = volume * volume;
if (this.closed) {
gain.disconnect();
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
return null;
}
this.host.audioContext = context;
this.host.gainNode = gain;
return context;
} catch (error) {
try {
gain?.disconnect();
} catch (failure) {
console.warn("MediaBunny gain setup cleanup:", failure);
}
void context.close().catch((failure) => console.warn("MediaBunny context setup cleanup:", failure));
throw error;
}
}
updateGain() {
if (!this.host.gainNode)
return;
const volume = this.host.muted ? 0 : this.host.volume;
this.host.gainNode.gain.value = volume * volume;
}
resume(context) {
const previous = this.resumes.get(context);
if (previous)
return previous;
const pending = context.resume();
this.resumes.set(context, pending);
const clear = () => {
if (this.resumes.get(context) === pending)
this.resumes.delete(context);
};
pending.then(clear, clear);
return pending;
}
destroy() {
if (this.closed)
return;
this.closed = true;
const { audioContext: context, gainNode: gain } = this.host;
this.host.audioContext = null;
this.host.gainNode = null;
try {
gain?.disconnect();
} finally {
if (context)
void context.close().catch((error) => console.warn("MediaBunny context close error:", error));
}
}
}
class NodeQueue {
constructor(host) {
this.host = host;
}
release(node, stop) {
this.host.queuedNodes.delete(node);
node.onended = null;
let failed = false;
let failure;
try {
if (stop)
node.stop();
} catch (error) {
failed = true;
failure = error;
}
try {
node.disconnect();
} catch (error) {
if (!failed) {
failed = true;
failure = error;
}
}
if (failed)
throw failure;
}
stop() {
const failures = [];
for (const node of [...this.host.queuedNodes]) {
try {
this.release(node, true);
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
schedule({ buffer, timestamp }) {
const context = this.host.audioContext;
const gain = this.host.gainNode;
if (!context || !gain)
return;
const timing = bufferTiming(this.host, timestamp, buffer.duration, context.currentTime);
if (timing.offset !== null && timing.offset >= buffer.duration)
return;
const node = context.createBufferSource();
this.host.queuedNodes.add(node);
try {
node.buffer = buffer;
node.connect(gain);
node.playbackRate.value = this.host.playbackRate;
node.onended = () => {
if (!this.host.queuedNodes.has(node))
return;
try {
this.release(node, false);
} catch (error) {
console.warn("MediaBunny ended node cleanup:", error);
}
};
if (timing.offset === null)
node.start(timing.startAt);
else
node.start(timing.startAt, timing.offset);
this.host.latestScheduledEndTime = Math.max(this.host.latestScheduledEndTime, timing.endMediaTime);
} catch (error) {
try {
this.release(node, true);
} catch (failure) {
console.warn("MediaBunny audio node cleanup:", failure);
}
throw error;
}
}
}
const CANCELLED = /* @__PURE__ */ Symbol("cancelled");
class AudioTask {
constructor() {
this.cancelled = false;
this.waits = /* @__PURE__ */ new Set();
this.timers = /* @__PURE__ */ new Map();
}
wait(pending) {
return new Promise((resolve, reject) => {
const cancel = () => resolve(CANCELLED);
if (this.cancelled)
cancel();
else
this.waits.add(cancel);
pending.then((value) => {
this.waits.delete(cancel);
resolve(value);
}, (error) => {
this.waits.delete(cancel);
reject(error);
});
});
}
delay(ms) {
if (this.cancelled)
return Promise.resolve(false);
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.timers.delete(timer);
resolve(!this.cancelled);
}, ms);
this.timers.set(timer, resolve);
});
}
cancel() {
if (this.cancelled)
return;
this.cancelled = true;
for (const cancel of this.waits)
cancel();
this.waits.clear();
for (const [timer, resolve] of this.timers) {
clearTimeout(timer);
resolve(false);
}
this.timers.clear();
}
}
function publish(events, current, names) {
for (const name of names) {
if (!current())
return;
events.emit(name);
}
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
published = true;
ready();
}
}
return { video: () => complete("video"), audio: () => complete("audio") };
}
class AudioPump {
constructor(host, contexts, nodes) {
this.host = host;
this.contexts = contexts;
this.nodes = nodes;
this.task = null;
this.closing = /* @__PURE__ */ new WeakMap();
}
async stopIterator() {
const iterator = this.host.audioIterator;
this.host.audioIterator = null;
if (!iterator)
return;
let pending = this.closing.get(iterator);
if (!pending) {
pending = Promise.resolve().then(() => iterator.return()).then(() => {
});
this.closing.set(iterator, pending);
}
await pending;
}
cancel() {
this.task?.cancel();
this.task = null;
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
}
async run(id) {
if (this.host.asyncId !== id)
return;
this.cancel();
const sink = this.host.audioSink;
const context = this.host.audioContext;
if (!sink || !context || this.host.paused || this.host.asyncId !== id)
return;
const task = new AudioTask();
this.task = task;
let iterator = null;
const current = () => !task.cancelled && this.task === task && this.host.asyncId === id && !this.host.paused && this.host.audioContext === context && (!iterator || this.host.audioIterator === iterator);
try {
iterator = sink.buffers(this.host.currentTime);
this.host.audioIterator = iterator;
while (current()) {
const batch = [];
let done = false;
for (let index = 0; index < 16; index++) {
const result = await task.wait(iterator.next());
if (result === CANCELLED || !current())
return;
if (result.done) {
done = true;
break;
}
batch.push(result.value);
}
if (batch.length && context.state === "suspended") {
const resumed = await task.wait(this.contexts.resume(context));
if (resumed === CANCELLED || !current())
return;
publish(this.host.events, current, ["canplay", "playing"]);
}
for (const buffer of batch) {
if (!current())
return;
this.nodes.schedule(buffer);
}
if (done)
return;
if (!await task.delay(0) || !current())
return;
while (this.host.latestScheduledEndTime - this.host.currentTime > 1) {
if (!await task.delay(50) || !current())
return;
}
}
} catch (error) {
if (current())
throw error;
} finally {
task.cancel();
if (this.task === task) {
this.task = null;
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
}
}
}
}
class AudioEngine {
constructor(events) {
constructor(events, onError) {
__privateAdd(this, _AudioEngine_instances);
__privateAdd(this, _closed2, false);
__privateAdd(this, _generation, 0);
__privateAdd(this, _contexts);
__privateAdd(this, _nodes);
__privateAdd(this, _pump);
__privateAdd(this, _playTask, null);
__privateAdd(this, _playing, null);
__privateAdd(this, _loading, null);
__privateAdd(this, _onError);
this.events = events;
this.input = null;
this.audioSink = null;
@@ -24094,164 +24430,108 @@ class AudioEngine {
this.playbackRate = 1;
this.asyncId = 0;
this.queuedNodes = /* @__PURE__ */ new Set();
__privateSet(this, _contexts, new ContextOwner(this));
__privateSet(this, _nodes, new NodeQueue(this));
__privateSet(this, _pump, new AudioPump(this, __privateGet(this, _contexts), __privateGet(this, _nodes)));
__privateSet(this, _onError, onError);
}
get currentTime() {
if (this.paused)
return this.playbackTimeAtStart;
return (this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
return mediaTime(this);
}
ensureAudioContext(sampleRate) {
if (this.audioContext)
return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
try {
this.audioContext = new AudioContext({ sampleRate });
} catch {
this.audioContext = new AudioContext();
}
this.gainNode = this.audioContext.createGain();
this.gainNode.connect(this.audioContext.destination);
this.updateGain();
__privateGet(this, _contexts).ensure(sampleRate);
}
updateGain() {
if (!this.gainNode)
return;
const v = this.muted ? 0 : this.volume;
this.gainNode.gain.value = v * v;
__privateGet(this, _contexts).updateGain();
}
stopQueuedNodes() {
this.queuedNodes.forEach((node) => node.stop());
this.queuedNodes.clear();
__privateGet(this, _nodes).stop();
}
async stopIterator() {
await this.audioIterator?.return();
this.audioIterator = null;
await __privateGet(this, _pump).stopIterator();
}
handleNoAudioTrack() {
if (__privateGet(this, _closed2))
return;
this.audioSink = null;
this.ensureAudioContext();
}
cancelPending() {
__privateWrapper(this, _generation)._++;
try {
this.pause();
} finally {
__privateSet(this, _loading, null);
this.input = null;
this.audioSink = null;
}
}
async load(media, onMetadata) {
++this.asyncId;
await this.stopIterator();
this.stopQueuedNodes();
this.paused = true;
if (__privateGet(this, _closed2))
return;
this.cancelPending();
const generation = __privateGet(this, _generation);
this.playbackTimeAtStart = 0;
this.audioContextStartTime = 0;
const { input, audioTrack, duration } = media;
this.input = input;
this.duration = duration;
if (!audioTrack) {
this.handleNoAudioTrack();
onMetadata?.();
return;
this.latestScheduledEndTime = 0;
const pending = Promise.resolve().then(() => __privateMethod(this, _AudioEngine_instances, load_fn).call(this, media, generation, onMetadata));
__privateSet(this, _loading, pending);
try {
await pending;
} finally {
if (__privateGet(this, _loading) === pending)
__privateSet(this, _loading, null);
}
if (audioTrack.codec === null || !await audioTrack.canDecode()) {
this.audioSink = null;
this.ensureAudioContext();
onMetadata?.();
return;
}
this.ensureAudioContext(audioTrack.sampleRate);
this.audioSink = new AudioBufferSink(audioTrack);
onMetadata?.();
}
async runIterator(localId) {
if (!this.audioSink)
if (__privateGet(this, _closed2))
return;
await this.stopIterator();
this.audioIterator = this.audioSink.buffers(this.currentTime);
const BATCH_SIZE = 16;
while (true) {
if (localId !== this.asyncId || this.paused)
return;
const batch = [];
let batchDone = false;
try {
for (let i = 0; i < BATCH_SIZE; i++) {
const result = await this.audioIterator.next();
if (result.done) {
batchDone = true;
break;
}
batch.push(result.value);
}
} catch (e) {
console.error("Audio iterator error:", e);
batchDone = true;
}
if (localId !== this.asyncId || this.paused)
return;
if (batch.length > 0 && this.audioContext.state === "suspended") {
await this.audioContext.resume();
this.events.emit("canplay");
this.events.emit("playing");
}
for (const { buffer, timestamp } of batch) {
const node = this.audioContext.createBufferSource();
node.buffer = buffer;
node.connect(this.gainNode);
node.playbackRate.value = this.playbackRate;
const startAt = this.audioContextStartTime + (timestamp - this.playbackTimeAtStart) / this.playbackRate;
const duration = buffer.duration;
const endAt = startAt + duration / this.playbackRate;
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
if (endMediaTime > this.latestScheduledEndTime) {
this.latestScheduledEndTime = endMediaTime;
}
if (startAt >= this.audioContext.currentTime) {
node.start(startAt);
} else {
node.start(
this.audioContext.currentTime,
(this.audioContext.currentTime - startAt) * this.playbackRate
);
}
this.queuedNodes.add(node);
node.onended = () => this.queuedNodes.delete(node);
}
if (batchDone)
break;
await new Promise((resolve) => setTimeout(resolve, 0));
const BUFFER_AHEAD = 1;
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
if (localId !== this.asyncId || this.paused)
return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
await __privateGet(this, _pump).run(localId);
}
async play() {
if (__privateGet(this, _closed2))
return;
if (__privateGet(this, _playing))
return __privateGet(this, _playing);
if (!this.paused)
return;
if (!this.audioContext) {
this.ensureAudioContext();
const task = new AudioTask();
__privateSet(this, _playTask, task);
const generation = __privateGet(this, _generation);
const pending = __privateMethod(this, _AudioEngine_instances, play_fn).call(this, task, generation);
__privateSet(this, _playing, pending);
try {
await pending;
} finally {
if (__privateGet(this, _playTask) === task) {
__privateSet(this, _playTask, null);
__privateSet(this, _playing, null);
}
}
if (this.audioContext.state === "suspended") {
await this.audioContext.resume();
}
this.audioContextStartTime = this.audioContext.currentTime;
this.latestScheduledEndTime = this.playbackTimeAtStart;
this.paused = false;
const id = ++this.asyncId;
this.runIterator(id);
}
pause() {
if (this.paused)
return;
this.playbackTimeAtStart = this.currentTime;
if (!this.paused)
this.playbackTimeAtStart = this.currentTime;
this.paused = true;
this.stopIterator();
this.asyncId++;
__privateGet(this, _playTask)?.cancel();
__privateSet(this, _playTask, null);
__privateSet(this, _playing, null);
__privateGet(this, _pump).cancel();
this.stopQueuedNodes();
}
async seek(time) {
if (__privateGet(this, _closed2))
return;
const playing = !this.paused;
__privateGet(this, _pump).cancel();
this.stopQueuedNodes();
this.playbackTimeAtStart = Math.max(0, time);
this.audioContextStartTime = this.audioContext.currentTime;
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
this.latestScheduledEndTime = this.playbackTimeAtStart;
const id = ++this.asyncId;
if (!this.paused) {
this.runIterator(id);
}
if (playing)
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
}
setVolume(volume, muted) {
this.volume = volume;
@@ -24259,27 +24539,118 @@ class AudioEngine {
this.updateGain();
}
setPlaybackRate(rate) {
if (rate === this.playbackRate)
if (__privateGet(this, _closed2) || rate === this.playbackRate)
return;
if (!this.paused) {
this.playbackTimeAtStart = this.currentTime;
this.audioContextStartTime = this.audioContext.currentTime;
}
const time = this.currentTime;
__privateGet(this, _pump).cancel();
this.stopQueuedNodes();
this.playbackTimeAtStart = time;
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
this.playbackRate = rate;
if (!this.paused) {
const id = ++this.asyncId;
this.runIterator(id);
}
this.latestScheduledEndTime = time;
const id = ++this.asyncId;
if (!this.paused)
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
}
destroy() {
this.asyncId++;
this.pause();
this.audioContext?.close();
this.audioContext = null;
if (__privateGet(this, _closed2))
return;
__privateSet(this, _closed2, true);
__privateWrapper(this, _generation)._++;
const failures = [];
for (const release of [() => this.pause(), () => __privateGet(this, _contexts).destroy()]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
this.input = null;
this.audioSink = null;
__privateSet(this, _loading, null);
if (failures.length)
throw failures[0];
}
}
_closed2 = new WeakMap();
_generation = new WeakMap();
_contexts = new WeakMap();
_nodes = new WeakMap();
_pump = new WeakMap();
_playTask = new WeakMap();
_playing = new WeakMap();
_loading = new WeakMap();
_onError = new WeakMap();
_AudioEngine_instances = new WeakSet();
load_fn = async function({ input, audioTrack, duration }, generation, onMetadata) {
const current = () => !__privateGet(this, _closed2) && __privateGet(this, _generation) === generation;
if (!current())
return;
this.input = input;
this.duration = duration;
try {
const decodable = audioTrack && audioTrack.codec !== null && await audioTrack.canDecode();
if (!current())
return;
if (!decodable) {
this.handleNoAudioTrack();
} else {
this.ensureAudioContext(audioTrack.sampleRate);
if (!current())
return;
this.audioSink = new AudioBufferSink(audioTrack);
}
if (current())
onMetadata?.();
} catch (error) {
if (current())
throw error;
}
};
startPump_fn = function(id) {
void this.runIterator(id).catch((error) => {
if (__privateGet(this, _closed2) || id !== this.asyncId)
return;
try {
this.pause();
} catch (failure) {
console.warn("MediaBunny audio failure cleanup:", failure);
}
try {
if (__privateGet(this, _onError))
__privateGet(this, _onError).call(this, error);
else
this.events.emit("error", error);
} catch (failure) {
console.warn("MediaBunny audio error listener:", failure);
}
});
};
play_fn = async function(task, generation) {
const current = () => !__privateGet(this, _closed2) && !task.cancelled && __privateGet(this, _generation) === generation;
try {
const context = __privateGet(this, _contexts).ensure();
if (!context || !current())
return;
if (context.state === "suspended") {
const resumed = await task.wait(__privateGet(this, _contexts).resume(context));
if (resumed === CANCELLED || !current())
return;
}
if (__privateGet(this, _loading)) {
const loaded = await task.wait(__privateGet(this, _loading));
if (loaded === CANCELLED || !current())
return;
}
this.audioContextStartTime = context.currentTime;
this.latestScheduledEndTime = this.playbackTimeAtStart;
this.paused = false;
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, ++this.asyncId);
} catch (error) {
if (current())
throw error;
}
};
async function selectQuality(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
@@ -24469,30 +24840,6 @@ class LoadSession {
this.disposeInput();
}
}
function publish(events, current, names) {
for (const name of names) {
if (!current())
return;
events.emit(name);
}
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
published = true;
ready();
}
}
return { video: () => complete("video"), audio: () => complete("audio") };
}
async function resolveDuration({ input, videoTrack, audioTrack }) {
const tracks = [videoTrack, audioTrack].filter((track) => track !== null);
const referenceTrack = videoTrack || audioTrack;
@@ -24537,7 +24884,6 @@ class Playback {
this.wanted = false;
this.playing = null;
this.seeking = null;
this.audioStart = null;
}
operation() {
return { ...completion(), source: this.host.loadSeq, complete: completion() };
@@ -24602,17 +24948,6 @@ class Playback {
this.runPlay(operation).then(operation.resolve, operation.reject);
return operation.promise;
}
async startAudio() {
if (!this.audioStart)
this.audioStart = this.host.audio.play();
const pending = this.audioStart;
try {
await pending;
} finally {
if (this.audioStart === pending)
this.audioStart = null;
}
}
async runPlay(operation) {
const current = () => this.current(operation, "playing") && this.wanted;
try {
@@ -24624,7 +24959,7 @@ class Playback {
if (!current())
return;
this.host.paused = false;
await this.startAudio();
await this.host.audio.play();
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause();
@@ -24974,16 +25309,16 @@ class Renderer {
class VideoEngine {
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, _generation2, 0);
__privateAdd(this, _iteration, 0);
__privateAdd(this, _loading, null);
__privateAdd(this, _loading2, 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);
__privateAdd(this, _onError2);
this.canvas = canvas;
this.ctx = ctx;
this.events = events;
@@ -25007,7 +25342,7 @@ class VideoEngine {
this.playbackRate = 1;
this.posterDrawn = false;
this.isFetching = false;
__privateSet(this, _onError, onError);
__privateSet(this, _onError2, 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(source, signal, current) {
@@ -25016,7 +25351,7 @@ class VideoEngine {
drawPoster() {
if (__privateGet(this, _destroyed) || !this.poster || this.posterDrawn)
return;
const generation = __privateGet(this, _generation);
const generation = __privateGet(this, _generation2);
__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;
@@ -25035,13 +25370,13 @@ class VideoEngine {
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).clearRect(0, 0, this.canvas.width, this.canvas.height);
}
cancelPending() {
__privateWrapper(this, _generation)._++;
__privateWrapper(this, _generation2)._++;
__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, _loading2, null);
__privateSet(this, _createSink, null);
__privateSet(this, _usedSink, null);
this.videoSink = null;
@@ -25051,16 +25386,16 @@ class VideoEngine {
if (__privateGet(this, _destroyed))
return;
this.cancelPending();
const generation = __privateGet(this, _generation);
const generation = __privateGet(this, _generation2);
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);
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn2).call(this, media, generation, iteration, onMetadata));
__privateSet(this, _loading2, pending);
try {
await pending;
} finally {
if (__privateGet(this, _loading) === pending)
__privateSet(this, _loading, null);
if (__privateGet(this, _loading2) === pending)
__privateSet(this, _loading2, null);
}
}
handleNoVideoTrack() {
@@ -25077,9 +25412,9 @@ class VideoEngine {
async resetIterator(time) {
if (__privateGet(this, _destroyed))
return;
const generation = __privateGet(this, _generation);
const generation = __privateGet(this, _generation2);
const iteration = ++__privateWrapper(this, _iteration)._;
const loading = __privateGet(this, _loading);
const loading = __privateGet(this, _loading2);
this.stop();
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, true);
@@ -25119,7 +25454,7 @@ class VideoEngine {
if (__privateGet(this, _destroyed))
return;
__privateSet(this, _destroyed, true);
__privateWrapper(this, _generation)._++;
__privateWrapper(this, _generation2)._++;
__privateGet(this, _renderer).destroy();
__privateGet(this, _poster).cancel();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
@@ -25131,19 +25466,19 @@ class VideoEngine {
this.audioClock = null;
}
}
_generation = new WeakMap();
_generation2 = new WeakMap();
_iteration = new WeakMap();
_loading = new WeakMap();
_loading2 = new WeakMap();
_createSink = new WeakMap();
_usedSink = new WeakMap();
_destroyed = new WeakMap();
_preparing = new WeakMap();
_poster = new WeakMap();
_renderer = new WeakMap();
_onError = new WeakMap();
_onError2 = new WeakMap();
_VideoEngine_instances = new WeakSet();
current_fn = function(generation) {
return !__privateGet(this, _destroyed) && __privateGet(this, _generation) === generation;
return !__privateGet(this, _destroyed) && __privateGet(this, _generation2) === generation;
};
context_fn = function() {
if (!this.ctx)
@@ -25154,8 +25489,8 @@ 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);
if (__privateGet(this, _onError2))
__privateGet(this, _onError2).call(this, error);
else
this.events.emit("error", error);
};
@@ -25168,7 +25503,7 @@ draw_fn = function(frame, clear) {
this.clear();
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(frame.canvas, 0, 0);
};
load_fn = async function(media, generation, iteration, onMetadata) {
load_fn2 = async function(media, generation, iteration, onMetadata) {
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
try {
@@ -25242,7 +25577,14 @@ class MediaBunnyEngine {
__privateAdd(this, _playback);
this.events = events;
this.option = option;
this.audio = new AudioEngine(events);
this.audio = new AudioEngine(events, (error) => {
try {
this.pause();
} catch (failure) {
console.warn("MediaBunny audio coordinator cleanup:", failure);
}
this.reportError(error);
});
this.video = new VideoEngine({
canvas,
ctx,
@@ -25279,6 +25621,7 @@ class MediaBunnyEngine {
this.ended = true;
this.paused = true;
__privateGet(this, _playback).ended();
this.audio.pause();
});
}
async load(src) {
@@ -25287,6 +25630,7 @@ class MediaBunnyEngine {
const id = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
this.audio.cancelPending();
this.loadSession?.cancel();
if (id !== this.loadSeq || this.destroyed)
return;
@@ -25311,6 +25655,7 @@ class MediaBunnyEngine {
const failed = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
this.audio.cancelPending();
session.cancel();
if (failed !== this.loadSeq || this.destroyed)
return;
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:unit": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -76,7 +76,7 @@
"test:canvas-types-package": "node refactor/scripts/canvas-package-types.mjs",
"test:dpip": "node --test test/dpip.test.js test/dpip-lifecycle.test.js",
"test:dpip-types-package": "node refactor/scripts/dpip-package-types.mjs",
"test:mediabunny": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js"
"test:mediabunny": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -16,25 +16,29 @@ declarations, the factory signature, canvas forwarding, or package entrypoints.
| `preflight.ts` | Optional HEAD Range check, historical warning/error policy, cancellation and stale-result guard |
| `load-session.ts` | Ownership of the pending/active SDK Input, HEAD AbortController, deferred load events and deadline |
| `MediaBunnyEngine.ts` | Load/readiness coordinator, source identity, errors and terminal teardown |
| `playback.ts` | Playback intent, cancellable play/seek/replacement operations and shared pending audio resume |
| `playback.ts` | Playback intent and cancellable play/seek/replacement operations |
| `readiness.ts` | One metadata publication after both participants, guarded event sequences |
| `hls-selection.ts` | Source-bound track queries, pairing and active replacement errors |
| `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 |
| `AudioEngine.ts` | Audio load/play generation, clock anchors and decoder-error forwarding |
| `audio-clock.ts` | Existing media-clock and future/partially-late buffer scheduling formulas |
| `audio-context.ts` | Context/gain setup, sample-rate fallback, shared native resume and terminal close |
| `audio-nodes.ts` | Scheduled source nodes, rate, ended handlers and complete stop/disconnect |
| `audio-pump.ts` | SDK buffer iterator, bounded batches, backpressure and stale-result guards |
| `audio-task.ts` | Cancelable waits and owned batch/backpressure timers |
| `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` | Temporary boundary for the remaining JS audio engine, removed in MB-06 |
| `m3u8.js` | Current ArtPlayer control/setting integration |
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.
The twenty-six production TypeScript modules use strict checking with `skipLibCheck: false`
and no ambient Node types. The coordinator and both decoder engines are checked
implementations; there are no remaining adjacent JavaScript declaration bridges.
The public factory and HLS integration remain JavaScript, scheduled in PKG-MB-07/08.
Do not describe the whole package as migrated yet.
The shim keeps its existing own-property order and direct prototype surface because
@@ -86,7 +90,10 @@ 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;
video frame ownership is detailed below; remaining audio work belongs to MB-06.
video and audio ownership are detailed below. Native resume coalescing belongs to the
context owner: each new playback intent calls audio.play(), but shares the same pending
native resume. Sharing a canceled upper-level play Promise would incorrectly start video
before the latest audio intent is active.
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,
@@ -140,6 +147,39 @@ stopIterator still rejects. Missing 2D context fails at use with a descriptive e
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.
## Audio clock and resource ownership
AudioEngine keeps source generation separate from pump IDs and cancellable play intent.
Pause invalidates a suspended resume even when the audio clock has not started; source
replacement invalidates audio before track parsing. Late resume, capability checks and
buffer reads cannot install a sink, schedule nodes or resurrect a destroyed instance.
Native AudioContext.resume is coalesced per context, while canceled callers settle early
and late rejections stay observed. A valid no-audio-track source retains the Web Audio
clock fallback; this is distinct from rejecting a container with neither track.
The clock retains the existing anchor/rate formula and squared gain with mute. Pause
captures media time; seek/rate changes cancel the old iterator and stop/disconnect its
scheduled nodes before rebuilding the queue at the new anchor. The renderer's ended
event freezes the audio clock and releases remaining nodes. Source load resets position.
Fully expired buffers do not allocate nodes; partially late buffers retain the old offset
calculation. Audio playbackRate changes still change pitch through native source playbackRate;
no new pitch-preservation or acoustic synchronization guarantee is introduced.
AudioPump owns 16-buffer batches, a zero-delay yield and the existing one-second/50ms
backpressure policy. Its timers include ID zero and are canceled with its task. Iterator
detachment is synchronous and return is observed once; obsolete cleanup cannot clear a
new iterator. Native AudioBuffers have no close() contract: the SDK closes its AudioSamples,
while the proxy releases buffer/node/iterator references. Every scheduled source is owned
before start, cleared on ended, stopped/disconnected on cancellation, and has its callback
removed. Cleanup attempts all nodes and context resources before throwing the first error;
background cleanup failures are observed, and do not replace an active decoder failure.
ContextOwner installs context/gain references only after successful setup. Setup failure
releases partial resources, destroy nulls references and observes the asynchronous close,
and repeated destroy is inert. Real-browser teardown assertions poll the native closed state.
Tests deliberately delay native resume completion or decoded buffer delivery, without
substituting fake audio decoding for native acceptance.
## Types and dependencies
The locked runtime SDK remains MediaBunny 1.56.1. Its pinned WebCodecs declaration
@@ -160,6 +200,7 @@ 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
yarn test:browser test/browser/mediabunny-audio.spec.js
```
Node tests use actual SDK parsing and controlled lifecycle interleavings. Browser tests
@@ -174,9 +215,9 @@ inputs, not a rebuilt dist. Package-specific evidence and remaining tasks are in
`refactor/mb-validation.md`. The full refactor plan is at the repository root.
PKG-MB-04 covers duplicate/trackless readiness, reentrant event sequences and
pending play/seek coordination. PKG-MB-05/06 own late frame and audio callbacks,
including operations already inside decoders at cancellation; releasing Input alone
does not prove those resources are fully handled. PKG-MB-07 owns HLS UI/selection
pending play/seek coordination. PKG-MB-05/06 cover late frame and audio callbacks,
including operations already inside decoders at cancellation. These bounded cases do not
replace long-run synchronization and combination validation. PKG-MB-07 owns HLS UI/selection
races. PKG-MB-09 owns long-run AV sync, full core/proxy/plugin combinations and device
validation. PKG-MB-10 owns the demo, installed package and MPL/source notice gate.
Windows WebKit without WebCodecs/Web Audio is recorded as a capability failure,
@@ -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,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
!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,h,d,f,m,p,g,k,y,b,w,T,S,v,P,I,x,C,B,E,_,R,F,A=e=>{throw TypeError(e)},M=(e,t,i)=>t.has(e)||A("Cannot "+i),D=(e,t,i)=>(M(e,t,"read from private field"),i?i.call(e):t.get(e)),N=(e,t,i)=>t.has(e)?A("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),O=(e,t,i,r)=>(M(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),z=(e,t,i)=>(M(e,t,"access private method"),i),L=(e,t,i,r)=>({set _(r){O(e,t,r,i)},get _(){return D(e,t,r)}});function U(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 q{constructor(){N(this,e,!1),this.listeners=new Map}addEventListener(t,i){D(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(D(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{D(this,e)||t(r)})}destroy(){O(this,e,!0),this.listeners.clear()}}function W(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const H=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},V=e=>e&&e[e.length-1],j=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)},K=(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)},X=e=>{const t=j(e);return 0===(1&t)?-(t>>1):t+1>>1},$=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),Q=new TextDecoder,Y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),J={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},Z=Y(J),ee={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},te=Y(ee),ie={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},re=Y(ie),ae=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,se={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ne=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class oe{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 ce=/^[0-9a-fA-F]+$/,le=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),ue=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,he=(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},de=(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},fe=(e,t,i)=>{const r=de(e,i(t),i);e.splice(r+1,0,t)},me=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},pe=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},ge=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ke=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},ye=e=>{throw new Error(`Unexpected value: ${e}`)},be=(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},we=(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)},Te=(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}}),Se=(e,t,i)=>Math.max(t,Math.min(i,e)),ve="und",Pe=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},Ie=(e,t)=>Math.round(e/t)*t,xe=(e,t)=>Math.round(e*t)/t,Ce=(e,t)=>Math.floor(e/t)*t,Be=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Ee=/^[a-z]{3}$/,_e=e=>Ee.test(e),Re=1e6*(1+Number.EPSILON),Fe=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?Ae(e.headers):{},a=t.headers?Ae(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},Ae=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},Me=async(e,t,i,r,a)=>{lLine 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,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
!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,v,w,S,P,I,x,C,B,E,_,R,F,A=Object.defineProperty,M=Object.defineProperties,D=Object.getOwnPropertyDescriptors,N=Object.getOwnPropertySymbols,O=Object.prototype.hasOwnProperty,z=Object.prototype.propertyIsEnumerable,L=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),U=e=>{throw TypeError(e)},q=Math.pow,W=(e,t,i)=>t in e?A(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,H=(e,t)=>{for(var i in t||(t={}))O.call(t,i)&&W(e,i,t[i]);if(N)for(var i of N(t))z.call(t,i)&&W(e,i,t[i]);return e},V=(e,t)=>M(e,D(t)),j=(e,t,i)=>t.has(e)||U("Cannot "+i),K=(e,t,i)=>(j(e,t,"read from private field"),i?i.call(e):t.get(e)),X=(e,t,i)=>t.has(e)?U("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),$=(e,t,i,r)=>(j(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),G=(e,t,i)=>(j(e,t,"access private method"),i),Q=(e,t,i,r)=>({set _(r){$(e,t,r,i)},get _(){return K(e,t,r)}}),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())}),J=function(e,t){this[0]=e,this[1]=t},Z=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof J,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},ee=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 J(new Promise(r=>{var n=i[e](t);n instanceof Object||U("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},te=(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 ie(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 re{constructor(){X(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;K(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(K(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{K(this,e)||t(n)})}destroy(){$(this,e,!0),this.listeners.clear()}}function ne(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const se=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},ae=e=>e&&e[e.length-1],oe=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)},le=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-q(2,r))},ce=e=>{const t=oe(e);return 0===(1&t)?-(t>>1):t+1>>1},ue=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),de=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),he=new TextDecoder,fe=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),me={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},pe=fe(me),ge={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ke=fe(ge),ye={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},be=fe(ye),Te=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,ve={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},we=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class Se{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 Pe=/^[0-9a-fA-F]+$/,Ie=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),xe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<Line truncated
@@ -20,7 +20,7 @@ var __privateWrapper = (obj, member, setter, getter) => ({
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;
var _closed, _closed2, _generation, _contexts, _nodes, _pump, _playTask, _playing, _loading, _onError, _AudioEngine_instances, load_fn, startPump_fn, play_fn, _generation2, _iteration, _loading2, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError2, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn2, 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) {
@@ -5317,13 +5317,13 @@ class IsobmffDemuxer extends Demuxer {
const entryCount = readU32Be(slice);
for (let i = 0; i < entryCount; i++) {
const segmentDuration = version === 1 ? readU64Be(slice) : readU32Be(slice);
const mediaTime = version === 1 ? readI64Be(slice) : readI32Be(slice);
const mediaTime2 = version === 1 ? readI64Be(slice) : readI32Be(slice);
const mediaRate = readFixed_16_16(slice);
if (relevantEntryFound) {
Logging._warn("Unsupported edit list: multiple edits are not currently supported. Only using first edit.");
break;
}
if (mediaTime === -1) {
if (mediaTime2 === -1) {
previousSegmentDurations += segmentDuration;
continue;
}
@@ -5332,7 +5332,7 @@ class IsobmffDemuxer extends Demuxer {
break;
}
track.editListPreviousSegmentDurations = previousSegmentDurations;
track.editListOffset = mediaTime;
track.editListOffset = mediaTime2;
relevantEntryFound = true;
}
}
@@ -24076,8 +24076,344 @@ class Id3V2Reader {
return this.readId3V2Text(encoding, until);
}
}
function mediaTime(clock) {
if (clock.paused || !clock.audioContext)
return clock.playbackTimeAtStart;
return (clock.audioContext.currentTime - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
}
function bufferTiming(clock, timestamp, duration, now) {
const startAt = clock.audioContextStartTime + (timestamp - clock.playbackTimeAtStart) / clock.playbackRate;
const endAt = startAt + duration / clock.playbackRate;
const endMediaTime = (endAt - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
return { startAt: Math.max(startAt, now), offset: startAt < now ? (now - startAt) * clock.playbackRate : null, endMediaTime };
}
class ContextOwner {
constructor(host) {
this.host = host;
this.closed = false;
this.resumes = /* @__PURE__ */ new WeakMap();
}
ensure(sampleRate) {
if (this.closed)
return null;
if (this.host.audioContext)
return this.host.audioContext;
const Context = window.AudioContext || window.webkitAudioContext;
let context;
try {
context = new Context({ sampleRate });
} catch {
context = new Context();
}
if (this.closed) {
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
return null;
}
let gain = null;
try {
gain = context.createGain();
gain.connect(context.destination);
const volume = this.host.muted ? 0 : this.host.volume;
gain.gain.value = volume * volume;
if (this.closed) {
gain.disconnect();
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
return null;
}
this.host.audioContext = context;
this.host.gainNode = gain;
return context;
} catch (error) {
try {
gain?.disconnect();
} catch (failure) {
console.warn("MediaBunny gain setup cleanup:", failure);
}
void context.close().catch((failure) => console.warn("MediaBunny context setup cleanup:", failure));
throw error;
}
}
updateGain() {
if (!this.host.gainNode)
return;
const volume = this.host.muted ? 0 : this.host.volume;
this.host.gainNode.gain.value = volume * volume;
}
resume(context) {
const previous = this.resumes.get(context);
if (previous)
return previous;
const pending = context.resume();
this.resumes.set(context, pending);
const clear = () => {
if (this.resumes.get(context) === pending)
this.resumes.delete(context);
};
pending.then(clear, clear);
return pending;
}
destroy() {
if (this.closed)
return;
this.closed = true;
const { audioContext: context, gainNode: gain } = this.host;
this.host.audioContext = null;
this.host.gainNode = null;
try {
gain?.disconnect();
} finally {
if (context)
void context.close().catch((error) => console.warn("MediaBunny context close error:", error));
}
}
}
class NodeQueue {
constructor(host) {
this.host = host;
}
release(node, stop) {
this.host.queuedNodes.delete(node);
node.onended = null;
let failed = false;
let failure;
try {
if (stop)
node.stop();
} catch (error) {
failed = true;
failure = error;
}
try {
node.disconnect();
} catch (error) {
if (!failed) {
failed = true;
failure = error;
}
}
if (failed)
throw failure;
}
stop() {
const failures = [];
for (const node of [...this.host.queuedNodes]) {
try {
this.release(node, true);
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
schedule({ buffer, timestamp }) {
const context = this.host.audioContext;
const gain = this.host.gainNode;
if (!context || !gain)
return;
const timing = bufferTiming(this.host, timestamp, buffer.duration, context.currentTime);
if (timing.offset !== null && timing.offset >= buffer.duration)
return;
const node = context.createBufferSource();
this.host.queuedNodes.add(node);
try {
node.buffer = buffer;
node.connect(gain);
node.playbackRate.value = this.host.playbackRate;
node.onended = () => {
if (!this.host.queuedNodes.has(node))
return;
try {
this.release(node, false);
} catch (error) {
console.warn("MediaBunny ended node cleanup:", error);
}
};
if (timing.offset === null)
node.start(timing.startAt);
else
node.start(timing.startAt, timing.offset);
this.host.latestScheduledEndTime = Math.max(this.host.latestScheduledEndTime, timing.endMediaTime);
} catch (error) {
try {
this.release(node, true);
} catch (failure) {
console.warn("MediaBunny audio node cleanup:", failure);
}
throw error;
}
}
}
const CANCELLED = /* @__PURE__ */ Symbol("cancelled");
class AudioTask {
constructor() {
this.cancelled = false;
this.waits = /* @__PURE__ */ new Set();
this.timers = /* @__PURE__ */ new Map();
}
wait(pending) {
return new Promise((resolve, reject) => {
const cancel = () => resolve(CANCELLED);
if (this.cancelled)
cancel();
else
this.waits.add(cancel);
pending.then((value) => {
this.waits.delete(cancel);
resolve(value);
}, (error) => {
this.waits.delete(cancel);
reject(error);
});
});
}
delay(ms) {
if (this.cancelled)
return Promise.resolve(false);
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.timers.delete(timer);
resolve(!this.cancelled);
}, ms);
this.timers.set(timer, resolve);
});
}
cancel() {
if (this.cancelled)
return;
this.cancelled = true;
for (const cancel of this.waits)
cancel();
this.waits.clear();
for (const [timer, resolve] of this.timers) {
clearTimeout(timer);
resolve(false);
}
this.timers.clear();
}
}
function publish(events, current, names) {
for (const name of names) {
if (!current())
return;
events.emit(name);
}
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
published = true;
ready();
}
}
return { video: () => complete("video"), audio: () => complete("audio") };
}
class AudioPump {
constructor(host, contexts, nodes) {
this.host = host;
this.contexts = contexts;
this.nodes = nodes;
this.task = null;
this.closing = /* @__PURE__ */ new WeakMap();
}
async stopIterator() {
const iterator = this.host.audioIterator;
this.host.audioIterator = null;
if (!iterator)
return;
let pending = this.closing.get(iterator);
if (!pending) {
pending = Promise.resolve().then(() => iterator.return()).then(() => {
});
this.closing.set(iterator, pending);
}
await pending;
}
cancel() {
this.task?.cancel();
this.task = null;
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
}
async run(id) {
if (this.host.asyncId !== id)
return;
this.cancel();
const sink = this.host.audioSink;
const context = this.host.audioContext;
if (!sink || !context || this.host.paused || this.host.asyncId !== id)
return;
const task = new AudioTask();
this.task = task;
let iterator = null;
const current = () => !task.cancelled && this.task === task && this.host.asyncId === id && !this.host.paused && this.host.audioContext === context && (!iterator || this.host.audioIterator === iterator);
try {
iterator = sink.buffers(this.host.currentTime);
this.host.audioIterator = iterator;
while (current()) {
const batch = [];
let done = false;
for (let index = 0; index < 16; index++) {
const result = await task.wait(iterator.next());
if (result === CANCELLED || !current())
return;
if (result.done) {
done = true;
break;
}
batch.push(result.value);
}
if (batch.length && context.state === "suspended") {
const resumed = await task.wait(this.contexts.resume(context));
if (resumed === CANCELLED || !current())
return;
publish(this.host.events, current, ["canplay", "playing"]);
}
for (const buffer of batch) {
if (!current())
return;
this.nodes.schedule(buffer);
}
if (done)
return;
if (!await task.delay(0) || !current())
return;
while (this.host.latestScheduledEndTime - this.host.currentTime > 1) {
if (!await task.delay(50) || !current())
return;
}
}
} catch (error) {
if (current())
throw error;
} finally {
task.cancel();
if (this.task === task) {
this.task = null;
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
}
}
}
}
class AudioEngine {
constructor(events) {
constructor(events, onError) {
__privateAdd(this, _AudioEngine_instances);
__privateAdd(this, _closed2, false);
__privateAdd(this, _generation, 0);
__privateAdd(this, _contexts);
__privateAdd(this, _nodes);
__privateAdd(this, _pump);
__privateAdd(this, _playTask, null);
__privateAdd(this, _playing, null);
__privateAdd(this, _loading, null);
__privateAdd(this, _onError);
this.events = events;
this.input = null;
this.audioSink = null;
@@ -24094,164 +24430,108 @@ class AudioEngine {
this.playbackRate = 1;
this.asyncId = 0;
this.queuedNodes = /* @__PURE__ */ new Set();
__privateSet(this, _contexts, new ContextOwner(this));
__privateSet(this, _nodes, new NodeQueue(this));
__privateSet(this, _pump, new AudioPump(this, __privateGet(this, _contexts), __privateGet(this, _nodes)));
__privateSet(this, _onError, onError);
}
get currentTime() {
if (this.paused)
return this.playbackTimeAtStart;
return (this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
return mediaTime(this);
}
ensureAudioContext(sampleRate) {
if (this.audioContext)
return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
try {
this.audioContext = new AudioContext({ sampleRate });
} catch {
this.audioContext = new AudioContext();
}
this.gainNode = this.audioContext.createGain();
this.gainNode.connect(this.audioContext.destination);
this.updateGain();
__privateGet(this, _contexts).ensure(sampleRate);
}
updateGain() {
if (!this.gainNode)
return;
const v = this.muted ? 0 : this.volume;
this.gainNode.gain.value = v * v;
__privateGet(this, _contexts).updateGain();
}
stopQueuedNodes() {
this.queuedNodes.forEach((node) => node.stop());
this.queuedNodes.clear();
__privateGet(this, _nodes).stop();
}
async stopIterator() {
await this.audioIterator?.return();
this.audioIterator = null;
await __privateGet(this, _pump).stopIterator();
}
handleNoAudioTrack() {
if (__privateGet(this, _closed2))
return;
this.audioSink = null;
this.ensureAudioContext();
}
cancelPending() {
__privateWrapper(this, _generation)._++;
try {
this.pause();
} finally {
__privateSet(this, _loading, null);
this.input = null;
this.audioSink = null;
}
}
async load(media, onMetadata) {
++this.asyncId;
await this.stopIterator();
this.stopQueuedNodes();
this.paused = true;
if (__privateGet(this, _closed2))
return;
this.cancelPending();
const generation = __privateGet(this, _generation);
this.playbackTimeAtStart = 0;
this.audioContextStartTime = 0;
const { input, audioTrack, duration } = media;
this.input = input;
this.duration = duration;
if (!audioTrack) {
this.handleNoAudioTrack();
onMetadata?.();
return;
this.latestScheduledEndTime = 0;
const pending = Promise.resolve().then(() => __privateMethod(this, _AudioEngine_instances, load_fn).call(this, media, generation, onMetadata));
__privateSet(this, _loading, pending);
try {
await pending;
} finally {
if (__privateGet(this, _loading) === pending)
__privateSet(this, _loading, null);
}
if (audioTrack.codec === null || !await audioTrack.canDecode()) {
this.audioSink = null;
this.ensureAudioContext();
onMetadata?.();
return;
}
this.ensureAudioContext(audioTrack.sampleRate);
this.audioSink = new AudioBufferSink(audioTrack);
onMetadata?.();
}
async runIterator(localId) {
if (!this.audioSink)
if (__privateGet(this, _closed2))
return;
await this.stopIterator();
this.audioIterator = this.audioSink.buffers(this.currentTime);
const BATCH_SIZE = 16;
while (true) {
if (localId !== this.asyncId || this.paused)
return;
const batch = [];
let batchDone = false;
try {
for (let i = 0; i < BATCH_SIZE; i++) {
const result = await this.audioIterator.next();
if (result.done) {
batchDone = true;
break;
}
batch.push(result.value);
}
} catch (e) {
console.error("Audio iterator error:", e);
batchDone = true;
}
if (localId !== this.asyncId || this.paused)
return;
if (batch.length > 0 && this.audioContext.state === "suspended") {
await this.audioContext.resume();
this.events.emit("canplay");
this.events.emit("playing");
}
for (const { buffer, timestamp } of batch) {
const node = this.audioContext.createBufferSource();
node.buffer = buffer;
node.connect(this.gainNode);
node.playbackRate.value = this.playbackRate;
const startAt = this.audioContextStartTime + (timestamp - this.playbackTimeAtStart) / this.playbackRate;
const duration = buffer.duration;
const endAt = startAt + duration / this.playbackRate;
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
if (endMediaTime > this.latestScheduledEndTime) {
this.latestScheduledEndTime = endMediaTime;
}
if (startAt >= this.audioContext.currentTime) {
node.start(startAt);
} else {
node.start(
this.audioContext.currentTime,
(this.audioContext.currentTime - startAt) * this.playbackRate
);
}
this.queuedNodes.add(node);
node.onended = () => this.queuedNodes.delete(node);
}
if (batchDone)
break;
await new Promise((resolve) => setTimeout(resolve, 0));
const BUFFER_AHEAD = 1;
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
if (localId !== this.asyncId || this.paused)
return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
await __privateGet(this, _pump).run(localId);
}
async play() {
if (__privateGet(this, _closed2))
return;
if (__privateGet(this, _playing))
return __privateGet(this, _playing);
if (!this.paused)
return;
if (!this.audioContext) {
this.ensureAudioContext();
const task = new AudioTask();
__privateSet(this, _playTask, task);
const generation = __privateGet(this, _generation);
const pending = __privateMethod(this, _AudioEngine_instances, play_fn).call(this, task, generation);
__privateSet(this, _playing, pending);
try {
await pending;
} finally {
if (__privateGet(this, _playTask) === task) {
__privateSet(this, _playTask, null);
__privateSet(this, _playing, null);
}
}
if (this.audioContext.state === "suspended") {
await this.audioContext.resume();
}
this.audioContextStartTime = this.audioContext.currentTime;
this.latestScheduledEndTime = this.playbackTimeAtStart;
this.paused = false;
const id = ++this.asyncId;
this.runIterator(id);
}
pause() {
if (this.paused)
return;
this.playbackTimeAtStart = this.currentTime;
if (!this.paused)
this.playbackTimeAtStart = this.currentTime;
this.paused = true;
this.stopIterator();
this.asyncId++;
__privateGet(this, _playTask)?.cancel();
__privateSet(this, _playTask, null);
__privateSet(this, _playing, null);
__privateGet(this, _pump).cancel();
this.stopQueuedNodes();
}
async seek(time) {
if (__privateGet(this, _closed2))
return;
const playing = !this.paused;
__privateGet(this, _pump).cancel();
this.stopQueuedNodes();
this.playbackTimeAtStart = Math.max(0, time);
this.audioContextStartTime = this.audioContext.currentTime;
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
this.latestScheduledEndTime = this.playbackTimeAtStart;
const id = ++this.asyncId;
if (!this.paused) {
this.runIterator(id);
}
if (playing)
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
}
setVolume(volume, muted) {
this.volume = volume;
@@ -24259,27 +24539,118 @@ class AudioEngine {
this.updateGain();
}
setPlaybackRate(rate) {
if (rate === this.playbackRate)
if (__privateGet(this, _closed2) || rate === this.playbackRate)
return;
if (!this.paused) {
this.playbackTimeAtStart = this.currentTime;
this.audioContextStartTime = this.audioContext.currentTime;
}
const time = this.currentTime;
__privateGet(this, _pump).cancel();
this.stopQueuedNodes();
this.playbackTimeAtStart = time;
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
this.playbackRate = rate;
if (!this.paused) {
const id = ++this.asyncId;
this.runIterator(id);
}
this.latestScheduledEndTime = time;
const id = ++this.asyncId;
if (!this.paused)
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
}
destroy() {
this.asyncId++;
this.pause();
this.audioContext?.close();
this.audioContext = null;
if (__privateGet(this, _closed2))
return;
__privateSet(this, _closed2, true);
__privateWrapper(this, _generation)._++;
const failures = [];
for (const release of [() => this.pause(), () => __privateGet(this, _contexts).destroy()]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
this.input = null;
this.audioSink = null;
__privateSet(this, _loading, null);
if (failures.length)
throw failures[0];
}
}
_closed2 = new WeakMap();
_generation = new WeakMap();
_contexts = new WeakMap();
_nodes = new WeakMap();
_pump = new WeakMap();
_playTask = new WeakMap();
_playing = new WeakMap();
_loading = new WeakMap();
_onError = new WeakMap();
_AudioEngine_instances = new WeakSet();
load_fn = async function({ input, audioTrack, duration }, generation, onMetadata) {
const current = () => !__privateGet(this, _closed2) && __privateGet(this, _generation) === generation;
if (!current())
return;
this.input = input;
this.duration = duration;
try {
const decodable = audioTrack && audioTrack.codec !== null && await audioTrack.canDecode();
if (!current())
return;
if (!decodable) {
this.handleNoAudioTrack();
} else {
this.ensureAudioContext(audioTrack.sampleRate);
if (!current())
return;
this.audioSink = new AudioBufferSink(audioTrack);
}
if (current())
onMetadata?.();
} catch (error) {
if (current())
throw error;
}
};
startPump_fn = function(id) {
void this.runIterator(id).catch((error) => {
if (__privateGet(this, _closed2) || id !== this.asyncId)
return;
try {
this.pause();
} catch (failure) {
console.warn("MediaBunny audio failure cleanup:", failure);
}
try {
if (__privateGet(this, _onError))
__privateGet(this, _onError).call(this, error);
else
this.events.emit("error", error);
} catch (failure) {
console.warn("MediaBunny audio error listener:", failure);
}
});
};
play_fn = async function(task, generation) {
const current = () => !__privateGet(this, _closed2) && !task.cancelled && __privateGet(this, _generation) === generation;
try {
const context = __privateGet(this, _contexts).ensure();
if (!context || !current())
return;
if (context.state === "suspended") {
const resumed = await task.wait(__privateGet(this, _contexts).resume(context));
if (resumed === CANCELLED || !current())
return;
}
if (__privateGet(this, _loading)) {
const loaded = await task.wait(__privateGet(this, _loading));
if (loaded === CANCELLED || !current())
return;
}
this.audioContextStartTime = context.currentTime;
this.latestScheduledEndTime = this.playbackTimeAtStart;
this.paused = false;
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, ++this.asyncId);
} catch (error) {
if (current())
throw error;
}
};
async function selectQuality(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
@@ -24469,30 +24840,6 @@ class LoadSession {
this.disposeInput();
}
}
function publish(events, current, names) {
for (const name of names) {
if (!current())
return;
events.emit(name);
}
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
published = true;
ready();
}
}
return { video: () => complete("video"), audio: () => complete("audio") };
}
async function resolveDuration({ input, videoTrack, audioTrack }) {
const tracks = [videoTrack, audioTrack].filter((track) => track !== null);
const referenceTrack = videoTrack || audioTrack;
@@ -24537,7 +24884,6 @@ class Playback {
this.wanted = false;
this.playing = null;
this.seeking = null;
this.audioStart = null;
}
operation() {
return { ...completion(), source: this.host.loadSeq, complete: completion() };
@@ -24602,17 +24948,6 @@ class Playback {
this.runPlay(operation).then(operation.resolve, operation.reject);
return operation.promise;
}
async startAudio() {
if (!this.audioStart)
this.audioStart = this.host.audio.play();
const pending = this.audioStart;
try {
await pending;
} finally {
if (this.audioStart === pending)
this.audioStart = null;
}
}
async runPlay(operation) {
const current = () => this.current(operation, "playing") && this.wanted;
try {
@@ -24624,7 +24959,7 @@ class Playback {
if (!current())
return;
this.host.paused = false;
await this.startAudio();
await this.host.audio.play();
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause();
@@ -24974,16 +25309,16 @@ class Renderer {
class VideoEngine {
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, _generation2, 0);
__privateAdd(this, _iteration, 0);
__privateAdd(this, _loading, null);
__privateAdd(this, _loading2, 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);
__privateAdd(this, _onError2);
this.canvas = canvas;
this.ctx = ctx;
this.events = events;
@@ -25007,7 +25342,7 @@ class VideoEngine {
this.playbackRate = 1;
this.posterDrawn = false;
this.isFetching = false;
__privateSet(this, _onError, onError);
__privateSet(this, _onError2, 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(source, signal, current) {
@@ -25016,7 +25351,7 @@ class VideoEngine {
drawPoster() {
if (__privateGet(this, _destroyed) || !this.poster || this.posterDrawn)
return;
const generation = __privateGet(this, _generation);
const generation = __privateGet(this, _generation2);
__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;
@@ -25035,13 +25370,13 @@ class VideoEngine {
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).clearRect(0, 0, this.canvas.width, this.canvas.height);
}
cancelPending() {
__privateWrapper(this, _generation)._++;
__privateWrapper(this, _generation2)._++;
__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, _loading2, null);
__privateSet(this, _createSink, null);
__privateSet(this, _usedSink, null);
this.videoSink = null;
@@ -25051,16 +25386,16 @@ class VideoEngine {
if (__privateGet(this, _destroyed))
return;
this.cancelPending();
const generation = __privateGet(this, _generation);
const generation = __privateGet(this, _generation2);
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);
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn2).call(this, media, generation, iteration, onMetadata));
__privateSet(this, _loading2, pending);
try {
await pending;
} finally {
if (__privateGet(this, _loading) === pending)
__privateSet(this, _loading, null);
if (__privateGet(this, _loading2) === pending)
__privateSet(this, _loading2, null);
}
}
handleNoVideoTrack() {
@@ -25077,9 +25412,9 @@ class VideoEngine {
async resetIterator(time) {
if (__privateGet(this, _destroyed))
return;
const generation = __privateGet(this, _generation);
const generation = __privateGet(this, _generation2);
const iteration = ++__privateWrapper(this, _iteration)._;
const loading = __privateGet(this, _loading);
const loading = __privateGet(this, _loading2);
this.stop();
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, true);
@@ -25119,7 +25454,7 @@ class VideoEngine {
if (__privateGet(this, _destroyed))
return;
__privateSet(this, _destroyed, true);
__privateWrapper(this, _generation)._++;
__privateWrapper(this, _generation2)._++;
__privateGet(this, _renderer).destroy();
__privateGet(this, _poster).cancel();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
@@ -25131,19 +25466,19 @@ class VideoEngine {
this.audioClock = null;
}
}
_generation = new WeakMap();
_generation2 = new WeakMap();
_iteration = new WeakMap();
_loading = new WeakMap();
_loading2 = new WeakMap();
_createSink = new WeakMap();
_usedSink = new WeakMap();
_destroyed = new WeakMap();
_preparing = new WeakMap();
_poster = new WeakMap();
_renderer = new WeakMap();
_onError = new WeakMap();
_onError2 = new WeakMap();
_VideoEngine_instances = new WeakSet();
current_fn = function(generation) {
return !__privateGet(this, _destroyed) && __privateGet(this, _generation) === generation;
return !__privateGet(this, _destroyed) && __privateGet(this, _generation2) === generation;
};
context_fn = function() {
if (!this.ctx)
@@ -25154,8 +25489,8 @@ 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);
if (__privateGet(this, _onError2))
__privateGet(this, _onError2).call(this, error);
else
this.events.emit("error", error);
};
@@ -25168,7 +25503,7 @@ draw_fn = function(frame, clear) {
this.clear();
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(frame.canvas, 0, 0);
};
load_fn = async function(media, generation, iteration, onMetadata) {
load_fn2 = async function(media, generation, iteration, onMetadata) {
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
try {
@@ -25242,7 +25577,14 @@ class MediaBunnyEngine {
__privateAdd(this, _playback);
this.events = events;
this.option = option;
this.audio = new AudioEngine(events);
this.audio = new AudioEngine(events, (error) => {
try {
this.pause();
} catch (failure) {
console.warn("MediaBunny audio coordinator cleanup:", failure);
}
this.reportError(error);
});
this.video = new VideoEngine({
canvas,
ctx,
@@ -25279,6 +25621,7 @@ class MediaBunnyEngine {
this.ended = true;
this.paused = true;
__privateGet(this, _playback).ended();
this.audio.pause();
});
}
async load(src) {
@@ -25287,6 +25630,7 @@ class MediaBunnyEngine {
const id = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
this.audio.cancelPending();
this.loadSession?.cancel();
if (id !== this.loadSeq || this.destroyed)
return;
@@ -25311,6 +25655,7 @@ class MediaBunnyEngine {
const failed = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
this.audio.cancelPending();
session.cancel();
if (failed !== this.loadSeq || this.destroyed)
return;
@@ -1,6 +0,0 @@
import type { AudioPort } from './engine-ports'
import type EventTarget from './EventTarget'
// Exact coordinator-facing port; implementation migration and decoder ownership are MB-06.
declare const AudioEngine: new (events: EventTarget) => AudioPort
export default AudioEngine
@@ -1,289 +0,0 @@
/**
* Audio Engine for MediaBunny
* Handles audio playback using Web Audio API
*/
import {
AudioBufferSink,
} from 'mediabunny'
export default class AudioEngine {
constructor(events) {
this.events = events
// MediaBunny instances
this.input = null
this.audioSink = null
this.audioIterator = null
// Web Audio API
this.audioContext = null
this.gainNode = null
// Playback state
this.audioContextStartTime = 0
this.playbackTimeAtStart = 0
this.latestScheduledEndTime = 0
this.duration = Number.NaN
this.paused = true
// Audio settings
this.volume = 0.7
this.muted = false
this.playbackRate = 1
// Async control
this.asyncId = 0
this.queuedNodes = new Set()
}
get currentTime() {
if (this.paused)
return this.playbackTimeAtStart
return (
(this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate
+ this.playbackTimeAtStart
)
}
ensureAudioContext(sampleRate) {
if (this.audioContext)
return
const AudioContext = window.AudioContext || window.webkitAudioContext
try {
this.audioContext = new AudioContext({ sampleRate })
}
catch {
this.audioContext = new AudioContext()
}
this.gainNode = this.audioContext.createGain()
this.gainNode.connect(this.audioContext.destination)
this.updateGain()
}
updateGain() {
if (!this.gainNode)
return
const v = this.muted ? 0 : this.volume
this.gainNode.gain.value = v * v
}
stopQueuedNodes() {
this.queuedNodes.forEach(node => node.stop())
this.queuedNodes.clear()
}
async stopIterator() {
await this.audioIterator?.return()
this.audioIterator = null
}
handleNoAudioTrack() {
this.audioSink = null
this.ensureAudioContext()
}
async load(media, onMetadata) {
++this.asyncId
await this.stopIterator()
this.stopQueuedNodes()
this.paused = true
this.playbackTimeAtStart = 0
this.audioContextStartTime = 0
const { input, audioTrack, duration } = media
this.input = input
this.duration = duration
if (!audioTrack) {
this.handleNoAudioTrack()
onMetadata?.()
return
}
if (audioTrack.codec === null || !(await audioTrack.canDecode())) {
this.audioSink = null
this.ensureAudioContext()
onMetadata?.()
return
}
this.ensureAudioContext(audioTrack.sampleRate)
this.audioSink = new AudioBufferSink(audioTrack)
onMetadata?.()
}
async runIterator(localId) {
if (!this.audioSink)
return
await this.stopIterator()
this.audioIterator = this.audioSink.buffers(this.currentTime)
// Batch size: read multiple audio buffers per iteration to reduce
// IPC round-trip overhead. 16 buffers ≈ 340ms of audio.
const BATCH_SIZE = 16
while (true) {
if (localId !== this.asyncId || this.paused)
return
const batch = []
let batchDone = false
try {
for (let i = 0; i < BATCH_SIZE; i++) {
const result = await this.audioIterator.next()
if (result.done) {
batchDone = true
break
}
batch.push(result.value)
}
}
catch (e) {
console.error('Audio iterator error:', e)
batchDone = true
}
if (localId !== this.asyncId || this.paused)
return
// Resume if was suspended
if (batch.length > 0 && this.audioContext.state === 'suspended') {
await this.audioContext.resume()
this.events.emit('canplay')
this.events.emit('playing')
}
// Schedule all buffers in the batch
for (const { buffer, timestamp } of batch) {
const node = this.audioContext.createBufferSource()
node.buffer = buffer
node.connect(this.gainNode)
node.playbackRate.value = this.playbackRate
const startAt
= this.audioContextStartTime
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
const duration = buffer.duration
const endAt = startAt + duration / this.playbackRate
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart
if (endMediaTime > this.latestScheduledEndTime) {
this.latestScheduledEndTime = endMediaTime
}
if (startAt >= this.audioContext.currentTime) {
node.start(startAt)
}
else {
node.start(
this.audioContext.currentTime,
(this.audioContext.currentTime - startAt) * this.playbackRate,
)
}
this.queuedNodes.add(node)
node.onended = () => this.queuedNodes.delete(node)
}
if (batchDone)
break
// Yield main thread after each batch so video rAF callbacks can run.
// Without this, the audio pump's tight loop starves the render loop.
await new Promise(resolve => setTimeout(resolve, 0))
// Backpressure: if we've scheduled too far ahead, wait for playback
// to catch up before decoding more. This prevents the audio pump from
// consuming all WebCodecs resources and starving the video decoder.
const BUFFER_AHEAD = 1 // seconds
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
if (localId !== this.asyncId || this.paused)
return
await new Promise(resolve => setTimeout(resolve, 50))
}
}
}
async play() {
if (!this.paused)
return
if (!this.audioContext) {
this.ensureAudioContext()
}
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume()
}
this.audioContextStartTime = this.audioContext.currentTime
this.latestScheduledEndTime = this.playbackTimeAtStart
this.paused = false
const id = ++this.asyncId
this.runIterator(id)
}
pause() {
if (this.paused)
return
this.playbackTimeAtStart = this.currentTime
this.paused = true
this.stopIterator()
this.stopQueuedNodes()
}
async seek(time) {
this.playbackTimeAtStart = Math.max(0, time)
this.audioContextStartTime = this.audioContext.currentTime
this.latestScheduledEndTime = this.playbackTimeAtStart
const id = ++this.asyncId
if (!this.paused) {
this.runIterator(id)
}
}
setVolume(volume, muted) {
this.volume = volume
this.muted = muted
this.updateGain()
}
setPlaybackRate(rate) {
if (rate === this.playbackRate)
return
if (!this.paused) {
this.playbackTimeAtStart = this.currentTime
this.audioContextStartTime = this.audioContext.currentTime
}
this.playbackRate = rate
if (!this.paused) {
const id = ++this.asyncId
this.runIterator(id)
}
}
destroy() {
this.asyncId++
this.pause()
this.audioContext?.close()
this.audioContext = null
this.input = null
this.audioSink = null
}
}
@@ -0,0 +1,288 @@
import type { AudioBufferSink as AudioSink, Input } from 'mediabunny'
import type { AudioIterator } from './audio-pump'
import type { AudioPort } from './engine-ports'
import type EventTarget from './EventTarget'
import type { PlaybackMedia } from './media'
import { AudioBufferSink } from 'mediabunny'
import { mediaTime } from './audio-clock'
import ContextOwner from './audio-context'
import NodeQueue from './audio-nodes'
import AudioPump from './audio-pump'
import AudioTask, { CANCELLED } from './audio-task'
export default class AudioEngine implements AudioPort {
declare events: EventTarget
declare input: Input | null
declare audioSink: AudioSink | null
declare audioIterator: AudioIterator | null
declare audioContext: AudioContext | null
declare gainNode: GainNode | null
declare audioContextStartTime: number
declare playbackTimeAtStart: number
declare latestScheduledEndTime: number
declare duration: number
declare paused: boolean
declare volume: number
declare muted: boolean
declare playbackRate: number
declare asyncId: number
declare queuedNodes: Set<AudioBufferSourceNode>
#closed = false
#generation = 0
#contexts: ContextOwner
#nodes: NodeQueue
#pump: AudioPump
#playTask: AudioTask | null = null
#playing: Promise<void> | null = null
#loading: Promise<void> | null = null
#onError: ((error: unknown) => void) | undefined
constructor(events: EventTarget, onError?: (error: unknown) => void) {
this.events = events
this.input = null
this.audioSink = null
this.audioIterator = null
this.audioContext = null
this.gainNode = null
this.audioContextStartTime = 0
this.playbackTimeAtStart = 0
this.latestScheduledEndTime = 0
this.duration = Number.NaN
this.paused = true
this.volume = 0.7
this.muted = false
this.playbackRate = 1
this.asyncId = 0
this.queuedNodes = new Set()
this.#contexts = new ContextOwner(this)
this.#nodes = new NodeQueue(this)
this.#pump = new AudioPump(this, this.#contexts, this.#nodes)
this.#onError = onError
}
get currentTime(): number {
return mediaTime(this)
}
ensureAudioContext(sampleRate?: number): void {
this.#contexts.ensure(sampleRate)
}
updateGain(): void {
this.#contexts.updateGain()
}
stopQueuedNodes(): void {
this.#nodes.stop()
}
async stopIterator(): Promise<void> {
await this.#pump.stopIterator()
}
handleNoAudioTrack(): void {
if (this.#closed)
return
this.audioSink = null
this.ensureAudioContext()
}
cancelPending(): void {
this.#generation++
try {
this.pause()
}
finally {
this.#loading = null
this.input = null
this.audioSink = null
}
}
async load(media: PlaybackMedia, onMetadata?: () => void): Promise<void> {
if (this.#closed)
return
this.cancelPending()
const generation = this.#generation
this.playbackTimeAtStart = 0
this.audioContextStartTime = 0
this.latestScheduledEndTime = 0
const pending = Promise.resolve().then(() => this.#load(media, generation, onMetadata))
this.#loading = pending
try {
await pending
}
finally {
if (this.#loading === pending)
this.#loading = null
}
}
async #load({ input, audioTrack, duration }: PlaybackMedia, generation: number, onMetadata?: () => void): Promise<void> {
const current = () => !this.#closed && this.#generation === generation
if (!current())
return
this.input = input
this.duration = duration
try {
const decodable = audioTrack && audioTrack.codec !== null && await audioTrack.canDecode()
if (!current())
return
if (!decodable) {
this.handleNoAudioTrack()
}
else {
this.ensureAudioContext(audioTrack.sampleRate)
if (!current())
return
this.audioSink = new AudioBufferSink(audioTrack)
}
if (current())
onMetadata?.()
}
catch (error) {
if (current())
throw error
}
}
async runIterator(localId: number): Promise<void> {
if (this.#closed)
return
await this.#pump.run(localId)
}
#startPump(id: number): void {
void this.runIterator(id).catch((error) => {
if (this.#closed || id !== this.asyncId)
return
try {
this.pause()
}
catch (failure) { console.warn('MediaBunny audio failure cleanup:', failure) }
try {
if (this.#onError)
this.#onError(error)
else
this.events.emit('error', error)
}
catch (failure) { console.warn('MediaBunny audio error listener:', failure) }
})
}
async play(): Promise<void> {
if (this.#closed)
return
if (this.#playing)
return this.#playing
if (!this.paused)
return
const task = new AudioTask()
this.#playTask = task
const generation = this.#generation
const pending = this.#play(task, generation)
this.#playing = pending
try {
await pending
}
finally {
if (this.#playTask === task) {
this.#playTask = null
this.#playing = null
}
}
}
async #play(task: AudioTask, generation: number): Promise<void> {
const current = () => !this.#closed && !task.cancelled && this.#generation === generation
try {
const context = this.#contexts.ensure()
if (!context || !current())
return
if (context.state === 'suspended') {
const resumed = await task.wait(this.#contexts.resume(context))
if (resumed === CANCELLED || !current())
return
}
if (this.#loading) {
const loaded = await task.wait(this.#loading)
if (loaded === CANCELLED || !current())
return
}
this.audioContextStartTime = context.currentTime
this.latestScheduledEndTime = this.playbackTimeAtStart
this.paused = false
this.#startPump(++this.asyncId)
}
catch (error) {
if (current())
throw error
}
}
pause(): void {
if (!this.paused)
this.playbackTimeAtStart = this.currentTime
this.paused = true
this.asyncId++
this.#playTask?.cancel()
this.#playTask = null
this.#playing = null
this.#pump.cancel()
this.stopQueuedNodes()
}
async seek(time: number): Promise<void> {
if (this.#closed)
return
const playing = !this.paused
this.#pump.cancel()
this.stopQueuedNodes()
this.playbackTimeAtStart = Math.max(0, time)
this.audioContextStartTime = this.audioContext?.currentTime ?? 0
this.latestScheduledEndTime = this.playbackTimeAtStart
const id = ++this.asyncId
if (playing)
this.#startPump(id)
}
setVolume(volume: number, muted: boolean): void {
this.volume = volume
this.muted = muted
this.updateGain()
}
setPlaybackRate(rate: number): void {
if (this.#closed || rate === this.playbackRate)
return
const time = this.currentTime
this.#pump.cancel()
this.stopQueuedNodes()
this.playbackTimeAtStart = time
this.audioContextStartTime = this.audioContext?.currentTime ?? 0
this.playbackRate = rate
this.latestScheduledEndTime = time
const id = ++this.asyncId
if (!this.paused)
this.#startPump(id)
}
destroy(): void {
if (this.#closed)
return
this.#closed = true
this.#generation++
const failures: unknown[] = []
for (const release of [() => this.pause(), () => this.#contexts.destroy()]) {
try {
release()
}
catch (error) { failures.push(error) }
}
this.input = null
this.audioSink = null
this.#loading = null
if (failures.length)
throw failures[0]
}
}
@@ -35,7 +35,15 @@ export default class MediaBunnyEngine implements EnginePort {
constructor({ canvas, ctx, events, option = {} }: EngineOptions) {
this.events = events
this.option = option
this.audio = new AudioEngine(events)
this.audio = new AudioEngine(events, (error) => {
try {
this.pause()
}
catch (failure) {
console.warn('MediaBunny audio coordinator cleanup:', failure)
}
this.reportError(error)
})
this.video = new VideoEngine({
canvas,
ctx,
@@ -73,6 +81,7 @@ export default class MediaBunnyEngine implements EnginePort {
this.ended = true
this.paused = true
this.#playback.ended()
this.audio.pause()
})
}
@@ -82,6 +91,7 @@ export default class MediaBunnyEngine implements EnginePort {
const id = ++this.loadSeq
this.#playback.invalidate()
this.video.cancelPending()
this.audio.cancelPending()
this.loadSession?.cancel()
if (id !== this.loadSeq || this.destroyed)
return
@@ -107,6 +117,7 @@ export default class MediaBunnyEngine implements EnginePort {
const failed = ++this.loadSeq
this.#playback.invalidate()
this.video.cancelPending()
this.audio.cancelPending()
session.cancel()
if (failed !== this.loadSeq || this.destroyed)
return
@@ -0,0 +1,20 @@
export interface AudioClock {
audioContext: AudioContext | null
paused: boolean
audioContextStartTime: number
playbackTimeAtStart: number
playbackRate: number
}
export function mediaTime(clock: AudioClock): number {
if (clock.paused || !clock.audioContext)
return clock.playbackTimeAtStart
return (clock.audioContext.currentTime - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart
}
export function bufferTiming(clock: AudioClock, timestamp: number, duration: number, now: number) {
const startAt = clock.audioContextStartTime + (timestamp - clock.playbackTimeAtStart) / clock.playbackRate
const endAt = startAt + duration / clock.playbackRate
const endMediaTime = (endAt - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart
return { startAt: Math.max(startAt, now), offset: startAt < now ? (now - startAt) * clock.playbackRate : null, endMediaTime }
}
@@ -0,0 +1,92 @@
interface ContextHost {
audioContext: AudioContext | null
gainNode: GainNode | null
volume: number
muted: boolean
}
export default class ContextOwner {
private closed = false
private resumes = new WeakMap<AudioContext, Promise<void>>()
constructor(private host: ContextHost) {}
ensure(sampleRate?: number): AudioContext | null {
if (this.closed)
return null
if (this.host.audioContext)
return this.host.audioContext
const Context = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
let context: AudioContext
try {
context = new Context({ sampleRate })
}
catch {
context = new Context()
}
if (this.closed) {
void context.close().catch(error => console.warn('MediaBunny obsolete context cleanup:', error))
return null
}
let gain: GainNode | null = null
try {
gain = context.createGain()
gain.connect(context.destination)
const volume = this.host.muted ? 0 : this.host.volume
gain.gain.value = volume * volume
if (this.closed) {
gain.disconnect()
void context.close().catch(error => console.warn('MediaBunny obsolete context cleanup:', error))
return null
}
this.host.audioContext = context
this.host.gainNode = gain
return context
}
catch (error) {
try {
gain?.disconnect()
}
catch (failure) { console.warn('MediaBunny gain setup cleanup:', failure) }
void context.close().catch(failure => console.warn('MediaBunny context setup cleanup:', failure))
throw error
}
}
updateGain(): void {
if (!this.host.gainNode)
return
const volume = this.host.muted ? 0 : this.host.volume
this.host.gainNode.gain.value = volume * volume
}
resume(context: AudioContext): Promise<void> {
const previous = this.resumes.get(context)
if (previous)
return previous
const pending = context.resume()
this.resumes.set(context, pending)
const clear = () => {
if (this.resumes.get(context) === pending)
this.resumes.delete(context)
}
pending.then(clear, clear)
return pending
}
destroy(): void {
if (this.closed)
return
this.closed = true
const { audioContext: context, gainNode: gain } = this.host
this.host.audioContext = null
this.host.gainNode = null
try {
gain?.disconnect()
}
finally {
if (context)
void context.close().catch(error => console.warn('MediaBunny context close error:', error))
}
}
}
@@ -0,0 +1,88 @@
import type { WrappedAudioBuffer } from 'mediabunny'
import type { AudioClock } from './audio-clock'
import { bufferTiming } from './audio-clock'
interface QueueHost extends AudioClock {
gainNode: GainNode | null
queuedNodes: Set<AudioBufferSourceNode>
latestScheduledEndTime: number
}
export default class NodeQueue {
constructor(private host: QueueHost) {}
private release(node: AudioBufferSourceNode, stop: boolean): void {
this.host.queuedNodes.delete(node)
node.onended = null
let failed = false
let failure: unknown
try {
if (stop)
node.stop()
}
catch (error) {
failed = true
failure = error
}
try {
node.disconnect()
}
catch (error) {
if (!failed) {
failed = true
failure = error
}
}
if (failed)
throw failure
}
stop(): void {
const failures: unknown[] = []
for (const node of [...this.host.queuedNodes]) {
try {
this.release(node, true)
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
schedule({ buffer, timestamp }: WrappedAudioBuffer): void {
const context = this.host.audioContext
const gain = this.host.gainNode
if (!context || !gain)
return
const timing = bufferTiming(this.host, timestamp, buffer.duration, context.currentTime)
if (timing.offset !== null && timing.offset >= buffer.duration)
return
const node = context.createBufferSource()
this.host.queuedNodes.add(node)
try {
node.buffer = buffer
node.connect(gain)
node.playbackRate.value = this.host.playbackRate
node.onended = () => {
if (!this.host.queuedNodes.has(node))
return
try {
this.release(node, false)
}
catch (error) { console.warn('MediaBunny ended node cleanup:', error) }
}
if (timing.offset === null)
node.start(timing.startAt)
else
node.start(timing.startAt, timing.offset)
this.host.latestScheduledEndTime = Math.max(this.host.latestScheduledEndTime, timing.endMediaTime)
}
catch (error) {
try {
this.release(node, true)
}
catch (failure) { console.warn('MediaBunny audio node cleanup:', failure) }
throw error
}
}
}
@@ -0,0 +1,106 @@
import type { AudioBufferSink, WrappedAudioBuffer } from 'mediabunny'
import type ContextOwner from './audio-context'
import type NodeQueue from './audio-nodes'
import type EventTarget from './EventTarget'
import AudioTask, { CANCELLED } from './audio-task'
import { publish } from './readiness'
export type AudioIterator = Pick<AsyncGenerator<WrappedAudioBuffer, void, unknown>, 'next' | 'return'>
export interface PumpHost {
audioSink: AudioBufferSink | null
audioIterator: AudioIterator | null
audioContext: AudioContext | null
paused: boolean
asyncId: number
readonly currentTime: number
latestScheduledEndTime: number
events: EventTarget
}
export default class AudioPump {
private task: AudioTask | null = null
private closing = new WeakMap<AudioIterator, Promise<void>>()
constructor(private host: PumpHost, private contexts: ContextOwner, private nodes: NodeQueue) {}
async stopIterator(): Promise<void> {
const iterator = this.host.audioIterator
this.host.audioIterator = null
if (!iterator)
return
let pending = this.closing.get(iterator)
if (!pending) {
pending = Promise.resolve().then(() => iterator.return()).then(() => {})
this.closing.set(iterator, pending)
}
await pending
}
cancel(): void {
this.task?.cancel()
this.task = null
void this.stopIterator().catch(error => console.warn('MediaBunny audio iterator cleanup:', error))
}
async run(id: number): Promise<void> {
if (this.host.asyncId !== id)
return
this.cancel()
const sink = this.host.audioSink
const context = this.host.audioContext
if (!sink || !context || this.host.paused || this.host.asyncId !== id)
return
const task = new AudioTask()
this.task = task
let iterator: AudioIterator | null = null
const current = () => !task.cancelled && this.task === task && this.host.asyncId === id && !this.host.paused && this.host.audioContext === context && (!iterator || this.host.audioIterator === iterator)
try {
iterator = sink.buffers(this.host.currentTime)
this.host.audioIterator = iterator
while (current()) {
const batch: WrappedAudioBuffer[] = []
let done = false
for (let index = 0; index < 16; index++) {
const result = await task.wait(iterator.next())
if (result === CANCELLED || !current())
return
if (result.done) {
done = true
break
}
batch.push(result.value)
}
if (batch.length && context.state === 'suspended') {
const resumed = await task.wait(this.contexts.resume(context))
if (resumed === CANCELLED || !current())
return
publish(this.host.events, current, ['canplay', 'playing'])
}
for (const buffer of batch) {
if (!current())
return
this.nodes.schedule(buffer)
}
if (done)
return
if (!(await task.delay(0)) || !current())
return
while (this.host.latestScheduledEndTime - this.host.currentTime > 1) {
if (!(await task.delay(50)) || !current())
return
}
}
}
catch (error) {
if (current())
throw error
}
finally {
task.cancel()
if (this.task === task) {
this.task = null
void this.stopIterator().catch(error => console.warn('MediaBunny audio iterator cleanup:', error))
}
}
}
}
@@ -0,0 +1,50 @@
export const CANCELLED = Symbol('cancelled')
export default class AudioTask {
cancelled = false
private waits = new Set<() => void>()
private timers = new Map<ReturnType<typeof setTimeout>, (active: boolean) => void>()
wait<T>(pending: Promise<T>): Promise<T | typeof CANCELLED> {
return new Promise((resolve, reject) => {
const cancel = () => resolve(CANCELLED)
if (this.cancelled)
cancel()
else
this.waits.add(cancel)
pending.then((value) => {
this.waits.delete(cancel)
resolve(value)
}, (error) => {
this.waits.delete(cancel)
reject(error)
})
})
}
delay(ms: number): Promise<boolean> {
if (this.cancelled)
return Promise.resolve(false)
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.timers.delete(timer)
resolve(!this.cancelled)
}, ms)
this.timers.set(timer, resolve)
})
}
cancel(): void {
if (this.cancelled)
return
this.cancelled = true
for (const cancel of this.waits)
cancel()
this.waits.clear()
for (const [timer, resolve] of this.timers) {
clearTimeout(timer)
resolve(false)
}
this.timers.clear()
}
}
@@ -4,6 +4,7 @@ import type EventTarget from './EventTarget'
import type { PlaybackMedia } from './media'
export interface AudioPort {
cancelPending: () => void
readonly currentTime: number
duration: number
paused: boolean
@@ -28,7 +28,6 @@ export default class Playback {
private wanted = false
private playing: Operation | null = null
private seeking: Operation | null = null
private audioStart: Promise<void> | null = null
constructor(private host: PlaybackHost) {}
@@ -103,19 +102,6 @@ export default class Playback {
return operation.promise
}
private async startAudio(): Promise<void> {
if (!this.audioStart)
this.audioStart = this.host.audio.play()
const pending = this.audioStart
try {
await pending
}
finally {
if (this.audioStart === pending)
this.audioStart = null
}
}
private async runPlay(operation: Operation): Promise<void> {
const current = () => this.current(operation, 'playing') && this.wanted
try {
@@ -127,7 +113,7 @@ export default class Playback {
if (!current())
return
this.host.paused = false
await this.startAudio()
await this.host.audio.play()
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause()
+10
View File
@@ -0,0 +1,10 @@
{
"task": "PKG-MB-06",
"sourceCommit": "9d037dcbf913fef2d2a9eb4f3924d6abf3ab5597",
"sourceSha256LF": {
"packages/artplayer-proxy-mediabunny/src/AudioEngine.js": "910d6831372e4895d55dec78010f281a199bc88f99735630a5a3b621f192ac35",
"packages/artplayer-proxy-mediabunny/src/MediaBunnyEngine.ts": "237d3d29fbf61a74df83dea11d862450dfd8f3b7fbd555dd5552b630ff0191e9",
"packages/artplayer-proxy-mediabunny/src/playback.ts": "0512f6a1b253d754b6644266327e368e0e18cce1cf9a3af62b6747ed55d691f7",
"packages/artplayer-proxy-mediabunny/dist/artplayer-proxy-mediabunny.js": "9c89a729197593ffcab11b64813b312f588f5515d2cb8acb0537d0a389a5209c"
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,72 @@
# PKG-MB-06:音频解码、时钟与资源归属
任务通过完整CI后标记done,并立即以专用本地提交交付。来源是9d037dcbf913fef2d2a9eb4f3924d6abf3ab5597,
冻结旧AudioEngine、协调器、playback和main的LF哈希见[源码基线](../baselines/mb-audio-source.json)。
候选文件、测试、产物和浏览器报告见[验证记录](../baselines/mb-audio-validation.json)。
## 实现与拆分
实际删除AudioEngine.js及临时AudioEngine.d.ts,迁移到AudioEngine.ts;拆出audio-clock、
audio-context、audio-nodes、audio-pump、audio-task五个模块,分别管理时钟计算、
Context/Gain、源节点、SDK迭代器/背压、取消等待/计时器。模块地图和维护命令同步到
包内ARCHITECTURE.md。包内26个生产TS模块,没有遗留JS声明桥;index.js和m3u8.js
分别由MB-08/07接续,不能据此声称整个包已经完成TS化。
source、play和pump分别拥有取消状态。暂停会取消尚未完成的resume;新source在解析轨道
之前就使旧音频操作失效。迟到的canDecode、resume、next不能重建context、写入sink、
发出旧playing或排队旧节点。迭代器同步脱离引用、return只调用一次且观察拒绝。
每批16buffer、0ms让出及1秒/50ms背压参数保留,任务释放包含ID零的计时器。
复查新实现时消除了对单个长期未决Promise反复race的设计:每次读取完成就移除取消回调,
避免正常长播放累积已完成读取的等待监听。这是源码审查改进,不声称已做长时间堆内存验收。
节点在start前即登记归属,自然ended也断开连接并清除回调;暂停、seek、倍速与destroy
停止/断开旧队列。一个节点失败仍尝试其余节点及Context/Gain清理,保留首个失败。
后台解码失败先停止播放并报告一次MediaError;清理失败不会替换解码原始错误。
Context/Gain设置成功后才安装引用;失败释放部分资源,销毁清空引用并观察close拒绝。
真正异步的AudioContext.close通过浏览器状态轮询验证,不以固定两帧声称关闭完成。
## 兼容边界与修复行为
公开factory、canvas转发、声明、默认选项、三种分发入口及MediaBunny 1.56.1依赖保持原有契约。
时钟仍按锚点和倍速计算,volume仍用平方增益,mute为零;暂停冻结位置,seek/rate更新锚点。
无音轨但有视频仍使用Web Audio时钟,完全无轨道容器继续MB-04的明确拒绝行为。
已完全过期的buffer不再创建无效节点,部分迟到buffer保留原offset计算。视频结束会停止
音频时钟和余留节点,倍速切换会停止旧速节点。仍使用原生source.playbackRate,未引入保调算法。
原先playback层缓存整个audio.play Promise;如今音频play可在取消时提前完成,该缓存不再
能代表底层resume状态,因此去重下移到ContextOwner。协调器新意图各调用audio.play,
实际原生resume仍共享一次。受控协调器测试的audio.play调用数由1改为2,新增实际AudioEngine
测试验证play→pause→play的原生resume次数仍为1,只有当前意图启动视频。
这项变化是内部归属修正,不能把stub调用数变化隐瞒为原样不变的测试。
## 验证与证据边界
- 37项新增音频断言:最终候选全部通过;同一断言对旧main为8通过/29失败,无取消或跳过。
首轮旧测试的一项等待夹具超时已归档;修正夹具在finally释放受控读后,最终旧基线没有超时。
- 包专项183通过,main/legacy各159通过;每个产物命令含155候选断言及4个冻结正常事件对照。
- 新增24项浏览器:Chromium/Firefox各8项实际Web Audio/SDK解码场景,Windows WebKit
8项精确能力缺失对照。包括暂停恢复、1×/2×、增益/静音、无音轨时钟、迟到buffer与resume、
seek后的HLS音画时钟取样。短时实际绘制帧与音频时钟偏差上限为250ms,报告保存原始样本;
这不是听觉输出、长时间漂移、物理设备或所有Safari安装的验收。
- 同24项对旧main:20通过/4失败;失败是Chromium/Firefox音频与HLS暂停后节点/回调
仍保留的状态断言,不是加载超时。其他旧浏览器场景通过属于回归对照,不能算作复现缺陷。
- 最终构建main运行全部六个MediaBunny浏览器文件,共78项:包含既有视频竞态、输入、
Stream取消及synthetic RAF。报告与整个results目录在下一次运行前归档。
- 严格TS、lint、三格式构建、ESM默认factory和dist/docs逐字节一致性独立核验。
- 本项不增加依赖;根test:unit与test:mediabunny加入音频测试,使完整CI持续执行。
最终完整CI1344项(1189单元+14工程+141基线)通过,另44重复契约;316生产TS。
最终78浏览器报告中,Chromium 27个播放帧取样最大偏差39.34ms,Firefox 28个取样最大
偏差37.67ms。所有源码/测试/产物及报告哈希已复核,dist与docs/compiled三个格式一致。
## 环境问题与后续
一次Windows copyfile UNKNOWN发生在构建复制既有legacy生成文件时。保留失败日志和旧生成文件,
核对唯一绝对路径后删除该生成目标,再由正常yarn build重建;未手改生成内容或停止用户进程。
首轮78浏览器报告也保留,因为取消等待实现随后改进,最终产物重新构建并重跑对应验证。
MB-LIFE-01仍open:音视频各自的取消与短时组合已覆盖,HLS UI拓扑/并发选择、长播放/AV同步、
codec能力矩阵和全核心/插件组合仍由MB-07/08/09及能力风险条目处理。MB-10承担真实安装包、
demo与MPL/source通知;本项通过不是npm发布许可或发布验收。
回退通过revert本项专用提交,冻结旧实现保持可追溯。下一步MB-07整理m3u8.ts选择与拓扑清理。
+10
View File
@@ -61,3 +61,13 @@ VideoEngine与迭代器/渲染/海报职责已实际迁移TS,删除视频声
执行yarn test:mediabunny及新增mediabunny-video.spec.js,按实际报告区分普通播放、
真实帧竞态、能力对照和synthetic RAF。[变更](changes/2026-09-12-PKG-MB-05-video.md)与
[验证](baselines/mb-video-validation.json)记录38个新断言、旧产物复现及真实解码场景。
## PKG-MB-06 音频归属与时钟
实际AudioEngine迁移并拆为六个TS模块,包内26生产TS,无JS声明桥;m3u8/index由07/08接续。
[变更](changes/2026-09-12-PKG-MB-06-audio.md)记录文件地图、resume归属下移、兼容边界和回退,
[证据](baselines/mb-audio-validation.json)记录旧main 37断言8通过/29失败、候选专项183及
main/legacy各159通过。最终78浏览器含16新增原生音频场景及8新增WebKit能力对照。
同24音频浏览器断言对旧main20通过/4失败,失败为实际暂停后的节点清理;其余为回归对照。
短时1×/2×帧/音频时钟偏差验证不替代长播放、听觉及设备验收;全部报告/results已归档。
完整CI1344项及另44重复契约通过,316生产TS;完成后立即专用提交,MB-LIFE-01剩余范围仍open。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 113 / doing 7 / blocked 0 / done 102 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 112 / doing 7 / blocked 0 / done 103 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -334,7 +334,7 @@
| PKG-MB-03 | artplayer-proxy-mediabunny<br>整理 input 与加载取消 | PKG-MB-02, CORE-09 | input.js 源检测、输入资源、Range 与取消 | HLS 检测在 input 边界,旧来源类型和超时保持 | H | done |
| PKG-MB-04 | artplayer-proxy-mediabunny<br>整理 VideoShim/EventTarget/Engine 协调 | PKG-MB-03, CORE-06, CORE-11 | shim 描述符、事件桥、加载状态和操作隔离 | 公开属性/同步异步返回与事件重入顺序保持 | H | done |
| PKG-MB-05 | artplayer-proxy-mediabunny<br>整理视频解码、seek 和帧释放 | PKG-MB-04 | VideoEngine 调度、队列、晚帧处理和资源释放 | 暂停/seek/切源无旧帧覆盖,默认丢帧策略不变 | H | done |
| PKG-MB-06 | artplayer-proxy-mediabunny<br>整理音频解码、时钟与同步 | PKG-MB-04 | AudioEngine 时钟、缓冲和 AudioContext 归属 | AV sync、倍速、无音轨、静音/音量和暂停恢复通过 | H | todo |
| PKG-MB-06 | artplayer-proxy-mediabunny<br>整理音频解码、时钟与同步 | PKG-MB-04 | AudioEngine 时钟、缓冲和 AudioContext 归属 | AV sync、倍速、无音轨、静音/音量和暂停恢复通过 | H | done |
| PKG-MB-07 | artplayer-proxy-mediabunny<br>整理 HLS 配对轨道与 selector | PKG-MB-05, PKG-MB-06, CORE-14 | m3u8 配对、质量/音频选择及拓扑清理 | 实际选择高亮、切到无轨道来源清理、无重复 readiness | H | todo |
| PKG-MB-08 | artplayer-proxy-mediabunny<br>完成 TS 与媒体能力声明 | PKG-MB-07, ENG-04, ENG-06 | 8 个自有 JS 模块迁移与 Result/shim 类型 | 解码器/Stream/DOM 类型清楚,旧 Option 和 art.mediabunny 使用保持 | H | todo |
| PKG-MB-09 | artplayer-proxy-mediabunny<br>完成新旧核心和真实媒体组合 | PKG-MB-08, CORE-22 | 跨浏览器能力、长播放、DPiP、HLS 音轨/质量报告 | 资源释放、事件顺序、AV sync 与支持范围满足基线 | H | todo |
@@ -525,4 +525,5 @@
- PKG-MB-03: [记录](changes/2026-09-12-PKG-MB-03-input.md) [记录](baselines/mb-input-validation.json)
- PKG-MB-04: [记录](baselines/mb-coordination-source.json) [记录](changes/2026-09-12-PKG-MB-04-shim-checkpoint.md) [记录](baselines/mb-shim-checkpoint.json) [记录](changes/2026-09-12-PKG-MB-04-coordination.md) [记录](baselines/mb-coordination-validation.json)
- PKG-MB-05: [记录](baselines/mb-video-source.json) [记录](changes/2026-09-12-PKG-MB-05-video.md) [记录](baselines/mb-video-validation.json)
- PKG-MB-06: [记录](baselines/mb-audio-source.json) [记录](changes/2026-09-12-PKG-MB-06-audio.md) [记录](baselines/mb-audio-validation.json)
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md)
+13
View File
@@ -1,5 +1,18 @@
# 进度与证据
## PKG-MB-06完成:音频解码、时钟与资源归属
AudioEngine实际迁移TS,拆清clock、context、nodes、pump、task职责,删除最后音频声明桥。
暂停/切源/销毁取消迟到resume和buffer,seek/rate释放旧速节点;时钟、增益与无音轨回退
保持契约,播放协调器的原生resume去重下移到context owner。包内26生产TS,剩m3u8/index
由07/08接续。37音频断言旧main8通过/29失败,候选专项183、main/legacy各159通过。
最终78浏览器通过:含16新增原生音频场景及8新增能力对照;旧main同24音频浏览器
20通过/4节点清理失败。短时1×/2×音画取样最大偏差约39/38ms,长播/听觉/设备仍MB-09。
完整CI1344项通过,另44重复契约;316生产TS。源码/产物/报告哈希及三格式复制一致。
见[变更](changes/2026-09-12-PKG-MB-06-audio.md)与[证据](baselines/mb-audio-validation.json)。
222项:103 done、7 doing、112 todo;立即专用本地提交,下一项MB-07 HLS UI/拓扑。
MB-LIFE-01剩余长播放、全组合及HLS UI范围仍open;不构成npm发布验收。
## PKG-MB-05完成:视频迭代器、seek、晚帧与RAF
VideoEngine实际迁移TS,拆出frame读取/释放、renderer与poster职责,删除视频声明桥。
+4 -2
View File
@@ -3648,7 +3648,7 @@
"PKG-MB-07",
"PKG-MB-09"
],
"compatibleResolution": "Reproduce pending operations and destroy/source-switch races before migrating each owner; preserve public event and promise behavior while preventing stale resource use. MB-02 now reproduces late load rejection and uncancelled waiting/loadstart timers after destroy in all three historical implementations using a controlled host. Other interleavings and full-core native behavior are not yet verified. Expanded MB-02 baseline reproduces play-after-pause/destroy, rejected-play false state, seek-after-destroy and retained successful-load timeout timers in all three actual historical implementations; Range and active-timeout normal controls remain separate. MB-03 candidate owns pending Input before track selection and cancels load/HEAD/timers on source replacement, timeout and destroy; source/main/legacy regressions pass. Pending play/seek, late decoder work and full readiness coordination remain MB-04/05/06, so this risk stays open. MB-04 shim checkpoint fixes synthetic RAF retention and terminal event-listener cleanup, including engine cleanup exceptions. Closing the event bridge does not prove pending engine operations stopped; coordination remains in progress. MB-04 now guards play/seek/replacement completion, preserves explicit pause, observes stale failures and isolates reentrant event sequences. Decoder-internal late frames/audio and HLS UI races remain MB-05/06/07; risk stays open. MB-05 migrates VideoEngine and owns iterators, late first/second seek frames, pending reads, per-instance RAF and poster callbacks; same native race assertions fail on old main and pass on candidate. Independent CanvasSink pools also prevent multiple obsolete seek samples from overwriting queued current-frame pixels. Audio nodes, long-run AV sync and HLS UI topology remain MB-06/07/09.",
"compatibleResolution": "Reproduce pending operations and destroy/source-switch races before migrating each owner; preserve public event and promise behavior while preventing stale resource use. MB-02 now reproduces late load rejection and uncancelled waiting/loadstart timers after destroy in all three historical implementations using a controlled host. Other interleavings and full-core native behavior are not yet verified. Expanded MB-02 baseline reproduces play-after-pause/destroy, rejected-play false state, seek-after-destroy and retained successful-load timeout timers in all three actual historical implementations; Range and active-timeout normal controls remain separate. MB-03 candidate owns pending Input before track selection and cancels load/HEAD/timers on source replacement, timeout and destroy; source/main/legacy regressions pass. Pending play/seek, late decoder work and full readiness coordination remain MB-04/05/06, so this risk stays open. MB-04 shim checkpoint fixes synthetic RAF retention and terminal event-listener cleanup, including engine cleanup exceptions. Closing the event bridge does not prove pending engine operations stopped; coordination remains in progress. MB-04 now guards play/seek/replacement completion, preserves explicit pause, observes stale failures and isolates reentrant event sequences. Decoder-internal late frames/audio and HLS UI races remain MB-05/06/07; risk stays open. MB-05 migrates VideoEngine and owns iterators, late first/second seek frames, pending reads, per-instance RAF and poster callbacks; same native race assertions fail on old main and pass on candidate. Independent CanvasSink pools also prevent multiple obsolete seek samples from overwriting queued current-frame pixels. Audio nodes, long-run AV sync and HLS UI topology remain MB-06/07/09. MB-06 migrates AudioEngine into six checked modules, owns context/gain/nodes/iterators and removable cancellation waits, preserves clock and gain formulas, and invalidates pending resume/decoding across pause/source/destroy. Same old main fails 29 of 37 audio assertions and four native pause-cleanup cases; candidate passes with short native 1x/2x AV-clock samples. Long-run AV sync, full combinations and HLS UI topology remain MB-07/09, so risk stays open.",
"closureCriteria": "Historical failing interleavings and candidate cancellation/cleanup pass with real media and event evidence, including long playback and track topology.",
"status": "open",
"evidence": [
@@ -3664,7 +3664,9 @@
"refactor/changes/2026-09-12-PKG-MB-04-coordination.md",
"refactor/baselines/mb-coordination-validation.json",
"refactor/changes/2026-09-12-PKG-MB-05-video.md",
"refactor/baselines/mb-video-validation.json"
"refactor/baselines/mb-video-validation.json",
"refactor/changes/2026-09-12-PKG-MB-06-audio.md",
"refactor/baselines/mb-audio-validation.json"
]
},
{
+6 -2
View File
@@ -3502,11 +3502,15 @@
"dependsOn": [
"PKG-MB-04"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "AudioEngine 时钟、缓冲和 AudioContext 归属",
"acceptance": "AV sync、倍速、无音轨、静音/音量和暂停恢复通过",
"evidence": []
"evidence": [
"baselines/mb-audio-source.json",
"changes/2026-09-12-PKG-MB-06-audio.md",
"baselines/mb-audio-validation.json"
]
},
{
"id": "PKG-MB-07",
+254
View File
@@ -0,0 +1,254 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { mbCandidate } from '../helpers/mediabunny.js'
import { expect, test } from './fixtures.js'
const implementation = await mbCandidate()
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
const hls = new Map(Object.entries(manifest.files).map(([name, expected]) => {
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
assert.equal(hash(bytes), expected.sha256)
return [name, bytes]
}))
for (const scenario of ['playback', 'buffer-pause', 'buffer-rate', 'resume-pause', 'resume-source', 'resume-destroy', 'av-clock', 'no-track']) {
test(`MediaBunny ${implementation.name}: native audio ownership ${scenario}`, async ({ page, browserName }, testInfo) => {
await page.route('**/mb-audio-hls/**', async (route) => {
const name = new URL(route.request().url()).pathname.split('/').at(-1)
const body = hls.get(name)
await route.fulfill({ status: body ? 200 : 404, body: body || 'Missing fixture', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
})
await page.goto('/test/player.html?core=published')
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
const capabilities = await page.evaluate(() => ({ AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioDecoder: typeof AudioDecoder, VideoDecoder: typeof VideoDecoder }))
let outcome = 'unverified'
let result
await page.evaluate(async (scenario) => {
const listeners = new Map()
window.mbEvents = []
window.mbNodes = []
window.mbClockSamples = []
window.mbFlush = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
window.mbHost = {
constructor: window.Artplayer,
option: { url: '', autoSize: false },
template: { $player: document.querySelector('.player') },
on(name, fn) {
const list = listeners.get(name) || []
list.push(fn)
listeners.set(name, list)
},
emit(name, ...args) {
window.mbEvents.push(name)
for (const fn of listeners.get(name) || []) fn(...args)
},
}
const canvas = window.mbFactory()(window.mbHost)
window.mbCanvas = canvas
document.querySelector('.player').append(canvas)
await canvas.engine.load(scenario === 'av-clock' ? '/mb-audio-hls/master.m3u8' : scenario === 'no-track' ? '/test/pattern.mp4' : '/test/audio-tone.m4a')
const audio = canvas.engine.audio
const context = audio.audioContext
window.mbContext = context
if (context) {
const create = context.createBufferSource.bind(context)
context.createBufferSource = () => {
const node = create()
const record = { node, stops: 0, disconnects: 0, starts: [] }
const stop = node.stop.bind(node)
const disconnect = node.disconnect.bind(node)
const start = node.start.bind(node)
node.stop = (...args) => {
record.stops++
return stop(...args)
}
node.disconnect = (...args) => {
record.disconnects++
return disconnect(...args)
}
node.start = (...args) => {
record.starts.push({ args, rate: node.playbackRate.value, at: context.currentTime })
return start(...args)
}
window.mbNodes.push(record)
return node
}
}
canvas.muted = true
document.querySelector('#play').onclick = () => {
window.mbPlay = canvas.play()
}
}, scenario)
try {
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
expect(browserName).toBe('webkit')
expect(capabilities.AudioDecoder).toBe('undefined')
expect(await page.evaluate(() => window.mbCanvas.error?.code)).toBe(4)
expect(await page.evaluate(() => window.mbCanvas.readyState)).toBeLessThan(4)
outcome = 'unsupported-capability-control'
return
}
expect(await page.evaluate(() => window.mbCanvas.error)).toBeNull()
expect(await page.evaluate(() => window.mbCanvas.readyState)).toBe(4)
if (scenario.startsWith('resume-')) {
await page.evaluate(async () => {
const context = window.mbContext
await context.suspend()
const resume = context.resume.bind(context)
context.resume = () => {
const native = resume()
window.mbResumeCalled = true
return native.then(() => new Promise((resolve) => {
window.mbReleaseResume = resolve
}))
}
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => typeof window.mbReleaseResume)).toBe('function')
result = await page.evaluate(async (scenario) => {
if (scenario === 'resume-pause')
window.mbCanvas.pause()
else if (scenario === 'resume-source')
await window.mbCanvas.engine.load('/test/pattern.mp4')
else window.mbHost.emit('destroy')
window.mbReleaseResume()
await window.mbPlay
await window.mbFlush()
const audio = window.mbCanvas.engine.audio
return { paused: audio.paused, queued: audio.queuedNodes.size, created: window.mbNodes.length, iterator: Boolean(audio.audioIterator), audioSink: Boolean(audio.audioSink) }
}, scenario)
expect(result).toMatchObject({ paused: true, queued: 0, created: 0, iterator: false })
if (scenario !== 'resume-pause')
expect(result.audioSink).toBe(false)
}
else if (scenario.startsWith('buffer-')) {
await page.evaluate(() => {
const sink = window.mbCanvas.engine.audio.audioSink
const buffers = sink.buffers.bind(sink)
let first = true
sink.buffers = (...args) => {
const iterator = buffers(...args)
const next = iterator.next.bind(iterator)
iterator.next = async (...args) => {
const value = await next(...args)
if (first && !value.done) {
first = false
window.mbHeldBuffer = value.value.buffer
return new Promise((resolve) => {
window.mbReleaseBuffer = () => resolve(value)
})
}
return value
}
return iterator
}
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => typeof window.mbReleaseBuffer)).toBe('function')
await page.evaluate((scenario) => {
if (scenario === 'buffer-pause')
window.mbCanvas.pause()
else window.mbCanvas.playbackRate = 2
window.mbReleaseBuffer()
}, scenario)
if (scenario === 'buffer-rate')
await expect.poll(() => page.evaluate(() => window.mbNodes.length)).toBeGreaterThan(0)
result = await page.evaluate(async () => {
await window.mbFlush()
const audio = window.mbCanvas.engine.audio
return { obsoleteScheduled: window.mbNodes.some(record => record.node.buffer === window.mbHeldBuffer), rates: window.mbNodes.flatMap(record => record.starts.map(start => start.rate)), queued: audio.queuedNodes.size, paused: audio.paused }
})
expect(result.obsoleteScheduled).toBe(false)
if (scenario === 'buffer-pause')
expect(result).toMatchObject({ queued: 0, paused: true })
else expect(result.rates.every(rate => rate === 2)).toBe(true)
}
else {
if (scenario === 'av-clock') {
await page.evaluate(async () => {
const video = window.mbCanvas.engine.video
const times = new WeakMap()
const prototype = Object.getPrototypeOf(video.videoSink)
const canvases = prototype.canvases
prototype.canvases = function (...args) {
const iterator = canvases.apply(this, args)
const next = iterator.next.bind(iterator)
iterator.next = async (...values) => {
const result = await next(...values)
if (!result.done)
times.set(result.value.canvas, result.value.timestamp)
return result
}
return iterator
}
const draw = video.ctx.drawImage.bind(video.ctx)
video.ctx.drawImage = (...args) => {
if (times.has(args[0]))
window.mbClockSamples.push({ frame: times.get(args[0]), audio: window.mbCanvas.engine.audio.currentTime, rate: window.mbCanvas.playbackRate, paused: window.mbCanvas.paused })
return draw(...args)
}
await window.mbCanvas.engine.seek(1)
})
}
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(scenario === 'av-clock' ? 1.3 : 0.3)
if (scenario !== 'no-track')
await expect.poll(() => page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBeGreaterThan(0)
const pause = await page.evaluate(async () => {
window.mbCanvas.pause()
const time = window.mbCanvas.currentTime
await window.mbFlush()
return { time, after: window.mbCanvas.currentTime, queued: window.mbCanvas.engine.audio.queuedNodes.size, retained: window.mbNodes.filter(record => !record.disconnects || record.node.onended !== null).length }
})
expect(pause.after).toBe(pause.time)
expect(pause).toMatchObject({ queued: 0, retained: 0 })
await page.evaluate(() => {
window.mbCanvas.volume = 0.5
window.mbCanvas.muted = false
})
expect(await page.evaluate(() => window.mbCanvas.engine.audio.gainNode.gain.value)).toBeCloseTo(0.25, 6)
await page.evaluate(() => {
window.mbCanvas.muted = true
window.mbCanvas.playbackRate = 2
})
expect(await page.evaluate(() => window.mbCanvas.engine.audio.gainNode.gain.value)).toBe(0)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(pause.time + 0.35)
result = await page.evaluate(() => {
const audio = window.mbCanvas.engine.audio
const clock = audio.currentTime
const elapsed = audio.audioContext.currentTime - audio.audioContextStartTime
const nodes = [...audio.queuedNodes]
return { clock, rate: audio.playbackRate, anchor: audio.playbackTimeAtStart, elapsed, nodeRates: nodes.map(node => node.playbackRate.value), hasSink: Boolean(audio.audioSink), samples: window.mbClockSamples, pause: window.mbCanvas.paused }
})
expect(Math.abs(result.clock - (result.anchor + result.elapsed * 2))).toBeLessThan(0.03)
expect(result).toMatchObject({ rate: 2, pause: false, hasSink: scenario !== 'no-track' })
if (scenario === 'no-track') {
expect(result.nodeRates).toEqual([])
}
else {
expect(result.nodeRates.length).toBeGreaterThan(0)
expect(result.nodeRates.every(rate => rate === 2)).toBe(true)
}
if (scenario === 'av-clock') {
const playing = result.samples.filter(sample => !sample.paused)
expect(playing.filter(sample => sample.rate === 1).length).toBeGreaterThan(2)
expect(playing.filter(sample => sample.rate === 2).length).toBeGreaterThan(2)
expect(Math.max(...playing.map(sample => Math.abs(sample.frame - sample.audio)))).toBeLessThan(0.25)
}
}
outcome = 'native-audio-ownership-observed'
}
finally {
await page.evaluate(() => window.mbHost.emit('destroy'))
if (capabilities.AudioContext !== 'undefined' || capabilities.webkitAudioContext !== 'undefined') {
await expect.poll(() => page.evaluate(() => window.mbContext.state)).toBe('closed')
expect(await page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBe(0)
}
const state = await page.evaluate(() => ({ context: window.mbContext?.state, nodes: window.mbNodes.map(record => ({ stops: record.stops, disconnects: record.disconnects, starts: record.starts })), error: window.mbCanvas.error, events: window.mbEvents }))
await testInfo.attach('mediabunny-audio', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), scenario, capabilities, outcome, result, state, scope: 'Native AudioContext, BufferSourceNodes and SDK decode. Delayed resume/buffer delivery is controlled. AV samples compare decoded canvas timestamps to the audio clock over short 1x/2x playback; acoustic output, long-run drift and physical devices remain MB-09.' }) })
}
})
}
+642
View File
@@ -0,0 +1,642 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Actual proxy engine with controlled Web Audio and SDK pump interleavings.
import test from 'node:test'
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
const implementation = await mbCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
const item = value => ({ value, done: !value })
const buffer = (timestamp = 0, duration = 0.1) => ({ timestamp, buffer: { duration } })
function iterator(values = []) {
let reads = 0
let returns = 0
return {
async next() {
reads++
return item(values.shift())
},
async return() {
returns++
return item()
},
get reads() { return reads },
get returns() { return returns },
}
}
function environment(options = {}) {
const env = mbEnvironment(implementation, options)
const contexts = []
const nodes = []
const warnings = []
env.globals.console = { ...console, error() {}, warn: (...args) => warnings.push(args) }
env.eventNames.push('canplay', 'playing', 'ended')
env.globals.window.AudioContext = class {
constructor(options) {
this.options = options
this.currentTime = 10
this.state = 'running'
this.closed = 0
this.resumes = 0
this.destination = {}
contexts.push(this)
}
createGain() {
this.gain = {
gain: { value: 0 },
connect() {},
disconnect() { this.disconnected = true },
}
return this.gain
}
createBufferSource() {
const node = {
playbackRate: { value: 1 },
starts: [],
stops: 0,
disconnects: 0,
onended: null,
connect() {},
start(...args) { this.starts.push(args) },
stop() { this.stops++ },
disconnect() { this.disconnects++ },
}
nodes.push(node)
return node
}
async resume() {
this.resumes++
this.state = 'running'
}
async close() {
this.closed++
this.state = 'closed'
}
}
const canvas = env.factory()(env.art)
return { ...env, canvas, audio: canvas.engine.audio, contexts, nodes, warnings }
}
function withContext() {
const env = environment()
env.audio.ensureAudioContext()
return { ...env, context: env.contexts[0] }
}
test('MediaBunny stale audio pump cannot cancel the current pending read', async () => {
const env = withContext()
const read = deferred()
let returns = 0
const frames = { next: () => read.promise, return: async () => {
returns++
return item()
} }
env.audio.paused = false
env.audio.audioSink = { buffers: () => frames }
const pumping = env.audio.runIterator(env.audio.asyncId)
const obsolete = env.audio.runIterator(env.audio.asyncId - 1)
try {
await flush()
assert.equal(returns, 0)
assert.equal(env.audio.audioIterator, frames)
}
finally {
env.audio.pause()
read.resolve(item())
await Promise.all([pumping, obsolete])
env.art.emit('destroy')
}
})
test('MediaBunny failed audio iterator construction remains retryable', async () => {
const env = withContext()
const failure = new Error('sink failed')
env.audio.paused = false
env.audio.audioSink = { buffers() {
throw failure
} }
await assert.rejects(env.audio.runIterator(env.audio.asyncId), error => error === failure)
assert.equal(env.audio.audioIterator, null)
env.audio.audioSink = { buffers: () => iterator() }
await env.audio.runIterator(env.audio.asyncId)
env.art.emit('destroy')
})
test('MediaBunny source cancellation clears sink and input after node cleanup failure', async () => {
const env = withContext()
env.audio.paused = false
env.audio.input = {}
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
await env.audio.runIterator(env.audio.asyncId)
const failure = new Error('stop failed')
env.nodes[0].stop = () => {
throw failure
}
assert.throws(() => env.audio.cancelPending(), error => error === failure)
assert.equal(env.audio.input, null)
assert.equal(env.audio.audioSink, null)
assert.equal(env.audio.queuedNodes.size, 0)
assert.equal(env.nodes[0].disconnects, 1)
env.art.emit('destroy')
})
test('MediaBunny gain setup failure releases context without installing partial references', async () => {
const env = environment()
const Context = env.globals.window.AudioContext
const failure = new Error('gain failed')
env.globals.window.AudioContext = class extends Context {
createGain() {
const gain = super.createGain()
Object.defineProperty(gain.gain, 'value', {
get() { return 0 },
set() { throw failure },
})
return gain
}
}
assert.throws(() => env.audio.ensureAudioContext(), error => error === failure)
assert.equal(env.contexts[0].closed, 1)
assert.equal(env.contexts[0].gain.disconnected, true)
assert.equal(env.audio.audioContext, null)
assert.equal(env.audio.gainNode, null)
env.globals.window.AudioContext = Context
env.audio.ensureAudioContext()
assert.equal(env.audio.audioContext, env.contexts[1])
env.art.emit('destroy')
})
test('MediaBunny context setup cannot resurrect audio after synchronous destroy', () => {
const env = environment()
const Context = env.globals.window.AudioContext
env.globals.window.AudioContext = class extends Context {
createGain() {
const gain = super.createGain()
gain.connect = () => env.audio.destroy()
return gain
}
}
env.audio.ensureAudioContext()
assert.equal(env.audio.audioContext, null)
assert.equal(env.audio.gainNode, null)
assert.equal(env.contexts[0].closed, 1)
assert.equal(env.contexts[0].gain.disconnected, true)
env.art.emit('destroy')
})
test('MediaBunny ended node disconnect failure is observed after removing retained references', async () => {
const env = withContext()
env.audio.paused = false
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
await env.audio.runIterator(env.audio.asyncId)
const failure = new Error('disconnect failed')
env.nodes[0].disconnect = () => {
throw failure
}
env.nodes[0].onended()
assert.equal(env.audio.queuedNodes.size, 0)
assert.equal(env.nodes[0].onended, null)
assert.equal(env.warnings[0][1], failure)
env.art.emit('destroy')
})
test('MediaBunny audio preserves clock, gain square law, mute and paused position', async () => {
const env = withContext()
env.audio.setVolume(0.5, false)
assert.equal(env.context.gain.gain.value, 0.25)
env.audio.setVolume(0.5, true)
assert.equal(env.context.gain.gain.value, 0)
await env.audio.seek(3)
await env.audio.play()
env.context.currentTime = 12
assert.equal(env.audio.currentTime, 5)
env.audio.pause()
env.context.currentTime = 20
assert.equal(env.audio.currentTime, 5)
env.art.emit('destroy')
})
test('MediaBunny audio preserves current media time during a playing rate change', async () => {
const env = withContext()
await env.audio.seek(3)
await env.audio.play()
env.context.currentTime = 12
env.audio.setPlaybackRate(2)
assert.equal(env.audio.currentTime, 5)
env.context.currentTime = 13
assert.equal(env.audio.currentTime, 7)
env.art.emit('destroy')
})
test('MediaBunny audio preserves future and partial late buffer scheduling math', async () => {
const env = withContext()
env.audio.paused = false
env.audio.audioContextStartTime = 10
env.audio.playbackTimeAtStart = 2
env.audio.playbackRate = 2
env.audio.audioSink = { buffers: () => iterator([buffer(1.8, 0.4), buffer(2.4, 0.4)]) }
await env.audio.runIterator(env.audio.asyncId)
assert.deepEqual(env.nodes.map(node => node.starts[0]), [[10, 0.1999999999999993], [10.2]])
assert.deepEqual(env.nodes.map(node => node.playbackRate.value), [2, 2])
assert(Math.abs(env.audio.latestScheduledEndTime - 2.8) < 0.000001)
env.art.emit('destroy')
})
test('MediaBunny audio detaches iterator synchronously and releases it once', async () => {
const env = environment()
const release = deferred()
let returns = 0
env.audio.audioIterator = { return: () => {
returns++
return release.promise
} }
const first = env.audio.stopIterator()
const detached = env.audio.audioIterator
const second = env.audio.stopIterator()
release.resolve(item())
await Promise.all([first, second])
assert.equal(detached, null)
assert.equal(returns, 1)
env.art.emit('destroy')
})
test('MediaBunny old audio iterator disposal cannot erase a current iterator', async () => {
const env = environment()
const release = deferred()
env.audio.audioIterator = { return: () => release.promise }
const closing = env.audio.stopIterator()
const current = iterator()
env.audio.audioIterator = current
release.resolve(item())
await closing
assert.equal(env.audio.audioIterator, current)
env.art.emit('destroy')
})
for (const ending of ['pause', 'destroy', 'source']) {
test(`MediaBunny pending audio resume cannot restart after ${ending}`, async () => {
const env = withContext()
env.context.state = 'suspended'
const resume = deferred()
env.context.resume = () => resume.promise
const playing = env.audio.play()
if (ending === 'pause') {
env.audio.pause()
}
else if (ending === 'destroy') {
env.art.emit('destroy')
}
else {
env.canvas.engine.performLoad = async () => {}
await env.canvas.engine.load('next.mp4')
}
resume.resolve()
await playing
assert.equal(env.audio.paused, true)
assert.equal(env.nodes.length, 0)
env.art.emit('destroy')
})
}
for (const ending of ['destroy', 'source']) {
test(`MediaBunny stale audio capability cannot recreate context or metadata after ${ending}`, async () => {
const env = environment()
const capability = deferred()
let metadata = 0
const loading = env.audio.load({ input: {}, audioTrack: { codec: 'aac', canDecode: () => capability.promise }, duration: 8 }, () => metadata++)
await flush()
if (ending === 'destroy') {
env.art.emit('destroy')
}
else {
env.canvas.engine.performLoad = async () => {}
await env.canvas.engine.load('next.mp4')
}
capability.resolve(false)
await loading
assert.equal(metadata, 0)
assert.equal(env.contexts.length, 0)
env.art.emit('destroy')
})
}
test('MediaBunny audio nodes disconnect and clear ended listeners after pause', async () => {
const env = withContext()
env.audio.paused = false
env.audio.audioSink = { buffers: () => iterator([buffer(11), buffer(12)]) }
await env.audio.runIterator(env.audio.asyncId)
assert.equal(env.nodes.length, 2)
env.audio.pause()
assert.deepEqual(env.nodes.map(node => [node.stops, node.disconnects, node.onended]), [[1, 1, null], [1, 1, null]])
assert.equal(env.audio.queuedNodes.size, 0)
env.art.emit('destroy')
})
test('MediaBunny naturally ended audio node disconnects and releases its callback', async () => {
const env = withContext()
env.audio.paused = false
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
await env.audio.runIterator(env.audio.asyncId)
const node = env.nodes[0]
node.onended()
assert.equal(node.disconnects, 1)
assert.equal(node.onended, null)
assert.equal(env.audio.queuedNodes.size, 0)
env.art.emit('destroy')
})
test('MediaBunny audio cleanup attempts all nodes when one stop throws', async () => {
const env = withContext()
env.audio.paused = false
env.audio.audioSink = { buffers: () => iterator([buffer(11), buffer(12)]) }
await env.audio.runIterator(env.audio.asyncId)
const failure = new Error('stop failed')
env.nodes[0].stop = () => {
throw failure
}
assert.throws(() => env.audio.stopQueuedNodes(), error => error === failure)
assert.equal(env.nodes[1].stops, 1)
assert.equal(env.nodes[0].disconnects, 1)
assert.equal(env.audio.queuedNodes.size, 0)
env.art.emit('destroy')
})
for (const ending of ['pause', 'destroy', 'rate']) {
test(`MediaBunny audio pump cannot schedule a batch after pending resume and ${ending}`, async () => {
const env = withContext()
env.context.state = 'suspended'
env.audio.paused = false
env.audio.audioContextStartTime = 10
const resume = deferred()
env.context.resume = () => resume.promise
env.audio.audioSink = { buffers: () => iterator([buffer(1)]) }
const pumping = env.audio.runIterator(env.audio.asyncId)
await flush()
if (ending === 'pause') {
env.audio.pause()
}
else if (ending === 'destroy') {
env.art.emit('destroy')
}
else {
env.audio.audioSink = null
env.audio.setPlaybackRate(2)
}
env.emitted.length = 0
resume.resolve()
await pumping
assert.equal(env.nodes.length, 0)
assert.equal(env.emitted.some(event => ['video:canplay', 'video:playing'].includes(event.name)), false)
env.art.emit('destroy')
})
}
test('MediaBunny rate change cancels already scheduled old-rate audio nodes', async () => {
const env = withContext()
env.audio.paused = false
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
await env.audio.runIterator(env.audio.asyncId)
const old = env.nodes[0]
env.audio.audioSink = null
env.audio.setPlaybackRate(2)
assert.equal(old.stops, 1)
assert.equal(old.disconnects, 1)
assert.equal(env.audio.queuedNodes.size, 0)
env.art.emit('destroy')
})
test('MediaBunny seeking before first context exists preserves paused position without throwing', async () => {
const env = environment()
await env.audio.seek(3)
assert.equal(env.audio.currentTime, 3)
assert.equal(env.audio.paused, true)
assert.equal(env.contexts.length, 0)
env.art.emit('destroy')
})
test('MediaBunny video ended stops the shared audio clock and scheduled nodes', async () => {
const env = withContext()
await env.audio.play()
env.canvas.engine.paused = false
env.context.currentTime = 12
env.canvas.events.emit('ended')
const stoppedAt = env.audio.currentTime
env.context.currentTime = 14
assert.equal(env.audio.paused, true)
assert.equal(env.audio.currentTime, stoppedAt)
env.art.emit('destroy')
})
test('MediaBunny audio destruction disconnects gain and observes context close', async () => {
const env = withContext()
const gain = env.context.gain
env.art.emit('destroy')
await flush()
assert.equal(gain.disconnected, true)
assert.equal(env.audio.gainNode, null)
assert.equal(env.context.closed, 1)
assert.equal(env.audio.audioContext, null)
env.art.emit('destroy')
assert.equal(env.context.closed, 1)
})
test('MediaBunny actual audio owner shares native resume across coordinator play-pause-play', async () => {
const env = withContext()
const pending = deferred()
let resumes = 0
env.context.state = 'suspended'
env.context.resume = () => {
resumes++
return pending.promise
}
let starts = 0
env.canvas.engine.video.start = () => starts++
const first = env.canvas.play()
env.canvas.pause()
const second = env.canvas.play()
pending.resolve()
await Promise.all([first, second])
assert.equal(resumes, 1)
assert.equal(starts, 1)
assert.equal(env.audio.paused, false)
assert.equal(env.canvas.paused, false)
env.art.emit('destroy')
})
test('MediaBunny canceled audio play settles without waiting for native resume and observes its rejection', async () => {
const env = withContext()
const pending = deferred()
env.context.state = 'suspended'
env.context.resume = () => pending.promise
let settled = false
const playing = env.audio.play().then(() => settled = true)
env.audio.pause()
await flush()
assert.equal(settled, true)
pending.reject(new Error('obsolete resume rejection'))
await playing
await flush()
assert.equal(env.audio.paused, true)
env.art.emit('destroy')
})
test('MediaBunny active resume rejection preserves the error and allows another play', async () => {
const env = withContext()
const failure = new Error('resume denied')
env.context.state = 'suspended'
env.context.resume = async () => {
throw failure
}
await assert.rejects(env.audio.play(), error => error === failure)
assert.equal(env.audio.paused, true)
env.context.resume = async () => {}
await env.audio.play()
assert.equal(env.audio.paused, false)
env.art.emit('destroy')
})
test('MediaBunny audio preserves no-track metadata and fallback context construction', async () => {
const env = environment()
const Context = env.globals.window.AudioContext
const attempts = []
env.globals.window.AudioContext = class extends Context {
constructor(option) {
attempts.push(option)
if (option)
throw new Error('sample rate not supported')
super()
}
}
env.audio.ensureAudioContext(96000)
assert.deepEqual(attempts.map(option => option?.sampleRate), [96000, undefined])
let metadata = 0
await env.audio.load({ input: {}, audioTrack: null, duration: 8 }, () => metadata++)
assert.equal(metadata, 1)
assert.equal(env.audio.duration, 8)
assert.equal(env.contexts.length, 1)
env.art.emit('destroy')
})
test('MediaBunny expired buffers do not create silent retained source nodes', async () => {
const env = withContext()
env.audio.paused = false
env.audio.audioContextStartTime = 0
env.audio.audioSink = { buffers: () => iterator([buffer(1, 0.1)]) }
await env.audio.runIterator(env.audio.asyncId)
assert.equal(env.nodes.length, 0)
env.art.emit('destroy')
})
test('MediaBunny audio seek stops old scheduled nodes even when called directly while playing', async () => {
const env = withContext()
env.audio.paused = false
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
await env.audio.runIterator(env.audio.asyncId)
const old = env.nodes[0]
env.audio.audioSink = null
await env.audio.seek(3)
assert.equal(old.stops, 1)
assert.equal(old.disconnects, 1)
assert.equal(env.audio.currentTime, 3)
env.art.emit('destroy')
})
test('MediaBunny background audio decode error stops playback and reports one media error', async () => {
const env = withContext()
const failure = new Error('decoder failed')
env.audio.audioSink = { buffers: () => ({ next: async () => {
throw failure
}, return: async () => item() }) }
await env.canvas.play()
await flush()
assert.deepEqual({ ...env.canvas.error }, { code: 4, message: 'decoder failed' })
assert.equal(env.canvas.paused, true)
assert.equal(env.audio.paused, true)
assert.equal(env.emitted.filter(event => event.name === 'video:error').length, 1)
env.art.emit('destroy')
})
test('MediaBunny decoder error is not replaced by iterator release failure', async () => {
const env = withContext()
env.audio.paused = false
const failure = new Error('decoder failed')
env.audio.audioSink = { buffers: () => ({ next: async () => {
throw failure
}, return: async () => {
throw new Error('cleanup failed')
} }) }
await assert.rejects(env.audio.runIterator(env.audio.asyncId), error => error === failure)
await flush()
assert.equal(env.warnings.length, 1)
env.art.emit('destroy')
})
test('MediaBunny destroy attempts context cleanup after a node stop error and preserves the first failure', async () => {
const env = withContext()
env.audio.paused = false
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
await env.audio.runIterator(env.audio.asyncId)
const failure = new Error('node failed')
env.nodes[0].stop = () => {
throw failure
}
assert.throws(() => env.audio.destroy(), error => error === failure)
assert.equal(env.context.closed, 1)
assert.equal(env.context.gain.disconnected, true)
assert.equal(env.audio.audioContext, null)
assert.equal(env.audio.audioIterator, null)
assert.equal(env.audio.queuedNodes.size, 0)
env.art.emit('destroy')
})
test('MediaBunny destroyed audio context close rejection remains observed', async () => {
const env = withContext()
const failure = new Error('close failed')
env.context.close = async () => {
throw failure
}
env.art.emit('destroy')
await flush()
assert.equal(env.warnings.length, 1)
assert.equal(env.warnings[0][1], failure)
assert.equal(env.audio.audioContext, null)
})
test('MediaBunny paused audio pump clears owned batch and backpressure timers', async () => {
const timers = new Map()
let next = 0
const env = environment({ setTimeout(callback, delay) {
const id = next++
timers.set(id, { callback, delay })
return id
}, clearTimeout(id) { timers.delete(id) } })
env.audio.ensureAudioContext()
env.audio.paused = false
env.audio.audioContextStartTime = 10
const frames = iterator(Array.from({ length: 32 }, (_, index) => buffer(index / 10)))
env.audio.audioSink = { buffers: () => frames }
const pumping = env.audio.runIterator(env.audio.asyncId)
await flush()
assert.equal(frames.reads, 16)
assert.deepEqual([...timers.values()].map(timer => timer.delay), [0])
const [id, timer] = [...timers][0]
timers.delete(id)
timer.callback()
await flush()
assert.deepEqual([...timers.values()].map(timer => timer.delay), [50])
env.audio.pause()
assert.equal(timers.size, 0)
await pumping
assert.equal(frames.returns, 1)
assert.equal(env.nodes.every(node => node.disconnects === 1), true)
env.art.emit('destroy')
})
+2 -2
View File
@@ -94,7 +94,7 @@ test('MediaBunny coordinator restores paused state after a play rejection and al
env.art.emit('destroy')
})
test('MediaBunny coordinator shares delayed audio resume across play-pause-play and starts video once', async () => {
test('MediaBunny coordinator delegates each new play intent to the audio owner and starts video once', async () => {
const env = environment()
const audio = deferred()
let attempts = 0
@@ -107,7 +107,7 @@ test('MediaBunny coordinator shares delayed audio resume across play-pause-play
const second = env.engine.play()
audio.resolve()
await Promise.all([first, second])
assert.equal(attempts, 1)
assert.equal(attempts, 2)
assert.equal(env.engine.paused, false)
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
assert.equal(env.calls.filter(name => name === 'play').length, 1)