mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
refactor(auto-thumbnail): [PKG-AUTO-THUMB-03] wait for native frame presentation
This commit is contained in:
1 parent
a128706fe7
commit
8c57d56f17
21 files changed
+10716
-296
No files matched your search
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e,t){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const i of["onloadedmetadata","onseeked","onerror"])e.own(()=>{t[i]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,r=i.videoHeight,o=i.videoWidth,{height:a,canvasWidth:u,canvasHeight:d}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:r,videoWidth:o});if(!e.active())return;i.width=o,i.height=r,i.style.width=`${o}px`,i.style.height=`${r}px`;const s=document.createElement("canvas");if(!e.active())return;s.width=u,s.height=d;const c=s.getContext("2d");if(!c)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let l=0;const h=e.guard(()=>{if(l>=t.number)return void e.dispose();const r=n*l/t.number;let o=0;i.onseeked=e.guard(()=>{if(i.seeking)return;const n=i.currentTime;if(!e.active())return;if(!Number.isFinite(n)||Math.abs(n-r)>.05){if(++o>3)throw new Error("Auto-thumbnail seek did not reach the requested time");return void(i.currentTime=r)}if(i.onseeked=null,c.drawImage(i,l%10*t.width,Math.floor(l/10)*a,t.width,a),!e.active())return;let u=!1;s.toBlob(e.guard(i=>{u||(u=!0,e.publish(i,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(l+=1,h()))}),"image/jpeg")}),e.active()&&(i.currentTime=r)});h()}),e.active()&&(i.src=t.url))}function t(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}return function(i){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(e,i){let n,r,o=!1;const a=new Set;function u(e){a.delete(e)&&URL.revokeObjectURL(e)}function d(){const e=n;n=void 0,e?.dispose()}return{start:function(){if(o)return;const d=n,s=[];let c=!1;const l={active:()=>!o&&!c&&n===l,own(e){if(c){const n=t([e]);n.failed&&i(n.failure)}else s.push(e)},dispose(){if(c)return;c=!0,n===l&&(n=void 0);const e=t(s.splice(0).reverse());e.failed&&i(e.failure)},fail(e){l.active()&&(l.dispose(),i(e))},guard:e=>(...t)=>{if(l.active())try{return e(...t)}catch(i){l.fail(i)}},publish(n,o){if(!l.active())return;if(!n)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(n);if(a.add(d),!l.active())return void u(d);const s=r;r=d;try{e({url:d,...o})}catch(c){r===d&&(r=a.has(s)?s:void 0);const e=t([()=>u(d)]);throw e.failed&&i(e.failure),c}u(s)}};return n=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)return;o=!0;const e=t([d,...[...a].map(e=>()=>u(e))]);r=void 0,e.failed&&i(e.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const t=o.start();t&&t.guard(()=>{const r=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(i,()=>n.option.url);t.active()&&e(t,r)})()},d=()=>{const e=a.splice(0),i=t([o.destroy,...e]);i.failed&&r(i.failure)};try{for(const[e,t]of[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]]){if(o.closed)break;a.push(()=>n.off(e,t)),n.on(e,t),o.closed&&n.off(e,t)}}catch(s){throw d(),s}return{name:"artplayerPluginAutoThumbnail"}}}});
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e){let t,n=!1;for(const r of e)try{r()}catch(i){n||(n=!0,t=i)}return{failed:n,failure:t}}function t(t,n){const i="function"===typeof n.requestVideoFrameCallback&&"function"===typeof n.cancelVideoFrameCallback;let r;function o(){const t=r;if(r=void 0,n.onloadeddata=null,n.onseeked=null,!t)return;const i=e([()=>clearTimeout(t.timer),()=>{null!==t.frame&&n.cancelVideoFrameCallback(t.frame)}]);if(i.failed)throw i.failure}return t.own(o),(e,a)=>{if(o(),!t.active())return;const u={frame:null,timer:null,sought:!1,presented:!i,retries:0,sequence:0,started:!1};r=u;const d=()=>t.active()&&r===u,s=t.guard(()=>{d()&&u.sought&&u.presented&&(o(),t.active()&&a())}),c=()=>{u.sought=!1,(()=>{if(!i)return;const e=++u.sequence;u.presented=!1;const r=u.frame;if(u.frame=null,null!==r&&n.cancelVideoFrameCallback(r),!d())return;let o=!1;const a=n.requestVideoFrameCallback(t.guard(()=>{d()&&e===u.sequence&&!o&&(o=!0,u.frame=null,u.presented=!0,s())}));d()&&e===u.sequence&&!o?u.frame=a:n.cancelVideoFrameCallback(a)})(),d()&&(n.currentTime=e)},l=t.guard(()=>{d()&&!u.started&&(u.started=!0,n.onloadeddata=null,n.onseeked=t.guard(()=>{if(!d()||n.seeking)return;const t=n.currentTime;if(d())if(!Number.isFinite(t)||Math.abs(t-e)>.05){if(++u.retries>3)throw new Error("Auto-thumbnail seek did not reach the requested time");c()}else u.sought=!0,s()}),c())});if(u.timer=setTimeout(t.guard(()=>{if(d())throw new Error("Auto-thumbnail frame readiness timed out")}),3e4),!d())return void clearTimeout(u.timer);const f=!i||n.readyState>=2;d()&&(f?l():n.onloadeddata=l)}}function n(e,n){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const n of["onloadedmetadata","onloadeddata","onseeked","onerror"])e.own(()=>{t[n]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const r=i.duration,o=i.videoHeight,a=i.videoWidth,{height:u,canvasWidth:d,canvasHeight:s}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const n=Math.floor(e.width*t.videoHeight/t.videoWidth),i=Math.trunc(10*e.width),r=Math.trunc(n*Math.ceil(e.number/10));if(![n,i,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:n,canvasWidth:i,canvasHeight:r}}(n,{duration:r,videoHeight:o,videoWidth:a});if(!e.active())return;i.width=a,i.height=o,i.style.width=`${a}px`,i.style.height=`${o}px`;const c=document.createElement("canvas");if(!e.active())return;c.width=d,c.height=s;const l=c.getContext("2d");if(!l)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=t(e,i);let h=0;const m=e.guard(()=>{h>=n.number?e.dispose():f(r*h/n.number,e.guard(()=>{if(l.drawImage(i,h%10*n.width,Math.floor(h/10)*u,n.width,u),!e.active())return;let t=!1;c.toBlob(e.guard(i=>{t||(t=!0,e.publish(i,{height:u,column:10,number:n.number,width:n.width,scale:n.scale}),e.active()&&(h+=1,m()))}),"image/jpeg")}))});m()}),e.active()&&(i.src=n.url))}return function(t){return async i=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(t,n){let i,r,o=!1;const a=new Set;function u(e){a.delete(e)&&URL.revokeObjectURL(e)}function d(){const e=i;i=void 0,e?.dispose()}return{start:function(){if(o)return;const d=i,s=[];let c=!1;const l={active:()=>!o&&!c&&i===l,own(t){if(c){const i=e([t]);i.failed&&n(i.failure)}else s.push(t)},dispose(){if(c)return;c=!0,i===l&&(i=void 0);const t=e(s.splice(0).reverse());t.failed&&n(t.failure)},fail(e){l.active()&&(l.dispose(),n(e))},guard:e=>(...t)=>{if(l.active())try{return e(...t)}catch(n){l.fail(n)}},publish(i,o){if(!l.active())return;if(!i)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(i);if(a.add(d),!l.active())return void u(d);const s=r;r=d;try{t({url:d,...o})}catch(c){r===d&&(r=a.has(s)?s:void 0);const t=e([()=>u(d)]);throw t.failed&&n(t.failure),c}u(s)}};return i=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)rLine truncated
|
||||
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,n=Object.prototype.propertyIsEnumerable,r=(t,i,n)=>i in t?e(t,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[i]=n;function o(e,t){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const i of["onloadedmetadata","onseeked","onerror"])e.own(()=>{t[i]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,r=i.videoHeight,o=i.videoWidth,{height:a,canvasWidth:u,canvasHeight:d}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:r,videoWidth:o});if(!e.active())return;i.width=o,i.height=r,i.style.width=`${o}px`,i.style.height=`${r}px`;const c=document.createElement("canvas");if(!e.active())return;c.width=u,c.height=d;const l=c.getContext("2d");if(!l)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let s=0;const h=e.guard(()=>{if(s>=t.number)return void e.dispose();const r=n*s/t.number;let o=0;i.onseeked=e.guard(()=>{if(i.seeking)return;const n=i.currentTime;if(!e.active())return;if(!Number.isFinite(n)||Math.abs(n-r)>.05){if(++o>3)throw new Error("Auto-thumbnail seek did not reach the requested time");return void(i.currentTime=r)}if(i.onseeked=null,l.drawImage(i,s%10*t.width,Math.floor(s/10)*a,t.width,a),!e.active())return;let u=!1;c.toBlob(e.guard(i=>{u||(u=!0,e.publish(i,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(s+=1,h()))}),"image/jpeg")}),e.active()&&(i.currentTime=r)});h()}),e.active()&&(i.src=t.url))}function a(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}function u(e,o){let u,d,c=!1;const l=new Set;function s(e){l.delete(e)&&URL.revokeObjectURL(e)}function h(){const e=u;u=void 0,null==e||e.dispose()}return{start:function(){if(c)return;const h=u,f=[];let m=!1;const v={active:()=>!c&&!m&&u===v,own(e){if(m){const t=a([e]);t.failed&&o(t.failure)}else f.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=a(f.splice(0).reverse());e.failed&&o(e.failure)},fail(e){v.active()&&(v.dispose(),o(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(i){v.fail(i)}},publish(u,c){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const h=URL.createObjectURL(u);if(l.add(h),!v.active())return void s(h);const f=d;d=h;try{e(((e,o)=>{for(var a in o||(o={}))i.call(o,a)&&r(e,a,o[a]);if(t)for(var a of t(o))n.call(o,a)&&r(e,a,o[a]);return e})({url:h},c))}catch(m){d===h&&(d=l.has(f)?f:void 0);const e=a([()=>s(h)]);throw e.failed&&o(e.failure),m}s(f)}};return u=v,null==h||h.dispose(),v},cancel:h,destroy:function(){if(c)return;c=!0;const e=a([h,...[...l].map(e=>()=>s(e))]);d=void 0,e.failed&&o(e.failure)},get closed(){return c}}}return function(e){return t=>{return i=null,n=null,r=function*(){const i=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),n=u(e=>{t.thumbnails=e},i),r=[],d=()=>{const i=n.start();i&&i.guard(()=>{const n=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(e,()=>t.option.url);i.active()&&o(i,n)})()},c=()=>{const e=r.splice(0),t=a([n.destroy,...e]);t.failed&&i(t.failure)};try{for(const[e,i]of[["destroy",c],["restart",n.cancel],["video:loadedmetadata",d]]){if(n.closed)break;r.push(()=>t.off(e,i)),t.on(e,i),n.closed&&t.off(e,i)}}catch(l){throw c(),l}return{name:"artplayerPluginAutoThumbLine truncated
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable,i=(t,n,r)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[n]=r;function o(e){let t,n=!1;for(const i of e)try{i()}catch(r){n||(n=!0,t=r)}return{failed:n,failure:t}}function a(e,a){let u,d,l=!1;const c=new Set;function s(e){c.delete(e)&&URL.revokeObjectURL(e)}function f(){const e=u;u=void 0,null==e||e.dispose()}return{start:function(){if(l)return;const f=u,h=[];let m=!1;const b={active:()=>!l&&!m&&u===b,own(e){if(m){const t=o([e]);t.failed&&a(t.failure)}else h.push(e)},dispose(){if(m)return;m=!0,u===b&&(u=void 0);const e=o(h.splice(0).reverse());e.failed&&a(e.failure)},fail(e){b.active()&&(b.dispose(),a(e))},guard:e=>(...t)=>{if(b.active())try{return e(...t)}catch(n){b.fail(n)}},publish(u,l){if(!b.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const f=URL.createObjectURL(u);if(c.add(f),!b.active())return void s(f);const h=d;d=f;try{e(((e,o)=>{for(var a in o||(o={}))n.call(o,a)&&i(e,a,o[a]);if(t)for(var a of t(o))r.call(o,a)&&i(e,a,o[a]);return e})({url:f},l))}catch(m){d===f&&(d=c.has(h)?h:void 0);const e=o([()=>s(f)]);throw e.failed&&a(e.failure),m}s(h)}};return u=b,null==f||f.dispose(),b},cancel:f,destroy:function(){if(l)return;l=!0;const e=o([f,...[...c].map(e=>()=>s(e))]);d=void 0,e.failed&&a(e.failure)},get closed(){return l}}}function u(e,t){const n="function"===typeof t.requestVideoFrameCallback&&"function"===typeof t.cancelVideoFrameCallback;let r;function i(){const e=r;if(r=void 0,t.onloadeddata=null,t.onseeked=null,!e)return;const n=o([()=>clearTimeout(e.timer),()=>{null!==e.frame&&t.cancelVideoFrameCallback(e.frame)}]);if(n.failed)throw n.failure}return e.own(i),(o,a)=>{if(i(),!e.active())return;const u={frame:null,timer:null,sought:!1,presented:!n,retries:0,sequence:0,started:!1};r=u;const d=()=>e.active()&&r===u,l=e.guard(()=>{d()&&u.sought&&u.presented&&(i(),e.active()&&a())}),c=()=>{u.sought=!1,(()=>{if(!n)return;const r=++u.sequence;u.presented=!1;const i=u.frame;if(u.frame=null,null!==i&&t.cancelVideoFrameCallback(i),!d())return;let o=!1;const a=t.requestVideoFrameCallback(e.guard(()=>{d()&&r===u.sequence&&!o&&(o=!0,u.frame=null,u.presented=!0,l())}));d()&&r===u.sequence&&!o?u.frame=a:t.cancelVideoFrameCallback(a)})(),d()&&(t.currentTime=o)},s=e.guard(()=>{d()&&!u.started&&(u.started=!0,t.onloadeddata=null,t.onseeked=e.guard(()=>{if(!d()||t.seeking)return;const e=t.currentTime;if(d())if(!Number.isFinite(e)||Math.abs(e-o)>.05){if(++u.retries>3)throw new Error("Auto-thumbnail seek did not reach the requested time");c()}else u.sought=!0,l()}),c())});if(u.timer=setTimeout(e.guard(()=>{if(d())throw new Error("Auto-thumbnail frame readiness timed out")}),3e4),!d())return void clearTimeout(u.timer);const f=!n||t.readyState>=2;d()&&(f?s():t.onloadeddata=s)}}function d(e,t){const n=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const n of["onloadedmetadata","onloadeddata","onseeked","onerror"])e.own(()=>{t[n]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(n.onerror=e.guard(()=>{throw n.error||new Error("Auto-thumbnail media failed to load")}),n.onloadedmetadata=e.guard(()=>{n.onloadedmetadata=null;const r=n.duration,i=n.videoHeight,o=n.videoWidth,{height:a,canvasWidth:d,canvasHeight:l}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const n=Math.floor(e.width*t.videoHeight/t.videoWidth),r=Math.trunc(10*e.width),i=Math.trunc(n*Math.ceil(e.number/10));if(![n,r,i].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:n,canvasWidth:r,canvasHeight:i}}(t,{duration:r,videoHeight:i,videoWidth:o});if(!e.active())return;n.width=o,n.height=i,n.style.width=`${o}px`,n.style.height=`${i}px`;const c=document.createElement("canvas");if(!e.active())return;c.width=d,c.height=l;const s=c.getContext("2d");if(!s)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=u(eLine truncated
|
||||
@@ -4,127 +4,6 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
function readOptions(option, getFallbackUrl) {
|
||||
const config = {
|
||||
url: option.url || getFallbackUrl(),
|
||||
width: option.width || 160,
|
||||
number: option.number || 100,
|
||||
scale: option.scale || 1
|
||||
};
|
||||
if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
|
||||
throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");
|
||||
return config;
|
||||
}
|
||||
function sheetSize(config, video) {
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
|
||||
throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");
|
||||
const height = Math.floor(config.width * video.videoHeight / video.videoWidth);
|
||||
const canvasWidth = Math.trunc(config.width * 10);
|
||||
const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10));
|
||||
if (![height, canvasWidth, canvasHeight].every((value) => Number.isSafeInteger(value) && value > 0 && value <= 4294967295))
|
||||
throw new RangeError("Auto-thumbnail canvas dimensions are invalid");
|
||||
return { height, canvasWidth, canvasHeight };
|
||||
}
|
||||
function createVideo(job) {
|
||||
const video = document.createElement("video");
|
||||
job.own(() => video.remove());
|
||||
job.own(() => video.load());
|
||||
job.own(() => video.removeAttribute("src"));
|
||||
job.own(() => video.pause());
|
||||
for (const property of ["onloadedmetadata", "onseeked", "onerror"])
|
||||
job.own(() => {
|
||||
video[property] = null;
|
||||
});
|
||||
if (!job.active())
|
||||
return video;
|
||||
video.crossOrigin = "anonymous";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.tabIndex = -1;
|
||||
video.setAttribute("aria-hidden", "true");
|
||||
video.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none";
|
||||
document.documentElement.appendChild(video);
|
||||
if (!job.active())
|
||||
video.remove();
|
||||
return video;
|
||||
}
|
||||
function extract(job, config) {
|
||||
const video = createVideo(job);
|
||||
if (!job.active())
|
||||
return;
|
||||
video.onerror = job.guard(() => {
|
||||
throw video.error || new Error("Auto-thumbnail media failed to load");
|
||||
});
|
||||
video.onloadedmetadata = job.guard(() => {
|
||||
video.onloadedmetadata = null;
|
||||
const duration = video.duration;
|
||||
const videoHeight = video.videoHeight;
|
||||
const videoWidth = video.videoWidth;
|
||||
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
|
||||
duration,
|
||||
videoHeight,
|
||||
videoWidth
|
||||
});
|
||||
if (!job.active())
|
||||
return;
|
||||
video.width = videoWidth;
|
||||
video.height = videoHeight;
|
||||
video.style.width = `${videoWidth}px`;
|
||||
video.style.height = `${videoHeight}px`;
|
||||
const canvas = document.createElement("canvas");
|
||||
if (!job.active())
|
||||
return;
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx)
|
||||
throw new Error("Auto-thumbnail canvas context is unavailable");
|
||||
if (!job.active())
|
||||
return;
|
||||
let index = 0;
|
||||
const seek = job.guard(() => {
|
||||
if (index >= config.number) {
|
||||
job.dispose();
|
||||
return;
|
||||
}
|
||||
const target = duration * index / config.number;
|
||||
let retries = 0;
|
||||
video.onseeked = job.guard(() => {
|
||||
if (video.seeking)
|
||||
return;
|
||||
const time = video.currentTime;
|
||||
if (!job.active())
|
||||
return;
|
||||
if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) {
|
||||
if (++retries > 3)
|
||||
throw new Error("Auto-thumbnail seek did not reach the requested time");
|
||||
video.currentTime = target;
|
||||
return;
|
||||
}
|
||||
video.onseeked = null;
|
||||
ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height);
|
||||
if (!job.active())
|
||||
return;
|
||||
let delivered = false;
|
||||
canvas.toBlob(job.guard((blob) => {
|
||||
if (delivered)
|
||||
return;
|
||||
delivered = true;
|
||||
job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale });
|
||||
if (job.active()) {
|
||||
index += 1;
|
||||
seek();
|
||||
}
|
||||
}), "image/jpeg");
|
||||
});
|
||||
if (job.active())
|
||||
video.currentTime = target;
|
||||
});
|
||||
seek();
|
||||
});
|
||||
if (job.active())
|
||||
video.src = config.url;
|
||||
}
|
||||
function cleanupAll(actions) {
|
||||
let failure;
|
||||
let failed = false;
|
||||
@@ -246,6 +125,217 @@ function createSession(publish, report) {
|
||||
}
|
||||
};
|
||||
}
|
||||
function createFrameReader(job, video) {
|
||||
const presentedFrames = typeof video.requestVideoFrameCallback === "function" && typeof video.cancelVideoFrameCallback === "function";
|
||||
let pending;
|
||||
function clear() {
|
||||
const previous = pending;
|
||||
pending = void 0;
|
||||
video.onloadeddata = null;
|
||||
video.onseeked = null;
|
||||
if (!previous)
|
||||
return;
|
||||
const result = cleanupAll([
|
||||
() => clearTimeout(previous.timer),
|
||||
() => {
|
||||
if (previous.frame !== null)
|
||||
video.cancelVideoFrameCallback(previous.frame);
|
||||
}
|
||||
]);
|
||||
if (result.failed)
|
||||
throw result.failure;
|
||||
}
|
||||
job.own(clear);
|
||||
return (target, draw) => {
|
||||
clear();
|
||||
if (!job.active())
|
||||
return;
|
||||
const current = { frame: null, timer: null, sought: false, presented: !presentedFrames, retries: 0, sequence: 0, started: false };
|
||||
pending = current;
|
||||
const active = () => job.active() && pending === current;
|
||||
const finish = job.guard(() => {
|
||||
if (!active() || !current.sought || !current.presented)
|
||||
return;
|
||||
clear();
|
||||
if (job.active())
|
||||
draw();
|
||||
});
|
||||
const requestFrame = () => {
|
||||
if (!presentedFrames)
|
||||
return;
|
||||
const sequence = ++current.sequence;
|
||||
current.presented = false;
|
||||
const previous = current.frame;
|
||||
current.frame = null;
|
||||
if (previous !== null)
|
||||
video.cancelVideoFrameCallback(previous);
|
||||
if (!active())
|
||||
return;
|
||||
let delivered = false;
|
||||
const id = video.requestVideoFrameCallback(job.guard(() => {
|
||||
if (!active() || sequence !== current.sequence || delivered)
|
||||
return;
|
||||
delivered = true;
|
||||
current.frame = null;
|
||||
current.presented = true;
|
||||
finish();
|
||||
}));
|
||||
if (active() && sequence === current.sequence && !delivered)
|
||||
current.frame = id;
|
||||
else
|
||||
video.cancelVideoFrameCallback(id);
|
||||
};
|
||||
const seek = () => {
|
||||
current.sought = false;
|
||||
requestFrame();
|
||||
if (active())
|
||||
video.currentTime = target;
|
||||
};
|
||||
const begin = job.guard(() => {
|
||||
if (!active() || current.started)
|
||||
return;
|
||||
current.started = true;
|
||||
video.onloadeddata = null;
|
||||
video.onseeked = job.guard(() => {
|
||||
if (!active() || video.seeking)
|
||||
return;
|
||||
const time = video.currentTime;
|
||||
if (!active())
|
||||
return;
|
||||
if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) {
|
||||
if (++current.retries > 3)
|
||||
throw new Error("Auto-thumbnail seek did not reach the requested time");
|
||||
seek();
|
||||
return;
|
||||
}
|
||||
current.sought = true;
|
||||
finish();
|
||||
});
|
||||
seek();
|
||||
});
|
||||
current.timer = setTimeout(job.guard(() => {
|
||||
if (active())
|
||||
throw new Error("Auto-thumbnail frame readiness timed out");
|
||||
}), 3e4);
|
||||
if (!active()) {
|
||||
clearTimeout(current.timer);
|
||||
return;
|
||||
}
|
||||
const ready = !presentedFrames || video.readyState >= 2;
|
||||
if (!active())
|
||||
return;
|
||||
if (!ready)
|
||||
video.onloadeddata = begin;
|
||||
else
|
||||
begin();
|
||||
};
|
||||
}
|
||||
function readOptions(option, getFallbackUrl) {
|
||||
const config = {
|
||||
url: option.url || getFallbackUrl(),
|
||||
width: option.width || 160,
|
||||
number: option.number || 100,
|
||||
scale: option.scale || 1
|
||||
};
|
||||
if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
|
||||
throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");
|
||||
return config;
|
||||
}
|
||||
function sheetSize(config, video) {
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
|
||||
throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");
|
||||
const height = Math.floor(config.width * video.videoHeight / video.videoWidth);
|
||||
const canvasWidth = Math.trunc(config.width * 10);
|
||||
const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10));
|
||||
if (![height, canvasWidth, canvasHeight].every((value) => Number.isSafeInteger(value) && value > 0 && value <= 4294967295))
|
||||
throw new RangeError("Auto-thumbnail canvas dimensions are invalid");
|
||||
return { height, canvasWidth, canvasHeight };
|
||||
}
|
||||
function createVideo(job) {
|
||||
const video = document.createElement("video");
|
||||
job.own(() => video.remove());
|
||||
job.own(() => video.load());
|
||||
job.own(() => video.removeAttribute("src"));
|
||||
job.own(() => video.pause());
|
||||
for (const property of ["onloadedmetadata", "onloadeddata", "onseeked", "onerror"])
|
||||
job.own(() => {
|
||||
video[property] = null;
|
||||
});
|
||||
if (!job.active())
|
||||
return video;
|
||||
video.crossOrigin = "anonymous";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.tabIndex = -1;
|
||||
video.setAttribute("aria-hidden", "true");
|
||||
video.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none";
|
||||
document.documentElement.appendChild(video);
|
||||
if (!job.active())
|
||||
video.remove();
|
||||
return video;
|
||||
}
|
||||
function extract(job, config) {
|
||||
const video = createVideo(job);
|
||||
if (!job.active())
|
||||
return;
|
||||
video.onerror = job.guard(() => {
|
||||
throw video.error || new Error("Auto-thumbnail media failed to load");
|
||||
});
|
||||
video.onloadedmetadata = job.guard(() => {
|
||||
video.onloadedmetadata = null;
|
||||
const duration = video.duration;
|
||||
const videoHeight = video.videoHeight;
|
||||
const videoWidth = video.videoWidth;
|
||||
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
|
||||
duration,
|
||||
videoHeight,
|
||||
videoWidth
|
||||
});
|
||||
if (!job.active())
|
||||
return;
|
||||
video.width = videoWidth;
|
||||
video.height = videoHeight;
|
||||
video.style.width = `${videoWidth}px`;
|
||||
video.style.height = `${videoHeight}px`;
|
||||
const canvas = document.createElement("canvas");
|
||||
if (!job.active())
|
||||
return;
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx)
|
||||
throw new Error("Auto-thumbnail canvas context is unavailable");
|
||||
if (!job.active())
|
||||
return;
|
||||
const readFrame = createFrameReader(job, video);
|
||||
let index = 0;
|
||||
const seek = job.guard(() => {
|
||||
if (index >= config.number) {
|
||||
job.dispose();
|
||||
return;
|
||||
}
|
||||
readFrame(duration * index / config.number, job.guard(() => {
|
||||
ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height);
|
||||
if (!job.active())
|
||||
return;
|
||||
let delivered = false;
|
||||
canvas.toBlob(job.guard((blob) => {
|
||||
if (delivered)
|
||||
return;
|
||||
delivered = true;
|
||||
job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale });
|
||||
if (job.active()) {
|
||||
index += 1;
|
||||
seek();
|
||||
}
|
||||
}), "image/jpeg");
|
||||
}));
|
||||
});
|
||||
seek();
|
||||
});
|
||||
if (job.active())
|
||||
video.src = config.url;
|
||||
}
|
||||
function artplayerPluginAutoThumbnail(option) {
|
||||
return async (art) => {
|
||||
const report = (error) => console.warn("ArtPlayer auto-thumbnail failed:", error);
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@
|
||||
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
|
||||
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
|
||||
"typecheck": "node scripts/typecheck.mjs",
|
||||
"test:unit": "node --test test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
|
||||
"test:unit": "node --test test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
|
||||
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
|
||||
"test": "yarn test:node && yarn test:baseline",
|
||||
"test:browser": "playwright test",
|
||||
@@ -85,7 +85,7 @@
|
||||
"test:iframe-types-package": "node refactor/scripts/iframe-package-types.mjs",
|
||||
"test:thumbnail": "node --test test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs refactor/scripts/thumbnail-types.test.mjs",
|
||||
"test:thumbnail-types-package": "node refactor/scripts/thumbnail-package-types.mjs",
|
||||
"test:auto-thumbnail": "node --test test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs"
|
||||
"test:auto-thumbnail": "node --test test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs"
|
||||
},
|
||||
"browserslist": "last 1 Chrome version",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -31,6 +31,12 @@ for video processing.
|
||||
video sizing rules. It is silent, excluded from focus/accessibility, and never
|
||||
played by this implementation. Cleanup also removes the element if insertion,
|
||||
cancellation, pause or decoder reset fails.
|
||||
- `src/frames.js` owns one pending sample, its seek/data handlers, deadline and
|
||||
native presentation callback. When request/cancel frame callbacks are both
|
||||
available it waits for loaded data, then requires both seek completion and frame
|
||||
presentation before drawing. Callback identities are invalidated on retry and
|
||||
replacement; late/duplicate delivery cannot complete a newer sample. One job
|
||||
cleanup handles every frame without accumulating per-frame cleanup closures.
|
||||
|
||||
Only the entry module receives ArtPlayer. Extraction receives a guarded job and a
|
||||
configuration snapshot. These internal modules do not add public player fields,
|
||||
@@ -52,6 +58,10 @@ sample; a mismatch retries that same target at most three times, then fails with
|
||||
cleanup and retains any previous preview. This prevents observed stale seek
|
||||
events from advancing the sheet, but does not prove frame presentation accuracy:
|
||||
the current time can match even when the drawable first frame is stale.
|
||||
Each pending data/seek/presentation wait has a 30-second deadline. Expiry cancels
|
||||
its callback and decoder, reports through the existing warning path, and retains
|
||||
the last usable preview. This is a per-sample readiness deadline, not a complete
|
||||
network, encoder, background-tab or aggregate resource policy.
|
||||
|
||||
The previous empty first encode is removed. Encoding is serial, duplicate native
|
||||
callbacks are consumed once, and source replacement/destruction invalidates old
|
||||
@@ -82,10 +92,12 @@ defects; their passing status does not mean those defects should remain.
|
||||
|
||||
This is an intermediate `PKG-AUTO-THUMB-03` implementation. Native lifecycle
|
||||
validation does not prove correct pixels. The attached renderer now has native
|
||||
pixel checks for cells 2-4 (zero-based) on a five-frame sheet, including real black
|
||||
content, changing colors and spatial detail. Cells 0-1 remain diagnostic only:
|
||||
initial transparent/stale draws and WebKit frame-time offsets are unresolved
|
||||
(`AUTO-THUMB-PIXEL-01`). Merely waiting for `seeked`, adding two animation frames,
|
||||
pixel checks for all five cells when native frame callbacks are available,
|
||||
including the unique first frame, real black content, changing colors, spatial
|
||||
detail and presentation timestamps. The callback-less fallback still only has
|
||||
acceptance for cells 2-4 (zero-based); its cells 0-1 are diagnostic only. Initial
|
||||
transparent/stale draws and Windows WebKit frame-time offsets remain unresolved
|
||||
under `AUTO-THUMB-PIXEL-01`. Merely waiting for `seeked`, adding two animation frames,
|
||||
or seeking away and back did not consistently repair the first frame. Do not
|
||||
close this risk based on the later-cell assertions or replace it with a nonblack
|
||||
test; legitimate black frames have opaque pixels. Frame timing, additional
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e,t){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const i of["onloadedmetadata","onseeked","onerror"])e.own(()=>{t[i]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,r=i.videoHeight,o=i.videoWidth,{height:a,canvasWidth:u,canvasHeight:d}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:r,videoWidth:o});if(!e.active())return;i.width=o,i.height=r,i.style.width=`${o}px`,i.style.height=`${r}px`;const s=document.createElement("canvas");if(!e.active())return;s.width=u,s.height=d;const c=s.getContext("2d");if(!c)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let l=0;const h=e.guard(()=>{if(l>=t.number)return void e.dispose();const r=n*l/t.number;let o=0;i.onseeked=e.guard(()=>{if(i.seeking)return;const n=i.currentTime;if(!e.active())return;if(!Number.isFinite(n)||Math.abs(n-r)>.05){if(++o>3)throw new Error("Auto-thumbnail seek did not reach the requested time");return void(i.currentTime=r)}if(i.onseeked=null,c.drawImage(i,l%10*t.width,Math.floor(l/10)*a,t.width,a),!e.active())return;let u=!1;s.toBlob(e.guard(i=>{u||(u=!0,e.publish(i,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(l+=1,h()))}),"image/jpeg")}),e.active()&&(i.currentTime=r)});h()}),e.active()&&(i.src=t.url))}function t(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}return function(i){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(e,i){let n,r,o=!1;const a=new Set;function u(e){a.delete(e)&&URL.revokeObjectURL(e)}function d(){const e=n;n=void 0,e?.dispose()}return{start:function(){if(o)return;const d=n,s=[];let c=!1;const l={active:()=>!o&&!c&&n===l,own(e){if(c){const n=t([e]);n.failed&&i(n.failure)}else s.push(e)},dispose(){if(c)return;c=!0,n===l&&(n=void 0);const e=t(s.splice(0).reverse());e.failed&&i(e.failure)},fail(e){l.active()&&(l.dispose(),i(e))},guard:e=>(...t)=>{if(l.active())try{return e(...t)}catch(i){l.fail(i)}},publish(n,o){if(!l.active())return;if(!n)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(n);if(a.add(d),!l.active())return void u(d);const s=r;r=d;try{e({url:d,...o})}catch(c){r===d&&(r=a.has(s)?s:void 0);const e=t([()=>u(d)]);throw e.failed&&i(e.failure),c}u(s)}};return n=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)return;o=!0;const e=t([d,...[...a].map(e=>()=>u(e))]);r=void 0,e.failed&&i(e.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const t=o.start();t&&t.guard(()=>{const r=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(i,()=>n.option.url);t.active()&&e(t,r)})()},d=()=>{const e=a.splice(0),i=t([o.destroy,...e]);i.failed&&r(i.failure)};try{for(const[e,t]of[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]]){if(o.closed)break;a.push(()=>n.off(e,t)),n.on(e,t),o.closed&&n.off(e,t)}}catch(s){throw d(),s}return{name:"artplayerPluginAutoThumbnail"}}}});
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e){let t,n=!1;for(const r of e)try{r()}catch(i){n||(n=!0,t=i)}return{failed:n,failure:t}}function t(t,n){const i="function"===typeof n.requestVideoFrameCallback&&"function"===typeof n.cancelVideoFrameCallback;let r;function o(){const t=r;if(r=void 0,n.onloadeddata=null,n.onseeked=null,!t)return;const i=e([()=>clearTimeout(t.timer),()=>{null!==t.frame&&n.cancelVideoFrameCallback(t.frame)}]);if(i.failed)throw i.failure}return t.own(o),(e,a)=>{if(o(),!t.active())return;const u={frame:null,timer:null,sought:!1,presented:!i,retries:0,sequence:0,started:!1};r=u;const d=()=>t.active()&&r===u,s=t.guard(()=>{d()&&u.sought&&u.presented&&(o(),t.active()&&a())}),c=()=>{u.sought=!1,(()=>{if(!i)return;const e=++u.sequence;u.presented=!1;const r=u.frame;if(u.frame=null,null!==r&&n.cancelVideoFrameCallback(r),!d())return;let o=!1;const a=n.requestVideoFrameCallback(t.guard(()=>{d()&&e===u.sequence&&!o&&(o=!0,u.frame=null,u.presented=!0,s())}));d()&&e===u.sequence&&!o?u.frame=a:n.cancelVideoFrameCallback(a)})(),d()&&(n.currentTime=e)},l=t.guard(()=>{d()&&!u.started&&(u.started=!0,n.onloadeddata=null,n.onseeked=t.guard(()=>{if(!d()||n.seeking)return;const t=n.currentTime;if(d())if(!Number.isFinite(t)||Math.abs(t-e)>.05){if(++u.retries>3)throw new Error("Auto-thumbnail seek did not reach the requested time");c()}else u.sought=!0,s()}),c())});if(u.timer=setTimeout(t.guard(()=>{if(d())throw new Error("Auto-thumbnail frame readiness timed out")}),3e4),!d())return void clearTimeout(u.timer);const f=!i||n.readyState>=2;d()&&(f?l():n.onloadeddata=l)}}function n(e,n){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const n of["onloadedmetadata","onloadeddata","onseeked","onerror"])e.own(()=>{t[n]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const r=i.duration,o=i.videoHeight,a=i.videoWidth,{height:u,canvasWidth:d,canvasHeight:s}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const n=Math.floor(e.width*t.videoHeight/t.videoWidth),i=Math.trunc(10*e.width),r=Math.trunc(n*Math.ceil(e.number/10));if(![n,i,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:n,canvasWidth:i,canvasHeight:r}}(n,{duration:r,videoHeight:o,videoWidth:a});if(!e.active())return;i.width=a,i.height=o,i.style.width=`${a}px`,i.style.height=`${o}px`;const c=document.createElement("canvas");if(!e.active())return;c.width=d,c.height=s;const l=c.getContext("2d");if(!l)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=t(e,i);let h=0;const m=e.guard(()=>{h>=n.number?e.dispose():f(r*h/n.number,e.guard(()=>{if(l.drawImage(i,h%10*n.width,Math.floor(h/10)*u,n.width,u),!e.active())return;let t=!1;c.toBlob(e.guard(i=>{t||(t=!0,e.publish(i,{height:u,column:10,number:n.number,width:n.width,scale:n.scale}),e.active()&&(h+=1,m()))}),"image/jpeg")}))});m()}),e.active()&&(i.src=n.url))}return function(t){return async i=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(t,n){let i,r,o=!1;const a=new Set;function u(e){a.delete(e)&&URL.revokeObjectURL(e)}function d(){const e=i;i=void 0,e?.dispose()}return{start:function(){if(o)return;const d=i,s=[];let c=!1;const l={active:()=>!o&&!c&&i===l,own(t){if(c){const i=e([t]);i.failed&&n(i.failure)}else s.push(t)},dispose(){if(c)return;c=!0,i===l&&(i=void 0);const t=e(s.splice(0).reverse());t.failed&&n(t.failure)},fail(e){l.active()&&(l.dispose(),n(e))},guard:e=>(...t)=>{if(l.active())try{return e(...t)}catch(n){l.fail(n)}},publish(i,o){if(!l.active())return;if(!i)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(i);if(a.add(d),!l.active())return void u(d);const s=r;r=d;try{t({url:d,...o})}catch(c){r===d&&(r=a.has(s)?s:void 0);const t=e([()=>u(d)]);throw t.failed&&n(t.failure),c}u(s)}};return i=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)rLine truncated
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,n=Object.prototype.propertyIsEnumerable,r=(t,i,n)=>i in t?e(t,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[i]=n;function o(e,t){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const i of["onloadedmetadata","onseeked","onerror"])e.own(()=>{t[i]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,r=i.videoHeight,o=i.videoWidth,{height:a,canvasWidth:u,canvasHeight:d}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:r,videoWidth:o});if(!e.active())return;i.width=o,i.height=r,i.style.width=`${o}px`,i.style.height=`${r}px`;const c=document.createElement("canvas");if(!e.active())return;c.width=u,c.height=d;const l=c.getContext("2d");if(!l)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let s=0;const h=e.guard(()=>{if(s>=t.number)return void e.dispose();const r=n*s/t.number;let o=0;i.onseeked=e.guard(()=>{if(i.seeking)return;const n=i.currentTime;if(!e.active())return;if(!Number.isFinite(n)||Math.abs(n-r)>.05){if(++o>3)throw new Error("Auto-thumbnail seek did not reach the requested time");return void(i.currentTime=r)}if(i.onseeked=null,l.drawImage(i,s%10*t.width,Math.floor(s/10)*a,t.width,a),!e.active())return;let u=!1;c.toBlob(e.guard(i=>{u||(u=!0,e.publish(i,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(s+=1,h()))}),"image/jpeg")}),e.active()&&(i.currentTime=r)});h()}),e.active()&&(i.src=t.url))}function a(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}function u(e,o){let u,d,c=!1;const l=new Set;function s(e){l.delete(e)&&URL.revokeObjectURL(e)}function h(){const e=u;u=void 0,null==e||e.dispose()}return{start:function(){if(c)return;const h=u,f=[];let m=!1;const v={active:()=>!c&&!m&&u===v,own(e){if(m){const t=a([e]);t.failed&&o(t.failure)}else f.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=a(f.splice(0).reverse());e.failed&&o(e.failure)},fail(e){v.active()&&(v.dispose(),o(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(i){v.fail(i)}},publish(u,c){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const h=URL.createObjectURL(u);if(l.add(h),!v.active())return void s(h);const f=d;d=h;try{e(((e,o)=>{for(var a in o||(o={}))i.call(o,a)&&r(e,a,o[a]);if(t)for(var a of t(o))n.call(o,a)&&r(e,a,o[a]);return e})({url:h},c))}catch(m){d===h&&(d=l.has(f)?f:void 0);const e=a([()=>s(h)]);throw e.failed&&o(e.failure),m}s(f)}};return u=v,null==h||h.dispose(),v},cancel:h,destroy:function(){if(c)return;c=!0;const e=a([h,...[...l].map(e=>()=>s(e))]);d=void 0,e.failed&&o(e.failure)},get closed(){return c}}}return function(e){return t=>{return i=null,n=null,r=function*(){const i=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),n=u(e=>{t.thumbnails=e},i),r=[],d=()=>{const i=n.start();i&&i.guard(()=>{const n=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(e,()=>t.option.url);i.active()&&o(i,n)})()},c=()=>{const e=r.splice(0),t=a([n.destroy,...e]);t.failed&&i(t.failure)};try{for(const[e,i]of[["destroy",c],["restart",n.cancel],["video:loadedmetadata",d]]){if(n.closed)break;r.push(()=>t.off(e,i)),t.on(e,i),n.closed&&t.off(e,i)}}catch(l){throw c(),l}return{name:"artplayerPluginAutoThumbLine truncated
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable,i=(t,n,r)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[n]=r;function o(e){let t,n=!1;for(const i of e)try{i()}catch(r){n||(n=!0,t=r)}return{failed:n,failure:t}}function a(e,a){let u,d,l=!1;const c=new Set;function s(e){c.delete(e)&&URL.revokeObjectURL(e)}function f(){const e=u;u=void 0,null==e||e.dispose()}return{start:function(){if(l)return;const f=u,h=[];let m=!1;const b={active:()=>!l&&!m&&u===b,own(e){if(m){const t=o([e]);t.failed&&a(t.failure)}else h.push(e)},dispose(){if(m)return;m=!0,u===b&&(u=void 0);const e=o(h.splice(0).reverse());e.failed&&a(e.failure)},fail(e){b.active()&&(b.dispose(),a(e))},guard:e=>(...t)=>{if(b.active())try{return e(...t)}catch(n){b.fail(n)}},publish(u,l){if(!b.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const f=URL.createObjectURL(u);if(c.add(f),!b.active())return void s(f);const h=d;d=f;try{e(((e,o)=>{for(var a in o||(o={}))n.call(o,a)&&i(e,a,o[a]);if(t)for(var a of t(o))r.call(o,a)&&i(e,a,o[a]);return e})({url:f},l))}catch(m){d===f&&(d=c.has(h)?h:void 0);const e=o([()=>s(f)]);throw e.failed&&a(e.failure),m}s(h)}};return u=b,null==f||f.dispose(),b},cancel:f,destroy:function(){if(l)return;l=!0;const e=o([f,...[...c].map(e=>()=>s(e))]);d=void 0,e.failed&&a(e.failure)},get closed(){return l}}}function u(e,t){const n="function"===typeof t.requestVideoFrameCallback&&"function"===typeof t.cancelVideoFrameCallback;let r;function i(){const e=r;if(r=void 0,t.onloadeddata=null,t.onseeked=null,!e)return;const n=o([()=>clearTimeout(e.timer),()=>{null!==e.frame&&t.cancelVideoFrameCallback(e.frame)}]);if(n.failed)throw n.failure}return e.own(i),(o,a)=>{if(i(),!e.active())return;const u={frame:null,timer:null,sought:!1,presented:!n,retries:0,sequence:0,started:!1};r=u;const d=()=>e.active()&&r===u,l=e.guard(()=>{d()&&u.sought&&u.presented&&(i(),e.active()&&a())}),c=()=>{u.sought=!1,(()=>{if(!n)return;const r=++u.sequence;u.presented=!1;const i=u.frame;if(u.frame=null,null!==i&&t.cancelVideoFrameCallback(i),!d())return;let o=!1;const a=t.requestVideoFrameCallback(e.guard(()=>{d()&&r===u.sequence&&!o&&(o=!0,u.frame=null,u.presented=!0,l())}));d()&&r===u.sequence&&!o?u.frame=a:t.cancelVideoFrameCallback(a)})(),d()&&(t.currentTime=o)},s=e.guard(()=>{d()&&!u.started&&(u.started=!0,t.onloadeddata=null,t.onseeked=e.guard(()=>{if(!d()||t.seeking)return;const e=t.currentTime;if(d())if(!Number.isFinite(e)||Math.abs(e-o)>.05){if(++u.retries>3)throw new Error("Auto-thumbnail seek did not reach the requested time");c()}else u.sought=!0,l()}),c())});if(u.timer=setTimeout(e.guard(()=>{if(d())throw new Error("Auto-thumbnail frame readiness timed out")}),3e4),!d())return void clearTimeout(u.timer);const f=!n||t.readyState>=2;d()&&(f?s():t.onloadeddata=s)}}function d(e,t){const n=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const n of["onloadedmetadata","onloadeddata","onseeked","onerror"])e.own(()=>{t[n]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(n.onerror=e.guard(()=>{throw n.error||new Error("Auto-thumbnail media failed to load")}),n.onloadedmetadata=e.guard(()=>{n.onloadedmetadata=null;const r=n.duration,i=n.videoHeight,o=n.videoWidth,{height:a,canvasWidth:d,canvasHeight:l}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const n=Math.floor(e.width*t.videoHeight/t.videoWidth),r=Math.trunc(10*e.width),i=Math.trunc(n*Math.ceil(e.number/10));if(![n,r,i].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:n,canvasWidth:r,canvasHeight:i}}(t,{duration:r,videoHeight:i,videoWidth:o});if(!e.active())return;n.width=o,n.height=i,n.style.width=`${o}px`,n.style.height=`${i}px`;const c=document.createElement("canvas");if(!e.active())return;c.width=d,c.height=l;const s=c.getContext("2d");if(!s)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=u(eLine truncated
|
||||
+211
-121
@@ -4,127 +4,6 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
function readOptions(option, getFallbackUrl) {
|
||||
const config = {
|
||||
url: option.url || getFallbackUrl(),
|
||||
width: option.width || 160,
|
||||
number: option.number || 100,
|
||||
scale: option.scale || 1
|
||||
};
|
||||
if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
|
||||
throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");
|
||||
return config;
|
||||
}
|
||||
function sheetSize(config, video) {
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
|
||||
throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");
|
||||
const height = Math.floor(config.width * video.videoHeight / video.videoWidth);
|
||||
const canvasWidth = Math.trunc(config.width * 10);
|
||||
const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10));
|
||||
if (![height, canvasWidth, canvasHeight].every((value) => Number.isSafeInteger(value) && value > 0 && value <= 4294967295))
|
||||
throw new RangeError("Auto-thumbnail canvas dimensions are invalid");
|
||||
return { height, canvasWidth, canvasHeight };
|
||||
}
|
||||
function createVideo(job) {
|
||||
const video = document.createElement("video");
|
||||
job.own(() => video.remove());
|
||||
job.own(() => video.load());
|
||||
job.own(() => video.removeAttribute("src"));
|
||||
job.own(() => video.pause());
|
||||
for (const property of ["onloadedmetadata", "onseeked", "onerror"])
|
||||
job.own(() => {
|
||||
video[property] = null;
|
||||
});
|
||||
if (!job.active())
|
||||
return video;
|
||||
video.crossOrigin = "anonymous";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.tabIndex = -1;
|
||||
video.setAttribute("aria-hidden", "true");
|
||||
video.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none";
|
||||
document.documentElement.appendChild(video);
|
||||
if (!job.active())
|
||||
video.remove();
|
||||
return video;
|
||||
}
|
||||
function extract(job, config) {
|
||||
const video = createVideo(job);
|
||||
if (!job.active())
|
||||
return;
|
||||
video.onerror = job.guard(() => {
|
||||
throw video.error || new Error("Auto-thumbnail media failed to load");
|
||||
});
|
||||
video.onloadedmetadata = job.guard(() => {
|
||||
video.onloadedmetadata = null;
|
||||
const duration = video.duration;
|
||||
const videoHeight = video.videoHeight;
|
||||
const videoWidth = video.videoWidth;
|
||||
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
|
||||
duration,
|
||||
videoHeight,
|
||||
videoWidth
|
||||
});
|
||||
if (!job.active())
|
||||
return;
|
||||
video.width = videoWidth;
|
||||
video.height = videoHeight;
|
||||
video.style.width = `${videoWidth}px`;
|
||||
video.style.height = `${videoHeight}px`;
|
||||
const canvas = document.createElement("canvas");
|
||||
if (!job.active())
|
||||
return;
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx)
|
||||
throw new Error("Auto-thumbnail canvas context is unavailable");
|
||||
if (!job.active())
|
||||
return;
|
||||
let index = 0;
|
||||
const seek = job.guard(() => {
|
||||
if (index >= config.number) {
|
||||
job.dispose();
|
||||
return;
|
||||
}
|
||||
const target = duration * index / config.number;
|
||||
let retries = 0;
|
||||
video.onseeked = job.guard(() => {
|
||||
if (video.seeking)
|
||||
return;
|
||||
const time = video.currentTime;
|
||||
if (!job.active())
|
||||
return;
|
||||
if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) {
|
||||
if (++retries > 3)
|
||||
throw new Error("Auto-thumbnail seek did not reach the requested time");
|
||||
video.currentTime = target;
|
||||
return;
|
||||
}
|
||||
video.onseeked = null;
|
||||
ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height);
|
||||
if (!job.active())
|
||||
return;
|
||||
let delivered = false;
|
||||
canvas.toBlob(job.guard((blob) => {
|
||||
if (delivered)
|
||||
return;
|
||||
delivered = true;
|
||||
job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale });
|
||||
if (job.active()) {
|
||||
index += 1;
|
||||
seek();
|
||||
}
|
||||
}), "image/jpeg");
|
||||
});
|
||||
if (job.active())
|
||||
video.currentTime = target;
|
||||
});
|
||||
seek();
|
||||
});
|
||||
if (job.active())
|
||||
video.src = config.url;
|
||||
}
|
||||
function cleanupAll(actions) {
|
||||
let failure;
|
||||
let failed = false;
|
||||
@@ -246,6 +125,217 @@ function createSession(publish, report) {
|
||||
}
|
||||
};
|
||||
}
|
||||
function createFrameReader(job, video) {
|
||||
const presentedFrames = typeof video.requestVideoFrameCallback === "function" && typeof video.cancelVideoFrameCallback === "function";
|
||||
let pending;
|
||||
function clear() {
|
||||
const previous = pending;
|
||||
pending = void 0;
|
||||
video.onloadeddata = null;
|
||||
video.onseeked = null;
|
||||
if (!previous)
|
||||
return;
|
||||
const result = cleanupAll([
|
||||
() => clearTimeout(previous.timer),
|
||||
() => {
|
||||
if (previous.frame !== null)
|
||||
video.cancelVideoFrameCallback(previous.frame);
|
||||
}
|
||||
]);
|
||||
if (result.failed)
|
||||
throw result.failure;
|
||||
}
|
||||
job.own(clear);
|
||||
return (target, draw) => {
|
||||
clear();
|
||||
if (!job.active())
|
||||
return;
|
||||
const current = { frame: null, timer: null, sought: false, presented: !presentedFrames, retries: 0, sequence: 0, started: false };
|
||||
pending = current;
|
||||
const active = () => job.active() && pending === current;
|
||||
const finish = job.guard(() => {
|
||||
if (!active() || !current.sought || !current.presented)
|
||||
return;
|
||||
clear();
|
||||
if (job.active())
|
||||
draw();
|
||||
});
|
||||
const requestFrame = () => {
|
||||
if (!presentedFrames)
|
||||
return;
|
||||
const sequence = ++current.sequence;
|
||||
current.presented = false;
|
||||
const previous = current.frame;
|
||||
current.frame = null;
|
||||
if (previous !== null)
|
||||
video.cancelVideoFrameCallback(previous);
|
||||
if (!active())
|
||||
return;
|
||||
let delivered = false;
|
||||
const id = video.requestVideoFrameCallback(job.guard(() => {
|
||||
if (!active() || sequence !== current.sequence || delivered)
|
||||
return;
|
||||
delivered = true;
|
||||
current.frame = null;
|
||||
current.presented = true;
|
||||
finish();
|
||||
}));
|
||||
if (active() && sequence === current.sequence && !delivered)
|
||||
current.frame = id;
|
||||
else
|
||||
video.cancelVideoFrameCallback(id);
|
||||
};
|
||||
const seek = () => {
|
||||
current.sought = false;
|
||||
requestFrame();
|
||||
if (active())
|
||||
video.currentTime = target;
|
||||
};
|
||||
const begin = job.guard(() => {
|
||||
if (!active() || current.started)
|
||||
return;
|
||||
current.started = true;
|
||||
video.onloadeddata = null;
|
||||
video.onseeked = job.guard(() => {
|
||||
if (!active() || video.seeking)
|
||||
return;
|
||||
const time = video.currentTime;
|
||||
if (!active())
|
||||
return;
|
||||
if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) {
|
||||
if (++current.retries > 3)
|
||||
throw new Error("Auto-thumbnail seek did not reach the requested time");
|
||||
seek();
|
||||
return;
|
||||
}
|
||||
current.sought = true;
|
||||
finish();
|
||||
});
|
||||
seek();
|
||||
});
|
||||
current.timer = setTimeout(job.guard(() => {
|
||||
if (active())
|
||||
throw new Error("Auto-thumbnail frame readiness timed out");
|
||||
}), 3e4);
|
||||
if (!active()) {
|
||||
clearTimeout(current.timer);
|
||||
return;
|
||||
}
|
||||
const ready = !presentedFrames || video.readyState >= 2;
|
||||
if (!active())
|
||||
return;
|
||||
if (!ready)
|
||||
video.onloadeddata = begin;
|
||||
else
|
||||
begin();
|
||||
};
|
||||
}
|
||||
function readOptions(option, getFallbackUrl) {
|
||||
const config = {
|
||||
url: option.url || getFallbackUrl(),
|
||||
width: option.width || 160,
|
||||
number: option.number || 100,
|
||||
scale: option.scale || 1
|
||||
};
|
||||
if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
|
||||
throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");
|
||||
return config;
|
||||
}
|
||||
function sheetSize(config, video) {
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
|
||||
throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");
|
||||
const height = Math.floor(config.width * video.videoHeight / video.videoWidth);
|
||||
const canvasWidth = Math.trunc(config.width * 10);
|
||||
const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10));
|
||||
if (![height, canvasWidth, canvasHeight].every((value) => Number.isSafeInteger(value) && value > 0 && value <= 4294967295))
|
||||
throw new RangeError("Auto-thumbnail canvas dimensions are invalid");
|
||||
return { height, canvasWidth, canvasHeight };
|
||||
}
|
||||
function createVideo(job) {
|
||||
const video = document.createElement("video");
|
||||
job.own(() => video.remove());
|
||||
job.own(() => video.load());
|
||||
job.own(() => video.removeAttribute("src"));
|
||||
job.own(() => video.pause());
|
||||
for (const property of ["onloadedmetadata", "onloadeddata", "onseeked", "onerror"])
|
||||
job.own(() => {
|
||||
video[property] = null;
|
||||
});
|
||||
if (!job.active())
|
||||
return video;
|
||||
video.crossOrigin = "anonymous";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.tabIndex = -1;
|
||||
video.setAttribute("aria-hidden", "true");
|
||||
video.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none";
|
||||
document.documentElement.appendChild(video);
|
||||
if (!job.active())
|
||||
video.remove();
|
||||
return video;
|
||||
}
|
||||
function extract(job, config) {
|
||||
const video = createVideo(job);
|
||||
if (!job.active())
|
||||
return;
|
||||
video.onerror = job.guard(() => {
|
||||
throw video.error || new Error("Auto-thumbnail media failed to load");
|
||||
});
|
||||
video.onloadedmetadata = job.guard(() => {
|
||||
video.onloadedmetadata = null;
|
||||
const duration = video.duration;
|
||||
const videoHeight = video.videoHeight;
|
||||
const videoWidth = video.videoWidth;
|
||||
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
|
||||
duration,
|
||||
videoHeight,
|
||||
videoWidth
|
||||
});
|
||||
if (!job.active())
|
||||
return;
|
||||
video.width = videoWidth;
|
||||
video.height = videoHeight;
|
||||
video.style.width = `${videoWidth}px`;
|
||||
video.style.height = `${videoHeight}px`;
|
||||
const canvas = document.createElement("canvas");
|
||||
if (!job.active())
|
||||
return;
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx)
|
||||
throw new Error("Auto-thumbnail canvas context is unavailable");
|
||||
if (!job.active())
|
||||
return;
|
||||
const readFrame = createFrameReader(job, video);
|
||||
let index = 0;
|
||||
const seek = job.guard(() => {
|
||||
if (index >= config.number) {
|
||||
job.dispose();
|
||||
return;
|
||||
}
|
||||
readFrame(duration * index / config.number, job.guard(() => {
|
||||
ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height);
|
||||
if (!job.active())
|
||||
return;
|
||||
let delivered = false;
|
||||
canvas.toBlob(job.guard((blob) => {
|
||||
if (delivered)
|
||||
return;
|
||||
delivered = true;
|
||||
job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale });
|
||||
if (job.active()) {
|
||||
index += 1;
|
||||
seek();
|
||||
}
|
||||
}), "image/jpeg");
|
||||
}));
|
||||
});
|
||||
seek();
|
||||
});
|
||||
if (job.active())
|
||||
video.src = config.url;
|
||||
}
|
||||
function artplayerPluginAutoThumbnail(option) {
|
||||
return async (art) => {
|
||||
const report = (error) => console.warn("ArtPlayer auto-thumbnail failed:", error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import createFrameReader from './frames'
|
||||
import { sheetSize } from './options'
|
||||
import createVideo from './video'
|
||||
|
||||
@@ -34,27 +35,14 @@ export default function extract(job, config) {
|
||||
throw new Error('Auto-thumbnail canvas context is unavailable')
|
||||
if (!job.active())
|
||||
return
|
||||
const readFrame = createFrameReader(job, video)
|
||||
let index = 0
|
||||
const seek = job.guard(() => {
|
||||
if (index >= config.number) {
|
||||
job.dispose()
|
||||
return
|
||||
}
|
||||
const target = duration * index / config.number
|
||||
let retries = 0
|
||||
video.onseeked = job.guard(() => {
|
||||
if (video.seeking)
|
||||
return
|
||||
const time = video.currentTime
|
||||
if (!job.active())
|
||||
return
|
||||
if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) {
|
||||
if (++retries > 3)
|
||||
throw new Error('Auto-thumbnail seek did not reach the requested time')
|
||||
video.currentTime = target
|
||||
return
|
||||
}
|
||||
video.onseeked = null
|
||||
readFrame(duration * index / config.number, job.guard(() => {
|
||||
ctx.drawImage(video, (index % 10) * config.width, Math.floor(index / 10) * height, config.width, height)
|
||||
if (!job.active())
|
||||
return
|
||||
@@ -69,9 +57,7 @@ export default function extract(job, config) {
|
||||
seek()
|
||||
}
|
||||
}), 'image/jpeg')
|
||||
})
|
||||
if (job.active())
|
||||
video.currentTime = target
|
||||
}))
|
||||
})
|
||||
seek()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { cleanupAll } from './session'
|
||||
|
||||
export default function createFrameReader(job, video) {
|
||||
const presentedFrames = typeof video.requestVideoFrameCallback === 'function'
|
||||
&& typeof video.cancelVideoFrameCallback === 'function'
|
||||
let pending
|
||||
|
||||
function clear() {
|
||||
const previous = pending
|
||||
pending = undefined
|
||||
video.onloadeddata = null
|
||||
video.onseeked = null
|
||||
if (!previous)
|
||||
return
|
||||
const result = cleanupAll([
|
||||
() => clearTimeout(previous.timer),
|
||||
() => {
|
||||
if (previous.frame !== null)
|
||||
video.cancelVideoFrameCallback(previous.frame)
|
||||
},
|
||||
])
|
||||
if (result.failed)
|
||||
throw result.failure
|
||||
}
|
||||
job.own(clear)
|
||||
|
||||
return (target, draw) => {
|
||||
clear()
|
||||
if (!job.active())
|
||||
return
|
||||
const current = { frame: null, timer: null, sought: false, presented: !presentedFrames, retries: 0, sequence: 0, started: false }
|
||||
pending = current
|
||||
const active = () => job.active() && pending === current
|
||||
const finish = job.guard(() => {
|
||||
if (!active() || !current.sought || !current.presented)
|
||||
return
|
||||
clear()
|
||||
if (job.active())
|
||||
draw()
|
||||
})
|
||||
const requestFrame = () => {
|
||||
if (!presentedFrames)
|
||||
return
|
||||
const sequence = ++current.sequence
|
||||
current.presented = false
|
||||
const previous = current.frame
|
||||
current.frame = null
|
||||
if (previous !== null)
|
||||
video.cancelVideoFrameCallback(previous)
|
||||
if (!active())
|
||||
return
|
||||
let delivered = false
|
||||
const id = video.requestVideoFrameCallback(job.guard(() => {
|
||||
if (!active() || sequence !== current.sequence || delivered)
|
||||
return
|
||||
delivered = true
|
||||
current.frame = null
|
||||
current.presented = true
|
||||
finish()
|
||||
}))
|
||||
if (active() && sequence === current.sequence && !delivered)
|
||||
current.frame = id
|
||||
else
|
||||
video.cancelVideoFrameCallback(id)
|
||||
}
|
||||
const seek = () => {
|
||||
current.sought = false
|
||||
requestFrame()
|
||||
if (active())
|
||||
video.currentTime = target
|
||||
}
|
||||
const begin = job.guard(() => {
|
||||
if (!active() || current.started)
|
||||
return
|
||||
current.started = true
|
||||
video.onloadeddata = null
|
||||
video.onseeked = job.guard(() => {
|
||||
if (!active() || video.seeking)
|
||||
return
|
||||
const time = video.currentTime
|
||||
if (!active())
|
||||
return
|
||||
if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) {
|
||||
if (++current.retries > 3)
|
||||
throw new Error('Auto-thumbnail seek did not reach the requested time')
|
||||
seek()
|
||||
return
|
||||
}
|
||||
current.sought = true
|
||||
finish()
|
||||
})
|
||||
seek()
|
||||
})
|
||||
current.timer = setTimeout(job.guard(() => {
|
||||
if (active())
|
||||
throw new Error('Auto-thumbnail frame readiness timed out')
|
||||
}), 30000)
|
||||
if (!active()) {
|
||||
clearTimeout(current.timer)
|
||||
return
|
||||
}
|
||||
const ready = !presentedFrames || video.readyState >= 2
|
||||
if (!active())
|
||||
return
|
||||
if (!ready)
|
||||
video.onloadeddata = begin
|
||||
else
|
||||
begin()
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export default function createVideo(job) {
|
||||
job.own(() => video.load())
|
||||
job.own(() => video.removeAttribute('src'))
|
||||
job.own(() => video.pause())
|
||||
for (const property of ['onloadedmetadata', 'onseeked', 'onerror'])
|
||||
for (const property of ['onloadedmetadata', 'onloadeddata', 'onseeked', 'onerror'])
|
||||
job.own(() => { video[property] = null })
|
||||
if (!job.active())
|
||||
return video
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
# PKG-AUTO-THUMB-03 原生帧就绪检查点(doing)
|
||||
|
||||
新增frames.js,集中拥有每次抽帧的数据等待、seek、原生帧回调和30秒deadline。
|
||||
同时支持requestVideoFrameCallback及cancelVideoFrameCallback时,等待loadeddata后
|
||||
注册回调再seek,必须seek完成且帧已呈现才draw。无该能力时仍用已记录的seek路径,
|
||||
不以UA字符串选择浏览器。每次重试递增回调身份,阻止被取消的旧回调完成当前帧;
|
||||
每个job只注册一个统一清理函数,避免逐帧累计清理闭包。
|
||||
|
||||
销毁、切源、正常完成及失败均释放timer、帧回调、data/seek处理器。句柄0有效;
|
||||
同步注册/销毁交错产生的迟到句柄也回收。清理一个资源抛错仍继续其他清理,失败保留
|
||||
之前的可用预览。等待超时使用既有console.warn通道;未新增公开参数、结果或依赖,
|
||||
不改变注册Promise和目标时间公式。30秒是每次数据/seek/帧等待上限,尚不是全部网络、
|
||||
编码、后台标签和整体内存预算;不声称已完成这些边界。
|
||||
|
||||
新增19项回归,纳入test:unit及test:auto-thumbnail。此前a128706f检查点19项为
|
||||
2通过/17失败;两项保留原兼容行为,其余覆盖就绪、两种事件顺序、超时、取消、重试、
|
||||
重复/迟到回调、同步重入、清理异常及旧预览保留。加上原33项生命周期共52项。
|
||||
|
||||
提交前追加审查抓到readyState getter触发销毁后仍可能装回loadeddata处理器;新测试
|
||||
在修复前19项18通过/1失败,修复为读完就绪状态后再核对任务身份。此前1817项CI和84项
|
||||
原生结果保留为中间证据,最终证据使用frames-final文件,重新构建后核对全部候选。
|
||||
|
||||
真实像素验收增强为:有原生帧回调时必须五格全部正确,包括单独紫色首帧、第二格红色、
|
||||
合法黑色、蓝色和黄色,同时核对实际呈现时间。空间样本防止1px平均色伪修复。新增原生
|
||||
回调被挂起期间destroy/restart测试,之后重复交付也不生成URL/写入/恢复decoder。
|
||||
无原生回调的浏览器相应场景使用原生Blob挂起,报告明确标出能力和路径。
|
||||
|
||||
Chromium/Firefox此前的早期像素问题进入候选验证;Windows WebKit26.6没有该帧回调,
|
||||
前两格继续诊断而不验收,仅第2-4格沿用已有像素断言。未将实验或局部通过扩成全浏览器
|
||||
正确,也未关闭AUTO-THUMB-PIXEL-01、标记03完成。物理Safari及核心组合仍属后续验收。
|
||||
|
||||
正常构建再生三份dist及docs副本,架构地图和任务/风险证据同步。TS仍留在04。
|
||||
见[验证](../baselines/auto-thumbnail-frame-presentation.json)。
|
||||
|
||||
最终源码/main/legacy各52项单元通过;原生源码两轮42项及main/legacy各21项,共84项
|
||||
通过,0重试/跳过/未处理页面错误。完整CI1818项(1572单元+14工程+232基线)、339生产TS
|
||||
及4种实际bundle入口通过;产物与docs副本字节相等。旧产物新浏览器断言2通过/4失败,
|
||||
包括缺少原生呈现证据和Firefox早期错误像素;不得称为所有旧浏览器都出现同一故障。
|
||||
|
||||
独立密集采样实验在Chromium/Firefox对0/.005/.01/.02/.03/.04秒均收到帧回调,前五个
|
||||
目标保留紫色首帧,.04秒为红色,未出现同一帧重复采样导致等待卡死。该实验不是完整
|
||||
默认100格候选验收。WebKit另测createImageBitmap(video)仍有首帧偏移,VideoFrame
|
||||
不可用;保留原始结果,不据此豁免像素风险。
|
||||
|
||||
去B帧的独立媒体对照中,Chromium仍读到紫色首帧,而Windows WebKit隐藏元素0秒
|
||||
仍为空、.01秒已变红,普通元素的一轮seek还超时;不能把问题简单归因于B帧,也不能
|
||||
统一偏移.04秒。该诊断保留输入指纹和生成命令,未修改生产回退算法或声明能力豁免。
|
||||
+1
-1
@@ -496,7 +496,7 @@
|
||||
- PKG-AUDIO-05: [记录](changes/2026-09-12-PKG-AUDIO-05-combinations.md) [记录](baselines/audio-combinations-first.json) [记录](baselines/audio-combinations-checkpoint.json) [记录](audio-validation.md)
|
||||
- PKG-AUTO-THUMB-01: [记录](baselines/auto-thumbnail-release.json) [记录](baselines/auto-thumbnail-contract.md) [记录](changes/2026-09-13-PKG-AUTO-THUMB-01-contract.md) [记录](baselines/auto-thumbnail-contract-validation.json)
|
||||
- PKG-AUTO-THUMB-02: [记录](baselines/auto-thumbnail-failures.md) [记录](baselines/auto-thumbnail-failures-validation.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-02-failures.md)
|
||||
- PKG-AUTO-THUMB-03: [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md) [记录](baselines/auto-thumbnail-lifecycle-checkpoint.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-hidden-renderer.md) [记录](baselines/auto-thumbnail-hidden-renderer.json) [记录](baselines/auto-thumbnail-timeline-media.json)
|
||||
- PKG-AUTO-THUMB-03: [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md) [记录](baselines/auto-thumbnail-lifecycle-checkpoint.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-hidden-renderer.md) [记录](baselines/auto-thumbnail-hidden-renderer.json) [记录](baselines/auto-thumbnail-timeline-media.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-frame-presentation.md) [记录](baselines/auto-thumbnail-frame-presentation.json)
|
||||
- PKG-HLS-01: [记录](baselines/hls-control-contract.md) [记录](baselines/hls-control-release.json) [记录](changes/2026-09-11-PKG-HLS-01-contract.md)
|
||||
- PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md)
|
||||
- PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md)
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 进度与证据
|
||||
|
||||
## PKG-AUTO-THUMB-03 原生帧就绪检查点(doing)
|
||||
|
||||
新增frames模块,对支持的浏览器同时等待seek完成与原生帧呈现,补齐取消、超时、
|
||||
重复/迟到回调和重试身份。Chromium/Firefox五格像素全部验收,WebKit缺该API时前两格
|
||||
仍只诊断,像素风险继续open。19项新回归此前2通过/17失败;源码/main/legacy各52通过。
|
||||
最终原生源码两轮42项,main/legacy各21项,共84项通过;包含真实帧挂起时destroy/restart。
|
||||
完整CI1818项、339生产TS及4种bundle入口通过;见[验证](baselines/auto-thumbnail-frame-presentation.json);
|
||||
[变更](changes/2026-09-13-PKG-AUTO-THUMB-03-frame-presentation.md)记录能力边界和后续要求。
|
||||
正常构建及文档同步,TS仍属04。222项:114 done、12 doing、96 todo;03未标完成。
|
||||
|
||||
|
||||
## PKG-AUTO-THUMB-03 隐藏解码与错时seek重试检查点(doing)
|
||||
|
||||
新增video模块,挂载隐藏且保持实际尺寸的解码元素,完善DOM回收及插入/清理重入。
|
||||
|
||||
+4
-2
@@ -4113,11 +4113,13 @@
|
||||
"refactor/baselines/auto-thumbnail-failures-validation.json",
|
||||
"test/browser/auto-thumbnail-pixels.spec.js",
|
||||
"refactor/baselines/auto-thumbnail-hidden-renderer.json",
|
||||
"refactor/baselines/auto-thumbnail-timeline-media.json"
|
||||
"refactor/baselines/auto-thumbnail-timeline-media.json",
|
||||
"test/auto-thumbnail-frames.test.js",
|
||||
"refactor/baselines/auto-thumbnail-frame-presentation.json"
|
||||
],
|
||||
"compatibleResolution": "Establish actual decoded-frame readiness for the existing timeline/grid and preserve silent independent extraction; do not waive colored pixel checks as unsupported media.",
|
||||
"closureCriteria": "Candidate source and artifacts generate usable sampled pixels on native browser controls with lifecycle checks; supported Safari and core combinations remain explicit final gates.",
|
||||
"workspaceState": "Hidden intrinsic-sized attached decoder mitigates detached WebKit black later frames. Source 45 repeated native cases plus main/legacy 15 each verify lifecycle and cells 2-4 only. Initial cells 0-1 remain diagnostic: transparent/stale draws and Windows WebKit first-frame timing remain unresolved. Bounded retries now reject wrong-time seeked events before drawing. Do not close this risk or task03 based on partial pixel acceptance."
|
||||
"workspaceState": "The frames module now waits for native presentation and seek completion on capable browsers. Chromium/Firefox source/main/legacy assert all five cells including first purple frame, second red cell, legitimate black and presentation times. Callback-less Windows WebKit first two cells remain diagnostic, later cells asserted; physical Safari/core combinations remain open. 84 native lifecycle/pixel cases passed with explicit held native-frame vs Blob-fallback paths; 19 new Node cases fail 17 ways on prior checkpoint and all 52 current cases pass per candidate. Risk and task03 remain open."
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-1
@@ -1786,7 +1786,9 @@
|
||||
"baselines/auto-thumbnail-lifecycle-checkpoint.json",
|
||||
"changes/2026-09-13-PKG-AUTO-THUMB-03-hidden-renderer.md",
|
||||
"baselines/auto-thumbnail-hidden-renderer.json",
|
||||
"baselines/auto-thumbnail-timeline-media.json"
|
||||
"baselines/auto-thumbnail-timeline-media.json",
|
||||
"changes/2026-09-13-PKG-AUTO-THUMB-03-frame-presentation.md",
|
||||
"baselines/auto-thumbnail-frame-presentation.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Repository Node frame ownership regression runner.
|
||||
import test from 'node:test'
|
||||
import { autoThumbnailCandidate, autoThumbnailEnvironment } from './helpers/auto-thumbnail.js'
|
||||
|
||||
const implementation = await autoThumbnailCandidate()
|
||||
|
||||
async function setup({ readyState = 4, cancel = true } = {}) {
|
||||
const env = autoThumbnailEnvironment(implementation)
|
||||
const frames = new Map()
|
||||
const canceled = []
|
||||
let nextFrame = 0
|
||||
await env.factory({ width: 80, number: 2 })(env.art)
|
||||
env.art.emit('video:loadedmetadata')
|
||||
const video = env.videos[0]
|
||||
video.readyState = readyState
|
||||
video.requestVideoFrameCallback = (callback) => {
|
||||
const id = nextFrame++
|
||||
frames.set(id, callback)
|
||||
return id
|
||||
}
|
||||
if (cancel) {
|
||||
video.cancelVideoFrameCallback = (id) => {
|
||||
canceled.push(id)
|
||||
frames.delete(id)
|
||||
}
|
||||
}
|
||||
return Object.assign(env, { video, frames, canceled, present(id = frames.keys().next().value) {
|
||||
const callback = frames.get(id)
|
||||
frames.delete(id)
|
||||
callback(0, { mediaTime: video.currentTime })
|
||||
} })
|
||||
}
|
||||
|
||||
function closed(env) {
|
||||
assert.equal(env.attached.size, 0)
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(env.video.onloadeddata, null)
|
||||
assert.equal(env.video.onseeked, null)
|
||||
assert.equal(env.video.src, undefined)
|
||||
}
|
||||
|
||||
test('Auto-thumbnail frames wait for loadeddata before starting presentation-aware seeking', async () => {
|
||||
const env = await setup({ readyState: 1 })
|
||||
env.metadata()
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal(env.operations.some(item => item.name === 'seek'), false)
|
||||
assert.equal(env.timers.size, 1)
|
||||
const loaded = env.video.onloadeddata
|
||||
env.video.readyState = 4
|
||||
loaded()
|
||||
loaded()
|
||||
assert.equal(env.frames.size, 1)
|
||||
assert.equal(env.operations.filter(item => item.name === 'seek').length, 1)
|
||||
env.art.emit('destroy')
|
||||
closed(env)
|
||||
})
|
||||
|
||||
for (const order of ['seek-first', 'frame-first']) {
|
||||
test(`Auto-thumbnail frames need both seek completion and presentation: ${order}`, async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
const seeked = env.video.onseeked
|
||||
const frame = env.frames.get(0)
|
||||
if (order === 'seek-first')
|
||||
env.seeked()
|
||||
else env.present()
|
||||
assert.equal(env.blobs.length, 0)
|
||||
if (order === 'seek-first')
|
||||
env.present()
|
||||
else env.seeked()
|
||||
assert.equal(env.blobs.length, 1)
|
||||
assert.equal(env.timers.size, 0)
|
||||
seeked()
|
||||
frame()
|
||||
assert.equal(env.blobs.length, 1)
|
||||
env.finish()
|
||||
seeked()
|
||||
frame()
|
||||
assert.equal(env.blobs.length, 1, 'Old callbacks cannot complete the next frame')
|
||||
env.seeked()
|
||||
env.present()
|
||||
env.finish(1)
|
||||
closed(env)
|
||||
assert.equal(env.updates.length, 2)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.urls.size, 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const phase of ['data', 'seek', 'frame']) {
|
||||
test(`Auto-thumbnail frames release ${phase} waits and ignore callbacks after destroy`, async () => {
|
||||
const env = await setup({ readyState: phase === 'data' ? 1 : 4 })
|
||||
env.metadata()
|
||||
const loaded = env.video.onloadeddata
|
||||
const frame = env.frames.get(0)
|
||||
const seeked = env.video.onseeked
|
||||
const timer = [...env.timers.values()][0].callback
|
||||
if (phase === 'frame')
|
||||
env.seeked()
|
||||
env.art.emit('destroy')
|
||||
loaded?.()
|
||||
frame?.()
|
||||
seeked?.()
|
||||
timer()
|
||||
assert.equal(env.blobs.length, 0)
|
||||
assert.equal(env.warnings.length, 0)
|
||||
closed(env)
|
||||
})
|
||||
}
|
||||
|
||||
test('Auto-thumbnail frames invalidate canceled presentation callbacks when retrying the same target', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
const first = env.frames.get(0)
|
||||
env.video.timeOffset = 1
|
||||
env.seeked()
|
||||
assert.deepEqual(env.canceled, [0])
|
||||
first()
|
||||
env.video.timeOffset = 0
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 0)
|
||||
env.present(1)
|
||||
assert.equal(env.blobs.length, 1)
|
||||
env.art.emit('destroy')
|
||||
closed(env)
|
||||
})
|
||||
|
||||
for (const phase of ['data', 'seek', 'frame']) {
|
||||
test(`Auto-thumbnail frames time out a stalled ${phase} wait without leaking callbacks`, async () => {
|
||||
const env = await setup({ readyState: phase === 'data' ? 1 : 4 })
|
||||
env.metadata()
|
||||
if (phase === 'frame')
|
||||
env.seeked()
|
||||
const timer = [...env.timers.values()][0]
|
||||
assert.equal(timer.delay, 30000)
|
||||
timer.callback()
|
||||
assert.equal(env.warnings.length, 1)
|
||||
assert.match(env.warnings[0][1].message, /frame readiness timed out/)
|
||||
assert.equal(env.blobs.length, 0)
|
||||
closed(env)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('Auto-thumbnail frames clean up the decoder even if native callback cancellation throws', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
const cancel = env.video.cancelVideoFrameCallback
|
||||
const failure = new Error('Frame cancellation failed')
|
||||
env.video.cancelVideoFrameCallback = (id) => {
|
||||
cancel(id)
|
||||
throw failure
|
||||
}
|
||||
env.art.emit('destroy')
|
||||
closed(env)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail frames recover a callback handle registered across synchronous destruction', async () => {
|
||||
const env = await setup()
|
||||
const request = env.video.requestVideoFrameCallback
|
||||
env.video.requestVideoFrameCallback = (callback) => {
|
||||
env.art.emit('destroy')
|
||||
return request(callback)
|
||||
}
|
||||
env.metadata()
|
||||
closed(env)
|
||||
assert.deepEqual(env.canceled, [0])
|
||||
assert.equal(env.operations.some(item => item.name === 'seek'), false)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail frames recover a timer handle created across synchronous destruction', async () => {
|
||||
const env = await setup()
|
||||
const schedule = env.box.setTimeout
|
||||
env.box.setTimeout = (...args) => {
|
||||
env.art.emit('destroy')
|
||||
return schedule(...args)
|
||||
}
|
||||
env.metadata()
|
||||
closed(env)
|
||||
assert.equal(env.operations.some(item => item.name === 'seek'), false)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail frames preserve fallback seeking when native callback cancellation is absent', async () => {
|
||||
const env = await setup({ cancel: false })
|
||||
env.metadata()
|
||||
assert.equal(env.frames.size, 0)
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 1)
|
||||
env.art.emit('destroy')
|
||||
closed(env)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail frames preserve an earlier preview if subsequent presentation times out', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.present()
|
||||
env.finish()
|
||||
const previous = env.art.thumbnails
|
||||
env.seeked()
|
||||
const timer = [...env.timers.values()][0]
|
||||
timer.callback()
|
||||
assert.equal(env.art.thumbnails, previous)
|
||||
assert.equal(env.urls.has(previous.url), true)
|
||||
closed(env)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.urls.size, 0)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail frames accept synchronous presentation without leaking its returned handle', async () => {
|
||||
const env = await setup()
|
||||
const request = env.video.requestVideoFrameCallback
|
||||
env.video.requestVideoFrameCallback = (callback) => {
|
||||
const id = request(callback)
|
||||
callback()
|
||||
return id
|
||||
}
|
||||
env.metadata()
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal(env.blobs.length, 0)
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 1)
|
||||
env.art.emit('destroy')
|
||||
closed(env)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail frames release callbacks and the decoder when clearing its timer throws', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
const clear = env.box.clearTimeout
|
||||
const failure = new Error('Timer cleanup failed')
|
||||
env.box.clearTimeout = (id) => {
|
||||
clear(id)
|
||||
throw failure
|
||||
}
|
||||
env.art.emit('destroy')
|
||||
closed(env)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail frames own native registration errors and clear the deadline', async () => {
|
||||
const env = await setup()
|
||||
const failure = new Error('Frame registration failed')
|
||||
env.video.requestVideoFrameCallback = () => {
|
||||
throw failure
|
||||
}
|
||||
env.metadata()
|
||||
closed(env)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail frames cannot restore a loadeddata handler after readiness getter destruction', async () => {
|
||||
const env = await setup()
|
||||
Object.defineProperty(env.video, 'readyState', { get() {
|
||||
env.art.emit('destroy')
|
||||
return 1
|
||||
} })
|
||||
env.metadata()
|
||||
closed(env)
|
||||
assert.equal(env.operations.some(item => item.name === 'seek'), false)
|
||||
})
|
||||
@@ -3,21 +3,29 @@ import { autoThumbnailCandidate } from '../helpers/auto-thumbnail.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await autoThumbnailCandidate()
|
||||
for (const scenario of ['destroy', 'restart', 'complete']) {
|
||||
for (const scenario of ['destroy', 'restart', 'complete', 'frame-destroy', 'frame-restart']) {
|
||||
test(`Auto-thumbnail candidate native ${scenario} owns decoder and encoded URLs`, async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=published')
|
||||
await page.setContent('<!doctype html><div></div>')
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await page.evaluate(async (hold) => {
|
||||
const probe = window.probe = { videos: [], updates: [], urls: new Set(), blobs: [], listeners: new Map(), hold }
|
||||
await page.evaluate(async (scenario) => {
|
||||
const probe = window.probe = { videos: [], updates: [], urls: new Set(), blobs: [], frames: [], frameSupport: false, listeners: new Map(), hold: scenario !== 'complete' }
|
||||
const createElement = document.createElement.bind(document)
|
||||
const createUrl = URL.createObjectURL.bind(URL)
|
||||
const revokeUrl = URL.revokeObjectURL.bind(URL)
|
||||
const toBlob = HTMLCanvasElement.prototype.toBlob
|
||||
document.createElement = function (name, ...args) {
|
||||
const element = createElement(name, ...args)
|
||||
if (name === 'video')
|
||||
if (name === 'video') {
|
||||
probe.videos.push(element)
|
||||
const request = element.requestVideoFrameCallback?.bind(element)
|
||||
probe.frameSupport = Boolean(request && element.cancelVideoFrameCallback)
|
||||
if (probe.frameSupport && scenario.startsWith('frame-')) {
|
||||
element.requestVideoFrameCallback = callback => request((...args) => {
|
||||
probe.frames.push({ callback, args })
|
||||
})
|
||||
}
|
||||
}
|
||||
return element
|
||||
}
|
||||
HTMLCanvasElement.prototype.toBlob = function (callback, type) {
|
||||
@@ -63,32 +71,41 @@ for (const scenario of ['destroy', 'restart', 'complete']) {
|
||||
}
|
||||
probe.result = await window.artplayerPluginAutoThumbnail({ width: 80, number: 2 })(probe.art)
|
||||
probe.art.emit('video:loadedmetadata')
|
||||
}, scenario !== 'complete')
|
||||
await expect.poll(() => page.evaluate(() => window.probe.blobs.length)).toBe(scenario === 'complete' ? 2 : 1)
|
||||
}, scenario)
|
||||
const holdFrame = scenario.startsWith('frame-') && await page.evaluate(() => window.probe.frameSupport)
|
||||
await expect.poll(() => page.evaluate(holdFrame => holdFrame ? window.probe.frames.length : window.probe.blobs.length, holdFrame)).toBe(scenario === 'complete' ? 2 : 1)
|
||||
const state = await page.evaluate((scenario) => {
|
||||
const probe = window.probe
|
||||
const before = { urls: probe.urls.size, updates: probe.updates.length }
|
||||
if (scenario !== 'complete') {
|
||||
probe.art.emit(scenario)
|
||||
probe.blobs[0].callback(probe.blobs[0].blob)
|
||||
probe.blobs[0].callback(probe.blobs[0].blob)
|
||||
probe.art.emit(scenario.replace('frame-', ''))
|
||||
if (probe.blobs[0]) {
|
||||
probe.blobs[0].callback(probe.blobs[0].blob)
|
||||
probe.blobs[0].callback(probe.blobs[0].blob)
|
||||
}
|
||||
if (probe.frames[0]) {
|
||||
probe.frames[0].callback(...probe.frames[0].args)
|
||||
probe.frames[0].callback(...probe.frames[0].args)
|
||||
}
|
||||
}
|
||||
const after = { urls: probe.urls.size, updates: probe.updates.length }
|
||||
const decoder = probe.videos[0]
|
||||
const video = { connected: decoder.isConnected, src: decoder.getAttribute('src'), readyState: decoder.readyState, paused: decoder.paused, handlers: ['onloadedmetadata', 'onseeked', 'onerror'].map(key => decoder[key] === null) }
|
||||
const video = { connected: decoder.isConnected, src: decoder.getAttribute('src'), readyState: decoder.readyState, paused: decoder.paused, handlers: ['onloadedmetadata', 'onloadeddata', 'onseeked', 'onerror'].map(key => decoder[key] === null) }
|
||||
probe.art.emit('destroy')
|
||||
probe.art.emit('destroy')
|
||||
const final = { urls: probe.urls.size, listeners: [...probe.listeners.values()].reduce((sum, set) => sum + set.size, 0), updates: probe.updates.length }
|
||||
const blobs = probe.blobs.map(({ blob }) => ({ type: blob.type, bytes: blob.size }))
|
||||
probe.restore()
|
||||
return { before, after, video, final, blobs, result: probe.result }
|
||||
return { before, after, video, final, blobs, heldFrames: probe.frames.length, frameSupport: probe.frameSupport, result: probe.result }
|
||||
}, scenario)
|
||||
await testInfo.attach('auto-thumbnail-candidate-native-lifecycle', { contentType: 'application/json', body: JSON.stringify({ scenario, sha256: hash(implementation.code), scope: 'Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.', ...state }) })
|
||||
await testInfo.attach('auto-thumbnail-candidate-native-lifecycle', { contentType: 'application/json', body: JSON.stringify({ scenario, sha256: hash(implementation.code), scope: 'Actual HTTP decoding/seek/JPEG and held native presentation callback lifecycle with a stub player host. Frame scenarios use a held Blob fallback only when native frame callbacks are absent. Pixel acceptance is separate; AUTO-THUMB-PIXEL-01 remains open.', ...state }) })
|
||||
expect(state.result).toEqual({ name: 'artplayerPluginAutoThumbnail' })
|
||||
expect(state.before).toEqual(scenario === 'complete' ? { urls: 1, updates: 2 } : { urls: 0, updates: 0 })
|
||||
expect(state.after).toEqual(state.before)
|
||||
expect(state.video).toEqual({ connected: false, src: null, readyState: 0, paused: true, handlers: [true, true, true] })
|
||||
expect(state.video).toEqual({ connected: false, src: null, readyState: 0, paused: true, handlers: [true, true, true, true] })
|
||||
expect(state.final).toEqual({ urls: 0, listeners: 0, updates: scenario === 'complete' ? 2 : 0 })
|
||||
expect(state.blobs.every(blob => blob.type === 'image/jpeg' && blob.bytes > 0)).toBe(true)
|
||||
expect(state.heldFrames).toBe(holdFrame ? 1 : 0)
|
||||
expect(state.blobs).toHaveLength(holdFrame ? 0 : scenario === 'complete' ? 2 : 1)
|
||||
})
|
||||
}
|
||||
@@ -5,14 +5,14 @@ import { expect, test } from './fixtures.js'
|
||||
const implementation = await autoThumbnailCandidate()
|
||||
|
||||
for (const media of ['auto-thumbnail-timeline', 'pattern']) {
|
||||
test(`Auto-thumbnail candidate retains cells 2-4 ${media} pixels in a hidden decoder`, async ({ page, browserName }, testInfo) => {
|
||||
test(`Auto-thumbnail candidate verifies presented ${media} pixels and records fallback limits`, async ({ page, browserName }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=published')
|
||||
await page.setContent('<!doctype html><style>video { width: 1px; height: 1px; max-width: 1px; max-height: 1px; display: none }</style>')
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await page.evaluate(async (media) => {
|
||||
const createElement = document.createElement.bind(document)
|
||||
const drawImage = CanvasRenderingContext2D.prototype.drawImage
|
||||
const probe = window.probe = { videos: [], updates: [], draws: [], playing: 0, warnings: [] }
|
||||
const probe = window.probe = { videos: [], updates: [], draws: [], presented: [], playing: 0, warnings: [] }
|
||||
const warn = console.warn
|
||||
console.warn = (...args) => probe.warnings.push(args.map(String))
|
||||
document.createElement = function (tag, ...args) {
|
||||
@@ -20,6 +20,13 @@ for (const media of ['auto-thumbnail-timeline', 'pattern']) {
|
||||
if (tag === 'video') {
|
||||
probe.videos.push(element)
|
||||
element.addEventListener('playing', () => probe.playing++)
|
||||
const request = element.requestVideoFrameCallback?.bind(element)
|
||||
if (request) {
|
||||
element.requestVideoFrameCallback = callback => request((now, metadata) => {
|
||||
probe.presented.push({ mediaTime: metadata.mediaTime, currentTime: element.currentTime })
|
||||
callback(now, metadata)
|
||||
})
|
||||
}
|
||||
}
|
||||
return element
|
||||
}
|
||||
@@ -79,15 +86,17 @@ for (const media of ['auto-thumbnail-timeline', 'pattern']) {
|
||||
const final = probe.videos.map(video => ({ connected: video.isConnected, src: video.getAttribute('src'), paused: video.paused }))
|
||||
probe.art.emit('destroy')
|
||||
probe.restore()
|
||||
return { cells, spatial, draws: probe.draws, final, playing: probe.playing, warnings: probe.warnings }
|
||||
const supportsPresentation = typeof probe.videos[0].requestVideoFrameCallback === 'function' && typeof probe.videos[0].cancelVideoFrameCallback === 'function'
|
||||
return { cells, spatial, draws: probe.draws, presented: probe.presented, supportsPresentation, final, playing: probe.playing, warnings: probe.warnings }
|
||||
})
|
||||
await testInfo.attach('auto-thumbnail-candidate-hidden-pixels', { contentType: 'application/json', body: JSON.stringify({
|
||||
sha256: hash(implementation.code),
|
||||
media,
|
||||
scope: 'Native decoding and JPEG pixels for cells 2-4, hidden intrinsic-sized rendering and cleanup. Cells 0-1 are diagnostic only: initial drawing/readiness/timestamp behavior remains open under AUTO-THUMB-PIXEL-01. Stub player host, not a core integration.',
|
||||
scope: 'All five cells are asserted where native frame presentation callbacks are available. Without that capability only cells 2-4 are asserted, with cells 0-1 recorded as unresolved diagnostics under AUTO-THUMB-PIXEL-01. Native HTTP/JPEG and a stub player host, not a core integration.',
|
||||
...state,
|
||||
}) })
|
||||
testInfo.annotations.push({ type: 'remaining-initial-frame-risk', description: `${browserName}: cells 0-1 are evidence only; this test cannot close AUTO-THUMB-PIXEL-01 or PKG-AUTO-THUMB-03.` })
|
||||
if (!state.supportsPresentation)
|
||||
testInfo.annotations.push({ type: 'remaining-initial-frame-risk', description: `${browserName}: no native presentation callback; cells 0-1 remain evidence only and cannot close AUTO-THUMB-PIXEL-01 or PKG-AUTO-THUMB-03.` })
|
||||
expect(state.warnings).toEqual([])
|
||||
expect(state.playing).toBe(0)
|
||||
expect(state.final).toEqual([{ connected: false, src: null, paused: true }])
|
||||
@@ -98,12 +107,25 @@ for (const media of ['auto-thumbnail-timeline', 'pattern']) {
|
||||
expect(draw.display).toBe('block')
|
||||
expect(draw.width).toBeGreaterThanOrEqual(80)
|
||||
expect(draw.height).toBeGreaterThanOrEqual(45)
|
||||
if (index > 1) {
|
||||
if (state.supportsPresentation || index > 1) {
|
||||
expect(Math.abs(draw.time - draw.duration * index / 5)).toBeLessThan(0.05)
|
||||
expect(draw.raw[3]).toBe(255)
|
||||
}
|
||||
}
|
||||
if (state.supportsPresentation) {
|
||||
expect(state.presented).toHaveLength(5)
|
||||
for (const [index, frame] of state.presented.entries())
|
||||
expect(Math.abs(frame.mediaTime - state.draws[index].duration * index / 5)).toBeLessThan(0.06)
|
||||
}
|
||||
if (media === 'auto-thumbnail-timeline') {
|
||||
if (state.supportsPresentation) {
|
||||
const [purple, red] = state.cells
|
||||
expect(Math.min(purple[0], purple[2])).toBeGreaterThan(100)
|
||||
expect(Math.abs(purple[0] - purple[2])).toBeLessThan(30)
|
||||
expect(purple[1]).toBeLessThan(40)
|
||||
expect(red[0]).toBeGreaterThan(200)
|
||||
expect(Math.max(red[1], red[2])).toBeLessThan(40)
|
||||
}
|
||||
const [black, blue, yellow] = state.cells.slice(2)
|
||||
expect(Math.max(...black.slice(0, 3))).toBeLessThan(30)
|
||||
expect(blue[2]).toBeGreaterThan(200)
|
||||
@@ -112,7 +134,7 @@ for (const media of ['auto-thumbnail-timeline', 'pattern']) {
|
||||
expect(yellow[2]).toBeLessThan(40)
|
||||
}
|
||||
else {
|
||||
for (const pixel of state.cells.slice(2, 4)) {
|
||||
for (const pixel of state.cells.slice(state.supportsPresentation ? 0 : 2, 4)) {
|
||||
expect(pixel[2] - Math.max(pixel[0], pixel[1])).toBeGreaterThan(80)
|
||||
}
|
||||
expect(state.cells[4][0] - Math.max(state.cells[4][1], state.cells[4][2])).toBeGreaterThan(80)
|
||||
|
||||
@@ -41,6 +41,8 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
const listeners = new Map()
|
||||
const updates = []
|
||||
const warnings = []
|
||||
const timers = new Map()
|
||||
let nextTimer = 0
|
||||
let nextUrl = 0
|
||||
const controls = { nullContext: false, drawError: null, encodeError: null, updateError: null, onUpdate: null, beforeAppend: null, afterAppend: null }
|
||||
const attached = new Set()
|
||||
@@ -68,6 +70,7 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
videoWidth: 1920,
|
||||
videoHeight: 1080,
|
||||
onloadedmetadata: null,
|
||||
onloadeddata: null,
|
||||
onseeked: null,
|
||||
onerror: null,
|
||||
pause() { operations.push({ name: 'pause', video }) },
|
||||
@@ -116,7 +119,11 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
},
|
||||
}
|
||||
const module = { exports: {} }
|
||||
const box = { document, URL: {
|
||||
const box = { document, setTimeout(callback, delay) {
|
||||
const id = nextTimer++
|
||||
timers.set(id, { callback, delay })
|
||||
return id
|
||||
}, clearTimeout(id) { timers.delete(id) }, URL: {
|
||||
createObjectURL(blob) {
|
||||
if (blob == null)
|
||||
throw new TypeError('Blob required')
|
||||
@@ -174,6 +181,7 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
listeners,
|
||||
updates,
|
||||
warnings,
|
||||
timers,
|
||||
controls,
|
||||
attached,
|
||||
metadata(video = videos.at(-1)) { video.onloadedmetadata?.() },
|
||||
|
||||
Reference in new issue
Block a user