From 4acf85ea696a99f05fce71089711bc4a4ca0d5b6 Mon Sep 17 00:00:00 2001 From: Harvey Zhao Date: Mon, 14 Sep 2026 11:55:05 +0800 Subject: [PATCH] refactor(auto-thumbnail): [PKG-AUTO-THUMB-10] own JPEG encoding cancellation and deadlines --- .../artplayer-plugin-auto-thumbnail.js | 2 +- .../artplayer-plugin-auto-thumbnail.legacy.js | 2 +- .../artplayer-plugin-auto-thumbnail.mjs | 43 ++++- package.json | 4 +- .../ARCHITECTURE.md | 24 ++- .../dist/artplayer-plugin-auto-thumbnail.js | 2 +- .../artplayer-plugin-auto-thumbnail.legacy.js | 2 +- .../dist/artplayer-plugin-auto-thumbnail.mjs | 43 ++++- .../src/encoding.ts | 41 +++++ .../src/extraction.ts | 10 +- .../auto-thumbnail-encoding-validation.json | 172 ++++++++++++++++++ .../2026-09-14-PKG-AUTO-THUMB-10-encoding.md | 88 +++++++++ refactor/plan.md | 10 +- refactor/progress.md | 14 ++ refactor/risk-table.md | 1 + refactor/risks.json | 22 +++ refactor/tasks.json | 23 ++- test/README.md | 7 + test/auto-thumbnail-encoding.test.js | 138 ++++++++++++++ test/auto-thumbnail-frames.test.js | 2 +- test/browser/auto-thumbnail-lifecycle.spec.js | 37 +++- 21 files changed, 652 insertions(+), 35 deletions(-) create mode 100644 packages/artplayer-plugin-auto-thumbnail/src/encoding.ts create mode 100644 refactor/baselines/auto-thumbnail-encoding-validation.json create mode 100644 refactor/changes/2026-09-14-PKG-AUTO-THUMB-10-encoding.md create mode 100644 test/auto-thumbnail-encoding.test.js diff --git a/docs/compiled/artplayer-plugin-auto-thumbnail.js b/docs/compiled/artplayer-plugin-auto-thumbnail.js index 26385dd2e..cd83d106e 100644 --- a/docs/compiled/artplayer-plugin-auto-thumbnail.js +++ b/docs/compiled/artplayer-plugin-auto-thumbnail.js @@ -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){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}function t(t,i){const n="function"===typeof i.requestVideoFrameCallback&&"function"===typeof i.cancelVideoFrameCallback;let r;function o(){const t=r;if(r=void 0,i.onloadeddata=null,i.onseeked=null,!t)return;const n=e([()=>{null!==t.timer&&clearTimeout(t.timer)},()=>{null!==t.frame&&i.cancelVideoFrameCallback(t.frame)}]);if(n.failed)throw n.failure}return t.own(o),(e,a)=>{if(o(),!t.active())return;const u={frame:null,timer:null,sought:!1,presented:!n,retries:0,sequence:0,started:!1};r=u;const d=()=>t.active()&&r===u,c=t.guard(()=>{d()&&u.sought&&u.presented&&(o(),t.active()&&a())}),s=()=>{u.sought=!1,(()=>{if(!n)return;const e=++u.sequence;u.presented=!1;const r=u.frame;if(u.frame=null,null!==r&&i.cancelVideoFrameCallback(r),!d())return;let o=!1;const a=i.requestVideoFrameCallback(t.guard(()=>{d()&&e===u.sequence&&!o&&(o=!0,u.frame=null,u.presented=!0,c())}));d()&&e===u.sequence&&!o?u.frame=a:i.cancelVideoFrameCallback(a)})(),d()&&(i.currentTime=e)},l=t.guard(()=>{d()&&!u.started&&(u.started=!0,i.onloadeddata=null,i.onseeked=t.guard(()=>{if(!d()||i.seeking)return;const t=i.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");s()}else u.sought=!0,c()}),s())});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=!n||i.readyState>=2;d()&&(f?l():i.onloadeddata=l)}}function i(e,i){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 i of["onloadedmetadata","onloadeddata","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()&&(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,o=n.videoHeight,a=n.videoWidth,{height:u,canvasWidth:d,canvasHeight:c}=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}}(i,{duration:r,videoHeight:o,videoWidth:a});if(!e.active())return;n.width=a,n.height=o,n.style.width=`${a}px`,n.style.height=`${o}px`;const s=document.createElement("canvas");if(e.own(()=>{s.height=0}),e.own(()=>{s.width=0}),!e.active())return;if(s.width=d,!e.active())return;if(s.height=c,!e.active())return;const l=s.getContext("2d");if(!l)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=t(e,n);let h=0;const m=e.guard(()=>{h>=i.number?e.dispose():f(r*h/i.number,e.guard(()=>{if(l.drawImage(n,h%10*i.width,Math.floor(h/10)*u,i.width,u),!e.active())return;let t=!1;s.toBlob(e.guard(n=>{t||(t=!0,e.publish(n,{height:u,column:10,number:i.number,width:i.width,scale:i.scale}),e.active()&&(h+=1,m()))}),"image/jpeg")}))});m()}),e.active()&&(n.src=i.url))}function n(t){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(t,i){let n,r,o=!1;const a=new Set;function u(e){void 0!==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,c=[];let s=!1;const l={active:()=>!o&&!s&&n===l,own(t){if(s){const n=e([t]);n.failed&&i(n.failure)}else c.push(t)},dispose(){if(s)return;s=!0,n===l&&(n=void 0);const t=e(c.splice(0).reverse());t.failed&&i(t.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 c=r;r=d;try{t({url:d,...o})}catch(s){r===d&&(r=void 0!==c&&a.has(c)?c:void 0);const t=e([()=>u(d)]);throw t.failed&&i(t.failure),s}u(c)}};return n=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)return;o=!0;const t=e([d,...[...a].map(e=>()=>u(e))]);r=void 0,t.failed&&i(t.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const e=o.start();e&&e.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}(t,()=>n.option.url);e.active()&&i(e,r)})()},d=()=>{const t=a.splice(0),i=e([o.destroy,...t]);i.failed&&r(i.failure)};try{const e=[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]];for(const[t,i]of e){if(o.closed)break;a.push(()=>n.off(t,i)),n.on(t,i),o.closed&&n.off(t,i)}}catch(c){throw d(),c}return{name:"artplayerPluginAutoThumbnail"}}}return n.default=n,n}); +!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,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}function t(t,i){const n="function"===typeof i.requestVideoFrameCallback&&"function"===typeof i.cancelVideoFrameCallback;let r;function o(){const t=r;if(r=void 0,i.onloadeddata=null,i.onseeked=null,!t)return;const n=e([()=>{null!==t.timer&&clearTimeout(t.timer)},()=>{null!==t.frame&&i.cancelVideoFrameCallback(t.frame)}]);if(n.failed)throw n.failure}return t.own(o),(e,a)=>{if(o(),!t.active())return;const u={frame:null,timer:null,sought:!1,presented:!n,retries:0,sequence:0,started:!1};r=u;const d=()=>t.active()&&r===u,c=t.guard(()=>{d()&&u.sought&&u.presented&&(o(),t.active()&&a())}),l=()=>{u.sought=!1,(()=>{if(!n)return;const e=++u.sequence;u.presented=!1;const r=u.frame;if(u.frame=null,null!==r&&i.cancelVideoFrameCallback(r),!d())return;let o=!1;const a=i.requestVideoFrameCallback(t.guard(()=>{d()&&e===u.sequence&&!o&&(o=!0,u.frame=null,u.presented=!0,c())}));d()&&e===u.sequence&&!o?u.frame=a:i.cancelVideoFrameCallback(a)})(),d()&&(i.currentTime=e)},s=t.guard(()=>{d()&&!u.started&&(u.started=!0,i.onloadeddata=null,i.onseeked=t.guard(()=>{if(!d()||i.seeking)return;const t=i.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");l()}else u.sought=!0,c()}),l())});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=!n||i.readyState>=2;d()&&(f?s():i.onloadeddata=s)}}function i(e,i){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 i of["onloadedmetadata","onloadeddata","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()&&(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,o=n.videoHeight,a=n.videoWidth,{height:u,canvasWidth:d,canvasHeight:c}=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}}(i,{duration:r,videoHeight:o,videoWidth:a});if(!e.active())return;n.width=a,n.height=o,n.style.width=`${a}px`,n.style.height=`${o}px`;const l=document.createElement("canvas");if(e.own(()=>{l.height=0}),e.own(()=>{l.width=0}),!e.active())return;if(l.width=d,!e.active())return;if(l.height=c,!e.active())return;const s=l.getContext("2d");if(!s)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=t(e,n),h=function(e,t){let i;function n(){const e=i;i=void 0,e&&null!==e.timer&&clearTimeout(e.timer)}return e.own(n),r=>{if(!e.active())return;if(i)throw new Error("Auto-thumbnail encoding is already pending");const o={timer:null};i=o;const a=()=>e.active()&&i===o;o.timer=setTimeout(e.guard(()=>{if(a())throw new Error("Auto-thumbnail encoding timed out")}),3e4),a()?t.toBlob(e.guard(t=>{a()&&(n(),e.active()&&r(t))}),"image/jpeg"):clearTimeout(o.timer)}}(e,l);let m=0;const v=e.guard(()=>{m>=i.number?e.dispose():f(r*m/i.number,e.guard(()=>{s.drawImage(n,m%10*i.width,Math.floor(m/10)*u,i.width,u),e.active()&&h(t=>{e.publish(t,{height:u,column:10,number:i.number,width:i.width,scale:i.scale}),e.active()&&(m+=1,v())})}))});v()}),e.active()&&(n.src=i.url))}function n(t){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(t,i){let n,r,o=!1;const a=new Set;function u(e){void 0!==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,c=[];let l=!1;const s={active:()=>!o&&!l&&n===s,own(t){if(l){const n=e([t]);n.failed&&i(n.failure)}else c.push(t)},dispose(){if(l)return;l=!0,n===s&&(n=void 0);const t=e(c.splice(0).reverse());t.failed&&i(t.failure)},fail(e){s.active()&&(s.dispose(),i(e))},guard:e=>(...t)=>{if(s.active())try{return e(...t)}catch(i){s.fail(i)}},publish(n,o){if(!s.active())return;if(!n)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(n);if(a.add(d),!s.active())return void u(d);const c=r;r=d;try{t({url:d,...o})}catch(l){r===d&&(r=void 0!==c&&a.has(c)?c:void 0);const t=e([()=>u(d)]);throw t.failed&&i(t.failure),l}u(c)}};return n=s,d?.dispose(),s},cancel:d,destroy:function(){if(o)return;o=!0;const t=e([d,...[...a].map(e=>()=>u(e))]);r=void 0,t.failed&&i(t.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const e=o.start();e&&e.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}(t,()=>n.option.url);e.active()&&i(e,r)})()},d=()=>{const t=a.splice(0),i=e([o.destroy,...t]);i.failed&&r(i.failure)};try{const e=[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]];for(const[t,i]of e){if(o.closed)break;a.push(()=>n.off(t,i)),n.on(t,i),o.closed&&n.off(t,i)}}catch(c){throw d(),c}return{name:"artplayerPluginAutoThumbnail"}}}return n.default=n,n}); diff --git a/docs/compiled/artplayer-plugin-auto-thumbnail.legacy.js b/docs/compiled/artplayer-plugin-auto-thumbnail.legacy.js index 162f4f3f7..3aad2ba09 100644 --- a/docs/compiled/artplayer-plugin-auto-thumbnail.legacy.js +++ b/docs/compiled/artplayer-plugin-auto-thumbnail.legacy.js @@ -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,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){void 0!==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 v={active:()=>!l&&!m&&u===v,own(e){if(m){const t=o([e]);t.failed&&a(t.failure)}else h.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=o(h.splice(0).reverse());e.failed&&a(e.failure)},fail(e){v.active()&&(v.dispose(),a(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(n){v.fail(n)}},publish(u,l){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const f=URL.createObjectURL(u);if(c.add(f),!v.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=void 0!==h&&c.has(h)?h:void 0);const e=o([()=>s(f)]);throw e.failed&&a(e.failure),m}s(h)}};return u=v,null==f||f.dispose(),v},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([()=>{null!==e.timer&&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.own(()=>{c.height=0}),e.own(()=>{c.width=0}),!e.active())return;if(c.width=d,!e.active())return;if(c.height=l,!e.active())return;const s=c.getContext("2d");if(!s)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=u(e,n);let h=0;const m=e.guard(()=>{h>=t.number?e.dispose():f(r*h/t.number,e.guard(()=>{if(s.drawImage(n,h%10*t.width,Math.floor(h/10)*a,t.width,a),!e.active())return;let r=!1;c.toBlob(e.guard(n=>{r||(r=!0,e.publish(n,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(h+=1,m()))}),"image/jpeg")}))});m()}),e.active()&&(n.src=t.url))}function l(e){return t=>{return n=null,r=null,i=function*(){const n=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),r=a(e=>{t.thumbnails=e},n),i=[],u=()=>{const n=r.start();n&&n.guard(()=>{const r=function(e,t){const n={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(n.width))||n.width<=0||!Number.isFinite(Number(n.number))||n.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return n}(e,()=>t.option.url);n.active()&&d(n,r)})()},l=()=>{const e=i.splice(0),t=o([r.destroy,...e]);t.failed&&n(t.failure)};try{const e=[["destroy",l],["restart",r.cancel],["video:loadedmetadata",u]];for(const[n,o]of e){if(r.closed)break;i.push(()=>t.off(n,o)),t.on(n,o),r.closed&&t.off(n,o)}}catch(c){throw l(),c}return{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var o=e=>{try{u(i.next(e))}catch(n){t(n)}},a=e=>{try{u(i.throw(e))}catch(n){t(n)}},u=t=>t.done?e(t.value):Promise.resolve(t.value).then(o,a);u((i=i.apply(n,r)).next())});var n,r,i}}return l.default=l,l}); +!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){void 0!==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 v={active:()=>!l&&!m&&u===v,own(e){if(m){const t=o([e]);t.failed&&a(t.failure)}else h.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=o(h.splice(0).reverse());e.failed&&a(e.failure)},fail(e){v.active()&&(v.dispose(),a(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(n){v.fail(n)}},publish(u,l){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const f=URL.createObjectURL(u);if(c.add(f),!v.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=void 0!==h&&c.has(h)?h:void 0);const e=o([()=>s(f)]);throw e.failed&&a(e.failure),m}s(h)}};return u=v,null==f||f.dispose(),v},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([()=>{null!==e.timer&&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.own(()=>{c.height=0}),e.own(()=>{c.width=0}),!e.active())return;if(c.width=d,!e.active())return;if(c.height=l,!e.active())return;const s=c.getContext("2d");if(!s)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=u(e,n),h=function(e,t){let n;function r(){const e=n;n=void 0,e&&null!==e.timer&&clearTimeout(e.timer)}return e.own(r),i=>{if(!e.active())return;if(n)throw new Error("Auto-thumbnail encoding is already pending");const o={timer:null};n=o;const a=()=>e.active()&&n===o;o.timer=setTimeout(e.guard(()=>{if(a())throw new Error("Auto-thumbnail encoding timed out")}),3e4),a()?t.toBlob(e.guard(t=>{a()&&(r(),e.active()&&i(t))}),"image/jpeg"):clearTimeout(o.timer)}}(e,c);let m=0;const v=e.guard(()=>{m>=t.number?e.dispose():f(r*m/t.number,e.guard(()=>{s.drawImage(n,m%10*t.width,Math.floor(m/10)*a,t.width,a),e.active()&&h(n=>{e.publish(n,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(m+=1,v())})}))});v()}),e.active()&&(n.src=t.url))}function l(e){return t=>{return n=null,r=null,i=function*(){const n=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),r=a(e=>{t.thumbnails=e},n),i=[],u=()=>{const n=r.start();n&&n.guard(()=>{const r=function(e,t){const n={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(n.width))||n.width<=0||!Number.isFinite(Number(n.number))||n.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return n}(e,()=>t.option.url);n.active()&&d(n,r)})()},l=()=>{const e=i.splice(0),t=o([r.destroy,...e]);t.failed&&n(t.failure)};try{const e=[["destroy",l],["restart",r.cancel],["video:loadedmetadata",u]];for(const[n,o]of e){if(r.closed)break;i.push(()=>t.off(n,o)),t.on(n,o),r.closed&&t.off(n,o)}}catch(c){throw l(),c}return{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var o=e=>{try{u(i.next(e))}catch(n){t(n)}},a=e=>{try{u(i.throw(e))}catch(n){t(n)}},u=t=>t.done?e(t.value):Promise.resolve(t.value).then(o,a);u((i=i.apply(n,r)).next())});var n,r,i}}return l.default=l,l}); diff --git a/docs/compiled/artplayer-plugin-auto-thumbnail.mjs b/docs/compiled/artplayer-plugin-auto-thumbnail.mjs index c3fca48f2..596b83d7f 100644 --- a/docs/compiled/artplayer-plugin-auto-thumbnail.mjs +++ b/docs/compiled/artplayer-plugin-auto-thumbnail.mjs @@ -4,6 +4,40 @@ * (c) 2017-2026 Harvey Zhao * Released under the MIT License. */ +function createEncoder(job, canvas) { + let pending; + function clear() { + const previous = pending; + pending = void 0; + if (previous && previous.timer !== null) + clearTimeout(previous.timer); + } + job.own(clear); + return (publish) => { + if (!job.active()) + return; + if (pending) + throw new Error("Auto-thumbnail encoding is already pending"); + const current = { timer: null }; + pending = current; + const active = () => job.active() && pending === current; + current.timer = setTimeout(job.guard(() => { + if (active()) + throw new Error("Auto-thumbnail encoding timed out"); + }), 3e4); + if (!active()) { + clearTimeout(current.timer); + return; + } + canvas.toBlob(job.guard((blob) => { + if (!active()) + return; + clear(); + if (job.active()) + publish(blob); + }), "image/jpeg"); + }; +} function cleanupAll(actions) { let failure; let failed = false; @@ -321,6 +355,7 @@ function extract(job, config) { if (!job.active()) return; const readFrame = createFrameReader(job, video); + const encode = createEncoder(job, canvas); let index = 0; const seek = job.guard(() => { if (index >= config.number) { @@ -331,17 +366,13 @@ function extract(job, config) { 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; + encode((blob) => { job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale }); if (job.active()) { index += 1; seek(); } - }), "image/jpeg"); + }); })); }); seek(); diff --git a/package.json b/package.json index 7bf94ea9f..5bb30dd91 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:llm && yarn build:test && yarn build:docs && yarn test:imports", "test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js", "typecheck": "node scripts/typecheck.mjs", - "test:unit": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/danmuku-mask-lifecycle.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/jassub-registration.test.js test/jassub-runtime.test.js test/jassub-vendor-lifecycle.test.js test/jassub-frame-polyfill.test.js test/jassub-offscreen.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-exports.test.js test/auto-thumbnail-canvas.test.js 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/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js", + "test:unit": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/danmuku-mask-lifecycle.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/jassub-registration.test.js test/jassub-runtime.test.js test/jassub-vendor-lifecycle.test.js test/jassub-frame-polyfill.test.js test/jassub-offscreen.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-exports.test.js test/auto-thumbnail-canvas.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-encoding.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/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.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-exports.test.js test/auto-thumbnail-canvas.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs refactor/scripts/auto-thumbnail-types.test.mjs", + "test:auto-thumbnail": "node --test test/auto-thumbnail-exports.test.js test/auto-thumbnail-canvas.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-encoding.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs refactor/scripts/auto-thumbnail-types.test.mjs", "test:vtt-thumbnail": "node --test test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js refactor/scripts/vtt-thumbnail-contract.test.mjs refactor/scripts/vtt-thumbnail-types.test.mjs", "test:vtt-thumbnail-types-package": "node refactor/scripts/vtt-thumbnail-package-types.mjs", "test:multiple-subtitles": "node --test test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js refactor/scripts/multiple-subtitles-contract.test.mjs refactor/scripts/multiple-subtitles-runtime-types.test.mjs refactor/scripts/multiple-subtitles-types.test.mjs", diff --git a/packages/artplayer-plugin-auto-thumbnail/ARCHITECTURE.md b/packages/artplayer-plugin-auto-thumbnail/ARCHITECTURE.md index 2f5a3a066..23f669c99 100644 --- a/packages/artplayer-plugin-auto-thumbnail/ARCHITECTURE.md +++ b/packages/artplayer-plugin-auto-thumbnail/ARCHITECTURE.md @@ -31,6 +31,14 @@ for video processing. after each dimension write; synchronous destruction cannot reallocate the sheet. Previously encoded JPEG Blobs/URLs retain their independent bytes. This controls canvas dimensions, not the browser's precise GPU/encoder reclamation time. +- `src/encoding.ts` owns one pending JPEG operation, its30-second deadline and + once-only completion. It clears that deadline before publishing or starting the + next sample, and ignores callbacks from an earlier operation. One job cleanup + handles all encodes rather than adding a cleanup closure for every frame. + Timeout disposes the job and retains the last usable preview; the browser's + toBlob work itself cannot be canceled. Late Blob delivery cannot publish after + timeout, replacement or destruction. Synchronous callbacks and timer-registration + reentry also keep timer ownership intact. - `src/video.ts` creates and owns the hidden media element. It is attached to the document root because detached media loses drawable pixels on Windows WebKit. `visibility:hidden` preserves its rendered box; `display:none` and a 1px box do @@ -87,7 +95,14 @@ 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. +network, background-tab or aggregate resource policy. PKG-AUTO-THUMB-10 adds a +separate30-second encoding deadline starting just before toBlob. This is an +intentional bound on formerly unbounded pending encoding; it does not shorten the +frame wait or change the public frame-time formula. Very slow encodes can now +warn and retain the last preview instead of retaining the decoder indefinitely. +Initial metadata/network acquisition remains governed by media errors and session +cancellation, not this encoding deadline. Neither timeout guarantees browser-level +GPU/encoder reclamation or exact background-tab wall-clock scheduling. The previous empty first encode is removed. Encoding is serial, duplicate native callbacks are consumed once, and source replacement/destruction invalidates old @@ -114,6 +129,13 @@ yarn test:browser test/browser/auto-thumbnail-pixels.spec.js yarn build artplayer-plugin-auto-thumbnail ``` +`test/auto-thumbnail-encoding.test.js` controls withheld/late callbacks and timer +delivery; it does not reproduce a spontaneous native encoder hang. The browser +lifecycle case performs actual JPEG encoding, deliberately withholds its callback, +then invokes the captured deadline to check real decoder/canvas cleanup. It does +not claim a measured30-second browser stall. Existing complete/restart/destroy +cases retain actual encoded Blob and image-decode checks. + `ARTPLAYER_AUTO_THUMBNAIL_BASELINE=1` selects the frozen workspace for candidate regression tests. `ARTPLAYER_AUTO_THUMBNAIL_ARTIFACT` selects an actual bundle. `test/auto-thumbnail-exports.test.js` checks the factory identity, alias descriptor, diff --git a/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.js b/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.js index 26385dd2e..cd83d106e 100644 --- a/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.js +++ b/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.js @@ -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){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}function t(t,i){const n="function"===typeof i.requestVideoFrameCallback&&"function"===typeof i.cancelVideoFrameCallback;let r;function o(){const t=r;if(r=void 0,i.onloadeddata=null,i.onseeked=null,!t)return;const n=e([()=>{null!==t.timer&&clearTimeout(t.timer)},()=>{null!==t.frame&&i.cancelVideoFrameCallback(t.frame)}]);if(n.failed)throw n.failure}return t.own(o),(e,a)=>{if(o(),!t.active())return;const u={frame:null,timer:null,sought:!1,presented:!n,retries:0,sequence:0,started:!1};r=u;const d=()=>t.active()&&r===u,c=t.guard(()=>{d()&&u.sought&&u.presented&&(o(),t.active()&&a())}),s=()=>{u.sought=!1,(()=>{if(!n)return;const e=++u.sequence;u.presented=!1;const r=u.frame;if(u.frame=null,null!==r&&i.cancelVideoFrameCallback(r),!d())return;let o=!1;const a=i.requestVideoFrameCallback(t.guard(()=>{d()&&e===u.sequence&&!o&&(o=!0,u.frame=null,u.presented=!0,c())}));d()&&e===u.sequence&&!o?u.frame=a:i.cancelVideoFrameCallback(a)})(),d()&&(i.currentTime=e)},l=t.guard(()=>{d()&&!u.started&&(u.started=!0,i.onloadeddata=null,i.onseeked=t.guard(()=>{if(!d()||i.seeking)return;const t=i.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");s()}else u.sought=!0,c()}),s())});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=!n||i.readyState>=2;d()&&(f?l():i.onloadeddata=l)}}function i(e,i){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 i of["onloadedmetadata","onloadeddata","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()&&(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,o=n.videoHeight,a=n.videoWidth,{height:u,canvasWidth:d,canvasHeight:c}=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}}(i,{duration:r,videoHeight:o,videoWidth:a});if(!e.active())return;n.width=a,n.height=o,n.style.width=`${a}px`,n.style.height=`${o}px`;const s=document.createElement("canvas");if(e.own(()=>{s.height=0}),e.own(()=>{s.width=0}),!e.active())return;if(s.width=d,!e.active())return;if(s.height=c,!e.active())return;const l=s.getContext("2d");if(!l)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=t(e,n);let h=0;const m=e.guard(()=>{h>=i.number?e.dispose():f(r*h/i.number,e.guard(()=>{if(l.drawImage(n,h%10*i.width,Math.floor(h/10)*u,i.width,u),!e.active())return;let t=!1;s.toBlob(e.guard(n=>{t||(t=!0,e.publish(n,{height:u,column:10,number:i.number,width:i.width,scale:i.scale}),e.active()&&(h+=1,m()))}),"image/jpeg")}))});m()}),e.active()&&(n.src=i.url))}function n(t){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(t,i){let n,r,o=!1;const a=new Set;function u(e){void 0!==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,c=[];let s=!1;const l={active:()=>!o&&!s&&n===l,own(t){if(s){const n=e([t]);n.failed&&i(n.failure)}else c.push(t)},dispose(){if(s)return;s=!0,n===l&&(n=void 0);const t=e(c.splice(0).reverse());t.failed&&i(t.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 c=r;r=d;try{t({url:d,...o})}catch(s){r===d&&(r=void 0!==c&&a.has(c)?c:void 0);const t=e([()=>u(d)]);throw t.failed&&i(t.failure),s}u(c)}};return n=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)return;o=!0;const t=e([d,...[...a].map(e=>()=>u(e))]);r=void 0,t.failed&&i(t.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const e=o.start();e&&e.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}(t,()=>n.option.url);e.active()&&i(e,r)})()},d=()=>{const t=a.splice(0),i=e([o.destroy,...t]);i.failed&&r(i.failure)};try{const e=[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]];for(const[t,i]of e){if(o.closed)break;a.push(()=>n.off(t,i)),n.on(t,i),o.closed&&n.off(t,i)}}catch(c){throw d(),c}return{name:"artplayerPluginAutoThumbnail"}}}return n.default=n,n}); +!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,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}function t(t,i){const n="function"===typeof i.requestVideoFrameCallback&&"function"===typeof i.cancelVideoFrameCallback;let r;function o(){const t=r;if(r=void 0,i.onloadeddata=null,i.onseeked=null,!t)return;const n=e([()=>{null!==t.timer&&clearTimeout(t.timer)},()=>{null!==t.frame&&i.cancelVideoFrameCallback(t.frame)}]);if(n.failed)throw n.failure}return t.own(o),(e,a)=>{if(o(),!t.active())return;const u={frame:null,timer:null,sought:!1,presented:!n,retries:0,sequence:0,started:!1};r=u;const d=()=>t.active()&&r===u,c=t.guard(()=>{d()&&u.sought&&u.presented&&(o(),t.active()&&a())}),l=()=>{u.sought=!1,(()=>{if(!n)return;const e=++u.sequence;u.presented=!1;const r=u.frame;if(u.frame=null,null!==r&&i.cancelVideoFrameCallback(r),!d())return;let o=!1;const a=i.requestVideoFrameCallback(t.guard(()=>{d()&&e===u.sequence&&!o&&(o=!0,u.frame=null,u.presented=!0,c())}));d()&&e===u.sequence&&!o?u.frame=a:i.cancelVideoFrameCallback(a)})(),d()&&(i.currentTime=e)},s=t.guard(()=>{d()&&!u.started&&(u.started=!0,i.onloadeddata=null,i.onseeked=t.guard(()=>{if(!d()||i.seeking)return;const t=i.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");l()}else u.sought=!0,c()}),l())});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=!n||i.readyState>=2;d()&&(f?s():i.onloadeddata=s)}}function i(e,i){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 i of["onloadedmetadata","onloadeddata","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()&&(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,o=n.videoHeight,a=n.videoWidth,{height:u,canvasWidth:d,canvasHeight:c}=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}}(i,{duration:r,videoHeight:o,videoWidth:a});if(!e.active())return;n.width=a,n.height=o,n.style.width=`${a}px`,n.style.height=`${o}px`;const l=document.createElement("canvas");if(e.own(()=>{l.height=0}),e.own(()=>{l.width=0}),!e.active())return;if(l.width=d,!e.active())return;if(l.height=c,!e.active())return;const s=l.getContext("2d");if(!s)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=t(e,n),h=function(e,t){let i;function n(){const e=i;i=void 0,e&&null!==e.timer&&clearTimeout(e.timer)}return e.own(n),r=>{if(!e.active())return;if(i)throw new Error("Auto-thumbnail encoding is already pending");const o={timer:null};i=o;const a=()=>e.active()&&i===o;o.timer=setTimeout(e.guard(()=>{if(a())throw new Error("Auto-thumbnail encoding timed out")}),3e4),a()?t.toBlob(e.guard(t=>{a()&&(n(),e.active()&&r(t))}),"image/jpeg"):clearTimeout(o.timer)}}(e,l);let m=0;const v=e.guard(()=>{m>=i.number?e.dispose():f(r*m/i.number,e.guard(()=>{s.drawImage(n,m%10*i.width,Math.floor(m/10)*u,i.width,u),e.active()&&h(t=>{e.publish(t,{height:u,column:10,number:i.number,width:i.width,scale:i.scale}),e.active()&&(m+=1,v())})}))});v()}),e.active()&&(n.src=i.url))}function n(t){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(t,i){let n,r,o=!1;const a=new Set;function u(e){void 0!==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,c=[];let l=!1;const s={active:()=>!o&&!l&&n===s,own(t){if(l){const n=e([t]);n.failed&&i(n.failure)}else c.push(t)},dispose(){if(l)return;l=!0,n===s&&(n=void 0);const t=e(c.splice(0).reverse());t.failed&&i(t.failure)},fail(e){s.active()&&(s.dispose(),i(e))},guard:e=>(...t)=>{if(s.active())try{return e(...t)}catch(i){s.fail(i)}},publish(n,o){if(!s.active())return;if(!n)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(n);if(a.add(d),!s.active())return void u(d);const c=r;r=d;try{t({url:d,...o})}catch(l){r===d&&(r=void 0!==c&&a.has(c)?c:void 0);const t=e([()=>u(d)]);throw t.failed&&i(t.failure),l}u(c)}};return n=s,d?.dispose(),s},cancel:d,destroy:function(){if(o)return;o=!0;const t=e([d,...[...a].map(e=>()=>u(e))]);r=void 0,t.failed&&i(t.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const e=o.start();e&&e.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}(t,()=>n.option.url);e.active()&&i(e,r)})()},d=()=>{const t=a.splice(0),i=e([o.destroy,...t]);i.failed&&r(i.failure)};try{const e=[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]];for(const[t,i]of e){if(o.closed)break;a.push(()=>n.off(t,i)),n.on(t,i),o.closed&&n.off(t,i)}}catch(c){throw d(),c}return{name:"artplayerPluginAutoThumbnail"}}}return n.default=n,n}); diff --git a/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.legacy.js b/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.legacy.js index 162f4f3f7..3aad2ba09 100644 --- a/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.legacy.js +++ b/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.legacy.js @@ -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,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){void 0!==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 v={active:()=>!l&&!m&&u===v,own(e){if(m){const t=o([e]);t.failed&&a(t.failure)}else h.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=o(h.splice(0).reverse());e.failed&&a(e.failure)},fail(e){v.active()&&(v.dispose(),a(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(n){v.fail(n)}},publish(u,l){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const f=URL.createObjectURL(u);if(c.add(f),!v.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=void 0!==h&&c.has(h)?h:void 0);const e=o([()=>s(f)]);throw e.failed&&a(e.failure),m}s(h)}};return u=v,null==f||f.dispose(),v},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([()=>{null!==e.timer&&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.own(()=>{c.height=0}),e.own(()=>{c.width=0}),!e.active())return;if(c.width=d,!e.active())return;if(c.height=l,!e.active())return;const s=c.getContext("2d");if(!s)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=u(e,n);let h=0;const m=e.guard(()=>{h>=t.number?e.dispose():f(r*h/t.number,e.guard(()=>{if(s.drawImage(n,h%10*t.width,Math.floor(h/10)*a,t.width,a),!e.active())return;let r=!1;c.toBlob(e.guard(n=>{r||(r=!0,e.publish(n,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(h+=1,m()))}),"image/jpeg")}))});m()}),e.active()&&(n.src=t.url))}function l(e){return t=>{return n=null,r=null,i=function*(){const n=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),r=a(e=>{t.thumbnails=e},n),i=[],u=()=>{const n=r.start();n&&n.guard(()=>{const r=function(e,t){const n={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(n.width))||n.width<=0||!Number.isFinite(Number(n.number))||n.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return n}(e,()=>t.option.url);n.active()&&d(n,r)})()},l=()=>{const e=i.splice(0),t=o([r.destroy,...e]);t.failed&&n(t.failure)};try{const e=[["destroy",l],["restart",r.cancel],["video:loadedmetadata",u]];for(const[n,o]of e){if(r.closed)break;i.push(()=>t.off(n,o)),t.on(n,o),r.closed&&t.off(n,o)}}catch(c){throw l(),c}return{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var o=e=>{try{u(i.next(e))}catch(n){t(n)}},a=e=>{try{u(i.throw(e))}catch(n){t(n)}},u=t=>t.done?e(t.value):Promise.resolve(t.value).then(o,a);u((i=i.apply(n,r)).next())});var n,r,i}}return l.default=l,l}); +!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){void 0!==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 v={active:()=>!l&&!m&&u===v,own(e){if(m){const t=o([e]);t.failed&&a(t.failure)}else h.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=o(h.splice(0).reverse());e.failed&&a(e.failure)},fail(e){v.active()&&(v.dispose(),a(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(n){v.fail(n)}},publish(u,l){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const f=URL.createObjectURL(u);if(c.add(f),!v.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=void 0!==h&&c.has(h)?h:void 0);const e=o([()=>s(f)]);throw e.failed&&a(e.failure),m}s(h)}};return u=v,null==f||f.dispose(),v},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([()=>{null!==e.timer&&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.own(()=>{c.height=0}),e.own(()=>{c.width=0}),!e.active())return;if(c.width=d,!e.active())return;if(c.height=l,!e.active())return;const s=c.getContext("2d");if(!s)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;const f=u(e,n),h=function(e,t){let n;function r(){const e=n;n=void 0,e&&null!==e.timer&&clearTimeout(e.timer)}return e.own(r),i=>{if(!e.active())return;if(n)throw new Error("Auto-thumbnail encoding is already pending");const o={timer:null};n=o;const a=()=>e.active()&&n===o;o.timer=setTimeout(e.guard(()=>{if(a())throw new Error("Auto-thumbnail encoding timed out")}),3e4),a()?t.toBlob(e.guard(t=>{a()&&(r(),e.active()&&i(t))}),"image/jpeg"):clearTimeout(o.timer)}}(e,c);let m=0;const v=e.guard(()=>{m>=t.number?e.dispose():f(r*m/t.number,e.guard(()=>{s.drawImage(n,m%10*t.width,Math.floor(m/10)*a,t.width,a),e.active()&&h(n=>{e.publish(n,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(m+=1,v())})}))});v()}),e.active()&&(n.src=t.url))}function l(e){return t=>{return n=null,r=null,i=function*(){const n=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),r=a(e=>{t.thumbnails=e},n),i=[],u=()=>{const n=r.start();n&&n.guard(()=>{const r=function(e,t){const n={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(n.width))||n.width<=0||!Number.isFinite(Number(n.number))||n.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return n}(e,()=>t.option.url);n.active()&&d(n,r)})()},l=()=>{const e=i.splice(0),t=o([r.destroy,...e]);t.failed&&n(t.failure)};try{const e=[["destroy",l],["restart",r.cancel],["video:loadedmetadata",u]];for(const[n,o]of e){if(r.closed)break;i.push(()=>t.off(n,o)),t.on(n,o),r.closed&&t.off(n,o)}}catch(c){throw l(),c}return{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var o=e=>{try{u(i.next(e))}catch(n){t(n)}},a=e=>{try{u(i.throw(e))}catch(n){t(n)}},u=t=>t.done?e(t.value):Promise.resolve(t.value).then(o,a);u((i=i.apply(n,r)).next())});var n,r,i}}return l.default=l,l}); diff --git a/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.mjs b/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.mjs index c3fca48f2..596b83d7f 100644 --- a/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.mjs +++ b/packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.mjs @@ -4,6 +4,40 @@ * (c) 2017-2026 Harvey Zhao * Released under the MIT License. */ +function createEncoder(job, canvas) { + let pending; + function clear() { + const previous = pending; + pending = void 0; + if (previous && previous.timer !== null) + clearTimeout(previous.timer); + } + job.own(clear); + return (publish) => { + if (!job.active()) + return; + if (pending) + throw new Error("Auto-thumbnail encoding is already pending"); + const current = { timer: null }; + pending = current; + const active = () => job.active() && pending === current; + current.timer = setTimeout(job.guard(() => { + if (active()) + throw new Error("Auto-thumbnail encoding timed out"); + }), 3e4); + if (!active()) { + clearTimeout(current.timer); + return; + } + canvas.toBlob(job.guard((blob) => { + if (!active()) + return; + clear(); + if (job.active()) + publish(blob); + }), "image/jpeg"); + }; +} function cleanupAll(actions) { let failure; let failed = false; @@ -321,6 +355,7 @@ function extract(job, config) { if (!job.active()) return; const readFrame = createFrameReader(job, video); + const encode = createEncoder(job, canvas); let index = 0; const seek = job.guard(() => { if (index >= config.number) { @@ -331,17 +366,13 @@ function extract(job, config) { 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; + encode((blob) => { job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale }); if (job.active()) { index += 1; seek(); } - }), "image/jpeg"); + }); })); }); seek(); diff --git a/packages/artplayer-plugin-auto-thumbnail/src/encoding.ts b/packages/artplayer-plugin-auto-thumbnail/src/encoding.ts new file mode 100644 index 000000000..78dba4ff7 --- /dev/null +++ b/packages/artplayer-plugin-auto-thumbnail/src/encoding.ts @@ -0,0 +1,41 @@ +import type { ExtractionJob } from './types' + +interface PendingEncoding { + timer: ReturnType | null +} + +export default function createEncoder(job: ExtractionJob, canvas: HTMLCanvasElement) { + let pending: PendingEncoding | undefined + function clear() { + const previous = pending + pending = undefined + if (previous && previous.timer !== null) + clearTimeout(previous.timer) + } + job.own(clear) + + return (publish: (blob: Blob | null) => void) => { + if (!job.active()) + return + if (pending) + throw new Error('Auto-thumbnail encoding is already pending') + const current: PendingEncoding = { timer: null } + pending = current + const active = () => job.active() && pending === current + current.timer = setTimeout(job.guard(() => { + if (active()) + throw new Error('Auto-thumbnail encoding timed out') + }), 30000) + if (!active()) { + clearTimeout(current.timer) + return + } + canvas.toBlob(job.guard((blob) => { + if (!active()) + return + clear() + if (job.active()) + publish(blob) + }), 'image/jpeg') + } +} diff --git a/packages/artplayer-plugin-auto-thumbnail/src/extraction.ts b/packages/artplayer-plugin-auto-thumbnail/src/extraction.ts index 708ace515..07f11308f 100644 --- a/packages/artplayer-plugin-auto-thumbnail/src/extraction.ts +++ b/packages/artplayer-plugin-auto-thumbnail/src/extraction.ts @@ -1,4 +1,5 @@ import type { ExtractionConfig, ExtractionJob } from './types' +import createEncoder from './encoding' import createFrameReader from './frames' import { sheetSize } from './options' import createVideo from './video' @@ -47,6 +48,7 @@ export default function extract(job: ExtractionJob, config: ExtractionConfig) { if (!job.active()) return const readFrame = createFrameReader(job, video) + const encode = createEncoder(job, canvas) let index = 0 const seek = job.guard(() => { if (index >= config.number) { @@ -57,17 +59,13 @@ export default function extract(job: ExtractionJob, config: ExtractionConfig) { 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 + encode((blob) => { job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale }) if (job.active()) { index += 1 seek() } - }), 'image/jpeg') + }) })) }) seek() diff --git a/refactor/baselines/auto-thumbnail-encoding-validation.json b/refactor/baselines/auto-thumbnail-encoding-validation.json new file mode 100644 index 000000000..07570311e --- /dev/null +++ b/refactor/baselines/auto-thumbnail-encoding-validation.json @@ -0,0 +1,172 @@ +{ + "task": "PKG-AUTO-THUMB-10", + "date": "2026-09-14", + "baselineCommit": "afb632d16fec59aaa871d7efb116e2817b81032f", + "node": "v24.21.0", + "yarn": "1.22.22", + "platform": "win32", + "before": { + "artifact": { + "path": "refactor/.cache/auto-thumbnail-encoding-before.js", + "sha256": "67dbd91228888e1a19d055b1cf2f6514f51fa63c2dfc36aa485c220631383f98" + }, + "log": { + "path": "refactor/.cache/auto-encoding-before.log", + "sha256": "40d6d9aa4e960ce0a53a573679a684257663ab6b302229bc4d43ff59fc66279e" + }, + "passed": 0, + "failed": 6, + "interpretation": "Controlled callback withholding; missing encoding deadline, not spontaneous native encoder failure", + "finalTests": { + "path": "refactor/.cache/auto-encoding-before-final-tests.log", + "sha256": "86e73e5c30fde4bab7636629e6cb6d669834c313de8da23699030d717523f2d3", + "exitCode": 1, + "passed": 1, + "failed": 8 + } + }, + "policy": { + "encodingDeadlineMs": 30000, + "starts": "Immediately before canvas.toBlob", + "timeout": "Dispose job and warn; retain previous preview; ignore late Blob", + "underlyingEncoderCancellation": false, + "initialMetadataDeadline": false + }, + "sources": [ + { + "path": "packages/artplayer-plugin-auto-thumbnail/src/encoding.ts", + "sha256": "5fc75397162c86f5906c19c94c9aba873c45ec8a370bc4489570ca653ff73300" + }, + { + "path": "packages/artplayer-plugin-auto-thumbnail/src/extraction.ts", + "sha256": "9d6867fda068f60f3dde51db67cc86486da1fc0cfc0d17cb166dd417c7b55567" + } + ], + "tests": [ + { + "path": "test/auto-thumbnail-encoding.test.js", + "sha256": "f47c7dca135253c125b6fe569b0252db019c6ad1059571152c9a7929735299f3" + }, + { + "path": "test/auto-thumbnail-frames.test.js", + "sha256": "0b635d194a76a2934181e806e35f1e9086cdda906738f4149e3ebecda6d7c3f5" + }, + { + "path": "test/browser/auto-thumbnail-lifecycle.spec.js", + "sha256": "125bd6fafe3c4ff5dc9c4799755d6557c51918689fcb37d6945c2e53ba298df8" + } + ], + "results": [ + { + "path": "refactor/.cache/auto-encoding-final-tests.log", + "sha256": "5eea1838306cff9404cda7522ad4642de19d8a22315770f8068f559161e78c6d", + "passed": 174, + "failed": 0, + "skipped": 0 + }, + { + "path": "refactor/.cache/auto-encoding-artifact.log", + "sha256": "ef295aec9ba6612ab81b54c731e3928290fbe51bcaf8224a9a2289003c78ccf5", + "passed": 11, + "failed": 0, + "skipped": 0 + }, + { + "path": "refactor/.cache/auto-encoding-ci.log", + "sha256": "220c84faf14a65929d194a54a4990dddae0c947e1b7f568d71435f8eb8f6aa86", + "passed": 50, + "failed": 0, + "skipped": 0 + } + ], + "browsers": [ + { + "path": "refactor/.cache/auto-encoding-browser/report.json", + "sha256": "a7cca4a03097db41aaa45e0728c7e76d0d9abe70f8dbe9fed562b74b90d51aef", + "stats": { + "startTime": "2026-09-14T03:48:18.577Z", + "duration": 31836.841, + "expected": 27, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "scope": "21 lifecycle and6 pixel cases; callback-less first-frame limitation retained" + }, + { + "path": "refactor/.cache/auto-encoding-artifact-browser/report.json", + "sha256": "bc09dfaedcb4f47b97c45a905e9ba4db69512d9249c5394068374eb529d61306", + "stats": { + "startTime": "2026-09-14T03:51:07.923Z", + "duration": 12860.902999999998, + "expected": 9, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "scope": "Actual built UMD: complete, default-alias complete and controlled encoding-timeout in all3 engines" + } + ], + "artifacts": [ + { + "path": "packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.js", + "sha256": "a9a1704f39ebd2ef6612cc146acf4ebd0d8eeb73764cb852320a85d31438ab01", + "docs": { + "path": "docs/compiled/artplayer-plugin-auto-thumbnail.js", + "sha256": "a9a1704f39ebd2ef6612cc146acf4ebd0d8eeb73764cb852320a85d31438ab01" + } + }, + { + "path": "packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.legacy.js", + "sha256": "452b1c0d4d6658fa96b4bff57ad0f40fa60ad309caaca1fd81d0a75aadb89325", + "docs": { + "path": "docs/compiled/artplayer-plugin-auto-thumbnail.legacy.js", + "sha256": "452b1c0d4d6658fa96b4bff57ad0f40fa60ad309caaca1fd81d0a75aadb89325" + } + }, + { + "path": "packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.mjs", + "sha256": "5e5020abcbee5a5a260ea657ec123bfe50bb8d926f0131431b42c21f8bec5d43", + "docs": { + "path": "docs/compiled/artplayer-plugin-auto-thumbnail.mjs", + "sha256": "5e5020abcbee5a5a260ea657ec123bfe50bb8d926f0131431b42c21f8bec5d43" + } + } + ], + "checks": [ + { + "path": "refactor/.cache/auto-encoding-types.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "exitCode": 0 + }, + { + "path": "refactor/.cache/auto-encoding-lint-final.log", + "sha256": "b66f33ce5b38fbf22d5b05efd1cdb9b47e5278aca38ad0e37aff64e5fbd0d6b8", + "exitCode": 0 + }, + { + "path": "refactor/.cache/auto-encoding-build.log", + "sha256": "c66ec03fdd0ca2c75912b6f6a1be3a65dba27b023e1d69197849519d2e28e716", + "exitCode": 0 + }, + { + "path": "refactor/.cache/auto-encoding-final.log", + "sha256": "56acd0fdc584d70e39cdc84da9e7a27d2be06fb3b8296775e275e2d42a294b23", + "exitCode": 0 + } + ], + "initialLintFailure": { + "path": "refactor/.cache/auto-encoding-lint.log", + "sha256": "93b74fb75b81ade20affb57e9de7ad99183a7f699e8dc58604f34dcdb6ec42ca" + }, + "limits": [ + "Native browser timeout case deliberately withholds an actual JPEG callback and invokes the captured timer; no measured30s stall claim", + "AUTO-THUMB-PIXEL-01 and task03 remain open", + "Windows local evidence, not remote CI or physical devices", + "No new dependency, public declaration or version change; no publication" + ], + "initialRiskValidationFailure": { + "path": "refactor/.cache/auto-encoding-risk-initial.log", + "sha256": "8af6f39925880a154278e1688fcde0883bd8ef17c9bb78b8414cc2780721ee14" + } +} diff --git a/refactor/changes/2026-09-14-PKG-AUTO-THUMB-10-encoding.md b/refactor/changes/2026-09-14-PKG-AUTO-THUMB-10-encoding.md new file mode 100644 index 000000000..f9402d5e5 --- /dev/null +++ b/refactor/changes/2026-09-14-PKG-AUTO-THUMB-10-encoding.md @@ -0,0 +1,88 @@ +# PKG-AUTO-THUMB-10: bounded JPEG encoding ownership + +Baseline: afb632d16fec59aaa871d7efb116e2817b81032f. Split this resource task from +unfinished task03 so that its delivery does not imply the native first-frame +problem is fixed. Task04 depends on this task as well as task03 and the separate +type/alias tasks. + +## Previous behavior and intentional policy change + +Frame readiness had a30-second deadline, cleared before canvas encoding began. +If the toBlob callback never arrived, no deadline remained and the current job +kept its decoder/canvas until restart, another metadata load or destruction. +The source boundary is verified; withholding callbacks in the controlled harness +reproduces that ownership state. This is not evidence that a real browser encoder +spontaneously hangs. Six initial tests fail against a bundle frozen before this +change because encoding has no registered timeout. + +The new encoding wait has its own30-second deadline, starting immediately before +toBlob and independent of the frame wait. Timeout disposes the job and reports +through the existing warning path, preserving the last usable preview. A very +slow encoding that formerly completed after30 seconds may now be discarded. +This is an explicit bounded-resource policy change for the private async job, +not an unchanged-behavior claim or a new public API. Initial metadata/network +acquisition, total extraction duration and browser background scheduling remain +outside this per-encode limit. + +## Implementation and compatibility + +`src/encoding.ts` owns a single pending operation token, timer and Blob callback. +Its once-per-job cleanup avoids accumulating one closure per frame. Completion +invalidates the token and clears its timer before publication or the next seek. +Late/duplicate callbacks cannot affect a newer operation. Timer registration that +reenters destruction releases the newly returned handle and never starts encoding. +Synchronous native callback delivery is handled without retaining a timer. Timer +cleanup failure still reaches job-owned video and canvas cleanup. + +`extraction.ts` now coordinates frame read, draw, encoding, publish and next sample; +it delegates callback identity and timeout ownership to the encoder. Existing +`session.ts` retains URL replacement/cleanup and error reporting. The browser's +underlying toBlob work is not cancellable: disposal zeros the private canvas and +releases the decoder, while late encoded bytes cannot publish. GPU/encoder memory +reclamation timing is not claimed. + +Normal JPEG MIME, serial progressive publication, ten-column layout, options, +sample-time formula, factory/default alias, registration Promise and public type +entrypoints remain unchanged. Null Blob and synchronous encoding errors follow the +existing job failure path. No dependency or lockfile change; the new regression +file joins existing test:auto-thumbnail/test:unit scripts. The actual module map +and maintenance boundaries are updated in the package ARCHITECTURE.md. + +## Validation + +Full evidence and file fingerprints: +[auto-thumbnail-encoding-validation.json](../baselines/auto-thumbnail-encoding-validation.json). + +- Frozen pre-change bundle: initial6 new tests fail on missing encoding deadline. + The final9-case file is also rerun unchanged against that frozen bundle:8 fail + and the synchronous-completion control passes; all9 pass on the candidate. +- Current package suite:174 pass, including9 encoding cases, historical + contracts/failures, lifecycle/frame/canvas/exports and public-type regressions. +- Strict package TypeScript passes. Lint's initial new-test single-line if error + is fixed; final lint retains only the existing generated docs type warning. +- Real package build regenerates UMD/legacy/ESM and docs copies. New encoding and + existing export checks also run against the actual produced main artifact: + 11 Node checks and9 three-browser complete/alias/timeout cases pass. +- Windows browser suite:27 pass.21 lifecycle cases cover completion, alias, + cancellation, frame cancellation and new encoding timeout;6 pixel cases retain + their existing explicit callback-less first-frame limitation. They do not close + AUTO-THUMB-PIXEL-01. The new timeout case performs native HTTP decode/seek/JPEG, + deliberately holds its Blob callback and invokes the captured30-second timer. + It proves real resource cleanup and ignored late delivery, not a measured native + timeout. Natural complete cases still decode the resulting JPEG after cleanup. +-50 CI regressions plus final plan/risk validation; remote workflows and physical + device acceptance are not claimed. Entire unrelated package suites are not + repeated for this isolated encoder change. + +Adding this new task as an unfinished dependency to already-doing task03 initially +failed the plan validator. It is instead attached to future task04, preserving +the plan's startup rule without changing any existing acceptance requirement. + +## Delivery and remaining work + +Complete and commit only task10 with its code, generated artifacts, tests, docs +and status. Task03 remains doing; native first-frame readiness, aggregate +resource limits and later package combinations/distribution acceptance remain +open. This change completes the bounded encoding responsibility only. Revert this +dedicated commit, including regenerated outputs, to restore the previous unbounded +encoding behavior. Local commit only; no push, version change or publication. diff --git a/refactor/plan.md b/refactor/plan.md index 1d344131a..23b2471fe 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -2,9 +2,9 @@ > 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。 -基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 245 项,范围 22 个包及工作区/示例。 +基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 246 项,范围 22 个包及工作区/示例。 -状态:todo 56 / doing 16 / blocked 0 / done 173 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 56 / doing 16 / blocked 0 / done 174 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -19,7 +19,7 @@ | artplayer-plugin-ambilight | 1.1.0 | PKG-AMBILIGHT-01, PKG-AMBILIGHT-02, PKG-AMBILIGHT-03, PKG-AMBILIGHT-04, PKG-AMBILIGHT-PROXY-01, PKG-AMBILIGHT-05, PKG-AMBILIGHT-06, PKG-FACTORY-01 | | artplayer-plugin-asr | 2.1.0 | PKG-ASR-01, PKG-ASR-02, PKG-ASR-03, PKG-ASR-04, PKG-ASR-05, PKG-ASR-06, PKG-ASR-07, PKG-ASR-08, PKG-ASR-09 | | artplayer-plugin-audio-track | 1.1.0 | PKG-AUDIO-01, PKG-AUDIO-02, PKG-AUDIO-03, PKG-AUDIO-04, PKG-AUDIO-05, PKG-AUDIO-06 | -| artplayer-plugin-auto-thumbnail | 1.1.0 | PKG-AUTO-THUMB-01, PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-04, PKG-AUTO-THUMB-05, PKG-AUTO-THUMB-06, PKG-AUTO-THUMB-07, PKG-AUTO-THUMB-08, PKG-AUTO-THUMB-09 | +| artplayer-plugin-auto-thumbnail | 1.1.0 | PKG-AUTO-THUMB-01, PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-04, PKG-AUTO-THUMB-05, PKG-AUTO-THUMB-06, PKG-AUTO-THUMB-07, PKG-AUTO-THUMB-08, PKG-AUTO-THUMB-09, PKG-AUTO-THUMB-10 | | artplayer-plugin-chapter | 1.1.0 | PILOT-01, PKG-CHAPTER-01, PKG-CHAPTER-02, PKG-CHAPTER-03, PKG-CHAPTER-04, PKG-CHAPTER-05, PKG-CHAPTER-06 | | artplayer-plugin-chromecast | 1.1.0 | PKG-CAST-01, PKG-CAST-02, PKG-CAST-03, PKG-CAST-04, PKG-CAST-05, PKG-CAST-06 | | artplayer-plugin-danmuku | 5.3.0 | PKG-DANMUKU-01, PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-06, PKG-DANMUKU-07, PKG-DANMUKU-08, PKG-DANMUKU-09, PKG-DANMUKU-10, PKG-DANMUKU-11, PKG-DANMUKU-12 | @@ -175,12 +175,13 @@ | PKG-AUTO-THUMB-01 | artplayer-plugin-auto-thumbnail
核对包契约与历史用法 | BASE-05 | url/width/number/scale、异步工厂和渐进缩略图更新 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | | PKG-AUTO-THUMB-02 | artplayer-plugin-auto-thumbnail
建立特有行为与错误测试 | PKG-AUTO-THUMB-01, ENG-03, ENG-05 | 抽帧边界、短视频、失败、连续来源和销毁中的抽帧 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done | | PKG-AUTO-THUMB-03 | artplayer-plugin-auto-thumbnail
整理内部职责与资源 | PKG-AUTO-THUMB-02, CORE-09, CORE-19, PKG-AUTO-THUMB-07 | 隐藏 video、seek 队列、canvas 编码与 Blob URL 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | doing | -| PKG-AUTO-THUMB-04 | artplayer-plugin-auto-thumbnail
迁移自有源码和公开类型 | PKG-AUTO-THUMB-03, ENG-04, ENG-06, CORE-07, PKG-AUTO-THUMB-08, PKG-AUTO-THUMB-09 | 抽帧结果和真实异步插件返回类型兼容 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | +| PKG-AUTO-THUMB-04 | artplayer-plugin-auto-thumbnail
迁移自有源码和公开类型 | PKG-AUTO-THUMB-03, ENG-04, ENG-06, CORE-07, PKG-AUTO-THUMB-08, PKG-AUTO-THUMB-09, PKG-AUTO-THUMB-10 | 抽帧结果和真实异步插件返回类型兼容 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | | PKG-AUTO-THUMB-05 | artplayer-plugin-auto-thumbnail
验证新旧核心和组合 | PKG-AUTO-THUMB-04, CORE-22 | 旧核心/候选核心预览、多次加载无过期缩略图覆盖 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo | | PKG-AUTO-THUMB-06 | artplayer-plugin-auto-thumbnail
验证分发并同步文档 | PKG-AUTO-THUMB-05, ENG-07 | auto.thumbnail.js、三种产物和内存/URL 证据 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo | | PKG-AUTO-THUMB-07 | artplayer-plugin-auto-thumbnail
终止抽帧时清空私有画布像素尺寸 | PKG-AUTO-THUMB-02, CORE-09, CORE-19 | 将私有 canvas 尺寸归入 job 清理;完成、取消、失败及分配重入均有回归,保留已编码 JPEG | 旧红新绿证明保留回调时画布归零,单项清理失败不阻断其他资源;原生取消和完成后 JPEG 仍可解码,原 API/时间公式/类型不变 | M | done | | PKG-AUTO-THUMB-08 | artplayer-plugin-auto-thumbnail
保留旧声明并提供准确异步类型入口 | PKG-AUTO-THUMB-02, ENG-04, ENG-06, CORE-07 | 保留实际 npm 1.1.0 根声明;新增同实现 /runtime 类型、编辑器声明及隔离安装消费者 | 原 Parameters/ReturnType/替代函数不变;准确 Promise 类型正反例、CJS/ESM 条件入口和声明解析通过;不把像素与设备缺口计为通过 | M | done | | PKG-AUTO-THUMB-09 | artplayer-plugin-auto-thumbnail
兼容旧 CommonJS default 与直接工厂调用 | PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-08 | 同一 callable 上的 default 自别名,保留最新根声明并补准确 runtime 别名类型;旧/新实际包入口证据 | 旧红新绿、直接/default/ESM/legacy/global 调用一致;旧根类型提取不变,公开准确类型与实际别名匹配;不关闭抽帧像素和设备缺口 | M | done | +| PKG-AUTO-THUMB-10 | artplayer-plugin-auto-thumbnail
为渐进 JPEG 编码建立取消时限与独立资源归属 | PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-07 | 拆分私有 canvas 编码模块,一次一帧的30秒编码时限,完成/失败/切源/销毁取消;迟到和重复回调无副作用 | 受控回调未返回的旧红新绿、正常JPEG和前一预览保持、同步重入/清理失败有回归;真实三浏览器编码/取消通过,公开时间公式与入口不变,首帧风险独立保留 | M | done | ## 5 包迁移:artplayer-plugin-vtt-thumbnail @@ -639,4 +640,5 @@ - PKG-AUTO-THUMB-07: [记录](changes/2026-09-14-PKG-AUTO-THUMB-07-canvas.md) [记录](baselines/auto-thumbnail-canvas-validation.json) - PKG-AUTO-THUMB-08: [记录](changes/2026-09-14-PKG-AUTO-THUMB-08-types.md) [记录](baselines/auto-thumbnail-types-validation.json) - PKG-AUTO-THUMB-09: [记录](changes/2026-09-14-PKG-AUTO-THUMB-09-alias.md) [记录](baselines/auto-thumbnail-alias-validation.json) +- PKG-AUTO-THUMB-10: [记录](changes/2026-09-14-PKG-AUTO-THUMB-10-encoding.md) [记录](baselines/auto-thumbnail-encoding-validation.json) - ENG-LINT-01: [记录](changes/2026-09-14-ENG-LINT-01-manifest-order.md) [记录](baselines/manifest-lint-validation.json) diff --git a/refactor/progress.md b/refactor/progress.md index aa0e3a0cc..8e87b18d9 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,19 @@ # 进度与证据 +## PKG-AUTO-THUMB-10 编码归属与取消时限完成 + +JPEG编码拆为独立TS模块,原来帧等待结束后编码无时限,现有独立30秒编码时限。 +完成时先撤销该时限再发布/取下一帧,切源/销毁/超时清理资源,迟到和重复Blob +不再产生后续动作;保留最后可用预览。此项明确限制极慢编码,不声称原生编码器 +已复现自发卡死,也不改变公开时间公式/类型/工厂。初始六项旧红,候选九项编码 +回归并入174项插件检查全部通过;严格TS通过,lint保留1既有warning。实际构建 +三格式及docs副本,产物11项Node与9项三浏览器通过;源码27项浏览器与50项CI +回归通过。超时浏览器用例执行真实JPEG但受控扣留回调/触发时限,像素测试原有 +首帧缺口仍独立保留。见[记录](changes/2026-09-14-PKG-AUTO-THUMB-10-encoding.md)及 +[证据](baselines/auto-thumbnail-encoding-validation.json)。新增任务10独立提交,246项: +174 done、16 doing、56 todo;任务03/首帧风险仍开放。没有依赖/版本变化或发布。 +下一步继续未完成包的资源、类型收尾与组合验证,不重复已失败的原生首帧等待方案。 + ## Auto Thumbnail 原生首帧边界检查点 新增独立 video/canvas 诊断,完全不加载 ArtPlayer 或插件。冻结原视频不变,另用 diff --git a/refactor/risk-table.md b/refactor/risk-table.md index 38342b4cf..543f81406 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -263,3 +263,4 @@ | VTT-CORE-NAME-01 | open / 已复现 | Published VTT 1.0.1 control name conflicts with the core placeholder introduced in 5.1.7 | PKG-VTT-THUMB-05, REL-08 | | SCAFFOLD-OUTPUT-01 | resolved / 已复现 | Plugin generation overwrites existing examples and links to a different example name | MOD-PLUGIN-01 | | DEV-SERVOR-PORT-01 | resolved / 已复现 | Servor exits the entire dev process with status zero when the requested port is occupied | MOD-DEV-01 | +| AUTO-THUMB-ENCODE-TIMEOUT-01 | resolved / 源码/产物事实 | Pending JPEG encoding has no bounded lifetime after frame readiness finishes | PKG-AUTO-THUMB-10 | diff --git a/refactor/risks.json b/refactor/risks.json index e740fea03..2f39af599 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -5940,6 +5940,28 @@ "test/browser/library-development.spec.js" ], "resolutionRationale": "Exact port binding rejects with EADDRINUSE; CLI propagates exit1. Explicit close/done owns HTTP sockets, file pipelines, source/docs watchers, SSE heartbeat and active builds. Eight node tests and six Windows real-browser cases cover the stated defect and lifecycle scope without stopping unrelated services." + }, + { + "id": "AUTO-THUMB-ENCODE-TIMEOUT-01", + "title": "Pending JPEG encoding has no bounded lifetime after frame readiness finishes", + "confirmation": "source-observed", + "status": "resolved", + "owners": [ + "PKG-AUTO-THUMB-10" + ], + "evidence": [ + "refactor/changes/2026-09-14-PKG-AUTO-THUMB-10-encoding.md", + "refactor/baselines/auto-thumbnail-encoding-validation.json" + ], + "compatibleResolution": "Preserve normal progressive JPEG publication and prior previews; add an explicit30-second encoding deadline with cancellation and stale callback protection.", + "closureCriteria": "Withheld callback ownership, timeout/late delivery, completion/restart/destroy and registration/cleanup reentry have regression coverage; native JPEG/resource and built artifact checks pass without waiving pixel/device gates.", + "workspaceState": "Source inspection and six controlled old failures show no encoding deadline. Dedicated encoder now owns one30-second timer, clears it before publication and releases the job on expiry. This is a bounded-resource policy, not proof of a spontaneous native encoder hang. Initial metadata/network and aggregate budgets remain separate.", + "resolutionEvidence": [ + "refactor/baselines/auto-thumbnail-encoding-validation.json", + "test/auto-thumbnail-encoding.test.js", + "test/browser/auto-thumbnail-lifecycle.spec.js" + ], + "resolutionRationale": "Nine controlled cases and real native JPEG tests cover held/late callbacks and decoder/canvas disposal. Actual production UMD passes11 Node and9 browser checks; previous preview survives timeout. Very slow encoding can now be discarded explicitly; public options, registration and frame-time formula remain intact." } ] } diff --git a/refactor/tasks.json b/refactor/tasks.json index 3428698c7..622816b37 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -1819,7 +1819,8 @@ "ENG-06", "CORE-07", "PKG-AUTO-THUMB-08", - "PKG-AUTO-THUMB-09" + "PKG-AUTO-THUMB-09", + "PKG-AUTO-THUMB-10" ], "status": "todo", "risk": "H", @@ -5071,6 +5072,26 @@ "baselines/auto-thumbnail-alias-validation.json" ] }, + { + "id": "PKG-AUTO-THUMB-10", + "phase": "5 包迁移:artplayer-plugin-auto-thumbnail", + "title": "为渐进 JPEG 编码建立取消时限与独立资源归属", + "scope": [ + "artplayer-plugin-auto-thumbnail" + ], + "dependsOn": [ + "PKG-AUTO-THUMB-02", + "PKG-AUTO-THUMB-07" + ], + "status": "done", + "risk": "M", + "deliverable": "拆分私有 canvas 编码模块,一次一帧的30秒编码时限,完成/失败/切源/销毁取消;迟到和重复回调无副作用", + "acceptance": "受控回调未返回的旧红新绿、正常JPEG和前一预览保持、同步重入/清理失败有回归;真实三浏览器编码/取消通过,公开时间公式与入口不变,首帧风险独立保留", + "evidence": [ + "changes/2026-09-14-PKG-AUTO-THUMB-10-encoding.md", + "baselines/auto-thumbnail-encoding-validation.json" + ] + }, { "id": "ENG-LINT-01", "phase": "2 工程基础", diff --git a/test/README.md b/test/README.md index aef253e24..0fa22e474 100644 --- a/test/README.md +++ b/test/README.md @@ -55,6 +55,13 @@ is in `test/react/`; unique reports and frozen consumer locks are retained under the recorded old wrapper's callback-exception leak. See the example README for the compatibility contract and the capabilities covered by other package tasks. +`test/auto-thumbnail-encoding.test.js` verifies a separate30-second JPEG deadline, +withheld and duplicate callbacks, replacement/destruction, synchronous completion +and timer cleanup failures. It runs in `test:auto-thumbnail` and `test:unit`. +Native lifecycle tests deliberately hold an actual JPEG callback and invoke the +captured deadline; that proves cleanup with native resources, not a spontaneous +encoder hang. First-frame pixel and device limitations remain separate gates. + `yarn test:auto-thumbnail-types` checks the preserved npm 1.1.0 declaration, accurate `/runtime` Promise types, exact rejected consumer lines, and generated editor globals. It also runs in `test:auto-thumbnail` and `test:baseline`. diff --git a/test/auto-thumbnail-encoding.test.js b/test/auto-thumbnail-encoding.test.js new file mode 100644 index 000000000..80afa2a4e --- /dev/null +++ b/test/auto-thumbnail-encoding.test.js @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test -- Verify bounded encoding ownership with controlled native callbacks. +import test from 'node:test' +import { autoThumbnailCandidate, autoThumbnailEnvironment } from './helpers/auto-thumbnail.js' + +const implementation = await autoThumbnailCandidate() +async function setup(number = 2) { + const env = autoThumbnailEnvironment(implementation) + await env.factory({ width: 80, number })(env.art) + env.art.emit('video:loadedmetadata') + env.metadata() + env.seeked() + return env +} + +function closed(env) { + assert.equal(env.timers.size, 0) + assert.equal(env.attached.size, 0) + assert.equal(env.videos[0].src, undefined) + assert.equal(env.canvases[0].width, 0) + assert.equal(env.canvases[0].height, 0) +} + +test('Auto-thumbnail stalled encoding closes owned resources and ignores a later Blob', async () => { + const env = await setup() + const timeout = [...env.timers.values()][0] + assert(timeout, 'Encoding must have an owned deadline after the frame wait finishes') + assert.equal(timeout.delay, 30000) + timeout.callback() + closed(env) + assert.equal(env.warnings.length, 1) + assert.match(env.warnings[0][1].message, /encoding timed out/) + env.finish() + timeout.callback() + assert.equal(env.updates.length, 0) + assert.equal(env.urls.size, 0) + assert.equal(env.warnings.length, 1) + env.art.emit('destroy') +}) + +test('Auto-thumbnail later encoding timeout preserves the last published preview', async () => { + const env = await setup() + env.finish() + const previous = env.art.thumbnails + env.seeked() + const timeout = [...env.timers.values()][0] + assert(timeout, 'The second encode needs a new deadline') + timeout.callback() + closed(env) + assert.equal(env.art.thumbnails, previous) + assert.equal(env.urls.has(previous.url), true) + env.finish(1) + assert.equal(env.updates.length, 1) + env.art.emit('destroy') + assert.equal(env.urls.size, 0) +}) + +for (const action of ['finish', 'destroy', 'restart']) { + test(`Auto-thumbnail ${action} invalidates an encoding deadline already queued for delivery`, async () => { + const env = await setup(1) + const timeout = [...env.timers.values()][0] + assert(timeout, 'Capture a real registered encoding deadline') + if (action === 'finish') + env.finish() + else env.art.emit(action) + timeout.callback() + env.finish() + closed(env) + assert.equal(env.warnings.length, 0) + assert.equal(env.updates.length, action === 'finish' ? 1 : 0) + env.art.emit('destroy') + assert.equal(env.urls.size, 0) + }) +} + +test('Auto-thumbnail completion clears only its own timeout before the next frame', async () => { + const env = await setup() + const timeout = [...env.timers.values()][0] + assert(timeout) + env.finish() + timeout.callback() + assert.equal(env.timers.size, 1, 'The next seek still owns its frame deadline') + env.seeked() + assert.equal(env.timers.size, 1, 'One encoding deadline replaces that frame deadline') + env.finish(1) + closed(env) + assert.equal(env.updates.length, 2) + assert.equal(env.warnings.length, 0) + env.art.emit('destroy') +}) + +test('Auto-thumbnail synchronous Blob delivery cancels its timeout and publishes once', async () => { + const env = autoThumbnailEnvironment(implementation) + await env.factory({ width: 80, number: 1 })(env.art) + env.art.emit('video:loadedmetadata') + env.metadata() + env.canvases[0].toBlob = (callback) => { + callback({ type: 'image/jpeg' }) + callback({ type: 'image/jpeg' }) + } + env.seeked() + closed(env) + assert.equal(env.updates.length, 1) + assert.equal(env.warnings.length, 0) + env.art.emit('destroy') +}) + +test('Auto-thumbnail deadline registration cannot start encoding across synchronous destruction', async () => { + const env = autoThumbnailEnvironment(implementation) + await env.factory({ width: 80, number: 1 })(env.art) + env.art.emit('video:loadedmetadata') + env.metadata() + const schedule = env.box.setTimeout + env.box.setTimeout = (...args) => { + env.art.emit('destroy') + return schedule(...args) + } + env.seeked() + closed(env) + assert.equal(env.blobs.length, 0) + assert.equal(env.warnings.length, 0) +}) + +test('Auto-thumbnail failed encoding deadline cleanup still releases canvas and decoder', async () => { + const env = await setup() + const clear = env.box.clearTimeout + const failure = new Error('Encoding timeout cleanup failed') + env.box.clearTimeout = (id) => { + clear(id) + throw failure + } + env.finish() + closed(env) + assert.equal(env.updates.length, 0) + assert.equal(env.warnings.length, 1) + assert.equal(env.warnings[0][1], failure) + env.art.emit('destroy') +}) diff --git a/test/auto-thumbnail-frames.test.js b/test/auto-thumbnail-frames.test.js index c267359c2..c9a74e9b9 100644 --- a/test/auto-thumbnail-frames.test.js +++ b/test/auto-thumbnail-frames.test.js @@ -71,7 +71,7 @@ for (const order of ['seek-first', 'frame-first']) { env.present() else env.seeked() assert.equal(env.blobs.length, 1) - assert.equal(env.timers.size, 0) + assert.equal(env.timers.size, 1, 'Encoding now owns a deadline after the frame wait ends') seeked() frame() assert.equal(env.blobs.length, 1) diff --git a/test/browser/auto-thumbnail-lifecycle.spec.js b/test/browser/auto-thumbnail-lifecycle.spec.js index 83da352fa..c97404760 100644 --- a/test/browser/auto-thumbnail-lifecycle.spec.js +++ b/test/browser/auto-thumbnail-lifecycle.spec.js @@ -3,7 +3,7 @@ import { autoThumbnailCandidate } from '../helpers/auto-thumbnail.js' import { expect, test } from './fixtures.js' const implementation = await autoThumbnailCandidate() -for (const variant of ['destroy', 'restart', 'complete', 'frame-destroy', 'frame-restart', 'alias-complete']) { +for (const variant of ['destroy', 'restart', 'complete', 'frame-destroy', 'frame-restart', 'alias-complete', 'encoding-timeout']) { const useDefault = variant === 'alias-complete' const scenario = useDefault ? 'complete' : variant test(`Auto-thumbnail candidate native ${variant} owns decoder and encoded URLs`, async ({ page }, testInfo) => { @@ -11,11 +11,25 @@ for (const variant of ['destroy', 'restart', 'complete', 'frame-destroy', 'frame await page.setContent('
') await page.addScriptTag({ content: implementation.code }) await page.evaluate(async ({ scenario, useDefault }) => { - const probe = window.probe = { videos: [], canvases: [], updates: [], urls: new Set(), blobs: [], frames: [], frameSupport: false, listeners: new Map(), hold: scenario !== 'complete' } + const probe = window.probe = { videos: [], canvases: [], updates: [], urls: new Set(), blobs: [], frames: [], timers: new Map(), warnings: [], 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 + const schedule = window.setTimeout + const clear = window.clearTimeout + const warn = console.warn + window.setTimeout = (callback, delay, ...args) => { + const id = schedule(callback, delay, ...args) + if (delay === 30000) + probe.timers.set(id, { callback, delay }) + return id + } + window.clearTimeout = (id) => { + probe.timers.delete(id) + clear(id) + } + console.warn = (...args) => probe.warnings.push(args.map(String)) document.createElement = function (name, ...args) { const element = createElement(name, ...args) if (name === 'canvas') @@ -33,6 +47,7 @@ for (const variant of ['destroy', 'restart', 'complete', 'frame-destroy', 'frame return element } HTMLCanvasElement.prototype.toBlob = function (callback, type) { + probe.encodingDeadline = [...probe.timers.values()][0] return toBlob.call(this, (blob) => { const record = { blob, callback } probe.blobs.push(record) @@ -54,6 +69,9 @@ for (const variant of ['destroy', 'restart', 'complete', 'frame-destroy', 'frame HTMLCanvasElement.prototype.toBlob = toBlob URL.createObjectURL = createUrl URL.revokeObjectURL = revokeUrl + window.setTimeout = schedule + window.clearTimeout = clear + console.warn = warn } probe.art = { option: { url: '/test/pattern.mp4' }, @@ -85,7 +103,12 @@ for (const variant of ['destroy', 'restart', 'complete', 'frame-destroy', 'frame const probe = window.probe const before = { urls: probe.urls.size, updates: probe.updates.length } if (scenario !== 'complete') { - probe.art.emit(scenario.replace('frame-', '')) + if (scenario === 'encoding-timeout') { + if (!probe.encodingDeadline || probe.encodingDeadline.delay !== 30000) + throw new Error('No owned encoding deadline') + probe.encodingDeadline.callback() + } + else 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) @@ -111,10 +134,16 @@ for (const variant of ['destroy', 'restart', 'complete', 'frame-destroy', 'frame 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, canvasDimensions, publishedImage, final, blobs, heldFrames: probe.frames.length, frameSupport: probe.frameSupport, result: probe.result } + return { before, after, video, canvasDimensions, publishedImage, final, blobs, pendingDeadlines: probe.timers.size, warnings: probe.warnings, 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, useDefault, 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.pendingDeadlines).toBe(0) + if (scenario === 'encoding-timeout') { + expect(state.warnings).toHaveLength(1) + expect(state.warnings[0][1]).toContain('encoding timed out') + } + else expect(state.warnings).toEqual([]) 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, true] })