fix(danmuku): [PKG-DANMUKU-MASK-LOAD-01] recover stalled-frame rows and remove relaxed placement backlog

This commit is contained in:
Harvey Zhao committed 2026-09-14 22:13:08 +08:00
1 parent 475d38043e
commit 314a1c3163
27 files changed
+14045 -195

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,h,p=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(p)return;p=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!p){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=r?.(m),p?h?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(p)return;const r=await a.text();if(!p)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),p)return void f();if(d=new Worker(c),p)return void f();d.onmessage=t=>{if(p||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class p{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,h={};h.top=a,h.left=s,h.height=r,h.width=o,h.right=d,h.speed=c,h.distance=l,h.time=n.$restTime,h.mode=n.mode,t.push(h)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,s=i(e);globalThis.postMessage({danmus:s,id:n})}let s=0;function a(t,{signal:a,onCancel:r}={}){return new Promise((o,l)=>{let d,h,c,p=!1;const u=++s,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(a));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==h){const n=h;h=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(p)return;p=!0;let i=arguments.length>1;a?.removeEventListener("abort",m),c?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!p){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}a?.aborted?m():(a?.addEventListener("abort",m),c=r?.(m),p?c?.():async function(){const s=a?await fetch(t,{signal:a}):await fetch(t);if(p)return;const r=await s.text();if(!p)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(h=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),p)return void f();if(d=new Worker(h),p)return void f();d.onmessage=t=>{if(p||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function h(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function c(t,e,i){const n=t.states[e]||[];for(let s=0;s<n.length;s++)i(n[s]);return n}class p{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const s=n.$ref.offsetTop,a=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=a+o,d=e-l,h=l/n.$restTime,c={};c.top=s,c.left=a,c.height=r,c.width=o,c.right=d,c.speed=h,c.distance=l,c.time=n.$restTime,c.mode=n.mode,t.push(c)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,s=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))s.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))a.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),h=(t,e,i)=>new Promise((n,s)=>{var a=t=>{try{o(i.next(t))}catch(e){s(e)}},r=t=>{try{o(i.throw(t))}catch(e){s(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,r);o((i=i.apply(t,e)).next())});function c(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function p(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:p(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,s)=>{let a,r,o,l=!1;const d=++f,c=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(a){const t=a;a=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let a=arguments.length>1;null==e||e.removeEventListener("abort",c),null==o||o();try{g()}catch(r){a||(i=r),a=!0}a?s(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?c():(null==e||e.addEventListener("abort",c),o=null==i?void 0:i(c),l?null==o||o():function(){return h(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${p.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(a=new Worker(r),l)return void g();a.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},a.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},a.onmessageerror=t=>v(n,t.error||t),a.postMessage({xml:n,id:d})}catch(s){v(n,s)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let s=0;s<n.length;s++)i(n[s]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const s=n.$ref.offsetTop,a=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=a+o,d=e-l,h=l/n.$restTime,c={};c.top=s,c.left=a,c.height=r,c.width=o,c.right=d,c.speed=h,c.distance=l,c.time=n.$restTime,c.mode=n.mode,t.push(c)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
+157 -3
View File
@@ -538,6 +538,155 @@ class Renderer {
this.nodes.clear();
}
}
function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }) {
const maxTop = clientHeight - marginBottom;
const danmus = visibles.filter((item) => item.mode === target.mode && item.top <= maxTop).sort((prev, next) => prev.top - next.top);
if (danmus.length === 0) {
if (target.mode === 2) {
return maxTop - target.height;
} else {
return marginTop;
}
}
danmus.unshift({
type: "top",
top: 0,
left: 0,
right: 0,
height: marginTop,
width: clientWidth,
speed: 0,
distance: clientWidth
});
danmus.push({
type: "bottom",
top: maxTop,
left: 0,
right: 0,
height: marginBottom,
width: clientWidth,
speed: 0,
distance: clientWidth
});
if (target.mode === 2) {
for (let index = danmus.length - 2; index >= 0; index -= 1) {
const item = danmus[index];
const prev = danmus[index + 1];
const itemBottom = item.top + item.height;
const diff = prev.top - itemBottom;
if (diff >= target.height) {
return prev.top - target.height;
}
}
} else {
for (let index = 1; index < danmus.length; index += 1) {
const item = danmus[index];
const prev = danmus[index - 1];
const prevBottom = prev.top + prev.height;
const diff = item.top - prevBottom;
if (diff >= target.height) {
return prevBottom;
}
}
}
const topMap = [];
for (let index = 1; index < danmus.length - 1; index += 1) {
const item = danmus[index];
if (topMap.length) {
const last = topMap[topMap.length - 1];
if (last[0].top === item.top) {
last.push(item);
} else {
topMap.push([item]);
}
} else {
topMap.push([item]);
}
}
if (antiOverlap) {
switch (target.mode) {
case 0: {
const result = topMap.find((list) => {
return list.every((danmu) => {
if (clientWidth < danmu.distance)
return false;
if (target.speed < danmu.speed)
return true;
const overlapTime = danmu.right / (target.speed - danmu.speed);
if (overlapTime > danmu.time)
return true;
return false;
});
});
return result && result[0] ? result[0].top : void 0;
}
// 静止弹幕没有重叠问题
case 1:
case 2:
return void 0;
}
} else {
switch (target.mode) {
case 0:
topMap.sort((prev, next) => {
const nextMinRight = Math.min(...next.map((item) => item.right));
const prevMinRight = Math.min(...prev.map((item) => item.right));
return nextMinRight * next.length - prevMinRight * prev.length;
});
break;
case 1:
case 2:
topMap.sort((prev, next) => {
const nextMaxWidth = Math.max(...next.map((item) => item.width));
const prevMaxWidth = Math.max(...prev.map((item) => item.width));
return prevMaxWidth * prev.length - nextMaxWidth * next.length;
});
break;
}
return topMap[0][0].top;
}
}
class SamplingWindow {
constructor() {
this.rows = /* @__PURE__ */ new Set();
this.waiting = /* @__PURE__ */ new Set();
this.visibility = () => this.clear();
}
capture(owner, rows, time) {
const doc = owner.$player.ownerDocument;
if (doc !== this.document) {
this.document?.removeEventListener("visibilitychange", this.visibility);
this.document = doc;
doc?.addEventListener("visibilitychange", this.visibility);
this.clear();
}
if (!Number.isFinite(time) || doc?.visibilityState === "hidden" || owner.art.video?.seeking) {
this.clear();
return rows;
}
const previousTime = this.time;
const previousRows = this.rows;
const previousWaiting = this.waiting;
this.time = time;
this.rows = new Set(rows);
this.waiting = new Set(owner.states.wait);
if (previousTime === void 0 || time <= previousTime)
return rows;
const missed = owner.states.wait.filter((row) => previousWaiting.has(row) && !previousRows.has(row) && row.time >= previousTime - 0.1 && row.time < time - 0.1);
for (const row of missed) this.rows.add(row);
return [...rows.filter((row) => row.$state === "ready"), ...missed, ...rows.filter((row) => row.$state !== "ready")];
}
clear() {
this.time = void 0;
this.rows.clear();
this.waiting.clear();
}
destroy() {
this.document?.removeEventListener("visibilitychange", this.visibility);
this.document = void 0;
this.clear();
}
}
class SchedulingBuffer {
constructor() {
this.pending = /* @__PURE__ */ new Set();
@@ -584,6 +733,7 @@ class Scheduler {
this.fault = false;
this.failedItems = /* @__PURE__ */ new Set();
this.buffer = new SchedulingBuffer();
this.sampling = new SamplingWindow();
}
active(operation) {
const owner = this.owner;
@@ -607,6 +757,7 @@ class Scheduler {
this.owner.workerClient?.cancel();
this.failedItems.clear();
this.buffer.clear();
this.sampling.clear();
}
report(error) {
try {
@@ -655,10 +806,11 @@ class Scheduler {
if (danmu.$restTime <= 0)
owner.makeWait(danmu);
});
const time = owner.art.currentTime;
const readys2 = owner.readys;
if (generation !== this.generation)
return;
this.buffer.capture(readys2, this.failedItems);
this.buffer.capture(this.sampling.capture(owner, readys2, time), this.failedItems);
}
} catch (error) {
if (generation === this.generation)
@@ -718,7 +870,7 @@ class Scheduler {
danmu.$lastStartTime = Date.now();
danmu.$restTime = owner.speed;
const distance = clientWidth + ref.clientWidth;
const reply = await operation.wait(owner.postMessage({
const request = {
type: "getDanmuTop",
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
@@ -728,7 +880,8 @@ class Scheduler {
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom,
marginTop: owner.marginTop
}));
};
const reply = request.antiOverlap ? await operation.wait(owner.postMessage(request)) : { result: getDanmuTop(request) };
const top = typeof reply === "symbol" ? void 0 : reply.result;
if (!this.active(operation) || danmu.$ref !== ref)
return;
@@ -747,6 +900,7 @@ class Scheduler {
destroy() {
this.closed = true;
this.invalidate();
this.sampling.destroy();
}
}
let nextRequest = 0;
@@ -534,6 +534,155 @@ var artplayerPluginDanmuku = (function() {
this.nodes.clear();
}
}
function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }) {
const maxTop = clientHeight - marginBottom;
const danmus = visibles.filter((item) => item.mode === target.mode && item.top <= maxTop).sort((prev, next) => prev.top - next.top);
if (danmus.length === 0) {
if (target.mode === 2) {
return maxTop - target.height;
} else {
return marginTop;
}
}
danmus.unshift({
type: "top",
top: 0,
left: 0,
right: 0,
height: marginTop,
width: clientWidth,
speed: 0,
distance: clientWidth
});
danmus.push({
type: "bottom",
top: maxTop,
left: 0,
right: 0,
height: marginBottom,
width: clientWidth,
speed: 0,
distance: clientWidth
});
if (target.mode === 2) {
for (let index = danmus.length - 2; index >= 0; index -= 1) {
const item = danmus[index];
const prev = danmus[index + 1];
const itemBottom = item.top + item.height;
const diff = prev.top - itemBottom;
if (diff >= target.height) {
return prev.top - target.height;
}
}
} else {
for (let index = 1; index < danmus.length; index += 1) {
const item = danmus[index];
const prev = danmus[index - 1];
const prevBottom = prev.top + prev.height;
const diff = item.top - prevBottom;
if (diff >= target.height) {
return prevBottom;
}
}
}
const topMap = [];
for (let index = 1; index < danmus.length - 1; index += 1) {
const item = danmus[index];
if (topMap.length) {
const last = topMap[topMap.length - 1];
if (last[0].top === item.top) {
last.push(item);
} else {
topMap.push([item]);
}
} else {
topMap.push([item]);
}
}
if (antiOverlap) {
switch (target.mode) {
case 0: {
const result = topMap.find((list) => {
return list.every((danmu) => {
if (clientWidth < danmu.distance)
return false;
if (target.speed < danmu.speed)
return true;
const overlapTime = danmu.right / (target.speed - danmu.speed);
if (overlapTime > danmu.time)
return true;
return false;
});
});
return result && result[0] ? result[0].top : void 0;
}
// 静止弹幕没有重叠问题
case 1:
case 2:
return void 0;
}
} else {
switch (target.mode) {
case 0:
topMap.sort((prev, next) => {
const nextMinRight = Math.min(...next.map((item) => item.right));
const prevMinRight = Math.min(...prev.map((item) => item.right));
return nextMinRight * next.length - prevMinRight * prev.length;
});
break;
case 1:
case 2:
topMap.sort((prev, next) => {
const nextMaxWidth = Math.max(...next.map((item) => item.width));
const prevMaxWidth = Math.max(...prev.map((item) => item.width));
return prevMaxWidth * prev.length - nextMaxWidth * next.length;
});
break;
}
return topMap[0][0].top;
}
}
class SamplingWindow {
constructor() {
this.rows = /* @__PURE__ */ new Set();
this.waiting = /* @__PURE__ */ new Set();
this.visibility = () => this.clear();
}
capture(owner, rows, time) {
const doc = owner.$player.ownerDocument;
if (doc !== this.document) {
this.document?.removeEventListener("visibilitychange", this.visibility);
this.document = doc;
doc?.addEventListener("visibilitychange", this.visibility);
this.clear();
}
if (!Number.isFinite(time) || doc?.visibilityState === "hidden" || owner.art.video?.seeking) {
this.clear();
return rows;
}
const previousTime = this.time;
const previousRows = this.rows;
const previousWaiting = this.waiting;
this.time = time;
this.rows = new Set(rows);
this.waiting = new Set(owner.states.wait);
if (previousTime === void 0 || time <= previousTime)
return rows;
const missed = owner.states.wait.filter((row) => previousWaiting.has(row) && !previousRows.has(row) && row.time >= previousTime - 0.1 && row.time < time - 0.1);
for (const row of missed) this.rows.add(row);
return [...rows.filter((row) => row.$state === "ready"), ...missed, ...rows.filter((row) => row.$state !== "ready")];
}
clear() {
this.time = void 0;
this.rows.clear();
this.waiting.clear();
}
destroy() {
this.document?.removeEventListener("visibilitychange", this.visibility);
this.document = void 0;
this.clear();
}
}
class SchedulingBuffer {
constructor() {
this.pending = /* @__PURE__ */ new Set();
@@ -580,6 +729,7 @@ var artplayerPluginDanmuku = (function() {
this.fault = false;
this.failedItems = /* @__PURE__ */ new Set();
this.buffer = new SchedulingBuffer();
this.sampling = new SamplingWindow();
}
active(operation) {
const owner = this.owner;
@@ -603,6 +753,7 @@ var artplayerPluginDanmuku = (function() {
this.owner.workerClient?.cancel();
this.failedItems.clear();
this.buffer.clear();
this.sampling.clear();
}
report(error) {
try {
@@ -651,10 +802,11 @@ var artplayerPluginDanmuku = (function() {
if (danmu.$restTime <= 0)
owner.makeWait(danmu);
});
const time = owner.art.currentTime;
const readys2 = owner.readys;
if (generation !== this.generation)
return;
this.buffer.capture(readys2, this.failedItems);
this.buffer.capture(this.sampling.capture(owner, readys2, time), this.failedItems);
}
} catch (error) {
if (generation === this.generation)
@@ -714,7 +866,7 @@ var artplayerPluginDanmuku = (function() {
danmu.$lastStartTime = Date.now();
danmu.$restTime = owner.speed;
const distance = clientWidth + ref.clientWidth;
const reply = await operation.wait(owner.postMessage({
const request = {
type: "getDanmuTop",
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
@@ -724,7 +876,8 @@ var artplayerPluginDanmuku = (function() {
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom,
marginTop: owner.marginTop
}));
};
const reply = request.antiOverlap ? await operation.wait(owner.postMessage(request)) : { result: getDanmuTop(request) };
const top = typeof reply === "symbol" ? void 0 : reply.result;
if (!this.active(operation) || danmu.$ref !== ref)
return;
@@ -743,6 +896,7 @@ var artplayerPluginDanmuku = (function() {
destroy() {
this.closed = true;
this.invalidate();
this.sampling.destroy();
}
}
let nextRequest = 0;
@@ -2,10 +2,13 @@
The native combination suite includes both frozen/current Danmuku with three core
versions and a bounded 20 rows/s load after model readiness. Model output must
continue during that load. Current Chromium/WebKit delivery failures block the
combination gate under PKG-DANMUKU-MASK-LOAD-01; Firefox passes do not close them.
See `refactor/changes/2026-09-14-PKG-DANMUKU-08-combined-load.md` for exact source
inputs, historical observations and the still-unproven performance cause.
continue during that load. PKG-DANMUKU-MASK-LOAD-01 addresses Chromium/WebKit
delivery loss in the Danmuku scheduler; this does not replace or patch the Mask
model, SDK, backend, video dimensions or inference loop. The optional Chromium
`ARTPLAYER_MASK_PROFILE=1` capture records actual SDK image conversion cost.
Keep profiling separate from ordinary timing acceptance. See the original
failures in `refactor/changes/2026-09-14-PKG-DANMUKU-08-combined-load.md` and the
repair record in `refactor/changes/2026-09-14-PKG-DANMUKU-MASK-LOAD-01-scheduling.md`.
The compatibility baseline is the actual npm 1.1.0 package and the earlier
1.0.0 export shape. Sources and historical failure probes are recorded in
@@ -21,8 +21,10 @@ old-plugin combinations, combined-load performance and devices remain task 08/09
The actual Mask combination now also tests frozen 5.3.0 and a 20 rows/s post-model
load. `test/helpers/danmuku-combination-load.js` owns only test RAF/listeners and
retains observations after failed assertions. Candidate complete delivery remains
mandatory. Chromium/WebKit failures are tracked by DANMUKU-MASK-LOAD-01 and
PKG-DANMUKU-MASK-LOAD-01; ordinary playback or Firefox passes cannot close them.
mandatory. PKG-DANMUKU-MASK-LOAD-01 addresses the original Chromium/WebKit loss
with continuous-playback sampling and relaxed placement. Preserve the original
failed reports and rerun the complete model/core/format matrix after changes;
ordinary playback or Firefox passes alone cannot close that gate.
`danmuku-fullscreen.spec.js` tests all old/current core and plugin pairs in real
document and CSS web fullscreen, with three fresh player lifetimes per case.
@@ -57,10 +59,12 @@ historical declarations, earlier exports and failure evidence are indexed in
| `src/bilibili.ts` | Fetch and response text, one parser Worker per request, fallback, settlement and Blob URL cleanup |
| `src/scheduler.ts` | One sampling RAF and one serial asynchronous batch, cancellation generations, preparation ownership and failure recovery |
| `src/scheduling-buffer.ts` | Identity-based observations and current-batch reservations; ready-before-wait selection without public state mutation |
| `src/sampling-window.ts` | Continuous media-time observations, stalled-frame recovery and document visibility ownership |
| `src/renderer.ts` | Owned node allocation, preparation, geometry snapshots, placement, pause/resume styling and disposal |
| `src/queue.ts` | Ordered state pools, eligibility and state transitions |
| `src/worker-client.ts` | Track Worker lifecycle, a shared response dispatcher, unique request IDs and pending requests |
| `src/worker.ts` | Track placement Worker; a separate protocol from the XML parser Worker |
| `src/placement.ts` | Shared pure legacy track geometry, used by relaxed placement and the Worker |
| `src/worker.ts` | Placement Worker message adapter; a separate protocol from the XML parser Worker |
| `src/setting.ts` | Settings coordinator, configuration controls, mount and fullscreen layout |
| `src/setting-template.ts` | Existing template HTML and static icon values |
| `src/setting-slider.ts` | Slider index, pointer and rotation behavior |
@@ -71,7 +75,7 @@ historical declarations, earlier exports and failure evidence are indexed in
| `src/heatmap-sampling.ts` | Sorted-time bin counts with historical numeric boundaries |
| `src/heatmap-geometry.ts` | Legacy point normalization, caller-visible point writes and SVG curve calculation |
All 20 owned runtime modules use TypeScript. Their responsibilities remain
All owned runtime modules use TypeScript. Their responsibilities remain
separate from public declaration compatibility and from release acceptance.
The following files contain types only:
@@ -191,8 +195,13 @@ retains the current-time +/- 0.1 second window and state-pool order. Keep the
existing track algorithm and geometry fields until separately reviewed.
RAF sampling and visible lifetime maintenance continue while `beforeVisible` or
the Worker is pending. The scheduling buffer stores only actually observed row
references; it does not sweep elapsed time or modify public wait/ready states.
the Worker is pending. Public `readys` remains the instantaneous +/- 0.1 second
query. The sampling window also recovers rows crossed between consecutive
forward media samples, but only if they were already waiting at the earlier
sample and were not already selected there. It does not replay newly appended
past rows or retry a false callback merely because time crossed that row.
The scheduling buffer stores the resulting row references without modifying
public wait/ready states.
The dispatcher remains serial. Reserve every row in its batch until completion,
so a rejected placement cannot repeatedly jump ahead of later rows in that batch.
At the next RAF, capture new observations before selecting ready rows followed by
@@ -200,14 +209,24 @@ wait rows, retaining capture order within each group. Identity sets prevent the
same observed row from entering both the current and pending batch. Cancellation
clears both sets; an old operation's finally must not clear a newer batch.
This fixes missed eligibility during asynchronous preparation, not timestamps
crossed while the entire main thread is blocked. The native timing diagnostic
keeps the unchanged published miss visible beside the candidate regression and
records CPU-blocked frame gaps without calling them successful load acceptance.
Sampling still scans the existing state pools on each active frame. Buffer storage
is bounded by the distinct observed queue rows, not an arbitrary drop limit; this
does not establish a throughput or memory improvement. Measure dense queues and
long asynchronous waits in the separate sustained-load acceptance task.
The sampling window clears on scheduler invalidation, seeking, backward or
invalid media time, hidden documents and document changes. It owns exactly one
visibility listener on the player's current document, releases it on adoption,
and removes it on destroy. Visibility changes clear catch-up history without
cancelling a running user callback. The next visible sample starts a new window.
This extends PKG-DANMUKU-12 to cover main-thread gaps during continuous playback;
it is not a promise to replay comments across pause, seek or background periods.
Sampling still scans existing state pools; retained identities are bounded by
queue rows rather than an arbitrary drop limit.
After the serial `beforeVisible` callback, `antiOverlap: false` uses the shared
placement function directly, avoiding a Worker round trip for every row while
an actual segmentation model competes for the main thread. `antiOverlap: true`
and the internal `postMessage` helper retain the Worker protocol. Track geometry,
default options, visible event order, generation checks and visible lifetime
remain unchanged. Do not duplicate the geometry in either caller or bypass the
callback cancellation guard. Native model loads and timing diagnostics complement
the controlled scheduling tests; they do not prove unlimited throughput.
Pause, reset, successful replacement input commit, hide, seeking, destroy and replacing the
`beforeVisible` callback invalidate unfinished preparation. Cancellation races
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,h,p=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(p)return;p=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!p){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=r?.(m),p?h?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(p)return;const r=await a.text();if(!p)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),p)return void f();if(d=new Worker(c),p)return void f();d.onmessage=t=>{if(p||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class p{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,h={};h.top=a,h.left=s,h.height=r,h.width=o,h.right=d,h.speed=c,h.distance=l,h.time=n.$restTime,h.mode=n.mode,t.push(h)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,s=i(e);globalThis.postMessage({danmus:s,id:n})}let s=0;function a(t,{signal:a,onCancel:r}={}){return new Promise((o,l)=>{let d,h,c,p=!1;const u=++s,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(a));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==h){const n=h;h=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(p)return;p=!0;let i=arguments.length>1;a?.removeEventListener("abort",m),c?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!p){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}a?.aborted?m():(a?.addEventListener("abort",m),c=r?.(m),p?c?.():async function(){const s=a?await fetch(t,{signal:a}):await fetch(t);if(p)return;const r=await s.text();if(!p)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(h=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),p)return void f();if(d=new Worker(h),p)return void f();d.onmessage=t=>{if(p||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function h(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function c(t,e,i){const n=t.states[e]||[];for(let s=0;s<n.length;s++)i(n[s]);return n}class p{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const s=n.$ref.offsetTop,a=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=a+o,d=e-l,h=l/n.$restTime,c={};c.top=s,c.left=a,c.height=r,c.width=o,c.right=d,c.speed=h,c.distance=l,c.time=n.$restTime,c.mode=n.mode,t.push(c)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,s=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))s.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))a.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),h=(t,e,i)=>new Promise((n,s)=>{var a=t=>{try{o(i.next(t))}catch(e){s(e)}},r=t=>{try{o(i.throw(t))}catch(e){s(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,r);o((i=i.apply(t,e)).next())});function c(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function p(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:p(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,s)=>{let a,r,o,l=!1;const d=++f,c=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(a){const t=a;a=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let a=arguments.length>1;null==e||e.removeEventListener("abort",c),null==o||o();try{g()}catch(r){a||(i=r),a=!0}a?s(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?c():(null==e||e.addEventListener("abort",c),o=null==i?void 0:i(c),l?null==o||o():function(){return h(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${p.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(a=new Worker(r),l)return void g();a.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},a.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},a.onmessageerror=t=>v(n,t.error||t),a.postMessage({xml:n,id:d})}catch(s){v(n,s)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let s=0;s<n.length;s++)i(n[s]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const s=n.$ref.offsetTop,a=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=a+o,d=e-l,h=l/n.$restTime,c={};c.top=s,c.left=a,c.height=r,c.width=o,c.right=d,c.speed=h,c.distance=l,c.time=n.$restTime,c.mode=n.mode,t.push(c)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
@@ -538,6 +538,155 @@ class Renderer {
this.nodes.clear();
}
}
function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }) {
const maxTop = clientHeight - marginBottom;
const danmus = visibles.filter((item) => item.mode === target.mode && item.top <= maxTop).sort((prev, next) => prev.top - next.top);
if (danmus.length === 0) {
if (target.mode === 2) {
return maxTop - target.height;
} else {
return marginTop;
}
}
danmus.unshift({
type: "top",
top: 0,
left: 0,
right: 0,
height: marginTop,
width: clientWidth,
speed: 0,
distance: clientWidth
});
danmus.push({
type: "bottom",
top: maxTop,
left: 0,
right: 0,
height: marginBottom,
width: clientWidth,
speed: 0,
distance: clientWidth
});
if (target.mode === 2) {
for (let index = danmus.length - 2; index >= 0; index -= 1) {
const item = danmus[index];
const prev = danmus[index + 1];
const itemBottom = item.top + item.height;
const diff = prev.top - itemBottom;
if (diff >= target.height) {
return prev.top - target.height;
}
}
} else {
for (let index = 1; index < danmus.length; index += 1) {
const item = danmus[index];
const prev = danmus[index - 1];
const prevBottom = prev.top + prev.height;
const diff = item.top - prevBottom;
if (diff >= target.height) {
return prevBottom;
}
}
}
const topMap = [];
for (let index = 1; index < danmus.length - 1; index += 1) {
const item = danmus[index];
if (topMap.length) {
const last = topMap[topMap.length - 1];
if (last[0].top === item.top) {
last.push(item);
} else {
topMap.push([item]);
}
} else {
topMap.push([item]);
}
}
if (antiOverlap) {
switch (target.mode) {
case 0: {
const result = topMap.find((list) => {
return list.every((danmu) => {
if (clientWidth < danmu.distance)
return false;
if (target.speed < danmu.speed)
return true;
const overlapTime = danmu.right / (target.speed - danmu.speed);
if (overlapTime > danmu.time)
return true;
return false;
});
});
return result && result[0] ? result[0].top : void 0;
}
// 静止弹幕没有重叠问题
case 1:
case 2:
return void 0;
}
} else {
switch (target.mode) {
case 0:
topMap.sort((prev, next) => {
const nextMinRight = Math.min(...next.map((item) => item.right));
const prevMinRight = Math.min(...prev.map((item) => item.right));
return nextMinRight * next.length - prevMinRight * prev.length;
});
break;
case 1:
case 2:
topMap.sort((prev, next) => {
const nextMaxWidth = Math.max(...next.map((item) => item.width));
const prevMaxWidth = Math.max(...prev.map((item) => item.width));
return prevMaxWidth * prev.length - nextMaxWidth * next.length;
});
break;
}
return topMap[0][0].top;
}
}
class SamplingWindow {
constructor() {
this.rows = /* @__PURE__ */ new Set();
this.waiting = /* @__PURE__ */ new Set();
this.visibility = () => this.clear();
}
capture(owner, rows, time) {
const doc = owner.$player.ownerDocument;
if (doc !== this.document) {
this.document?.removeEventListener("visibilitychange", this.visibility);
this.document = doc;
doc?.addEventListener("visibilitychange", this.visibility);
this.clear();
}
if (!Number.isFinite(time) || doc?.visibilityState === "hidden" || owner.art.video?.seeking) {
this.clear();
return rows;
}
const previousTime = this.time;
const previousRows = this.rows;
const previousWaiting = this.waiting;
this.time = time;
this.rows = new Set(rows);
this.waiting = new Set(owner.states.wait);
if (previousTime === void 0 || time <= previousTime)
return rows;
const missed = owner.states.wait.filter((row) => previousWaiting.has(row) && !previousRows.has(row) && row.time >= previousTime - 0.1 && row.time < time - 0.1);
for (const row of missed) this.rows.add(row);
return [...rows.filter((row) => row.$state === "ready"), ...missed, ...rows.filter((row) => row.$state !== "ready")];
}
clear() {
this.time = void 0;
this.rows.clear();
this.waiting.clear();
}
destroy() {
this.document?.removeEventListener("visibilitychange", this.visibility);
this.document = void 0;
this.clear();
}
}
class SchedulingBuffer {
constructor() {
this.pending = /* @__PURE__ */ new Set();
@@ -584,6 +733,7 @@ class Scheduler {
this.fault = false;
this.failedItems = /* @__PURE__ */ new Set();
this.buffer = new SchedulingBuffer();
this.sampling = new SamplingWindow();
}
active(operation) {
const owner = this.owner;
@@ -607,6 +757,7 @@ class Scheduler {
this.owner.workerClient?.cancel();
this.failedItems.clear();
this.buffer.clear();
this.sampling.clear();
}
report(error) {
try {
@@ -655,10 +806,11 @@ class Scheduler {
if (danmu.$restTime <= 0)
owner.makeWait(danmu);
});
const time = owner.art.currentTime;
const readys2 = owner.readys;
if (generation !== this.generation)
return;
this.buffer.capture(readys2, this.failedItems);
this.buffer.capture(this.sampling.capture(owner, readys2, time), this.failedItems);
}
} catch (error) {
if (generation === this.generation)
@@ -718,7 +870,7 @@ class Scheduler {
danmu.$lastStartTime = Date.now();
danmu.$restTime = owner.speed;
const distance = clientWidth + ref.clientWidth;
const reply = await operation.wait(owner.postMessage({
const request = {
type: "getDanmuTop",
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
@@ -728,7 +880,8 @@ class Scheduler {
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom,
marginTop: owner.marginTop
}));
};
const reply = request.antiOverlap ? await operation.wait(owner.postMessage(request)) : { result: getDanmuTop(request) };
const top = typeof reply === "symbol" ? void 0 : reply.result;
if (!this.active(operation) || danmu.$ref !== ref)
return;
@@ -747,6 +900,7 @@ class Scheduler {
destroy() {
this.closed = true;
this.invalidate();
this.sampling.destroy();
}
}
let nextRequest = 0;
@@ -0,0 +1,139 @@
import type { PlacementRequest, PlacementRow, VisibleDanmu } from './worker-types'
export function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }: PlacementRequest): number | undefined {
// 弹幕最大高度
const maxTop = clientHeight - marginBottom
// 过滤同模式的弹幕,即每种模式各不影响
const danmus: PlacementRow[] = visibles
.filter(item => item.mode === target.mode && item.top <= maxTop)
.sort((prev, next) => prev.top - next.top)
// 如果没有同模式的弹幕,直接返回
if (danmus.length === 0) {
if (target.mode === 2) {
return maxTop - target.height
}
else {
return marginTop
}
}
// 上下各加一个虚拟弹幕,方便计算
danmus.unshift({
type: 'top',
top: 0,
left: 0,
right: 0,
height: marginTop,
width: clientWidth,
speed: 0,
distance: clientWidth,
})
danmus.push({
type: 'bottom',
top: maxTop,
left: 0,
right: 0,
height: marginBottom,
width: clientWidth,
speed: 0,
distance: clientWidth,
})
// 查找是否有多余的缝隙足以容纳当前弹幕
if (target.mode === 2) {
// 倒序查找
for (let index = danmus.length - 2; index >= 0; index -= 1) {
const item = danmus[index]!
const prev = danmus[index + 1]!
const itemBottom = item.top + item.height
const diff = prev.top - itemBottom
if (diff >= target.height) {
return prev.top - target.height
}
}
}
else {
// 顺序查找
for (let index = 1; index < danmus.length; index += 1) {
const item = danmus[index]!
const prev = danmus[index - 1]!
const prevBottom = prev.top + prev.height
const diff = item.top - prevBottom
if (diff >= target.height) {
return prevBottom
}
}
}
const topMap: VisibleDanmu[][] = []
for (let index = 1; index < danmus.length - 1; index += 1) {
// The loop excludes the two virtual boundary rows inserted above.
const item = danmus[index] as VisibleDanmu
if (topMap.length) {
const last = topMap[topMap.length - 1]!
if (last[0]!.top === item.top) {
last.push(item)
}
else {
topMap.push([item])
}
}
else {
topMap.push([item])
}
}
if (antiOverlap) {
switch (target.mode) {
case 0: {
const result = topMap.find((list) => {
return list.every((danmu) => {
if (clientWidth < danmu.distance)
return false
if (target.speed < danmu.speed)
return true
const overlapTime = danmu.right / (target.speed - danmu.speed)
if (overlapTime > danmu.time)
return true
return false
})
})
return result && result[0] ? result[0].top : undefined
}
// 静止弹幕没有重叠问题
case 1:
case 2:
return undefined
default:
break
}
}
else {
switch (target.mode) {
case 0:
topMap.sort((prev, next) => {
const nextMinRight = Math.min(...next.map(item => item.right))
const prevMinRight = Math.min(...prev.map(item => item.right))
return nextMinRight * next.length - prevMinRight * prev.length
})
break
case 1:
case 2:
topMap.sort((prev, next) => {
const nextMaxWidth = Math.max(...next.map(item => item.width))
const prevMaxWidth = Math.max(...prev.map(item => item.width))
return prevMaxWidth * prev.length - nextMaxWidth * next.length
})
break
default:
break
}
return topMap[0]![0]!.top
}
}
@@ -0,0 +1,49 @@
import type Danmuku from './danmuku'
import type { DanmuItem } from './types'
// Public readys remains a point-in-time query; automatic playback can bridge a stalled frame.
export default class SamplingWindow {
private time: number | undefined
private rows = new Set<DanmuItem>()
private waiting = new Set<DanmuItem>()
private document: Document | undefined
private visibility = () => this.clear()
capture(owner: Danmuku, rows: DanmuItem[], time: number) {
const doc = owner.$player.ownerDocument
if (doc !== this.document) {
this.document?.removeEventListener('visibilitychange', this.visibility)
this.document = doc
doc?.addEventListener('visibilitychange', this.visibility)
this.clear()
}
if (!Number.isFinite(time) || doc?.visibilityState === 'hidden' || owner.art.video?.seeking) {
this.clear()
return rows
}
const previousTime = this.time
const previousRows = this.rows
const previousWaiting = this.waiting
this.time = time
this.rows = new Set(rows)
this.waiting = new Set(owner.states.wait)
if (previousTime === undefined || time <= previousTime)
return rows
const missed = owner.states.wait.filter(row => previousWaiting.has(row) && !previousRows.has(row)
&& row.time >= previousTime - 0.1 && row.time < time - 0.1)
for (const row of missed) this.rows.add(row)
return [...rows.filter(row => row.$state === 'ready'), ...missed, ...rows.filter(row => row.$state !== 'ready')]
}
clear() {
this.time = undefined
this.rows.clear()
this.waiting.clear()
}
destroy() {
this.document?.removeEventListener('visibilitychange', this.visibility)
this.document = undefined
this.clear()
}
}
@@ -1,5 +1,8 @@
import type Danmuku from './danmuku'
import type { DanmuItem } from './types'
import type { PlacementRequest } from './worker-types'
import { getDanmuTop } from './placement'
import SamplingWindow from './sampling-window'
import SchedulingBuffer from './scheduling-buffer'
const cancelled = Symbol('cancelled danmuku frame')
@@ -23,6 +26,7 @@ export default class Scheduler {
declare fault: boolean
declare failedItems: Set<DanmuItem>
declare buffer: SchedulingBuffer
declare sampling: SamplingWindow
constructor(owner: Danmuku) {
this.owner = owner
@@ -35,6 +39,7 @@ export default class Scheduler {
this.fault = false
this.failedItems = new Set()
this.buffer = new SchedulingBuffer()
this.sampling = new SamplingWindow()
}
active(operation: FrameOperation) {
@@ -62,6 +67,7 @@ export default class Scheduler {
this.owner.workerClient?.cancel()
this.failedItems.clear()
this.buffer.clear()
this.sampling.clear()
}
report(error: unknown) {
@@ -116,10 +122,11 @@ export default class Scheduler {
if (danmu.$restTime <= 0)
owner.makeWait(danmu)
})
const time = owner.art.currentTime
const readys = owner.readys
if (generation !== this.generation)
return
this.buffer.capture(readys, this.failedItems)
this.buffer.capture(this.sampling.capture(owner, readys, time), this.failedItems)
}
}
catch (error) {
@@ -183,7 +190,7 @@ export default class Scheduler {
danmu.$lastStartTime = Date.now()
danmu.$restTime = owner.speed
const distance = clientWidth + ref.clientWidth
const reply = await operation.wait(owner.postMessage({
const request: PlacementRequest = {
type: 'getDanmuTop',
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
@@ -193,7 +200,12 @@ export default class Scheduler {
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom as number,
marginTop: owner.marginTop,
}))
}
// Relaxed placement is small and deterministic; avoid a Worker round trip per row.
// Collision-sensitive placement and the internal postMessage API still use the Worker.
const reply = request.antiOverlap
? await operation.wait(owner.postMessage(request))
: { result: getDanmuTop(request) }
const top = typeof reply === 'symbol' ? undefined : reply.result
if (!this.active(operation) || danmu.$ref !== ref)
return
@@ -215,5 +227,6 @@ export default class Scheduler {
destroy() {
this.closed = true
this.invalidate()
this.sampling.destroy()
}
}
+2 -139
View File
@@ -1,142 +1,5 @@
import type { PlacementMessage, PlacementReply, PlacementRequest, PlacementRow, VisibleDanmu } from './worker-types'
function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }: PlacementRequest): number | undefined {
// 弹幕最大高度
const maxTop = clientHeight - marginBottom
// 过滤同模式的弹幕,即每种模式各不影响
const danmus: PlacementRow[] = visibles
.filter(item => item.mode === target.mode && item.top <= maxTop)
.sort((prev, next) => prev.top - next.top)
// 如果没有同模式的弹幕,直接返回
if (danmus.length === 0) {
if (target.mode === 2) {
return maxTop - target.height
}
else {
return marginTop
}
}
// 上下各加一个虚拟弹幕,方便计算
danmus.unshift({
type: 'top',
top: 0,
left: 0,
right: 0,
height: marginTop,
width: clientWidth,
speed: 0,
distance: clientWidth,
})
danmus.push({
type: 'bottom',
top: maxTop,
left: 0,
right: 0,
height: marginBottom,
width: clientWidth,
speed: 0,
distance: clientWidth,
})
// 查找是否有多余的缝隙足以容纳当前弹幕
if (target.mode === 2) {
// 倒序查找
for (let index = danmus.length - 2; index >= 0; index -= 1) {
const item = danmus[index]!
const prev = danmus[index + 1]!
const itemBottom = item.top + item.height
const diff = prev.top - itemBottom
if (diff >= target.height) {
return prev.top - target.height
}
}
}
else {
// 顺序查找
for (let index = 1; index < danmus.length; index += 1) {
const item = danmus[index]!
const prev = danmus[index - 1]!
const prevBottom = prev.top + prev.height
const diff = item.top - prevBottom
if (diff >= target.height) {
return prevBottom
}
}
}
const topMap: VisibleDanmu[][] = []
for (let index = 1; index < danmus.length - 1; index += 1) {
// The loop excludes the two virtual boundary rows inserted above.
const item = danmus[index] as VisibleDanmu
if (topMap.length) {
const last = topMap[topMap.length - 1]!
if (last[0]!.top === item.top) {
last.push(item)
}
else {
topMap.push([item])
}
}
else {
topMap.push([item])
}
}
if (antiOverlap) {
switch (target.mode) {
case 0: {
const result = topMap.find((list) => {
return list.every((danmu) => {
if (clientWidth < danmu.distance)
return false
if (target.speed < danmu.speed)
return true
const overlapTime = danmu.right / (target.speed - danmu.speed)
if (overlapTime > danmu.time)
return true
return false
})
})
return result && result[0] ? result[0].top : undefined
}
// 静止弹幕没有重叠问题
case 1:
case 2:
return undefined
default:
break
}
}
else {
switch (target.mode) {
case 0:
topMap.sort((prev, next) => {
const nextMinRight = Math.min(...next.map(item => item.right))
const prevMinRight = Math.min(...prev.map(item => item.right))
return nextMinRight * next.length - prevMinRight * prev.length
})
break
case 1:
case 2:
topMap.sort((prev, next) => {
const nextMaxWidth = Math.max(...next.map(item => item.width))
const prevMaxWidth = Math.max(...prev.map(item => item.width))
return prevMaxWidth * prev.length - nextMaxWidth * next.length
})
break
default:
break
}
return topMap[0]![0]!.top
}
}
import type { PlacementMessage, PlacementReply } from './worker-types'
import { getDanmuTop } from './placement'
onmessage = (event: MessageEvent<PlacementMessage>) => {
const { data } = event
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,103 @@
# PKG-DANMUKU-MASK-LOAD-01 真实模型负载下的弹幕调度
状态:本缺陷修复验收完成;完整组合/分发任务仍独立开放。起点
475d38043e917b412018c6851d6a7464ef7bda67。范围为弹幕内部调度及组合验证;
Mask生产实现、MediaPipe模型/SDK、视频尺寸、负载与等待预算不变。
## 缺陷与诊断
原始真实模型矩阵18项12通过6失败,保留在
[原始记录](2026-09-14-PKG-DANMUKU-08-combined-load.md)及
[原始证据](../baselines/danmuku-combined-load-validation.json)。
5项候选120行未完整显示,另1项beta核心负载前行未显示;旧插件也漏显,
不能据此声称是重构引入的回归。
增加只读pending/reserved行快照和显式Chromium CDP CPU诊断入口。
单worker原实现诊断仍失败:120行均已采样,仅68行显示,reserved中62行,
其中52行尚未显示,确认串行放置积压。CPU采样显示SDK结果转换中的
createImageBitmap约5.57秒,二值mask转换约1.01秒,drawImage约0.89秒;
自有调度约15毫秒。这是一次采样的自耗时,不是精确分阶段计时或总成本保证。
调用栈指向MediaPipe转换image/segmentationMask的原有路径;没有替换SDK私有
函数、禁用输出、降低模型/视频尺寸或使用假时钟让负载通过。
## 结构与兼容边界
- `placement.ts`从Worker中原样提取轨道计算。Worker保留旧消息协议;
scheduler仅在`antiOverlap: false`时直接调用同一计算,消除逐行消息往返。
默认防重叠模式和内部postMessage仍使用Worker,没有新增公共选项或依赖。
- `sampling-window.ts`拥有连续媒体采样及当前document的visibility监听器。
自动调度可补回上次已在wait池、尚未被采样而被连续播放跨过的行。
不追加播放刚插入的过期行,不因跨窗口再次调用已采样的false回调。
pause/seek/hide/reset等失效、原生seeking、页面隐藏、反向时间和document切换
切断追赶窗口;销毁释放监听器及保留引用。
- 公开`readys`仍是当前时间±0.1秒查询;公开wait/ready池不被采样模块写入。
ready优先级、批次身份保留、beforeVisible串行与取消、可见事件顺序、
显示寿命和已有DOM/CSS/类型/分发入口保持不变。
这是API-04/05/08/11/12边界内的缺陷修复,扩展了PKG-DANMUKU-12只处理
异步等待期间采样的能力。连续播放CPU卡顿后可能出现旧版漏掉的行;
它不承诺跨暂停、seek或后台时段重放,也不承诺无限吞吐量。
架构、维护入口和资源归属同步更新于两个包的ARCHITECTURE.md。
## 验证记录
固定Node24.21.0、YarnClassic1.22.22;没有新增依赖。
- 可控旧红:首批6项4通过2失败,分别复现允许重叠模式Worker积压和CPU跨窗漏显。
- 当前弹幕Node套件275/275通过,包含串行回调、生命周期重入、公开readys不变、
不重放追加过期行、false回调不额外重试及visibility监听器释放。
后续增加document迁移用例:旧窗口不追赶、旧监听器移除、新窗口恢复连续采样,
最终source/main/legacy各276/276通过。直接内部用例仍执行源码,集成用例加载
指定产物及其实际内嵌Worker,不把所有276项都称为产物执行。
- 首次直接`yarn tsc`错误地进入根`.bin/tsc`所指向的TS5.1.6兼容别名,
setting-lifecycle闭包收窄产生TS18048。按既有typechecking.md指定的
`node node_modules/typescript/bin/tsc -p packages/artplayer-plugin-danmuku/tsconfig.json --noEmit`
使用生产编译器TS5.9.3通过。未修改该源文件或放松strict;TS4.3/5.1的
消费者兼容与生产源码编译器是不同要求。
- 第一轮修复后真实模型矩阵18/18退出0:9候选完整交付、9冻结旧插件行为观察;
Chromium/Firefox/WebKit、当前/5.4.0/5.3.1-beta.1核心覆盖。每个候选120行
完整显示且回收,模型保持运行。
最终结果及实际输入指纹见[机器证据](../baselines/danmuku-mask-load-validation.json)。
| 验证 | 结果与范围 |
| --- | --- |
| 实际模型源码矩阵 | 18通过,9候选完整交付、9旧插件观察;216.28秒 |
| 首次寿命/PiP/时钟回归 | 72通过12失败;12失败全部是候选允许重叠路径零Worker请求与旧测试前提冲突。48项PiP包含32原生播放和16API不可用记录 |
| 扩展两种放置路径后的源码寿命/时钟 | 60通过;48寿命含24候选/24旧插件,12时钟含6候选/6旧插件;114.36秒 |
| main候选 | 39通过:9模型、24寿命、6时钟;162.43秒 |
| legacy候选 | 同范围39通过;160.03秒 |
| Node source/main/legacy | 每轮276通过;内部纯函数仍是源码测试,集成测试加载对应产物 |
| 类型与实际安装 | TS5.9.3严格源码通过;4项类型/实现赋值/编辑器测试通过;实际隔离包12组消费者通过,保留精确旧声明诊断对照 |
| 构建/字节一致性 | 弹幕与Mask三格式构建通过;Mask产物无变化;原轨道函数逐字一致;隔离包79个成员仍匹配当前文件,三份docs/compiled与dist一致 |
源码/main/legacy的27个候选真实模型负载均显示120个唯一行,最终120行wait、
无节点引用、pending/reserved均为空,模型继续输出。原来负载前漏显的beta核心
路径同样通过。全部最终浏览器报告零重试、零跳过;API不可用是独立记录。
浏览器为Windows Chromium153.0.8010.12、Firefox155.0、WebKit26.6。
模型下帧间隔P95仍约79–275毫秒,故这里确认交付完整性,不宣称解决模型帧率
或GPU/WASM性能。公开类型、模型输入、负载和原等待预算未改变。
另一次错误调用把`.mjs`传入只接受CommonJS/UMD的同步VM测试宿主,271项
146通过125失败,语法解析报Unexpected token export,插件尚未执行。
保留该日志;不通过改写ESM字节伪装原生模块执行。test/README.md已明确入口范围。
真实ESM导入由仓库外打包消费验证,完整ESM浏览器分发仍属PKG-DANMUKU-09。
原始日志、CPU profile和各轮浏览器报告归档在refactor/.cache/danmuku-mask-load-*。
首次寿命回归仍假设允许重叠模式必经Worker,该前提已被本修复取消;已保留
原失败并扩展两种模式。防重叠候选仍要求一对真实Worker请求/回复、至少1400ms
受控延迟及至少900ms显示;允许重叠候选要求零请求/回复和至少900ms显示。
旧插件两种模式都仍复现被等待吃掉寿命的缺陷。原寿命断言没有降低。
CPU卡顿用例新增候选全部三行按序显示的断言,旧插件CPU漏显继续独立观察。
## 重跑与回退
实际模型:`yarn test:browser:source test/browser/danmuku-mask-native.spec.js
--grep 'actual model and Danmuku' --workers=2`。main/legacy分别指定存在的
ARTPLAYER_DANMUKU_ARTIFACT和ARTPLAYER_MASK_ARTIFACT文件;报告包含实际输入哈希。
诊断仅Chromium单worker增加ARTPLAYER_MASK_PROFILE=1;正式验收不打开profile。
每轮终止后先归档报告,再运行下一轮,不能混用源与产物证据。
回退本任务的独立提交会恢复原Worker逐行路径及点采样,并重新暴露记录中的漏显。
没有公共类型或数据迁移。完整组合任务08/Mask05、设备、其他包和发布复盘仍独立
验收;本修复不授权push、部署或npm发布。
+3 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 262 项,范围 22 个包及工作区/示例。
状态:todo 49 / doing 21 / blocked 0 / done 192 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 48 / doing 21 / blocked 0 / done 193 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -467,7 +467,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-DANMUKU-MASK-LOAD-01 | artplayer-plugin-danmuku, artplayer-plugin-danmuku-mask<br>修复真实Mask组合下的弹幕未采样与放置积压 | PKG-DANMUKU-12, PKG-MASK-04, CORE-22 | 分阶段性能/采样诊断、兼容实现修复及新旧核心/产物回归 | 关闭真实模型组合5项密集负载失败和1项负载前漏显,保持回调顺序、显示寿命与生命周期取消;不能以降低负载或关闭模型制造通过 | H | todo |
| PKG-DANMUKU-MASK-LOAD-01 | artplayer-plugin-danmuku, artplayer-plugin-danmuku-mask<br>修复真实Mask组合下的弹幕未采样与放置积压 | PKG-DANMUKU-12, PKG-MASK-04, CORE-22 | 分阶段性能/采样诊断、兼容实现修复及新旧核心/产物回归 | 关闭真实模型组合5项密集负载失败和1项负载前漏显,保持回调顺序、显示寿命与生命周期取消;不能以降低负载或关闭模型制造通过 | H | done |
## 完成证据与阻塞
@@ -684,4 +684,4 @@
- CI-BROWSER-01: [记录](changes/2026-09-14-CI-BROWSER-01-scopes.md) [记录](baselines/browser-scope-validation.json)
- PKG-CHAPTER-HOVER-01: [记录](changes/2026-09-14-PKG-CHAPTER-HOVER-01-geometry.md) [记录](baselines/chapter-hover-validation.json)
- PKG-CANVAS-SUBTITLE-01: [记录](changes/2026-09-14-PKG-CANVAS-SUBTITLE-01-track.md) [记录](baselines/canvas-subtitles-validation.json)
- PKG-DANMUKU-MASK-LOAD-01: [记录](changes/2026-09-14-PKG-DANMUKU-08-combined-load.md) [记录](baselines/danmuku-combined-load-validation.json)
- PKG-DANMUKU-MASK-LOAD-01: [记录](changes/2026-09-14-PKG-DANMUKU-08-combined-load.md) [记录](baselines/danmuku-combined-load-validation.json) [记录](changes/2026-09-14-PKG-DANMUKU-MASK-LOAD-01-scheduling.md) [记录](baselines/danmuku-mask-load-validation.json)
+24
View File
@@ -1,5 +1,29 @@
# 进度与证据
## PKG-DANMUKU-MASK-LOAD-01 真实模型负载漏显与积压修复
连续播放采样补回主线程卡顿跨过的既有wait行,公开readys窗口不变;允许重叠
模式直接使用原轨道计算,默认防重叠仍走Worker。采样/纯几何/消息协议拆分,
保留串行回调、取消、显示寿命;document迁移及destroy释放visibility监听器。
没有更换Mask模型/SDK、缩小视频、降低20条每秒负载或扩大原等待预算。
模型源码18通过(9候选完整/9旧观察),main/legacy各39通过(9模型、24寿命、
6时钟):27个候选模型用例均120行唯一显示/回收,pending/reserved为空。
寿命测试补齐防重叠/允许重叠两条路径,源码60通过;原生PiP矩阵48项中的
32原生播放与16API不可用保持区分。第一次寿命12失败来自旧测试强制Worker
假设,完整报告保留;扩展用例继续验证实际延迟路径,没有降低寿命要求。
Node source/main/legacy各276通过,严格TS5.9.3、4项类型/编辑器测试、实际
隔离安装12组通过。误用TS5.1.6别名及把ESM交给同步VM的失败单独记录,
不归为产品缺陷或ESM行为通过。构建及79个最终打包成员一致性已核验。
见[变更](changes/2026-09-14-PKG-DANMUKU-MASK-LOAD-01-scheduling.md)及
[证据](baselines/danmuku-mask-load-validation.json)。模型帧间隔仍高,未宣称帧率提升。
本缺陷任务及风险关闭;262项:193 done、21 doing、48 todo。弹幕08/09、
Mask05/06及其他包/设备/远端CI/发布复盘继续开放;ESM完整浏览器分发仍属09。
本项按独立本地提交交付并核验审计,未push/发布。下一步继续包组合与分发收尾。
## PKG-DANMUKU-08 旧插件组合与真实负载:新增必须修复项
扩展PiP/Mask测试加载校验后的npm弹幕5.3.0及候选源码。普通PiP矩阵36项退出0:
+1 -1
View File
@@ -275,4 +275,4 @@
| CANVAS-SUBTITLE-01 | resolved / 已复现 | Canvas底层video缺初始字幕轨道且普通Canvas挂载不能加载原生字幕 | PKG-CANVAS-SUBTITLE-01, PKG-CANVAS-05 |
| DPIP-INSTALLED-PROVENANCE-01 | resolved / 已复现 | Document PiP测试加载器忽略显式安装map并回退到源码 | PKG-CANVAS-05, CI-01 |
| DASH-MENU-01 | resolved / 已复现 | SDK 5.2.1 播放中设置质量选项在点击时变为不可见 | PKG-DASH-MENU-01, PKG-DASH-05, REVIEW-02 |
| DANMUKU-MASK-LOAD-01 | open / 已复现 | 真实Mask模型运行时弹幕存在未采样窗口及串行放置积压 | PKG-DANMUKU-MASK-LOAD-01, PKG-DANMUKU-08, PKG-MASK-05 |
| DANMUKU-MASK-LOAD-01 | resolved / 已复现 | 真实Mask模型运行时弹幕存在未采样窗口及串行放置积压 | PKG-DANMUKU-MASK-LOAD-01, PKG-DANMUKU-08, PKG-MASK-05 |
+11 -2
View File
@@ -6299,7 +6299,7 @@
"id": "DANMUKU-MASK-LOAD-01",
"title": "真实Mask模型运行时弹幕存在未采样窗口及串行放置积压",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-DANMUKU-MASK-LOAD-01",
"PKG-DANMUKU-08",
@@ -6313,7 +6313,16 @@
],
"compatibleResolution": "分析真实模型/绘制/Worker往返成本和未采样、待放置行,保持公共readys与回调顺序、显示寿命及生命周期取消;用独立回归验证兼容修复。",
"closureCriteria": "原始失败保留并有可解释修复;真实模型下120行完整交付、无活动引用积压,新旧核心和main/legacy以及暂停/seek/hide/destroy回归通过;不降低负载、停模型或扩大等待伪造结果。",
"workspaceState": "源码18项12通过6失败:5项候选密集负载不完整,1项Chromium beta核心负载前行未显示。旧插件同环境也漏显,尚未证明是重构回归或定位单一瓶颈。Firefox候选和原生PiP负载通过。"
"workspaceState": "连续播放采样窗口补回CPU跨窗行;允许重叠放置直接复用原轨道函数,防重叠和内部消息API仍使用Worker。真实模型源码18、main39、legacy39通过;其中27个候选模型负载全部120行完整交付/回收,pending/reserved为空。",
"resolutionEvidence": [
"refactor/changes/2026-09-14-PKG-DANMUKU-MASK-LOAD-01-scheduling.md",
"refactor/baselines/danmuku-mask-load-validation.json",
"test/danmuku-scheduler.test.js",
"test/browser/danmuku-mask-native.spec.js",
"test/browser/danmuku-lifetime.spec.js",
"test/browser/danmuku-timing-diagnostic.spec.js"
],
"resolutionRationale": "原5项负载漏显和1项负载前行失败保留;实际模型/SDK/视频和20条每秒输入、等待预算不变。源与两产物共27项模型负载完整交付;两种放置路径寿命、CPU/异步间隙、新旧核心及取消/文档迁移回归通过。Node source/main/legacy各276通过,源码类型、编辑器/消费者及隔离包12组通过。首次旧Worker假设失败和错误ESM宿主调用单独记录;不冒充ESM完整浏览器验收、帧率提升或全部包发布通过。"
}
]
}
+4 -2
View File
@@ -5483,13 +5483,15 @@
"PKG-MASK-04",
"CORE-22"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "分阶段性能/采样诊断、兼容实现修复及新旧核心/产物回归",
"acceptance": "关闭真实模型组合5项密集负载失败和1项负载前漏显,保持回调顺序、显示寿命与生命周期取消;不能以降低负载或关闭模型制造通过",
"evidence": [
"changes/2026-09-14-PKG-DANMUKU-08-combined-load.md",
"baselines/danmuku-combined-load-validation.json"
"baselines/danmuku-combined-load-validation.json",
"changes/2026-09-14-PKG-DANMUKU-MASK-LOAD-01-scheduling.md",
"baselines/danmuku-mask-load-validation.json"
]
}
]
+5 -1
View File
@@ -183,7 +183,11 @@ Document PiP: `yarn test:dpip` runs 48 historical window/DOM/lifecycle cases. Na
Danmuku: `yarn test:danmuku` covers historical behavior and current input, scheduling,
settings, rendering and heatmap boundaries. `ARTPLAYER_DANMUKU_ARTIFACT` selects a
built candidate for integration cases; direct internal tests remain source tests.
built main/legacy candidate for integration cases; direct internal tests remain
source tests. This controlled synchronous VM loader executes CommonJS/UMD scripts,
not `.mjs` modules. Do not pass ESM bytes to that override or interpret its parser
failure as a production ESM failure. Actual ESM imports are checked by the isolated
package consumer; full native distribution coverage remains the package release gate.
The candidate Worker helper executes the selected artifact's Blob or data URL bytes;
only source-mode controlled imports compile `worker.ts` separately. The frozen
published helper remains unchanged. `yarn test:danmuku-types` checks the unchanged
+8 -1
View File
@@ -253,7 +253,14 @@ See refactor/changes/2026-09-14-PKG-MASK-05-native-checkpoint.md for exact limit
Its combination cases now include frozen/current Danmuku and a bounded 120-row
load while the actual model continues. `danmuku-combination-load.js` observes
real media/RAF and cleans up its test listeners; it does not seed eligible rows.
Candidate completeness failures remain failing under PKG-DANMUKU-MASK-LOAD-01.
The candidate must deliver all 120 rows exactly once and recycle their references;
original failures and the PKG-DANMUKU-MASK-LOAD-01 repair remain separate evidence.
For a Chromium diagnostic only, set `ARTPLAYER_MASK_PROFILE=1` to attach the actual
CDP CPU profile around the load. This never stops inference or patches the SDK.
Ordinary format acceptance runs without profiling. Final load snapshots also
record pending/reserved row IDs to distinguish missed sampling from placement
backlog. Use `ARTPLAYER_DANMUKU_ARTIFACT` with `ARTPLAYER_MASK_ARTIFACT` to bind both
candidate plugins to the exact main/legacy files being tested.
`danmuku-dpip.spec.js` also includes two-window load cases and both plugin versions.
Frozen-plugin retained DOM, candidate cleanup and unsupported API outcomes remain
distinct; the old plugin's observed loss is not complete-delivery acceptance.
+11 -9
View File
@@ -19,8 +19,8 @@ test.beforeAll(async () => {
for (const core of ['published', 'candidate']) {
for (const implementation of ['published', 'candidate']) {
for (const mode of [0, 1]) {
test(`${core} core / ${implementation} plugin / mode ${mode}: Worker waiting does not consume visible lifetime`, async ({ page }, testInfo) => {
for (const [mode, antiOverlap] of [0, 1].flatMap(mode => [true, false].map(antiOverlap => [mode, antiOverlap]))) {
test(`${core} core / ${implementation} plugin / mode ${mode} / antiOverlap ${antiOverlap}: placement preserves visible lifetime`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(() => {
const evidence = window.lifetimeEvidence = { posts: [], replies: [], visible: [], recycled: [] }
@@ -50,11 +50,11 @@ for (const core of ['published', 'candidate']) {
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
// Install after native playing, then start once. The published duplicate
// play/playing loop defect is covered elsewhere and is not this timing case.
await page.evaluate(async (mode) => {
await page.evaluate(async ({ mode, antiOverlap }) => {
const art = window.art
const evidence = window.lifetimeEvidence
art.on('artplayerPluginDanmuku:visible', row => evidence.visible.push({ wall: performance.now(), clock: Date.now(), startedAt: row.$lastStartTime, rest: row.$restTime, mode: row.mode, time: art.currentTime, transition: row.$ref.style.transition }))
art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], speed: 1, antiOverlap: false, heatmap: false, emitter: false }))
art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], speed: 1, antiOverlap, heatmap: false, emitter: false }))
const plugin = art.plugins.artplayerPluginDanmuku
const owner = plugin.config({})
const makeWait = owner.makeWait
@@ -65,16 +65,18 @@ for (const core of ['published', 'candidate']) {
}
await plugin.load([{ id: 'delayed', text: 'Full visible lifetime', time: art.currentTime + 0.4, mode }])
owner.start()
}, mode)
}, { mode, antiOverlap })
await expect.poll(() => page.evaluate(() => window.lifetimeEvidence.recycled.length)).toBe(1)
await page.click('#pause')
const evidence = await page.evaluate(() => ({ ...window.lifetimeEvidence, video: { width: window.art.video.videoWidth, time: window.art.currentTime } }))
await testInfo.attach('danmuku-lifetime', { contentType: 'application/json', body: JSON.stringify({ core, implementation, mode, sha256: hash(code), evidence, scope: 'Native video and real Worker geometry with controlled 1500ms request delay; does not assert naturally occurring Worker starvation.' }) })
expect(evidence.posts).toHaveLength(1)
expect(evidence.replies).toHaveLength(1)
const workerPlacement = implementation === 'published' || antiOverlap
await testInfo.attach('danmuku-lifetime', { contentType: 'application/json', body: JSON.stringify({ core, implementation, mode, antiOverlap, workerPlacement, sha256: hash(code), evidence, scope: 'Native video; Worker path has a controlled 1500ms request delay, candidate relaxed path must bypass it. Both candidate paths require full visible lifetime. Does not assert naturally occurring Worker starvation.' }) })
expect(evidence.posts).toHaveLength(workerPlacement ? 1 : 0)
expect(evidence.replies).toHaveLength(workerPlacement ? 1 : 0)
expect(evidence.visible).toHaveLength(1)
expect(evidence.video.width).toBeGreaterThan(0)
expect(evidence.replies[0].wall - evidence.posts[0].wall).toBeGreaterThanOrEqual(1400)
if (workerPlacement)
expect(evidence.replies[0].wall - evidence.posts[0].wall).toBeGreaterThanOrEqual(1400)
const lifetime = evidence.recycled[0].wall - evidence.visible[0].wall
if (implementation === 'published') {
// Preserve the historical defect as explicit evidence, not the candidate contract.
+17 -1
View File
@@ -265,7 +265,23 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
expect(restored.mode).toBe('alpha')
await testInfo.attach('actual-combination-page', { body: await page.screenshot(), contentType: 'image/png' })
const outputsBeforeLoad = await page.evaluate(() => window.maskCombination.times.length)
load = await observeDanmukuLoad(page, { label: 'Mask load', requireComplete: implementation === 'candidate' })
let profiler
if (process.env.ARTPLAYER_MASK_PROFILE === '1') {
assert.equal(testInfo.project.name, 'chromium', 'CDP profiling requires the Chromium diagnostic subset')
profiler = await page.context().newCDPSession(page)
await profiler.send('Profiler.enable')
await profiler.send('Profiler.start')
}
try {
load = await observeDanmukuLoad(page, { label: 'Mask load', requireComplete: implementation === 'candidate' })
}
finally {
if (profiler) {
const { profile } = await profiler.send('Profiler.stop')
await testInfo.attach('mask-cpu-profile', { contentType: 'application/json', body: JSON.stringify(profile) })
await profiler.detach()
}
}
load.modelOutputs = await page.evaluate(before => window.maskCombination.times.length - before, outputsBeforeLoad)
expect(load.modelOutputs).toBeGreaterThan(1)
await page.evaluate(() => window.art.destroy(false))
@@ -93,7 +93,7 @@ for (const implementation of ['published', 'candidate']) {
const visible = new Set(evidence.visible.map(row => row.id))
return { ...evidence, rows, missing: rows.filter(row => !visible.has(row.id)).map(row => row.id), media: { time: window.art.currentTime, width: window.art.video.videoWidth } }
})
await testInfo.attach('danmuku-timing-observation', { contentType: 'application/json', body: JSON.stringify({ implementation, scenario, sha256: hash(code), observation, scope: 'Diagnostic only: native timing with controlled load, original readys getter and unmodified queue states. Missing rows are reported, not waived as stability acceptance.' }) })
await testInfo.attach('danmuku-timing-observation', { contentType: 'application/json', body: JSON.stringify({ implementation, scenario, sha256: hash(code), observation, scope: 'Native timing with controlled load, original readys getter and unmodified queue states. Candidate must recover both CPU and asynchronous gaps; published CPU loss is observed separately. This is not sustained-load acceptance.' }) })
expect(observation.media.width).toBeGreaterThan(0)
expect(observation.rows).toHaveLength(3)
expect(observation.frames.length).toBeGreaterThan(2)
@@ -111,6 +111,10 @@ for (const implementation of ['published', 'candidate']) {
expect(observation.callbacks[index].start).toBeGreaterThanOrEqual(observation.callbacks[index - 1].end)
}
}
if (implementation === 'candidate') {
expect(observation.missing).toEqual([])
expect(observation.visible.map(row => row.id)).toEqual(['first', 'middle', 'sentinel'])
}
await page.evaluate(() => window.art.destroy())
})
}
+143
View File
@@ -78,6 +78,149 @@ function geometry(mode = 1) {
return { type: 'getDanmuTop', target: { mode, height: 20, speed: 148 }, visibles: [], antiOverlap: true, clientWidth: 640, clientHeight: 360, marginTop: 10, marginBottom: 90 }
}
test(`${prefix}: non-overlap-disabled placement does not queue a Worker round trip and keeps callbacks serial`, async (t) => {
const gate = deferred()
const calls = []
const { env, plugin, owner, worker } = await fixture({ antiOverlap: false, beforeVisible(row) {
calls.push(row.text)
return row.text === 'first' ? gate.promise : true
} })
t.after(() => {
env.destroy()
gate.resolve(false)
})
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'second', time: 10, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.deepEqual(calls, ['first'])
gate.resolve(true)
await env.flush()
assert.deepEqual(calls, ['first', 'second'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first', 'second'])
assert.equal(worker.messages.length, 0)
assert.deepEqual([...owner.queue].map(row => row.$ref.offsetTop), [owner.marginTop, owner.marginTop + 20])
})
for (const boundary of ['continuous', 'seek', 'stop', 'hide', 'visibility']) {
test(`${prefix}: CPU gap recovery respects ${boundary} boundaries and keeps public readys unchanged`, async (t) => {
const { env, plugin, owner } = await fixture({}, { automatic: true })
t.after(() => env.destroy())
const listeners = new Set()
const doc = {
visibilityState: 'visible',
addEventListener(name, fn) {
assert.equal(name, 'visibilitychange')
listeners.add(fn)
},
removeEventListener(name, fn) {
assert.equal(name, 'visibilitychange')
listeners.delete(fn)
},
}
owner.$player.ownerDocument = doc
await plugin.load([{ text: 'gap', time: 10.4, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.tick(800)
env.art.currentTime = 10.8
if (boundary === 'seek')
env.art.emit('video:seeking')
if (boundary === 'stop') {
owner.stop()
owner.start()
}
if (boundary === 'hide') {
owner.hide()
owner.show()
}
if (boundary === 'visibility') {
for (const listener of listeners) listener()
}
assert.equal(owner.readys.length, 0)
env.frame()
await env.flush()
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), boundary === 'continuous' ? ['gap'] : [])
env.destroy()
assert.equal(listeners.size, 0)
})
}
test(`${prefix}: document adoption drops the old catch-up window and transfers visibility ownership`, async (t) => {
const { env, plugin, owner } = await fixture({}, { automatic: true })
t.after(() => env.destroy())
function document() {
const listeners = new Set()
return {
visibilityState: 'visible',
listeners,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
}
}
const original = document()
const adopted = document()
owner.$player.ownerDocument = original
await plugin.load([{ text: 'before adoption', time: 10.4, mode: 1 }, { text: 'after adoption', time: 11.2, mode: 1 }])
env.art.playing = true
owner.start()
await env.frame()
assert.equal(original.listeners.size, 1)
owner.$player.ownerDocument = adopted
env.tick(800)
env.art.currentTime = 10.8
await env.frame()
assert.equal(original.listeners.size, 0)
assert.equal(adopted.listeners.size, 1)
assert.equal(output(env, 'visible').length, 0, 'Do not replay rows crossed while adopting another document')
env.tick(800)
env.art.currentTime = 11.6
await env.frame()
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['after adoption'])
env.destroy()
assert.equal(adopted.listeners.size, 0)
})
test(`${prefix}: CPU gap recovery ignores newly appended past rows and does not retry an already sampled false callback`, async (t) => {
const calls = []
const { env, plugin, owner } = await fixture({ beforeVisible(row) {
calls.push(row.text)
return row.text !== 'declined'
} }, { automatic: true })
t.after(() => env.destroy())
await plugin.load([{ text: 'declined', time: 10, mode: 1 }, { text: 'gap', time: 10.4, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.tick(800)
env.art.currentTime = 10.8
await plugin.load([{ text: 'appended-past', time: 10.3, mode: 1 }])
env.frame()
await env.flush()
assert.deepEqual(calls, ['declined', 'gap'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['gap'])
})
for (const boundary of ['stop', 'hide', 'reset', 'destroy']) {
test(`${prefix}: inline relaxed placement honors ${boundary} from the first visible callback`, async (t) => {
const { env, plugin, owner, worker } = await fixture({ antiOverlap: false })
t.after(() => env.destroy())
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'cancelled', time: 10, mode: 1 }])
env.art.on('artplayerPluginDanmuku:visible', () => owner[boundary]())
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first'])
assert.equal(worker.messages.length, 0)
assert.equal(owner.queue[1].$ref, null)
})
}
test(`${prefix}: repeated play/playing and direct starts preserve events and internal return with only one queued frame`, async () => {
const { env, owner } = await fixture()
env.art.playing = true
+2 -1
View File
@@ -36,7 +36,8 @@ export async function observeDanmukuLoad(page, { label, requireComplete, rate =
art.off('artplayerPluginDanmuku:visible', visible)
art.off('artplayerPluginDanmuku:error', error)
const loaded = owner.queue.filter(row => ids.has(row.id))
const result = { ...state, endWall: host.performance.now(), time: art.currentTime, loaded: loaded.length, waiting: loaded.filter(row => row.$state === 'wait').length, referenced: loaded.filter(row => row.$ref).length, nodes: art.template.$danmuku.children.length, pooled: owner.$refs.length, uniquePool: new Set(owner.$refs).size, decodedWidth: art.video.videoWidth }
const buffered = { pending: [...owner.scheduler?.buffer.pending || []].filter(row => ids.has(row.id)).map(row => row.id), reserved: [...owner.scheduler?.buffer.reserved || []].filter(row => ids.has(row.id)).map(row => row.id) }
const result = { ...state, endWall: host.performance.now(), time: art.currentTime, loaded: loaded.length, waiting: loaded.filter(row => row.$state === 'wait').length, referenced: loaded.filter(row => row.$ref).length, nodes: art.template.$danmuku.children.length, pooled: owner.$refs.length, uniquePool: new Set(owner.$refs).size, decodedWidth: art.video.videoWidth, buffered }
window.danmukuCombinationLoadResult = result
return result
}