refactor(auto-thumbnail): [PKG-AUTO-THUMB-03] own hidden renderers and reject stale seeks

This commit is contained in:
Harvey Zhao committed 2026-09-13 07:23:55 +08:00
1 parent 84721f19ed
commit a128706fe7
23 files changed
+12210 -38

No files matched your search

@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e,t){const i=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"}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e,t){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const i of["onloadedmetadata","onseeked","onerror"])e.own(()=>{t[i]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,r=i.videoHeight,o=i.videoWidth,{height:a,canvasWidth:u,canvasHeight:d}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:r,videoWidth:o});if(!e.active())return;i.width=o,i.height=r,i.style.width=`${o}px`,i.style.height=`${r}px`;const s=document.createElement("canvas");if(!e.active())return;s.width=u,s.height=d;const c=s.getContext("2d");if(!c)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let l=0;const h=e.guard(()=>{if(l>=t.number)return void e.dispose();const r=n*l/t.number;let o=0;i.onseeked=e.guard(()=>{if(i.seeking)return;const n=i.currentTime;if(!e.active())return;if(!Number.isFinite(n)||Math.abs(n-r)>.05){if(++o>3)throw new Error("Auto-thumbnail seek did not reach the requested time");return void(i.currentTime=r)}if(i.onseeked=null,c.drawImage(i,l%10*t.width,Math.floor(l/10)*a,t.width,a),!e.active())return;let u=!1;s.toBlob(e.guard(i=>{u||(u=!0,e.publish(i,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(l+=1,h()))}),"image/jpeg")}),e.active()&&(i.currentTime=r)});h()}),e.active()&&(i.src=t.url))}function t(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}return function(i){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(e,i){let n,r,o=!1;const a=new Set;function u(e){a.delete(e)&&URL.revokeObjectURL(e)}function d(){const e=n;n=void 0,e?.dispose()}return{start:function(){if(o)return;const d=n,s=[];let c=!1;const l={active:()=>!o&&!c&&n===l,own(e){if(c){const n=t([e]);n.failed&&i(n.failure)}else s.push(e)},dispose(){if(c)return;c=!0,n===l&&(n=void 0);const e=t(s.splice(0).reverse());e.failed&&i(e.failure)},fail(e){l.active()&&(l.dispose(),i(e))},guard:e=>(...t)=>{if(l.active())try{return e(...t)}catch(i){l.fail(i)}},publish(n,o){if(!l.active())return;if(!n)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(n);if(a.add(d),!l.active())return void u(d);const s=r;r=d;try{e({url:d,...o})}catch(c){r===d&&(r=a.has(s)?s:void 0);const e=t([()=>u(d)]);throw e.failed&&i(e.failure),c}u(s)}};return n=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)return;o=!0;const e=t([d,...[...a].map(e=>()=>u(e))]);r=void 0,e.failed&&i(e.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const t=o.start();t&&t.guard(()=>{const r=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(i,()=>n.option.url);t.active()&&e(t,r)})()},d=()=>{const e=a.splice(0),i=t([o.destroy,...e]);i.failed&&r(i.failure)};try{for(const[e,t]of[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]]){if(o.closed)break;a.push(()=>n.off(e,t)),n.on(e,t),o.closed&&n.off(e,t)}}catch(s){throw d(),s}return{name:"artplayerPluginAutoThumbnail"}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,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}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,n=Object.prototype.propertyIsEnumerable,r=(t,i,n)=>i in t?e(t,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[i]=n;function o(e,t){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const i of["onloadedmetadata","onseeked","onerror"])e.own(()=>{t[i]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,r=i.videoHeight,o=i.videoWidth,{height:a,canvasWidth:u,canvasHeight:d}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:r,videoWidth:o});if(!e.active())return;i.width=o,i.height=r,i.style.width=`${o}px`,i.style.height=`${r}px`;const c=document.createElement("canvas");if(!e.active())return;c.width=u,c.height=d;const l=c.getContext("2d");if(!l)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let s=0;const h=e.guard(()=>{if(s>=t.number)return void e.dispose();const r=n*s/t.number;let o=0;i.onseeked=e.guard(()=>{if(i.seeking)return;const n=i.currentTime;if(!e.active())return;if(!Number.isFinite(n)||Math.abs(n-r)>.05){if(++o>3)throw new Error("Auto-thumbnail seek did not reach the requested time");return void(i.currentTime=r)}if(i.onseeked=null,l.drawImage(i,s%10*t.width,Math.floor(s/10)*a,t.width,a),!e.active())return;let u=!1;c.toBlob(e.guard(i=>{u||(u=!0,e.publish(i,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(s+=1,h()))}),"image/jpeg")}),e.active()&&(i.currentTime=r)});h()}),e.active()&&(i.src=t.url))}function a(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}function u(e,o){let u,d,c=!1;const l=new Set;function s(e){l.delete(e)&&URL.revokeObjectURL(e)}function h(){const e=u;u=void 0,null==e||e.dispose()}return{start:function(){if(c)return;const h=u,f=[];let m=!1;const v={active:()=>!c&&!m&&u===v,own(e){if(m){const t=a([e]);t.failed&&o(t.failure)}else f.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=a(f.splice(0).reverse());e.failed&&o(e.failure)},fail(e){v.active()&&(v.dispose(),o(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(i){v.fail(i)}},publish(u,c){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const h=URL.createObjectURL(u);if(l.add(h),!v.active())return void s(h);const f=d;d=h;try{e(((e,o)=>{for(var a in o||(o={}))i.call(o,a)&&r(e,a,o[a]);if(t)for(var a of t(o))n.call(o,a)&&r(e,a,o[a]);return e})({url:h},c))}catch(m){d===h&&(d=l.has(f)?f:void 0);const e=a([()=>s(h)]);throw e.failed&&o(e.failure),m}s(f)}};return u=v,null==h||h.dispose(),v},cancel:h,destroy:function(){if(c)return;c=!0;const e=a([h,...[...l].map(e=>()=>s(e))]);d=void 0,e.failed&&o(e.failure)},get closed(){return c}}}return function(e){return t=>{return i=null,n=null,r=function*(){const i=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),n=u(e=>{t.thumbnails=e},i),r=[],d=()=>{const i=n.start();i&&i.guard(()=>{const n=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(e,()=>t.option.url);i.active()&&o(i,n)})()},c=()=>{const e=r.splice(0),t=a([n.destroy,...e]);t.failed&&i(t.failure)};try{for(const[e,i]of[["destroy",c],["restart",n.cancel],["video:loadedmetadata",d]]){if(n.closed)break;r.push(()=>t.off(e,i)),t.on(e,i),n.closed&&t.off(e,i)}}catch(l){throw c(),l}return{name:"artplayerPluginAutoThumbLine truncated
@@ -25,8 +25,9 @@ function sheetSize(config, video) {
throw new RangeError("Auto-thumbnail canvas dimensions are invalid");
return { height, canvasWidth, canvasHeight };
}
function extract(job, config) {
function createVideo(job) {
const video = document.createElement("video");
job.own(() => video.remove());
job.own(() => video.load());
job.own(() => video.removeAttribute("src"));
job.own(() => video.pause());
@@ -35,21 +36,41 @@ function extract(job, config) {
video[property] = null;
});
if (!job.active())
return;
return video;
video.crossOrigin = "anonymous";
video.muted = true;
video.playsInline = true;
video.tabIndex = -1;
video.setAttribute("aria-hidden", "true");
video.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none";
document.documentElement.appendChild(video);
if (!job.active())
video.remove();
return video;
}
function extract(job, config) {
const video = createVideo(job);
if (!job.active())
return;
video.onerror = job.guard(() => {
throw video.error || new Error("Auto-thumbnail media failed to load");
});
video.onloadedmetadata = job.guard(() => {
video.onloadedmetadata = null;
const duration = video.duration;
const videoHeight = video.videoHeight;
const videoWidth = video.videoWidth;
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
duration,
videoHeight: video.videoHeight,
videoWidth: video.videoWidth
videoHeight,
videoWidth
});
if (!job.active())
return;
video.width = videoWidth;
video.height = videoHeight;
video.style.width = `${videoWidth}px`;
video.style.height = `${videoHeight}px`;
const canvas = document.createElement("canvas");
if (!job.active())
return;
@@ -66,7 +87,20 @@ function extract(job, config) {
job.dispose();
return;
}
const target = duration * index / config.number;
let retries = 0;
video.onseeked = job.guard(() => {
if (video.seeking)
return;
const time = video.currentTime;
if (!job.active())
return;
if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) {
if (++retries > 3)
throw new Error("Auto-thumbnail seek did not reach the requested time");
video.currentTime = target;
return;
}
video.onseeked = null;
ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height);
if (!job.active())
@@ -84,7 +118,7 @@ function extract(job, config) {
}), "image/jpeg");
});
if (job.active())
video.currentTime = duration * index / config.number;
video.currentTime = target;
});
seek();
});
@@ -24,9 +24,16 @@ for video processing.
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.
- `src/video.js` creates and owns the hidden media element. It is attached to the
document root because detached media loses drawable pixels on Windows WebKit.
`visibility:hidden` preserves its rendered box; `display:none` and a 1px box do
not. Metadata locks the box to intrinsic dimensions, overriding ordinary global
video sizing rules. It is silent, excluded from focus/accessibility, and never
played by this implementation. Cleanup also removes the element if insertion,
cancellation, pause or decoder reset fails.
Only the entry module receives ArtPlayer. Extraction receives a guarded job and a
configuration snapshot. These internal modules do not add public player fields,
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
@@ -39,6 +46,13 @@ Duration and sheet dimensions are captured at metadata time; later duration chan
do not move the remaining samples within the current sheet.
The old `height` option remains ignored at runtime.
A `seeked` callback is ignored while another seek is pending. Before drawing,
extraction checks that the media time is finite and within 50ms of the requested
sample; a mismatch retries that same target at most three times, then fails with
cleanup and retains any previous preview. This prevents observed stale seek
events from advancing the sheet, but does not prove frame presentation accuracy:
the current time can match even when the drawable first frame is stale.
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
@@ -57,6 +71,7 @@ Use the root Yarn toolchain:
```sh
yarn test:auto-thumbnail
yarn test:browser test/browser/auto-thumbnail-lifecycle.spec.js
yarn test:browser test/browser/auto-thumbnail-pixels.spec.js
yarn build artplayer-plugin-auto-thumbnail
```
@@ -66,11 +81,21 @@ 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.
validation does not prove correct pixels. The attached renderer now has native
pixel checks for cells 2-4 (zero-based) on a five-frame sheet, including real black
content, changing colors and spatial detail. Cells 0-1 remain diagnostic only:
initial transparent/stale draws and WebKit frame-time offsets are unresolved
(`AUTO-THUMB-PIXEL-01`). Merely waiting for `seeked`, adding two animation frames,
or seeking away and back did not consistently repair the first frame. Do not
close this risk based on the later-cell assertions or replace it with a nonblack
test; legitimate black frames have opaque pixels. Frame timing, additional
reentry/cleanup boundaries and resource budgets still need the remaining task03 work.
The frozen eight-second timeline has one unique purple first frame followed by
red, black, blue and yellow sections. Its command and fingerprint are in
`refactor/baselines/auto-thumbnail-timeline-media.json`. Reproduce into a new
directory with `ARTPLAYER_FFMPEG` and `node scripts/generate-auto-thumbnail-fixture.mjs
<new-directory>`; never silently replace the committed fixture.
Task04 converts these modules to strict TypeScript and resolves historical public
declaration/CommonJS differences. Task05 verifies old/final cores and actual
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e,t){const i=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"}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e,t){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const i of["onloadedmetadata","onseeked","onerror"])e.own(()=>{t[i]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,r=i.videoHeight,o=i.videoWidth,{height:a,canvasWidth:u,canvasHeight:d}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:r,videoWidth:o});if(!e.active())return;i.width=o,i.height=r,i.style.width=`${o}px`,i.style.height=`${r}px`;const s=document.createElement("canvas");if(!e.active())return;s.width=u,s.height=d;const c=s.getContext("2d");if(!c)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let l=0;const h=e.guard(()=>{if(l>=t.number)return void e.dispose();const r=n*l/t.number;let o=0;i.onseeked=e.guard(()=>{if(i.seeking)return;const n=i.currentTime;if(!e.active())return;if(!Number.isFinite(n)||Math.abs(n-r)>.05){if(++o>3)throw new Error("Auto-thumbnail seek did not reach the requested time");return void(i.currentTime=r)}if(i.onseeked=null,c.drawImage(i,l%10*t.width,Math.floor(l/10)*a,t.width,a),!e.active())return;let u=!1;s.toBlob(e.guard(i=>{u||(u=!0,e.publish(i,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(l+=1,h()))}),"image/jpeg")}),e.active()&&(i.currentTime=r)});h()}),e.active()&&(i.src=t.url))}function t(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}return function(i){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(e,i){let n,r,o=!1;const a=new Set;function u(e){a.delete(e)&&URL.revokeObjectURL(e)}function d(){const e=n;n=void 0,e?.dispose()}return{start:function(){if(o)return;const d=n,s=[];let c=!1;const l={active:()=>!o&&!c&&n===l,own(e){if(c){const n=t([e]);n.failed&&i(n.failure)}else s.push(e)},dispose(){if(c)return;c=!0,n===l&&(n=void 0);const e=t(s.splice(0).reverse());e.failed&&i(e.failure)},fail(e){l.active()&&(l.dispose(),i(e))},guard:e=>(...t)=>{if(l.active())try{return e(...t)}catch(i){l.fail(i)}},publish(n,o){if(!l.active())return;if(!n)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(n);if(a.add(d),!l.active())return void u(d);const s=r;r=d;try{e({url:d,...o})}catch(c){r===d&&(r=a.has(s)?s:void 0);const e=t([()=>u(d)]);throw e.failed&&i(e.failure),c}u(s)}};return n=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)return;o=!0;const e=t([d,...[...a].map(e=>()=>u(e))]);r=void 0,e.failed&&i(e.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const t=o.start();t&&t.guard(()=>{const r=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(i,()=>n.option.url);t.active()&&e(t,r)})()},d=()=>{const e=a.splice(0),i=t([o.destroy,...e]);i.failed&&r(i.failure)};try{for(const[e,t]of[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]]){if(o.closed)break;a.push(()=>n.off(e,t)),n.on(e,t),o.closed&&n.off(e,t)}}catch(s){throw d(),s}return{name:"artplayerPluginAutoThumbnail"}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,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}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,n=Object.prototype.propertyIsEnumerable,r=(t,i,n)=>i in t?e(t,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[i]=n;function o(e,t){const i=function(e){const t=document.createElement("video");e.own(()=>t.remove()),e.own(()=>t.load()),e.own(()=>t.removeAttribute("src")),e.own(()=>t.pause());for(const i of["onloadedmetadata","onseeked","onerror"])e.own(()=>{t[i]=null});return e.active()?(t.crossOrigin="anonymous",t.muted=!0,t.playsInline=!0,t.tabIndex=-1,t.setAttribute("aria-hidden","true"),t.style.cssText="position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none",document.documentElement.appendChild(t),e.active()||t.remove(),t):t}(e);e.active()&&(i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,r=i.videoHeight,o=i.videoWidth,{height:a,canvasWidth:u,canvasHeight:d}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:r,videoWidth:o});if(!e.active())return;i.width=o,i.height=r,i.style.width=`${o}px`,i.style.height=`${r}px`;const c=document.createElement("canvas");if(!e.active())return;c.width=u,c.height=d;const l=c.getContext("2d");if(!l)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let s=0;const h=e.guard(()=>{if(s>=t.number)return void e.dispose();const r=n*s/t.number;let o=0;i.onseeked=e.guard(()=>{if(i.seeking)return;const n=i.currentTime;if(!e.active())return;if(!Number.isFinite(n)||Math.abs(n-r)>.05){if(++o>3)throw new Error("Auto-thumbnail seek did not reach the requested time");return void(i.currentTime=r)}if(i.onseeked=null,l.drawImage(i,s%10*t.width,Math.floor(s/10)*a,t.width,a),!e.active())return;let u=!1;c.toBlob(e.guard(i=>{u||(u=!0,e.publish(i,{height:a,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(s+=1,h()))}),"image/jpeg")}),e.active()&&(i.currentTime=r)});h()}),e.active()&&(i.src=t.url))}function a(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}function u(e,o){let u,d,c=!1;const l=new Set;function s(e){l.delete(e)&&URL.revokeObjectURL(e)}function h(){const e=u;u=void 0,null==e||e.dispose()}return{start:function(){if(c)return;const h=u,f=[];let m=!1;const v={active:()=>!c&&!m&&u===v,own(e){if(m){const t=a([e]);t.failed&&o(t.failure)}else f.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=a(f.splice(0).reverse());e.failed&&o(e.failure)},fail(e){v.active()&&(v.dispose(),o(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(i){v.fail(i)}},publish(u,c){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const h=URL.createObjectURL(u);if(l.add(h),!v.active())return void s(h);const f=d;d=h;try{e(((e,o)=>{for(var a in o||(o={}))i.call(o,a)&&r(e,a,o[a]);if(t)for(var a of t(o))n.call(o,a)&&r(e,a,o[a]);return e})({url:h},c))}catch(m){d===h&&(d=l.has(f)?f:void 0);const e=a([()=>s(h)]);throw e.failed&&o(e.failure),m}s(f)}};return u=v,null==h||h.dispose(),v},cancel:h,destroy:function(){if(c)return;c=!0;const e=a([h,...[...l].map(e=>()=>s(e))]);d=void 0,e.failed&&o(e.failure)},get closed(){return c}}}return function(e){return t=>{return i=null,n=null,r=function*(){const i=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),n=u(e=>{t.thumbnails=e},i),r=[],d=()=>{const i=n.start();i&&i.guard(()=>{const n=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(e,()=>t.option.url);i.active()&&o(i,n)})()},c=()=>{const e=r.splice(0),t=a([n.destroy,...e]);t.failed&&i(t.failure)};try{for(const[e,i]of[["destroy",c],["restart",n.cancel],["video:loadedmetadata",d]]){if(n.closed)break;r.push(()=>t.off(e,i)),t.on(e,i),n.closed&&t.off(e,i)}}catch(l){throw c(),l}return{name:"artplayerPluginAutoThumbLine truncated
@@ -25,8 +25,9 @@ function sheetSize(config, video) {
throw new RangeError("Auto-thumbnail canvas dimensions are invalid");
return { height, canvasWidth, canvasHeight };
}
function extract(job, config) {
function createVideo(job) {
const video = document.createElement("video");
job.own(() => video.remove());
job.own(() => video.load());
job.own(() => video.removeAttribute("src"));
job.own(() => video.pause());
@@ -35,21 +36,41 @@ function extract(job, config) {
video[property] = null;
});
if (!job.active())
return;
return video;
video.crossOrigin = "anonymous";
video.muted = true;
video.playsInline = true;
video.tabIndex = -1;
video.setAttribute("aria-hidden", "true");
video.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none";
document.documentElement.appendChild(video);
if (!job.active())
video.remove();
return video;
}
function extract(job, config) {
const video = createVideo(job);
if (!job.active())
return;
video.onerror = job.guard(() => {
throw video.error || new Error("Auto-thumbnail media failed to load");
});
video.onloadedmetadata = job.guard(() => {
video.onloadedmetadata = null;
const duration = video.duration;
const videoHeight = video.videoHeight;
const videoWidth = video.videoWidth;
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
duration,
videoHeight: video.videoHeight,
videoWidth: video.videoWidth
videoHeight,
videoWidth
});
if (!job.active())
return;
video.width = videoWidth;
video.height = videoHeight;
video.style.width = `${videoWidth}px`;
video.style.height = `${videoHeight}px`;
const canvas = document.createElement("canvas");
if (!job.active())
return;
@@ -66,7 +87,20 @@ function extract(job, config) {
job.dispose();
return;
}
const target = duration * index / config.number;
let retries = 0;
video.onseeked = job.guard(() => {
if (video.seeking)
return;
const time = video.currentTime;
if (!job.active())
return;
if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) {
if (++retries > 3)
throw new Error("Auto-thumbnail seek did not reach the requested time");
video.currentTime = target;
return;
}
video.onseeked = null;
ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height);
if (!job.active())
@@ -84,7 +118,7 @@ function extract(job, config) {
}), "image/jpeg");
});
if (job.active())
video.currentTime = duration * index / config.number;
video.currentTime = target;
});
seek();
});
@@ -1,28 +1,29 @@
import { sheetSize } from './options'
import createVideo from './video'
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 })
const video = createVideo(job)
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 videoHeight = video.videoHeight
const videoWidth = video.videoWidth
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
duration,
videoHeight: video.videoHeight,
videoWidth: video.videoWidth,
videoHeight,
videoWidth,
})
if (!job.active())
return
video.width = videoWidth
video.height = videoHeight
video.style.width = `${videoWidth}px`
video.style.height = `${videoHeight}px`
const canvas = document.createElement('canvas')
if (!job.active())
return
@@ -39,7 +40,20 @@ export default function extract(job, config) {
job.dispose()
return
}
const target = duration * index / config.number
let retries = 0
video.onseeked = job.guard(() => {
if (video.seeking)
return
const time = video.currentTime
if (!job.active())
return
if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) {
if (++retries > 3)
throw new Error('Auto-thumbnail seek did not reach the requested time')
video.currentTime = target
return
}
video.onseeked = null
ctx.drawImage(video, (index % 10) * config.width, Math.floor(index / 10) * height, config.width, height)
if (!job.active())
@@ -57,7 +71,7 @@ export default function extract(job, config) {
}), 'image/jpeg')
})
if (job.active())
video.currentTime = duration * index / config.number
video.currentTime = target
})
seek()
})
@@ -0,0 +1,22 @@
export default function createVideo(job) {
const video = document.createElement('video')
job.own(() => video.remove())
job.own(() => video.load())
job.own(() => video.removeAttribute('src'))
job.own(() => video.pause())
for (const property of ['onloadedmetadata', 'onseeked', 'onerror'])
job.own(() => { video[property] = null })
if (!job.active())
return video
video.crossOrigin = 'anonymous'
video.muted = true
video.playsInline = true
video.tabIndex = -1
video.setAttribute('aria-hidden', 'true')
// WebKit needs a rendered box; display:none and a 1px box lose decoded pixels.
video.style.cssText = 'position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none'
document.documentElement.appendChild(video)
if (!job.active())
video.remove()
return video
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,75 @@
{
"kind": "generated-auto-thumbnail-timeline",
"version": "ffmpeg version 2025-11-12-git-6cdd2cbe32-full_build-www.gyan.dev Copyright (c) 2000-2025 the FFmpeg developers",
"args": [
"-hide_banner",
"-loglevel",
"error",
"-n",
"-f",
"lavfi",
"-i",
"color=c=purple:s=320x180:r=30:d=0.033333333",
"-f",
"lavfi",
"-i",
"color=c=red:s=320x180:r=30:d=1.966666667",
"-f",
"lavfi",
"-i",
"color=c=black:s=320x180:r=30:d=2",
"-f",
"lavfi",
"-i",
"color=c=blue:s=320x180:r=30:d=2",
"-f",
"lavfi",
"-i",
"color=c=yellow:s=320x180:r=30:d=2",
"-filter_complex",
"[0:v][1:v][2:v][3:v][4:v]concat=n=5:v=1:a=0[v]",
"-map",
"[v]",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-g",
"30",
"-movflags",
"+faststart",
"auto-thumbnail-timeline.mp4"
],
"width": 320,
"height": 180,
"fps": 30,
"duration": 8,
"segments": [
[
"purple",
"0.033333333"
],
[
"red",
"1.966666667"
],
[
"black",
"2"
],
[
"blue",
"2"
],
[
"yellow",
"2"
]
],
"purpose": "Unique purple first frame, then red/black/blue/yellow timeline; distinguish missing transparent pixels, legitimate black frames and stale first frames.",
"file": {
"name": "auto-thumbnail-timeline.mp4",
"bytes": 8602,
"sha256": "10370156cffada462d3ea00aae0caadd62752369cd6538cc55248b3ddd7bf994"
}
}
@@ -0,0 +1,50 @@
# PKG-AUTO-THUMB-03 隐藏解码元素检查点(doing)
新增video.js,独立拥有内部video的挂载、静音、可访问性和移除。使用固定定位、
visibility:hidden及metadata时锁定的原始尺寸;不调用play,不用display:none或1px缩放。
extraction继续拥有原来的起点采样公式和串行编码。普通全局video样式不会把内部解码
元素缩成1px;初始化取消、插入前后同步重入、插入失败及load/pause异常也回收DOM。
公开参数、结果、Promise时序和声明未改。正常构建再生dist和docs副本。
构建main产物的验证又复现一次后续格偏移约1.6秒,失败报告独立保留为
auto-thumbnail03-render-browser-verified-main.json(该文件名虽含verified,结果仍是
14通过/1失败)。因此继续修复seeked归属:仍在seeking时忽略旧事件,绘制前检查有限
currentTime及目标50ms容差;偏离只重试原目标最多3次,再失败清理。新增4项回归覆盖
错时重试、重试界限/旧预览保留、seeking旧事件和时间getter销毁重入。最终证据使用
browser-timing及unit-timing文件,不把这次失败当成通过或靠重复运行覆盖。
新增5项单元边界:此前检查点运行29项为25通过/4失败,新实现29通过。第五项load抛错
本来就能通过,此次加强其DOM所有权断言。不能将29项都称为本次新修复。
真实浏览器新增两种像素样本:时间颜色片段及空间颜色图案。时间片段8秒/30fps,首帧
独立紫色,随后红、黑、蓝、黄;生成脚本、FFmpeg版本、参数、SHA256保存在媒体清单。
重新生成字节完全一致。解码前raw alpha区分“没有画出来”和合法黑画面,实际JPEG解码
验证颜色及空间差异,记录采样时间。9项原生生命周期还增加DOM脱离断言。
**这是局部修复,03和AUTO-THUMB-PIXEL-01保持未完成。** 新的像素验收只覆盖五格中的
第2-4格(从0编号);前两格完整记录为诊断数据,不能当成通过。初次广泛断言的报告
保留在auto-thumbnail03-render-browser-source-initial.json:发现Firefox早期draw可能
透明,Windows WebKit的早期seeked可能时间回退。pattern样本的JPEG中心在色块交界处
存在色度混合,末段本身也改变颜色,因此不能错误地要求每一格中心始终纯蓝。
当前像素断言使用已知阶段的颜色优势和空间差异;这不意味着早期帧问题被修复。
独立诊断还证明:WebKit跳回0秒可能保留上一张图;两次rAF、先跳远再返回、fastSeek及
playing后立即pause均未稳定解决。唯一紫色首帧在Chromium/Firefox的0秒出现,而本机
WebKit可能在约0.04秒才读到;不能简单把所有浏览器偏移0.04秒,也不能用均匀红色片段
掩盖一帧的时间错误。相关原始实验摘要保存在本检查点验证记录,物理Safari仍未验证。
后续继续首帧和早期frame-ready调度,保持任务取消及URL归属,再完成资源预算/其余
边界;04转严格TS,05/06做核心组合、设备、真实安装与demo/editor。没有新依赖、
发布、推送或任务完成数变化。提交仅是可回退的03检查点。
见[验证记录](../baselines/auto-thumbnail-hidden-renderer.json)。
最终33项候选回归:此前检查点25通过/8失败,源码/main/legacy各33通过。最终源码原生
连续三轮45项,main/legacy各15项,共75项通过,无重试/跳过/未处理页面错误。完整CI1799项
(1553单元+14工程+232基线)及339生产TS通过;另核对4种实际bundle入口与无DOM异步
注册,尚不代表安装tarball。三份产物与docs副本逐字节相等,03仍doing,像素风险仍open。
后续实验auto-thumbnail-frame-callback-probe在loadeddata后对每次seek等待原生帧回调,
Chromium/Firefox隐藏和普通元素都能读到五格预期颜色,包括独立紫色首帧;本机WebKit
没有requestVideoFrameCallback。这尚未接入生产,下一步要处理回调归属/取消/失效/
超时及无该API浏览器的可靠路径,不能把实验结果提前标为修复。
+1 -1
View File
@@ -496,7 +496,7 @@
- PKG-AUDIO-05: [记录](changes/2026-09-12-PKG-AUDIO-05-combinations.md) [记录](baselines/audio-combinations-first.json) [记录](baselines/audio-combinations-checkpoint.json) [记录](audio-validation.md)
- PKG-AUTO-THUMB-01: [记录](baselines/auto-thumbnail-release.json) [记录](baselines/auto-thumbnail-contract.md) [记录](changes/2026-09-13-PKG-AUTO-THUMB-01-contract.md) [记录](baselines/auto-thumbnail-contract-validation.json)
- PKG-AUTO-THUMB-02: [记录](baselines/auto-thumbnail-failures.md) [记录](baselines/auto-thumbnail-failures-validation.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-02-failures.md)
- PKG-AUTO-THUMB-03: [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md) [记录](baselines/auto-thumbnail-lifecycle-checkpoint.json)
- PKG-AUTO-THUMB-03: [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md) [记录](baselines/auto-thumbnail-lifecycle-checkpoint.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-hidden-renderer.md) [记录](baselines/auto-thumbnail-hidden-renderer.json) [记录](baselines/auto-thumbnail-timeline-media.json)
- PKG-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)
+13
View File
@@ -1,5 +1,18 @@
# 进度与证据
## PKG-AUTO-THUMB-03 隐藏解码与错时seek重试检查点(doing)
新增video模块,挂载隐藏且保持实际尺寸的解码元素,完善DOM回收及插入/清理重入。
真实产物测试发现seeked可能停留在前一格,已增加绘制前时间核对及最多3次原目标重试。
33项候选单元在此前检查点25通过/8失败,新源码/main/legacy各33通过。
最终原生源码连续三轮45项、main/legacy各15项通过,共75项;其中仅第2-4格有像素
验收,前两格及首帧精度保持open。保留先前真实失败,不把重复通过当作全流程完成。
新增可再生时间颜色夹具及脚本,严格TS仍属于04,无依赖或版本改动。
完整CI1799项、339生产TS通过,4种实际bundle入口通过;见[验证](baselines/auto-thumbnail-hidden-renderer.json),
[变更](changes/2026-09-13-PKG-AUTO-THUMB-03-hidden-renderer.md)解释修复和剩余边界。
222项:114 done、12 doing、96 todo;本次是03检查点,任务完成数未增加。
## PKG-AUTO-THUMB-03 源码任务与资源所有权检查点(doing)
生产源码拆为index/options/session/extraction,保持异步公开注册与原有效配置语义。
+6 -2
View File
@@ -4110,10 +4110,14 @@
"evidence": [
"test/browser/auto-thumbnail.spec.js",
"refactor/baselines/auto-thumbnail-failures.md",
"refactor/baselines/auto-thumbnail-failures-validation.json"
"refactor/baselines/auto-thumbnail-failures-validation.json",
"test/browser/auto-thumbnail-pixels.spec.js",
"refactor/baselines/auto-thumbnail-hidden-renderer.json",
"refactor/baselines/auto-thumbnail-timeline-media.json"
],
"compatibleResolution": "Establish actual decoded-frame readiness for the existing timeline/grid and preserve silent independent extraction; do not waive colored pixel checks as unsupported media.",
"closureCriteria": "Candidate source and artifacts generate usable sampled pixels on native browser controls with lifecycle checks; supported Safari and core combinations remain explicit final gates."
"closureCriteria": "Candidate source and artifacts generate usable sampled pixels on native browser controls with lifecycle checks; supported Safari and core combinations remain explicit final gates.",
"workspaceState": "Hidden intrinsic-sized attached decoder mitigates detached WebKit black later frames. Source 45 repeated native cases plus main/legacy 15 each verify lifecycle and cells 2-4 only. Initial cells 0-1 remain diagnostic: transparent/stale draws and Windows WebKit first-frame timing remain unresolved. Bounded retries now reject wrong-time seeked events before drawing. Do not close this risk or task03 based on partial pixel acceptance."
}
]
}
+4 -1
View File
@@ -1783,7 +1783,10 @@
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
"evidence": [
"changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md",
"baselines/auto-thumbnail-lifecycle-checkpoint.json"
"baselines/auto-thumbnail-lifecycle-checkpoint.json",
"changes/2026-09-13-PKG-AUTO-THUMB-03-hidden-renderer.md",
"baselines/auto-thumbnail-hidden-renderer.json",
"baselines/auto-thumbnail-timeline-media.json"
]
},
{
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { hash } from '../refactor/scripts/releases.mjs'
const output = path.resolve(process.argv[2] || 'refactor/.cache/auto-thumbnail-fixture')
assert(!fs.existsSync(output), 'Use a new directory; never overwrite frozen media')
const ffmpeg = process.env.ARTPLAYER_FFMPEG || 'ffmpeg'
const version = execFileSync(ffmpeg, ['-version'], { encoding: 'utf8' }).split(/\r?\n/)[0]
fs.mkdirSync(output, { recursive: true })
const segments = [['purple', '0.033333333'], ['red', '1.966666667'], ['black', '2'], ['blue', '2'], ['yellow', '2']]
const args = [
'-hide_banner',
'-loglevel',
'error',
'-n',
...segments.flatMap(([color, duration]) => ['-f', 'lavfi', '-i', `color=c=${color}:s=320x180:r=30:d=${duration}`]),
'-filter_complex',
'[0:v][1:v][2:v][3:v][4:v]concat=n=5:v=1:a=0[v]',
'-map',
'[v]',
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
'-g',
'30',
'-movflags',
'+faststart',
'auto-thumbnail-timeline.mp4',
]
execFileSync(ffmpeg, args, { cwd: output, stdio: 'pipe', timeout: 120000 })
const bytes = fs.readFileSync(path.join(output, 'auto-thumbnail-timeline.mp4'))
fs.writeFileSync(path.join(output, 'manifest.json'), `${JSON.stringify({
kind: 'generated-auto-thumbnail-timeline',
version,
args,
width: 320,
height: 180,
fps: 30,
duration: 8,
segments,
purpose: 'Unique purple first frame, then red/black/blue/yellow timeline; distinguish missing transparent pixels, legitimate black frames and stale first frames.',
file: { name: 'auto-thumbnail-timeline.mp4', bytes: bytes.length, sha256: hash(bytes) },
}, null, 2)}\n`)
console.log(`Generated auto-thumbnail timeline in ${output}`)
+117
View File
@@ -13,6 +13,7 @@ async function setup(option = { width: 80, number: 2 }) {
return env
}
function assertClosed(env, video = env.videos[0]) {
assert.equal(env.attached.has(video), false)
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))
@@ -329,3 +330,119 @@ test('Auto-thumbnail candidate keeps the metadata duration snapshot for frame ti
assert.deepEqual(env.operations.filter(item => item.name === 'seek').map(item => item.value), [0, 60])
env.art.emit('destroy')
})
test('Auto-thumbnail candidate hides an attached decoder at intrinsic dimensions without playback', async () => {
const env = await setup()
const video = env.videos[0]
assert.equal(env.attached.has(video), true)
assert.equal(video['aria-hidden'], 'true')
assert.equal(video.tabIndex, -1)
assert.equal(video.muted, true)
assert.match(video.style.cssText, /visibility:hidden/)
assert.doesNotMatch(video.style.cssText, /display:none|width:1px|height:1px/)
env.metadata()
assert.equal(video.width, 1920)
assert.equal(video.height, 1080)
assert.equal(video.style.width, '1920px')
assert.equal(video.style.height, '1080px')
env.art.emit('destroy')
assertClosed(env)
})
for (const phase of ['beforeAppend', 'afterAppend']) {
test(`Auto-thumbnail candidate ${phase} reentry cannot leave an attached decoder`, async () => {
const env = autoThumbnailEnvironment(implementation)
await env.factory({})(env.art)
env.controls[phase] = () => env.art.emit('destroy')
env.art.emit('video:loadedmetadata')
assert.equal(env.attached.size, 0)
assert.equal(env.videos[0].src, undefined)
assertClosed(env)
assert.equal(env.warnings.length, 0)
})
}
test('Auto-thumbnail candidate failed insertion removes a partially attached decoder', async () => {
const env = autoThumbnailEnvironment(implementation)
const failure = new Error('Insertion observer failed')
env.controls.afterAppend = () => {
throw failure
}
await env.factory({})(env.art)
env.art.emit('video:loadedmetadata')
assert.equal(env.attached.size, 0)
assert.equal(env.videos[0].src, undefined)
assert.equal(env.warnings[0][1], failure)
assertClosed(env)
env.art.emit('destroy')
})
test('Auto-thumbnail candidate decoder removal still runs after load throws', async () => {
const env = await setup()
const failure = new Error('Decoder reset failed')
env.videos[0].load = () => {
throw failure
}
env.art.emit('destroy')
assert.equal(env.attached.size, 0)
assert.equal(env.videos[0].src, undefined)
assert.equal(env.warnings[0][1], failure)
assert([...env.listeners.values()].every(set => set.size === 0))
})
test('Auto-thumbnail candidate ignores a stale seeked event while a new seek is in progress', async () => {
const env = await setup()
env.metadata()
env.videos[0].seeking = true
env.seeked()
assert.equal(env.blobs.length, 0)
env.videos[0].seeking = false
env.seeked()
assert.equal(env.blobs.length, 1)
env.art.emit('destroy')
assertClosed(env)
})
test('Auto-thumbnail candidate retries a completed seek at the wrong time before drawing', async () => {
const env = await setup()
env.metadata()
env.videos[0].timeOffset = 1
env.seeked()
assert.equal(env.blobs.length, 0)
assert.deepEqual(env.operations.filter(item => item.name === 'seek').map(item => item.value), [0, 0])
env.videos[0].timeOffset = 0
env.seeked()
assert.equal(env.blobs.length, 1)
env.art.emit('destroy')
assertClosed(env)
})
test('Auto-thumbnail candidate bounds incorrect seek retries and preserves its last usable preview', async () => {
const env = await setup()
env.metadata()
env.seeked()
env.finish()
const previous = env.art.thumbnails
env.videos[0].timeOffset = -60
for (let index = 0; index < 5; index++) env.seeked()
assert.equal(env.blobs.length, 1)
assert.equal(env.art.thumbnails, previous)
assert.equal(env.urls.size, 1)
assert.equal(env.operations.filter(item => item.name === 'seek').length, 5)
assert.equal(env.warnings.length, 1)
assertClosed(env)
env.art.emit('destroy')
assert.equal(env.urls.size, 0)
})
test('Auto-thumbnail candidate seek-time getters cannot draw after synchronous destruction', async () => {
const env = await setup()
env.metadata()
Object.defineProperty(env.videos[0], 'timeOffset', { get() {
env.art.emit('destroy')
return 0
} })
env.seeked()
assert.equal(env.blobs.length, 0)
assertClosed(env)
})
@@ -75,7 +75,7 @@ for (const scenario of ['destroy', 'restart', 'complete']) {
}
const after = { urls: probe.urls.size, updates: probe.updates.length }
const decoder = probe.videos[0]
const video = { src: decoder.getAttribute('src'), readyState: decoder.readyState, paused: decoder.paused, handlers: ['onloadedmetadata', 'onseeked', 'onerror'].map(key => decoder[key] === null) }
const video = { connected: decoder.isConnected, src: decoder.getAttribute('src'), readyState: decoder.readyState, paused: decoder.paused, handlers: ['onloadedmetadata', 'onseeked', 'onerror'].map(key => decoder[key] === null) }
probe.art.emit('destroy')
probe.art.emit('destroy')
const final = { urls: probe.urls.size, listeners: [...probe.listeners.values()].reduce((sum, set) => sum + set.size, 0), updates: probe.updates.length }
@@ -87,7 +87,7 @@ for (const scenario of ['destroy', 'restart', 'complete']) {
expect(state.result).toEqual({ name: 'artplayerPluginAutoThumbnail' })
expect(state.before).toEqual(scenario === 'complete' ? { urls: 1, updates: 2 } : { urls: 0, updates: 0 })
expect(state.after).toEqual(state.before)
expect(state.video).toEqual({ src: null, readyState: 0, paused: true, handlers: [true, true, true] })
expect(state.video).toEqual({ connected: false, src: null, readyState: 0, paused: true, handlers: [true, true, true] })
expect(state.final).toEqual({ urls: 0, listeners: 0, updates: scenario === 'complete' ? 2 : 0 })
expect(state.blobs.every(blob => blob.type === 'image/jpeg' && blob.bytes > 0)).toBe(true)
})
+122
View File
@@ -0,0 +1,122 @@
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 media of ['auto-thumbnail-timeline', 'pattern']) {
test(`Auto-thumbnail candidate retains cells 2-4 ${media} pixels in a hidden decoder`, async ({ page, browserName }, testInfo) => {
await page.goto('/test/player.html?core=published')
await page.setContent('<!doctype html><style>video { width: 1px; height: 1px; max-width: 1px; max-height: 1px; display: none }</style>')
await page.addScriptTag({ content: implementation.code })
await page.evaluate(async (media) => {
const createElement = document.createElement.bind(document)
const drawImage = CanvasRenderingContext2D.prototype.drawImage
const probe = window.probe = { videos: [], updates: [], draws: [], playing: 0, warnings: [] }
const warn = console.warn
console.warn = (...args) => probe.warnings.push(args.map(String))
document.createElement = function (tag, ...args) {
const element = createElement(tag, ...args)
if (tag === 'video') {
probe.videos.push(element)
element.addEventListener('playing', () => probe.playing++)
}
return element
}
CanvasRenderingContext2D.prototype.drawImage = function (source, ...args) {
const result = drawImage.call(this, source, ...args)
if (source instanceof HTMLVideoElement) {
const [x, y, width, height] = args
const style = getComputedStyle(source)
const box = source.getBoundingClientRect()
probe.draws.push({
time: source.currentTime,
duration: source.duration,
connected: source.isConnected,
visibility: style.visibility,
display: style.display,
width: box.width,
height: box.height,
raw: [...this.getImageData(x + Math.floor(width / 2), y + Math.floor(height / 2), 1, 1).data],
})
}
return result
}
const listeners = new Map()
probe.art = {
option: { url: `/test/${media}.mp4` },
on(name, callback) {
if (!listeners.has(name))
listeners.set(name, new Set())
listeners.get(name).add(callback)
},
off(name, callback) { listeners.get(name)?.delete(callback) },
emit(name) { for (const callback of [...(listeners.get(name) || [])]) callback() },
get thumbnails() { return probe.updates.at(-1) },
set thumbnails(value) { probe.updates.push(value) },
}
probe.restore = () => {
document.createElement = createElement
CanvasRenderingContext2D.prototype.drawImage = drawImage
console.warn = warn
}
await window.artplayerPluginAutoThumbnail({ width: 80, number: 5 })(probe.art)
probe.art.emit('video:loadedmetadata')
}, media)
await expect.poll(() => page.evaluate(() => window.probe.updates.length)).toBe(5)
const state = await page.evaluate(async () => {
const probe = window.probe
const image = document.createElement('img')
image.src = probe.updates.at(-1).url
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(image, 0, 0)
const cells = Array.from({ length: 5 }, (_, index) => [...ctx.getImageData(index * 80 + 40, 22, 1, 1).data])
const spatial = [10, 20, 40, 60, 70].map(x => [...ctx.getImageData(160 + x, 22, 1, 1).data])
const final = probe.videos.map(video => ({ connected: video.isConnected, src: video.getAttribute('src'), paused: video.paused }))
probe.art.emit('destroy')
probe.restore()
return { cells, spatial, draws: probe.draws, final, playing: probe.playing, warnings: probe.warnings }
})
await testInfo.attach('auto-thumbnail-candidate-hidden-pixels', { contentType: 'application/json', body: JSON.stringify({
sha256: hash(implementation.code),
media,
scope: 'Native decoding and JPEG pixels for cells 2-4, hidden intrinsic-sized rendering and cleanup. Cells 0-1 are diagnostic only: initial drawing/readiness/timestamp behavior remains open under AUTO-THUMB-PIXEL-01. Stub player host, not a core integration.',
...state,
}) })
testInfo.annotations.push({ type: 'remaining-initial-frame-risk', description: `${browserName}: cells 0-1 are evidence only; this test cannot close AUTO-THUMB-PIXEL-01 or PKG-AUTO-THUMB-03.` })
expect(state.warnings).toEqual([])
expect(state.playing).toBe(0)
expect(state.final).toEqual([{ connected: false, src: null, paused: true }])
expect(state.draws).toHaveLength(5)
for (const [index, draw] of state.draws.entries()) {
expect(draw.connected).toBe(true)
expect(draw.visibility).toBe('hidden')
expect(draw.display).toBe('block')
expect(draw.width).toBeGreaterThanOrEqual(80)
expect(draw.height).toBeGreaterThanOrEqual(45)
if (index > 1) {
expect(Math.abs(draw.time - draw.duration * index / 5)).toBeLessThan(0.05)
expect(draw.raw[3]).toBe(255)
}
}
if (media === 'auto-thumbnail-timeline') {
const [black, blue, yellow] = state.cells.slice(2)
expect(Math.max(...black.slice(0, 3))).toBeLessThan(30)
expect(blue[2]).toBeGreaterThan(200)
expect(Math.max(blue[0], blue[1])).toBeLessThan(40)
expect(Math.min(yellow[0], yellow[1])).toBeGreaterThan(200)
expect(yellow[2]).toBeLessThan(40)
}
else {
for (const pixel of state.cells.slice(2, 4)) {
expect(pixel[2] - Math.max(pixel[0], pixel[1])).toBeGreaterThan(80)
}
expect(state.cells[4][0] - Math.max(state.cells[4][1], state.cells[4][2])).toBeGreaterThan(80)
expect(new Set(state.spatial.map(pixel => pixel.slice(0, 3).join(','))).size).toBeGreaterThan(2)
}
})
}
Binary file not shown.
+1
View File
@@ -76,6 +76,7 @@ assert.equal(hash(iframeCoreBytes), iframeCore.files[iframeCoreMember], 'Histori
add('/published-4.5.9/artplayer.js', iframeCoreBytes, { kind: 'npm-release', version: iframeCore.version, integrity: iframeCore.integrity, member: iframeCoreMember })
add('/test/audio-tone.m4a', fs.readFileSync(path.join(workspace, 'test/browser/media/audio-tone.m4a')), { kind: 'generated-media', file: 'test/browser/media/audio-tone.m4a' })
add('/test/thumbnail-pattern.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-pattern.mp4')), { kind: 'generated-media', file: 'test/browser/media/thumbnail-pattern.mp4' })
add('/test/auto-thumbnail-timeline.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/auto-thumbnail-timeline.mp4')), { kind: 'generated-media', file: 'test/browser/media/auto-thumbnail-timeline.mp4' })
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
+20 -2
View File
@@ -42,12 +42,29 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
const updates = []
const warnings = []
let nextUrl = 0
const controls = { nullContext: false, drawError: null, encodeError: null, updateError: null, onUpdate: null }
const controls = { nullContext: false, drawError: null, encodeError: null, updateError: null, onUpdate: null, beforeAppend: null, afterAppend: null }
const attached = new Set()
const document = {
documentElement: {
appendChild(video) {
controls.beforeAppend?.(video)
attached.add(video)
controls.afterAppend?.(video)
return video
},
},
createElement(tag) {
if (tag === 'video') {
const video = {
style: {},
setAttribute(name, value) { this[name] = value },
remove() {
attached.delete(this)
operations.push({ name: 'remove', video: this })
},
duration: 120,
timeOffset: 0,
seeking: false,
videoWidth: 1920,
videoHeight: 1080,
onloadedmetadata: null,
@@ -62,7 +79,7 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
}
let time = 0
Object.defineProperty(video, 'currentTime', {
get: () => time,
get: () => time + video.timeOffset,
set(value) {
time = value
operations.push({ name: 'seek', value, handlerInstalled: typeof video.onseeked === 'function', video })
@@ -158,6 +175,7 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
updates,
warnings,
controls,
attached,
metadata(video = videos.at(-1)) { video.onloadedmetadata?.() },
seeked(video = videos.at(-1)) { video.onseeked?.() },
finish(index = 0, blob = { type: 'image/jpeg' }) { blobs[index].callback(blob) },