diff --git a/docs/compiled/artplayer-plugin-auto-thumbnail.js b/docs/compiled/artplayer-plugin-auto-thumbnail.js
index 32a90e93e..8b86f9ee5 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?(t.artplayerPluginAutoThumbnail=t(),define(function(){return t.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";return function(e){return async t=>(t.on("video:loadedmetadata",()=>{const n=e.url||t.option.url,o=e.width||160,i=e.number||100,u=e.scale||1;!function({url:e,width:t,number:n},o){const i=document.createElement("video");i.crossOrigin="anonymous",i.src=e,i.onloadedmetadata=()=>{const e=i.duration,u=document.createElement("canvas"),a=u.getContext("2d"),l=Math.floor(t*i.videoHeight/i.videoWidth);u.width=10*t,u.height=l*Math.ceil(n/10);let r=null;!function d(c){u.toBlob(e=>{URL.revokeObjectURL(r),r=URL.createObjectURL(e),o({url:r,height:l})},"image/jpeg"),c>=n||(i.currentTime=e*c/n,i.onseeked=()=>{a.drawImage(i,c%10*t,Math.floor(c/10)*l,t,l),d(c+1)})}(0)}}({url:n,width:o,number:i},e=>{t.thumbnails={...e,column:10,number:i,width:o,scale:u}})}),{name:"artplayerPluginAutoThumbnail"})}});
+!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e,t){const i=document.createElement("video");e.own(()=>i.load()),e.own(()=>i.removeAttribute("src")),e.own(()=>i.pause());for(const n of["onloadedmetadata","onseeked","onerror"])e.own(()=>{i[n]=null});e.active()&&(i.crossOrigin="anonymous",i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,{height:r,canvasWidth:o,canvasHeight:a}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:i.videoHeight,videoWidth:i.videoWidth});if(!e.active())return;const u=document.createElement("canvas");if(!e.active())return;u.width=o,u.height=a;const d=u.getContext("2d");if(!d)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let c=0;const s=e.guard(()=>{c>=t.number?e.dispose():(i.onseeked=e.guard(()=>{if(i.onseeked=null,d.drawImage(i,c%10*t.width,Math.floor(c/10)*r,t.width,r),!e.active())return;let n=!1;u.toBlob(e.guard(i=>{n||(n=!0,e.publish(i,{height:r,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(c+=1,s()))}),"image/jpeg")}),e.active()&&(i.currentTime=n*c/t.number))});s()}),e.active()&&(i.src=t.url))}function t(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}return function(i){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(e,i){let n,r,o=!1;const a=new Set;function u(e){a.delete(e)&&URL.revokeObjectURL(e)}function d(){const e=n;n=void 0,e?.dispose()}return{start:function(){if(o)return;const d=n,c=[];let s=!1;const l={active:()=>!o&&!s&&n===l,own(e){if(s){const n=t([e]);n.failed&&i(n.failure)}else c.push(e)},dispose(){if(s)return;s=!0,n===l&&(n=void 0);const e=t(c.splice(0).reverse());e.failed&&i(e.failure)},fail(e){l.active()&&(l.dispose(),i(e))},guard:e=>(...t)=>{if(l.active())try{return e(...t)}catch(i){l.fail(i)}},publish(n,o){if(!l.active())return;if(!n)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(n);if(a.add(d),!l.active())return void u(d);const c=r;r=d;try{e({url:d,...o})}catch(s){r===d&&(r=a.has(c)?c:void 0);const e=t([()=>u(d)]);throw e.failed&&i(e.failure),s}u(c)}};return n=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)return;o=!0;const e=t([d,...[...a].map(e=>()=>u(e))]);r=void 0,e.failed&&i(e.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const t=o.start();t&&t.guard(()=>{const r=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(i,()=>n.option.url);t.active()&&e(t,r)})()},d=()=>{const e=a.splice(0),i=t([o.destroy,...e]);i.failed&&r(i.failure)};try{for(const[e,t]of[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]]){if(o.closed)break;a.push(()=>n.off(e,t)),n.on(e,t),o.closed&&n.off(e,t)}}catch(c){throw d(),c}return{name:"artplayerPluginAutoThumbnail"}}}});
diff --git a/docs/compiled/artplayer-plugin-auto-thumbnail.legacy.js b/docs/compiled/artplayer-plugin-auto-thumbnail.legacy.js
index 130e2be79..efa10b587 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?(t.artplayerPluginAutoThumbnail=t(),define(function(){return t.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,r=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,i=(t,n,r)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[n]=r;return function(e){return l=>{return u=null,c=null,d=function*(){return l.on("video:loadedmetadata",()=>{const u=e.url||l.option.url,c=e.width||160,d=e.number||100,s=e.scale||1;!function({url:e,width:t,number:n},r){const o=document.createElement("video");o.crossOrigin="anonymous",o.src=e,o.onloadedmetadata=()=>{const e=o.duration,a=document.createElement("canvas"),i=a.getContext("2d"),l=Math.floor(t*o.videoHeight/o.videoWidth);a.width=10*t,a.height=l*Math.ceil(n/10);let u=null;!function c(d){a.toBlob(e=>{URL.revokeObjectURL(u),u=URL.createObjectURL(e),r({url:u,height:l})},"image/jpeg"),d>=n||(o.currentTime=e*d/n,o.onseeked=()=>{i.drawImage(o,d%10*t,Math.floor(d/10)*l,t,l),c(d+1)})}(0)}}({url:u,width:c,number:d},e=>{var u;l.thumbnails=(u=((e,t)=>{for(var n in t||(t={}))o.call(t,n)&&i(e,n,t[n]);if(r)for(var n of r(t))a.call(t,n)&&i(e,n,t[n]);return e})({},e),t(u,n({column:10,number:d,width:c,scale:s})))})}),{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var n=e=>{try{o(d.next(e))}catch(n){t(n)}},r=e=>{try{o(d.throw(e))}catch(n){t(n)}},o=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,r);o((d=d.apply(u,c)).next())});var u,c,d}}});
+!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,r=Object.prototype.hasOwnProperty,n=Object.prototype.propertyIsEnumerable,i=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n;function o(e,t){const r=document.createElement("video");e.own(()=>r.load()),e.own(()=>r.removeAttribute("src")),e.own(()=>r.pause());for(const n of["onloadedmetadata","onseeked","onerror"])e.own(()=>{r[n]=null});e.active()&&(r.crossOrigin="anonymous",r.onerror=e.guard(()=>{throw r.error||new Error("Auto-thumbnail media failed to load")}),r.onloadedmetadata=e.guard(()=>{r.onloadedmetadata=null;const n=r.duration,{height:i,canvasWidth:o,canvasHeight:a}=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 r=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),i=Math.trunc(r*Math.ceil(e.number/10));if(![r,n,i].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:r,canvasWidth:n,canvasHeight:i}}(t,{duration:n,videoHeight:r.videoHeight,videoWidth:r.videoWidth});if(!e.active())return;const u=document.createElement("canvas");if(!e.active())return;u.width=o,u.height=a;const d=u.getContext("2d");if(!d)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let c=0;const l=e.guard(()=>{c>=t.number?e.dispose():(r.onseeked=e.guard(()=>{if(r.onseeked=null,d.drawImage(r,c%10*t.width,Math.floor(c/10)*i,t.width,i),!e.active())return;let n=!1;u.toBlob(e.guard(r=>{n||(n=!0,e.publish(r,{height:i,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(c+=1,l()))}),"image/jpeg")}),e.active()&&(r.currentTime=n*c/t.number))});l()}),e.active()&&(r.src=t.url))}function a(e){let t,r=!1;for(const i of e)try{i()}catch(n){r||(r=!0,t=n)}return{failed:r,failure:t}}function u(e,o){let u,d,c=!1;const l=new Set;function s(e){l.delete(e)&&URL.revokeObjectURL(e)}function f(){const e=u;u=void 0,null==e||e.dispose()}return{start:function(){if(c)return;const f=u,h=[];let m=!1;const v={active:()=>!c&&!m&&u===v,own(e){if(m){const t=a([e]);t.failed&&o(t.failure)}else h.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=a(h.splice(0).reverse());e.failed&&o(e.failure)},fail(e){v.active()&&(v.dispose(),o(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(r){v.fail(r)}},publish(u,c){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const f=URL.createObjectURL(u);if(l.add(f),!v.active())return void s(f);const h=d;d=f;try{e(((e,o)=>{for(var a in o||(o={}))r.call(o,a)&&i(e,a,o[a]);if(t)for(var a of t(o))n.call(o,a)&&i(e,a,o[a]);return e})({url:f},c))}catch(m){d===f&&(d=l.has(h)?h:void 0);const e=a([()=>s(f)]);throw e.failed&&o(e.failure),m}s(h)}};return u=v,null==f||f.dispose(),v},cancel:f,destroy:function(){if(c)return;c=!0;const e=a([f,...[...l].map(e=>()=>s(e))]);d=void 0,e.failed&&o(e.failure)},get closed(){return c}}}return function(e){return t=>{return r=null,n=null,i=function*(){const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),n=u(e=>{t.thumbnails=e},r),i=[],d=()=>{const r=n.start();r&&r.guard(()=>{const n=function(e,t){const r={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(r.width))||r.width<=0||!Number.isFinite(Number(r.number))||r.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return r}(e,()=>t.option.url);r.active()&&o(r,n)})()},c=()=>{const e=i.splice(0),t=a([n.destroy,...e]);t.failed&&r(t.failure)};try{for(const[e,r]of[["destroy",c],["restart",n.cancel],["video:loadedmetadata",d]]){if(n.closed)break;i.push(()=>t.off(e,r)),t.on(e,r),n.closed&&t.off(e,r)}}catch(l){throw c(),l}return{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var o=e=>{try{u(i.next(e))}catch(r){t(r)}},a=e=>{try{u(i.throw(e))}catch(r){t(r)}},u=t=>t.done?e(t.value):Promise.resolve(t.value).then(o,a);u((i=i.apply(r,n)).next())});var r,n,i}}});
diff --git a/docs/compiled/artplayer-plugin-auto-thumbnail.mjs b/docs/compiled/artplayer-plugin-auto-thumbnail.mjs
index 534add23f..bee9c380f 100644
--- a/docs/compiled/artplayer-plugin-auto-thumbnail.mjs
+++ b/docs/compiled/artplayer-plugin-auto-thumbnail.mjs
@@ -4,58 +4,251 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
-function create({ url, width, number }, callback) {
+function readOptions(option, getFallbackUrl) {
+ const config = {
+ url: option.url || getFallbackUrl(),
+ width: option.width || 160,
+ number: option.number || 100,
+ scale: option.scale || 1
+ };
+ if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
+ throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");
+ return config;
+}
+function sheetSize(config, video) {
+ if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
+ throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");
+ const height = Math.floor(config.width * video.videoHeight / video.videoWidth);
+ const canvasWidth = Math.trunc(config.width * 10);
+ const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10));
+ if (![height, canvasWidth, canvasHeight].every((value) => Number.isSafeInteger(value) && value > 0 && value <= 4294967295))
+ throw new RangeError("Auto-thumbnail canvas dimensions are invalid");
+ return { height, canvasWidth, canvasHeight };
+}
+function extract(job, config) {
const video = document.createElement("video");
+ job.own(() => video.load());
+ job.own(() => video.removeAttribute("src"));
+ job.own(() => video.pause());
+ for (const property of ["onloadedmetadata", "onseeked", "onerror"])
+ job.own(() => {
+ video[property] = null;
+ });
+ if (!job.active())
+ return;
video.crossOrigin = "anonymous";
- video.src = url;
- video.onloadedmetadata = () => {
+ video.onerror = job.guard(() => {
+ throw video.error || new Error("Auto-thumbnail media failed to load");
+ });
+ video.onloadedmetadata = job.guard(() => {
+ video.onloadedmetadata = null;
const duration = video.duration;
+ const { height, canvasWidth, canvasHeight } = sheetSize(config, {
+ duration,
+ videoHeight: video.videoHeight,
+ videoWidth: video.videoWidth
+ });
+ if (!job.active())
+ return;
const canvas = document.createElement("canvas");
+ if (!job.active())
+ return;
+ canvas.width = canvasWidth;
+ canvas.height = canvasHeight;
const ctx = canvas.getContext("2d");
- const height = Math.floor(width * video.videoHeight / video.videoWidth);
- canvas.width = width * 10;
- canvas.height = height * Math.ceil(number / 10);
- let blobUrl = null;
- function seekAndDraw(index) {
- canvas.toBlob((blob) => {
- URL.revokeObjectURL(blobUrl);
- blobUrl = URL.createObjectURL(blob);
- callback({
- url: blobUrl,
- height
- });
- }, "image/jpeg");
- if (index >= number)
+ if (!ctx)
+ throw new Error("Auto-thumbnail canvas context is unavailable");
+ if (!job.active())
+ return;
+ let index = 0;
+ const seek = job.guard(() => {
+ if (index >= config.number) {
+ job.dispose();
return;
- video.currentTime = duration * index / number;
- video.onseeked = () => {
- ctx.drawImage(video, index % 10 * width, Math.floor(index / 10) * height, width, height);
- seekAndDraw(index + 1);
- };
+ }
+ video.onseeked = job.guard(() => {
+ video.onseeked = null;
+ ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height);
+ if (!job.active())
+ return;
+ let delivered = false;
+ canvas.toBlob(job.guard((blob) => {
+ if (delivered)
+ return;
+ delivered = true;
+ job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale });
+ if (job.active()) {
+ index += 1;
+ seek();
+ }
+ }), "image/jpeg");
+ });
+ if (job.active())
+ video.currentTime = duration * index / config.number;
+ });
+ seek();
+ });
+ if (job.active())
+ video.src = config.url;
+}
+function cleanupAll(actions) {
+ let failure;
+ let failed = false;
+ for (const action of actions) {
+ try {
+ action();
+ } catch (error) {
+ if (!failed) {
+ failed = true;
+ failure = error;
+ }
+ }
+ }
+ return { failed, failure };
+}
+function createSession(publish, report) {
+ let closed = false;
+ let current;
+ let lastUrl;
+ const urls = /* @__PURE__ */ new Set();
+ function release(url) {
+ if (urls.delete(url))
+ URL.revokeObjectURL(url);
+ }
+ function cancel() {
+ const previous = current;
+ current = void 0;
+ previous?.dispose();
+ }
+ function destroy() {
+ if (closed)
+ return;
+ closed = true;
+ const result = cleanupAll([cancel, ...[...urls].map((url) => () => release(url))]);
+ lastUrl = void 0;
+ if (result.failed)
+ report(result.failure);
+ }
+ function start() {
+ if (closed)
+ return;
+ const previous = current;
+ const actions = [];
+ let disposed = false;
+ const job = {
+ active: () => !closed && !disposed && current === job,
+ own(action) {
+ if (disposed) {
+ const result = cleanupAll([action]);
+ if (result.failed)
+ report(result.failure);
+ } else {
+ actions.push(action);
+ }
+ },
+ dispose() {
+ if (disposed)
+ return;
+ disposed = true;
+ if (current === job)
+ current = void 0;
+ const result = cleanupAll(actions.splice(0).reverse());
+ if (result.failed)
+ report(result.failure);
+ },
+ fail(error) {
+ if (!job.active())
+ return;
+ job.dispose();
+ report(error);
+ },
+ guard(callback) {
+ return (...args) => {
+ if (!job.active())
+ return;
+ try {
+ return callback(...args);
+ } catch (error) {
+ job.fail(error);
+ }
+ };
+ },
+ publish(blob, config) {
+ if (!job.active())
+ return;
+ if (!blob)
+ throw new Error("Auto-thumbnail encoding returned no Blob");
+ const url = URL.createObjectURL(blob);
+ urls.add(url);
+ if (!job.active()) {
+ release(url);
+ return;
+ }
+ const previousUrl = lastUrl;
+ lastUrl = url;
+ try {
+ publish({ url, ...config });
+ } catch (error) {
+ if (lastUrl === url)
+ lastUrl = urls.has(previousUrl) ? previousUrl : void 0;
+ const result = cleanupAll([() => release(url)]);
+ if (result.failed)
+ report(result.failure);
+ throw error;
+ }
+ release(previousUrl);
+ }
+ };
+ current = job;
+ previous?.dispose();
+ return job;
+ }
+ return {
+ start,
+ cancel,
+ destroy,
+ get closed() {
+ return closed;
}
- seekAndDraw(0);
};
}
function artplayerPluginAutoThumbnail(option) {
return async (art) => {
- art.on("video:loadedmetadata", () => {
- const url = option.url || art.option.url;
- const width = option.width || 160;
- const number = option.number || 100;
- const scale = option.scale || 1;
- create({ url, width, number }, (config) => {
- art.thumbnails = {
- ...config,
- column: 10,
- number,
- width,
- scale
- };
- });
- });
- return {
- name: "artplayerPluginAutoThumbnail"
+ const report = (error) => console.warn("ArtPlayer auto-thumbnail failed:", error);
+ const session = createSession((config) => {
+ art.thumbnails = config;
+ }, report);
+ const subscriptions = [];
+ const onMetadata = () => {
+ const job = session.start();
+ if (job) {
+ job.guard(() => {
+ const config = readOptions(option, () => art.option.url);
+ if (job.active())
+ extract(job, config);
+ })();
+ }
};
+ const onDestroy = () => {
+ const actions = subscriptions.splice(0);
+ const result = cleanupAll([session.destroy, ...actions]);
+ if (result.failed)
+ report(result.failure);
+ };
+ try {
+ for (const [name, callback] of [["destroy", onDestroy], ["restart", session.cancel], ["video:loadedmetadata", onMetadata]]) {
+ if (session.closed)
+ break;
+ subscriptions.push(() => art.off(name, callback));
+ art.on(name, callback);
+ if (session.closed)
+ art.off(name, callback);
+ }
+ } catch (error) {
+ onDestroy();
+ throw error;
+ }
+ return { name: "artplayerPluginAutoThumbnail" };
};
}
export {
diff --git a/package.json b/package.json
index 6773a62b7..81583db43 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:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
- "test:unit": "node --test test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
+ "test:unit": "node --test test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -85,7 +85,7 @@
"test:iframe-types-package": "node refactor/scripts/iframe-package-types.mjs",
"test:thumbnail": "node --test test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs refactor/scripts/thumbnail-types.test.mjs",
"test:thumbnail-types-package": "node refactor/scripts/thumbnail-package-types.mjs",
- "test:auto-thumbnail": "node --test test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs"
+ "test:auto-thumbnail": "node --test test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
diff --git a/packages/artplayer-plugin-auto-thumbnail/ARCHITECTURE.md b/packages/artplayer-plugin-auto-thumbnail/ARCHITECTURE.md
new file mode 100644
index 000000000..d5243747e
--- /dev/null
+++ b/packages/artplayer-plugin-auto-thumbnail/ARCHITECTURE.md
@@ -0,0 +1,80 @@
+# Auto-thumbnail maintenance
+
+This package registers an asynchronous ArtPlayer plugin and generates progressive
+JPEG thumbnail sheets using a separate media element. Its public result remains
+`{ name: 'artplayerPluginAutoThumbnail' }`; the registration Promise does not wait
+for video processing.
+
+## Modules and ownership
+
+- `src/index.js` owns ArtPlayer subscriptions and the public factory. Options are
+ read at each `video:loadedmetadata` event, including subsequent changes to the
+ original options object. `restart` cancels the old decoder immediately. `destroy`
+ closes the session and removes subscriptions. Partial registration is rolled back.
+- `src/options.js` preserves truthy defaults and computes the ten-column layout.
+ Coercible numeric JS inputs and fractional frame counts retain their previous
+ behavior; raw option values are forwarded to the thumbnail configuration.
+ Invalid/nonfinite dimensions fail before canvas allocation. Canvas dimensions
+ are checked against integer/IDL bounds, not a universal memory budget.
+- `src/session.js` owns one active extraction job and the generated object URLs.
+ Guards prevent obsolete callbacks from publishing or scheduling another frame.
+ Cleanup runs all registered actions even if one fails. Installing a replacement
+ before cleaning up its predecessor makes synchronous host reentry observable.
+- `src/extraction.js` owns the private video and canvas, metadata/seek/error
+ handlers, and the frame/encode sequence. Each valid draw is encoded before the
+ next seek. The final decoder is paused, cleared and reset; the final sheet URL
+ remains owned by the session until another usable sheet replaces it or destroy.
+
+Only the entry module receives ArtPlayer. Extraction receives a guarded job and a
+configuration snapshot. These internal modules do not add public player fields,
+plugin methods, or a dependency on a particular core implementation.
+
+## Compatibility and intentional fixes
+
+The factory/global name, Promise registration, result name, default width 160,
+count 100, scale 1, ten columns, aspect-ratio height, and time formula
+`duration * index / number` are retained. An explicit URL takes precedence over
+`art.option.url`; the fallback is read lazily only when no explicit URL is supplied.
+Duration and sheet dimensions are captured at metadata time; later duration changes
+do not move the remaining samples within the current sheet.
+The old `height` option remains ignored at runtime.
+
+The previous empty first encode is removed. Encoding is serial, duplicate native
+callbacks are consumed once, and source replacement/destruction invalidates old
+metadata, seek and Blob callbacks. Failure does not revoke an earlier usable
+preview. Only generated URLs are revoked; a foreign public thumbnail URL is not
+owned by this package. A failed host setter releases the newly created URL.
+
+Extraction failures are observed with `console.warn('ArtPlayer auto-thumbnail failed:', error)`
+after cleanup. The already-resolved registration Promise cannot represent later
+media failures. Native callbacks do not escape with an unhandled exception. A
+registration failure still rejects its original Promise with the original error.
+
+## Verification and remaining work
+
+Use the root Yarn toolchain:
+
+```sh
+yarn test:auto-thumbnail
+yarn test:browser test/browser/auto-thumbnail-lifecycle.spec.js
+yarn build artplayer-plugin-auto-thumbnail
+```
+
+`ARTPLAYER_AUTO_THUMBNAIL_BASELINE=1` selects the frozen workspace for candidate
+regression tests. `ARTPLAYER_AUTO_THUMBNAIL_ARTIFACT` selects an actual bundle.
+Historical contract/failure tests remain separate and continue to reproduce old
+defects; their passing status does not mean those defects should remain.
+
+This is an intermediate `PKG-AUTO-THUMB-03` implementation. Native lifecycle
+validation does not prove correct pixels: the detached decoder path still needs
+the recorded Windows WebKit frame/rendering fix (`AUTO-THUMB-PIXEL-01`). Hidden
+rendering must retain source detail; a 1x1 element producing an averaged pixel is
+not a correct fix. Frame timing, additional reentry/cleanup boundaries and resource
+budgets still need the remaining task03 work.
+
+Task04 converts these modules to strict TypeScript and resolves historical public
+declaration/CommonJS differences. Task05 verifies old/final cores and actual
+devices; task06 covers installed package entries and the real demo/editor. The
+types and package version are unchanged at this checkpoint. Use
+`refactor/baselines/auto-thumbnail-contract.md` and `auto-thumbnail-failures.md` as
+the historical evidence map; do not regenerate those baselines from this source.
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 32a90e93e..8b86f9ee5 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?(t.artplayerPluginAutoThumbnail=t(),define(function(){return t.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";return function(e){return async t=>(t.on("video:loadedmetadata",()=>{const n=e.url||t.option.url,o=e.width||160,i=e.number||100,u=e.scale||1;!function({url:e,width:t,number:n},o){const i=document.createElement("video");i.crossOrigin="anonymous",i.src=e,i.onloadedmetadata=()=>{const e=i.duration,u=document.createElement("canvas"),a=u.getContext("2d"),l=Math.floor(t*i.videoHeight/i.videoWidth);u.width=10*t,u.height=l*Math.ceil(n/10);let r=null;!function d(c){u.toBlob(e=>{URL.revokeObjectURL(r),r=URL.createObjectURL(e),o({url:r,height:l})},"image/jpeg"),c>=n||(i.currentTime=e*c/n,i.onseeked=()=>{a.drawImage(i,c%10*t,Math.floor(c/10)*l,t,l),d(c+1)})}(0)}}({url:n,width:o,number:i},e=>{t.thumbnails={...e,column:10,number:i,width:o,scale:u}})}),{name:"artplayerPluginAutoThumbnail"})}});
+!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e,t){const i=document.createElement("video");e.own(()=>i.load()),e.own(()=>i.removeAttribute("src")),e.own(()=>i.pause());for(const n of["onloadedmetadata","onseeked","onerror"])e.own(()=>{i[n]=null});e.active()&&(i.crossOrigin="anonymous",i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,{height:r,canvasWidth:o,canvasHeight:a}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:i.videoHeight,videoWidth:i.videoWidth});if(!e.active())return;const u=document.createElement("canvas");if(!e.active())return;u.width=o,u.height=a;const d=u.getContext("2d");if(!d)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let c=0;const s=e.guard(()=>{c>=t.number?e.dispose():(i.onseeked=e.guard(()=>{if(i.onseeked=null,d.drawImage(i,c%10*t.width,Math.floor(c/10)*r,t.width,r),!e.active())return;let n=!1;u.toBlob(e.guard(i=>{n||(n=!0,e.publish(i,{height:r,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(c+=1,s()))}),"image/jpeg")}),e.active()&&(i.currentTime=n*c/t.number))});s()}),e.active()&&(i.src=t.url))}function t(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}return function(i){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(e,i){let n,r,o=!1;const a=new Set;function u(e){a.delete(e)&&URL.revokeObjectURL(e)}function d(){const e=n;n=void 0,e?.dispose()}return{start:function(){if(o)return;const d=n,c=[];let s=!1;const l={active:()=>!o&&!s&&n===l,own(e){if(s){const n=t([e]);n.failed&&i(n.failure)}else c.push(e)},dispose(){if(s)return;s=!0,n===l&&(n=void 0);const e=t(c.splice(0).reverse());e.failed&&i(e.failure)},fail(e){l.active()&&(l.dispose(),i(e))},guard:e=>(...t)=>{if(l.active())try{return e(...t)}catch(i){l.fail(i)}},publish(n,o){if(!l.active())return;if(!n)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(n);if(a.add(d),!l.active())return void u(d);const c=r;r=d;try{e({url:d,...o})}catch(s){r===d&&(r=a.has(c)?c:void 0);const e=t([()=>u(d)]);throw e.failed&&i(e.failure),s}u(c)}};return n=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)return;o=!0;const e=t([d,...[...a].map(e=>()=>u(e))]);r=void 0,e.failed&&i(e.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const t=o.start();t&&t.guard(()=>{const r=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(i,()=>n.option.url);t.active()&&e(t,r)})()},d=()=>{const e=a.splice(0),i=t([o.destroy,...e]);i.failed&&r(i.failure)};try{for(const[e,t]of[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]]){if(o.closed)break;a.push(()=>n.off(e,t)),n.on(e,t),o.closed&&n.off(e,t)}}catch(c){throw d(),c}return{name:"artplayerPluginAutoThumbnail"}}}});
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 130e2be79..efa10b587 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?(t.artplayerPluginAutoThumbnail=t(),define(function(){return t.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,r=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,i=(t,n,r)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[n]=r;return function(e){return l=>{return u=null,c=null,d=function*(){return l.on("video:loadedmetadata",()=>{const u=e.url||l.option.url,c=e.width||160,d=e.number||100,s=e.scale||1;!function({url:e,width:t,number:n},r){const o=document.createElement("video");o.crossOrigin="anonymous",o.src=e,o.onloadedmetadata=()=>{const e=o.duration,a=document.createElement("canvas"),i=a.getContext("2d"),l=Math.floor(t*o.videoHeight/o.videoWidth);a.width=10*t,a.height=l*Math.ceil(n/10);let u=null;!function c(d){a.toBlob(e=>{URL.revokeObjectURL(u),u=URL.createObjectURL(e),r({url:u,height:l})},"image/jpeg"),d>=n||(o.currentTime=e*d/n,o.onseeked=()=>{i.drawImage(o,d%10*t,Math.floor(d/10)*l,t,l),c(d+1)})}(0)}}({url:u,width:c,number:d},e=>{var u;l.thumbnails=(u=((e,t)=>{for(var n in t||(t={}))o.call(t,n)&&i(e,n,t[n]);if(r)for(var n of r(t))a.call(t,n)&&i(e,n,t[n]);return e})({},e),t(u,n({column:10,number:d,width:c,scale:s})))})}),{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var n=e=>{try{o(d.next(e))}catch(n){t(n)}},r=e=>{try{o(d.throw(e))}catch(n){t(n)}},o=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,r);o((d=d.apply(u,c)).next())});var u,c,d}}});
+!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,r=Object.prototype.hasOwnProperty,n=Object.prototype.propertyIsEnumerable,i=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n;function o(e,t){const r=document.createElement("video");e.own(()=>r.load()),e.own(()=>r.removeAttribute("src")),e.own(()=>r.pause());for(const n of["onloadedmetadata","onseeked","onerror"])e.own(()=>{r[n]=null});e.active()&&(r.crossOrigin="anonymous",r.onerror=e.guard(()=>{throw r.error||new Error("Auto-thumbnail media failed to load")}),r.onloadedmetadata=e.guard(()=>{r.onloadedmetadata=null;const n=r.duration,{height:i,canvasWidth:o,canvasHeight:a}=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 r=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),i=Math.trunc(r*Math.ceil(e.number/10));if(![r,n,i].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:r,canvasWidth:n,canvasHeight:i}}(t,{duration:n,videoHeight:r.videoHeight,videoWidth:r.videoWidth});if(!e.active())return;const u=document.createElement("canvas");if(!e.active())return;u.width=o,u.height=a;const d=u.getContext("2d");if(!d)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let c=0;const l=e.guard(()=>{c>=t.number?e.dispose():(r.onseeked=e.guard(()=>{if(r.onseeked=null,d.drawImage(r,c%10*t.width,Math.floor(c/10)*i,t.width,i),!e.active())return;let n=!1;u.toBlob(e.guard(r=>{n||(n=!0,e.publish(r,{height:i,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(c+=1,l()))}),"image/jpeg")}),e.active()&&(r.currentTime=n*c/t.number))});l()}),e.active()&&(r.src=t.url))}function a(e){let t,r=!1;for(const i of e)try{i()}catch(n){r||(r=!0,t=n)}return{failed:r,failure:t}}function u(e,o){let u,d,c=!1;const l=new Set;function s(e){l.delete(e)&&URL.revokeObjectURL(e)}function f(){const e=u;u=void 0,null==e||e.dispose()}return{start:function(){if(c)return;const f=u,h=[];let m=!1;const v={active:()=>!c&&!m&&u===v,own(e){if(m){const t=a([e]);t.failed&&o(t.failure)}else h.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=a(h.splice(0).reverse());e.failed&&o(e.failure)},fail(e){v.active()&&(v.dispose(),o(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(r){v.fail(r)}},publish(u,c){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const f=URL.createObjectURL(u);if(l.add(f),!v.active())return void s(f);const h=d;d=f;try{e(((e,o)=>{for(var a in o||(o={}))r.call(o,a)&&i(e,a,o[a]);if(t)for(var a of t(o))n.call(o,a)&&i(e,a,o[a]);return e})({url:f},c))}catch(m){d===f&&(d=l.has(h)?h:void 0);const e=a([()=>s(f)]);throw e.failed&&o(e.failure),m}s(h)}};return u=v,null==f||f.dispose(),v},cancel:f,destroy:function(){if(c)return;c=!0;const e=a([f,...[...l].map(e=>()=>s(e))]);d=void 0,e.failed&&o(e.failure)},get closed(){return c}}}return function(e){return t=>{return r=null,n=null,i=function*(){const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),n=u(e=>{t.thumbnails=e},r),i=[],d=()=>{const r=n.start();r&&r.guard(()=>{const n=function(e,t){const r={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(r.width))||r.width<=0||!Number.isFinite(Number(r.number))||r.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return r}(e,()=>t.option.url);r.active()&&o(r,n)})()},c=()=>{const e=i.splice(0),t=a([n.destroy,...e]);t.failed&&r(t.failure)};try{for(const[e,r]of[["destroy",c],["restart",n.cancel],["video:loadedmetadata",d]]){if(n.closed)break;i.push(()=>t.off(e,r)),t.on(e,r),n.closed&&t.off(e,r)}}catch(l){throw c(),l}return{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var o=e=>{try{u(i.next(e))}catch(r){t(r)}},a=e=>{try{u(i.throw(e))}catch(r){t(r)}},u=t=>t.done?e(t.value):Promise.resolve(t.value).then(o,a);u((i=i.apply(r,n)).next())});var r,n,i}}});
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 534add23f..bee9c380f 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,58 +4,251 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
-function create({ url, width, number }, callback) {
+function readOptions(option, getFallbackUrl) {
+ const config = {
+ url: option.url || getFallbackUrl(),
+ width: option.width || 160,
+ number: option.number || 100,
+ scale: option.scale || 1
+ };
+ if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
+ throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");
+ return config;
+}
+function sheetSize(config, video) {
+ if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
+ throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");
+ const height = Math.floor(config.width * video.videoHeight / video.videoWidth);
+ const canvasWidth = Math.trunc(config.width * 10);
+ const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10));
+ if (![height, canvasWidth, canvasHeight].every((value) => Number.isSafeInteger(value) && value > 0 && value <= 4294967295))
+ throw new RangeError("Auto-thumbnail canvas dimensions are invalid");
+ return { height, canvasWidth, canvasHeight };
+}
+function extract(job, config) {
const video = document.createElement("video");
+ job.own(() => video.load());
+ job.own(() => video.removeAttribute("src"));
+ job.own(() => video.pause());
+ for (const property of ["onloadedmetadata", "onseeked", "onerror"])
+ job.own(() => {
+ video[property] = null;
+ });
+ if (!job.active())
+ return;
video.crossOrigin = "anonymous";
- video.src = url;
- video.onloadedmetadata = () => {
+ video.onerror = job.guard(() => {
+ throw video.error || new Error("Auto-thumbnail media failed to load");
+ });
+ video.onloadedmetadata = job.guard(() => {
+ video.onloadedmetadata = null;
const duration = video.duration;
+ const { height, canvasWidth, canvasHeight } = sheetSize(config, {
+ duration,
+ videoHeight: video.videoHeight,
+ videoWidth: video.videoWidth
+ });
+ if (!job.active())
+ return;
const canvas = document.createElement("canvas");
+ if (!job.active())
+ return;
+ canvas.width = canvasWidth;
+ canvas.height = canvasHeight;
const ctx = canvas.getContext("2d");
- const height = Math.floor(width * video.videoHeight / video.videoWidth);
- canvas.width = width * 10;
- canvas.height = height * Math.ceil(number / 10);
- let blobUrl = null;
- function seekAndDraw(index) {
- canvas.toBlob((blob) => {
- URL.revokeObjectURL(blobUrl);
- blobUrl = URL.createObjectURL(blob);
- callback({
- url: blobUrl,
- height
- });
- }, "image/jpeg");
- if (index >= number)
+ if (!ctx)
+ throw new Error("Auto-thumbnail canvas context is unavailable");
+ if (!job.active())
+ return;
+ let index = 0;
+ const seek = job.guard(() => {
+ if (index >= config.number) {
+ job.dispose();
return;
- video.currentTime = duration * index / number;
- video.onseeked = () => {
- ctx.drawImage(video, index % 10 * width, Math.floor(index / 10) * height, width, height);
- seekAndDraw(index + 1);
- };
+ }
+ video.onseeked = job.guard(() => {
+ video.onseeked = null;
+ ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height);
+ if (!job.active())
+ return;
+ let delivered = false;
+ canvas.toBlob(job.guard((blob) => {
+ if (delivered)
+ return;
+ delivered = true;
+ job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale });
+ if (job.active()) {
+ index += 1;
+ seek();
+ }
+ }), "image/jpeg");
+ });
+ if (job.active())
+ video.currentTime = duration * index / config.number;
+ });
+ seek();
+ });
+ if (job.active())
+ video.src = config.url;
+}
+function cleanupAll(actions) {
+ let failure;
+ let failed = false;
+ for (const action of actions) {
+ try {
+ action();
+ } catch (error) {
+ if (!failed) {
+ failed = true;
+ failure = error;
+ }
+ }
+ }
+ return { failed, failure };
+}
+function createSession(publish, report) {
+ let closed = false;
+ let current;
+ let lastUrl;
+ const urls = /* @__PURE__ */ new Set();
+ function release(url) {
+ if (urls.delete(url))
+ URL.revokeObjectURL(url);
+ }
+ function cancel() {
+ const previous = current;
+ current = void 0;
+ previous?.dispose();
+ }
+ function destroy() {
+ if (closed)
+ return;
+ closed = true;
+ const result = cleanupAll([cancel, ...[...urls].map((url) => () => release(url))]);
+ lastUrl = void 0;
+ if (result.failed)
+ report(result.failure);
+ }
+ function start() {
+ if (closed)
+ return;
+ const previous = current;
+ const actions = [];
+ let disposed = false;
+ const job = {
+ active: () => !closed && !disposed && current === job,
+ own(action) {
+ if (disposed) {
+ const result = cleanupAll([action]);
+ if (result.failed)
+ report(result.failure);
+ } else {
+ actions.push(action);
+ }
+ },
+ dispose() {
+ if (disposed)
+ return;
+ disposed = true;
+ if (current === job)
+ current = void 0;
+ const result = cleanupAll(actions.splice(0).reverse());
+ if (result.failed)
+ report(result.failure);
+ },
+ fail(error) {
+ if (!job.active())
+ return;
+ job.dispose();
+ report(error);
+ },
+ guard(callback) {
+ return (...args) => {
+ if (!job.active())
+ return;
+ try {
+ return callback(...args);
+ } catch (error) {
+ job.fail(error);
+ }
+ };
+ },
+ publish(blob, config) {
+ if (!job.active())
+ return;
+ if (!blob)
+ throw new Error("Auto-thumbnail encoding returned no Blob");
+ const url = URL.createObjectURL(blob);
+ urls.add(url);
+ if (!job.active()) {
+ release(url);
+ return;
+ }
+ const previousUrl = lastUrl;
+ lastUrl = url;
+ try {
+ publish({ url, ...config });
+ } catch (error) {
+ if (lastUrl === url)
+ lastUrl = urls.has(previousUrl) ? previousUrl : void 0;
+ const result = cleanupAll([() => release(url)]);
+ if (result.failed)
+ report(result.failure);
+ throw error;
+ }
+ release(previousUrl);
+ }
+ };
+ current = job;
+ previous?.dispose();
+ return job;
+ }
+ return {
+ start,
+ cancel,
+ destroy,
+ get closed() {
+ return closed;
}
- seekAndDraw(0);
};
}
function artplayerPluginAutoThumbnail(option) {
return async (art) => {
- art.on("video:loadedmetadata", () => {
- const url = option.url || art.option.url;
- const width = option.width || 160;
- const number = option.number || 100;
- const scale = option.scale || 1;
- create({ url, width, number }, (config) => {
- art.thumbnails = {
- ...config,
- column: 10,
- number,
- width,
- scale
- };
- });
- });
- return {
- name: "artplayerPluginAutoThumbnail"
+ const report = (error) => console.warn("ArtPlayer auto-thumbnail failed:", error);
+ const session = createSession((config) => {
+ art.thumbnails = config;
+ }, report);
+ const subscriptions = [];
+ const onMetadata = () => {
+ const job = session.start();
+ if (job) {
+ job.guard(() => {
+ const config = readOptions(option, () => art.option.url);
+ if (job.active())
+ extract(job, config);
+ })();
+ }
};
+ const onDestroy = () => {
+ const actions = subscriptions.splice(0);
+ const result = cleanupAll([session.destroy, ...actions]);
+ if (result.failed)
+ report(result.failure);
+ };
+ try {
+ for (const [name, callback] of [["destroy", onDestroy], ["restart", session.cancel], ["video:loadedmetadata", onMetadata]]) {
+ if (session.closed)
+ break;
+ subscriptions.push(() => art.off(name, callback));
+ art.on(name, callback);
+ if (session.closed)
+ art.off(name, callback);
+ }
+ } catch (error) {
+ onDestroy();
+ throw error;
+ }
+ return { name: "artplayerPluginAutoThumbnail" };
};
}
export {
diff --git a/packages/artplayer-plugin-auto-thumbnail/src/extraction.js b/packages/artplayer-plugin-auto-thumbnail/src/extraction.js
new file mode 100644
index 000000000..5ace488e2
--- /dev/null
+++ b/packages/artplayer-plugin-auto-thumbnail/src/extraction.js
@@ -0,0 +1,66 @@
+import { sheetSize } from './options'
+
+export default function extract(job, config) {
+ const video = document.createElement('video')
+ job.own(() => video.load())
+ job.own(() => video.removeAttribute('src'))
+ job.own(() => video.pause())
+ for (const property of ['onloadedmetadata', 'onseeked', 'onerror'])
+ job.own(() => { video[property] = null })
+ if (!job.active())
+ return
+ video.crossOrigin = 'anonymous'
+ video.onerror = job.guard(() => {
+ throw video.error || new Error('Auto-thumbnail media failed to load')
+ })
+ video.onloadedmetadata = job.guard(() => {
+ video.onloadedmetadata = null
+ const duration = video.duration
+ const { height, canvasWidth, canvasHeight } = sheetSize(config, {
+ duration,
+ videoHeight: video.videoHeight,
+ videoWidth: video.videoWidth,
+ })
+ if (!job.active())
+ return
+ const canvas = document.createElement('canvas')
+ if (!job.active())
+ return
+ canvas.width = canvasWidth
+ canvas.height = canvasHeight
+ const ctx = canvas.getContext('2d')
+ if (!ctx)
+ throw new Error('Auto-thumbnail canvas context is unavailable')
+ if (!job.active())
+ return
+ let index = 0
+ const seek = job.guard(() => {
+ if (index >= config.number) {
+ job.dispose()
+ return
+ }
+ video.onseeked = job.guard(() => {
+ video.onseeked = null
+ ctx.drawImage(video, (index % 10) * config.width, Math.floor(index / 10) * height, config.width, height)
+ if (!job.active())
+ return
+ let delivered = false
+ canvas.toBlob(job.guard((blob) => {
+ if (delivered)
+ return
+ delivered = true
+ job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale })
+ if (job.active()) {
+ index += 1
+ seek()
+ }
+ }), 'image/jpeg')
+ })
+ if (job.active())
+ video.currentTime = duration * index / config.number
+ })
+ seek()
+ })
+ if (job.active())
+ video.src = config.url
+}
diff --git a/packages/artplayer-plugin-auto-thumbnail/src/index.js b/packages/artplayer-plugin-auto-thumbnail/src/index.js
index 7afba4029..35c9f1725 100644
--- a/packages/artplayer-plugin-auto-thumbnail/src/index.js
+++ b/packages/artplayer-plugin-auto-thumbnail/src/index.js
@@ -1,64 +1,44 @@
-function create({ url, width, number }, callback) {
- const video = document.createElement('video')
- video.crossOrigin = 'anonymous'
- video.src = url
-
- video.onloadedmetadata = () => {
- const duration = video.duration
- const canvas = document.createElement('canvas')
- const ctx = canvas.getContext('2d')
- const height = Math.floor((width * video.videoHeight) / video.videoWidth)
-
- canvas.width = width * 10
- canvas.height = height * Math.ceil(number / 10)
-
- let blobUrl = null
-
- function seekAndDraw(index) {
- canvas.toBlob((blob) => {
- URL.revokeObjectURL(blobUrl)
- blobUrl = URL.createObjectURL(blob)
-
- callback({
- url: blobUrl,
- height,
- })
- }, 'image/jpeg')
-
- if (index >= number)
- return
- video.currentTime = (duration * index) / number
-
- video.onseeked = () => {
- ctx.drawImage(video, (index % 10) * width, Math.floor(index / 10) * height, width, height)
- seekAndDraw(index + 1)
- }
- }
-
- seekAndDraw(0)
- }
-}
+import extract from './extraction'
+import { readOptions } from './options'
+import createSession, { cleanupAll } from './session'
export default function artplayerPluginAutoThumbnail(option) {
return async (art) => {
- art.on('video:loadedmetadata', () => {
- const url = option.url || art.option.url
- const width = option.width || 160
- const number = option.number || 100
- const scale = option.scale || 1
- create({ url, width, number }, (config) => {
- art.thumbnails = {
- ...config,
- column: 10,
- number,
- width,
- scale,
- }
- })
- })
-
- return {
- name: 'artplayerPluginAutoThumbnail',
+ const report = error => console.warn('ArtPlayer auto-thumbnail failed:', error)
+ const session = createSession((config) => {
+ art.thumbnails = config
+ }, report)
+ const subscriptions = []
+ const onMetadata = () => {
+ const job = session.start()
+ if (job) {
+ job.guard(() => {
+ const config = readOptions(option, () => art.option.url)
+ if (job.active())
+ extract(job, config)
+ })()
+ }
}
+ const onDestroy = () => {
+ const actions = subscriptions.splice(0)
+ const result = cleanupAll([session.destroy, ...actions])
+ if (result.failed)
+ report(result.failure)
+ }
+ try {
+ for (const [name, callback] of [['destroy', onDestroy], ['restart', session.cancel], ['video:loadedmetadata', onMetadata]]) {
+ if (session.closed)
+ break
+ subscriptions.push(() => art.off(name, callback))
+ art.on(name, callback)
+ if (session.closed)
+ art.off(name, callback)
+ }
+ }
+ catch (error) {
+ onDestroy()
+ throw error
+ }
+ return { name: 'artplayerPluginAutoThumbnail' }
}
}
diff --git a/packages/artplayer-plugin-auto-thumbnail/src/options.js b/packages/artplayer-plugin-auto-thumbnail/src/options.js
new file mode 100644
index 000000000..6006ab83e
--- /dev/null
+++ b/packages/artplayer-plugin-auto-thumbnail/src/options.js
@@ -0,0 +1,22 @@
+export function readOptions(option, getFallbackUrl) {
+ const config = {
+ url: option.url || getFallbackUrl(),
+ width: option.width || 160,
+ number: option.number || 100,
+ scale: option.scale || 1,
+ }
+ if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
+ throw new TypeError('Auto-thumbnail width and number must be finite positive numbers')
+ return config
+}
+
+export function sheetSize(config, video) {
+ if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
+ throw new TypeError('Auto-thumbnail requires finite media duration and dimensions')
+ const height = Math.floor(config.width * video.videoHeight / video.videoWidth)
+ const canvasWidth = Math.trunc(config.width * 10)
+ const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10))
+ if (![height, canvasWidth, canvasHeight].every(value => Number.isSafeInteger(value) && value > 0 && value <= 0xFFFFFFFF))
+ throw new RangeError('Auto-thumbnail canvas dimensions are invalid')
+ return { height, canvasWidth, canvasHeight }
+}
diff --git a/packages/artplayer-plugin-auto-thumbnail/src/session.js b/packages/artplayer-plugin-auto-thumbnail/src/session.js
new file mode 100644
index 000000000..07368ba6b
--- /dev/null
+++ b/packages/artplayer-plugin-auto-thumbnail/src/session.js
@@ -0,0 +1,130 @@
+export function cleanupAll(actions) {
+ let failure
+ let failed = false
+ for (const action of actions) {
+ try {
+ action()
+ }
+ catch (error) {
+ if (!failed) {
+ failed = true
+ failure = error
+ }
+ }
+ }
+ return { failed, failure }
+}
+
+export default function createSession(publish, report) {
+ let closed = false
+ let current
+ let lastUrl
+ const urls = new Set()
+
+ function release(url) {
+ if (urls.delete(url))
+ URL.revokeObjectURL(url)
+ }
+
+ function cancel() {
+ const previous = current
+ current = undefined
+ previous?.dispose()
+ }
+
+ function destroy() {
+ if (closed)
+ return
+ closed = true
+ const result = cleanupAll([cancel, ...[...urls].map(url => () => release(url))])
+ lastUrl = undefined
+ if (result.failed)
+ report(result.failure)
+ }
+
+ function start() {
+ if (closed)
+ return
+ // Install the replacement before cleanup, which can reenter through host APIs.
+ const previous = current
+ const actions = []
+ let disposed = false
+ const job = {
+ active: () => !closed && !disposed && current === job,
+ own(action) {
+ if (disposed) {
+ const result = cleanupAll([action])
+ if (result.failed)
+ report(result.failure)
+ }
+ else {
+ actions.push(action)
+ }
+ },
+ dispose() {
+ if (disposed)
+ return
+ disposed = true
+ if (current === job)
+ current = undefined
+ const result = cleanupAll(actions.splice(0).reverse())
+ if (result.failed)
+ report(result.failure)
+ },
+ fail(error) {
+ if (!job.active())
+ return
+ job.dispose()
+ report(error)
+ },
+ guard(callback) {
+ return (...args) => {
+ if (!job.active())
+ return
+ try {
+ return callback(...args)
+ }
+ catch (error) {
+ job.fail(error)
+ }
+ }
+ },
+ publish(blob, config) {
+ if (!job.active())
+ return
+ if (!blob)
+ throw new Error('Auto-thumbnail encoding returned no Blob')
+ const url = URL.createObjectURL(blob)
+ urls.add(url)
+ if (!job.active()) {
+ release(url)
+ return
+ }
+ const previousUrl = lastUrl
+ lastUrl = url
+ try {
+ publish({ url, ...config })
+ }
+ catch (error) {
+ if (lastUrl === url)
+ lastUrl = urls.has(previousUrl) ? previousUrl : undefined
+ const result = cleanupAll([() => release(url)])
+ if (result.failed)
+ report(result.failure)
+ throw error
+ }
+ release(previousUrl)
+ },
+ }
+ current = job
+ previous?.dispose()
+ return job
+ }
+
+ return {
+ start,
+ cancel,
+ destroy,
+ get closed() { return closed },
+ }
+}
diff --git a/refactor/baselines/auto-thumbnail-lifecycle-checkpoint.json b/refactor/baselines/auto-thumbnail-lifecycle-checkpoint.json
new file mode 100644
index 000000000..e7f457eee
--- /dev/null
+++ b/refactor/baselines/auto-thumbnail-lifecycle-checkpoint.json
@@ -0,0 +1,1335 @@
+{
+ "schemaVersion": 1,
+ "task": "PKG-AUTO-THUMB-03",
+ "status": "doing",
+ "parentCommit": "9cd6659ab4c856d853d67c71c0353fc49c70ef5e",
+ "checks": [
+ {
+ "command": "ARTPLAYER_AUTO_THUMBNAIL_BASELINE=1 node --test test/auto-thumbnail-lifecycle.test.js",
+ "result": "expected-failures",
+ "tests": 24,
+ "passed": 2,
+ "failed": 22,
+ "log": "refactor/.cache/auto-thumbnail03-lifecycle-verified-before.log",
+ "scope": "Twenty-two lifecycle fixes fail historically; explicit URL short-circuit and metadata duration snapshot compatibility cases pass historically."
+ },
+ {
+ "command": "node --test test/auto-thumbnail-lifecycle.test.js",
+ "variant": "source",
+ "result": "pass",
+ "tests": 24,
+ "log": "refactor/.cache/auto-thumbnail03-lifecycle-verified-source.log"
+ },
+ {
+ "command": "node --test test/auto-thumbnail-lifecycle.test.js",
+ "variant": "main",
+ "result": "pass",
+ "tests": 24,
+ "log": "refactor/.cache/auto-thumbnail03-lifecycle-verified-main.log"
+ },
+ {
+ "command": "node --test test/auto-thumbnail-lifecycle.test.js",
+ "variant": "legacy",
+ "result": "pass",
+ "tests": 24,
+ "log": "refactor/.cache/auto-thumbnail03-lifecycle-verified-legacy.log"
+ },
+ {
+ "command": "yarn build artplayer-plugin-auto-thumbnail",
+ "result": "pass",
+ "log": "refactor/.cache/auto-thumbnail03-build-verified.log"
+ },
+ {
+ "command": "yarn ci:check",
+ "result": "pass",
+ "tests": {
+ "unit": 1544,
+ "engineering": 14,
+ "baseline": 232,
+ "total": 1790
+ },
+ "strictProductionTypeScriptFiles": 339,
+ "log": "refactor/.cache/auto-thumbnail03-ci-verified.log",
+ "logSha256": "3e08a72766da5e3f4ddfcec3d39cf423bdde7b7810c6846e9cbf3d1a13bb254a",
+ "scope": "Auto-thumbnail remains JavaScript for this task03 checkpoint; its source is not counted as migrated TypeScript."
+ }
+ ],
+ "limitations": [
+ "Intermediate JavaScript ownership refactor; strict TypeScript and public declarations remain task04.",
+ "Native lifecycle results do not prove accurate or usable extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "Hidden rendering, frame timing, resource budget and additional reentry/cleanup boundaries remain task03.",
+ "Stub browser hosts are not old/final core integration, actual docs/editor or physical Safari evidence.",
+ "Existing CJS generation and public type differences remain explicit task04/06 work."
+ ],
+ "checkpointValidatedAt": "2026-09-12T22:54:01.794Z",
+ "initialChecks": [
+ {
+ "command": "ARTPLAYER_AUTO_THUMBNAIL_BASELINE=1 node --test test/auto-thumbnail-lifecycle.test.js",
+ "result": "expected-failures",
+ "tests": 22,
+ "passed": 0,
+ "failed": 22,
+ "log": "refactor/.cache/auto-thumbnail03-lifecycle-before.log"
+ },
+ {
+ "command": "node --test test/auto-thumbnail-lifecycle.test.js",
+ "variant": "source",
+ "result": "pass",
+ "tests": 22,
+ "log": "refactor/.cache/auto-thumbnail03-lifecycle-source.log"
+ },
+ {
+ "command": "node --test test/auto-thumbnail-lifecycle.test.js",
+ "variant": "main",
+ "result": "pass",
+ "tests": 22,
+ "log": "refactor/.cache/auto-thumbnail03-lifecycle-main.log"
+ },
+ {
+ "command": "node --test test/auto-thumbnail-lifecycle.test.js",
+ "variant": "legacy",
+ "result": "pass",
+ "tests": 22,
+ "log": "refactor/.cache/auto-thumbnail03-lifecycle-legacy.log"
+ },
+ {
+ "command": "yarn build artplayer-plugin-auto-thumbnail",
+ "result": "pass",
+ "log": "refactor/.cache/auto-thumbnail03-build.log"
+ }
+ ],
+ "caughtBeforeCommit": [
+ {
+ "issue": "An eager fallback URL argument read art.option even when explicit option.url was truthy.",
+ "preFixLog": "refactor/.cache/auto-thumbnail03-lazy-before.log",
+ "preFixPassed": 22,
+ "preFixFailed": 1,
+ "correction": "Use a lazy fallback callback; preserve original short-circuit order and the historical passing test."
+ },
+ {
+ "issue": "Reading mutable video.duration at every seek shifted the remaining frame times.",
+ "preFixLog": "refactor/.cache/auto-thumbnail03-duration-before.log",
+ "preFixPassed": 23,
+ "preFixFailed": 1,
+ "correction": "Capture the metadata duration and dimensions, retaining the historical frame-time snapshot."
+ }
+ ],
+ "actualEntryModes": [
+ {
+ "name": "main-require",
+ "entry": "./dist/artplayer-plugin-auto-thumbnail.js",
+ "result": "pass",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual built entry and registration without DOM access; not an installed tarball test."
+ },
+ {
+ "name": "legacy-require",
+ "entry": "./dist/artplayer-plugin-auto-thumbnail.legacy.js",
+ "result": "pass",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual built entry and registration without DOM access; not an installed tarball test."
+ },
+ {
+ "name": "legacy-import",
+ "entry": "./dist/artplayer-plugin-auto-thumbnail.legacy.js",
+ "result": "pass",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual built entry and registration without DOM access; not an installed tarball test."
+ },
+ {
+ "name": "module-import",
+ "entry": "./dist/artplayer-plugin-auto-thumbnail.mjs",
+ "result": "pass",
+ "sha256": "712d3e409280fb44171dfeb9ea53ed066895039b2650c3b2e4e682e39db12dde",
+ "scope": "Actual built entry and registration without DOM access; not an installed tarball test."
+ }
+ ],
+ "nativeLifecycle": [
+ {
+ "variant": "source",
+ "result": "pass",
+ "tests": 9,
+ "retries": 0,
+ "skipped": 0,
+ "report": "refactor/.cache/auto-thumbnail03-browser-verified-source.json",
+ "reportSha256": "8c631007a82a5af0910e1b169d4bd5806c4cf1617bdfc87c77469ac6bde96e18",
+ "results": "refactor/.cache/auto-thumbnail03-browser-verified-source-results",
+ "measurements": [
+ {
+ "browser": "chromium",
+ "browserVersion": "153.0.8010.12",
+ "scenario": "destroy",
+ "sha256": "0580765fcacfb2bed731a7bea01a6e2cd06b9afb496e1ffcf0c4f099c609e8cb",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 2635
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "chromium",
+ "browserVersion": "153.0.8010.12",
+ "scenario": "restart",
+ "sha256": "0580765fcacfb2bed731a7bea01a6e2cd06b9afb496e1ffcf0c4f099c609e8cb",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 2635
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "chromium",
+ "browserVersion": "153.0.8010.12",
+ "scenario": "complete",
+ "sha256": "0580765fcacfb2bed731a7bea01a6e2cd06b9afb496e1ffcf0c4f099c609e8cb",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 1,
+ "updates": 2
+ },
+ "after": {
+ "urls": 1,
+ "updates": 2
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 2
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 2635
+ },
+ {
+ "type": "image/jpeg",
+ "bytes": 4226
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "firefox",
+ "browserVersion": "155.0",
+ "scenario": "destroy",
+ "sha256": "0580765fcacfb2bed731a7bea01a6e2cd06b9afb496e1ffcf0c4f099c609e8cb",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 4076
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "firefox",
+ "browserVersion": "155.0",
+ "scenario": "restart",
+ "sha256": "0580765fcacfb2bed731a7bea01a6e2cd06b9afb496e1ffcf0c4f099c609e8cb",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 4076
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "firefox",
+ "browserVersion": "155.0",
+ "scenario": "complete",
+ "sha256": "0580765fcacfb2bed731a7bea01a6e2cd06b9afb496e1ffcf0c4f099c609e8cb",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 1,
+ "updates": 2
+ },
+ "after": {
+ "urls": 1,
+ "updates": 2
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 2
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 4076
+ },
+ {
+ "type": "image/jpeg",
+ "bytes": 6752
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "webkit",
+ "browserVersion": "26.6",
+ "scenario": "destroy",
+ "sha256": "0580765fcacfb2bed731a7bea01a6e2cd06b9afb496e1ffcf0c4f099c609e8cb",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "webkit",
+ "browserVersion": "26.6",
+ "scenario": "restart",
+ "sha256": "0580765fcacfb2bed731a7bea01a6e2cd06b9afb496e1ffcf0c4f099c609e8cb",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "webkit",
+ "browserVersion": "26.6",
+ "scenario": "complete",
+ "sha256": "0580765fcacfb2bed731a7bea01a6e2cd06b9afb496e1ffcf0c4f099c609e8cb",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 1,
+ "updates": 2
+ },
+ "after": {
+ "urls": 1,
+ "updates": 2
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 2
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ },
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ }
+ ]
+ },
+ {
+ "variant": "main",
+ "result": "pass",
+ "tests": 9,
+ "retries": 0,
+ "skipped": 0,
+ "report": "refactor/.cache/auto-thumbnail03-browser-verified-main.json",
+ "reportSha256": "cf9c427bfbf0f11534f3385c8deb571aa1f69eecc6588b7d0365784dfd531766",
+ "results": "refactor/.cache/auto-thumbnail03-browser-verified-main-results",
+ "measurements": [
+ {
+ "browser": "chromium",
+ "browserVersion": "153.0.8010.12",
+ "scenario": "destroy",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 2635
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "chromium",
+ "browserVersion": "153.0.8010.12",
+ "scenario": "restart",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 2635
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "chromium",
+ "browserVersion": "153.0.8010.12",
+ "scenario": "complete",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 1,
+ "updates": 2
+ },
+ "after": {
+ "urls": 1,
+ "updates": 2
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 2
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 2635
+ },
+ {
+ "type": "image/jpeg",
+ "bytes": 4226
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "firefox",
+ "browserVersion": "155.0",
+ "scenario": "destroy",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 4076
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "firefox",
+ "browserVersion": "155.0",
+ "scenario": "restart",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 4076
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "firefox",
+ "browserVersion": "155.0",
+ "scenario": "complete",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 1,
+ "updates": 2
+ },
+ "after": {
+ "urls": 1,
+ "updates": 2
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 2
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 4076
+ },
+ {
+ "type": "image/jpeg",
+ "bytes": 6752
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "webkit",
+ "browserVersion": "26.6",
+ "scenario": "destroy",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "webkit",
+ "browserVersion": "26.6",
+ "scenario": "restart",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "webkit",
+ "browserVersion": "26.6",
+ "scenario": "complete",
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 1,
+ "updates": 2
+ },
+ "after": {
+ "urls": 1,
+ "updates": 2
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 2
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ },
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ }
+ ]
+ },
+ {
+ "variant": "legacy",
+ "result": "pass",
+ "tests": 9,
+ "retries": 0,
+ "skipped": 0,
+ "report": "refactor/.cache/auto-thumbnail03-browser-verified-legacy.json",
+ "reportSha256": "098d1f2044b1a55dfe63e0ff77f1e89a0028ec538780c9fc736e2168f8937306",
+ "results": "refactor/.cache/auto-thumbnail03-browser-verified-legacy-results",
+ "measurements": [
+ {
+ "browser": "chromium",
+ "browserVersion": "153.0.8010.12",
+ "scenario": "destroy",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 2635
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "chromium",
+ "browserVersion": "153.0.8010.12",
+ "scenario": "restart",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 2635
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "chromium",
+ "browserVersion": "153.0.8010.12",
+ "scenario": "complete",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 1,
+ "updates": 2
+ },
+ "after": {
+ "urls": 1,
+ "updates": 2
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 2
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 2635
+ },
+ {
+ "type": "image/jpeg",
+ "bytes": 4226
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "firefox",
+ "browserVersion": "155.0",
+ "scenario": "destroy",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 4076
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "firefox",
+ "browserVersion": "155.0",
+ "scenario": "restart",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 4076
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "firefox",
+ "browserVersion": "155.0",
+ "scenario": "complete",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 1,
+ "updates": 2
+ },
+ "after": {
+ "urls": 1,
+ "updates": 2
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 2
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 4076
+ },
+ {
+ "type": "image/jpeg",
+ "bytes": 6752
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "webkit",
+ "browserVersion": "26.6",
+ "scenario": "destroy",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "webkit",
+ "browserVersion": "26.6",
+ "scenario": "restart",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 0,
+ "updates": 0
+ },
+ "after": {
+ "urls": 0,
+ "updates": 0
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 0
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ },
+ {
+ "browser": "webkit",
+ "browserVersion": "26.6",
+ "scenario": "complete",
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "scope": "Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.",
+ "before": {
+ "urls": 1,
+ "updates": 2
+ },
+ "after": {
+ "urls": 1,
+ "updates": 2
+ },
+ "video": {
+ "src": null,
+ "readyState": 0,
+ "paused": true,
+ "handlers": [
+ true,
+ true,
+ true
+ ]
+ },
+ "final": {
+ "urls": 0,
+ "listeners": 0,
+ "updates": 2
+ },
+ "blobs": [
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ },
+ {
+ "type": "image/jpeg",
+ "bytes": 983
+ }
+ ],
+ "result": {
+ "name": "artplayerPluginAutoThumbnail"
+ }
+ }
+ ]
+ }
+ ],
+ "artifacts": [
+ {
+ "file": "packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.js",
+ "copy": "docs/compiled/artplayer-plugin-auto-thumbnail.js",
+ "bytes": 4112,
+ "sha256": "deae7e5e06be58248783bea0e840f4c51bdc32e5a98e5dabc1ef203c00091373",
+ "copiedBytesEqual": true
+ },
+ {
+ "file": "packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.legacy.js",
+ "copy": "docs/compiled/artplayer-plugin-auto-thumbnail.legacy.js",
+ "bytes": 4692,
+ "sha256": "bea5f27be7ef794cd47ea8d809e05be672849a24396b59b41242f41fb5f0be55",
+ "copiedBytesEqual": true
+ },
+ {
+ "file": "packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.mjs",
+ "copy": "docs/compiled/artplayer-plugin-auto-thumbnail.mjs",
+ "bytes": 7572,
+ "sha256": "712d3e409280fb44171dfeb9ea53ed066895039b2650c3b2e4e682e39db12dde",
+ "copiedBytesEqual": true
+ }
+ ],
+ "inputs": [
+ {
+ "file": "package.json",
+ "sha256LF": "c4897904a6d279110ad03ef9442766c70963f14ff876a2d115d0aac7d87d4460"
+ },
+ {
+ "file": "packages/artplayer-plugin-auto-thumbnail/ARCHITECTURE.md",
+ "sha256LF": "3bec89a7713616f4118ee1cba1d9154c9d0f4ead3794c53e2b24d4777ae5aece"
+ },
+ {
+ "file": "packages/artplayer-plugin-auto-thumbnail/src/index.js",
+ "sha256LF": "6ecc757ac74d961fb0bda13d58582f0845e29b62f10e351a267b67adb7b684b8"
+ },
+ {
+ "file": "packages/artplayer-plugin-auto-thumbnail/src/options.js",
+ "sha256LF": "11c88fe9682d0736d28a128b352f34832a8ed2a7a82fb6e92e44fc85b7458056"
+ },
+ {
+ "file": "packages/artplayer-plugin-auto-thumbnail/src/session.js",
+ "sha256LF": "14b69480a829d15c9a74995fc398296ad10983bd3a177aa1ddc0205273afa6ec"
+ },
+ {
+ "file": "packages/artplayer-plugin-auto-thumbnail/src/extraction.js",
+ "sha256LF": "9c7707238f955387242241c45faa6ec6e8ef9f007641b98380f77903b30bc729"
+ },
+ {
+ "file": "test/helpers/auto-thumbnail.js",
+ "sha256LF": "52014dfddbe3677f43dc4bbaef88a6688b4520c559a192343ec91da961b6cc52"
+ },
+ {
+ "file": "test/auto-thumbnail-lifecycle.test.js",
+ "sha256LF": "2d2cca2072be2679c7e7068b183d9e563d62053776afdc58825263c0aad5ff45"
+ },
+ {
+ "file": "test/browser/auto-thumbnail-lifecycle.spec.js",
+ "sha256LF": "609141b9500b26034bf06b37b0e0597fc799e36b0cd3ab28f724d8450b866d36"
+ }
+ ]
+}
diff --git a/refactor/changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md b/refactor/changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md
new file mode 100644
index 000000000..e8f82af25
--- /dev/null
+++ b/refactor/changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md
@@ -0,0 +1,36 @@
+# PKG-AUTO-THUMB-03 任务与资源所有权检查点(doing)
+
+生产入口拆为index/options/session/extraction四个JS模块。入口只负责公开异步注册、
+活options和核心事件;session拥有当前任务及生成的URL,extraction只接收受保护的job
+和本次配置。新增包内ARCHITECTURE.md,严格TS及公开声明留在04,不冒充已迁移。
+
+旧任务在restart/重复metadata/destroy前失效,metadata/seek/Blob迟到回调不能再写入。
+先draw有效帧再串行编码,移除空白初始encode和并发回调倒退,重复Blob回调只消费一次。
+完成后也pause/清空src/load释放decoder,最终URL保留到下一张可用图或destroy;失败保留
+此前可用预览,只收回本包URL。核心setter异常、原生media错误、缺context、draw/encode/
+null Blob都有失败归属并通过console.warn观察,公开注册Promise保持原时序和Result形状。
+
+注册失败回滚监听器并保持原拒绝值;清理中pause抛错也继续后续清理。新任务先安装
+再释放旧任务,覆盖清理/参数getter/媒体getter/URL创建/宿主setter中的同步重入。
+保留十列/帧起点/比例高度/活参数/defaults/忽略height,及旧JS可转换数字字符串和
+小数number的ceil抽帧行为;非有限和无效画布维度提前终止。unsigned整数界限不代表
+完整内存预算,后续仍需审查;不会靠一个粗略限制偷偷改变可用的大图调用。
+
+24项候选测试在冻结旧版2通过/22失败,源码/main/legacy各24通过;两个通过项保留旧URL
+短路及metadata时长快照,22项失败对应已修复生命周期问题。原生验证针对
+真实HTTP/video/seek/JPEG回调的destroy/restart/complete资源所有权,明确不是像素修复
+或最终核心组合。WebKit分离decoder的像素风险保持open,本任务继续doing;剩余03需
+完善隐藏渲染/精确帧时间/资源预算与进一步边界,再进入04。
+
+正常包构建再生三份dist和docs/compiled副本,没有手改产物。根test:auto-thumbnail及
+test:unit接入24项候选回归,没有新依赖或版本升级。生产源码改变后的每份浏览器
+报告独立保存;此前历史像素报告不拿来充当候选像素证据。
+
+见[验证](../baselines/auto-thumbnail-lifecycle-checkpoint.json)与包内维护地图。
+本地检查点提交可单独回退源码/测试/文档;若撤回实现,重新运行正常包构建。
+不推送、不发布;不将本检查点提交计为完成整个03任务。
+
+最终完整CI1790项(1544单元+14工程+232基线)、339生产TS通过。最终候选各24项,
+旧版2通过/22失败;两项额外兼容回归抓住并修复本次引入的fallback提前读取和duration
+快照偏差。源码/main/legacy原生生命周期各9共27项重新通过,无重试/跳过/未处理页面错误;
+本检查点不声称像素问题已修复。
diff --git a/refactor/plan.md b/refactor/plan.md
index a7643132a..b8baf94b2 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
-状态:todo 97 / doing 11 / blocked 0 / done 114 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 96 / doing 12 / blocked 0 / done 114 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -173,7 +173,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| 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 | 隐藏 video、seek 队列、canvas 编码与 Blob URL 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
+| PKG-AUTO-THUMB-03 | artplayer-plugin-auto-thumbnail
整理内部职责与资源 | PKG-AUTO-THUMB-02, CORE-09, CORE-19 | 隐藏 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 | 抽帧结果和真实异步插件返回类型兼容 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | 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 |
@@ -496,6 +496,7 @@
- PKG-AUDIO-05: [记录](changes/2026-09-12-PKG-AUDIO-05-combinations.md) [记录](baselines/audio-combinations-first.json) [记录](baselines/audio-combinations-checkpoint.json) [记录](audio-validation.md)
- PKG-AUTO-THUMB-01: [记录](baselines/auto-thumbnail-release.json) [记录](baselines/auto-thumbnail-contract.md) [记录](changes/2026-09-13-PKG-AUTO-THUMB-01-contract.md) [记录](baselines/auto-thumbnail-contract-validation.json)
- PKG-AUTO-THUMB-02: [记录](baselines/auto-thumbnail-failures.md) [记录](baselines/auto-thumbnail-failures-validation.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-02-failures.md)
+- PKG-AUTO-THUMB-03: [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md) [记录](baselines/auto-thumbnail-lifecycle-checkpoint.json)
- PKG-HLS-01: [记录](baselines/hls-control-contract.md) [记录](baselines/hls-control-release.json) [记录](changes/2026-09-11-PKG-HLS-01-contract.md)
- PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md)
- PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md)
diff --git a/refactor/progress.md b/refactor/progress.md
index 0666437a2..6bdd93d8b 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,19 @@
# 进度与证据
+## PKG-AUTO-THUMB-03 源码任务与资源所有权检查点(doing)
+
+生产源码拆为index/options/session/extraction,保持异步公开注册与原有效配置语义。
+串行有效帧编码、任务取消和URL归属取代无主回调;完成/切源/destroy均回收decoder,
+失败保留此前有效预览,修复重复回调、null Blob、原生错误及宿主setter/参数getter重入。
+24项回归旧版2通过/22失败;源码/main/legacy各24通过;三份原生生命周期各9共27通过。
+提交前抓住并修复fallback提前读取和duration快照两处新增偏差,重新验证最终实现。
+完整CI1790项、339生产TS通过;本包此时仍为JS,04才迁严格TS。正常构建再生三份产物及
+docs副本,包内维护地图同步。黑帧/准确帧时间/资源预算及更多边界仍需03继续,不能将
+生命周期通过当成像素或最终核心验收。相关风险保持open,提交仅为03检查点。
+见[变更](changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md)和
+[验证](baselines/auto-thumbnail-lifecycle-checkpoint.json)。222项:114 done、12 doing、96 todo。
+
+
## PKG-AUTO-THUMB-02 完成:失败回归和原生抽帧对照
64项旧行为测试覆盖8份冻结夹具的销毁/切源/重复metadata/逆序Blob/null Blob/
diff --git a/refactor/risks.json b/refactor/risks.json
index cb189145e..3af50a0eb 100644
--- a/refactor/risks.json
+++ b/refactor/risks.json
@@ -4064,11 +4064,14 @@
"refactor/scripts/auto-thumbnail-contract.test.mjs",
"test/auto-thumbnail.test.js",
"refactor/baselines/auto-thumbnail-failures.md",
- "refactor/baselines/auto-thumbnail-failures-validation.json"
+ "refactor/baselines/auto-thumbnail-failures-validation.json",
+ "test/auto-thumbnail-lifecycle.test.js",
+ "test/browser/auto-thumbnail-lifecycle.spec.js",
+ "refactor/baselines/auto-thumbnail-lifecycle-checkpoint.json"
],
"compatibleResolution": "Cancel obsolete work and release owned resources while retaining live options and progressive usable sheet updates.",
"closureCriteria": "Delayed callbacks, repeated metadata, switching, destroy, multiple instances and failure cleanup are reproduced then pass source/artifacts and native browser checks.",
- "workspaceState": "Task02 reproduces old failures with 64 controlled tests and native delayed JPEG callbacks. Candidate fixes, numeric validation, reentrancy and complete cleanup boundaries remain task03; status stays open."
+ "workspaceState": "Task03 ownership checkpoint: 22 candidate lifecycle regressions fail on frozen source and pass on source/main/legacy; two further explicit-URL short-circuit and duration-snapshot cases preserve historical successes. Final candidate runs have 24 passing cases each; 27 native lifecycle cases verify destroy/restart/completion and URL/decoder cleanup. New index/options/session/extraction modules fix serial encoding, stale callbacks, failure cleanup and selected reentry paths. Pixel/frame correctness, resource budgets and additional boundaries remain for task03; old/final core acceptance remains task05. Risk stays open rather than claiming complete extraction repair."
},
{
"id": "AUTO-THUMB-ENCODE-01",
@@ -4086,11 +4089,14 @@
"refactor/scripts/auto-thumbnail-contract.test.mjs",
"test/auto-thumbnail.test.js",
"refactor/baselines/auto-thumbnail-failures.md",
- "refactor/baselines/auto-thumbnail-failures-validation.json"
+ "refactor/baselines/auto-thumbnail-failures-validation.json",
+ "test/auto-thumbnail-lifecycle.test.js",
+ "test/browser/auto-thumbnail-lifecycle.spec.js",
+ "refactor/baselines/auto-thumbnail-lifecycle-checkpoint.json"
],
"compatibleResolution": "Preserve JPEG grid/time/scale while bounding jobs, sequencing valid updates and handling null Blob, invalid media and thrown callbacks.",
"closureCriteria": "Fault controls and native valid/corrupt media prove no stale sheet replacement, leaked URLs or unhandled async callbacks; capability limitations remain explicit.",
- "workspaceState": "Task02 reproduces old failures with 64 controlled tests and native delayed JPEG callbacks. Candidate fixes, numeric validation, reentrancy and complete cleanup boundaries remain task03; status stays open."
+ "workspaceState": "Task03 ownership checkpoint: 22 candidate lifecycle regressions fail on frozen source and pass on source/main/legacy; two further explicit-URL short-circuit and duration-snapshot cases preserve historical successes. Final candidate runs have 24 passing cases each; 27 native lifecycle cases verify destroy/restart/completion and URL/decoder cleanup. New index/options/session/extraction modules fix serial encoding, stale callbacks, failure cleanup and selected reentry paths. Pixel/frame correctness, resource budgets and additional boundaries remain for task03; old/final core acceptance remains task05. Risk stays open rather than claiming complete extraction repair."
},
{
"id": "AUTO-THUMB-PIXEL-01",
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 294621054..90345da58 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -1777,11 +1777,14 @@
"CORE-09",
"CORE-19"
],
- "status": "todo",
+ "status": "doing",
"risk": "H",
"deliverable": "隐藏 video、seek 队列、canvas 编码与 Blob URL 清理",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md",
+ "baselines/auto-thumbnail-lifecycle-checkpoint.json"
+ ]
},
{
"id": "PKG-AUTO-THUMB-04",
diff --git a/test/auto-thumbnail-lifecycle.test.js b/test/auto-thumbnail-lifecycle.test.js
new file mode 100644
index 000000000..7136409c8
--- /dev/null
+++ b/test/auto-thumbnail-lifecycle.test.js
@@ -0,0 +1,331 @@
+import assert from 'node:assert/strict'
+// eslint-disable-next-line test/no-import-node-test -- Repository Node lifecycle regression runner.
+import test from 'node:test'
+import { autoThumbnailCandidate, autoThumbnailEnvironment } from './helpers/auto-thumbnail.js'
+
+const implementation = await autoThumbnailCandidate()
+async function setup(option = { width: 80, number: 2 }) {
+ const env = autoThumbnailEnvironment(implementation)
+ const result = env.factory(option)(env.art)
+ assert.equal(typeof result.then, 'function')
+ assert.deepEqual({ ...await result }, { name: 'artplayerPluginAutoThumbnail' })
+ env.art.emit('video:loadedmetadata')
+ return env
+}
+function assertClosed(env, video = env.videos[0]) {
+ assert.equal(video.src, undefined)
+ for (const key of ['onloadedmetadata', 'onseeked', 'onerror']) assert.equal(video[key], null)
+ assert(env.operations.some(item => item.name === 'pause' && item.video === video))
+ assert(env.operations.some(item => item.name === 'load' && item.video === video))
+}
+
+test('Auto-thumbnail candidate serializes valid JPEG updates and retains only the final URL until destroy', async () => {
+ const env = await setup({ width: 80, number: 2, scale: 0.5, height: 999 })
+ env.metadata()
+ assert.equal(env.blobs.length, 0, 'No empty preview is encoded')
+ assert.equal(env.operations.find(item => item.name === 'seek').handlerInstalled, true)
+ env.seeked()
+ assert.equal(env.blobs.length, 1)
+ assert.equal(env.blobs[0].draws.length, 1)
+ env.seeked()
+ assert.equal(env.blobs.length, 1, 'No next frame starts while encoding is pending')
+ env.finish()
+ env.seeked()
+ env.finish(1)
+ assert.deepEqual(env.operations.filter(item => item.name === 'seek').map(item => item.value), [0, 60])
+ assert.deepEqual(env.canvases[0].draws.map(args => args.slice(1)), [[0, 0, 80, 45], [80, 0, 80, 45]])
+ assert.equal(env.updates.length, 2)
+ assert.deepEqual({ ...env.updates[1] }, { url: 'blob:auto-thumbnail-2', height: 45, column: 10, number: 2, width: 80, scale: 0.5 })
+ assert.equal(env.urls.size, 1)
+ assertClosed(env)
+ env.art.emit('destroy')
+ assert.equal(env.urls.size, 0)
+})
+
+test('Auto-thumbnail candidate destroys pending metadata without creating later canvases', async () => {
+ const env = await setup()
+ const callback = env.videos[0].onloadedmetadata
+ env.art.emit('destroy')
+ callback()
+ assert.equal(env.canvases.length, 0)
+ assertClosed(env)
+ assert([...env.listeners.values()].every(set => set.size === 0))
+})
+
+test('Auto-thumbnail candidate ignores Blob delivery after destroy and repeated destruction', async () => {
+ const env = await setup()
+ env.metadata()
+ env.seeked()
+ env.art.emit('destroy')
+ env.finish()
+ env.art.emit('destroy')
+ assert.equal(env.updates.length, 0)
+ assert.equal(env.urls.size, 0)
+ assertClosed(env)
+})
+
+test('Auto-thumbnail candidate prevents old metadata from replacing the new source', async () => {
+ const options = { width: 80, number: 1 }
+ const env = await setup(options)
+ const oldVideo = env.videos[0]
+ const oldMetadata = oldVideo.onloadedmetadata
+ env.art.option.url = 'new.mp4'
+ options.width = 160
+ env.art.emit('video:loadedmetadata')
+ oldMetadata()
+ assert.equal(env.canvases.length, 0)
+ env.metadata()
+ env.seeked()
+ env.finish()
+ assert.equal(env.updates.length, 1)
+ assert.equal(env.updates[0].width, 160)
+ assertClosed(env, oldVideo)
+ env.art.emit('destroy')
+ assert.equal(env.urls.size, 0)
+})
+
+test('Auto-thumbnail candidate restart cancels encoding before replacement metadata', async () => {
+ const env = await setup()
+ env.metadata()
+ env.seeked()
+ env.art.emit('restart')
+ env.finish()
+ assert.equal(env.updates.length, 0)
+ assert.equal(env.urls.size, 0)
+ assertClosed(env)
+ env.art.emit('video:loadedmetadata')
+ assert.equal(env.videos.length, 2)
+ env.art.emit('destroy')
+})
+
+test('Auto-thumbnail candidate delivers each Blob callback once', async () => {
+ const env = await setup()
+ env.metadata()
+ env.seeked()
+ env.finish()
+ env.finish()
+ assert.equal(env.updates.length, 1)
+ assert.equal(env.operations.filter(item => item.name === 'seek').length, 2)
+ env.art.emit('destroy')
+ assert.equal(env.urls.size, 0)
+})
+
+test('Auto-thumbnail candidate null Blob preserves the previous valid preview and closes the decoder', async () => {
+ const env = await setup()
+ env.metadata()
+ env.seeked()
+ env.finish()
+ const previous = env.art.thumbnails
+ env.seeked()
+ assert.doesNotThrow(() => env.finish(1, null))
+ assert.equal(env.urls.has(previous.url), true)
+ assert.equal(env.art.thumbnails, previous)
+ assert.equal(env.warnings.length, 1)
+ assertClosed(env)
+ env.art.emit('destroy')
+ assert.equal(env.urls.size, 0)
+})
+
+for (const phase of ['draw', 'encode', 'context', 'media', 'setter']) {
+ test(`Auto-thumbnail candidate ${phase} failure has an owner and releases decoder resources`, async () => {
+ const env = await setup()
+ const failure = new Error(`${phase} failed`)
+ if (phase === 'context')
+ env.controls.nullContext = true
+ if (phase === 'draw')
+ env.controls.drawError = failure
+ if (phase === 'encode')
+ env.controls.encodeError = failure
+ if (phase === 'setter')
+ env.controls.updateError = failure
+ if (phase === 'media') {
+ env.videos[0].error = failure
+ assert.doesNotThrow(() => env.videos[0].onerror?.())
+ }
+ else {
+ assert.doesNotThrow(() => env.metadata())
+ assert.doesNotThrow(() => env.seeked())
+ if (phase === 'setter')
+ assert.doesNotThrow(() => env.finish())
+ }
+ assert.equal(env.warnings.length, 1)
+ if (phase !== 'context')
+ assert.equal(env.warnings[0][1], failure)
+ assert.equal(env.urls.size, 0)
+ assertClosed(env)
+ })
+}
+
+test('Auto-thumbnail candidate cleanup continues after pause throws', async () => {
+ const env = await setup()
+ const failure = new Error('pause failed')
+ env.videos[0].pause = () => {
+ throw failure
+ }
+ env.art.emit('destroy')
+ assert.equal(env.videos[0].src, undefined)
+ assert.equal(env.videos[0].onloadedmetadata, null)
+ assert(env.operations.some(item => item.name === 'load'))
+ assert([...env.listeners.values()].every(set => set.size === 0))
+ assert.equal(env.warnings[0][1], failure)
+})
+
+test('Auto-thumbnail candidate returns registration failures and rolls back listeners', async () => {
+ const env = autoThumbnailEnvironment(implementation)
+ const on = env.art.on
+ const failure = new Error('listen failed')
+ env.art.on = function (name, callback) {
+ on.call(this, name, callback)
+ if (name === 'video:loadedmetadata')
+ throw failure
+ return this
+ }
+ await assert.rejects(env.factory({})(env.art), error => error === failure)
+ assert([...env.listeners.values()].every(set => set.size === 0))
+})
+
+test('Auto-thumbnail candidate rejects nonfinite input and live durations without creating a canvas', async () => {
+ for (const option of [{ width: Infinity }, { number: Infinity }, { width: -1 }, { number: -2 }]) {
+ const env = await setup(option)
+ assert.equal(env.videos.length, 0)
+ assert.equal(env.warnings.length, 1)
+ env.art.emit('destroy')
+ }
+ const env = await setup()
+ env.videos[0].duration = Infinity
+ env.metadata()
+ assert.equal(env.canvases.length, 0)
+ assertClosed(env)
+})
+
+test('Auto-thumbnail candidate does not revoke a foreign public URL on destroy', async () => {
+ const env = await setup({ number: 1 })
+ env.metadata()
+ env.seeked()
+ env.finish()
+ env.art.thumbnails = { url: 'blob:foreign' }
+ env.art.emit('destroy')
+ assert.equal(env.operations.some(item => item.name === 'revokeURL' && item.url === 'blob:foreign'), false)
+ assert.equal(env.urls.size, 0)
+})
+
+test('Auto-thumbnail candidate recovers a URL created across reentrant destroy', async () => {
+ const env = await setup()
+ env.metadata()
+ env.seeked()
+ const create = env.box.URL.createObjectURL
+ env.box.URL.createObjectURL = (blob) => {
+ env.art.emit('destroy')
+ return create(blob)
+ }
+ env.finish()
+ assert.equal(env.updates.length, 0)
+ assert.equal(env.urls.size, 0)
+ assertClosed(env)
+})
+
+test('Auto-thumbnail candidate replacement during URL creation cannot resume the old decoder', async () => {
+ const env = await setup()
+ env.metadata()
+ env.seeked()
+ const create = env.box.URL.createObjectURL
+ env.box.URL.createObjectURL = (blob) => {
+ const url = create(blob)
+ env.art.emit('video:loadedmetadata')
+ return url
+ }
+ env.finish()
+ assert.equal(env.videos.length, 2)
+ assert.equal(env.updates.length, 0)
+ assert.equal(env.urls.size, 0)
+ assertClosed(env, env.videos[0])
+ env.art.emit('destroy')
+})
+
+test('Auto-thumbnail candidate replacement from the host setter preserves the new job and its preview', async () => {
+ const env = await setup({ number: 1 })
+ env.metadata()
+ env.seeked()
+ env.controls.onUpdate = () => {
+ env.controls.onUpdate = null
+ env.art.emit('video:loadedmetadata')
+ }
+ env.finish()
+ assert.equal(env.videos.length, 2)
+ assert.equal(env.urls.size, 1)
+ assertClosed(env, env.videos[0])
+ env.metadata()
+ env.seeked()
+ env.finish(1)
+ assert.equal(env.updates.length, 2)
+ assert.equal(env.urls.size, 1)
+ assert.equal(env.urls.has(env.updates[0].url), false)
+ env.art.emit('destroy')
+ assert.equal(env.urls.size, 0)
+})
+
+test('Auto-thumbnail candidate retains coercible numeric strings and fractional frame-count behavior', async () => {
+ for (const option of [{ width: '80', number: '2', scale: '0.5' }, { width: 20.5, number: 2.5, scale: 1 }]) {
+ const env = await setup(option)
+ env.metadata()
+ for (let index = 0; index < Math.ceil(Number(option.number)); index++) {
+ env.seeked()
+ env.finish(index)
+ }
+ assert.equal(env.updates.length, Math.ceil(Number(option.number)))
+ assert.equal(env.updates[0].width, option.width)
+ assert.equal(env.updates[0].number, option.number)
+ assert.equal(env.updates[0].scale, option.scale)
+ assert.equal(env.canvases[0].width, Math.trunc(Number(option.width) * 10))
+ assertClosed(env)
+ env.art.emit('destroy')
+ assert.equal(env.urls.size, 0)
+ }
+})
+
+test('Auto-thumbnail candidate parameter getters cannot start extraction after destroying the host', async () => {
+ const env = autoThumbnailEnvironment(implementation)
+ const option = { get width() {
+ env.art.emit('destroy')
+ return 80
+ } }
+ await env.factory(option)(env.art)
+ env.art.emit('video:loadedmetadata')
+ assert.equal(env.videos.length, 0)
+ assert.equal(env.canvases.length, 0)
+ assert([...env.listeners.values()].every(set => set.size === 0))
+})
+
+test('Auto-thumbnail candidate media dimension getters cannot allocate after cancellation', async () => {
+ const env = await setup()
+ Object.defineProperty(env.videos[0], 'videoWidth', { get() {
+ env.art.emit('destroy')
+ return 1920
+ } })
+ env.metadata()
+ assert.equal(env.canvases.length, 0)
+ assertClosed(env)
+})
+
+test('Auto-thumbnail candidate explicit URL does not read the fallback options getter', async () => {
+ const env = autoThumbnailEnvironment(implementation)
+ Object.defineProperty(env.art, 'option', { get() {
+ throw new Error('Explicit URL must not read fallback options')
+ } })
+ await env.factory({ url: 'explicit.mp4', number: 1 })(env.art)
+ env.art.emit('video:loadedmetadata')
+ assert.equal(env.videos.length, 1)
+ assert.equal(env.videos[0].src, 'explicit.mp4')
+ assert.equal(env.warnings.length, 0)
+ env.art.emit('destroy')
+})
+
+test('Auto-thumbnail candidate keeps the metadata duration snapshot for frame times', async () => {
+ const env = await setup({ width: 80, number: 2 })
+ env.metadata()
+ env.videos[0].duration = 240
+ env.seeked()
+ env.finish()
+ assert.deepEqual(env.operations.filter(item => item.name === 'seek').map(item => item.value), [0, 60])
+ env.art.emit('destroy')
+})
diff --git a/test/browser/auto-thumbnail-lifecycle.spec.js b/test/browser/auto-thumbnail-lifecycle.spec.js
new file mode 100644
index 000000000..8750ce4cc
--- /dev/null
+++ b/test/browser/auto-thumbnail-lifecycle.spec.js
@@ -0,0 +1,94 @@
+import { hash } from '../../refactor/scripts/releases.mjs'
+import { autoThumbnailCandidate } from '../helpers/auto-thumbnail.js'
+import { expect, test } from './fixtures.js'
+
+const implementation = await autoThumbnailCandidate()
+for (const scenario of ['destroy', 'restart', 'complete']) {
+ test(`Auto-thumbnail candidate native ${scenario} owns decoder and encoded URLs`, async ({ page }, testInfo) => {
+ await page.goto('/test/player.html?core=published')
+ await page.setContent('