refactor(auto-thumbnail): [PKG-AUTO-THUMB-03] wait for native frame presentation

This commit is contained in:
Harvey Zhao committed 2026-09-13 07:53:29 +08:00
1 parent a128706fe7
commit 8c57d56f17
21 files changed
+10716 -296

No files matched your search

@@ -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
@@ -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);
@@ -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