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

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

No files matched your search

+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=e=>{throw TypeError(e)},r=(e,t,r)=>t.has(e)||i("Cannot "+r),a=(e,t,i)=>(r(e,t,"read from private field"),i?i.call(e):t.get(e)),s=(e,t,r)=>t.has(e)?i("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,r),n=(e,t,i,a)=>(r(e,t,"write to private field"),a?a.call(e,i):t.set(e,i),i);function o(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class c{constructor(){s(this,e,!1),this.listeners=new Map}addEventListener(t,i){a(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(a(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{a(this,e)||t(r)})}destroy(){n(this,e,!0),this.listeners.clear()}}function l(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const u=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},d=e=>e&&e[e.length-1],h=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},f=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},m=e=>{const t=h(e);return 0===(1&t)?-(t>>1):t+1>>1},p=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),g=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),k=new TextDecoder,y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),b={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},w=y(b),T={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},S=y(T),v={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},P=y(v),I=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,x={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},C=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class B{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const E=/^[0-9a-fA-F]+$/,_=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),R=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,F=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},A=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},D=(e,t,i)=>{const r=A(e,i(t),i);e.splice(r+1,0,t)},M=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},N=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},O=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},L=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},z=e=>{throw new Error(`Unexpected value: ${e}`)},U=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},q=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},W=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),H=(e,t,i)=>Math.max(t,Math.min(i,e)),V="und",j=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},K=(e,t)=>Math.round(e/t)*t,X=(e,t)=>Math.round(e*t)/t,$=(e,t)=>Math.floor(e/t)*t,G=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Q=/^[a-z]{3}$/,Y=e=>Q.test(e),J=1e6*(1+Number.EPSILON),Z=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?ee(e.headers):{},a=t.headers?ee(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},ee=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catch(n){if(a())throw n;s++;const e=r(s,n,t);if(null===e)throw n;if(xe._error("Retrying failed fetch. Error:",n),!Number.isFinite(e)||e<0)throw new TypeError("Retry delayLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,a,s,n,o,c,l,u,d,h,f,m,p,g,k,y,b,w=e=>{throw TypeError(e)},T=(e,t,i)=>t.has(e)||w("Cannot "+i),S=(e,t,i)=>(T(e,t,"read from private field"),i?i.call(e):t.get(e)),v=(e,t,i)=>t.has(e)?w("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),P=(e,t,i,r)=>(T(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),I=(e,t,i)=>(T(e,t,"access private method"),i),x=(e,t,i,r)=>({set _(r){P(e,t,r,i)},get _(){return S(e,t,r)}});function C(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class B{constructor(){v(this,e,!1),this.listeners=new Map}addEventListener(t,i){S(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(S(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{S(this,e)||t(r)})}destroy(){P(this,e,!0),this.listeners.clear()}}function E(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const _=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},R=e=>e&&e[e.length-1],F=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},A=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},D=e=>{const t=F(e);return 0===(1&t)?-(t>>1):t+1>>1},M=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),N=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),O=new TextDecoder,z=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),L={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},U=z(L),q={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},W=z(q),H={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},V=z(H),j=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,K={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},X=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ${constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const G=/^[0-9a-fA-F]+$/,Q=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),Y=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,J=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},Z=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},ee=(e,t,i)=>{const r=Z(e,i(t),i);e.splice(r+1,0,t)},te=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},ie=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},re=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ae=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},se=e=>{throw new Error(`Unexpected value: ${e}`)},ne=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},oe=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},ce=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),le=(e,t,i)=>Math.max(t,Math.min(i,e)),ue="und",de=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},he=(e,t)=>Math.round(e/t)*t,fe=(e,t)=>Math.round(e*t)/t,me=(e,t)=>Math.floor(e/t)*t,pe=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},ge=/^[a-z]{3}$/,ke=e=>ge.test(e),ye=1e6*(1+Number.EPSILON),be=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?we(e.headers):{},a=t.headers?we(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},we=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},Te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catcLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,s=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,l=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),c=e=>{throw TypeError(e)},u=Math.pow,d=(e,t,r)=>t in e?i(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,h=(e,t)=>{for(var i in t||(t={}))a.call(t,i)&&d(e,i,t[i]);if(s)for(var i of s(t))o.call(t,i)&&d(e,i,t[i]);return e},f=(e,t)=>r(e,n(t)),m=(e,t,i)=>t.has(e)||c("Cannot "+i),p=(e,t,i)=>(m(e,t,"read from private field"),i?i.call(e):t.get(e)),g=(e,t,i)=>t.has(e)?c("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),k=(e,t,i,r)=>(m(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),y=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),b=function(e,t){this[0]=e,this[1]=t},T=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof b,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[l("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},v=e=>{var t,i=e[l("asyncIterator")],r=!1,n={};return null==i?(i=e[l("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new b(new Promise(r=>{var n=i[e](t);n instanceof Object||c("Object expected"),r(n)}),1)}}),n[l("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},S=(e,t,i)=>(t=e[l("asyncIterator")])?t.call(e):(e=e[l("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function w(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class P{constructor(){g(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;p(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(p(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{p(this,e)||t(n)})}destroy(){k(this,e,!0),this.listeners.clear()}}function I(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const x=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},C=e=>e&&e[e.length-1],B=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},E=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-u(2,r))},_=e=>{const t=B(e);return 0===(1&t)?-(t>>1):t+1>>1},R=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),F=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),A=new TextDecoder,D=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),M={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},N=D(M),O={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},L=D(O),z={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},U=D(z),q=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,W={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},H=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class V{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return y(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const j=/^[0-9a-fA-F]+$/,K=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),X=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,$=(e,t,i)=>{let r=0,n=e.length-1,s=-1;for(;r<=n;){const a=r+n>>1,o=i(e[a]);o===t?(s=a,n=a-1):o<t?r=a+1:n=a-1}retuLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,n,s,a,o,l,c,u,d,h,f,m,p,g,k,y,b,T=Object.defineProperty,v=Object.defineProperties,S=Object.getOwnPropertyDescriptors,w=Object.getOwnPropertySymbols,P=Object.prototype.hasOwnProperty,I=Object.prototype.propertyIsEnumerable,x=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),C=e=>{throw TypeError(e)},B=Math.pow,E=(e,t,i)=>t in e?T(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,_=(e,t)=>{for(var i in t||(t={}))P.call(t,i)&&E(e,i,t[i]);if(w)for(var i of w(t))I.call(t,i)&&E(e,i,t[i]);return e},R=(e,t)=>v(e,S(t)),F=(e,t,i)=>t.has(e)||C("Cannot "+i),A=(e,t,i)=>(F(e,t,"read from private field"),i?i.call(e):t.get(e)),D=(e,t,i)=>t.has(e)?C("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),M=(e,t,i,r)=>(F(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),N=(e,t,i)=>(F(e,t,"access private method"),i),O=(e,t,i,r)=>({set _(r){M(e,t,r,i)},get _(){return A(e,t,r)}}),z=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),L=function(e,t){this[0]=e,this[1]=t},U=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof L,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[x("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},q=e=>{var t,i=e[x("asyncIterator")],r=!1,n={};return null==i?(i=e[x("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new L(new Promise(r=>{var n=i[e](t);n instanceof Object||C("Object expected"),r(n)}),1)}}),n[x("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},W=(e,t,i)=>(t=e[x("asyncIterator")])?t.call(e):(e=e[x("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function H(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class V{constructor(){D(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;A(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(A(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{A(this,e)||t(n)})}destroy(){M(this,e,!0),this.listeners.clear()}}function j(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const K=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},X=e=>e&&e[e.length-1],$=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},G=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-B(2,r))},Q=e=>{const t=$(e);return 0===(1&t)?-(t>>1):t+1>>1},Y=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),J=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),Z=new TextDecoder,ee=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),te={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},ie=ee(te),re={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ne=ee(re),se={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},ae=ee(se),oe=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,le={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ce=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ue{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return z(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const de=/^[0-9a-fA-F]+$/,he=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),fe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&167Line truncated
+450 -148
View File
@@ -11,7 +11,16 @@ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
var _closed, _playback;
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
var __privateWrapper = (obj, member, setter, getter) => ({
set _(value) {
__privateSet(obj, member, value, setter);
},
get _() {
return __privateGet(obj, member, getter);
}
});
var _closed, _generation, _iteration, _loading, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn, reset_fn, _playback;
const $audio = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>';
const $quality = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>';
function uniqBy(array, property) {
@@ -24741,17 +24750,240 @@ async function preflightRange(url, enabled, events, signal, isCurrent = () => tr
return true;
}
}
const closing = /* @__PURE__ */ new WeakMap();
const reading = /* @__PURE__ */ new WeakMap();
function releaseIterator(iterator) {
if (!iterator)
return Promise.resolve();
const previous = closing.get(iterator);
if (previous)
return previous;
const pending = Promise.resolve().then(() => iterator.return()).then(() => {
});
closing.set(iterator, pending);
return pending;
}
function readCanvas(iterator) {
const previous = reading.get(iterator);
if (previous)
return previous;
const pending = Promise.resolve().then(() => iterator.next()).then((result) => result.value ?? null);
reading.set(iterator, pending);
const clear = () => {
if (reading.get(iterator) === pending)
reading.delete(iterator);
};
pending.then(clear, clear);
return pending;
}
function frameAction(timestamp, time, dropLateFrames, avSyncTolerance, playbackRate) {
const tolerance = dropLateFrames ? Math.max(0.06, avSyncTolerance / Math.max(1, playbackRate)) : 0;
if (dropLateFrames && timestamp < time - tolerance)
return "skip";
return timestamp <= time + tolerance ? "draw" : "queue";
}
class Poster {
constructor() {
this.image = null;
}
cancel() {
if (!this.image)
return;
this.image.onload = null;
this.image.onerror = null;
this.image = null;
}
draw(source, current, draw, failure) {
this.cancel();
const image = new Image();
this.image = image;
image.onload = () => {
if (this.image !== image || !current())
return;
this.cancel();
try {
draw(image);
} catch (error) {
failure(error);
}
};
image.onerror = () => {
if (this.image === image)
this.cancel();
};
image.src = source;
}
}
class Renderer {
constructor(host, ready, draw, failure) {
this.host = host;
this.ready = ready;
this.draw = draw;
this.failure = failure;
this.stopped = false;
this.closed = false;
this.scheduled = null;
this.fetching = null;
}
current(id) {
return !this.closed && !this.stopped && this.host.asyncId === id;
}
async update(id) {
const iterator = this.host.videoIterator;
if (!iterator || !this.ready() || !this.current(id) || this.host.isFetching)
return;
const request = {};
this.fetching = request;
this.host.isFetching = true;
const current = () => this.current(id) && this.host.videoIterator === iterator;
try {
while (current()) {
const frame = await readCanvas(iterator);
if (!current() || !frame)
return;
const time = this.host.audioClock?.currentTime;
if (time === void 0)
return;
const action = frameAction(frame.timestamp, time, this.host.dropLateFrames, this.host.avSyncTolerance, this.host.playbackRate);
if (action === "skip")
continue;
if (action === "draw") {
this.draw(frame);
} else {
this.host.nextFrame = frame;
return;
}
}
} catch (error) {
if (current())
throw error;
} finally {
if (this.fetching === request) {
this.fetching = null;
this.host.isFetching = false;
}
}
}
request(id) {
void this.update(id).catch((error) => {
if (!this.current(id))
return;
this.stop();
this.report(error);
});
}
report(error) {
try {
this.failure(error);
} catch (failure) {
console.warn("MediaBunny video error listener:", failure);
}
}
cancelFrame() {
if (this.scheduled === null)
return;
cancelAnimationFrame(this.scheduled);
this.scheduled = null;
}
schedule(id) {
if (!this.current(id))
return;
this.cancelFrame();
const frame = requestAnimationFrame(() => {
if (this.scheduled !== frame || !this.current(id))
return;
this.scheduled = null;
try {
this.render();
} catch (error) {
this.stop();
this.report(error);
}
});
this.scheduled = frame;
this.host.rafId = frame;
}
render() {
const id = this.host.asyncId;
const current = () => this.current(id);
if (!current() || !this.host.audioClock)
return;
const time = this.host.audioClock.currentTime;
const now = Date.now();
if (now - this.host.lastTimeUpdate >= this.host.timeupdateInterval) {
this.host.events.emit("timeupdate");
if (!current())
return;
this.host.lastTimeUpdate = now;
}
if (!this.ready()) {
this.schedule(id);
return;
}
if (Number.isFinite(this.host.duration) && time >= this.host.duration) {
this.stop();
this.host.stalled = false;
const ended = this.host.asyncId;
publish(this.host.events, () => !this.closed && this.host.asyncId === ended, ["ended", "pause", "canplay"]);
return;
}
if (this.host.nextFrame && this.host.nextFrame.timestamp <= time) {
this.draw(this.host.nextFrame);
this.host.nextFrame = null;
this.request(id);
if (this.host.stalled) {
publish(this.host.events, current, ["canplay", "playing"]);
if (!current())
return;
this.host.stalled = false;
}
} else if (!this.host.nextFrame) {
this.request(id);
if (Number.isFinite(this.host.duration) && time < this.host.duration && !this.host.stalled) {
this.host.stalled = true;
this.host.events.emit("waiting");
}
}
this.schedule(id);
}
start(clock) {
if (this.closed)
return;
this.stop();
this.stopped = false;
this.host.audioClock = clock;
this.host.stalled = false;
if (!this.host.nextFrame)
this.request(this.host.asyncId);
this.schedule(this.host.asyncId);
}
stop() {
this.stopped = true;
this.host.asyncId++;
this.cancelFrame();
this.fetching = null;
this.host.isFetching = false;
}
destroy() {
if (this.closed)
return;
this.stop();
this.closed = true;
}
}
class VideoEngine {
constructor({
canvas,
ctx,
events,
timeupdateInterval = 250,
avSyncTolerance = 0.12,
dropLateFrames = false,
poster = "",
preflightRange: preflightRange2 = false
}) {
constructor({ canvas, ctx, events, timeupdateInterval = 250, avSyncTolerance = 0.12, dropLateFrames = false, poster = "", preflightRange: preflightRange2 = false, onError }) {
__privateAdd(this, _VideoEngine_instances);
__privateAdd(this, _generation, 0);
__privateAdd(this, _iteration, 0);
__privateAdd(this, _loading, null);
__privateAdd(this, _createSink, null);
__privateAdd(this, _usedSink, null);
__privateAdd(this, _destroyed, false);
__privateAdd(this, _preparing, false);
__privateAdd(this, _poster, new Poster());
__privateAdd(this, _renderer);
__privateAdd(this, _onError);
this.canvas = canvas;
this.ctx = ctx;
this.events = events;
@@ -24775,33 +25007,171 @@ class VideoEngine {
this.playbackRate = 1;
this.posterDrawn = false;
this.isFetching = false;
__privateSet(this, _onError, onError);
__privateSet(this, _renderer, new Renderer(this, () => !__privateGet(this, _preparing), (frame) => __privateMethod(this, _VideoEngine_instances, draw_fn).call(this, frame, true), (error) => __privateMethod(this, _VideoEngine_instances, report_fn).call(this, error)));
}
async preflight(url, signal, isCurrent) {
return preflightRange(url, this.preflightRange, this.events, signal, isCurrent);
async preflight(source, signal, current) {
return preflightRange(source, this.preflightRange, this.events, signal, current);
}
drawPoster() {
if (!this.poster || this.posterDrawn)
if (__privateGet(this, _destroyed) || !this.poster || this.posterDrawn)
return;
const img = new Image();
img.onload = () => {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.canvas.width = img.naturalWidth || this.canvas.width;
this.canvas.height = img.naturalHeight || this.canvas.height;
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
const generation = __privateGet(this, _generation);
__privateGet(this, _poster).draw(this.poster, () => __privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation), (image) => {
this.clear();
this.canvas.width = image.naturalWidth || this.canvas.width;
this.canvas.height = image.naturalHeight || this.canvas.height;
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(image, 0, 0, this.canvas.width, this.canvas.height);
this.posterDrawn = true;
};
img.src = this.poster;
}, (error) => __privateMethod(this, _VideoEngine_instances, report_fn).call(this, error));
}
async stopIterator() {
await this.videoIterator?.return();
const iterator = this.videoIterator;
this.videoIterator = null;
this.nextFrame = null;
await releaseIterator(iterator);
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).clearRect(0, 0, this.canvas.width, this.canvas.height);
}
cancelPending() {
__privateWrapper(this, _generation)._++;
__privateWrapper(this, _iteration)._++;
this.stop();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, false);
__privateSet(this, _loading, null);
__privateSet(this, _createSink, null);
__privateSet(this, _usedSink, null);
this.videoSink = null;
this.input = null;
}
async load(media, onMetadata) {
++this.asyncId;
await this.stopIterator();
if (__privateGet(this, _destroyed))
return;
this.cancelPending();
const generation = __privateGet(this, _generation);
const iteration = __privateGet(this, _iteration);
__privateSet(this, _preparing, true);
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn).call(this, media, generation, iteration, onMetadata));
__privateSet(this, _loading, pending);
try {
await pending;
} finally {
if (__privateGet(this, _loading) === pending)
__privateSet(this, _loading, null);
}
}
handleNoVideoTrack() {
if (__privateGet(this, _destroyed))
return;
this.videoSink = null;
this.width = 0;
this.height = 0;
this.canvas.width = 0;
this.canvas.height = 0;
this.clear();
this.drawPoster();
}
async resetIterator(time) {
if (__privateGet(this, _destroyed))
return;
const generation = __privateGet(this, _generation);
const iteration = ++__privateWrapper(this, _iteration)._;
const loading = __privateGet(this, _loading);
this.stop();
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, true);
try {
if (loading)
await loading;
await __privateMethod(this, _VideoEngine_instances, reset_fn).call(this, time, generation, iteration);
} catch (error) {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration) {
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
throw error;
}
} finally {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration)
__privateSet(this, _preparing, false);
}
}
async updateNextFrame(localId) {
await __privateGet(this, _renderer).update(localId);
}
render() {
__privateGet(this, _renderer).render();
}
start(audio) {
__privateGet(this, _renderer).start(audio);
}
stop() {
__privateGet(this, _renderer).stop();
}
async seek(time) {
await this.resetIterator(time);
}
setPlaybackRate(rate) {
this.playbackRate = Math.max(0.1, Number(rate) || 1);
}
destroy() {
if (__privateGet(this, _destroyed))
return;
__privateSet(this, _destroyed, true);
__privateWrapper(this, _generation)._++;
__privateGet(this, _renderer).destroy();
__privateGet(this, _poster).cancel();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
this.posterDrawn = false;
__privateSet(this, _createSink, null);
__privateSet(this, _usedSink, null);
this.videoSink = null;
this.input = null;
this.audioClock = null;
}
}
_generation = new WeakMap();
_iteration = new WeakMap();
_loading = new WeakMap();
_createSink = new WeakMap();
_usedSink = new WeakMap();
_destroyed = new WeakMap();
_preparing = new WeakMap();
_poster = new WeakMap();
_renderer = new WeakMap();
_onError = new WeakMap();
_VideoEngine_instances = new WeakSet();
current_fn = function(generation) {
return !__privateGet(this, _destroyed) && __privateGet(this, _generation) === generation;
};
context_fn = function() {
if (!this.ctx)
throw new Error("Canvas 2D context is unavailable.");
return this.ctx;
};
report_fn = function(error) {
if (__privateGet(this, _destroyed))
return;
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
if (__privateGet(this, _onError))
__privateGet(this, _onError).call(this, error);
else
this.events.emit("error", error);
};
release_fn = function() {
void this.stopIterator().catch((error) => console.warn("MediaBunny iterator cleanup error:", error));
};
draw_fn = function(frame, clear) {
__privateGet(this, _poster).cancel();
if (clear)
this.clear();
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(frame.canvas, 0, 0);
};
load_fn = async function(media, generation, iteration, onMetadata) {
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
try {
this.clear();
this.posterDrawn = false;
const { input, videoTrack, duration } = media;
@@ -24812,139 +25182,61 @@ class VideoEngine {
onMetadata?.();
return;
}
if (videoTrack.codec === null || !await videoTrack.canDecode()) {
const decodable = videoTrack.codec !== null && await videoTrack.canDecode();
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
if (!decodable) {
this.handleNoVideoTrack();
onMetadata?.();
return;
}
const transparent = await videoTrack.canBeTransparent();
this.videoSink = new CanvasSink(videoTrack, {
poolSize: 2,
fit: "contain",
alpha: transparent
});
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
__privateSet(this, _createSink, () => new CanvasSink(videoTrack, { poolSize: 2, fit: "contain", alpha: transparent }));
this.videoSink = __privateGet(this, _createSink).call(this);
this.width = videoTrack.displayWidth;
this.height = videoTrack.displayHeight;
this.canvas.width = this.width;
this.canvas.height = this.height;
onMetadata?.();
await this.resetIterator(0);
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
await __privateMethod(this, _VideoEngine_instances, reset_fn).call(this, 0, generation, iteration);
} catch (error) {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation)) {
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
throw error;
}
} finally {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration)
__privateSet(this, _preparing, false);
}
handleNoVideoTrack() {
this.videoSink = null;
this.width = 0;
this.height = 0;
this.canvas.width = 0;
this.canvas.height = 0;
this.clear();
};
reset_fn = async function(time, generation, iteration) {
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) || __privateGet(this, _iteration) !== iteration)
return;
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
if (__privateGet(this, _createSink) && this.videoSink === __privateGet(this, _usedSink))
this.videoSink = __privateGet(this, _createSink).call(this);
const sink = this.videoSink;
if (!sink || !__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
__privateSet(this, _usedSink, sink);
const iterator = sink.canvases(time);
this.videoIterator = iterator;
const current = () => __privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration && this.videoIterator === iterator;
const first = await readCanvas(iterator);
if (!current())
return;
const second = await readCanvas(iterator);
if (!current())
return;
this.nextFrame = second;
if (first)
__privateMethod(this, _VideoEngine_instances, draw_fn).call(this, first, false);
else
this.drawPoster();
}
async resetIterator(time) {
await this.stopIterator();
if (!this.videoSink)
return;
this.videoIterator = this.videoSink.canvases(time);
const first = (await this.videoIterator.next()).value ?? null;
const second = (await this.videoIterator.next()).value ?? null;
this.nextFrame = second;
if (first) {
this.ctx.drawImage(first.canvas, 0, 0);
} else {
this.drawPoster();
}
}
async updateNextFrame(localId) {
if (!this.videoIterator || this.isFetching)
return;
this.isFetching = true;
try {
while (true) {
const frame = (await this.videoIterator.next()).value ?? null;
if (!frame || localId !== this.asyncId)
return;
const t = this.audioClock.currentTime;
const tolerance = this.dropLateFrames ? Math.max(0.06, this.avSyncTolerance / Math.max(1, this.playbackRate)) : 0;
if (this.dropLateFrames && frame.timestamp < t - tolerance) {
continue;
}
if (frame.timestamp <= t + tolerance) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(frame.canvas, 0, 0);
if (!this.dropLateFrames && frame.timestamp > t) {
this.nextFrame = null;
return;
}
} else {
this.nextFrame = frame;
return;
}
}
} finally {
this.isFetching = false;
}
}
render() {
if (!this.audioClock)
return;
const t = this.audioClock.currentTime;
const now = Date.now();
if (now - this.lastTimeUpdate >= this.timeupdateInterval) {
this.events.emit("timeupdate");
this.lastTimeUpdate = now;
}
if (Number.isFinite(this.duration) && t >= this.duration) {
this.stop();
this.stalled = false;
this.events.emit("ended");
this.events.emit("pause");
this.events.emit("canplay");
return;
}
if (this.nextFrame && this.nextFrame.timestamp <= t) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(this.nextFrame.canvas, 0, 0);
this.nextFrame = null;
this.updateNextFrame(this.asyncId);
if (this.stalled) {
this.events.emit("canplay");
this.events.emit("playing");
this.stalled = false;
}
} else if (!this.nextFrame) {
this.updateNextFrame(this.asyncId);
if (!this.nextFrame && Number.isFinite(this.duration) && t < this.duration && !this.stalled) {
this.stalled = true;
this.events.emit("waiting");
}
}
this.rafId = requestAnimationFrame(() => this.render());
}
start(audioEngine) {
this.audioClock = audioEngine;
this.asyncId++;
this.stalled = false;
this.updateNextFrame(this.asyncId);
this.rafId = requestAnimationFrame(() => this.render());
}
stop() {
cancelAnimationFrame(this.rafId);
}
async seek(time) {
this.asyncId++;
await this.resetIterator(time);
}
setPlaybackRate(rate) {
this.playbackRate = Math.max(0.1, Number(rate) || 1);
}
destroy() {
this.asyncId++;
this.stop();
this.stopIterator();
this.posterDrawn = false;
this.videoSink = null;
this.input = null;
}
}
};
class MediaBunnyEngine {
constructor({ canvas, ctx, events, option = {} }) {
__privateAdd(this, _playback);
@@ -24959,7 +25251,15 @@ class MediaBunnyEngine {
avSyncTolerance: option.avSyncTolerance ?? 0.12,
dropLateFrames: option.dropLateFrames ?? false,
poster: option.poster ?? "",
preflightRange: option.preflightRange ?? false
preflightRange: option.preflightRange ?? false,
onError: (error) => {
try {
this.pause();
} catch (failure) {
console.warn("MediaBunny video failure cleanup:", failure);
}
this.reportError(error);
}
});
this.paused = true;
this.ended = false;
@@ -24986,6 +25286,7 @@ class MediaBunnyEngine {
return;
const id = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
this.loadSession?.cancel();
if (id !== this.loadSeq || this.destroyed)
return;
@@ -25009,6 +25310,7 @@ class MediaBunnyEngine {
return;
const failed = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
session.cancel();
if (failed !== this.loadSeq || this.destroyed)
return;
+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-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-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-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-load.test.js test/mediabunny-input.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -19,18 +19,22 @@ declarations, the factory signature, canvas forwarding, or package entrypoints.
| `playback.ts` | Playback intent, cancellable play/seek/replacement operations and shared pending audio resume |
| `readiness.ts` | One metadata publication after both participants, guarded event sequences |
| `hls-selection.ts` | Source-bound track queries, pairing and active replacement errors |
| `VideoEngine.js` / `AudioEngine.js` | Current native decoding, rendering, seeking, Web Audio clock and scheduling |
| `VideoEngine.ts` | Video capability/setup, source and seek ownership, first frames and terminal cleanup |
| `video-frames.ts` | Shared pending iterator reads, idempotent iterator release and existing frame timing policy |
| `video-renderer.ts` | Owned RAF, stale frame rejection, clock-driven draw/stall/end events |
| `video-poster.ts` | Per-request image callbacks, source guards and handler cleanup |
| `AudioEngine.js` | Remaining Web Audio clock, audio decoding and scheduling implementation |
| `VideoShim.ts` / `EventTarget.ts` | Video-like descriptors, synchronous/Promise forwarding and owned event lifecycle |
| `shim-values.ts` | Existing volume coercion and synthetic TimeRanges values |
| `shim-frames.ts` | Synthetic frame callbacks, per-instance RAF ownership and terminal cleanup |
| `engine-types.ts` / `engine-ports.ts` | Internal shim, coordinator and decoder interfaces |
| `AudioEngine.d.ts` / `VideoEngine.d.ts` | Temporary boundaries for JS decoders, removed in MB-06/05 |
| `AudioEngine.d.ts` | Temporary boundary for the remaining JS audio engine, removed in MB-06 |
| `m3u8.js` | Current ArtPlayer control/setting integration |
The sixteen production TypeScript modules use strict checking with `skipLibCheck: false`
and no ambient Node types. The coordinator is an actual checked implementation. The two
adjacent decoder declarations are temporary boundaries, not checking of their JavaScript
implementations. Remaining JavaScript is scheduled in PKG-MB-05/06/07/08.
The twenty production TypeScript modules use strict checking with `skipLibCheck: false`
and no ambient Node types. The coordinator and video engine are checked implementations.
The adjacent audio declaration is a temporary boundary, not checking of its JavaScript
implementation. Remaining JavaScript is scheduled in PKG-MB-06/07/08.
Do not describe the whole package as migrated yet.
The shim keeps its existing own-property order and direct prototype surface because
@@ -82,7 +86,7 @@ for the seek completion signal, resolved before optional resume to avoid a cycle
A pending AudioContext resume is shared across play-pause-play; only the current operation
starts video and emits play/playing. Source change and destroy invalidate both operations.
Cancellation settles the public operation while observing the underlying late result;
actual late decoder work still belongs to MB-05/06.
video frame ownership is detailed below; remaining audio work belongs to MB-06.
Normal play, pause, seek/resume and HLS replacement event order is retained. Before each
event the coordinator rechecks ownership, since a listener may synchronously pause, seek,
@@ -99,6 +103,43 @@ This corrects the old false-ready state; normal valid input event order is prese
Existing-track unsupported-codec handling remains a decoder capability task, not evidence
that every capability failure has been fixed.
## Video frame ownership
VideoEngine keeps source/load generation separate from seek iteration and renderer activity.
A new source calls cancelPending before track selection starts, so an old seek cannot draw
while the new source is still parsing. Seek during capability detection waits for that load
without canceling its metadata; only the latest seek may install or draw its frames.
Ordinary paused seek still displays its first target frame and queues the second.
Iterator detachment is synchronous. The old return Promise is observed without blocking a
new load on obsolete reads; repeated release calls return the same cleanup Promise. Late
completion cannot clear the current iterator or its fetching flag. Stop/start shares an
already pending next() for the same iterator instead of issuing concurrent reads. Pause
invalidates render work; it does not cancel a legitimate paused seek's target frame.
Each seek after the initial iterator creates a fresh CanvasSink with its own two-canvas pool.
Concurrent obsolete decoders can otherwise overwrite the pixels of a queued frame even when
the proxy discards their returned objects. Sink identity is internal and may change on seek;
public canvas identity stays stable. Tests intercept sink prototypes to follow new instances.
CanvasSink retains poolSize 2, contain fit and track transparency. Its implementation closes
VideoSample after drawing into the pooled canvas. The proxy releases iterators and references;
WrappedCanvas is not a VideoFrame and must not receive close(). Default dropLateFrames remains
false, with the same tolerance/rate formula when enabled. Start preserves the queued second
frame until it is consumed instead of replacing it through premature prefetch. There is no
added frame copy.
Each renderer owns one RAF, including ID zero. Stop/destroy and event-listener reentry make
queued callbacks inert; an instance that never scheduled a frame cannot cancel another
instance's frame. Normal timeupdate, stall recovery and ended ordering is preserved, while
reentrant pause/source/destroy/restart stops the obsolete event sequence. Poster image
callbacks have source and request identity and are detached on replacement, draw or destroy.
Background decoder failure stops rendering and reports one media error through the coordinator,
even if audio cleanup fails. Iterator cleanup failures are observed and warned; explicit
stopIterator still rejects. Missing 2D context fails at use with a descriptive error, rather
than becoming falsely ready. Capability handling for existing undecodable tracks remains
under MB-CAP-01/MB-READY-01; the current no-video fallback is preserved for that separate review.
## Types and dependencies
The locked runtime SDK remains MediaBunny 1.56.1. Its pinned WebCodecs declaration
@@ -118,6 +159,7 @@ node node_modules/typescript/bin/tsc -p packages/artplayer-proxy-mediabunny/tsco
yarn build artplayer-proxy-mediabunny
yarn test:browser test/browser/mediabunny-inputs.spec.js test/browser/mediabunny.spec.js test/browser/mediabunny-load.spec.js
yarn test:browser test/browser/mediabunny-shim.spec.js
yarn test:browser test/browser/mediabunny-video.spec.js
```
Node tests use actual SDK parsing and controlled lifecycle interleavings. Browser tests
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=e=>{throw TypeError(e)},r=(e,t,r)=>t.has(e)||i("Cannot "+r),a=(e,t,i)=>(r(e,t,"read from private field"),i?i.call(e):t.get(e)),s=(e,t,r)=>t.has(e)?i("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,r),n=(e,t,i,a)=>(r(e,t,"write to private field"),a?a.call(e,i):t.set(e,i),i);function o(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class c{constructor(){s(this,e,!1),this.listeners=new Map}addEventListener(t,i){a(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(a(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{a(this,e)||t(r)})}destroy(){n(this,e,!0),this.listeners.clear()}}function l(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const u=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},d=e=>e&&e[e.length-1],h=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},f=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},m=e=>{const t=h(e);return 0===(1&t)?-(t>>1):t+1>>1},p=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),g=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),k=new TextDecoder,y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),b={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},w=y(b),T={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},S=y(T),v={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},P=y(v),I=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,x={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},C=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class B{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const E=/^[0-9a-fA-F]+$/,_=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),R=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,F=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},A=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},D=(e,t,i)=>{const r=A(e,i(t),i);e.splice(r+1,0,t)},M=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},N=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},O=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},L=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},z=e=>{throw new Error(`Unexpected value: ${e}`)},U=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},q=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},W=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),H=(e,t,i)=>Math.max(t,Math.min(i,e)),V="und",j=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},K=(e,t)=>Math.round(e/t)*t,X=(e,t)=>Math.round(e*t)/t,$=(e,t)=>Math.floor(e/t)*t,G=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Q=/^[a-z]{3}$/,Y=e=>Q.test(e),J=1e6*(1+Number.EPSILON),Z=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?ee(e.headers):{},a=t.headers?ee(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},ee=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catch(n){if(a())throw n;s++;const e=r(s,n,t);if(null===e)throw n;if(xe._error("Retrying failed fetch. Error:",n),!Number.isFinite(e)||e<0)throw new TypeError("Retry delayLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,a,s,n,o,c,l,u,d,h,f,m,p,g,k,y,b,w=e=>{throw TypeError(e)},T=(e,t,i)=>t.has(e)||w("Cannot "+i),S=(e,t,i)=>(T(e,t,"read from private field"),i?i.call(e):t.get(e)),v=(e,t,i)=>t.has(e)?w("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),P=(e,t,i,r)=>(T(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),I=(e,t,i)=>(T(e,t,"access private method"),i),x=(e,t,i,r)=>({set _(r){P(e,t,r,i)},get _(){return S(e,t,r)}});function C(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class B{constructor(){v(this,e,!1),this.listeners=new Map}addEventListener(t,i){S(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(S(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{S(this,e)||t(r)})}destroy(){P(this,e,!0),this.listeners.clear()}}function E(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const _=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},R=e=>e&&e[e.length-1],F=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},A=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},D=e=>{const t=F(e);return 0===(1&t)?-(t>>1):t+1>>1},M=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),N=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),O=new TextDecoder,z=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),L={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},U=z(L),q={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},W=z(q),H={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},V=z(H),j=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,K={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},X=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ${constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const G=/^[0-9a-fA-F]+$/,Q=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),Y=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,J=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},Z=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},ee=(e,t,i)=>{const r=Z(e,i(t),i);e.splice(r+1,0,t)},te=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},ie=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},re=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ae=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},se=e=>{throw new Error(`Unexpected value: ${e}`)},ne=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},oe=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},ce=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),le=(e,t,i)=>Math.max(t,Math.min(i,e)),ue="und",de=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},he=(e,t)=>Math.round(e/t)*t,fe=(e,t)=>Math.round(e*t)/t,me=(e,t)=>Math.floor(e/t)*t,pe=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},ge=/^[a-z]{3}$/,ke=e=>ge.test(e),ye=1e6*(1+Number.EPSILON),be=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?we(e.headers):{},a=t.headers?we(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},we=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},Te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catcLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,s=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,l=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),c=e=>{throw TypeError(e)},u=Math.pow,d=(e,t,r)=>t in e?i(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,h=(e,t)=>{for(var i in t||(t={}))a.call(t,i)&&d(e,i,t[i]);if(s)for(var i of s(t))o.call(t,i)&&d(e,i,t[i]);return e},f=(e,t)=>r(e,n(t)),m=(e,t,i)=>t.has(e)||c("Cannot "+i),p=(e,t,i)=>(m(e,t,"read from private field"),i?i.call(e):t.get(e)),g=(e,t,i)=>t.has(e)?c("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),k=(e,t,i,r)=>(m(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),y=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),b=function(e,t){this[0]=e,this[1]=t},T=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof b,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[l("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},v=e=>{var t,i=e[l("asyncIterator")],r=!1,n={};return null==i?(i=e[l("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new b(new Promise(r=>{var n=i[e](t);n instanceof Object||c("Object expected"),r(n)}),1)}}),n[l("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},S=(e,t,i)=>(t=e[l("asyncIterator")])?t.call(e):(e=e[l("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function w(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class P{constructor(){g(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;p(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(p(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{p(this,e)||t(n)})}destroy(){k(this,e,!0),this.listeners.clear()}}function I(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const x=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},C=e=>e&&e[e.length-1],B=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},E=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-u(2,r))},_=e=>{const t=B(e);return 0===(1&t)?-(t>>1):t+1>>1},R=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),F=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),A=new TextDecoder,D=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),M={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},N=D(M),O={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},L=D(O),z={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},U=D(z),q=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,W={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},H=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class V{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return y(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const j=/^[0-9a-fA-F]+$/,K=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),X=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,$=(e,t,i)=>{let r=0,n=e.length-1,s=-1;for(;r<=n;){const a=r+n>>1,o=i(e[a]);o===t?(s=a,n=a-1):o<t?r=a+1:n=a-1}retuLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,n,s,a,o,l,c,u,d,h,f,m,p,g,k,y,b,T=Object.defineProperty,v=Object.defineProperties,S=Object.getOwnPropertyDescriptors,w=Object.getOwnPropertySymbols,P=Object.prototype.hasOwnProperty,I=Object.prototype.propertyIsEnumerable,x=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),C=e=>{throw TypeError(e)},B=Math.pow,E=(e,t,i)=>t in e?T(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,_=(e,t)=>{for(var i in t||(t={}))P.call(t,i)&&E(e,i,t[i]);if(w)for(var i of w(t))I.call(t,i)&&E(e,i,t[i]);return e},R=(e,t)=>v(e,S(t)),F=(e,t,i)=>t.has(e)||C("Cannot "+i),A=(e,t,i)=>(F(e,t,"read from private field"),i?i.call(e):t.get(e)),D=(e,t,i)=>t.has(e)?C("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),M=(e,t,i,r)=>(F(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),N=(e,t,i)=>(F(e,t,"access private method"),i),O=(e,t,i,r)=>({set _(r){M(e,t,r,i)},get _(){return A(e,t,r)}}),z=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),L=function(e,t){this[0]=e,this[1]=t},U=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof L,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[x("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},q=e=>{var t,i=e[x("asyncIterator")],r=!1,n={};return null==i?(i=e[x("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new L(new Promise(r=>{var n=i[e](t);n instanceof Object||C("Object expected"),r(n)}),1)}}),n[x("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},W=(e,t,i)=>(t=e[x("asyncIterator")])?t.call(e):(e=e[x("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function H(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class V{constructor(){D(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;A(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(A(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{A(this,e)||t(n)})}destroy(){M(this,e,!0),this.listeners.clear()}}function j(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const K=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},X=e=>e&&e[e.length-1],$=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},G=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-B(2,r))},Q=e=>{const t=$(e);return 0===(1&t)?-(t>>1):t+1>>1},Y=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),J=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),Z=new TextDecoder,ee=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),te={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},ie=ee(te),re={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ne=ee(re),se={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},ae=ee(se),oe=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,le={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ce=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ue{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return z(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const de=/^[0-9a-fA-F]+$/,he=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),fe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&167Line truncated
@@ -11,7 +11,16 @@ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
var _closed, _playback;
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
var __privateWrapper = (obj, member, setter, getter) => ({
set _(value) {
__privateSet(obj, member, value, setter);
},
get _() {
return __privateGet(obj, member, getter);
}
});
var _closed, _generation, _iteration, _loading, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn, reset_fn, _playback;
const $audio = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>';
const $quality = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>';
function uniqBy(array, property) {
@@ -24741,17 +24750,240 @@ async function preflightRange(url, enabled, events, signal, isCurrent = () => tr
return true;
}
}
const closing = /* @__PURE__ */ new WeakMap();
const reading = /* @__PURE__ */ new WeakMap();
function releaseIterator(iterator) {
if (!iterator)
return Promise.resolve();
const previous = closing.get(iterator);
if (previous)
return previous;
const pending = Promise.resolve().then(() => iterator.return()).then(() => {
});
closing.set(iterator, pending);
return pending;
}
function readCanvas(iterator) {
const previous = reading.get(iterator);
if (previous)
return previous;
const pending = Promise.resolve().then(() => iterator.next()).then((result) => result.value ?? null);
reading.set(iterator, pending);
const clear = () => {
if (reading.get(iterator) === pending)
reading.delete(iterator);
};
pending.then(clear, clear);
return pending;
}
function frameAction(timestamp, time, dropLateFrames, avSyncTolerance, playbackRate) {
const tolerance = dropLateFrames ? Math.max(0.06, avSyncTolerance / Math.max(1, playbackRate)) : 0;
if (dropLateFrames && timestamp < time - tolerance)
return "skip";
return timestamp <= time + tolerance ? "draw" : "queue";
}
class Poster {
constructor() {
this.image = null;
}
cancel() {
if (!this.image)
return;
this.image.onload = null;
this.image.onerror = null;
this.image = null;
}
draw(source, current, draw, failure) {
this.cancel();
const image = new Image();
this.image = image;
image.onload = () => {
if (this.image !== image || !current())
return;
this.cancel();
try {
draw(image);
} catch (error) {
failure(error);
}
};
image.onerror = () => {
if (this.image === image)
this.cancel();
};
image.src = source;
}
}
class Renderer {
constructor(host, ready, draw, failure) {
this.host = host;
this.ready = ready;
this.draw = draw;
this.failure = failure;
this.stopped = false;
this.closed = false;
this.scheduled = null;
this.fetching = null;
}
current(id) {
return !this.closed && !this.stopped && this.host.asyncId === id;
}
async update(id) {
const iterator = this.host.videoIterator;
if (!iterator || !this.ready() || !this.current(id) || this.host.isFetching)
return;
const request = {};
this.fetching = request;
this.host.isFetching = true;
const current = () => this.current(id) && this.host.videoIterator === iterator;
try {
while (current()) {
const frame = await readCanvas(iterator);
if (!current() || !frame)
return;
const time = this.host.audioClock?.currentTime;
if (time === void 0)
return;
const action = frameAction(frame.timestamp, time, this.host.dropLateFrames, this.host.avSyncTolerance, this.host.playbackRate);
if (action === "skip")
continue;
if (action === "draw") {
this.draw(frame);
} else {
this.host.nextFrame = frame;
return;
}
}
} catch (error) {
if (current())
throw error;
} finally {
if (this.fetching === request) {
this.fetching = null;
this.host.isFetching = false;
}
}
}
request(id) {
void this.update(id).catch((error) => {
if (!this.current(id))
return;
this.stop();
this.report(error);
});
}
report(error) {
try {
this.failure(error);
} catch (failure) {
console.warn("MediaBunny video error listener:", failure);
}
}
cancelFrame() {
if (this.scheduled === null)
return;
cancelAnimationFrame(this.scheduled);
this.scheduled = null;
}
schedule(id) {
if (!this.current(id))
return;
this.cancelFrame();
const frame = requestAnimationFrame(() => {
if (this.scheduled !== frame || !this.current(id))
return;
this.scheduled = null;
try {
this.render();
} catch (error) {
this.stop();
this.report(error);
}
});
this.scheduled = frame;
this.host.rafId = frame;
}
render() {
const id = this.host.asyncId;
const current = () => this.current(id);
if (!current() || !this.host.audioClock)
return;
const time = this.host.audioClock.currentTime;
const now = Date.now();
if (now - this.host.lastTimeUpdate >= this.host.timeupdateInterval) {
this.host.events.emit("timeupdate");
if (!current())
return;
this.host.lastTimeUpdate = now;
}
if (!this.ready()) {
this.schedule(id);
return;
}
if (Number.isFinite(this.host.duration) && time >= this.host.duration) {
this.stop();
this.host.stalled = false;
const ended = this.host.asyncId;
publish(this.host.events, () => !this.closed && this.host.asyncId === ended, ["ended", "pause", "canplay"]);
return;
}
if (this.host.nextFrame && this.host.nextFrame.timestamp <= time) {
this.draw(this.host.nextFrame);
this.host.nextFrame = null;
this.request(id);
if (this.host.stalled) {
publish(this.host.events, current, ["canplay", "playing"]);
if (!current())
return;
this.host.stalled = false;
}
} else if (!this.host.nextFrame) {
this.request(id);
if (Number.isFinite(this.host.duration) && time < this.host.duration && !this.host.stalled) {
this.host.stalled = true;
this.host.events.emit("waiting");
}
}
this.schedule(id);
}
start(clock) {
if (this.closed)
return;
this.stop();
this.stopped = false;
this.host.audioClock = clock;
this.host.stalled = false;
if (!this.host.nextFrame)
this.request(this.host.asyncId);
this.schedule(this.host.asyncId);
}
stop() {
this.stopped = true;
this.host.asyncId++;
this.cancelFrame();
this.fetching = null;
this.host.isFetching = false;
}
destroy() {
if (this.closed)
return;
this.stop();
this.closed = true;
}
}
class VideoEngine {
constructor({
canvas,
ctx,
events,
timeupdateInterval = 250,
avSyncTolerance = 0.12,
dropLateFrames = false,
poster = "",
preflightRange: preflightRange2 = false
}) {
constructor({ canvas, ctx, events, timeupdateInterval = 250, avSyncTolerance = 0.12, dropLateFrames = false, poster = "", preflightRange: preflightRange2 = false, onError }) {
__privateAdd(this, _VideoEngine_instances);
__privateAdd(this, _generation, 0);
__privateAdd(this, _iteration, 0);
__privateAdd(this, _loading, null);
__privateAdd(this, _createSink, null);
__privateAdd(this, _usedSink, null);
__privateAdd(this, _destroyed, false);
__privateAdd(this, _preparing, false);
__privateAdd(this, _poster, new Poster());
__privateAdd(this, _renderer);
__privateAdd(this, _onError);
this.canvas = canvas;
this.ctx = ctx;
this.events = events;
@@ -24775,33 +25007,171 @@ class VideoEngine {
this.playbackRate = 1;
this.posterDrawn = false;
this.isFetching = false;
__privateSet(this, _onError, onError);
__privateSet(this, _renderer, new Renderer(this, () => !__privateGet(this, _preparing), (frame) => __privateMethod(this, _VideoEngine_instances, draw_fn).call(this, frame, true), (error) => __privateMethod(this, _VideoEngine_instances, report_fn).call(this, error)));
}
async preflight(url, signal, isCurrent) {
return preflightRange(url, this.preflightRange, this.events, signal, isCurrent);
async preflight(source, signal, current) {
return preflightRange(source, this.preflightRange, this.events, signal, current);
}
drawPoster() {
if (!this.poster || this.posterDrawn)
if (__privateGet(this, _destroyed) || !this.poster || this.posterDrawn)
return;
const img = new Image();
img.onload = () => {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.canvas.width = img.naturalWidth || this.canvas.width;
this.canvas.height = img.naturalHeight || this.canvas.height;
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
const generation = __privateGet(this, _generation);
__privateGet(this, _poster).draw(this.poster, () => __privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation), (image) => {
this.clear();
this.canvas.width = image.naturalWidth || this.canvas.width;
this.canvas.height = image.naturalHeight || this.canvas.height;
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(image, 0, 0, this.canvas.width, this.canvas.height);
this.posterDrawn = true;
};
img.src = this.poster;
}, (error) => __privateMethod(this, _VideoEngine_instances, report_fn).call(this, error));
}
async stopIterator() {
await this.videoIterator?.return();
const iterator = this.videoIterator;
this.videoIterator = null;
this.nextFrame = null;
await releaseIterator(iterator);
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).clearRect(0, 0, this.canvas.width, this.canvas.height);
}
cancelPending() {
__privateWrapper(this, _generation)._++;
__privateWrapper(this, _iteration)._++;
this.stop();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, false);
__privateSet(this, _loading, null);
__privateSet(this, _createSink, null);
__privateSet(this, _usedSink, null);
this.videoSink = null;
this.input = null;
}
async load(media, onMetadata) {
++this.asyncId;
await this.stopIterator();
if (__privateGet(this, _destroyed))
return;
this.cancelPending();
const generation = __privateGet(this, _generation);
const iteration = __privateGet(this, _iteration);
__privateSet(this, _preparing, true);
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn).call(this, media, generation, iteration, onMetadata));
__privateSet(this, _loading, pending);
try {
await pending;
} finally {
if (__privateGet(this, _loading) === pending)
__privateSet(this, _loading, null);
}
}
handleNoVideoTrack() {
if (__privateGet(this, _destroyed))
return;
this.videoSink = null;
this.width = 0;
this.height = 0;
this.canvas.width = 0;
this.canvas.height = 0;
this.clear();
this.drawPoster();
}
async resetIterator(time) {
if (__privateGet(this, _destroyed))
return;
const generation = __privateGet(this, _generation);
const iteration = ++__privateWrapper(this, _iteration)._;
const loading = __privateGet(this, _loading);
this.stop();
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, true);
try {
if (loading)
await loading;
await __privateMethod(this, _VideoEngine_instances, reset_fn).call(this, time, generation, iteration);
} catch (error) {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration) {
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
throw error;
}
} finally {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration)
__privateSet(this, _preparing, false);
}
}
async updateNextFrame(localId) {
await __privateGet(this, _renderer).update(localId);
}
render() {
__privateGet(this, _renderer).render();
}
start(audio) {
__privateGet(this, _renderer).start(audio);
}
stop() {
__privateGet(this, _renderer).stop();
}
async seek(time) {
await this.resetIterator(time);
}
setPlaybackRate(rate) {
this.playbackRate = Math.max(0.1, Number(rate) || 1);
}
destroy() {
if (__privateGet(this, _destroyed))
return;
__privateSet(this, _destroyed, true);
__privateWrapper(this, _generation)._++;
__privateGet(this, _renderer).destroy();
__privateGet(this, _poster).cancel();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
this.posterDrawn = false;
__privateSet(this, _createSink, null);
__privateSet(this, _usedSink, null);
this.videoSink = null;
this.input = null;
this.audioClock = null;
}
}
_generation = new WeakMap();
_iteration = new WeakMap();
_loading = new WeakMap();
_createSink = new WeakMap();
_usedSink = new WeakMap();
_destroyed = new WeakMap();
_preparing = new WeakMap();
_poster = new WeakMap();
_renderer = new WeakMap();
_onError = new WeakMap();
_VideoEngine_instances = new WeakSet();
current_fn = function(generation) {
return !__privateGet(this, _destroyed) && __privateGet(this, _generation) === generation;
};
context_fn = function() {
if (!this.ctx)
throw new Error("Canvas 2D context is unavailable.");
return this.ctx;
};
report_fn = function(error) {
if (__privateGet(this, _destroyed))
return;
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
if (__privateGet(this, _onError))
__privateGet(this, _onError).call(this, error);
else
this.events.emit("error", error);
};
release_fn = function() {
void this.stopIterator().catch((error) => console.warn("MediaBunny iterator cleanup error:", error));
};
draw_fn = function(frame, clear) {
__privateGet(this, _poster).cancel();
if (clear)
this.clear();
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(frame.canvas, 0, 0);
};
load_fn = async function(media, generation, iteration, onMetadata) {
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
try {
this.clear();
this.posterDrawn = false;
const { input, videoTrack, duration } = media;
@@ -24812,139 +25182,61 @@ class VideoEngine {
onMetadata?.();
return;
}
if (videoTrack.codec === null || !await videoTrack.canDecode()) {
const decodable = videoTrack.codec !== null && await videoTrack.canDecode();
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
if (!decodable) {
this.handleNoVideoTrack();
onMetadata?.();
return;
}
const transparent = await videoTrack.canBeTransparent();
this.videoSink = new CanvasSink(videoTrack, {
poolSize: 2,
fit: "contain",
alpha: transparent
});
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
__privateSet(this, _createSink, () => new CanvasSink(videoTrack, { poolSize: 2, fit: "contain", alpha: transparent }));
this.videoSink = __privateGet(this, _createSink).call(this);
this.width = videoTrack.displayWidth;
this.height = videoTrack.displayHeight;
this.canvas.width = this.width;
this.canvas.height = this.height;
onMetadata?.();
await this.resetIterator(0);
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
await __privateMethod(this, _VideoEngine_instances, reset_fn).call(this, 0, generation, iteration);
} catch (error) {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation)) {
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
throw error;
}
} finally {
if (__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration)
__privateSet(this, _preparing, false);
}
handleNoVideoTrack() {
this.videoSink = null;
this.width = 0;
this.height = 0;
this.canvas.width = 0;
this.canvas.height = 0;
this.clear();
};
reset_fn = async function(time, generation, iteration) {
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) || __privateGet(this, _iteration) !== iteration)
return;
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
if (__privateGet(this, _createSink) && this.videoSink === __privateGet(this, _usedSink))
this.videoSink = __privateGet(this, _createSink).call(this);
const sink = this.videoSink;
if (!sink || !__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
__privateSet(this, _usedSink, sink);
const iterator = sink.canvases(time);
this.videoIterator = iterator;
const current = () => __privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation) && __privateGet(this, _iteration) === iteration && this.videoIterator === iterator;
const first = await readCanvas(iterator);
if (!current())
return;
const second = await readCanvas(iterator);
if (!current())
return;
this.nextFrame = second;
if (first)
__privateMethod(this, _VideoEngine_instances, draw_fn).call(this, first, false);
else
this.drawPoster();
}
async resetIterator(time) {
await this.stopIterator();
if (!this.videoSink)
return;
this.videoIterator = this.videoSink.canvases(time);
const first = (await this.videoIterator.next()).value ?? null;
const second = (await this.videoIterator.next()).value ?? null;
this.nextFrame = second;
if (first) {
this.ctx.drawImage(first.canvas, 0, 0);
} else {
this.drawPoster();
}
}
async updateNextFrame(localId) {
if (!this.videoIterator || this.isFetching)
return;
this.isFetching = true;
try {
while (true) {
const frame = (await this.videoIterator.next()).value ?? null;
if (!frame || localId !== this.asyncId)
return;
const t = this.audioClock.currentTime;
const tolerance = this.dropLateFrames ? Math.max(0.06, this.avSyncTolerance / Math.max(1, this.playbackRate)) : 0;
if (this.dropLateFrames && frame.timestamp < t - tolerance) {
continue;
}
if (frame.timestamp <= t + tolerance) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(frame.canvas, 0, 0);
if (!this.dropLateFrames && frame.timestamp > t) {
this.nextFrame = null;
return;
}
} else {
this.nextFrame = frame;
return;
}
}
} finally {
this.isFetching = false;
}
}
render() {
if (!this.audioClock)
return;
const t = this.audioClock.currentTime;
const now = Date.now();
if (now - this.lastTimeUpdate >= this.timeupdateInterval) {
this.events.emit("timeupdate");
this.lastTimeUpdate = now;
}
if (Number.isFinite(this.duration) && t >= this.duration) {
this.stop();
this.stalled = false;
this.events.emit("ended");
this.events.emit("pause");
this.events.emit("canplay");
return;
}
if (this.nextFrame && this.nextFrame.timestamp <= t) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(this.nextFrame.canvas, 0, 0);
this.nextFrame = null;
this.updateNextFrame(this.asyncId);
if (this.stalled) {
this.events.emit("canplay");
this.events.emit("playing");
this.stalled = false;
}
} else if (!this.nextFrame) {
this.updateNextFrame(this.asyncId);
if (!this.nextFrame && Number.isFinite(this.duration) && t < this.duration && !this.stalled) {
this.stalled = true;
this.events.emit("waiting");
}
}
this.rafId = requestAnimationFrame(() => this.render());
}
start(audioEngine) {
this.audioClock = audioEngine;
this.asyncId++;
this.stalled = false;
this.updateNextFrame(this.asyncId);
this.rafId = requestAnimationFrame(() => this.render());
}
stop() {
cancelAnimationFrame(this.rafId);
}
async seek(time) {
this.asyncId++;
await this.resetIterator(time);
}
setPlaybackRate(rate) {
this.playbackRate = Math.max(0.1, Number(rate) || 1);
}
destroy() {
this.asyncId++;
this.stop();
this.stopIterator();
this.posterDrawn = false;
this.videoSink = null;
this.input = null;
}
}
};
class MediaBunnyEngine {
constructor({ canvas, ctx, events, option = {} }) {
__privateAdd(this, _playback);
@@ -24959,7 +25251,15 @@ class MediaBunnyEngine {
avSyncTolerance: option.avSyncTolerance ?? 0.12,
dropLateFrames: option.dropLateFrames ?? false,
poster: option.poster ?? "",
preflightRange: option.preflightRange ?? false
preflightRange: option.preflightRange ?? false,
onError: (error) => {
try {
this.pause();
} catch (failure) {
console.warn("MediaBunny video failure cleanup:", failure);
}
this.reportError(error);
}
});
this.paused = true;
this.ended = false;
@@ -24986,6 +25286,7 @@ class MediaBunnyEngine {
return;
const id = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
this.loadSession?.cancel();
if (id !== this.loadSeq || this.destroyed)
return;
@@ -25009,6 +25310,7 @@ class MediaBunnyEngine {
return;
const failed = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
session.cancel();
if (failed !== this.loadSeq || this.destroyed)
return;
@@ -45,6 +45,15 @@ export default class MediaBunnyEngine implements EnginePort {
dropLateFrames: option.dropLateFrames ?? false,
poster: option.poster ?? '',
preflightRange: option.preflightRange ?? false,
onError: (error) => {
try {
this.pause()
}
catch (failure) {
console.warn('MediaBunny video failure cleanup:', failure)
}
this.reportError(error)
},
})
this.paused = true
this.ended = false
@@ -72,6 +81,7 @@ export default class MediaBunnyEngine implements EnginePort {
return
const id = ++this.loadSeq
this.#playback.invalidate()
this.video.cancelPending()
this.loadSession?.cancel()
if (id !== this.loadSeq || this.destroyed)
return
@@ -96,6 +106,7 @@ export default class MediaBunnyEngine implements EnginePort {
return
const failed = ++this.loadSeq
this.#playback.invalidate()
this.video.cancelPending()
session.cancel()
if (failed !== this.loadSeq || this.destroyed)
return
@@ -1,5 +0,0 @@
import type { VideoOptions, VideoPort } from './engine-ports'
// Exact coordinator-facing port; implementation migration and frame ownership are MB-05.
declare const VideoEngine: new (options: VideoOptions) => VideoPort
export default VideoEngine
@@ -1,272 +0,0 @@
/**
* Video Engine for MediaBunny
* Handles video frame rendering and synchronization
*/
import {
CanvasSink,
} from 'mediabunny'
import { preflightRange } from './preflight'
export default class VideoEngine {
constructor({
canvas,
ctx,
events,
timeupdateInterval = 250,
avSyncTolerance = 0.12,
dropLateFrames = false,
poster = '',
preflightRange = false,
}) {
this.canvas = canvas
this.ctx = ctx
this.events = events
this.timeupdateInterval = timeupdateInterval
this.avSyncTolerance = avSyncTolerance
this.dropLateFrames = dropLateFrames
this.poster = poster
this.preflightRange = preflightRange
// MediaBunny instances
this.input = null
this.videoSink = null
this.videoIterator = null
// Frame rendering
this.nextFrame = null
this.rafId = 0
this.asyncId = 0
// Video properties
this.width = 0
this.height = 0
this.duration = Number.NaN
// Playback state
this.audioClock = null
this.lastTimeUpdate = 0
this.stalled = false
this.playbackRate = 1
this.posterDrawn = false
this.isFetching = false
}
async preflight(url, signal, isCurrent) {
return preflightRange(url, this.preflightRange, this.events, signal, isCurrent)
}
drawPoster() {
if (!this.poster || this.posterDrawn)
return
const img = new Image()
img.onload = () => {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.canvas.width = img.naturalWidth || this.canvas.width
this.canvas.height = img.naturalHeight || this.canvas.height
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height)
this.posterDrawn = true
}
img.src = this.poster
}
async stopIterator() {
await this.videoIterator?.return()
this.videoIterator = null
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
async load(media, onMetadata) {
++this.asyncId
await this.stopIterator()
this.clear()
this.posterDrawn = false
const { input, videoTrack, duration } = media
this.input = input
this.duration = duration
if (!videoTrack) {
this.handleNoVideoTrack()
onMetadata?.()
return
}
if (videoTrack.codec === null || !(await videoTrack.canDecode())) {
this.handleNoVideoTrack()
onMetadata?.()
return
}
const transparent = await videoTrack.canBeTransparent()
this.videoSink = new CanvasSink(videoTrack, {
poolSize: 2,
fit: 'contain',
alpha: transparent,
})
this.width = videoTrack.displayWidth
this.height = videoTrack.displayHeight
this.canvas.width = this.width
this.canvas.height = this.height
onMetadata?.()
await this.resetIterator(0)
}
handleNoVideoTrack() {
this.videoSink = null
this.width = 0
this.height = 0
this.canvas.width = 0
this.canvas.height = 0
this.clear()
this.drawPoster()
}
async resetIterator(time) {
await this.stopIterator()
if (!this.videoSink)
return
this.videoIterator = this.videoSink.canvases(time)
const first = (await this.videoIterator.next()).value ?? null
const second = (await this.videoIterator.next()).value ?? null
this.nextFrame = second
if (first) {
this.ctx.drawImage(first.canvas, 0, 0)
}
else {
this.drawPoster()
}
}
async updateNextFrame(localId) {
if (!this.videoIterator || this.isFetching)
return
this.isFetching = true
try {
while (true) {
const frame = (await this.videoIterator.next()).value ?? null
if (!frame || localId !== this.asyncId)
return
const t = this.audioClock.currentTime
const tolerance = this.dropLateFrames
? Math.max(0.06, this.avSyncTolerance / Math.max(1, this.playbackRate))
: 0
if (this.dropLateFrames && frame.timestamp < t - tolerance) {
// Skip late frame
continue
}
if (frame.timestamp <= t + tolerance) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.drawImage(frame.canvas, 0, 0)
if (!this.dropLateFrames && frame.timestamp > t) {
this.nextFrame = null
return
}
}
else {
this.nextFrame = frame
return
}
}
}
finally {
this.isFetching = false
}
}
render() {
if (!this.audioClock)
return
const t = this.audioClock.currentTime
const now = Date.now()
// Emit timeupdate event
if (now - this.lastTimeUpdate >= this.timeupdateInterval) {
this.events.emit('timeupdate')
this.lastTimeUpdate = now
}
// Check if reached end
if (Number.isFinite(this.duration) && t >= this.duration) {
this.stop()
this.stalled = false
this.events.emit('ended')
this.events.emit('pause')
this.events.emit('canplay')
return
}
// Render next frame if ready
if (this.nextFrame && this.nextFrame.timestamp <= t) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.drawImage(this.nextFrame.canvas, 0, 0)
this.nextFrame = null
this.updateNextFrame(this.asyncId)
if (this.stalled) {
this.events.emit('canplay')
this.events.emit('playing')
this.stalled = false
}
}
else if (!this.nextFrame) {
this.updateNextFrame(this.asyncId)
if (!this.nextFrame && Number.isFinite(this.duration) && t < this.duration && !this.stalled) {
this.stalled = true
this.events.emit('waiting')
}
}
this.rafId = requestAnimationFrame(() => this.render())
}
start(audioEngine) {
this.audioClock = audioEngine
this.asyncId++
this.stalled = false
this.updateNextFrame(this.asyncId)
this.rafId = requestAnimationFrame(() => this.render())
}
stop() {
cancelAnimationFrame(this.rafId)
}
async seek(time) {
this.asyncId++
await this.resetIterator(time)
}
setPlaybackRate(rate) {
this.playbackRate = Math.max(0.1, Number(rate) || 1)
}
destroy() {
this.asyncId++
this.stop()
this.stopIterator()
this.posterDrawn = false
this.videoSink = null
this.input = null
}
}
@@ -0,0 +1,316 @@
import type { Input, WrappedCanvas } from 'mediabunny'
import type { VideoOptions, VideoPort } from './engine-ports'
import type EventTarget from './EventTarget'
import type { PlaybackMedia } from './media'
import type { FrameIterator } from './video-frames'
import { CanvasSink } from 'mediabunny'
import { preflightRange } from './preflight'
import { readCanvas, releaseIterator } from './video-frames'
import Poster from './video-poster'
import Renderer from './video-renderer'
export default class VideoEngine implements VideoPort {
declare canvas: HTMLCanvasElement
declare ctx: CanvasRenderingContext2D | null
declare events: EventTarget
declare timeupdateInterval: number
declare avSyncTolerance: number
declare dropLateFrames: boolean
declare poster: string
declare preflightRange: boolean
declare input: Input | null
declare videoSink: CanvasSink | null
declare videoIterator: FrameIterator | null
declare nextFrame: WrappedCanvas | null
declare rafId: number
declare asyncId: number
declare width: number
declare height: number
declare duration: number
declare audioClock: { readonly currentTime: number } | null
declare lastTimeUpdate: number
declare stalled: boolean
declare playbackRate: number
declare posterDrawn: boolean
declare isFetching: boolean
#generation = 0
#iteration = 0
#loading: Promise<void> | null = null
#createSink: (() => CanvasSink) | null = null
#usedSink: CanvasSink | null = null
#destroyed = false
#preparing = false
#poster = new Poster()
#renderer: Renderer
#onError: ((error: unknown) => void) | undefined
constructor({ canvas, ctx, events, timeupdateInterval = 250, avSyncTolerance = 0.12, dropLateFrames = false, poster = '', preflightRange = false, onError }: VideoOptions) {
this.canvas = canvas
this.ctx = ctx
this.events = events
this.timeupdateInterval = timeupdateInterval
this.avSyncTolerance = avSyncTolerance
this.dropLateFrames = dropLateFrames
this.poster = poster
this.preflightRange = preflightRange
this.input = null
this.videoSink = null
this.videoIterator = null
this.nextFrame = null
this.rafId = 0
this.asyncId = 0
this.width = 0
this.height = 0
this.duration = Number.NaN
this.audioClock = null
this.lastTimeUpdate = 0
this.stalled = false
this.playbackRate = 1
this.posterDrawn = false
this.isFetching = false
this.#onError = onError
this.#renderer = new Renderer(this, () => !this.#preparing, frame => this.#draw(frame, true), error => this.#report(error))
}
#current(generation: number): boolean {
return !this.#destroyed && this.#generation === generation
}
#context(): CanvasRenderingContext2D {
if (!this.ctx)
throw new Error('Canvas 2D context is unavailable.')
return this.ctx
}
#report(error: unknown): void {
if (this.#destroyed)
return
this.#release()
if (this.#onError)
this.#onError(error)
else
this.events.emit('error', error)
}
#release(): void {
void this.stopIterator().catch(error => console.warn('MediaBunny iterator cleanup error:', error))
}
#draw(frame: WrappedCanvas, clear: boolean): void {
this.#poster.cancel()
if (clear)
this.clear()
this.#context().drawImage(frame.canvas, 0, 0)
}
async preflight(source: unknown, signal?: AbortSignal, current?: () => boolean): Promise<boolean> {
return preflightRange(source, this.preflightRange, this.events, signal, current)
}
drawPoster(): void {
if (this.#destroyed || !this.poster || this.posterDrawn)
return
const generation = this.#generation
this.#poster.draw(this.poster, () => this.#current(generation), (image) => {
this.clear()
this.canvas.width = image.naturalWidth || this.canvas.width
this.canvas.height = image.naturalHeight || this.canvas.height
this.#context().drawImage(image, 0, 0, this.canvas.width, this.canvas.height)
this.posterDrawn = true
}, error => this.#report(error))
}
async stopIterator(): Promise<void> {
const iterator = this.videoIterator
this.videoIterator = null
this.nextFrame = null
await releaseIterator(iterator)
}
clear(): void {
this.#context().clearRect(0, 0, this.canvas.width, this.canvas.height)
}
cancelPending(): void {
this.#generation++
this.#iteration++
this.stop()
this.#release()
this.#poster.cancel()
this.#preparing = false
this.#loading = null
this.#createSink = null
this.#usedSink = null
this.videoSink = null
this.input = null
}
async load(media: PlaybackMedia, onMetadata?: () => void): Promise<void> {
if (this.#destroyed)
return
this.cancelPending()
const generation = this.#generation
const iteration = this.#iteration
this.#preparing = true
const pending = Promise.resolve().then(() => this.#load(media, generation, iteration, onMetadata))
this.#loading = pending
try {
await pending
}
finally {
if (this.#loading === pending)
this.#loading = null
}
}
async #load(media: PlaybackMedia, generation: number, iteration: number, onMetadata?: () => void): Promise<void> {
if (!this.#current(generation))
return
try {
this.clear()
this.posterDrawn = false
const { input, videoTrack, duration } = media
this.input = input
this.duration = duration
if (!videoTrack) {
this.handleNoVideoTrack()
onMetadata?.()
return
}
const decodable = videoTrack.codec !== null && await videoTrack.canDecode()
if (!this.#current(generation))
return
if (!decodable) {
this.handleNoVideoTrack()
onMetadata?.()
return
}
const transparent = await videoTrack.canBeTransparent()
if (!this.#current(generation))
return
this.#createSink = () => new CanvasSink(videoTrack, { poolSize: 2, fit: 'contain', alpha: transparent })
this.videoSink = this.#createSink()
this.width = videoTrack.displayWidth
this.height = videoTrack.displayHeight
this.canvas.width = this.width
this.canvas.height = this.height
onMetadata?.()
if (this.#current(generation))
await this.#reset(0, generation, iteration)
}
catch (error) {
if (this.#current(generation)) {
this.#release()
throw error
}
}
finally {
if (this.#current(generation) && this.#iteration === iteration)
this.#preparing = false
}
}
handleNoVideoTrack(): void {
if (this.#destroyed)
return
this.videoSink = null
this.width = 0
this.height = 0
this.canvas.width = 0
this.canvas.height = 0
this.clear()
this.drawPoster()
}
async #reset(time: number, generation: number, iteration: number): Promise<void> {
if (!this.#current(generation) || this.#iteration !== iteration)
return
this.#release()
if (this.#createSink && this.videoSink === this.#usedSink)
this.videoSink = this.#createSink()
const sink = this.videoSink
if (!sink || !this.#current(generation))
return
this.#usedSink = sink
const iterator = sink.canvases(time)
this.videoIterator = iterator
const current = () => this.#current(generation) && this.#iteration === iteration && this.videoIterator === iterator
const first = await readCanvas(iterator)
if (!current())
return
const second = await readCanvas(iterator)
if (!current())
return
this.nextFrame = second
if (first)
this.#draw(first, false)
else
this.drawPoster()
}
async resetIterator(time: number): Promise<void> {
if (this.#destroyed)
return
const generation = this.#generation
const iteration = ++this.#iteration
const loading = this.#loading
this.stop()
this.#poster.cancel()
this.#preparing = true
try {
if (loading)
await loading
await this.#reset(time, generation, iteration)
}
catch (error) {
if (this.#current(generation) && this.#iteration === iteration) {
this.#release()
throw error
}
}
finally {
if (this.#current(generation) && this.#iteration === iteration)
this.#preparing = false
}
}
async updateNextFrame(localId: number): Promise<void> {
await this.#renderer.update(localId)
}
render(): void {
this.#renderer.render()
}
start(audio: { readonly currentTime: number }): void {
this.#renderer.start(audio)
}
stop(): void {
this.#renderer.stop()
}
async seek(time: number): Promise<void> {
await this.resetIterator(time)
}
setPlaybackRate(rate: number): void {
this.playbackRate = Math.max(0.1, Number(rate) || 1)
}
destroy(): void {
if (this.#destroyed)
return
this.#destroyed = true
this.#generation++
this.#renderer.destroy()
this.#poster.cancel()
this.#release()
this.posterDrawn = false
this.#createSink = null
this.#usedSink = null
this.videoSink = null
this.input = null
this.audioClock = null
}
}
@@ -18,6 +18,7 @@ export interface AudioPort {
}
export interface VideoPort {
cancelPending: () => void
width: number
height: number
duration: number
@@ -32,6 +33,7 @@ export interface VideoPort {
}
export interface VideoOptions {
onError?: (error: unknown) => void
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D | null
events: EventTarget
@@ -0,0 +1,37 @@
import type { WrappedCanvas } from 'mediabunny'
export type FrameIterator = Pick<AsyncGenerator<WrappedCanvas, void, unknown>, 'next' | 'return'>
const closing = new WeakMap<FrameIterator, Promise<void>>()
const reading = new WeakMap<FrameIterator, Promise<WrappedCanvas | null>>()
export function releaseIterator(iterator: FrameIterator | null): Promise<void> {
if (!iterator)
return Promise.resolve()
const previous = closing.get(iterator)
if (previous)
return previous
const pending = Promise.resolve().then(() => iterator.return()).then(() => {})
closing.set(iterator, pending)
return pending
}
export function readCanvas(iterator: FrameIterator): Promise<WrappedCanvas | null> {
const previous = reading.get(iterator)
if (previous)
return previous
const pending = Promise.resolve().then(() => iterator.next()).then(result => result.value ?? null)
reading.set(iterator, pending)
const clear = () => {
if (reading.get(iterator) === pending)
reading.delete(iterator)
}
pending.then(clear, clear)
return pending
}
export function frameAction(timestamp: number, time: number, dropLateFrames: boolean, avSyncTolerance: number, playbackRate: number): 'skip' | 'draw' | 'queue' {
const tolerance = dropLateFrames ? Math.max(0.06, avSyncTolerance / Math.max(1, playbackRate)) : 0
if (dropLateFrames && timestamp < time - tolerance)
return 'skip'
return timestamp <= time + tolerance ? 'draw' : 'queue'
}
@@ -0,0 +1,33 @@
export default class Poster {
private image: HTMLImageElement | null = null
cancel(): void {
if (!this.image)
return
this.image.onload = null
this.image.onerror = null
this.image = null
}
draw(source: string, current: () => boolean, draw: (image: HTMLImageElement) => void, failure: (error: unknown) => void): void {
this.cancel()
const image = new Image()
this.image = image
image.onload = () => {
if (this.image !== image || !current())
return
this.cancel()
try {
draw(image)
}
catch (error) {
failure(error)
}
}
image.onerror = () => {
if (this.image === image)
this.cancel()
}
image.src = source
}
}
@@ -0,0 +1,192 @@
import type { WrappedCanvas } from 'mediabunny'
import type EventTarget from './EventTarget'
import type { FrameIterator } from './video-frames'
import { publish } from './readiness'
import { frameAction, readCanvas } from './video-frames'
export interface RenderHost {
events: EventTarget
audioClock: { readonly currentTime: number } | null
videoIterator: FrameIterator | null
nextFrame: WrappedCanvas | null
rafId: number
asyncId: number
duration: number
lastTimeUpdate: number
timeupdateInterval: number
avSyncTolerance: number
dropLateFrames: boolean
playbackRate: number
stalled: boolean
isFetching: boolean
}
export default class Renderer {
private stopped = false
private closed = false
private scheduled: number | null = null
private fetching: object | null = null
constructor(private host: RenderHost, private ready: () => boolean, private draw: (frame: WrappedCanvas) => void, private failure: (error: unknown) => void) {}
private current(id: number): boolean {
return !this.closed && !this.stopped && this.host.asyncId === id
}
async update(id: number): Promise<void> {
const iterator = this.host.videoIterator
if (!iterator || !this.ready() || !this.current(id) || this.host.isFetching)
return
const request = {}
this.fetching = request
this.host.isFetching = true
const current = () => this.current(id) && this.host.videoIterator === iterator
try {
while (current()) {
const frame = await readCanvas(iterator)
if (!current() || !frame)
return
const time = this.host.audioClock?.currentTime
if (time === undefined)
return
const action = frameAction(frame.timestamp, time, this.host.dropLateFrames, this.host.avSyncTolerance, this.host.playbackRate)
if (action === 'skip')
continue
if (action === 'draw') {
this.draw(frame)
}
else {
this.host.nextFrame = frame
return
}
}
}
catch (error) {
if (current())
throw error
}
finally {
if (this.fetching === request) {
this.fetching = null
this.host.isFetching = false
}
}
}
private request(id: number): void {
void this.update(id).catch((error) => {
if (!this.current(id))
return
this.stop()
this.report(error)
})
}
private report(error: unknown): void {
try {
this.failure(error)
}
catch (failure) {
console.warn('MediaBunny video error listener:', failure)
}
}
private cancelFrame(): void {
if (this.scheduled === null)
return
cancelAnimationFrame(this.scheduled)
this.scheduled = null
}
private schedule(id: number): void {
if (!this.current(id))
return
this.cancelFrame()
const frame = requestAnimationFrame(() => {
if (this.scheduled !== frame || !this.current(id))
return
this.scheduled = null
try {
this.render()
}
catch (error) {
this.stop()
this.report(error)
}
})
this.scheduled = frame
this.host.rafId = frame
}
render(): void {
const id = this.host.asyncId
const current = () => this.current(id)
if (!current() || !this.host.audioClock)
return
const time = this.host.audioClock.currentTime
const now = Date.now()
if (now - this.host.lastTimeUpdate >= this.host.timeupdateInterval) {
this.host.events.emit('timeupdate')
if (!current())
return
this.host.lastTimeUpdate = now
}
if (!this.ready()) {
this.schedule(id)
return
}
if (Number.isFinite(this.host.duration) && time >= this.host.duration) {
this.stop()
this.host.stalled = false
const ended = this.host.asyncId
publish(this.host.events, () => !this.closed && this.host.asyncId === ended, ['ended', 'pause', 'canplay'])
return
}
if (this.host.nextFrame && this.host.nextFrame.timestamp <= time) {
this.draw(this.host.nextFrame)
this.host.nextFrame = null
this.request(id)
if (this.host.stalled) {
publish(this.host.events, current, ['canplay', 'playing'])
if (!current())
return
this.host.stalled = false
}
}
else if (!this.host.nextFrame) {
this.request(id)
if (Number.isFinite(this.host.duration) && time < this.host.duration && !this.host.stalled) {
this.host.stalled = true
this.host.events.emit('waiting')
}
}
this.schedule(id)
}
start(clock: { readonly currentTime: number }): void {
if (this.closed)
return
this.stop()
this.stopped = false
this.host.audioClock = clock
this.host.stalled = false
if (!this.host.nextFrame)
this.request(this.host.asyncId)
this.schedule(this.host.asyncId)
}
stop(): void {
this.stopped = true
this.host.asyncId++
this.cancelFrame()
this.fetching = null
this.host.isFetching = false
}
destroy(): void {
if (this.closed)
return
this.stop()
this.closed = true
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"task": "PKG-MB-05",
"sourceCommit": "5d0b128483a78a5374bb7163adc1421ba6a4d4c8",
"sourceSha256LF": {
"packages/artplayer-proxy-mediabunny/src/VideoEngine.js": "5ac862b0fd9c196dc6656f8908a214c1b3a0a53a7e85ff845d9068888831986b",
"packages/artplayer-proxy-mediabunny/dist/artplayer-proxy-mediabunny.js": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"node_modules/mediabunny/src/media-sink.ts": "9fbec4b6b0ec6c613d8f546fa91d8f14aac1c7c4a53ec7ed14b31319d43510f2"
},
"sdkNote": "Installed 1.56.1 CanvasSink closes each VideoSample after drawing into its pooled canvas. Consumer owns the iterator return, not VideoFrame.close on WrappedCanvas."
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,80 @@
# PKG-MB-05:视频迭代器、seek、晚帧与RAF
任务通过完整CI后标记done,并立即以专用本地提交交付。冻结来源为5d0b1284,源码与实际main哈希见
mb-video-source.json;SDK仍为已锁定MediaBunny 1.56.1,未升级依赖。
## 实际实现
VideoEngine.js已迁移为严格TS并删除VideoEngine.d.ts桥。拆出video-frames.ts、
video-renderer.ts、video-poster.ts,包内共20个生产TS模块;仅AudioEngine.d.ts仍是
显式的临时JS边界。ARCHITECTURE.md记录文件地图和状态/资源归属。
- source/load、seek迭代和render活动分别拥有身份;旧load、第一/第二seek帧、能力查询和
异步frame读取结果不得覆盖新source或seek。新source开始解析时即取消旧视频操作。
- 加载能力检测期间seek等待该加载,不能把合法metadata一起取消;暂停状态的正常seek
仍显示目标第一帧并保留第二帧。load/reset不等待废弃迭代器无限期返回。
- 迭代器先同步脱离当前字段,再观察return;每个迭代器只释放一次。stop/start对同一
pending next共享读取,旧finally不清掉新fetch状态。
- RAF每实例单一归属,ID 0有效;暂停、销毁与事件回调重入后不再排程旧任务。
未创建RAF的实例不能取消其他实例的ID 0。正常timeupdate/ended/stall事件顺序保持。
- 海报请求有源身份和请求身份,替换、真正绘制帧、销毁时解除回调。
- 活动后台解码失败停止渲染并通过协调器报告一次MediaError,音频清理异常不覆盖原错误。
后台iterator return拒绝有观察和警告;显式stopIterator保持Promise拒绝。
- 无2D context时在实际使用阶段报明确错误,替代旧的空值TypeError;不会伪造就绪。
默认dropLateFrames=false、容差/倍速公式、CanvasSink poolSize=2、contain、透明度保持。
SDK的CanvasSink把VideoSample画入池化canvas后自行close;代理负责迭代器,不对WrappedCanvas
调用VideoFrame.close,不增加每帧拷贝。公开factory/类型/Canvas门面与三种产物入口保持。
## 证据
38项新视频断言对5d0b1284实际main为7通过/31失败;候选全部通过。包专项146项通过。
main/legacy各110项通过,106候选断言+4冻结正常对照。三格式构建与严格类型检查通过。
最终浏览器结果归档并记录在mb-video-validation.json。54项由12普通真实播放、10真实
解码帧竞态、9原生Stream取消、9synthetic RAF、11Windows WebKit能力对照、3无轨道拒绝组成。
其中10个新竞态场景对旧main全部失败,另5能力对照通过;失败trace与报告完整保留。
新场景使用实际SDK解析/解码、实际CanvasSink帧和浏览器时钟,只延迟一帧的交付来控制
暂停/连续seek/切源时序。timeupdate回调暂停检查真实RAF,不是mock codec或纯事件出口测试。
开发时修正了测试宿主:控制音频seek来隔离视频测试;两个实例的RAF测试直接创建两个实际
VideoEngine,避免测试helper重复返回同一canvas。旧32项测试中25失败,扩展到37项后30失败。
初始复现读取必须在下一次返回done,避免旧实现缺陷造成测试自身无限循环;没有扩大超时。
## 后续
MB-06继续实际音频解码、时钟与节点释放;MB-07继续并发HLS选轨和UI拓扑;MB-08完善
消费者声明矩阵。MB-09仍需要长播放、AV同步和完整核心/插件/设备组合。既有轨道但不可解码
时的fallback还需MB-CAP-01/MB-READY-01评审,不以这次晚帧修复宣布所有能力问题已关闭。
MPL通知、8082示例与真实安装包在MB-10;未推送、打tag或发布。
最终复核另复现了启动预取覆盖已排队第二帧,修复为消费该帧后再取下一帧,保持默认
不丢帧意图及容差策略。新增第38项回归,旧main为7通过/31失败。首轮完整CI1306通过
之后因这项实际源码修复重新构建、验证产物/浏览器并运行完整CI,不复用旧的绿色结果。
进一步使用两个实际解码VideoSample在进入CanvasSink池化转换前暂停交付,连续seek
1→3→5后释放旧样本:当前帧timestamp仍属于5秒,但排队canvas像素校验从1220654181
变成2730873815。此缺陷在已通过第二轮CI的实现上也复现,报告为mb05-pool-before-results。
已改为每个seek独立CanvasSink池(仍2个canvas,无每帧拷贝),保留公开canvas实例;
内部sink身份可变化。对应浏览器测试跟踪SDK原型以覆盖新sink,而非绑定一次旧实例。
修复后像素稳定;最终54项浏览器及对应旧main15项对照另行归档。
构建中两次发生Windows copyfile UNKNOWN,仅覆盖已有docs/compiled legacy目标失败;
源码和目标读取、r+打开及复制到cache均成功,磁盘空间充足。备份并移除该可再生目标,
由正常yarn build重新生成后三格式构建成功,没有手改产物内容或扩大文件删除范围。
首次54项浏览器所用main与重新完整构建的main哈希相同,仍有效;legacy重新运行110项检查。
进一步补充新迭代器保持断言:Firefox旧切源在某次运行中未多画旧帧,却已被旧清理
清空新迭代器,原画面断言不足以发现。新增断言后最终旧main10个原生竞态均失败,
5个能力对照通过;先前9失败/6通过报告另存,不把同一副作用的不同调度结果隐去。
最终浏览器清理断言改为按现有expect超时等待AudioContext真实closed状态;close本来是
异步操作,固定两帧不足以判定失败。此处只调整测试等待,生产代码未改。末次完整CI
1307项覆盖最终生产源码;之后单独重跑浏览器spec lint和全部54浏览器用例。旧main
同样重跑15项以保留清理后的原始视频断言结论,未提高超时或跳过失败。
最终完整CI1307项(1152单元+14工程+141基线)通过,另44重复契约;310生产TS。
源码、测试、产物与报告哈希已核验,三格式docs/compiled与dist逐字节一致。
38视频断言在旧main为7通过/31失败;最终包专项146、main/legacy各110、浏览器54均通过。
旧产物10个原生帧竞态失败均为绘制计数、帧像素、迭代器保留或RAF状态断言,不是超时:暂停后绘制2→10,
旧seek结果在新seek/source后绘制2→3,timeupdate暂停后残留1个RAF;候选均无此残留。
回退可revert专用提交,原始5d0b1284及基线保持可追溯。下一项PKG-MB-06继续音频引擎。
+7
View File
@@ -54,3 +54,10 @@ VideoShim/EventTarget与主协调器已拆为严格TS模块,详情见
无轨道容器由历史readyState 4改为code 4错误;有效媒体的正常顺序保持,重复就绪和
失效操作结果被阻止。不要把synthetic RAF、受控竞态或Windows WebKit能力对照计为真实播放。
音视频解码器仍通过两个显式声明桥连接,MB-05/06继续实际实现迁移和底层晚操作清理。
## MB-05 video ownership
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个新断言、旧产物复现及真实解码场景。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 114 / doing 7 / blocked 0 / done 101 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 113 / doing 7 / blocked 0 / done 102 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -333,7 +333,7 @@
| PKG-MB-02 | artplayer-proxy-mediabunny<br>建立真实媒体与事件基线 | PKG-MB-01, ENG-05, ENG-08 | MP4/WebM/HLS/Blob/Stream、seek/错误/轨道切换样本 | ready 事件顺序、音画同步、超时和释放可重跑 | H | done |
| 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 | todo |
| 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-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 |
@@ -524,4 +524,5 @@
- PKG-MB-02: [记录](changes/2026-09-12-PKG-MB-02-checkpoint.md) [记录](baselines/mb-media-checkpoint.json) [记录](mb-validation.md) [记录](changes/2026-09-12-PKG-MB-02-baseline.md) [记录](baselines/mb-behavior-validation.json)
- 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-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)
+14
View File
@@ -1,5 +1,19 @@
# 进度与证据
## PKG-MB-05完成:视频迭代器、seek、晚帧与RAF
VideoEngine实际迁移TS,拆出frame读取/释放、renderer与poster职责,删除视频声明桥。
新source立即取消旧视频操作;seek与加载、暂停分别管理,旧帧不覆盖新状态;单实例RAF
与海报回调有清理;保留默认丢帧策略并修复启动预取覆盖第二帧,以及多seek共享
Canvas池导致排队帧像素被旧样本改写。包内20生产TS,
只剩AudioEngine显式声明桥。38项新断言对旧main7通过/31失败、候选全通过;包专项
146、main/legacy各110通过。最终54浏览器:12普通播放+10真实解码帧竞态+9Stream
取消+9synthetic RAF+11能力对照+3无轨道拒绝。旧main10项真实帧竞态全部失败,
候选全部通过,完整报告/trace归档。最终完整CI1307通过,另44重复契约,310生产TS。
见[变更](changes/2026-09-12-PKG-MB-05-video.md)及[证据](baselines/mb-video-validation.json)。
222项:102 done、7 doing、113 todo;立即专用本地提交,下一项MB-06音频引擎。
音频、HLS拓扑、长播放/AV同步与codec能力剩余项继续,不构成npm发布验收。
## PKG-MB-04完成:主协调器TS与操作隔离
MediaBunnyEngine实际迁移TS,并拆分playback/readiness/HLS查询职责;正常接口和事件顺序
+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.",
"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.",
"closureCriteria": "Historical failing interleavings and candidate cancellation/cleanup pass with real media and event evidence, including long playback and track topology.",
"status": "open",
"evidence": [
@@ -3662,7 +3662,9 @@
"refactor/changes/2026-09-12-PKG-MB-03-input.md",
"refactor/changes/2026-09-12-PKG-MB-04-shim-checkpoint.md",
"refactor/changes/2026-09-12-PKG-MB-04-coordination.md",
"refactor/baselines/mb-coordination-validation.json"
"refactor/baselines/mb-coordination-validation.json",
"refactor/changes/2026-09-12-PKG-MB-05-video.md",
"refactor/baselines/mb-video-validation.json"
]
},
{
+6 -2
View File
@@ -3482,11 +3482,15 @@
"dependsOn": [
"PKG-MB-04"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "VideoEngine 调度、队列、晚帧处理和资源释放",
"acceptance": "暂停/seek/切源无旧帧覆盖,默认丢帧策略不变",
"evidence": []
"evidence": [
"baselines/mb-video-source.json",
"changes/2026-09-12-PKG-MB-05-video.md",
"baselines/mb-video-validation.json"
]
},
{
"id": "PKG-MB-06",
+4 -3
View File
@@ -145,9 +145,10 @@ for (const implementation of implementations) {
const video = canvas.engine.video
if (video.videoSink) {
const times = new WeakMap()
const canvases = video.videoSink.canvases.bind(video.videoSink)
video.videoSink.canvases = (...args) => {
const iterator = canvases(...args)
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)
+218
View File
@@ -0,0 +1,218 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { mbCandidate } from '../helpers/mediabunny.js'
import { expect, test } from './fixtures.js'
const implementation = await mbCandidate()
for (const scenario of ['pause-frame', 'seek-source', 'seek-newer', 'timeupdate-pause', 'seek-pool']) {
test(`MediaBunny ${implementation.name}: native video ownership ${scenario}`, async ({ page, browserName }, testInfo) => {
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; })();` })
let outcome = 'unverified'
let result
await page.evaluate(async () => {
const listeners = new Map()
window.mbEvents = []
window.mbDraws = []
window.mbFrames = new Set()
const raf = requestAnimationFrame.bind(window)
const cancel = cancelAnimationFrame.bind(window)
window.mbFlush = () => new Promise(resolve => raf(() => raf(resolve)))
window.requestAnimationFrame = (callback) => {
const id = raf((time) => {
window.mbFrames.delete(id)
callback(time)
})
window.mbFrames.add(id)
return id
}
window.cancelAnimationFrame = (id) => {
window.mbFrames.delete(id)
cancel(id)
}
window.mbHost = {
constructor: window.Artplayer,
option: { url: '', autoSize: false },
template: { $player: document.querySelector('.player') },
on(name, callback) {
const list = listeners.get(name) || []
list.push(callback)
listeners.set(name, list)
},
emit(name, ...args) {
window.mbEvents.push(name)
for (const callback of listeners.get(name) || []) callback(...args)
},
}
window.mbCanvas = window.mbFactory()(window.mbHost)
const context = window.mbCanvas.engine.video.ctx
const draw = context.drawImage.bind(context)
context.drawImage = (...args) => {
window.mbDraws.push({ time: window.mbCanvas.currentTime, paused: window.mbCanvas.paused })
return draw(...args)
}
await window.mbCanvas.engine.load('/test/pattern.mp4')
const button = document.createElement('button')
button.id = 'mb-play'
button.textContent = 'Play'
button.onclick = () => {
window.mbPlay = window.mbCanvas.play()
}
document.body.append(button)
window.mbHold = (iterator) => {
const next = iterator.next.bind(iterator)
let first = true
iterator.next = async (...args) => {
const value = await next(...args)
if (first) {
first = false
window.mbHeldTimestamp = value.value?.timestamp
window.mbHeldReads = 1
return new Promise((resolve) => {
window.mbRelease = () => resolve(value)
})
}
window.mbHeldReads++
return value
}
return iterator
}
})
try {
const capabilities = await page.evaluate(() => ({ VideoDecoder: typeof VideoDecoder, AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext }))
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
expect(browserName).toBe('webkit')
expect(capabilities.VideoDecoder).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)
expect(await page.evaluate(() => [window.mbCanvas.videoWidth, window.mbCanvas.videoHeight])).toEqual([320, 180])
if (scenario === 'pause-frame') {
await page.evaluate(() => window.mbHold(window.mbCanvas.engine.video.videoIterator))
await page.click('#mb-play')
await expect.poll(() => page.evaluate(() => typeof window.mbRelease)).toBe('function')
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime > window.mbHeldTimestamp + 0.1)).toBe(true)
result = await page.evaluate(async () => {
window.mbCanvas.pause()
const before = window.mbDraws.length
window.mbRelease()
await window.mbPlay
await window.mbFlush()
return { before, after: window.mbDraws.length, paused: window.mbCanvas.paused, frames: window.mbFrames.size }
})
expect(result.after).toBe(result.before)
expect(result.paused).toBe(true)
expect(result.frames).toBe(0)
}
else if (scenario === 'seek-pool') {
await page.evaluate(() => {
const prototype = Object.getPrototypeOf(window.mbCanvas.engine.video.videoSink._videoSampleSink)
const samples = prototype.samples
window.mbSampleReleases = {}
prototype.samples = function (...args) {
const iterator = samples.apply(this, args)
const next = iterator.next.bind(iterator)
let first = true
iterator.next = async (...values) => {
const result = await next(...values)
if (first && [1, 3].includes(args[0]) && !result.done) {
first = false
return new Promise((resolve) => {
window.mbSampleReleases[args[0]] = () => resolve(result)
})
}
return result
}
return iterator
}
window.mbOldFirst = window.mbCanvas.engine.seek(1)
})
await expect.poll(() => page.evaluate(() => typeof window.mbSampleReleases[1])).toBe('function')
await page.evaluate(() => {
window.mbOldSecond = window.mbCanvas.engine.seek(3)
})
await expect.poll(() => page.evaluate(() => typeof window.mbSampleReleases[3])).toBe('function')
result = await page.evaluate(async () => {
await window.mbCanvas.engine.seek(5)
const frame = window.mbCanvas.engine.video.nextFrame
const fingerprint = () => {
let hash = 2166136261
for (const value of frame.canvas.getContext('2d').getImageData(0, 0, frame.canvas.width, frame.canvas.height).data)
hash = Math.imul(hash ^ value, 16777619) >>> 0
return hash
}
const before = fingerprint()
window.mbSampleReleases[1]()
window.mbSampleReleases[3]()
await Promise.all([window.mbOldFirst, window.mbOldSecond])
await window.mbFlush()
return { before, after: fingerprint(), timestamp: frame.timestamp, currentFrameUnchanged: frame === window.mbCanvas.engine.video.nextFrame, time: window.mbCanvas.currentTime }
})
expect(result.after).toBe(result.before)
expect(result.currentFrameUnchanged).toBe(true)
expect(result.timestamp).toBeGreaterThan(5)
expect(result.time).toBe(5)
}
else if (scenario.startsWith('seek-')) {
await page.evaluate(() => {
const prototype = Object.getPrototypeOf(window.mbCanvas.engine.video.videoSink)
const canvases = prototype.canvases
prototype.canvases = function (...args) {
const iterator = canvases.apply(this, args)
return args[0] === 3 ? window.mbHold(iterator) : iterator
}
window.mbSeek = window.mbCanvas.engine.seek(3)
})
await expect.poll(() => page.evaluate(() => typeof window.mbRelease)).toBe('function')
result = await page.evaluate(async (scenario) => {
if (scenario === 'seek-source')
await window.mbCanvas.engine.load('/test/pattern.mp4?replacement=1')
else await window.mbCanvas.engine.seek(5)
const iterator = window.mbCanvas.engine.video.videoIterator
const before = window.mbDraws.length
window.mbRelease()
await window.mbSeek
await window.mbFlush()
return { before, after: window.mbDraws.length, heldReads: window.mbHeldReads, currentIteratorKept: Boolean(iterator) && iterator === window.mbCanvas.engine.video.videoIterator, frames: window.mbFrames.size, time: window.mbCanvas.currentTime, error: window.mbCanvas.error }
}, scenario)
expect(result.after).toBe(result.before)
expect(result.heldReads).toBe(1)
expect(result.currentIteratorKept).toBe(true)
expect(result.frames).toBe(0)
expect(result.error).toBeNull()
expect(result.time).toBe(scenario === 'seek-source' ? 0 : 5)
}
else {
await page.evaluate(() => window.mbCanvas.events.addEventListener('timeupdate', () => window.mbCanvas.pause()))
await page.click('#mb-play')
await expect.poll(() => page.evaluate(() => window.mbEvents.includes('video:timeupdate'))).toBe(true)
result = await page.evaluate(async () => {
await window.mbFlush()
return { paused: window.mbCanvas.paused, frames: window.mbFrames.size }
})
expect(result).toEqual({ paused: true, frames: 0 })
}
outcome = 'native-decoded-video-race'
}
finally {
const state = await page.evaluate(async () => {
const canvas = window.mbCanvas
window.mbRelease?.()
for (const release of Object.values(window.mbSampleReleases || {})) release()
const context = canvas.engine.audio.audioContext
window.mbCleanupContext = context
window.mbHost.emit('destroy')
await window.mbFlush()
return { events: window.mbEvents, draws: window.mbDraws.length, error: canvas.error, audioContext: context?.state || 'not-created', frames: window.mbFrames.size }
})
expect(state.frames).toBe(0)
await expect.poll(() => page.evaluate(() => window.mbCleanupContext?.state || 'not-created')).toMatch(/^(?:closed|not-created)$/)
state.audioContext = await page.evaluate(() => window.mbCleanupContext?.state || 'not-created')
await testInfo.attach('mediabunny-video-ownership', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), scenario, outcome, result, state, scope: 'Actual SDK MP4 decoding and browser clock/RAF; frame delivery or two raw VideoSample deliveries before CanvasSink conversion control interleaving. Source/seek ownership, pause and timeupdate reentry verified; not long-run AV sync or a mock codec.' }) })
}
})
}
+517
View File
@@ -0,0 +1,517 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Real compiled video engine with controlled SDK iterator 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 frame = (name, timestamp = 0) => ({ canvas: { name }, timestamp, duration: 0.1 })
const item = value => ({ value, done: !value })
function iterator(values = []) {
let reads = 0
let returns = 0
return {
next: async () => {
reads++
return item(values.shift())
},
return: async () => {
returns++
return item()
},
get reads() { return reads },
get returns() { return returns },
}
}
function environment(options = {}) {
const env = mbEnvironment(implementation)
env.eventNames.push('timeupdate', 'ended', 'waiting', 'playing', 'canplay')
const canvas = env.factory(options)(env.art)
const video = canvas.engine.video
canvas.engine.audio.seek = async () => {}
const draws = []
env.context.drawImage = source => draws.push(source.name)
video.audioClock = { currentTime: 1 }
return { ...env, canvas, video, draws }
}
test('MediaBunny video preserves first seek frame and queues the second with exact iterator timestamp', async () => {
const env = environment()
const frames = iterator([frame('first', 3), frame('second', 3.1)])
env.video.videoSink = { canvases: (time) => {
assert.equal(time, 3)
return frames
} }
await env.video.seek(3)
assert.deepEqual(env.draws, ['first'])
assert.equal(env.video.nextFrame.canvas.name, 'second')
assert.equal(frames.reads, 2)
env.art.emit('destroy')
})
test('MediaBunny video detaches iterator before awaiting release, and releases it once', async () => {
const env = environment()
const release = deferred()
let returns = 0
env.video.videoIterator = { return: () => {
returns++
return release.promise
} }
const closing = env.video.stopIterator()
assert.equal(env.video.videoIterator, null)
const again = env.video.stopIterator()
release.resolve(item())
await Promise.all([closing, again])
assert.equal(returns, 1)
env.art.emit('destroy')
})
test('MediaBunny old iterator return cannot erase a newer iterator', async () => {
const env = environment()
const release = deferred()
env.video.videoIterator = { return: () => release.promise }
const closing = env.video.stopIterator()
const next = iterator()
env.video.videoIterator = next
release.resolve(item())
await closing
assert.equal(env.video.videoIterator, next)
env.art.emit('destroy')
})
for (const ending of ['stop', 'destroy']) {
test(`MediaBunny pending render frame cannot draw after ${ending}`, async () => {
const env = environment()
const pending = deferred()
let reads = 0
env.video.videoIterator = { next: () => ++reads === 1 ? pending.promise : Promise.resolve(item()), return: async () => item() }
const reading = env.video.updateNextFrame(env.video.asyncId)
if (ending === 'stop')
env.video.stop()
else env.art.emit('destroy')
pending.resolve(item(frame('obsolete')))
await reading
assert.deepEqual(env.draws, [])
assert.equal(env.video.nextFrame, null)
env.art.emit('destroy')
})
}
for (const ending of ['destroy', 'seek']) {
test(`MediaBunny pending first seek frame cannot consume or overwrite frames after ${ending}`, async () => {
const env = environment()
const pending = deferred()
let oldReads = 0
const old = { next: async () => {
oldReads++
return oldReads === 1 ? pending.promise : item(frame('old-second'))
}, return: async () => item() }
const latest = iterator([frame('new-first'), frame('new-second')])
env.video.videoSink = { canvases: time => time === 1 ? old : latest }
const seeking = env.video.seek(1)
await flush()
if (ending === 'destroy')
env.art.emit('destroy')
else await env.video.seek(2)
pending.resolve(item(frame('old-first')))
await seeking
assert.deepEqual(env.draws, ending === 'destroy' ? [] : ['new-first'])
assert.equal(oldReads, 1)
if (ending === 'seek')
assert.equal(env.video.nextFrame.canvas.name, 'new-second')
env.art.emit('destroy')
})
}
test('MediaBunny a new load does not wait for obsolete iterator disposal', async () => {
const env = environment()
const release = deferred()
env.video.videoIterator = { return: () => release.promise }
let metadata = 0
const loading = env.video.load({ input: {}, videoTrack: null, duration: 9 }, () => metadata++)
await flush()
assert.equal(metadata, 1)
assert.equal(env.video.duration, 9)
release.resolve(item())
await loading
env.art.emit('destroy')
})
for (const ending of ['destroy', 'source']) {
test(`MediaBunny stale codec capability result cannot alter metadata after ${ending}`, async () => {
const env = environment()
const capability = deferred()
let metadata = 0
const loading = env.video.load({ input: {}, videoTrack: { codec: 'avc', canDecode: () => capability.promise }, duration: 8 }, () => metadata++)
await flush()
if (ending === 'destroy')
env.art.emit('destroy')
else await env.video.load({ input: {}, videoTrack: null, duration: 4 })
env.canvas.width = 42
capability.resolve(false)
await loading
assert.equal(metadata, 0)
assert.equal(env.canvas.width, 42)
env.art.emit('destroy')
})
}
for (const ending of ['stop', 'destroy']) {
test(`MediaBunny timeupdate listener ${ending} prevents rendering and RAF rescheduling`, () => {
const env = environment()
env.video.nextFrame = frame('due')
env.video.events.addEventListener('timeupdate', () => ending === 'stop' ? env.video.stop() : env.art.emit('destroy'))
env.video.render()
assert.deepEqual(env.draws, [])
assert.equal(env.frames.size, 0)
env.art.emit('destroy')
})
}
test('MediaBunny repeated video start owns only one native RAF including id zero', async () => {
const env = environment()
env.video.start({ currentTime: 0 })
assert.equal(env.frames.size, 1)
env.video.start({ currentTime: 0 })
assert.equal(env.frames.size, 1)
env.video.stop()
assert.equal(env.frames.size, 0)
env.art.emit('destroy')
})
test('MediaBunny already queued RAF callback stays inert after stop', () => {
const env = environment()
env.video.start({ currentTime: 0 })
const callback = [...env.frames.values()][0]
env.video.stop()
env.emitted.length = 0
callback()
assert.deepEqual(env.emitted, [])
assert.equal(env.frames.size, 0)
env.art.emit('destroy')
})
for (const ending of ['destroy', 'source']) {
test(`MediaBunny poster callback cannot overwrite canvas after ${ending}`, async () => {
const env = environment({ poster: 'poster.jpg' })
const images = []
env.globals.Image = class {
constructor() {
this.name = 'poster'
this.naturalWidth = 320
this.naturalHeight = 180
images.push(this)
}
}
env.video.drawPoster()
const callback = images[0].onload
if (ending === 'destroy') {
env.art.emit('destroy')
}
else {
env.video.poster = ''
await env.video.load({ input: {}, videoTrack: null, duration: 9 })
}
env.canvas.width = 42
callback()
assert.deepEqual(env.draws, [])
assert.equal(env.canvas.width, 42)
env.art.emit('destroy')
})
}
for (const dropLateFrames of [false, true]) {
test(`MediaBunny video retains default/tolerance frame selection with dropLateFrames=${dropLateFrames}`, async () => {
const env = environment({ dropLateFrames, avSyncTolerance: 0.12 })
env.video.playbackRate = 2
env.video.videoIterator = iterator([frame('late', 0.8), frame('near', 0.97), frame('future', 1.2)])
await env.video.updateNextFrame(env.video.asyncId)
assert.deepEqual(env.draws, dropLateFrames ? ['near'] : ['late', 'near'])
assert.equal(env.video.nextFrame.canvas.name, 'future')
env.art.emit('destroy')
})
}
test('MediaBunny waiting listener pause prevents a new RAF', () => {
const env = environment()
env.video.duration = 8
env.video.events.addEventListener('waiting', () => env.video.stop())
env.video.render()
assert.equal(env.frames.size, 0)
env.art.emit('destroy')
})
test('MediaBunny stalled canplay listener pause suppresses playing and another RAF', () => {
const env = environment()
env.video.stalled = true
env.video.nextFrame = frame('due')
env.video.events.addEventListener('canplay', () => env.video.stop())
env.video.render()
assert.equal(env.emitted.some(event => event.name === 'video:playing'), false)
assert.equal(env.frames.size, 0)
env.art.emit('destroy')
})
test('MediaBunny seek during pending capability detection preserves the current load metadata', async () => {
const env = environment()
const capability = deferred()
let metadata = 0
const loading = env.video.load({ input: {}, videoTrack: { codec: 'avc', canDecode: () => capability.promise }, duration: 8 }, () => metadata++)
await flush()
const seeking = env.video.seek(3)
capability.resolve(false)
await Promise.all([loading, seeking])
assert.equal(metadata, 1)
assert.equal(env.video.duration, 8)
env.art.emit('destroy')
})
test('MediaBunny new source cancels a pending seek before its own track selection finishes', async () => {
const env = environment()
const pending = deferred()
let reads = 0
env.video.videoSink = { canvases: () => ({ next: async () => ++reads === 1 ? pending.promise : item(frame('second')), return: async () => item() }) }
const seeking = env.canvas.engine.seek(2)
await flush()
const source = deferred()
env.canvas.engine.performLoad = () => source.promise
const loading = env.canvas.engine.load('new.mp4')
pending.resolve(item(frame('old-source')))
await seeking
await flush()
assert.deepEqual(env.draws, [])
source.resolve()
await loading
env.art.emit('destroy')
})
test('MediaBunny paused seek still renders its requested first frame', async () => {
const env = environment()
env.video.stop()
env.video.videoSink = { canvases: () => iterator([frame('target'), frame('next')]) }
await env.canvas.engine.seek(3)
assert.deepEqual(env.draws, ['target'])
assert.equal(env.canvas.paused, true)
assert.equal(env.frames.size, 0)
env.art.emit('destroy')
})
test('MediaBunny late seek failure cannot release the current iterator', async () => {
const env = environment()
const pending = deferred()
const latest = iterator([frame('current'), frame('next')])
env.video.videoSink = { canvases: time => time === 1 ? { next: () => pending.promise, return: async () => item() } : latest }
const old = env.video.seek(1)
await flush()
await env.video.seek(2)
pending.reject(new Error('old decoder failed'))
await old
assert.equal(env.video.videoIterator, latest)
assert.equal(latest.returns, 0)
assert.deepEqual(env.draws, ['current'])
env.art.emit('destroy')
})
test('MediaBunny stale transparency rejection is observed after source replacement', async () => {
const env = environment()
const pending = deferred()
const loading = env.video.load({ input: {}, videoTrack: { codec: 'avc', canDecode: async () => true, canBeTransparent: () => pending.promise }, duration: 8 })
await flush()
await env.video.load({ input: {}, videoTrack: null, duration: 4 })
pending.reject(new Error('old capability failed'))
await loading
assert.equal(env.video.duration, 4)
env.art.emit('destroy')
})
test('MediaBunny old frame fetch completion cannot unlock a newer pending fetch', async () => {
const env = environment()
const old = deferred()
const latest = deferred()
env.video.videoIterator = { next: () => old.promise, return: async () => item() }
const reading = env.video.updateNextFrame(env.video.asyncId)
await flush()
env.video.stop()
env.video.videoIterator = { next: () => latest.promise, return: async () => item() }
env.video.start({ currentTime: 1 })
await flush()
old.resolve(item(frame('obsolete')))
await reading
assert.equal(env.video.isFetching, true)
latest.resolve(item(frame('future', 3)))
await flush()
assert.equal(env.video.isFetching, false)
assert.equal(env.video.nextFrame.canvas.name, 'future')
env.art.emit('destroy')
})
test('MediaBunny stop/start shares a pending read without concurrent iterator.next calls', async () => {
const env = environment()
const pending = deferred()
let reads = 0
env.video.videoIterator = { next: () => {
reads++
return pending.promise
}, return: async () => item() }
const old = env.video.updateNextFrame(env.video.asyncId)
await flush()
env.video.stop()
env.video.start({ currentTime: 1 })
await flush()
assert.equal(reads, 1)
pending.resolve(item(frame('future', 2)))
await old
await flush()
assert.equal(env.video.nextFrame.canvas.name, 'future')
assert.equal(reads, 1)
env.art.emit('destroy')
})
test('MediaBunny renderer observes decoder failure, stops and reports one media error', async () => {
const env = environment()
const failure = new Error('decode failed')
const active = iterator()
active.next = async () => {
throw failure
}
env.video.videoIterator = active
env.video.start({ currentTime: 1 })
await flush()
assert.deepEqual({ ...env.canvas.error }, { code: 4, message: failure.message })
assert.equal(env.emitted.filter(event => event.name === 'video:error').length, 1)
assert.equal(env.frames.size, 0)
assert.equal(active.returns, 1)
assert.equal(env.video.videoIterator, null)
env.art.emit('destroy')
})
test('MediaBunny explicit iterator release preserves rejection after detachment', async () => {
const env = environment()
const failure = new Error('release failed')
env.video.videoIterator = { return: async () => {
throw failure
} }
await assert.rejects(env.video.stopIterator(), error => error === failure)
assert.equal(env.video.videoIterator, null)
env.art.emit('destroy')
})
test('MediaBunny normal ended sequence remains timeupdate, ended, pause, canplay', () => {
const env = environment()
env.video.duration = 1
env.video.render()
assert.deepEqual(env.emitted.map(event => event.name), ['video:timeupdate', 'video:ended', 'video:pause', 'video:canplay'])
assert.equal(env.frames.size, 0)
env.art.emit('destroy')
})
test('MediaBunny ended listener restart suppresses old pause/canplay without canceling its new RAF', () => {
const env = environment()
env.video.duration = 1
env.video.events.addEventListener('ended', () => env.video.start({ currentTime: 0 }))
env.video.render()
assert.deepEqual(env.emitted.map(event => event.name), ['video:timeupdate', 'video:ended'])
assert.equal(env.frames.size, 1)
env.art.emit('destroy')
})
test('MediaBunny inactive instance cleanup cannot cancel another instance RAF id zero', () => {
const env = environment()
const other = new env.video.constructor({ canvas: { width: 0, height: 0 }, ctx: env.context, events: env.video.events })
env.video.start({ currentTime: 0 })
assert.equal(env.frames.size, 1)
other.destroy()
assert.equal(env.frames.size, 1)
env.art.emit('destroy')
})
test('MediaBunny decoder failure remains observable if audio cleanup throws', async () => {
const env = environment()
env.globals.console = { ...console, warn() {} }
env.canvas.engine.paused = false
env.canvas.engine.audio.pause = () => {
throw new Error('audio cleanup failed')
}
env.video.videoIterator = { next: async () => {
throw new Error('original decode failure')
}, return: async () => item() }
env.video.start({ currentTime: 1 })
await flush()
env.canvas.engine.audio.pause = () => {}
assert.equal(env.canvas.error.message, 'original decode failure')
assert.equal(env.canvas.paused, true)
assert.equal(env.frames.size, 0)
env.art.emit('destroy')
})
test('MediaBunny destroy observes iterator release failure and keeps terminal references cleared', async () => {
const env = environment()
const warnings = []
env.globals.console = { ...console, warn: (...values) => warnings.push(values) }
const failure = new Error('iterator return failed')
env.video.videoIterator = { return: async () => {
throw failure
} }
env.art.emit('destroy')
await flush()
assert.equal(warnings.length, 1)
assert.equal(warnings[0][1], failure)
assert.equal(env.video.videoIterator, null)
assert.equal(env.video.nextFrame, null)
assert.equal(env.video.input, null)
assert.equal(env.video.videoSink, null)
})
for (const ending of ['destroy', 'seek']) {
test(`MediaBunny pending second seek frame cannot overwrite the current image after ${ending}`, async () => {
const env = environment()
const second = deferred()
let reads = 0
const old = { next: async () => ++reads === 1 ? item(frame('old-first')) : second.promise, return: async () => item() }
env.video.videoSink = { canvases: time => time === 1 ? old : iterator([frame('new-first'), frame('new-second')]) }
const seeking = env.video.seek(1)
await flush()
assert.equal(reads, 2)
if (ending === 'destroy')
env.art.emit('destroy')
else await env.video.seek(2)
second.resolve(item(frame('old-second')))
await seeking
assert.deepEqual(env.draws, ending === 'destroy' ? [] : ['new-first'])
env.art.emit('destroy')
})
}
test('MediaBunny missing 2D context rejects loading with a descriptive error', async () => {
const env = environment()
env.video.ctx = null
await assert.rejects(env.video.load({ input: {}, videoTrack: null, duration: 8 }), /Canvas 2D context is unavailable/)
env.art.emit('destroy')
})
test('MediaBunny start retains the queued second frame until its clock time before fetching another', async () => {
const env = environment()
const frames = iterator([frame('third', 1)])
env.video.nextFrame = frame('second', 0.5)
env.video.videoIterator = frames
const clock = { currentTime: 0 }
env.video.start(clock)
await flush()
assert.equal(frames.reads, 0)
assert.equal(env.video.nextFrame.canvas.name, 'second')
clock.currentTime = 0.5
env.video.render()
await flush()
assert.deepEqual(env.draws, ['second'])
assert.equal(frames.reads, 1)
assert.equal(env.video.nextFrame.canvas.name, 'third')
env.art.emit('destroy')
})