refactor(thumbnail): [PKG-TOOL-THUMB-03] own extraction jobs and source cancellation

This commit is contained in:
Harvey Zhao committed 2026-09-13 04:25:22 +08:00
1 parent 426c0c5d42
commit efd9f42fb9
22 files changed
+9430 -295

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.ArtplayerToolThumbnail=e(),define(function(){return t.ArtplayerToolThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).ArtplayerToolThumbnail=e()}(this,function(){"use strict";class t{on(t,e,i){const n=this.e||(this.e={});return(n[t]||(n[t]=[])).push({fn:e,ctx:i}),this}once(t,e,i){const n=this;function o(...s){n.off(t,o),e.apply(i,s)}return o._=e,this.on(t,o,i)}emit(t,...e){const i=((this.e||(this.e={}))[t]||[]).slice();for(let n=0;n<i.length;n+=1)i[n].fn.apply(i[n].ctx,e);return this}off(t,e){const i=this.e||(this.e={}),n=i[t],o=[];if(n&&e)for(let s=0,r=n.length;s<r;s+=1)n[s].fn!==e&&n[s].fn._!==e&&o.push(n[s]);return o.length?i[t]=o:delete i[t],this}}function e(t,e,i){return Math.max(Math.min(t,Math.max(e,i)),Math.min(e,i))}const i=new WeakMap;function n(t){let e;for(const[n,o]of t.listeners.splice(0))try{t.input.removeEventListener(n,o)}catch(i){e||(e=i)}if(t.wrapper){try{t.input.parentNode&&t.input.parentNode.removeChild(t.input)}catch(i){e||(e=i)}"relative"===t.wrapper.style.position&&(t.wrapper.style.position=t.position)}if(e)throw e}function o(t,e){try{for(const[i,n]of e)t.listeners.push([i,n]),t.input.addEventListener(i,n)}catch(i){try{n(t)}catch{}throw i}}function s(t){const e=i.get(t);e&&(i.delete(t),n(e))}const r=new WeakSet;class h extends t{constructor(t={}){super(),this.processing=!1,this.option={};try{this.setup(Object.assign({},h.DEFAULTS,t)),this.video=h.creatVideo(),this.duration=0,this.inputChange=this.inputChange.bind(this),this.ondrop=this.ondrop.bind(this),function(t,e){const n=i.get(t);n.callbacks=[["change",t.inputChange],["dragover",e],["drop",t.ondrop]],o(n,n.callbacks)}(this,h.ondragover)}catch(e){try{s(this)}catch{}try{this.video?.parentNode&&this.video.parentNode.removeChild(this.video)}catch{}throw e}}static get DEFAULTS(){return{number:60,width:160,height:90,column:10,begin:0,end:Number.NaN}}static ondragover(t){t.preventDefault()}ondrop(t){if(r.has(this))return;t.preventDefault();const e=t.dataTransfer.files[0];this.loadVideo(e)}setup(t={}){return r.has(this)||(this.option=function(t,s){const r=Object.assign({},t.option,s),h=r.fileInput;t.errorHandle(h instanceof Element,"The 'fileInput' is not a Element");for(const e of["number","width","column","begin","end"])t.errorHandle("number"===typeof r[e],`The '${e}' is not a number`);r.number=e(r.number,10,1e3),r.width=e(r.width,10,1e3),r.column=e(r.column,1,1e3);const a=i.get(t);if(a&&(h===a.input||h===a.wrapper))return r.fileInput=a.input,r;const l={input:h,wrapper:null,position:"",listeners:[],callbacks:a?.callbacks||null};try{"INPUT"===h.tagName&&"file"===h.type||(l.wrapper=h,l.position=h.style.position,l.input=document.createElement("input"),l.input.type="file",Object.assign(l.input.style,{position:"absolute",width:"100%",height:"100%",left:"0",top:"0",right:"0",bottom:"0",opacity:"0"}),h.style.position="relative",h.appendChild(l.input)),l.callbacks&&o(l,l.callbacks)}catch(c){try{n(l)}catch{}throw c}return r.fileInput=l.input,i.set(t,l),t.option=r,a&&n(a),r}(this,t)),this}static creatVideo(){const t=document.createElement("video");t.style.position="absolute",t.style.top="-9999px",t.style.left="-9999px",t.muted=!0,t.controls=!0;try{document.body.appendChild(t)}catch(e){throw t.parentNode&&t.parentNode.removeChild(t),e}return t}inputChange(t){if(r.has(this))return;const e=this.option.fileInput.files[0];this.loadVideo(e),t.target.value=""}loadVideo(t){if(t&&!r.has(this)){const e=this.video.canPlayType(t.type);this.errorHandle("maybe"===e||"probably"===e,`Playback of this file format is not supported: ${t.type}`);const i=URL.createObjectURL(t);this.videoUrl=i,this.file=t,this.emit("file",this.file),this.video.src=i,this.emit("video",this.video)}}start(){if(!this.video.duration)return(t=1e3,new Promise(e=>setTimeout(e,t))).then(()=>this.start());var t;const{width:i,number:n,begin:o,end:s}=this.option,r=this.video.videoHeight/this.video.videoWidth*i;this.option.height=r,this.option.begin=e(o,0,this.video.duration),this.option.end=e(s||this.video.duration,o,this.video.duration),this.errorHandle(this.option.end>this.option.begin,"End time must be greater than the start time"),this.duration=this.option.end-this.option.begin,this.density=n/this.duration,this.errorHandle(this.file&&this.video,"Please select the video file first"),this.errorHandle(!this.processing,"There is currently a task in progress, please wait a moment..."),this.errorHandle(this.density<=1,`The preview density cannot be greater than 1, but got ${this.density}`);const h=this.creatScreenshotDate(),a=this.creatCanvas(),l=a.getContext("2d");this.emit("canvas",a);const c=h.map((t,e)=>()=>new Promise(o=>{this.video.oncanplay=()=>{l.drawImage(this.video,t.x,t.y,i,r),a.toBlob(t=>{this.thumbnailUrl&&URL.revokeObjectURL(this.thumbnailUrl),this.thumbnailUrl=URL.createObjectURL(t),this.emit("update",this.thumbnailUrl,(e+1)/n),this.videLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolThumbnail=t(),define(function(){return e.ArtplayerToolThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolThumbnail=t()}(this,function(){"use strict";class e{on(e,t,n){const r=this.e||(this.e={});return(r[e]||(r[e]=[])).push({fn:t,ctx:n}),this}once(e,t,n){const r=this;function o(...i){r.off(e,o),t.apply(n,i)}return o._=t,this.on(e,o,n)}emit(e,...t){const n=((this.e||(this.e={}))[e]||[]).slice();for(let r=0;r<n.length;r+=1)n[r].fn.apply(n[r].ctx,t);return this}off(e,t){const n=this.e||(this.e={}),r=n[e],o=[];if(r&&t)for(let i=0,s=r.length;i<s;i+=1)r[i].fn!==t&&r[i].fn._!==t&&o.push(r[i]);return o.length?n[e]=o:delete n[e],this}}const t=new WeakMap;function n(e){return t.has(e)||t.set(e,{closed:!1,epoch:0,inputEpoch:0,job:null,video:null,sourceUrl:null,thumbnailUrl:null,sourceUrls:new Set,thumbnailUrls:new Set,sourceListeners:[],loading:!1}),t.get(e)}function r(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function o(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function i(e){let t;for(const r of e)try{r()}catch(n){t||(t=n)}if(t)throw t}function s(e,t="destroyed"){const r=n(e);return!r.closed&&(r.closed=!0,r.epoch++,r.job?.cancel(t),!0)}function c(e){const t=n(e),r=new Set([...t.sourceUrls,e.videoUrl]),s=new Set([...t.thumbnailUrls,e.thumbnailUrl]),c=[];for(const n of new Set([t.video,e.video]))n&&c.push(()=>n.pause?.(),()=>n.removeAttribute?.("src"),()=>n.load?.(),()=>n.parentNode?.removeChild(n));i([...t.sourceListeners.splice(0),...c,...[...r].map(e=>()=>o(t.sourceUrls,e)),...[...s].map(e=>()=>o(t.thumbnailUrls,e))])}function a(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function l(e,t){let s=t.epoch;const c=e.video;let l,u,d,h,p=!1,f=!1,m=()=>{};const b=[],y=new Promise((e,t)=>{l=e,u=t});y.catch(()=>{});const g=()=>!p&&!t.closed&&t.epoch===s&&t.job===h;function v(){return!p&&(p=!0,t.job===h&&(t.job=null,e.processing=!1),i([()=>clearTimeout(d),m,...b.splice(0)]),!0)}function w(t,n=!0){if(!p){try{v()}catch(r){t||(t=r)}if(n)try{e.emit("error",t?.message)}catch(o){t=o}u(t)}}function U(t){if(!g())return;f=!0,e.processing=!0;let r=0;const i=()=>{if(!g())return;if(r===t.points.length)return void function(){if(g())try{v(),e.emit("done"),l()}catch(t){let r=t;try{e.emit("error",t?.message)}catch(n){r=n}u(r)}}();const s=t.points[r];let a=!1,d=!1;const h=c.oncanplay,p=()=>{if(!(!g()||a||c.seeking||void 0!==c.readyState&&c.readyState<2)){a=!0;try{if(m(),t.context.drawImage(c,s.x,s.y,t.width,t.height),!g())return;t.canvas.toBlob(s=>{if(g()&&!d){d=!0;try{if(!s)throw new Error("Unable to create thumbnail image");const c=function(e,t,r){const i=n(e),s=URL.createObjectURL(t);if(i.thumbnailUrls.add(s),!r())return o(i.thumbnailUrls,s),null;const c=new Set([...i.thumbnailUrls,e.thumbnailUrl]);i.thumbnailUrl=s,e.thumbnailUrl=s;for(const n of c)n&&n!==s&&o(i.thumbnailUrls,n);return r()?s:null}(e,s,g);if(!c)return;e.emit("update",c,(r+1)/t.number),r++,Promise.resolve().then(i)}catch(c){w(c)}}})}catch(l){w(l)}}};m=()=>{c.oncanplay===p&&(c.oncanplay=h),c.removeEventListener("seeked",p)};try{c.oncanplay=p,c.addEventListener("seeked",p),c.currentTime=s.time,c.readyState>=2&&!c.seeking&&Promise.resolve().then(p)}catch(f){w(f)}};Promise.resolve().then(i)}function E(n=!1){if(g()&&!f)if(clearTimeout(d),!t.loading&&c.duration){f=!0;try{const t=function(e,t){const{video:n}=t,{width:r,number:o,begin:i,end:s}=e.option,c=n.videoHeight/n.videoWidth*r,l=(n,r)=>{try{e.errorHandle(n,r)}catch(o){throw t.reported=o,t.wasReported=!0,o}};e.option.height=c,e.option.begin=a(i,0,n.duration),e.option.end=a(s||n.duration,i,n.duration),l(e.option.end>e.option.begin,"End time must be greater than the start time"),l(Number.isFinite(n.duration),"Video duration must be finite"),e.duration=e.option.end-e.option.begin,e.density=o/e.duration,l(e.file&&n,"Please select the video file first"),l(!e.processing,"There is currently a task in progress, please wait a moment..."),l(e.density<=1,`The preview density cannot be greater than 1, but got ${e.density}`);const u=e.creatScreenshotDate(),d=e.creatCanvas(),h=d.getContext("2d");return e.emit("canvas",d),{width:r,height:c,number:o,points:u,canvas:d,context:h}}(e,h);U(t)}catch(r){if(w(r,!n&&!(h.wasReported&&r===h.reported)),n)throw r}}else d=setTimeout(()=>E(),1e3),g()||clearTimeout(d)}h={video:c,promise:y,ready:E,fail:w,cancel(e){w(r(e),!1)},adopt:t=>!p&&!f&&c===e.video&&(s=t,!0)},t.job=h;try{for(const[e,t]of[["error",()=>{g()&&w(new Error(`Unable to load video: media error ${c.error?.code||0}`))}],["loadedmetadata",()=>E()],["durationchange",()=>E()]])if(b.push(()=>c.removeEventListener(e,t)),c.addEventListener(e,t),!g())return i([()=>c.removeEventListener(e,t),...b.splice(0)]),y;c.error?w(new Error(`Unable to load video: media error ${c.error.code}`)):E(!0)}catch(j){throw w(j,!1),j}return Line truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.ArtplayerToolThumbnail=e(),define(function(){return t.ArtplayerToolThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).ArtplayerToolThumbnail=e()}(this,function(){"use strict";class t{on(t,e,i){const n=this.e||(this.e={});return(n[t]||(n[t]=[])).push({fn:e,ctx:i}),this}once(t,e,i){const n=this;function o(...s){n.off(t,o),e.apply(i,s)}return o._=e,this.on(t,o,i)}emit(t,...e){const i=((this.e||(this.e={}))[t]||[]).slice();for(let n=0;n<i.length;n+=1)i[n].fn.apply(i[n].ctx,e);return this}off(t,e){const i=this.e||(this.e={}),n=i[t],o=[];if(n&&e)for(let s=0,r=n.length;s<r;s+=1)n[s].fn!==e&&n[s].fn._!==e&&o.push(n[s]);return o.length?i[t]=o:delete i[t],this}}function e(t,e,i){return Math.max(Math.min(t,Math.max(e,i)),Math.min(e,i))}const i=new WeakMap;function n(t){let e;for(const[n,o]of t.listeners.splice(0))try{t.input.removeEventListener(n,o)}catch(i){e||(e=i)}if(t.wrapper){try{t.input.parentNode&&t.input.parentNode.removeChild(t.input)}catch(i){e||(e=i)}"relative"===t.wrapper.style.position&&(t.wrapper.style.position=t.position)}if(e)throw e}function o(t,e){try{for(const[i,n]of e)t.listeners.push([i,n]),t.input.addEventListener(i,n)}catch(i){try{n(t)}catch(o){}throw i}}function s(t){const e=i.get(t);e&&(i.delete(t),n(e))}const r=new WeakSet;class h extends t{constructor(t={}){var e;super(),this.processing=!1,this.option={};try{this.setup(Object.assign({},h.DEFAULTS,t)),this.video=h.creatVideo(),this.duration=0,this.inputChange=this.inputChange.bind(this),this.ondrop=this.ondrop.bind(this),function(t,e){const n=i.get(t);n.callbacks=[["change",t.inputChange],["dragover",e],["drop",t.ondrop]],o(n,n.callbacks)}(this,h.ondragover)}catch(n){try{s(this)}catch(r){}try{(null==(e=this.video)?void 0:e.parentNode)&&this.video.parentNode.removeChild(this.video)}catch(r){}throw n}}static get DEFAULTS(){return{number:60,width:160,height:90,column:10,begin:0,end:Number.NaN}}static ondragover(t){t.preventDefault()}ondrop(t){if(r.has(this))return;t.preventDefault();const e=t.dataTransfer.files[0];this.loadVideo(e)}setup(t={}){return r.has(this)||(this.option=function(t,s){const r=Object.assign({},t.option,s),h=r.fileInput;t.errorHandle(h instanceof Element,"The 'fileInput' is not a Element");for(const e of["number","width","column","begin","end"])t.errorHandle("number"===typeof r[e],`The '${e}' is not a number`);r.number=e(r.number,10,1e3),r.width=e(r.width,10,1e3),r.column=e(r.column,1,1e3);const a=i.get(t);if(a&&(h===a.input||h===a.wrapper))return r.fileInput=a.input,r;const l={input:h,wrapper:null,position:"",listeners:[],callbacks:(null==a?void 0:a.callbacks)||null};try{"INPUT"===h.tagName&&"file"===h.type||(l.wrapper=h,l.position=h.style.position,l.input=document.createElement("input"),l.input.type="file",Object.assign(l.input.style,{position:"absolute",width:"100%",height:"100%",left:"0",top:"0",right:"0",bottom:"0",opacity:"0"}),h.style.position="relative",h.appendChild(l.input)),l.callbacks&&o(l,l.callbacks)}catch(c){try{n(l)}catch(d){}throw c}return r.fileInput=l.input,i.set(t,l),t.option=r,a&&n(a),r}(this,t)),this}static creatVideo(){const t=document.createElement("video");t.style.position="absolute",t.style.top="-9999px",t.style.left="-9999px",t.muted=!0,t.controls=!0;try{document.body.appendChild(t)}catch(e){throw t.parentNode&&t.parentNode.removeChild(t),e}return t}inputChange(t){if(r.has(this))return;const e=this.option.fileInput.files[0];this.loadVideo(e),t.target.value=""}loadVideo(t){if(t&&!r.has(this)){const e=this.video.canPlayType(t.type);this.errorHandle("maybe"===e||"probably"===e,`Playback of this file format is not supported: ${t.type}`);const i=URL.createObjectURL(t);this.videoUrl=i,this.file=t,this.emit("file",this.file),this.video.src=i,this.emit("video",this.video)}}start(){if(!this.video.duration)return(t=1e3,new Promise(e=>setTimeout(e,t))).then(()=>this.start());var t;const{width:i,number:n,begin:o,end:s}=this.option,r=this.video.videoHeight/this.video.videoWidth*i;this.option.height=r,this.option.begin=e(o,0,this.video.duration),this.option.end=e(s||this.video.duration,o,this.video.duration),this.errorHandle(this.option.end>this.option.begin,"End time must be greater than the start time"),this.duration=this.option.end-this.option.begin,this.density=n/this.duration,this.errorHandle(this.file&&this.video,"Please select the video file first"),this.errorHandle(!this.processing,"There is currently a task in progress, please wait a moment..."),this.errorHandle(this.density<=1,`The preview density cannot be greater than 1, but got ${this.density}`);const h=this.creatScreenshotDate(),a=this.creatCanvas(),l=a.getContext("2d");this.emit("canvas",a);const c=h.map((t,e)=>()=>new Promise(o=>{this.video.oncanplay=()=>{l.drawImage(this.video,t.x,t.y,i,r),a.toBlob(t=>{this.thumbnailUrl&&URL.revokeObjectURL(this.thumbnailUrl),this.thumbnailUrl=URL.createObjectURL(t),tLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolThumbnail=t(),define(function(){return e.ArtplayerToolThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolThumbnail=t()}(this,function(){"use strict";class e{on(e,t,n){const r=this.e||(this.e={});return(r[e]||(r[e]=[])).push({fn:t,ctx:n}),this}once(e,t,n){const r=this;function o(...i){r.off(e,o),t.apply(n,i)}return o._=t,this.on(e,o,n)}emit(e,...t){const n=((this.e||(this.e={}))[e]||[]).slice();for(let r=0;r<n.length;r+=1)n[r].fn.apply(n[r].ctx,t);return this}off(e,t){const n=this.e||(this.e={}),r=n[e],o=[];if(r&&t)for(let i=0,s=r.length;i<s;i+=1)r[i].fn!==t&&r[i].fn._!==t&&o.push(r[i]);return o.length?n[e]=o:delete n[e],this}}const t=new WeakMap;function n(e){return t.has(e)||t.set(e,{closed:!1,epoch:0,inputEpoch:0,job:null,video:null,sourceUrl:null,thumbnailUrl:null,sourceUrls:new Set,thumbnailUrls:new Set,sourceListeners:[],loading:!1}),t.get(e)}function r(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function o(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function i(e){let t;for(const r of e)try{r()}catch(n){t||(t=n)}if(t)throw t}function s(e,t="destroyed"){var r;const o=n(e);return!o.closed&&(o.closed=!0,o.epoch++,null==(r=o.job)||r.cancel(t),!0)}function c(e){const t=n(e),r=new Set([...t.sourceUrls,e.videoUrl]),s=new Set([...t.thumbnailUrls,e.thumbnailUrl]),c=[];for(const n of new Set([t.video,e.video]))n&&c.push(()=>{var e;return null==(e=n.pause)?void 0:e.call(n)},()=>{var e;return null==(e=n.removeAttribute)?void 0:e.call(n,"src")},()=>{var e;return null==(e=n.load)?void 0:e.call(n)},()=>{var e;return null==(e=n.parentNode)?void 0:e.removeChild(n)});i([...t.sourceListeners.splice(0),...c,...[...r].map(e=>()=>o(t.sourceUrls,e)),...[...s].map(e=>()=>o(t.thumbnailUrls,e))])}function l(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function a(e,t){let s=t.epoch;const c=e.video;let a,u,d,h,p=!1,f=!1,m=()=>{};const b=[],v=new Promise((e,t)=>{a=e,u=t});v.catch(()=>{});const y=()=>!p&&!t.closed&&t.epoch===s&&t.job===h;function g(){return!p&&(p=!0,t.job===h&&(t.job=null,e.processing=!1),i([()=>clearTimeout(d),m,...b.splice(0)]),!0)}function w(t,n=!0){if(!p){try{g()}catch(r){t||(t=r)}if(n)try{e.emit("error",null==t?void 0:t.message)}catch(o){t=o}u(t)}}function U(t){if(!y())return;f=!0,e.processing=!0;let r=0;const i=()=>{if(!y())return;if(r===t.points.length)return void function(){if(y())try{g(),e.emit("done"),a()}catch(t){let r=t;try{e.emit("error",null==t?void 0:t.message)}catch(n){r=n}u(r)}}();const s=t.points[r];let l=!1,d=!1;const h=c.oncanplay,p=()=>{if(!(!y()||l||c.seeking||void 0!==c.readyState&&c.readyState<2)){l=!0;try{if(m(),t.context.drawImage(c,s.x,s.y,t.width,t.height),!y())return;t.canvas.toBlob(s=>{if(y()&&!d){d=!0;try{if(!s)throw new Error("Unable to create thumbnail image");const c=function(e,t,r){const i=n(e),s=URL.createObjectURL(t);if(i.thumbnailUrls.add(s),!r())return o(i.thumbnailUrls,s),null;const c=new Set([...i.thumbnailUrls,e.thumbnailUrl]);i.thumbnailUrl=s,e.thumbnailUrl=s;for(const n of c)n&&n!==s&&o(i.thumbnailUrls,n);return r()?s:null}(e,s,y);if(!c)return;e.emit("update",c,(r+1)/t.number),r++,Promise.resolve().then(i)}catch(c){w(c)}}})}catch(a){w(a)}}};m=()=>{c.oncanplay===p&&(c.oncanplay=h),c.removeEventListener("seeked",p)};try{c.oncanplay=p,c.addEventListener("seeked",p),c.currentTime=s.time,c.readyState>=2&&!c.seeking&&Promise.resolve().then(p)}catch(f){w(f)}};Promise.resolve().then(i)}function E(n=!1){if(y()&&!f)if(clearTimeout(d),!t.loading&&c.duration){f=!0;try{const t=function(e,t){const{video:n}=t,{width:r,number:o,begin:i,end:s}=e.option,c=n.videoHeight/n.videoWidth*r,a=(n,r)=>{try{e.errorHandle(n,r)}catch(o){throw t.reported=o,t.wasReported=!0,o}};e.option.height=c,e.option.begin=l(i,0,n.duration),e.option.end=l(s||n.duration,i,n.duration),a(e.option.end>e.option.begin,"End time must be greater than the start time"),a(Number.isFinite(n.duration),"Video duration must be finite"),e.duration=e.option.end-e.option.begin,e.density=o/e.duration,a(e.file&&n,"Please select the video file first"),a(!e.processing,"There is currently a task in progress, please wait a moment..."),a(e.density<=1,`The preview density cannot be greater than 1, but got ${e.density}`);const u=e.creatScreenshotDate(),d=e.creatCanvas(),h=d.getContext("2d");return e.emit("canvas",d),{width:r,height:c,number:o,points:u,canvas:d,context:h}}(e,h);U(t)}catch(r){if(w(r,!n&&!(h.wasReported&&r===h.reported)),n)throw r}}else d=setTimeout(()=>E(),1e3),y()||clearTimeout(d)}h={video:c,promise:v,ready:E,fail:w,cancel(e){w(r(e),!1)},adopt:t=>!p&&!f&&c===e.video&&(s=t,!0)},t.job=h;try{for(const[e,t]of[["error",()=>{var e;y()&&w(new Error(`Unable to load video: media error ${(null==(e=c.error)?void 0:e.code)||0}`))}],["loadedmetadata",()=>E()],["durationchange",()=>E()]])if(b.push(()=>c.removeELine truncated
+436 -83
View File
@@ -44,11 +44,188 @@ class Emitter {
return this;
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
const states = /* @__PURE__ */ new WeakMap();
function stateFor(tool) {
if (!states.has(tool)) {
states.set(tool, {
closed: false,
epoch: 0,
inputEpoch: 0,
job: null,
video: null,
sourceUrl: null,
thumbnailUrl: null,
sourceUrls: /* @__PURE__ */ new Set(),
thumbnailUrls: /* @__PURE__ */ new Set(),
sourceListeners: [],
loading: false
});
}
return states.get(tool);
}
function runPromisesInSeries(ps) {
return ps.reduce((p, next) => p.then(next), Promise.resolve());
function cancellation(reason) {
const error = new Error(`Thumbnail task cancelled: ${reason}`);
error.name = "AbortError";
return error;
}
function revoke(urls, url) {
if (!url)
return;
urls.delete(url);
URL.revokeObjectURL(url);
}
function cleanupAll(callbacks) {
let failure;
for (const callback of callbacks) {
try {
callback();
} catch (error) {
failure || (failure = error);
}
}
if (failure)
throw failure;
}
function closeState(tool, reason = "destroyed") {
const state = stateFor(tool);
if (state.closed)
return false;
state.closed = true;
state.epoch++;
state.job?.cancel(reason);
return true;
}
function releaseMedia(tool) {
const state = stateFor(tool);
const sources = /* @__PURE__ */ new Set([...state.sourceUrls, tool.videoUrl]);
const thumbnails = /* @__PURE__ */ new Set([...state.thumbnailUrls, tool.thumbnailUrl]);
const media = [];
for (const video of /* @__PURE__ */ new Set([state.video, tool.video])) {
if (video) {
media.push(
() => video.pause?.(),
() => video.removeAttribute?.("src"),
() => video.load?.(),
() => video.parentNode?.removeChild(video)
);
}
}
cleanupAll([
...state.sourceListeners.splice(0),
...media,
...[...sources].map((url) => () => revoke(state.sourceUrls, url)),
...[...thumbnails].map((url) => () => revoke(state.thumbnailUrls, url))
]);
}
function loadSource(tool, file) {
const state = stateFor(tool);
if (!file || state.closed)
return;
const initialEpoch = state.epoch;
const video = tool.video;
const support = video.canPlayType(file.type);
tool.errorHandle(support === "maybe" || support === "probably", `Playback of this file format is not supported: ${file.type}`);
if (state.closed || state.epoch !== initialEpoch)
return;
const url = URL.createObjectURL(file);
state.sourceUrls.add(url);
if (state.closed || state.epoch !== initialEpoch) {
revoke(state.sourceUrls, url);
return;
}
const epoch = ++state.epoch;
const initialWait = !state.sourceUrl && !tool.file && state.job?.adopt(epoch);
if (!initialWait)
state.job?.cancel("source changed");
state.loading = true;
const current = () => !state.closed && state.epoch === epoch;
const previousListeners = state.sourceListeners;
const listeners = [];
state.sourceListeners = listeners;
let pendingCleanup = () => {
};
let reported = false;
const error = () => {
if (!current() || reported || video.currentSrc && video.currentSrc !== url)
return;
reported = true;
state.loading = false;
const failure = new Error(`Unable to load video: media error ${video.error?.code || 0}`);
if (state.job)
state.job.fail(failure);
else
tool.emit("error", failure.message);
};
const ready = () => {
if (current() && (!video.currentSrc || video.currentSrc === url)) {
state.loading = false;
state.job?.ready();
}
};
try {
cleanupAll(previousListeners.splice(0));
if (!current()) {
revoke(state.sourceUrls, url);
return;
}
for (const [name, callback] of [["error", error], ["loadedmetadata", ready]]) {
pendingCleanup = () => video.removeEventListener(name, callback);
listeners.push(pendingCleanup);
video.addEventListener(name, callback);
if (!current()) {
cleanupAll([pendingCleanup, ...listeners.splice(0)]);
revoke(state.sourceUrls, url);
return;
}
}
tool.videoUrl = url;
tool.file = file;
tool.emit("file", file);
if (!current()) {
revoke(state.sourceUrls, url);
return;
}
video.src = url;
if (!current()) {
revoke(state.sourceUrls, url);
return;
}
state.sourceUrl = url;
for (const previous of [...state.sourceUrls]) {
if (previous !== url)
revoke(state.sourceUrls, previous);
}
if (current())
tool.emit("video", video);
} catch (error2) {
if (current()) {
state.loading = false;
state.job?.cancel("source setup failed");
}
try {
cleanupAll([pendingCleanup, ...listeners.splice(0)]);
} catch {
}
revoke(state.sourceUrls, url);
throw error2;
}
}
function replaceThumbnail(tool, blob, live) {
const state = stateFor(tool);
const url = URL.createObjectURL(blob);
state.thumbnailUrls.add(url);
if (!live()) {
revoke(state.thumbnailUrls, url);
return null;
}
const previous = /* @__PURE__ */ new Set([...state.thumbnailUrls, tool.thumbnailUrl]);
state.thumbnailUrl = url;
tool.thumbnailUrl = url;
for (const old of previous) {
if (old && old !== url)
revoke(state.thumbnailUrls, old);
}
return live() ? url : null;
}
function getFileName(name) {
const nameArray = name.split(".");
@@ -58,6 +235,236 @@ function getFileName(name) {
function clamp(num, a, b) {
return Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b));
}
function prepare(tool, job) {
const { video } = job;
const { width, number, begin, end } = tool.option;
const height = video.videoHeight / video.videoWidth * width;
const guard = (condition, message) => {
try {
tool.errorHandle(condition, message);
} catch (error) {
job.reported = error;
job.wasReported = true;
throw error;
}
};
tool.option.height = height;
tool.option.begin = clamp(begin, 0, video.duration);
tool.option.end = clamp(end || video.duration, begin, video.duration);
guard(tool.option.end > tool.option.begin, "End time must be greater than the start time");
guard(Number.isFinite(video.duration), "Video duration must be finite");
tool.duration = tool.option.end - tool.option.begin;
tool.density = number / tool.duration;
guard(tool.file && video, "Please select the video file first");
guard(!tool.processing, "There is currently a task in progress, please wait a moment...");
guard(tool.density <= 1, `The preview density cannot be greater than 1, but got ${tool.density}`);
const points = tool.creatScreenshotDate();
const canvas = tool.creatCanvas();
const context = canvas.getContext("2d");
tool.emit("canvas", canvas);
return { width, height, number, points, canvas, context };
}
function createJob(tool, state) {
let epoch = state.epoch;
const video = tool.video;
let resolve;
let reject;
let timer;
let settled = false;
let running = false;
let job;
let frameCleanup = () => {
};
const listeners = [];
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
promise.catch(() => {
});
const live = () => !settled && !state.closed && state.epoch === epoch && state.job === job;
function finish() {
if (settled)
return false;
settled = true;
if (state.job === job) {
state.job = null;
tool.processing = false;
}
cleanupAll([() => clearTimeout(timer), frameCleanup, ...listeners.splice(0)]);
return true;
}
function fail(error, report = true) {
if (settled)
return;
try {
finish();
} catch (cleanupError) {
error || (error = cleanupError);
}
if (report) {
try {
tool.emit("error", error?.message);
} catch (listenerError) {
error = listenerError;
}
}
reject(error);
}
function complete() {
if (!live())
return;
try {
finish();
tool.emit("done");
resolve();
} catch (error) {
let failure = error;
try {
tool.emit("error", error?.message);
} catch (listenerError) {
failure = listenerError;
}
reject(failure);
}
}
function extract(plan) {
if (!live())
return;
running = true;
tool.processing = true;
let index = 0;
const next = () => {
if (!live())
return;
if (index === plan.points.length) {
complete();
return;
}
const point = plan.points[index];
let drawing = false;
let encoded = false;
const previous = video.oncanplay;
const draw = () => {
if (!live() || drawing || video.seeking || video.readyState !== void 0 && video.readyState < 2)
return;
drawing = true;
try {
frameCleanup();
plan.context.drawImage(video, point.x, point.y, plan.width, plan.height);
if (!live())
return;
plan.canvas.toBlob((blob) => {
if (!live() || encoded)
return;
encoded = true;
try {
if (!blob)
throw new Error("Unable to create thumbnail image");
const url = replaceThumbnail(tool, blob, live);
if (!url)
return;
tool.emit("update", url, (index + 1) / plan.number);
index++;
Promise.resolve().then(next);
} catch (error) {
fail(error);
}
});
} catch (error) {
fail(error);
}
};
frameCleanup = () => {
if (video.oncanplay === draw)
video.oncanplay = previous;
video.removeEventListener("seeked", draw);
};
try {
video.oncanplay = draw;
video.addEventListener("seeked", draw);
video.currentTime = point.time;
if (video.readyState >= 2 && !video.seeking)
Promise.resolve().then(draw);
} catch (error) {
fail(error);
}
};
Promise.resolve().then(next);
}
function ready(synchronous = false) {
if (!live() || running)
return;
clearTimeout(timer);
if (!state.loading && video.duration) {
running = true;
try {
const plan = prepare(tool, job);
extract(plan);
} catch (error) {
fail(error, !synchronous && !(job.wasReported && error === job.reported));
if (synchronous)
throw error;
}
} else {
timer = setTimeout(() => ready(), 1e3);
if (!live())
clearTimeout(timer);
}
}
job = {
video,
promise,
ready,
fail,
cancel(reason) {
fail(cancellation(reason), false);
},
adopt(sourceEpoch) {
if (settled || running || video !== tool.video)
return false;
epoch = sourceEpoch;
return true;
}
};
state.job = job;
try {
for (const [name, callback] of [
["error", () => {
if (live())
fail(new Error(`Unable to load video: media error ${video.error?.code || 0}`));
}],
["loadedmetadata", () => ready()],
["durationchange", () => ready()]
]) {
listeners.push(() => video.removeEventListener(name, callback));
video.addEventListener(name, callback);
if (!live()) {
cleanupAll([() => video.removeEventListener(name, callback), ...listeners.splice(0)]);
return promise;
}
}
if (video.error)
fail(new Error(`Unable to load video: media error ${video.error.code}`));
else
ready(true);
} catch (error) {
fail(error, false);
throw error;
}
return promise;
}
function startExtraction(tool) {
const state = stateFor(tool);
if (state.closed) {
const promise = Promise.reject(cancellation("destroyed"));
promise.catch(() => {
});
return promise;
}
tool.errorHandle(!state.job, "There is currently a task in progress, please wait a moment...");
return createJob(tool, state);
}
const inputs = /* @__PURE__ */ new WeakMap();
function release(record) {
let failure;
@@ -96,6 +503,9 @@ function connect(record, callbacks) {
}
}
function setupInput(tool, patch) {
const state = stateFor(tool);
const epoch = ++state.inputEpoch;
const current = () => !state.closed && state.inputEpoch === epoch;
const option = Object.assign({}, tool.option, patch);
const target = option.fileInput;
tool.errorHandle(target instanceof Element, "The 'fileInput' is not a Element");
@@ -104,9 +514,12 @@ function setupInput(tool, patch) {
option.number = clamp(option.number, 10, 1e3);
option.width = clamp(option.width, 10, 1e3);
option.column = clamp(option.column, 1, 1e3);
if (!current())
throw cancellation("input setup superseded");
const previous = inputs.get(tool);
if (previous && (target === previous.input || target === previous.wrapper)) {
option.fileInput = previous.input;
tool.option = option;
return option;
}
const record = { input: target, wrapper: null, position: "", listeners: [], callbacks: previous?.callbacks || null };
@@ -120,8 +533,12 @@ function setupInput(tool, patch) {
target.style.position = "relative";
target.appendChild(record.input);
}
if (!current())
throw cancellation("input setup superseded");
if (record.callbacks)
connect(record, record.callbacks);
if (!current())
throw cancellation("input setup superseded");
} catch (error) {
try {
release(record);
@@ -193,7 +610,6 @@ function downloadSheet(file, url) {
}
return name;
}
const destroyed = /* @__PURE__ */ new WeakSet();
class ArtplayerToolThumbnail extends Emitter {
constructor(option = {}) {
super();
@@ -202,18 +618,19 @@ class ArtplayerToolThumbnail extends Emitter {
try {
this.setup(Object.assign({}, ArtplayerToolThumbnail.DEFAULTS, option));
this.video = ArtplayerToolThumbnail.creatVideo();
stateFor(this).video = this.video;
this.duration = 0;
this.inputChange = this.inputChange.bind(this);
this.ondrop = this.ondrop.bind(this);
connectInput(this, ArtplayerToolThumbnail.ondragover);
} catch (error) {
closeState(this, "construction failed");
try {
releaseInput(this);
} catch {
}
try {
if (this.video?.parentNode)
this.video.parentNode.removeChild(this.video);
releaseMedia(this);
} catch {
}
throw error;
@@ -233,15 +650,15 @@ class ArtplayerToolThumbnail extends Emitter {
event.preventDefault();
}
ondrop(event) {
if (destroyed.has(this))
if (stateFor(this).closed)
return;
event.preventDefault();
const file = event.dataTransfer.files[0];
this.loadVideo(file);
}
setup(option = {}) {
if (!destroyed.has(this))
this.option = setupInput(this, option);
if (!stateFor(this).closed)
setupInput(this, option);
return this;
}
static creatVideo() {
@@ -261,71 +678,17 @@ class ArtplayerToolThumbnail extends Emitter {
return video;
}
inputChange(event) {
if (destroyed.has(this))
if (stateFor(this).closed)
return;
const file = this.option.fileInput.files[0];
this.loadVideo(file);
event.target.value = "";
}
loadVideo(file) {
if (file && !destroyed.has(this)) {
const canPlayType = this.video.canPlayType(file.type);
this.errorHandle(
canPlayType === "maybe" || canPlayType === "probably",
`Playback of this file format is not supported: ${file.type}`
);
const videoUrl = URL.createObjectURL(file);
this.videoUrl = videoUrl;
this.file = file;
this.emit("file", this.file);
this.video.src = videoUrl;
this.emit("video", this.video);
}
loadSource(this, file);
}
start() {
if (!this.video.duration)
return sleep(1e3).then(() => this.start());
const { width, number, begin, end } = this.option;
const height = this.video.videoHeight / this.video.videoWidth * width;
this.option.height = height;
this.option.begin = clamp(begin, 0, this.video.duration);
this.option.end = clamp(end || this.video.duration, begin, this.video.duration);
this.errorHandle(this.option.end > this.option.begin, `End time must be greater than the start time`);
this.duration = this.option.end - this.option.begin;
this.density = number / this.duration;
this.errorHandle(this.file && this.video, "Please select the video file first");
this.errorHandle(!this.processing, "There is currently a task in progress, please wait a moment...");
this.errorHandle(this.density <= 1, `The preview density cannot be greater than 1, but got ${this.density}`);
const screenshotDate = this.creatScreenshotDate();
const canvas = this.creatCanvas();
const context2D = canvas.getContext("2d");
this.emit("canvas", canvas);
const promiseList = screenshotDate.map((item, index) => () => {
return new Promise((resolve) => {
this.video.oncanplay = () => {
context2D.drawImage(this.video, item.x, item.y, width, height);
canvas.toBlob((blob) => {
if (this.thumbnailUrl) {
URL.revokeObjectURL(this.thumbnailUrl);
}
this.thumbnailUrl = URL.createObjectURL(blob);
this.emit("update", this.thumbnailUrl, (index + 1) / number);
this.video.oncanplay = null;
resolve();
});
};
this.video.currentTime = item.time;
});
});
this.processing = true;
return runPromisesInSeries(promiseList).then(() => {
this.processing = false;
this.emit("done");
}).catch((err) => {
this.processing = false;
this.emit("error", err.message);
throw err;
});
return startExtraction(this);
}
creatScreenshotDate() {
return screenshotPoints(this.option, this.duration);
@@ -334,6 +697,8 @@ class ArtplayerToolThumbnail extends Emitter {
return createSheet(this.option);
}
download() {
if (stateFor(this).closed)
return this;
this.errorHandle(
this.file && this.thumbnailUrl,
"Download does not seem to be ready, please create preview first"
@@ -350,25 +715,13 @@ class ArtplayerToolThumbnail extends Emitter {
}
}
destroy() {
if (destroyed.has(this))
if (!closeState(this))
return;
destroyed.add(this);
let failure;
for (const cleanup of [
cleanupAll([
() => releaseInput(this),
() => this.video.parentNode?.removeChild(this.video),
() => this.videoUrl && URL.revokeObjectURL(this.videoUrl),
() => this.thumbnailUrl && URL.revokeObjectURL(this.thumbnailUrl),
() => releaseMedia(this),
() => this.emit("destroy")
]) {
try {
cleanup();
} catch (error) {
failure || (failure = error);
}
}
if (failure)
throw failure;
]);
}
}
export {
+2 -2
View File
@@ -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/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/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",
@@ -83,7 +83,7 @@
"generate:media:hls": "node scripts/generate-hls-fixture.mjs",
"test:iframe": "node --test test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe.test.js test/iframe-lifecycle.test.js refactor/scripts/iframe-contract.test.mjs refactor/scripts/iframe-types.test.mjs",
"test:iframe-types-package": "node refactor/scripts/iframe-package-types.mjs",
"test:thumbnail": "node --test test/thumbnail-input.test.js test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs"
"test:thumbnail": "node --test test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -1,19 +1,23 @@
# Thumbnail tool maintenance map
This package is being migrated under PKG-TOOL-THUMB-03. Input ownership and sheet
export are separated; extraction/source cancellation is still being implemented.
Strict TS and published declaration work belongs to PKG-TOOL-THUMB-04. Do not
infer complete migration or release readiness from this checkpoint.
The runtime responsibilities are separated under PKG-TOOL-THUMB-03. Strict TS and
published declaration work belongs to PKG-TOOL-THUMB-04. Runtime restructuring
does not imply complete migration or release readiness.
| File | Responsibility |
| --- | --- |
| src/index.js | Public class, historical method names, file/video events, current extraction loop and destruction entry |
| src/index.js | Public class, historical method names, construction and destruction entry |
| src/lifecycle.js | Private state, source/input generations, cancellation and resource release |
| src/source.js | File loading, native metadata/error listeners and source/thumbnail Blob URLs |
| src/extraction.js | Owned metadata wait and serial frame job, callback/error completion and cancellation |
| src/input.js | Option validation/clamps, file-input wrapper creation, listener registration/replacement and release |
| src/sheet.js | Midpoint grid, canvas/footer geometry, temporary download anchor |
| src/emitter.js | Existing on/once/emit/off semantics; provenance review remains in 04 |
| src/utils.js | Existing clamp, filename, sleep and serial-promise helpers |
The entry delegates to input/sheet modules; neither module imports the entry.
The entry delegates to input/source/extraction/sheet; none imports the entry.
Input/source use lifecycle state, extraction uses lifecycle/source, and sheet uses
only filename calculation. There is no dependency back from helpers to the class.
Input records live in a private WeakMap, so callers replacing `option` cannot
lose ownership of generated inputs/listeners. Normal construction preserves the
existing instance-field order and bound inputChange/ondrop methods. DEFAULTS,
@@ -30,9 +34,35 @@ The existing bound ondrop method is now registered correctly.
Destroy is idempotent and attempts input, video, current URL and destroy-event
cleanup even when one step throws; the first cleanup error escapes afterward.
Setup/file-input/drop/load calls cannot recreate input/source resources after
destruction. The emitter is not globally cleared. This does **not yet** cancel
existing extraction timers or late Blob callbacks; pending work and replaced
source URLs remain tracked risks for the rest of 03.
destruction. The emitter is not globally cleared. Destruction closes the instance
before cleanup, cancels its job and releases private URLs even if public URL fields
were overwritten. Current public URLs are also revoked, preserving historical
destroy behavior. The video is paused, its src removed, load resets its decoder
state, and its node is removed. Registration checks for reentrant closure/replacement.
## Extraction and event ordering
Loading emits file before assigning video.src, then video synchronously as in
workspace 4.4.0. A source generation prevents an older file callback or URL creation
hook from overwriting a nested newer load. Successful replacement revokes old
source URLs. Native errors report once for their source; stale listeners cannot
fail the latest job. Existing sheet URLs stay available until the next sheet frame
or destruction, matching the previous public behavior.
start creates one owned job. Metadata waits use native events plus the existing
one-second polling fallback. Waiting before the first file adopts that selection;
replacing an existing source cancels its old job. Duplicate starts are rejected.
Preflight stays synchronous when metadata is ready. Canvas precedes processing=true;
updates observe true, done observes false and may start another job without old
completion clearing the new state.
Frames wait for readiness/seek, accept readiness/Blob callbacks once and restore
oncanplay only while still owning it. Seek/draw/encoding failures, null Blob and
throwing callbacks settle the promise and detach job resources. Source replacement
or destruction rejects with AbortError without an error event. The public promise
still rejects for awaiting consumers; an internal handler owns ignored cancellation.
Late callbacks cannot create URLs, emit updates or schedule frames. There is no
arbitrary new metadata deadline; waiting can be cancelled by destroy/replacement.
Sheet extraction preserves fractional coordinates, historical `creat*` names,
the 30-pixel footer and the filename algorithm. The temporary download anchor is
@@ -63,8 +93,10 @@ cleanup runs on all three engines, while extraction acceptance needs supported
Safari/WebKit evidence in 05. The independent tool example is
`docs/assets/example/tool.thumbnail.js`, not the external thumbnail plugin example.
Continue 03 by introducing owned metadata/extraction jobs and source/thumbnail
URL cleanup. Add candidate failure/cancellation regressions and preserve old
tests; do not change default delay/height policy silently. Then migrate the final
module boundaries to strict TS in 04 and verify installed declarations/old paths.
`test/thumbnail-lifecycle.test.js` covers jobs, source cancellation, native errors,
callback failures, reentrancy and private resources. Browser tests hold real PNG
callbacks and replace a selected file during encoding; only the latest job may
complete. Continue strict TS and installed declarations in 04; preserve old tests
and the default delay/height boundary. The old sleep/serial helpers in utils.js are
no longer used by extraction and can be removed during that migration.
See [task plan](../../refactor/plan.md) and [risk ledger](../../refactor/risks.json).
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.ArtplayerToolThumbnail=e(),define(function(){return t.ArtplayerToolThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).ArtplayerToolThumbnail=e()}(this,function(){"use strict";class t{on(t,e,i){const n=this.e||(this.e={});return(n[t]||(n[t]=[])).push({fn:e,ctx:i}),this}once(t,e,i){const n=this;function o(...s){n.off(t,o),e.apply(i,s)}return o._=e,this.on(t,o,i)}emit(t,...e){const i=((this.e||(this.e={}))[t]||[]).slice();for(let n=0;n<i.length;n+=1)i[n].fn.apply(i[n].ctx,e);return this}off(t,e){const i=this.e||(this.e={}),n=i[t],o=[];if(n&&e)for(let s=0,r=n.length;s<r;s+=1)n[s].fn!==e&&n[s].fn._!==e&&o.push(n[s]);return o.length?i[t]=o:delete i[t],this}}function e(t,e,i){return Math.max(Math.min(t,Math.max(e,i)),Math.min(e,i))}const i=new WeakMap;function n(t){let e;for(const[n,o]of t.listeners.splice(0))try{t.input.removeEventListener(n,o)}catch(i){e||(e=i)}if(t.wrapper){try{t.input.parentNode&&t.input.parentNode.removeChild(t.input)}catch(i){e||(e=i)}"relative"===t.wrapper.style.position&&(t.wrapper.style.position=t.position)}if(e)throw e}function o(t,e){try{for(const[i,n]of e)t.listeners.push([i,n]),t.input.addEventListener(i,n)}catch(i){try{n(t)}catch{}throw i}}function s(t){const e=i.get(t);e&&(i.delete(t),n(e))}const r=new WeakSet;class h extends t{constructor(t={}){super(),this.processing=!1,this.option={};try{this.setup(Object.assign({},h.DEFAULTS,t)),this.video=h.creatVideo(),this.duration=0,this.inputChange=this.inputChange.bind(this),this.ondrop=this.ondrop.bind(this),function(t,e){const n=i.get(t);n.callbacks=[["change",t.inputChange],["dragover",e],["drop",t.ondrop]],o(n,n.callbacks)}(this,h.ondragover)}catch(e){try{s(this)}catch{}try{this.video?.parentNode&&this.video.parentNode.removeChild(this.video)}catch{}throw e}}static get DEFAULTS(){return{number:60,width:160,height:90,column:10,begin:0,end:Number.NaN}}static ondragover(t){t.preventDefault()}ondrop(t){if(r.has(this))return;t.preventDefault();const e=t.dataTransfer.files[0];this.loadVideo(e)}setup(t={}){return r.has(this)||(this.option=function(t,s){const r=Object.assign({},t.option,s),h=r.fileInput;t.errorHandle(h instanceof Element,"The 'fileInput' is not a Element");for(const e of["number","width","column","begin","end"])t.errorHandle("number"===typeof r[e],`The '${e}' is not a number`);r.number=e(r.number,10,1e3),r.width=e(r.width,10,1e3),r.column=e(r.column,1,1e3);const a=i.get(t);if(a&&(h===a.input||h===a.wrapper))return r.fileInput=a.input,r;const l={input:h,wrapper:null,position:"",listeners:[],callbacks:a?.callbacks||null};try{"INPUT"===h.tagName&&"file"===h.type||(l.wrapper=h,l.position=h.style.position,l.input=document.createElement("input"),l.input.type="file",Object.assign(l.input.style,{position:"absolute",width:"100%",height:"100%",left:"0",top:"0",right:"0",bottom:"0",opacity:"0"}),h.style.position="relative",h.appendChild(l.input)),l.callbacks&&o(l,l.callbacks)}catch(c){try{n(l)}catch{}throw c}return r.fileInput=l.input,i.set(t,l),t.option=r,a&&n(a),r}(this,t)),this}static creatVideo(){const t=document.createElement("video");t.style.position="absolute",t.style.top="-9999px",t.style.left="-9999px",t.muted=!0,t.controls=!0;try{document.body.appendChild(t)}catch(e){throw t.parentNode&&t.parentNode.removeChild(t),e}return t}inputChange(t){if(r.has(this))return;const e=this.option.fileInput.files[0];this.loadVideo(e),t.target.value=""}loadVideo(t){if(t&&!r.has(this)){const e=this.video.canPlayType(t.type);this.errorHandle("maybe"===e||"probably"===e,`Playback of this file format is not supported: ${t.type}`);const i=URL.createObjectURL(t);this.videoUrl=i,this.file=t,this.emit("file",this.file),this.video.src=i,this.emit("video",this.video)}}start(){if(!this.video.duration)return(t=1e3,new Promise(e=>setTimeout(e,t))).then(()=>this.start());var t;const{width:i,number:n,begin:o,end:s}=this.option,r=this.video.videoHeight/this.video.videoWidth*i;this.option.height=r,this.option.begin=e(o,0,this.video.duration),this.option.end=e(s||this.video.duration,o,this.video.duration),this.errorHandle(this.option.end>this.option.begin,"End time must be greater than the start time"),this.duration=this.option.end-this.option.begin,this.density=n/this.duration,this.errorHandle(this.file&&this.video,"Please select the video file first"),this.errorHandle(!this.processing,"There is currently a task in progress, please wait a moment..."),this.errorHandle(this.density<=1,`The preview density cannot be greater than 1, but got ${this.density}`);const h=this.creatScreenshotDate(),a=this.creatCanvas(),l=a.getContext("2d");this.emit("canvas",a);const c=h.map((t,e)=>()=>new Promise(o=>{this.video.oncanplay=()=>{l.drawImage(this.video,t.x,t.y,i,r),a.toBlob(t=>{this.thumbnailUrl&&URL.revokeObjectURL(this.thumbnailUrl),this.thumbnailUrl=URL.createObjectURL(t),this.emit("update",this.thumbnailUrl,(e+1)/n),this.videLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolThumbnail=t(),define(function(){return e.ArtplayerToolThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolThumbnail=t()}(this,function(){"use strict";class e{on(e,t,n){const r=this.e||(this.e={});return(r[e]||(r[e]=[])).push({fn:t,ctx:n}),this}once(e,t,n){const r=this;function o(...i){r.off(e,o),t.apply(n,i)}return o._=t,this.on(e,o,n)}emit(e,...t){const n=((this.e||(this.e={}))[e]||[]).slice();for(let r=0;r<n.length;r+=1)n[r].fn.apply(n[r].ctx,t);return this}off(e,t){const n=this.e||(this.e={}),r=n[e],o=[];if(r&&t)for(let i=0,s=r.length;i<s;i+=1)r[i].fn!==t&&r[i].fn._!==t&&o.push(r[i]);return o.length?n[e]=o:delete n[e],this}}const t=new WeakMap;function n(e){return t.has(e)||t.set(e,{closed:!1,epoch:0,inputEpoch:0,job:null,video:null,sourceUrl:null,thumbnailUrl:null,sourceUrls:new Set,thumbnailUrls:new Set,sourceListeners:[],loading:!1}),t.get(e)}function r(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function o(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function i(e){let t;for(const r of e)try{r()}catch(n){t||(t=n)}if(t)throw t}function s(e,t="destroyed"){const r=n(e);return!r.closed&&(r.closed=!0,r.epoch++,r.job?.cancel(t),!0)}function c(e){const t=n(e),r=new Set([...t.sourceUrls,e.videoUrl]),s=new Set([...t.thumbnailUrls,e.thumbnailUrl]),c=[];for(const n of new Set([t.video,e.video]))n&&c.push(()=>n.pause?.(),()=>n.removeAttribute?.("src"),()=>n.load?.(),()=>n.parentNode?.removeChild(n));i([...t.sourceListeners.splice(0),...c,...[...r].map(e=>()=>o(t.sourceUrls,e)),...[...s].map(e=>()=>o(t.thumbnailUrls,e))])}function a(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function l(e,t){let s=t.epoch;const c=e.video;let l,u,d,h,p=!1,f=!1,m=()=>{};const b=[],y=new Promise((e,t)=>{l=e,u=t});y.catch(()=>{});const g=()=>!p&&!t.closed&&t.epoch===s&&t.job===h;function v(){return!p&&(p=!0,t.job===h&&(t.job=null,e.processing=!1),i([()=>clearTimeout(d),m,...b.splice(0)]),!0)}function w(t,n=!0){if(!p){try{v()}catch(r){t||(t=r)}if(n)try{e.emit("error",t?.message)}catch(o){t=o}u(t)}}function U(t){if(!g())return;f=!0,e.processing=!0;let r=0;const i=()=>{if(!g())return;if(r===t.points.length)return void function(){if(g())try{v(),e.emit("done"),l()}catch(t){let r=t;try{e.emit("error",t?.message)}catch(n){r=n}u(r)}}();const s=t.points[r];let a=!1,d=!1;const h=c.oncanplay,p=()=>{if(!(!g()||a||c.seeking||void 0!==c.readyState&&c.readyState<2)){a=!0;try{if(m(),t.context.drawImage(c,s.x,s.y,t.width,t.height),!g())return;t.canvas.toBlob(s=>{if(g()&&!d){d=!0;try{if(!s)throw new Error("Unable to create thumbnail image");const c=function(e,t,r){const i=n(e),s=URL.createObjectURL(t);if(i.thumbnailUrls.add(s),!r())return o(i.thumbnailUrls,s),null;const c=new Set([...i.thumbnailUrls,e.thumbnailUrl]);i.thumbnailUrl=s,e.thumbnailUrl=s;for(const n of c)n&&n!==s&&o(i.thumbnailUrls,n);return r()?s:null}(e,s,g);if(!c)return;e.emit("update",c,(r+1)/t.number),r++,Promise.resolve().then(i)}catch(c){w(c)}}})}catch(l){w(l)}}};m=()=>{c.oncanplay===p&&(c.oncanplay=h),c.removeEventListener("seeked",p)};try{c.oncanplay=p,c.addEventListener("seeked",p),c.currentTime=s.time,c.readyState>=2&&!c.seeking&&Promise.resolve().then(p)}catch(f){w(f)}};Promise.resolve().then(i)}function E(n=!1){if(g()&&!f)if(clearTimeout(d),!t.loading&&c.duration){f=!0;try{const t=function(e,t){const{video:n}=t,{width:r,number:o,begin:i,end:s}=e.option,c=n.videoHeight/n.videoWidth*r,l=(n,r)=>{try{e.errorHandle(n,r)}catch(o){throw t.reported=o,t.wasReported=!0,o}};e.option.height=c,e.option.begin=a(i,0,n.duration),e.option.end=a(s||n.duration,i,n.duration),l(e.option.end>e.option.begin,"End time must be greater than the start time"),l(Number.isFinite(n.duration),"Video duration must be finite"),e.duration=e.option.end-e.option.begin,e.density=o/e.duration,l(e.file&&n,"Please select the video file first"),l(!e.processing,"There is currently a task in progress, please wait a moment..."),l(e.density<=1,`The preview density cannot be greater than 1, but got ${e.density}`);const u=e.creatScreenshotDate(),d=e.creatCanvas(),h=d.getContext("2d");return e.emit("canvas",d),{width:r,height:c,number:o,points:u,canvas:d,context:h}}(e,h);U(t)}catch(r){if(w(r,!n&&!(h.wasReported&&r===h.reported)),n)throw r}}else d=setTimeout(()=>E(),1e3),g()||clearTimeout(d)}h={video:c,promise:y,ready:E,fail:w,cancel(e){w(r(e),!1)},adopt:t=>!p&&!f&&c===e.video&&(s=t,!0)},t.job=h;try{for(const[e,t]of[["error",()=>{g()&&w(new Error(`Unable to load video: media error ${c.error?.code||0}`))}],["loadedmetadata",()=>E()],["durationchange",()=>E()]])if(b.push(()=>c.removeEventListener(e,t)),c.addEventListener(e,t),!g())return i([()=>c.removeEventListener(e,t),...b.splice(0)]),y;c.error?w(new Error(`Unable to load video: media error ${c.error.code}`)):E(!0)}catch(j){throw w(j,!1),j}return Line truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.ArtplayerToolThumbnail=e(),define(function(){return t.ArtplayerToolThumbnail})):(t="undefined"!==typeof globalThis?globalThis:t||self).ArtplayerToolThumbnail=e()}(this,function(){"use strict";class t{on(t,e,i){const n=this.e||(this.e={});return(n[t]||(n[t]=[])).push({fn:e,ctx:i}),this}once(t,e,i){const n=this;function o(...s){n.off(t,o),e.apply(i,s)}return o._=e,this.on(t,o,i)}emit(t,...e){const i=((this.e||(this.e={}))[t]||[]).slice();for(let n=0;n<i.length;n+=1)i[n].fn.apply(i[n].ctx,e);return this}off(t,e){const i=this.e||(this.e={}),n=i[t],o=[];if(n&&e)for(let s=0,r=n.length;s<r;s+=1)n[s].fn!==e&&n[s].fn._!==e&&o.push(n[s]);return o.length?i[t]=o:delete i[t],this}}function e(t,e,i){return Math.max(Math.min(t,Math.max(e,i)),Math.min(e,i))}const i=new WeakMap;function n(t){let e;for(const[n,o]of t.listeners.splice(0))try{t.input.removeEventListener(n,o)}catch(i){e||(e=i)}if(t.wrapper){try{t.input.parentNode&&t.input.parentNode.removeChild(t.input)}catch(i){e||(e=i)}"relative"===t.wrapper.style.position&&(t.wrapper.style.position=t.position)}if(e)throw e}function o(t,e){try{for(const[i,n]of e)t.listeners.push([i,n]),t.input.addEventListener(i,n)}catch(i){try{n(t)}catch(o){}throw i}}function s(t){const e=i.get(t);e&&(i.delete(t),n(e))}const r=new WeakSet;class h extends t{constructor(t={}){var e;super(),this.processing=!1,this.option={};try{this.setup(Object.assign({},h.DEFAULTS,t)),this.video=h.creatVideo(),this.duration=0,this.inputChange=this.inputChange.bind(this),this.ondrop=this.ondrop.bind(this),function(t,e){const n=i.get(t);n.callbacks=[["change",t.inputChange],["dragover",e],["drop",t.ondrop]],o(n,n.callbacks)}(this,h.ondragover)}catch(n){try{s(this)}catch(r){}try{(null==(e=this.video)?void 0:e.parentNode)&&this.video.parentNode.removeChild(this.video)}catch(r){}throw n}}static get DEFAULTS(){return{number:60,width:160,height:90,column:10,begin:0,end:Number.NaN}}static ondragover(t){t.preventDefault()}ondrop(t){if(r.has(this))return;t.preventDefault();const e=t.dataTransfer.files[0];this.loadVideo(e)}setup(t={}){return r.has(this)||(this.option=function(t,s){const r=Object.assign({},t.option,s),h=r.fileInput;t.errorHandle(h instanceof Element,"The 'fileInput' is not a Element");for(const e of["number","width","column","begin","end"])t.errorHandle("number"===typeof r[e],`The '${e}' is not a number`);r.number=e(r.number,10,1e3),r.width=e(r.width,10,1e3),r.column=e(r.column,1,1e3);const a=i.get(t);if(a&&(h===a.input||h===a.wrapper))return r.fileInput=a.input,r;const l={input:h,wrapper:null,position:"",listeners:[],callbacks:(null==a?void 0:a.callbacks)||null};try{"INPUT"===h.tagName&&"file"===h.type||(l.wrapper=h,l.position=h.style.position,l.input=document.createElement("input"),l.input.type="file",Object.assign(l.input.style,{position:"absolute",width:"100%",height:"100%",left:"0",top:"0",right:"0",bottom:"0",opacity:"0"}),h.style.position="relative",h.appendChild(l.input)),l.callbacks&&o(l,l.callbacks)}catch(c){try{n(l)}catch(d){}throw c}return r.fileInput=l.input,i.set(t,l),t.option=r,a&&n(a),r}(this,t)),this}static creatVideo(){const t=document.createElement("video");t.style.position="absolute",t.style.top="-9999px",t.style.left="-9999px",t.muted=!0,t.controls=!0;try{document.body.appendChild(t)}catch(e){throw t.parentNode&&t.parentNode.removeChild(t),e}return t}inputChange(t){if(r.has(this))return;const e=this.option.fileInput.files[0];this.loadVideo(e),t.target.value=""}loadVideo(t){if(t&&!r.has(this)){const e=this.video.canPlayType(t.type);this.errorHandle("maybe"===e||"probably"===e,`Playback of this file format is not supported: ${t.type}`);const i=URL.createObjectURL(t);this.videoUrl=i,this.file=t,this.emit("file",this.file),this.video.src=i,this.emit("video",this.video)}}start(){if(!this.video.duration)return(t=1e3,new Promise(e=>setTimeout(e,t))).then(()=>this.start());var t;const{width:i,number:n,begin:o,end:s}=this.option,r=this.video.videoHeight/this.video.videoWidth*i;this.option.height=r,this.option.begin=e(o,0,this.video.duration),this.option.end=e(s||this.video.duration,o,this.video.duration),this.errorHandle(this.option.end>this.option.begin,"End time must be greater than the start time"),this.duration=this.option.end-this.option.begin,this.density=n/this.duration,this.errorHandle(this.file&&this.video,"Please select the video file first"),this.errorHandle(!this.processing,"There is currently a task in progress, please wait a moment..."),this.errorHandle(this.density<=1,`The preview density cannot be greater than 1, but got ${this.density}`);const h=this.creatScreenshotDate(),a=this.creatCanvas(),l=a.getContext("2d");this.emit("canvas",a);const c=h.map((t,e)=>()=>new Promise(o=>{this.video.oncanplay=()=>{l.drawImage(this.video,t.x,t.y,i,r),a.toBlob(t=>{this.thumbnailUrl&&URL.revokeObjectURL(this.thumbnailUrl),this.thumbnailUrl=URL.createObjectURL(t),tLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.ArtplayerToolThumbnail=t(),define(function(){return e.ArtplayerToolThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).ArtplayerToolThumbnail=t()}(this,function(){"use strict";class e{on(e,t,n){const r=this.e||(this.e={});return(r[e]||(r[e]=[])).push({fn:t,ctx:n}),this}once(e,t,n){const r=this;function o(...i){r.off(e,o),t.apply(n,i)}return o._=t,this.on(e,o,n)}emit(e,...t){const n=((this.e||(this.e={}))[e]||[]).slice();for(let r=0;r<n.length;r+=1)n[r].fn.apply(n[r].ctx,t);return this}off(e,t){const n=this.e||(this.e={}),r=n[e],o=[];if(r&&t)for(let i=0,s=r.length;i<s;i+=1)r[i].fn!==t&&r[i].fn._!==t&&o.push(r[i]);return o.length?n[e]=o:delete n[e],this}}const t=new WeakMap;function n(e){return t.has(e)||t.set(e,{closed:!1,epoch:0,inputEpoch:0,job:null,video:null,sourceUrl:null,thumbnailUrl:null,sourceUrls:new Set,thumbnailUrls:new Set,sourceListeners:[],loading:!1}),t.get(e)}function r(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function o(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function i(e){let t;for(const r of e)try{r()}catch(n){t||(t=n)}if(t)throw t}function s(e,t="destroyed"){var r;const o=n(e);return!o.closed&&(o.closed=!0,o.epoch++,null==(r=o.job)||r.cancel(t),!0)}function c(e){const t=n(e),r=new Set([...t.sourceUrls,e.videoUrl]),s=new Set([...t.thumbnailUrls,e.thumbnailUrl]),c=[];for(const n of new Set([t.video,e.video]))n&&c.push(()=>{var e;return null==(e=n.pause)?void 0:e.call(n)},()=>{var e;return null==(e=n.removeAttribute)?void 0:e.call(n,"src")},()=>{var e;return null==(e=n.load)?void 0:e.call(n)},()=>{var e;return null==(e=n.parentNode)?void 0:e.removeChild(n)});i([...t.sourceListeners.splice(0),...c,...[...r].map(e=>()=>o(t.sourceUrls,e)),...[...s].map(e=>()=>o(t.thumbnailUrls,e))])}function l(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function a(e,t){let s=t.epoch;const c=e.video;let a,u,d,h,p=!1,f=!1,m=()=>{};const b=[],v=new Promise((e,t)=>{a=e,u=t});v.catch(()=>{});const y=()=>!p&&!t.closed&&t.epoch===s&&t.job===h;function g(){return!p&&(p=!0,t.job===h&&(t.job=null,e.processing=!1),i([()=>clearTimeout(d),m,...b.splice(0)]),!0)}function w(t,n=!0){if(!p){try{g()}catch(r){t||(t=r)}if(n)try{e.emit("error",null==t?void 0:t.message)}catch(o){t=o}u(t)}}function U(t){if(!y())return;f=!0,e.processing=!0;let r=0;const i=()=>{if(!y())return;if(r===t.points.length)return void function(){if(y())try{g(),e.emit("done"),a()}catch(t){let r=t;try{e.emit("error",null==t?void 0:t.message)}catch(n){r=n}u(r)}}();const s=t.points[r];let l=!1,d=!1;const h=c.oncanplay,p=()=>{if(!(!y()||l||c.seeking||void 0!==c.readyState&&c.readyState<2)){l=!0;try{if(m(),t.context.drawImage(c,s.x,s.y,t.width,t.height),!y())return;t.canvas.toBlob(s=>{if(y()&&!d){d=!0;try{if(!s)throw new Error("Unable to create thumbnail image");const c=function(e,t,r){const i=n(e),s=URL.createObjectURL(t);if(i.thumbnailUrls.add(s),!r())return o(i.thumbnailUrls,s),null;const c=new Set([...i.thumbnailUrls,e.thumbnailUrl]);i.thumbnailUrl=s,e.thumbnailUrl=s;for(const n of c)n&&n!==s&&o(i.thumbnailUrls,n);return r()?s:null}(e,s,y);if(!c)return;e.emit("update",c,(r+1)/t.number),r++,Promise.resolve().then(i)}catch(c){w(c)}}})}catch(a){w(a)}}};m=()=>{c.oncanplay===p&&(c.oncanplay=h),c.removeEventListener("seeked",p)};try{c.oncanplay=p,c.addEventListener("seeked",p),c.currentTime=s.time,c.readyState>=2&&!c.seeking&&Promise.resolve().then(p)}catch(f){w(f)}};Promise.resolve().then(i)}function E(n=!1){if(y()&&!f)if(clearTimeout(d),!t.loading&&c.duration){f=!0;try{const t=function(e,t){const{video:n}=t,{width:r,number:o,begin:i,end:s}=e.option,c=n.videoHeight/n.videoWidth*r,a=(n,r)=>{try{e.errorHandle(n,r)}catch(o){throw t.reported=o,t.wasReported=!0,o}};e.option.height=c,e.option.begin=l(i,0,n.duration),e.option.end=l(s||n.duration,i,n.duration),a(e.option.end>e.option.begin,"End time must be greater than the start time"),a(Number.isFinite(n.duration),"Video duration must be finite"),e.duration=e.option.end-e.option.begin,e.density=o/e.duration,a(e.file&&n,"Please select the video file first"),a(!e.processing,"There is currently a task in progress, please wait a moment..."),a(e.density<=1,`The preview density cannot be greater than 1, but got ${e.density}`);const u=e.creatScreenshotDate(),d=e.creatCanvas(),h=d.getContext("2d");return e.emit("canvas",d),{width:r,height:c,number:o,points:u,canvas:d,context:h}}(e,h);U(t)}catch(r){if(w(r,!n&&!(h.wasReported&&r===h.reported)),n)throw r}}else d=setTimeout(()=>E(),1e3),y()||clearTimeout(d)}h={video:c,promise:v,ready:E,fail:w,cancel(e){w(r(e),!1)},adopt:t=>!p&&!f&&c===e.video&&(s=t,!0)},t.job=h;try{for(const[e,t]of[["error",()=>{var e;y()&&w(new Error(`Unable to load video: media error ${(null==(e=c.error)?void 0:e.code)||0}`))}],["loadedmetadata",()=>E()],["durationchange",()=>E()]])if(b.push(()=>c.removeELine truncated
@@ -44,11 +44,188 @@ class Emitter {
return this;
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
const states = /* @__PURE__ */ new WeakMap();
function stateFor(tool) {
if (!states.has(tool)) {
states.set(tool, {
closed: false,
epoch: 0,
inputEpoch: 0,
job: null,
video: null,
sourceUrl: null,
thumbnailUrl: null,
sourceUrls: /* @__PURE__ */ new Set(),
thumbnailUrls: /* @__PURE__ */ new Set(),
sourceListeners: [],
loading: false
});
}
return states.get(tool);
}
function runPromisesInSeries(ps) {
return ps.reduce((p, next) => p.then(next), Promise.resolve());
function cancellation(reason) {
const error = new Error(`Thumbnail task cancelled: ${reason}`);
error.name = "AbortError";
return error;
}
function revoke(urls, url) {
if (!url)
return;
urls.delete(url);
URL.revokeObjectURL(url);
}
function cleanupAll(callbacks) {
let failure;
for (const callback of callbacks) {
try {
callback();
} catch (error) {
failure || (failure = error);
}
}
if (failure)
throw failure;
}
function closeState(tool, reason = "destroyed") {
const state = stateFor(tool);
if (state.closed)
return false;
state.closed = true;
state.epoch++;
state.job?.cancel(reason);
return true;
}
function releaseMedia(tool) {
const state = stateFor(tool);
const sources = /* @__PURE__ */ new Set([...state.sourceUrls, tool.videoUrl]);
const thumbnails = /* @__PURE__ */ new Set([...state.thumbnailUrls, tool.thumbnailUrl]);
const media = [];
for (const video of /* @__PURE__ */ new Set([state.video, tool.video])) {
if (video) {
media.push(
() => video.pause?.(),
() => video.removeAttribute?.("src"),
() => video.load?.(),
() => video.parentNode?.removeChild(video)
);
}
}
cleanupAll([
...state.sourceListeners.splice(0),
...media,
...[...sources].map((url) => () => revoke(state.sourceUrls, url)),
...[...thumbnails].map((url) => () => revoke(state.thumbnailUrls, url))
]);
}
function loadSource(tool, file) {
const state = stateFor(tool);
if (!file || state.closed)
return;
const initialEpoch = state.epoch;
const video = tool.video;
const support = video.canPlayType(file.type);
tool.errorHandle(support === "maybe" || support === "probably", `Playback of this file format is not supported: ${file.type}`);
if (state.closed || state.epoch !== initialEpoch)
return;
const url = URL.createObjectURL(file);
state.sourceUrls.add(url);
if (state.closed || state.epoch !== initialEpoch) {
revoke(state.sourceUrls, url);
return;
}
const epoch = ++state.epoch;
const initialWait = !state.sourceUrl && !tool.file && state.job?.adopt(epoch);
if (!initialWait)
state.job?.cancel("source changed");
state.loading = true;
const current = () => !state.closed && state.epoch === epoch;
const previousListeners = state.sourceListeners;
const listeners = [];
state.sourceListeners = listeners;
let pendingCleanup = () => {
};
let reported = false;
const error = () => {
if (!current() || reported || video.currentSrc && video.currentSrc !== url)
return;
reported = true;
state.loading = false;
const failure = new Error(`Unable to load video: media error ${video.error?.code || 0}`);
if (state.job)
state.job.fail(failure);
else
tool.emit("error", failure.message);
};
const ready = () => {
if (current() && (!video.currentSrc || video.currentSrc === url)) {
state.loading = false;
state.job?.ready();
}
};
try {
cleanupAll(previousListeners.splice(0));
if (!current()) {
revoke(state.sourceUrls, url);
return;
}
for (const [name, callback] of [["error", error], ["loadedmetadata", ready]]) {
pendingCleanup = () => video.removeEventListener(name, callback);
listeners.push(pendingCleanup);
video.addEventListener(name, callback);
if (!current()) {
cleanupAll([pendingCleanup, ...listeners.splice(0)]);
revoke(state.sourceUrls, url);
return;
}
}
tool.videoUrl = url;
tool.file = file;
tool.emit("file", file);
if (!current()) {
revoke(state.sourceUrls, url);
return;
}
video.src = url;
if (!current()) {
revoke(state.sourceUrls, url);
return;
}
state.sourceUrl = url;
for (const previous of [...state.sourceUrls]) {
if (previous !== url)
revoke(state.sourceUrls, previous);
}
if (current())
tool.emit("video", video);
} catch (error2) {
if (current()) {
state.loading = false;
state.job?.cancel("source setup failed");
}
try {
cleanupAll([pendingCleanup, ...listeners.splice(0)]);
} catch {
}
revoke(state.sourceUrls, url);
throw error2;
}
}
function replaceThumbnail(tool, blob, live) {
const state = stateFor(tool);
const url = URL.createObjectURL(blob);
state.thumbnailUrls.add(url);
if (!live()) {
revoke(state.thumbnailUrls, url);
return null;
}
const previous = /* @__PURE__ */ new Set([...state.thumbnailUrls, tool.thumbnailUrl]);
state.thumbnailUrl = url;
tool.thumbnailUrl = url;
for (const old of previous) {
if (old && old !== url)
revoke(state.thumbnailUrls, old);
}
return live() ? url : null;
}
function getFileName(name) {
const nameArray = name.split(".");
@@ -58,6 +235,236 @@ function getFileName(name) {
function clamp(num, a, b) {
return Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b));
}
function prepare(tool, job) {
const { video } = job;
const { width, number, begin, end } = tool.option;
const height = video.videoHeight / video.videoWidth * width;
const guard = (condition, message) => {
try {
tool.errorHandle(condition, message);
} catch (error) {
job.reported = error;
job.wasReported = true;
throw error;
}
};
tool.option.height = height;
tool.option.begin = clamp(begin, 0, video.duration);
tool.option.end = clamp(end || video.duration, begin, video.duration);
guard(tool.option.end > tool.option.begin, "End time must be greater than the start time");
guard(Number.isFinite(video.duration), "Video duration must be finite");
tool.duration = tool.option.end - tool.option.begin;
tool.density = number / tool.duration;
guard(tool.file && video, "Please select the video file first");
guard(!tool.processing, "There is currently a task in progress, please wait a moment...");
guard(tool.density <= 1, `The preview density cannot be greater than 1, but got ${tool.density}`);
const points = tool.creatScreenshotDate();
const canvas = tool.creatCanvas();
const context = canvas.getContext("2d");
tool.emit("canvas", canvas);
return { width, height, number, points, canvas, context };
}
function createJob(tool, state) {
let epoch = state.epoch;
const video = tool.video;
let resolve;
let reject;
let timer;
let settled = false;
let running = false;
let job;
let frameCleanup = () => {
};
const listeners = [];
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
promise.catch(() => {
});
const live = () => !settled && !state.closed && state.epoch === epoch && state.job === job;
function finish() {
if (settled)
return false;
settled = true;
if (state.job === job) {
state.job = null;
tool.processing = false;
}
cleanupAll([() => clearTimeout(timer), frameCleanup, ...listeners.splice(0)]);
return true;
}
function fail(error, report = true) {
if (settled)
return;
try {
finish();
} catch (cleanupError) {
error || (error = cleanupError);
}
if (report) {
try {
tool.emit("error", error?.message);
} catch (listenerError) {
error = listenerError;
}
}
reject(error);
}
function complete() {
if (!live())
return;
try {
finish();
tool.emit("done");
resolve();
} catch (error) {
let failure = error;
try {
tool.emit("error", error?.message);
} catch (listenerError) {
failure = listenerError;
}
reject(failure);
}
}
function extract(plan) {
if (!live())
return;
running = true;
tool.processing = true;
let index = 0;
const next = () => {
if (!live())
return;
if (index === plan.points.length) {
complete();
return;
}
const point = plan.points[index];
let drawing = false;
let encoded = false;
const previous = video.oncanplay;
const draw = () => {
if (!live() || drawing || video.seeking || video.readyState !== void 0 && video.readyState < 2)
return;
drawing = true;
try {
frameCleanup();
plan.context.drawImage(video, point.x, point.y, plan.width, plan.height);
if (!live())
return;
plan.canvas.toBlob((blob) => {
if (!live() || encoded)
return;
encoded = true;
try {
if (!blob)
throw new Error("Unable to create thumbnail image");
const url = replaceThumbnail(tool, blob, live);
if (!url)
return;
tool.emit("update", url, (index + 1) / plan.number);
index++;
Promise.resolve().then(next);
} catch (error) {
fail(error);
}
});
} catch (error) {
fail(error);
}
};
frameCleanup = () => {
if (video.oncanplay === draw)
video.oncanplay = previous;
video.removeEventListener("seeked", draw);
};
try {
video.oncanplay = draw;
video.addEventListener("seeked", draw);
video.currentTime = point.time;
if (video.readyState >= 2 && !video.seeking)
Promise.resolve().then(draw);
} catch (error) {
fail(error);
}
};
Promise.resolve().then(next);
}
function ready(synchronous = false) {
if (!live() || running)
return;
clearTimeout(timer);
if (!state.loading && video.duration) {
running = true;
try {
const plan = prepare(tool, job);
extract(plan);
} catch (error) {
fail(error, !synchronous && !(job.wasReported && error === job.reported));
if (synchronous)
throw error;
}
} else {
timer = setTimeout(() => ready(), 1e3);
if (!live())
clearTimeout(timer);
}
}
job = {
video,
promise,
ready,
fail,
cancel(reason) {
fail(cancellation(reason), false);
},
adopt(sourceEpoch) {
if (settled || running || video !== tool.video)
return false;
epoch = sourceEpoch;
return true;
}
};
state.job = job;
try {
for (const [name, callback] of [
["error", () => {
if (live())
fail(new Error(`Unable to load video: media error ${video.error?.code || 0}`));
}],
["loadedmetadata", () => ready()],
["durationchange", () => ready()]
]) {
listeners.push(() => video.removeEventListener(name, callback));
video.addEventListener(name, callback);
if (!live()) {
cleanupAll([() => video.removeEventListener(name, callback), ...listeners.splice(0)]);
return promise;
}
}
if (video.error)
fail(new Error(`Unable to load video: media error ${video.error.code}`));
else
ready(true);
} catch (error) {
fail(error, false);
throw error;
}
return promise;
}
function startExtraction(tool) {
const state = stateFor(tool);
if (state.closed) {
const promise = Promise.reject(cancellation("destroyed"));
promise.catch(() => {
});
return promise;
}
tool.errorHandle(!state.job, "There is currently a task in progress, please wait a moment...");
return createJob(tool, state);
}
const inputs = /* @__PURE__ */ new WeakMap();
function release(record) {
let failure;
@@ -96,6 +503,9 @@ function connect(record, callbacks) {
}
}
function setupInput(tool, patch) {
const state = stateFor(tool);
const epoch = ++state.inputEpoch;
const current = () => !state.closed && state.inputEpoch === epoch;
const option = Object.assign({}, tool.option, patch);
const target = option.fileInput;
tool.errorHandle(target instanceof Element, "The 'fileInput' is not a Element");
@@ -104,9 +514,12 @@ function setupInput(tool, patch) {
option.number = clamp(option.number, 10, 1e3);
option.width = clamp(option.width, 10, 1e3);
option.column = clamp(option.column, 1, 1e3);
if (!current())
throw cancellation("input setup superseded");
const previous = inputs.get(tool);
if (previous && (target === previous.input || target === previous.wrapper)) {
option.fileInput = previous.input;
tool.option = option;
return option;
}
const record = { input: target, wrapper: null, position: "", listeners: [], callbacks: previous?.callbacks || null };
@@ -120,8 +533,12 @@ function setupInput(tool, patch) {
target.style.position = "relative";
target.appendChild(record.input);
}
if (!current())
throw cancellation("input setup superseded");
if (record.callbacks)
connect(record, record.callbacks);
if (!current())
throw cancellation("input setup superseded");
} catch (error) {
try {
release(record);
@@ -193,7 +610,6 @@ function downloadSheet(file, url) {
}
return name;
}
const destroyed = /* @__PURE__ */ new WeakSet();
class ArtplayerToolThumbnail extends Emitter {
constructor(option = {}) {
super();
@@ -202,18 +618,19 @@ class ArtplayerToolThumbnail extends Emitter {
try {
this.setup(Object.assign({}, ArtplayerToolThumbnail.DEFAULTS, option));
this.video = ArtplayerToolThumbnail.creatVideo();
stateFor(this).video = this.video;
this.duration = 0;
this.inputChange = this.inputChange.bind(this);
this.ondrop = this.ondrop.bind(this);
connectInput(this, ArtplayerToolThumbnail.ondragover);
} catch (error) {
closeState(this, "construction failed");
try {
releaseInput(this);
} catch {
}
try {
if (this.video?.parentNode)
this.video.parentNode.removeChild(this.video);
releaseMedia(this);
} catch {
}
throw error;
@@ -233,15 +650,15 @@ class ArtplayerToolThumbnail extends Emitter {
event.preventDefault();
}
ondrop(event) {
if (destroyed.has(this))
if (stateFor(this).closed)
return;
event.preventDefault();
const file = event.dataTransfer.files[0];
this.loadVideo(file);
}
setup(option = {}) {
if (!destroyed.has(this))
this.option = setupInput(this, option);
if (!stateFor(this).closed)
setupInput(this, option);
return this;
}
static creatVideo() {
@@ -261,71 +678,17 @@ class ArtplayerToolThumbnail extends Emitter {
return video;
}
inputChange(event) {
if (destroyed.has(this))
if (stateFor(this).closed)
return;
const file = this.option.fileInput.files[0];
this.loadVideo(file);
event.target.value = "";
}
loadVideo(file) {
if (file && !destroyed.has(this)) {
const canPlayType = this.video.canPlayType(file.type);
this.errorHandle(
canPlayType === "maybe" || canPlayType === "probably",
`Playback of this file format is not supported: ${file.type}`
);
const videoUrl = URL.createObjectURL(file);
this.videoUrl = videoUrl;
this.file = file;
this.emit("file", this.file);
this.video.src = videoUrl;
this.emit("video", this.video);
}
loadSource(this, file);
}
start() {
if (!this.video.duration)
return sleep(1e3).then(() => this.start());
const { width, number, begin, end } = this.option;
const height = this.video.videoHeight / this.video.videoWidth * width;
this.option.height = height;
this.option.begin = clamp(begin, 0, this.video.duration);
this.option.end = clamp(end || this.video.duration, begin, this.video.duration);
this.errorHandle(this.option.end > this.option.begin, `End time must be greater than the start time`);
this.duration = this.option.end - this.option.begin;
this.density = number / this.duration;
this.errorHandle(this.file && this.video, "Please select the video file first");
this.errorHandle(!this.processing, "There is currently a task in progress, please wait a moment...");
this.errorHandle(this.density <= 1, `The preview density cannot be greater than 1, but got ${this.density}`);
const screenshotDate = this.creatScreenshotDate();
const canvas = this.creatCanvas();
const context2D = canvas.getContext("2d");
this.emit("canvas", canvas);
const promiseList = screenshotDate.map((item, index) => () => {
return new Promise((resolve) => {
this.video.oncanplay = () => {
context2D.drawImage(this.video, item.x, item.y, width, height);
canvas.toBlob((blob) => {
if (this.thumbnailUrl) {
URL.revokeObjectURL(this.thumbnailUrl);
}
this.thumbnailUrl = URL.createObjectURL(blob);
this.emit("update", this.thumbnailUrl, (index + 1) / number);
this.video.oncanplay = null;
resolve();
});
};
this.video.currentTime = item.time;
});
});
this.processing = true;
return runPromisesInSeries(promiseList).then(() => {
this.processing = false;
this.emit("done");
}).catch((err) => {
this.processing = false;
this.emit("error", err.message);
throw err;
});
return startExtraction(this);
}
creatScreenshotDate() {
return screenshotPoints(this.option, this.duration);
@@ -334,6 +697,8 @@ class ArtplayerToolThumbnail extends Emitter {
return createSheet(this.option);
}
download() {
if (stateFor(this).closed)
return this;
this.errorHandle(
this.file && this.thumbnailUrl,
"Download does not seem to be ready, please create preview first"
@@ -350,25 +715,13 @@ class ArtplayerToolThumbnail extends Emitter {
}
}
destroy() {
if (destroyed.has(this))
if (!closeState(this))
return;
destroyed.add(this);
let failure;
for (const cleanup of [
cleanupAll([
() => releaseInput(this),
() => this.video.parentNode?.removeChild(this.video),
() => this.videoUrl && URL.revokeObjectURL(this.videoUrl),
() => this.thumbnailUrl && URL.revokeObjectURL(this.thumbnailUrl),
() => releaseMedia(this),
() => this.emit("destroy")
]) {
try {
cleanup();
} catch (error) {
failure || (failure = error);
}
}
if (failure)
throw failure;
]);
}
}
export {
@@ -0,0 +1,243 @@
import { cancellation, cleanupAll, stateFor } from './lifecycle'
import { replaceThumbnail } from './source'
import { clamp } from './utils'
function prepare(tool, job) {
const { video } = job
const { width, number, begin, end } = tool.option
const height = (video.videoHeight / video.videoWidth) * width
const guard = (condition, message) => {
try {
tool.errorHandle(condition, message)
}
catch (error) {
job.reported = error
job.wasReported = true
throw error
}
}
tool.option.height = height
tool.option.begin = clamp(begin, 0, video.duration)
tool.option.end = clamp(end || video.duration, begin, video.duration)
guard(tool.option.end > tool.option.begin, 'End time must be greater than the start time')
guard(Number.isFinite(video.duration), 'Video duration must be finite')
tool.duration = tool.option.end - tool.option.begin
tool.density = number / tool.duration
guard(tool.file && video, 'Please select the video file first')
guard(!tool.processing, 'There is currently a task in progress, please wait a moment...')
guard(tool.density <= 1, `The preview density cannot be greater than 1, but got ${tool.density}`)
const points = tool.creatScreenshotDate()
const canvas = tool.creatCanvas()
const context = canvas.getContext('2d')
tool.emit('canvas', canvas)
return { width, height, number, points, canvas, context }
}
function createJob(tool, state) {
let epoch = state.epoch
const video = tool.video
let resolve
let reject
let timer
let settled = false
let running = false
let job
let frameCleanup = () => {}
const listeners = []
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
// Consumers still receive the rejecting promise; ignored cancellation is owned.
promise.catch(() => {})
const live = () => !settled && !state.closed && state.epoch === epoch && state.job === job
function finish() {
if (settled)
return false
settled = true
if (state.job === job) {
state.job = null
tool.processing = false
}
cleanupAll([() => clearTimeout(timer), frameCleanup, ...listeners.splice(0)])
return true
}
function fail(error, report = true) {
if (settled)
return
try {
finish()
}
catch (cleanupError) {
error ||= cleanupError
}
if (report) {
try {
tool.emit('error', error?.message)
}
catch (listenerError) {
error = listenerError
}
}
reject(error)
}
function complete() {
if (!live())
return
try {
finish()
tool.emit('done')
resolve()
}
catch (error) {
let failure = error
try {
tool.emit('error', error?.message)
}
catch (listenerError) {
failure = listenerError
}
reject(failure)
}
}
function extract(plan) {
if (!live())
return
running = true
tool.processing = true
let index = 0
const next = () => {
if (!live())
return
if (index === plan.points.length) {
complete()
return
}
const point = plan.points[index]
let drawing = false
let encoded = false
const previous = video.oncanplay
const draw = () => {
if (!live() || drawing || video.seeking || (video.readyState !== undefined && video.readyState < 2))
return
drawing = true
try {
frameCleanup()
plan.context.drawImage(video, point.x, point.y, plan.width, plan.height)
if (!live())
return
plan.canvas.toBlob((blob) => {
if (!live() || encoded)
return
encoded = true
try {
if (!blob)
throw new Error('Unable to create thumbnail image')
const url = replaceThumbnail(tool, blob, live)
if (!url)
return
tool.emit('update', url, (index + 1) / plan.number)
index++
Promise.resolve().then(next)
}
catch (error) {
fail(error)
}
})
}
catch (error) {
fail(error)
}
}
frameCleanup = () => {
if (video.oncanplay === draw)
video.oncanplay = previous
video.removeEventListener('seeked', draw)
}
try {
video.oncanplay = draw
video.addEventListener('seeked', draw)
video.currentTime = point.time
if (video.readyState >= 2 && !video.seeking)
Promise.resolve().then(draw)
}
catch (error) {
fail(error)
}
}
Promise.resolve().then(next)
}
function ready(synchronous = false) {
if (!live() || running)
return
clearTimeout(timer)
if (!state.loading && video.duration) {
running = true
try {
const plan = prepare(tool, job)
extract(plan)
}
catch (error) {
fail(error, !synchronous && !(job.wasReported && error === job.reported))
if (synchronous)
throw error
}
}
else {
timer = setTimeout(() => ready(), 1000)
if (!live())
clearTimeout(timer)
}
}
job = {
video,
promise,
ready,
fail,
cancel(reason) { fail(cancellation(reason), false) },
adopt(sourceEpoch) {
if (settled || running || video !== tool.video)
return false
epoch = sourceEpoch
return true
},
}
state.job = job
try {
for (const [name, callback] of [
['error', () => {
if (live())
fail(new Error(`Unable to load video: media error ${video.error?.code || 0}`))
}],
['loadedmetadata', () => ready()],
['durationchange', () => ready()],
]) {
listeners.push(() => video.removeEventListener(name, callback))
video.addEventListener(name, callback)
if (!live()) {
cleanupAll([() => video.removeEventListener(name, callback), ...listeners.splice(0)])
return promise
}
}
if (video.error)
fail(new Error(`Unable to load video: media error ${video.error.code}`))
else
ready(true)
}
catch (error) {
fail(error, false)
throw error
}
return promise
}
export function startExtraction(tool) {
const state = stateFor(tool)
if (state.closed) {
const promise = Promise.reject(cancellation('destroyed'))
promise.catch(() => {})
return promise
}
tool.errorHandle(!state.job, 'There is currently a task in progress, please wait a moment...')
return createJob(tool, state)
}
+18 -82
View File
@@ -1,9 +1,9 @@
import Emitter from './emitter'
import { startExtraction } from './extraction'
import { connectInput, releaseInput, setupInput } from './input'
import { cleanupAll, closeState, releaseMedia, stateFor } from './lifecycle'
import { createSheet, downloadSheet, screenshotPoints } from './sheet'
import { clamp, runPromisesInSeries, sleep } from './utils'
const destroyed = new WeakSet()
import { loadSource } from './source'
export default class ArtplayerToolThumbnail extends Emitter {
constructor(option = {}) {
@@ -13,19 +13,20 @@ export default class ArtplayerToolThumbnail extends Emitter {
try {
this.setup(Object.assign({}, ArtplayerToolThumbnail.DEFAULTS, option))
this.video = ArtplayerToolThumbnail.creatVideo()
stateFor(this).video = this.video
this.duration = 0
this.inputChange = this.inputChange.bind(this)
this.ondrop = this.ondrop.bind(this)
connectInput(this, ArtplayerToolThumbnail.ondragover)
}
catch (error) {
closeState(this, 'construction failed')
try {
releaseInput(this)
}
catch {}
try {
if (this.video?.parentNode)
this.video.parentNode.removeChild(this.video)
releaseMedia(this)
}
catch {}
throw error
@@ -48,7 +49,7 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
ondrop(event) {
if (destroyed.has(this))
if (stateFor(this).closed)
return
event.preventDefault()
const file = event.dataTransfer.files[0]
@@ -56,8 +57,8 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
setup(option = {}) {
if (!destroyed.has(this))
this.option = setupInput(this, option)
if (!stateFor(this).closed)
setupInput(this, option)
return this
}
@@ -80,7 +81,7 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
inputChange(event) {
if (destroyed.has(this))
if (stateFor(this).closed)
return
const file = this.option.fileInput.files[0]
this.loadVideo(file)
@@ -88,67 +89,11 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
loadVideo(file) {
if (file && !destroyed.has(this)) {
const canPlayType = this.video.canPlayType(file.type)
this.errorHandle(
canPlayType === 'maybe' || canPlayType === 'probably',
`Playback of this file format is not supported: ${file.type}`,
)
const videoUrl = URL.createObjectURL(file)
this.videoUrl = videoUrl
this.file = file
this.emit('file', this.file)
this.video.src = videoUrl
this.emit('video', this.video)
}
loadSource(this, file)
}
start() {
if (!this.video.duration)
return sleep(1000).then(() => this.start())
const { width, number, begin, end } = this.option
const height = (this.video.videoHeight / this.video.videoWidth) * width
this.option.height = height
this.option.begin = clamp(begin, 0, this.video.duration)
this.option.end = clamp(end || this.video.duration, begin, this.video.duration)
this.errorHandle(this.option.end > this.option.begin, `End time must be greater than the start time`)
this.duration = this.option.end - this.option.begin
this.density = number / this.duration
this.errorHandle(this.file && this.video, 'Please select the video file first')
this.errorHandle(!this.processing, 'There is currently a task in progress, please wait a moment...')
this.errorHandle(this.density <= 1, `The preview density cannot be greater than 1, but got ${this.density}`)
const screenshotDate = this.creatScreenshotDate()
const canvas = this.creatCanvas()
const context2D = canvas.getContext('2d')
this.emit('canvas', canvas)
const promiseList = screenshotDate.map((item, index) => () => {
return new Promise((resolve) => {
this.video.oncanplay = () => {
context2D.drawImage(this.video, item.x, item.y, width, height)
canvas.toBlob((blob) => {
if (this.thumbnailUrl) {
URL.revokeObjectURL(this.thumbnailUrl)
}
this.thumbnailUrl = URL.createObjectURL(blob)
this.emit('update', this.thumbnailUrl, (index + 1) / number)
this.video.oncanplay = null
resolve()
})
}
this.video.currentTime = item.time
})
})
this.processing = true
return runPromisesInSeries(promiseList)
.then(() => {
this.processing = false
this.emit('done')
})
.catch((err) => {
this.processing = false
this.emit('error', err.message)
throw err
})
return startExtraction(this)
}
creatScreenshotDate() {
@@ -160,6 +105,8 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
download() {
if (stateFor(this).closed)
return this
this.errorHandle(
this.file && this.thumbnailUrl,
'Download does not seem to be ready, please create preview first',
@@ -178,23 +125,12 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
destroy() {
if (destroyed.has(this))
if (!closeState(this))
return
destroyed.add(this)
let failure
for (const cleanup of [
cleanupAll([
() => releaseInput(this),
() => this.video.parentNode?.removeChild(this.video),
() => this.videoUrl && URL.revokeObjectURL(this.videoUrl),
() => this.thumbnailUrl && URL.revokeObjectURL(this.thumbnailUrl),
() => releaseMedia(this),
() => this.emit('destroy'),
]) {
try {
cleanup()
}
catch (error) { failure ||= error }
}
if (failure)
throw failure
])
}
}
@@ -1,3 +1,4 @@
import { cancellation, stateFor } from './lifecycle'
import { clamp } from './utils'
const inputs = new WeakMap()
@@ -44,6 +45,9 @@ function connect(record, callbacks) {
}
export function setupInput(tool, patch) {
const state = stateFor(tool)
const epoch = ++state.inputEpoch
const current = () => !state.closed && state.inputEpoch === epoch
const option = Object.assign({}, tool.option, patch)
const target = option.fileInput
tool.errorHandle(target instanceof Element, 'The \'fileInput\' is not a Element')
@@ -52,10 +56,13 @@ export function setupInput(tool, patch) {
option.number = clamp(option.number, 10, 1000)
option.width = clamp(option.width, 10, 1000)
option.column = clamp(option.column, 1, 1000)
if (!current())
throw cancellation('input setup superseded')
const previous = inputs.get(tool)
if (previous && (target === previous.input || target === previous.wrapper)) {
option.fileInput = previous.input
tool.option = option
return option
}
const record = { input: target, wrapper: null, position: '', listeners: [], callbacks: previous?.callbacks || null }
@@ -69,8 +76,12 @@ export function setupInput(tool, patch) {
target.style.position = 'relative'
target.appendChild(record.input)
}
if (!current())
throw cancellation('input setup superseded')
if (record.callbacks)
connect(record, record.callbacks)
if (!current())
throw cancellation('input setup superseded')
}
catch (error) {
try {
@@ -0,0 +1,80 @@
const states = new WeakMap()
export function stateFor(tool) {
if (!states.has(tool)) {
states.set(tool, {
closed: false,
epoch: 0,
inputEpoch: 0,
job: null,
video: null,
sourceUrl: null,
thumbnailUrl: null,
sourceUrls: new Set(),
thumbnailUrls: new Set(),
sourceListeners: [],
loading: false,
})
}
return states.get(tool)
}
export function cancellation(reason) {
const error = new Error(`Thumbnail task cancelled: ${reason}`)
error.name = 'AbortError'
return error
}
export function revoke(urls, url) {
if (!url)
return
urls.delete(url)
URL.revokeObjectURL(url)
}
export function cleanupAll(callbacks) {
let failure
for (const callback of callbacks) {
try {
callback()
}
catch (error) {
failure ||= error
}
}
if (failure)
throw failure
}
export function closeState(tool, reason = 'destroyed') {
const state = stateFor(tool)
if (state.closed)
return false
state.closed = true
state.epoch++
state.job?.cancel(reason)
return true
}
export function releaseMedia(tool) {
const state = stateFor(tool)
const sources = new Set([...state.sourceUrls, tool.videoUrl])
const thumbnails = new Set([...state.thumbnailUrls, tool.thumbnailUrl])
const media = []
for (const video of new Set([state.video, tool.video])) {
if (video) {
media.push(
() => video.pause?.(),
() => video.removeAttribute?.('src'),
() => video.load?.(),
() => video.parentNode?.removeChild(video),
)
}
}
cleanupAll([
...state.sourceListeners.splice(0),
...media,
...[...sources].map(url => () => revoke(state.sourceUrls, url)),
...[...thumbnails].map(url => () => revoke(state.thumbnailUrls, url)),
])
}
@@ -0,0 +1,113 @@
import { cleanupAll, revoke, stateFor } from './lifecycle'
export function loadSource(tool, file) {
const state = stateFor(tool)
if (!file || state.closed)
return
const initialEpoch = state.epoch
const video = tool.video
const support = video.canPlayType(file.type)
tool.errorHandle(support === 'maybe' || support === 'probably', `Playback of this file format is not supported: ${file.type}`)
if (state.closed || state.epoch !== initialEpoch)
return
const url = URL.createObjectURL(file)
state.sourceUrls.add(url)
if (state.closed || state.epoch !== initialEpoch) {
revoke(state.sourceUrls, url)
return
}
const epoch = ++state.epoch
const initialWait = !state.sourceUrl && !tool.file && state.job?.adopt(epoch)
if (!initialWait)
state.job?.cancel('source changed')
state.loading = true
const current = () => !state.closed && state.epoch === epoch
const previousListeners = state.sourceListeners
const listeners = []
state.sourceListeners = listeners
let pendingCleanup = () => {}
let reported = false
const error = () => {
if (!current() || reported || (video.currentSrc && video.currentSrc !== url))
return
reported = true
state.loading = false
const failure = new Error(`Unable to load video: media error ${video.error?.code || 0}`)
if (state.job)
state.job.fail(failure)
else
tool.emit('error', failure.message)
}
const ready = () => {
if (current() && (!video.currentSrc || video.currentSrc === url)) {
state.loading = false
state.job?.ready()
}
}
try {
cleanupAll(previousListeners.splice(0))
if (!current()) {
revoke(state.sourceUrls, url)
return
}
for (const [name, callback] of [['error', error], ['loadedmetadata', ready]]) {
pendingCleanup = () => video.removeEventListener(name, callback)
listeners.push(pendingCleanup)
video.addEventListener(name, callback)
if (!current()) {
cleanupAll([pendingCleanup, ...listeners.splice(0)])
revoke(state.sourceUrls, url)
return
}
}
tool.videoUrl = url
tool.file = file
tool.emit('file', file)
if (!current()) {
revoke(state.sourceUrls, url)
return
}
video.src = url
if (!current()) {
revoke(state.sourceUrls, url)
return
}
state.sourceUrl = url
for (const previous of [...state.sourceUrls]) {
if (previous !== url)
revoke(state.sourceUrls, previous)
}
if (current())
tool.emit('video', video)
}
catch (error) {
if (current()) {
state.loading = false
state.job?.cancel('source setup failed')
}
try {
cleanupAll([pendingCleanup, ...listeners.splice(0)])
}
catch {}
revoke(state.sourceUrls, url)
throw error
}
}
export function replaceThumbnail(tool, blob, live) {
const state = stateFor(tool)
const url = URL.createObjectURL(blob)
state.thumbnailUrls.add(url)
if (!live()) {
revoke(state.thumbnailUrls, url)
return null
}
const previous = new Set([...state.thumbnailUrls, tool.thumbnailUrl])
state.thumbnailUrl = url
tool.thumbnailUrl = url
for (const old of previous) {
if (old && old !== url)
revoke(state.thumbnailUrls, old)
}
return live() ? url : null
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,88 @@
# PKG-TOOL-THUMB-03 owned extraction and source lifecycle
Started from 426c0c5d4209aa91be7414b810f1e8dca712c297, following the input/sheet
checkpoint. This completes the runtime responsibility split for 03; strict TS,
emitter provenance and public declarations remain in 04. Existing release/core
integration and Windows WebKit/Safari evidence gates remain in 05/06.
## Implementation and observable behavior
The public class delegates to input, sheet, source, extraction and lifecycle
modules. Private WeakMap state owns generations, listeners, jobs and generated
URLs without adding public instance fields. Input setup also guards reentrant
replacement/destruction. Construction failure marks the instance closed and
releases its partial media resources.
Source loading keeps synchronous file-before-src-before-video ordering. New
successful selections revoke superseded source URLs and cancel the old extraction.
Nested file callbacks and URL-creation hooks cannot overwrite newer selections.
Native metadata/error listeners belong to their source generation. Errors now
report through the tool and settle a waiting/running job; stale callbacks do not
affect the latest source.
Each extraction has one promise, owned metadata timer/listeners and per-frame
readiness cleanup. It preserves preflight throws, midpoint/grid/footer geometry,
normal progress/done order, undefined completion and the previous oncanplay owner.
A start before the first selected file retains its wait and adopts that first
selection. A replacement of an existing source cancels old work. Existing sheet
URLs remain available until replaced by a new frame or destruction.
Destruction/replacement rejects cancelled work with AbortError and does not emit
error for intentional cancellation. An internal rejection handler prevents ignored
cancelled jobs from becoming orphan rejections; the returned promise still rejects
for consumers. Native canvas encoding itself has no cancellation operation here:
the job settles immediately and its eventual callback is ignored. No new URL,
update/done event or next frame may be published by that callback.
Null Blob, seek/draw/toBlob failures and throwing update/error/done callbacks settle
the promise after cleanup. Duplicate readiness/Blob callbacks cannot double-publish
a frame. Completion releases ownership before done so a callback can start another
job. Reentrant listener/timer installation or URL creation is checked after host
calls, including handles registered only after destruction returned.
Destroy now pauses the native video, removes its src, calls load to reset media
state and removes the node. Cleanup continues after individual failures. Private
URLs are released even if callers changed public URL properties; historical cleanup
of current public URL values is retained. Closed start returns a rejecting promise;
closed download retains chaining and allocates no anchor.
Default height and video-event timing remain the workspace 4.4.0 behavior. This
does not resolve the pre-existing recovered 3.5.31 default differences documented
in thumbnail-contract.md. No silent default-mode or legacy-CSS decision is made.
## Verification
26 new lifecycle cases plus the previous 14 candidate input cases run on source,
main and legacy. Frozen workspace running the new lifecycle assertions has 2
passes and 24 failures. Normal extraction, cancellation/error paths, native media
errors, late/duplicate callbacks, nested loads/setup, first-selection waiting,
public URL mutation and decoder-reset failure handling are covered.
Each final browser run has 57 rows: 34 real tool scenarios in Chromium/Firefox,
3 candidate input/DOM cases across all engines, 17 Windows WebKit Blob-unavailable
controls and 3 independent native-media comparisons. Candidate cases now require
error notification, replaced-source URL cleanup and rejected/ignored late encoding;
historical cases still assert their old defects. A real file is replaced while a
native PNG callback is held; only the new source produces ten updates and one done.
Successful extraction also verifies retired video readyState=0 and no src attribute.
The 17 unavailable controls are not successful file extraction.
Earlier passing source/main probe reports remain archived; final media-reset
source/main/legacy results and hashes are in thumbnail-lifecycle-validation.json.
Normal builds regenerate UMD, legacy and ESM plus byte-identical docs copies. The
new lifecycle file is included in test:thumbnail and the normal unit/CI script.
The coverage policy still measures core/chapter only; no tool percentage is claimed.
## Completion and continuation
03 completes the runtime split and the reproduced ownership fixes. The package
ARCHITECTURE.md describes actual dependencies, resources, ordering, tests and
limits. 04 owns strict TS, the now-unused sleep/serial utility cleanup, emitter
attribution, installed public declarations and the explicit historical default
compatibility policy. 05 owns complete old/new-core sheet integration and actual
Safari/WebKit Blob extraction; 06 owns promised ESM/type paths, README demo and
candidate tarball validation. Risks stay open until those gates are met.
Rollback by reverting this dedicated completion commit and rebuilding the package;
the preceding input checkpoint and all frozen historical tests remain available.
No new dependencies, version bump, push, tag, hosted CI run or npm publication.
+3 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 100 / doing 11 / blocked 0 / done 111 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 100 / doing 10 / blocked 0 / done 112 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -357,7 +357,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-TOOL-THUMB-01 | artplayer-tool-thumbnail<br>核对包契约与历史用法 | BASE-05 | 构造/defaults/事件/方法/历史拼写、.esm.js 入口和缺失 types | 源码/声明/README/demo/可恢复发布内容差异已登记;公开形状和版本范围冻结;区分旧 npm 的 main/CSS 与当前工作区无 main、.esm.js/类型缺失,不把工作区状态冒称旧 npm 发布事实 | H | done |
| PKG-TOOL-THUMB-02 | artplayer-tool-thumbnail<br>建立特有行为与错误测试 | PKG-TOOL-THUMB-01, ENG-03, ENG-05 | 文件输入/拖放、抽帧网格、begin/end、进度/失败/重复任务 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-TOOL-THUMB-03 | artplayer-tool-thumbnail<br>整理内部职责与资源 | PKG-TOOL-THUMB-02, CORE-01 | 输入、抽帧队列、网格导出、URL/监听清理分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | doing |
| PKG-TOOL-THUMB-03 | artplayer-tool-thumbnail<br>整理内部职责与资源 | PKG-TOOL-THUMB-02, CORE-01 | 输入、抽帧队列、网格导出、URL/监听清理分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-TOOL-THUMB-04 | artplayer-tool-thumbnail<br>迁移自有源码和公开类型 | PKG-TOOL-THUMB-03, ENG-04, ENG-06, CORE-07 | 补齐真实 API 声明,自有 emitter/utils TS 化 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;为 BASE-DIST-01 提供兼容入口/声明及消费者验证 | H | todo |
| PKG-TOOL-THUMB-05 | artplayer-tool-thumbnail<br>验证新旧核心和组合 | PKG-TOOL-THUMB-04, CORE-22 | 工具独立浏览器使用,生成缩略图在核心中显示 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-TOOL-THUMB-06 | artplayer-tool-thumbnail<br>验证分发并同步文档 | PKG-TOOL-THUMB-05, ENG-07 | tool.thumbnail.js、历史 ESM 兼容文件、types 路径和 tarball | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
@@ -537,5 +537,5 @@
- PKG-IFRAME-05: [记录](changes/2026-09-13-PKG-IFRAME-05-integration.md) [记录](baselines/iframe-integration-validation.json) [记录](changes/2026-09-13-PKG-IFRAME-05-history.md) [记录](baselines/iframe-history-validation.json)
- PKG-TOOL-THUMB-01: [记录](baselines/thumbnail-release.json) [记录](baselines/thumbnail-contract.md) [记录](changes/2026-09-13-PKG-TOOL-THUMB-01-contract.md) [记录](baselines/thumbnail-contract-validation.json)
- PKG-TOOL-THUMB-02: [记录](baselines/thumbnail-behavior-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md)
- PKG-TOOL-THUMB-03: [记录](changes/2026-09-13-PKG-TOOL-THUMB-03-input-checkpoint.md) [记录](baselines/thumbnail-input-checkpoint.json)
- PKG-TOOL-THUMB-03: [记录](changes/2026-09-13-PKG-TOOL-THUMB-03-input-checkpoint.md) [记录](baselines/thumbnail-input-checkpoint.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-03-lifecycle.md) [记录](baselines/thumbnail-lifecycle-validation.json)
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md)
+14
View File
@@ -1,5 +1,19 @@
# 进度与证据
## PKG-TOOL-THUMB-03 完成:抽帧任务与媒体资源所有权
增加lifecycle/source/extraction模块,保留公开类形状与工作区默认行为。元数据等待、
逐帧回调和URL归属明确;切源/destroy立即取消旧Promise,迟到PNG不能再update或创建URL。
修复原生错误、null Blob、监听器异常和重入;保留首次选文件前start等待。销毁暂停/清空
src/load重置媒体,即使部分清理抛错也继续。源码仍为JS,04负责严格TS/声明与来源审查。
新26项生命周期断言在冻结旧版2通过/24失败;连同输入测试,源码/main/legacy各40通过。
三份浏览器各57通过:34真实工具场景、3输入场景、17Blob不可用对照、3原生媒体对照。
实际切源期间持有原生PNG回调验证仅新任务完成,销毁后readyState=0且无src。
完整CI1646、330生产TS、3导入及候选ESM导入通过。类型/默认历史差异及Safari/分发风险
仍open。见[变更](changes/2026-09-13-PKG-TOOL-THUMB-03-lifecycle.md)与
[验证](baselines/thumbnail-lifecycle-validation.json)。222项:112 done、10 doing、100 todo。
## PKG-TOOL-THUMB-03 输入与导出源码检查点(doing)
生产入口拆出input/sheet模块,修复drop注册、输入切换与wrapper所有权、构造/安装失败
+1 -1
View File
@@ -177,7 +177,7 @@
| IFRAME-LIFE-01 | open / 已复现 | Iframe timestamp IDs and unowned pending requests/timers lack terminal cleanup | PKG-IFRAME-02, PKG-IFRAME-03, PKG-IFRAME-05 |
| IFRAME-TRUST-01 | open / 已复现 | Iframe executable commit protocol accepts messages without source/origin validation | PKG-IFRAME-03, PKG-IFRAME-05 |
| IFRAME-DIST-01 | open / 已复现 | Iframe tool name is unavailable at registry observation and old archive includes distinct helper exports | PKG-IFRAME-04, PKG-IFRAME-06 |
| THUMB-COMPAT-01 | open / 已复现 | Thumbnail recovered 3.5.31 and workspace 4.4.0 have conflicting height and event timing defaults | PKG-TOOL-THUMB-02, PKG-TOOL-THUMB-03, PKG-TOOL-THUMB-04 |
| THUMB-COMPAT-01 | open / 已复现 | Thumbnail recovered 3.5.31 and workspace 4.4.0 have conflicting height and event timing defaults | PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-05 |
| THUMB-LIFE-01 | open / 已复现 | Thumbnail drop binding and repeated destroy fail; pending work and replacement resource ownership need repair | PKG-TOOL-THUMB-02, PKG-TOOL-THUMB-03, PKG-TOOL-THUMB-05 |
| THUMB-PROVENANCE-01 | open / 源码/产物事实 | Thumbnail local emitter attribution and historical distribution completeness remain unverified | PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-06 |
| THUMB-MEDIA-01 | open / 已复现 | Windows WebKit native Blob URLs cannot load tested MP4 media despite successful HTTP controls | PKG-TOOL-THUMB-05, PKG-TOOL-THUMB-06 |
+9 -6
View File
@@ -3853,9 +3853,8 @@
"title": "Thumbnail recovered 3.5.31 and workspace 4.4.0 have conflicting height and event timing defaults",
"confirmation": "reproduced",
"owners": [
"PKG-TOOL-THUMB-02",
"PKG-TOOL-THUMB-03",
"PKG-TOOL-THUMB-04"
"PKG-TOOL-THUMB-04",
"PKG-TOOL-THUMB-05"
],
"compatibleResolution": "Freeze both implementations and derive a concrete compatibility policy before changing defaults; no silent claim that identical calls have identical historical semantics.",
"closureCriteria": "Explicit documented compatibility decision, old/candidate tests and migration examples covering delay, height, input reset and readiness.",
@@ -3867,7 +3866,8 @@
"refactor/baselines/thumbnail-behavior-validation.json",
"refactor/changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md",
"test/thumbnail.test.js",
"test/browser/thumbnail-tool.spec.js"
"test/browser/thumbnail-tool.spec.js",
"refactor/changes/2026-09-13-PKG-TOOL-THUMB-03-lifecycle.md"
]
},
{
@@ -3879,7 +3879,7 @@
"PKG-TOOL-THUMB-03",
"PKG-TOOL-THUMB-05"
],
"compatibleResolution": "03 input checkpoint fixes registered drop, input/wrapper ownership, failed setup/construction cleanup, repeated destroy and temporary download anchors. Candidate input regressions contrast 12 old failures; source/main/legacy verify preserved shape. Pending extraction callbacks, source URL replacement, cancellation and failure reentrancy remain open for the rest of 03 and real integration in 05.",
"compatibleResolution": "03 splits input/sheet/source/extraction/lifecycle and fixes reproduced cancellation, Blob URL ownership, native errors, callback failure/reentrancy and decoder reset. Keep risk open for 04 strict types and 05 complete core/Safari integration; Windows WebKit Blob-unavailable controls do not prove extraction.",
"closureCriteria": "Deterministic old failure/candidate success for replacement, error and destruction plus real media/browser resource validation.",
"status": "open",
"evidence": [
@@ -3894,7 +3894,10 @@
"packages/artplayer-tool-thumbnail/ARCHITECTURE.md",
"test/thumbnail-input.test.js",
"test/browser/thumbnail-input.spec.js",
"refactor/baselines/thumbnail-input-checkpoint.json"
"refactor/baselines/thumbnail-input-checkpoint.json",
"refactor/changes/2026-09-13-PKG-TOOL-THUMB-03-lifecycle.md",
"test/thumbnail-lifecycle.test.js",
"refactor/baselines/thumbnail-lifecycle-validation.json"
]
},
{
+4 -2
View File
@@ -3785,13 +3785,15 @@
"PKG-TOOL-THUMB-02",
"CORE-01"
],
"status": "doing",
"status": "done",
"risk": "H",
"deliverable": "输入、抽帧队列、网格导出、URL/监听清理分离",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
"evidence": [
"changes/2026-09-13-PKG-TOOL-THUMB-03-input-checkpoint.md",
"baselines/thumbnail-input-checkpoint.json"
"baselines/thumbnail-input-checkpoint.json",
"changes/2026-09-13-PKG-TOOL-THUMB-03-lifecycle.md",
"baselines/thumbnail-lifecycle-validation.json"
]
},
{
+78 -16
View File
@@ -8,7 +8,7 @@ import { expect, test } from './fixtures.js'
const sample = fileURLToPath(new URL('./media/thumbnail-pattern.mp4', import.meta.url))
async function capabilityControl(page, testInfo) {
async function capabilityControl(page, testInfo, lifecycle) {
const native = await page.evaluate(() => window.thumbnailNative)
if (native.blob === 'loaded')
return false
@@ -21,13 +21,13 @@ async function capabilityControl(page, testInfo) {
tool.destroy()
return state
})
expect(state).toEqual({ errors: 0, processing: false, duration: null })
expect(state).toEqual({ errors: lifecycle ? 1 : 0, processing: false, duration: null })
testInfo.annotations.push({ type: 'capability-control', description: 'Native HTTP succeeds but native Blob and tool Blob return media error 4. Extraction scenario unavailable; no successful extraction claimed.' })
await testInfo.attach('thumbnail-blob-unavailable', { contentType: 'application/json', body: JSON.stringify({ native, tool: state }) })
return true
}
const implementations = [...thumbnailHistorical(), { ...await thumbnailCandidate(), inputOwnership: true }]
const implementations = [...thumbnailHistorical(), { ...await thumbnailCandidate(), inputOwnership: true, lifecycle: true }]
for (const implementation of implementations) {
test.describe(`Thumbnail tool ${implementation.name}`, () => {
test.beforeEach(async ({ page }, testInfo) => {
@@ -85,7 +85,7 @@ for (const implementation of implementations) {
test('native file selection, seek extraction, PNG pixels and repeat generation', async ({ page }, testInfo) => {
test.setTimeout(60000)
if (await capabilityControl(page, testInfo))
if (await capabilityControl(page, testInfo, implementation.lifecycle))
return
await page.locator('#input').setInputFiles(sample)
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
@@ -111,8 +111,10 @@ for (const implementation of implementations) {
const initialEvents = observed.slice()
await tool.start()
const second = tool.thumbnailUrl
const sourceWidth = tool.video.videoWidth
const sourceHeight = tool.video.videoHeight
tool.destroy()
return { width: image.width, height: image.height, sourceWidth: tool.video.videoWidth, sourceHeight: tool.video.videoHeight, cellHeight: tool.option.height, mime: blob.type, bytes: blob.size, nonBlack, initialEvents, events: observed, first, second, created, revoked, connected: tool.video.isConnected }
return { width: image.width, height: image.height, sourceWidth, sourceHeight, cellHeight: tool.option.height, mime: blob.type, bytes: blob.size, nonBlack, initialEvents, events: observed, first, second, created, revoked, connected: tool.video.isConnected, retiredReadyState: tool.video.readyState, retiredSrc: tool.video.getAttribute('src') }
})
expect(result.width).toBe(240)
expect(result.cellHeight).toBe(implementation.legacy ? 30 : result.sourceHeight / result.sourceWidth * 80)
@@ -125,11 +127,15 @@ for (const implementation of implementations) {
expect(result.first).not.toBe(result.second)
expect([...result.created].sort()).toEqual([...result.revoked].sort())
expect(result.connected).toBe(false)
if (implementation.lifecycle) {
expect(result.retiredReadyState).toBe(0)
expect(result.retiredSrc).toBe(null)
}
await testInfo.attach('thumbnail-extraction', { contentType: 'application/json', body: JSON.stringify(result) })
})
test(`DOM drop dispatch ${implementation.inputOwnership ? 'uses the registered handler' : 'exposes missing listener'} while callable ondrop works`, async ({ page }, testInfo) => {
if (await capabilityControl(page, testInfo))
if (await capabilityControl(page, testInfo, implementation.lifecycle))
return
const result = await page.evaluate(async () => {
const bytes = await (await fetch('/test/thumbnail-pattern.mp4')).blob()
@@ -149,12 +155,12 @@ for (const implementation of implementations) {
})
test('corrupt media, replacement URLs and repeated destroy reproduce baseline gaps', async ({ page }, testInfo) => {
if (await capabilityControl(page, testInfo))
if (await capabilityControl(page, testInfo, implementation.lifecycle))
return
await page.locator('#input').setInputFiles({ name: 'corrupt.mp4', mimeType: 'video/mp4', buffer: Buffer.from('not a media container') })
await expect.poll(() => page.evaluate(() => tool.video.error?.code || 0)).toBeGreaterThan(0)
const failed = await page.evaluate(() => ({ code: tool.video.error.code, toolErrors: observed.filter(item => item.event === 'error').length, first: tool.videoUrl }))
expect(failed.toolErrors).toBe(0)
expect(failed.toolErrors).toBe(implementation.lifecycle ? 1 : 0)
await page.locator('#input').setInputFiles(sample)
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
const result = await page.evaluate(() => {
@@ -168,15 +174,15 @@ for (const implementation of implementations) {
return { value, created, revoked, repeated }
})
expect(result.created).toHaveLength(2)
expect(result.revoked).toEqual([result.created[1]])
expect(result.revoked).not.toContain(failed.first)
expect(result.revoked).toEqual(implementation.lifecycle ? result.created : [result.created[1]])
expect(result.revoked.includes(failed.first)).toBe(!!implementation.lifecycle)
expect(result.repeated).toBe(implementation.inputOwnership ? undefined : 'NotFoundError')
expect(result.value === '').toBe(!implementation.legacy)
await testInfo.attach('thumbnail-media-failure', { contentType: 'application/json', body: JSON.stringify({ failed, result }) })
})
test('native PNG callback delivered after destroy still emits update and creates a URL', async ({ page }, testInfo) => {
if (await capabilityControl(page, testInfo))
test(`native PNG callback delivered after destroy ${implementation.lifecycle ? 'is ignored after cancellation' : 'still emits update and creates a URL'}`, async ({ page }, testInfo) => {
if (await capabilityControl(page, testInfo, implementation.lifecycle))
return
await page.locator('#input').setInputFiles(sample)
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
@@ -204,13 +210,69 @@ for (const implementation of implementations) {
return { count, created, revoked, pendingState, events: observed.map(item => item.event), connected: tool.video.isConnected, bytes: nativeBlob.size }
})
expect(result.bytes).toBeGreaterThan(100)
expect(result.created).toHaveLength(result.count + 1)
expect(result.revoked).not.toContain(result.created.at(-1))
expect(result.events.indexOf('update')).toBeGreaterThan(result.events.indexOf('destroy'))
expect(result.pendingState).toBe('pending')
expect(result.created).toHaveLength(result.count + (implementation.lifecycle ? 0 : 1))
if (implementation.lifecycle) {
expect(result.revoked).toContain(result.created.at(-1))
expect(result.events).not.toContain('update')
}
else {
expect(result.revoked).not.toContain(result.created.at(-1))
expect(result.events.indexOf('update')).toBeGreaterThan(result.events.indexOf('destroy'))
}
expect(result.pendingState).toBe(implementation.lifecycle ? 'rejected' : 'pending')
expect(result.connected).toBe(false)
await testInfo.attach('thumbnail-late-native-blob', { contentType: 'application/json', body: JSON.stringify(result) })
})
if (implementation.lifecycle) {
test('source replacement cancels held native encoding and completes only the latest job', async ({ page }, testInfo) => {
if (await capabilityControl(page, testInfo, true))
return
await page.locator('#input').setInputFiles(sample)
await expect.poll(() => page.evaluate(() => tool.video.readyState)).toBeGreaterThanOrEqual(2)
await page.evaluate(() => {
const original = HTMLCanvasElement.prototype.toBlob
let first = true
HTMLCanvasElement.prototype.toBlob = function (callback, ...args) {
if (first) {
first = false
original.call(this, (blob) => {
window.firstEncodedBytes = blob.size
window.releaseFirst = () => callback(blob)
}, ...args)
}
else {
original.call(this, callback, ...args)
}
}
window.oldState = 'pending'
window.updateSources = []
tool.on('update', () => window.updateSources.push(tool.videoUrl))
tool.start().then(() => {
window.oldState = 'resolved'
}, (error) => {
window.oldState = error.name
})
})
await expect.poll(() => page.evaluate(() => typeof window.releaseFirst)).toBe('function')
await page.locator('#input').setInputFiles(sample)
const result = await page.evaluate(async () => {
const source = tool.videoUrl
const next = tool.start()
window.releaseFirst()
await next
tool.destroy()
return { oldState: window.oldState, firstEncodedBytes: window.firstEncodedBytes, source, updateSources: window.updateSources, created, revoked, events: observed.map(item => item.event), connected: tool.video.isConnected }
})
expect(result.oldState).toBe('AbortError')
expect(result.firstEncodedBytes).toBeGreaterThan(100)
expect(result.updateSources).toEqual(Array.from({ length: 10 }).fill(result.source))
expect(result.events.filter(event => event === 'done')).toHaveLength(1)
expect([...result.created].sort()).toEqual([...result.revoked].sort())
expect(result.connected).toBe(false)
await testInfo.attach('thumbnail-native-source-cancellation', { contentType: 'application/json', body: JSON.stringify(result) })
})
}
})
}
/* global created, revoked, ArtplayerToolThumbnail, tool, observed, releaseBlob, pendingState, nativeBlob */
+439
View File
@@ -0,0 +1,439 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate regressions also run against frozen workspace main.
import test from 'node:test'
import { thumbnailCandidate, thumbnailEnvironment } from './helpers/thumbnail.js'
const implementation = await thumbnailCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
function dispatch(video, name) {
for (const callback of [...(video.handlers.get(name) || [])])
callback({ target: video, type: name })
}
function observe(promise) {
const state = { status: 'pending' }
promise.then(value => Object.assign(state, { status: 'resolved', value }), error => Object.assign(state, { status: 'rejected', error }))
return state
}
function setup(ready = true) {
const env = thumbnailEnvironment(implementation)
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10, width: 40, column: 3 })
env.instance.loadVideo({ name: 'first.mp4', type: 'video/mp4' })
if (ready) {
env.instance.video.duration = 100
dispatch(env.instance.video, 'loadedmetadata')
}
return env
}
async function firstFrame(env) {
await flush()
assert.equal(typeof env.instance.video.oncanplay, 'function')
env.instance.video.oncanplay()
await flush()
}
async function complete(env, state, count = 10) {
for (let index = 0; index < count; index++)
await firstFrame(env)
assert.equal(state.status, 'resolved')
assert.equal(state.value, undefined)
}
function cancelled(state) {
assert.equal(state.status, 'rejected')
assert.equal(state.error.name, 'AbortError')
}
test('Thumbnail owned extraction preserves progress, event order, repeat runs and prior oncanplay', async () => {
const env = setup()
const previous = () => {}
env.instance.video.oncanplay = previous
const events = []
for (const name of ['canvas', 'update', 'done'])
env.instance.on(name, (value, progress) => events.push([name, progress, env.instance.processing]))
const state = observe(env.instance.start())
assert.deepEqual(events[0], ['canvas', undefined, false])
await complete(env, state)
assert.deepEqual(events.filter(item => item[0] === 'update').map(item => item.slice(1)), Array.from({ length: 10 }, (_, i) => [(i + 1) / 10, true]))
assert.deepEqual(events.at(-1), ['done', undefined, false])
assert.equal(env.instance.video.oncanplay, previous)
assert.equal(env.instance.video.handlers.get('seeked')?.size || 0, 0)
const second = observe(env.instance.start())
await complete(env, second)
env.instance.destroy()
assert.deepEqual(env.operations.filter(item => item.name === 'createURL').map(item => item.url).sort(), env.operations.filter(item => item.name === 'revokeURL').map(item => item.url).sort())
})
test('Thumbnail cancellation owns both metadata polling and a not-yet-started frame', async () => {
for (const ready of [false, true]) {
const env = setup(ready)
const state = observe(env.instance.start())
env.instance.destroy()
await flush()
cancelled(state)
assert.equal(env.timers.size, 0)
assert.equal(env.instance.processing, false)
assert.equal(env.operations.filter(item => item.name === 'drawImage').length, 0)
for (const callbacks of env.instance.video.handlers.values())
assert.equal(callbacks.size, 0)
}
})
test('Thumbnail metadata event resumes extraction and duplicate pending start is rejected', async () => {
const env = setup(false)
const state = observe(env.instance.start())
assert.throws(() => env.instance.start(), /task in progress/)
env.instance.video.duration = 100
dispatch(env.instance.video, 'loadedmetadata')
assert.equal(env.timers.size, 0)
await complete(env, state)
env.instance.destroy()
})
test('Thumbnail native media errors settle waiting and active jobs once', async () => {
for (const ready of [false, true]) {
const env = setup(ready)
const errors = []
env.instance.on('error', message => errors.push(message))
const state = observe(env.instance.start())
await flush()
env.instance.video.error = { code: 3 }
dispatch(env.instance.video, 'error')
await flush()
assert.equal(state.status, 'rejected')
assert.match(state.error.message, /media error 3/)
assert.deepEqual(errors, ['Unable to load video: media error 3'])
assert.equal(env.instance.processing, false)
assert.equal(env.timers.size, 0)
env.instance.destroy()
}
})
test('Thumbnail idle native errors report without creating a task and stale source errors are ignored', () => {
const env = setup(false)
const oldError = [...(env.instance.video.handlers.get('error') || [])][0]
const errors = []
env.instance.on('error', message => errors.push(message))
env.instance.loadVideo({ name: 'second.mp4', type: 'video/mp4' })
assert.equal(typeof oldError, 'function')
oldError()
assert.deepEqual(errors, [])
env.instance.video.error = { code: 4 }
dispatch(env.instance.video, 'error')
dispatch(env.instance.video, 'error')
assert.deepEqual(errors, ['Unable to load video: media error 4'])
env.instance.destroy()
})
test('Thumbnail destroy during native encoding rejects immediately and ignores the late Blob', async () => {
const env = setup()
env.controls.deferBlobs = true
const updates = []
env.instance.on('update', value => updates.push(value))
const state = observe(env.instance.start())
await firstFrame(env)
env.instance.destroy()
await flush()
cancelled(state)
const count = env.operations.filter(item => item.name === 'createURL').length
env.blobs.shift()({ kind: 'image/png' })
await flush()
assert.equal(env.operations.filter(item => item.name === 'createURL').length, count)
assert.deepEqual(updates, [])
assert.equal(env.instance.video.handlers.get('seeked')?.size || 0, 0)
})
test('Thumbnail switching source cancels the old encoding without cancelling the next job', async () => {
const env = setup()
env.controls.deferBlobs = true
const oldUrl = env.instance.videoUrl
const oldState = observe(env.instance.start())
await firstFrame(env)
const late = env.blobs.shift()
env.instance.loadVideo({ name: 'second.mp4', type: 'video/mp4' })
dispatch(env.instance.video, 'loadedmetadata')
const next = observe(env.instance.start())
await flush()
cancelled(oldState)
late({ kind: 'late-png' })
assert.equal(env.instance.processing, true)
assert.equal(env.instance.thumbnailUrl, undefined)
assert(env.operations.some(item => item.name === 'revokeURL' && item.url === oldUrl))
env.controls.deferBlobs = false
await complete(env, next)
env.instance.destroy()
})
for (const failure of ['null-blob', 'draw', 'toBlob', 'seek', 'update-listener']) {
test(`Thumbnail ${failure} failure rejects instead of abandoning the extraction promise`, async () => {
const env = setup()
env.controls.deferBlobs = true
const error = new Error(failure)
const errors = []
env.instance.on('error', message => errors.push(message))
if (failure === 'draw')
env.context2D.drawImage = () => { throw error }
if (failure === 'toBlob')
env.Element.prototype.toBlob = () => { throw error }
if (failure === 'seek')
Object.defineProperty(env.instance.video, 'currentTime', { get: () => 0, set() { throw error } })
if (failure === 'update-listener')
env.instance.on('update', () => { throw error })
const state = observe(env.instance.start())
if (failure === 'seek')
await flush()
else
await firstFrame(env)
if (failure === 'null-blob' || failure === 'update-listener')
env.blobs.shift()(failure === 'null-blob' ? null : { kind: 'image/png' })
await flush()
assert.equal(state.status, 'rejected')
if (failure === 'null-blob')
assert.match(state.error.message, /Unable to create thumbnail/)
else
assert.equal(state.error, error)
assert.equal(errors.length, 1)
assert.equal(env.instance.processing, false)
env.instance.destroy()
})
}
test('Thumbnail duplicate readiness and duplicate Blob callback cannot duplicate a frame', async () => {
const env = setup()
env.controls.deferBlobs = true
const state = observe(env.instance.start())
await flush()
const ready = env.instance.video.oncanplay
ready()
ready()
assert.equal(env.blobs.length, 1)
const blob = env.blobs.shift()
const updates = []
env.instance.on('update', (url, progress) => updates.push(progress))
blob({ kind: 'image/png' })
blob({ kind: 'image/png' })
assert.deepEqual(updates, [0.1])
env.controls.deferBlobs = false
await complete(env, state, 9)
env.instance.destroy()
})
test('Thumbnail canvas listener destruction never starts a frame', async () => {
const env = setup()
const updates = []
env.instance.on('canvas', () => env.instance.destroy())
env.instance.on('update', () => updates.push('update'))
const state = observe(env.instance.start())
await flush()
cancelled(state)
assert.deepEqual(updates, [])
assert.equal(env.instance.processing, false)
assert.equal(env.operations.filter(item => item.name === 'drawImage').length, 0)
})
test('Thumbnail file callback destruction and nested load cannot overwrite the latest source', () => {
for (const action of ['destroy', 'replace']) {
const env = setup()
const videos = []
env.instance.on('video', () => videos.push(env.instance.file.name))
env.instance.once('file', () => {
if (action === 'destroy')
env.instance.destroy()
else
env.instance.loadVideo({ name: 'latest.mp4', type: 'video/mp4' })
})
env.instance.loadVideo({ name: 'outer.mp4', type: 'video/mp4' })
assert.deepEqual(videos, action === 'destroy' ? [] : ['latest.mp4'])
if (action === 'replace')
assert.equal(env.instance.video.src, env.instance.videoUrl)
env.instance.destroy()
const created = env.operations.filter(item => item.name === 'createURL').map(item => item.url)
const revoked = new Set(env.operations.filter(item => item.name === 'revokeURL').map(item => item.url))
assert(created.every(url => revoked.has(url)))
}
})
test('Thumbnail file callback start waits for metadata of the new source', async () => {
const env = setup()
let state
env.instance.once('file', () => {
state = observe(env.instance.start())
})
env.instance.loadVideo({ name: 'second.mp4', type: 'video/mp4' })
await flush()
assert.equal(env.instance.processing, false)
assert.equal(env.operations.filter(item => item.name === 'drawImage').length, 0)
dispatch(env.instance.video, 'loadedmetadata')
await complete(env, state)
env.instance.destroy()
})
test('Thumbnail error listener failure rejects with its error after resources are detached', async () => {
const env = setup()
env.controls.deferBlobs = true
const error = new Error('error listener failed')
env.instance.on('error', () => {
throw error
})
const state = observe(env.instance.start())
await firstFrame(env)
env.blobs.shift()(null)
await flush()
assert.equal(state.error, error)
assert.equal(env.instance.processing, false)
env.instance.destroy()
})
test('Thumbnail done listener may start a new task without old completion clearing it', async () => {
const env = setup()
let second
env.instance.once('done', () => {
second = observe(env.instance.start())
})
const first = observe(env.instance.start())
await complete(env, first)
assert.equal(env.instance.processing, true)
await complete(env, second)
env.instance.destroy()
})
test('Thumbnail private URL ownership survives public URL replacement and closed calls allocate nothing', async () => {
const env = setup()
const first = observe(env.instance.start())
await complete(env, first)
const owned = env.operations.filter(item => item.name === 'createURL').map(item => item.url)
env.instance.videoUrl = 'blob:external-video'
env.instance.thumbnailUrl = 'blob:external-sheet'
env.instance.destroy()
const revoked = new Set(env.operations.filter(item => item.name === 'revokeURL').map(item => item.url))
assert(owned.every(url => revoked.has(url)))
const after = observe(env.instance.start())
assert.equal(env.instance.download(), env.instance)
await flush()
cancelled(after)
assert.equal(env.timers.size, 0)
assert.equal(env.body.children.length, 0)
})
test('Thumbnail input attachment reentrant destroy cannot publish new input resources', () => {
const env = setup()
const wrapper = new env.Element('div')
const append = wrapper.appendChild
wrapper.appendChild = function (child) {
append.call(this, child)
env.instance.destroy()
return child
}
assert.throws(() => env.instance.setup({ fileInput: wrapper }), error => error.name === 'AbortError')
assert.equal(wrapper.children.length, 0)
assert.equal(env.body.children.length, 0)
})
test('Thumbnail nested input setup wins without outer cleanup overwriting its option', () => {
const env = setup()
const target = env.instance.option.fileInput
const second = new env.Element('input')
const latest = new env.Element('input')
const remove = target.removeEventListener
let nested = false
target.removeEventListener = function (name, callback) {
remove.call(this, name, callback)
if (!nested) {
nested = true
env.instance.setup({ fileInput: latest })
}
}
env.instance.setup({ fileInput: second })
assert.equal(env.instance.option.fileInput, latest)
for (const callbacks of second.handlers.values())
assert.equal(callbacks.size, 0)
assert.equal(latest.handlers.get('change').size, 1)
env.instance.destroy()
})
test('Thumbnail source and job listener registration cannot revive resources after reentrant destroy', async () => {
for (const stage of ['source', 'job']) {
const env = setup(false)
const add = env.instance.video.addEventListener
let once = false
env.instance.video.addEventListener = function (name, callback) {
if (!once) {
once = true
env.instance.destroy()
}
add.call(this, name, callback)
}
if (stage === 'source') {
env.instance.loadVideo({ name: 'second.mp4', type: 'video/mp4' })
}
else {
const state = observe(env.instance.start())
await flush()
cancelled(state)
}
for (const callbacks of env.instance.video.handlers.values())
assert.equal(callbacks.size, 0)
assert.equal(env.body.children.length, 0)
}
})
test('Thumbnail timer creation reentrant destroy clears the newly returned timer handle', async () => {
const env = setup(false)
const schedule = env.box.setTimeout
env.box.setTimeout = (callback, delay) => {
env.instance.destroy()
return schedule(callback, delay)
}
const state = observe(env.instance.start())
await flush()
cancelled(state)
assert.equal(env.timers.size, 0)
})
test('Thumbnail URL creation reentrant load preserves the newer source and revokes the superseded URL', () => {
const env = setup()
const create = env.box.URL.createObjectURL
let once = false
env.box.URL.createObjectURL = (file) => {
if (!once) {
once = true
env.instance.loadVideo({ name: 'latest.mp4', type: 'video/mp4' })
}
return create(file)
}
env.instance.loadVideo({ name: 'outer.mp4', type: 'video/mp4' })
assert.equal(env.instance.file.name, 'latest.mp4')
assert.equal(env.instance.video.src, env.instance.videoUrl)
env.instance.destroy()
const created = env.operations.filter(item => item.name === 'createURL').map(item => item.url)
const revoked = new Set(env.operations.filter(item => item.name === 'revokeURL').map(item => item.url))
assert(created.every(url => revoked.has(url)))
})
test('Thumbnail start before the first file retains its legacy wait-for-selection behavior', async () => {
const env = thumbnailEnvironment(implementation)
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10 })
const state = observe(env.instance.start())
env.instance.loadVideo({ name: 'first.mp4', type: 'video/mp4' })
env.instance.video.duration = 100
dispatch(env.instance.video, 'loadedmetadata')
await complete(env, state)
env.instance.destroy()
})
test('Thumbnail destroy clears the media source and resets the decoder even when pause throws', () => {
const env = setup()
const failure = new Error('pause failed')
const events = []
env.instance.video.pause = () => {
events.push('pause')
throw failure
}
env.instance.video.removeAttribute = (name) => {
events.push(`remove:${name}`)
delete env.instance.video[name]
}
env.instance.video.load = () => events.push('load')
assert.throws(() => env.instance.destroy(), error => error === failure)
assert.deepEqual(events, ['pause', 'remove:src', 'load'])
assert.equal(env.instance.video.src, undefined)
assert.equal(env.instance.video.parentNode, null)
assert.equal(env.operations.filter(item => item.name === 'revokeURL').length, 1)
})