fix(thumbnail): [PKG-TOOL-THUMB-03] own input resources and separate sheet export

This commit is contained in:
Harvey Zhao committed 2026-09-13 03:51:26 +08:00
1 parent 1cb7779192
commit 426c0c5d42
22 files changed
+13724 -256

No files matched your search

@@ -0,0 +1,70 @@
# 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.
| File | Responsibility |
| --- | --- |
| src/index.js | Public class, historical method names, file/video events, current extraction loop and destruction entry |
| 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.
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,
file/video event order, public method spelling and return values stay unchanged.
Input setup validates options before changing DOM or committed options. A wrapper
receives one owned input; repeated setup with that wrapper reuses it. Replacement
installs the new listeners before releasing the old input. A failed new listener
installation rolls back its partial listeners/DOM. Explicit caller-provided file
inputs are never removed. Generated inputs are removed and the wrapper's previous
position is restored only if its current position still matches the tool's write.
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.
Sheet extraction preserves fractional coordinates, historical `creat*` names,
the 30-pixel footer and the filename algorithm. The temporary download anchor is
removed even if click throws. Default aspect-derived height and synchronous video
events currently match workspace 4.4.0; recovered 3.5.31 differs. See the frozen
[contract](../../refactor/baselines/thumbnail-contract.md) before changing defaults.
## Validation and continuation
Use Yarn and the repository scripts:
```sh
yarn test:thumbnail
yarn build artplayer-tool-thumbnail
yarn test:browser test/browser/thumbnail-tool.spec.js test/browser/thumbnail-native.spec.js test/browser/thumbnail-input.spec.js
```
`test/thumbnail-input.test.js` contains candidate input/export regressions; set
ARTPLAYER_THUMBNAIL_BASELINE=1 to reproduce failures against frozen workspace
main. ARTPLAYER_THUMBNAIL_ARTIFACT selects an actual built artifact for Node and
browser candidate checks. `test/thumbnail.test.js` remains immutable-behavior
evidence against old implementations, including intentionally reproduced bugs.
Browser records distinguish actual extraction from native Blob-unavailable
controls. Windows WebKit can load tested MP4 files over HTTP but returns error 4
for native Blob URLs; this is not successful file extraction. Actual input/DOM
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.
See [task plan](../../refactor/plan.md) and [risk ledger](../../refactor/risks.json).
@@ -2,6 +2,8 @@
Thumbnail tool for ArtPlayer
Implementation and migration notes: [ARCHITECTURE.md](./ARCHITECTURE.md).
## Demo
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-tool-thumbnail/index.js&example=thumbnail)
@@ -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))}class i extends t{constructor(t={}){super(),this.processing=!1,this.option={},this.setup(Object.assign({},i.DEFAULTS,t)),this.video=i.creatVideo(),this.duration=0,this.inputChange=this.inputChange.bind(this),this.ondrop=this.ondrop.bind(this),this.option.fileInput.addEventListener("change",this.inputChange),this.option.fileInput.addEventListener("dragover",i.ondragover),this.option.fileInput.addEventListener("drop",i.ondrop)}static get DEFAULTS(){return{number:60,width:160,height:90,column:10,begin:0,end:Number.NaN}}static ondragover(t){t.preventDefault()}ondrop(t){t.preventDefault();const e=t.dataTransfer.files[0];this.loadVideo(e)}setup(t={}){this.option=Object.assign({},this.option,t);const{fileInput:i,number:n,width:o,column:s}=this.option;if(this.errorHandle(i instanceof Element,"The 'fileInput' is not a Element"),"INPUT"!==i.tagName||"file"!==i.type){i.style.position="relative";const t=document.createElement("input");t.type="file",t.style.position="absolute",t.style.width="100%",t.style.height="100%",t.style.left="0",t.style.top="0",t.style.right="0",t.style.bottom="0",t.style.opacity="0",i.appendChild(t),this.option.fileInput=t}return["number","width","column","begin","end"].forEach(t=>{this.errorHandle("number"===typeof this.option[t],`The '${t}' is not a number`)}),this.option.number=e(n,10,1e3),this.option.width=e(o,10,1e3),this.option.column=e(s,1,1e3),this}static creatVideo(){const t=document.createElement("video");return t.style.position="absolute",t.style.top="-9999px",t.style.left="-9999px",t.muted=!0,t.controls=!0,document.body.appendChild(t),t}inputChange(t){const e=this.option.fileInput.files[0];this.loadVideo(e),t.target.value=""}loadVideo(t){if(t){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 d=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.video.oncanplay=null,o()})},this.video.currentTime=t.time}));return this.processing=!0,(p=d,p.reduce((t,e)=>t.then(e),Promise.resolve())).then(()=>{this.processing=!1,this.emit("done")}).catch(t=>{throw this.processing=!1,this.emit("error",t.message),t});var p}creatScreenshotDate(){const{number:t,width:e,height:i,column:n,begin:o}=this.option,s=this.duration/t,r=[o+s];for(;r.length<t;){const t=r[r.length-1];r.push(t+s)}return r.map((t,o)=>({time:t-s/2,x:o%n*e,y:Math.floor(o/n)*i}))}creatCanvas(){const{number:t,width:e,height:i,column:n}=this.option,o=document.createElement("canvas"),s=o.getContext("2d");return o.width=e*n,o.height=Math.ceil(t/n)*i+30,s.fillStyle="black",s.fillRect(0,0,o.width,o.height),s.font="14px Georgia",s.fillStyle="#fff",s.fillText(`From: https://artplayer.org/, Number: ${t}, Width: ${e}, Height: ${i}, Column: ${n}`,10,o.height-11),o}download(){this.errorHandle(this.file&&this.thumbnailUrl,"Download does not seem to be ready, please create previeLine truncated
!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
@@ -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))}class i extends t{constructor(t={}){super(),this.processing=!1,this.option={},this.setup(Object.assign({},i.DEFAULTS,t)),this.video=i.creatVideo(),this.duration=0,this.inputChange=this.inputChange.bind(this),this.ondrop=this.ondrop.bind(this),this.option.fileInput.addEventListener("change",this.inputChange),this.option.fileInput.addEventListener("dragover",i.ondragover),this.option.fileInput.addEventListener("drop",i.ondrop)}static get DEFAULTS(){return{number:60,width:160,height:90,column:10,begin:0,end:Number.NaN}}static ondragover(t){t.preventDefault()}ondrop(t){t.preventDefault();const e=t.dataTransfer.files[0];this.loadVideo(e)}setup(t={}){this.option=Object.assign({},this.option,t);const{fileInput:i,number:n,width:o,column:s}=this.option;if(this.errorHandle(i instanceof Element,"The 'fileInput' is not a Element"),"INPUT"!==i.tagName||"file"!==i.type){i.style.position="relative";const t=document.createElement("input");t.type="file",t.style.position="absolute",t.style.width="100%",t.style.height="100%",t.style.left="0",t.style.top="0",t.style.right="0",t.style.bottom="0",t.style.opacity="0",i.appendChild(t),this.option.fileInput=t}return["number","width","column","begin","end"].forEach(t=>{this.errorHandle("number"===typeof this.option[t],`The '${t}' is not a number`)}),this.option.number=e(n,10,1e3),this.option.width=e(o,10,1e3),this.option.column=e(s,1,1e3),this}static creatVideo(){const t=document.createElement("video");return t.style.position="absolute",t.style.top="-9999px",t.style.left="-9999px",t.muted=!0,t.controls=!0,document.body.appendChild(t),t}inputChange(t){const e=this.option.fileInput.files[0];this.loadVideo(e),t.target.value=""}loadVideo(t){if(t){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 d=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.video.oncanplay=null,o()})},this.video.currentTime=t.time}));return this.processing=!0,(p=d,p.reduce((t,e)=>t.then(e),Promise.resolve())).then(()=>{this.processing=!1,this.emit("done")}).catch(t=>{throw this.processing=!1,this.emit("error",t.message),t});var p}creatScreenshotDate(){const{number:t,width:e,height:i,column:n,begin:o}=this.option,s=this.duration/t,r=[o+s];for(;r.length<t;){const t=r[r.length-1];r.push(t+s)}return r.map((t,o)=>({time:t-s/2,x:o%n*e,y:Math.floor(o/n)*i}))}creatCanvas(){const{number:t,width:e,height:i,column:n}=this.option,o=document.createElement("canvas"),s=o.getContext("2d");return o.width=e*n,o.height=Math.ceil(t/n)*i+30,s.fillStyle="black",s.fillRect(0,0,o.width,o.height),s.font="14px Georgia",s.fillStyle="#fff",s.fillText(`From: https://artplayer.org/, Number: ${t}, Width: ${e}, Height: ${i}, Column: ${n}`,10,o.height-11),o}download(){this.errorHandle(this.file&&this.thumbnailUrl,"Download does not seem to be ready, please create previeLine truncated
!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
@@ -58,19 +58,166 @@ function getFileName(name) {
function clamp(num, a, b) {
return Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b));
}
const inputs = /* @__PURE__ */ new WeakMap();
function release(record) {
let failure;
for (const [name, callback] of record.listeners.splice(0)) {
try {
record.input.removeEventListener(name, callback);
} catch (error) {
failure || (failure = error);
}
}
if (record.wrapper) {
try {
if (record.input.parentNode)
record.input.parentNode.removeChild(record.input);
} catch (error) {
failure || (failure = error);
}
if (record.wrapper.style.position === "relative")
record.wrapper.style.position = record.position;
}
if (failure)
throw failure;
}
function connect(record, callbacks) {
try {
for (const [name, callback] of callbacks) {
record.listeners.push([name, callback]);
record.input.addEventListener(name, callback);
}
} catch (error) {
try {
release(record);
} catch {
}
throw error;
}
}
function setupInput(tool, patch) {
const option = Object.assign({}, tool.option, patch);
const target = option.fileInput;
tool.errorHandle(target instanceof Element, "The 'fileInput' is not a Element");
for (const name of ["number", "width", "column", "begin", "end"])
tool.errorHandle(typeof option[name] === "number", `The '${name}' is not a number`);
option.number = clamp(option.number, 10, 1e3);
option.width = clamp(option.width, 10, 1e3);
option.column = clamp(option.column, 1, 1e3);
const previous = inputs.get(tool);
if (previous && (target === previous.input || target === previous.wrapper)) {
option.fileInput = previous.input;
return option;
}
const record = { input: target, wrapper: null, position: "", listeners: [], callbacks: previous?.callbacks || null };
try {
if (!(target.tagName === "INPUT" && target.type === "file")) {
record.wrapper = target;
record.position = target.style.position;
record.input = document.createElement("input");
record.input.type = "file";
Object.assign(record.input.style, { position: "absolute", width: "100%", height: "100%", left: "0", top: "0", right: "0", bottom: "0", opacity: "0" });
target.style.position = "relative";
target.appendChild(record.input);
}
if (record.callbacks)
connect(record, record.callbacks);
} catch (error) {
try {
release(record);
} catch {
}
throw error;
}
option.fileInput = record.input;
inputs.set(tool, record);
tool.option = option;
if (previous)
release(previous);
return option;
}
function connectInput(tool, dragover) {
const record = inputs.get(tool);
record.callbacks = [["change", tool.inputChange], ["dragover", dragover], ["drop", tool.ondrop]];
connect(record, record.callbacks);
}
function releaseInput(tool) {
const record = inputs.get(tool);
if (!record)
return;
inputs.delete(tool);
release(record);
}
function screenshotPoints(option, duration) {
const { number, width, height, column, begin } = option;
const timeGap = duration / number;
const timePoints = [begin + timeGap];
while (timePoints.length < number) {
const last = timePoints[timePoints.length - 1];
timePoints.push(last + timeGap);
}
return timePoints.map((item, index) => ({
time: item - timeGap / 2,
x: index % column * width,
y: Math.floor(index / column) * height
}));
}
function createSheet(option) {
const { number, width, height, column } = option;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = width * column;
canvas.height = Math.ceil(number / column) * height + 30;
context.fillStyle = "black";
context.fillRect(0, 0, canvas.width, canvas.height);
context.font = "14px Georgia";
context.fillStyle = "#fff";
context.fillText(
`From: https://artplayer.org/, Number: ${number}, Width: ${width}, Height: ${height}, Column: ${column}`,
10,
canvas.height - 11
);
return canvas;
}
function downloadSheet(file, url) {
const link = document.createElement("a");
const name = `${getFileName(file.name)}.png`;
link.download = name;
link.href = url;
try {
document.body.appendChild(link);
link.click();
} finally {
if (link.parentNode)
link.parentNode.removeChild(link);
}
return name;
}
const destroyed = /* @__PURE__ */ new WeakSet();
class ArtplayerToolThumbnail extends Emitter {
constructor(option = {}) {
super();
this.processing = false;
this.option = {};
this.setup(Object.assign({}, ArtplayerToolThumbnail.DEFAULTS, option));
this.video = ArtplayerToolThumbnail.creatVideo();
this.duration = 0;
this.inputChange = this.inputChange.bind(this);
this.ondrop = this.ondrop.bind(this);
this.option.fileInput.addEventListener("change", this.inputChange);
this.option.fileInput.addEventListener("dragover", ArtplayerToolThumbnail.ondragover);
this.option.fileInput.addEventListener("drop", ArtplayerToolThumbnail.ondrop);
try {
this.setup(Object.assign({}, ArtplayerToolThumbnail.DEFAULTS, option));
this.video = ArtplayerToolThumbnail.creatVideo();
this.duration = 0;
this.inputChange = this.inputChange.bind(this);
this.ondrop = this.ondrop.bind(this);
connectInput(this, ArtplayerToolThumbnail.ondragover);
} catch (error) {
try {
releaseInput(this);
} catch {
}
try {
if (this.video?.parentNode)
this.video.parentNode.removeChild(this.video);
} catch {
}
throw error;
}
}
static get DEFAULTS() {
return {
@@ -86,35 +233,15 @@ class ArtplayerToolThumbnail extends Emitter {
event.preventDefault();
}
ondrop(event) {
if (destroyed.has(this))
return;
event.preventDefault();
const file = event.dataTransfer.files[0];
this.loadVideo(file);
}
setup(option = {}) {
this.option = Object.assign({}, this.option, option);
const { fileInput, number, width, column } = this.option;
this.errorHandle(fileInput instanceof Element, "The 'fileInput' is not a Element");
if (!(fileInput.tagName === "INPUT" && fileInput.type === "file")) {
fileInput.style.position = "relative";
const newFileInput = document.createElement("input");
newFileInput.type = "file";
newFileInput.style.position = "absolute";
newFileInput.style.width = "100%";
newFileInput.style.height = "100%";
newFileInput.style.left = "0";
newFileInput.style.top = "0";
newFileInput.style.right = "0";
newFileInput.style.bottom = "0";
newFileInput.style.opacity = "0";
fileInput.appendChild(newFileInput);
this.option.fileInput = newFileInput;
}
["number", "width", "column", "begin", "end"].forEach((item) => {
this.errorHandle(typeof this.option[item] === "number", `The '${item}' is not a number`);
});
this.option.number = clamp(number, 10, 1e3);
this.option.width = clamp(width, 10, 1e3);
this.option.column = clamp(column, 1, 1e3);
if (!destroyed.has(this))
this.option = setupInput(this, option);
return this;
}
static creatVideo() {
@@ -124,16 +251,24 @@ class ArtplayerToolThumbnail extends Emitter {
video.style.left = "-9999px";
video.muted = true;
video.controls = true;
document.body.appendChild(video);
try {
document.body.appendChild(video);
} catch (error) {
if (video.parentNode)
video.parentNode.removeChild(video);
throw error;
}
return video;
}
inputChange(event) {
if (destroyed.has(this))
return;
const file = this.option.fileInput.files[0];
this.loadVideo(file);
event.target.value = "";
}
loadVideo(file) {
if (file) {
if (file && !destroyed.has(this)) {
const canPlayType = this.video.canPlayType(file.type);
this.errorHandle(
canPlayType === "maybe" || canPlayType === "probably",
@@ -193,35 +328,10 @@ class ArtplayerToolThumbnail extends Emitter {
});
}
creatScreenshotDate() {
const { number, width, height, column, begin } = this.option;
const timeGap = this.duration / number;
const timePoints = [begin + timeGap];
while (timePoints.length < number) {
const last = timePoints[timePoints.length - 1];
timePoints.push(last + timeGap);
}
return timePoints.map((item, index) => ({
time: item - timeGap / 2,
x: index % column * width,
y: Math.floor(index / column) * height
}));
return screenshotPoints(this.option, this.duration);
}
creatCanvas() {
const { number, width, height, column } = this.option;
const canvas = document.createElement("canvas");
const context2D = canvas.getContext("2d");
canvas.width = width * column;
canvas.height = Math.ceil(number / column) * height + 30;
context2D.fillStyle = "black";
context2D.fillRect(0, 0, canvas.width, canvas.height);
context2D.font = "14px Georgia";
context2D.fillStyle = "#fff";
context2D.fillText(
`From: https://artplayer.org/, Number: ${number}, Width: ${width}, Height: ${height}, Column: ${column}`,
10,
canvas.height - 11
);
return canvas;
return createSheet(this.option);
}
download() {
this.errorHandle(
@@ -229,13 +339,7 @@ class ArtplayerToolThumbnail extends Emitter {
"Download does not seem to be ready, please create preview first"
);
this.errorHandle(!this.processing, "There is currently a task in progress, please wait a moment...");
const elink = document.createElement("a");
const name = `${getFileName(this.file.name)}.png`;
elink.download = name;
elink.href = this.thumbnailUrl;
document.body.appendChild(elink);
elink.click();
document.body.removeChild(elink);
const name = downloadSheet(this.file, this.thumbnailUrl);
this.emit("download", name);
return this;
}
@@ -246,17 +350,25 @@ class ArtplayerToolThumbnail extends Emitter {
}
}
destroy() {
this.option.fileInput.removeEventListener("change", this.inputChange);
this.option.fileInput.removeEventListener("dragover", ArtplayerToolThumbnail.ondragover);
this.option.fileInput.removeEventListener("drop", ArtplayerToolThumbnail.ondrop);
document.body.removeChild(this.video);
if (this.videoUrl) {
URL.revokeObjectURL(this.videoUrl);
if (destroyed.has(this))
return;
destroyed.add(this);
let failure;
for (const cleanup of [
() => releaseInput(this),
() => this.video.parentNode?.removeChild(this.video),
() => this.videoUrl && URL.revokeObjectURL(this.videoUrl),
() => this.thumbnailUrl && URL.revokeObjectURL(this.thumbnailUrl),
() => this.emit("destroy")
]) {
try {
cleanup();
} catch (error) {
failure || (failure = error);
}
}
if (this.thumbnailUrl) {
URL.revokeObjectURL(this.thumbnailUrl);
}
this.emit("destroy");
if (failure)
throw failure;
}
}
export {
+60 -83
View File
@@ -1,19 +1,35 @@
import Emitter from './emitter'
import { clamp, getFileName, runPromisesInSeries, sleep } from './utils'
import { connectInput, releaseInput, setupInput } from './input'
import { createSheet, downloadSheet, screenshotPoints } from './sheet'
import { clamp, runPromisesInSeries, sleep } from './utils'
const destroyed = new WeakSet()
export default class ArtplayerToolThumbnail extends Emitter {
constructor(option = {}) {
super()
this.processing = false
this.option = {}
this.setup(Object.assign({}, ArtplayerToolThumbnail.DEFAULTS, option))
this.video = ArtplayerToolThumbnail.creatVideo()
this.duration = 0
this.inputChange = this.inputChange.bind(this)
this.ondrop = this.ondrop.bind(this)
this.option.fileInput.addEventListener('change', this.inputChange)
this.option.fileInput.addEventListener('dragover', ArtplayerToolThumbnail.ondragover)
this.option.fileInput.addEventListener('drop', ArtplayerToolThumbnail.ondrop)
try {
this.setup(Object.assign({}, ArtplayerToolThumbnail.DEFAULTS, option))
this.video = ArtplayerToolThumbnail.creatVideo()
this.duration = 0
this.inputChange = this.inputChange.bind(this)
this.ondrop = this.ondrop.bind(this)
connectInput(this, ArtplayerToolThumbnail.ondragover)
}
catch (error) {
try {
releaseInput(this)
}
catch {}
try {
if (this.video?.parentNode)
this.video.parentNode.removeChild(this.video)
}
catch {}
throw error
}
}
static get DEFAULTS() {
@@ -32,40 +48,16 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
ondrop(event) {
if (destroyed.has(this))
return
event.preventDefault()
const file = event.dataTransfer.files[0]
this.loadVideo(file)
}
setup(option = {}) {
this.option = Object.assign({}, this.option, option)
const { fileInput, number, width, column } = this.option
this.errorHandle(fileInput instanceof Element, 'The \'fileInput\' is not a Element')
if (!(fileInput.tagName === 'INPUT' && fileInput.type === 'file')) {
fileInput.style.position = 'relative'
const newFileInput = document.createElement('input')
newFileInput.type = 'file'
newFileInput.style.position = 'absolute'
newFileInput.style.width = '100%'
newFileInput.style.height = '100%'
newFileInput.style.left = '0'
newFileInput.style.top = '0'
newFileInput.style.right = '0'
newFileInput.style.bottom = '0'
newFileInput.style.opacity = '0'
fileInput.appendChild(newFileInput)
this.option.fileInput = newFileInput
}
['number', 'width', 'column', 'begin', 'end'].forEach((item) => {
this.errorHandle(typeof this.option[item] === 'number', `The '${item}' is not a number`)
})
this.option.number = clamp(number, 10, 1000)
this.option.width = clamp(width, 10, 1000)
this.option.column = clamp(column, 1, 1000)
if (!destroyed.has(this))
this.option = setupInput(this, option)
return this
}
@@ -76,18 +68,27 @@ export default class ArtplayerToolThumbnail extends Emitter {
video.style.left = '-9999px'
video.muted = true
video.controls = true
document.body.appendChild(video)
try {
document.body.appendChild(video)
}
catch (error) {
if (video.parentNode)
video.parentNode.removeChild(video)
throw error
}
return video
}
inputChange(event) {
if (destroyed.has(this))
return
const file = this.option.fileInput.files[0]
this.loadVideo(file)
event.target.value = ''
}
loadVideo(file) {
if (file) {
if (file && !destroyed.has(this)) {
const canPlayType = this.video.canPlayType(file.type)
this.errorHandle(
canPlayType === 'maybe' || canPlayType === 'probably',
@@ -151,36 +152,11 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
creatScreenshotDate() {
const { number, width, height, column, begin } = this.option
const timeGap = this.duration / number
const timePoints = [begin + timeGap]
while (timePoints.length < number) {
const last = timePoints[timePoints.length - 1]
timePoints.push(last + timeGap)
}
return timePoints.map((item, index) => ({
time: item - timeGap / 2,
x: (index % column) * width,
y: Math.floor(index / column) * height,
}))
return screenshotPoints(this.option, this.duration)
}
creatCanvas() {
const { number, width, height, column } = this.option
const canvas = document.createElement('canvas')
const context2D = canvas.getContext('2d')
canvas.width = width * column
canvas.height = Math.ceil(number / column) * height + 30
context2D.fillStyle = 'black'
context2D.fillRect(0, 0, canvas.width, canvas.height)
context2D.font = '14px Georgia'
context2D.fillStyle = '#fff'
context2D.fillText(
`From: https://artplayer.org/, Number: ${number}, Width: ${width}, Height: ${height}, Column: ${column}`,
10,
canvas.height - 11,
)
return canvas
return createSheet(this.option)
}
download() {
@@ -189,13 +165,7 @@ export default class ArtplayerToolThumbnail extends Emitter {
'Download does not seem to be ready, please create preview first',
)
this.errorHandle(!this.processing, 'There is currently a task in progress, please wait a moment...')
const elink = document.createElement('a')
const name = `${getFileName(this.file.name)}.png`
elink.download = name
elink.href = this.thumbnailUrl
document.body.appendChild(elink)
elink.click()
document.body.removeChild(elink)
const name = downloadSheet(this.file, this.thumbnailUrl)
this.emit('download', name)
return this
}
@@ -208,16 +178,23 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
destroy() {
this.option.fileInput.removeEventListener('change', this.inputChange)
this.option.fileInput.removeEventListener('dragover', ArtplayerToolThumbnail.ondragover)
this.option.fileInput.removeEventListener('drop', ArtplayerToolThumbnail.ondrop)
document.body.removeChild(this.video)
if (this.videoUrl) {
URL.revokeObjectURL(this.videoUrl)
if (destroyed.has(this))
return
destroyed.add(this)
let failure
for (const cleanup of [
() => releaseInput(this),
() => this.video.parentNode?.removeChild(this.video),
() => this.videoUrl && URL.revokeObjectURL(this.videoUrl),
() => this.thumbnailUrl && URL.revokeObjectURL(this.thumbnailUrl),
() => this.emit('destroy'),
]) {
try {
cleanup()
}
catch (error) { failure ||= error }
}
if (this.thumbnailUrl) {
URL.revokeObjectURL(this.thumbnailUrl)
}
this.emit('destroy')
if (failure)
throw failure
}
}
@@ -0,0 +1,102 @@
import { clamp } from './utils'
const inputs = new WeakMap()
function release(record) {
let failure
for (const [name, callback] of record.listeners.splice(0)) {
try {
record.input.removeEventListener(name, callback)
}
catch (error) {
failure ||= error
}
}
if (record.wrapper) {
try {
if (record.input.parentNode)
record.input.parentNode.removeChild(record.input)
}
catch (error) {
failure ||= error
}
if (record.wrapper.style.position === 'relative')
record.wrapper.style.position = record.position
}
if (failure)
throw failure
}
function connect(record, callbacks) {
try {
for (const [name, callback] of callbacks) {
record.listeners.push([name, callback])
record.input.addEventListener(name, callback)
}
}
catch (error) {
try {
release(record)
}
catch {}
throw error
}
}
export function setupInput(tool, patch) {
const option = Object.assign({}, tool.option, patch)
const target = option.fileInput
tool.errorHandle(target instanceof Element, 'The \'fileInput\' is not a Element')
for (const name of ['number', 'width', 'column', 'begin', 'end'])
tool.errorHandle(typeof option[name] === 'number', `The '${name}' is not a number`)
option.number = clamp(option.number, 10, 1000)
option.width = clamp(option.width, 10, 1000)
option.column = clamp(option.column, 1, 1000)
const previous = inputs.get(tool)
if (previous && (target === previous.input || target === previous.wrapper)) {
option.fileInput = previous.input
return option
}
const record = { input: target, wrapper: null, position: '', listeners: [], callbacks: previous?.callbacks || null }
try {
if (!(target.tagName === 'INPUT' && target.type === 'file')) {
record.wrapper = target
record.position = target.style.position
record.input = document.createElement('input')
record.input.type = 'file'
Object.assign(record.input.style, { position: 'absolute', width: '100%', height: '100%', left: '0', top: '0', right: '0', bottom: '0', opacity: '0' })
target.style.position = 'relative'
target.appendChild(record.input)
}
if (record.callbacks)
connect(record, record.callbacks)
}
catch (error) {
try {
release(record)
}
catch {}
throw error
}
option.fileInput = record.input
inputs.set(tool, record)
tool.option = option
if (previous)
release(previous)
return option
}
export function connectInput(tool, dragover) {
const record = inputs.get(tool)
record.callbacks = [['change', tool.inputChange], ['dragover', dragover], ['drop', tool.ondrop]]
connect(record, record.callbacks)
}
export function releaseInput(tool) {
const record = inputs.get(tool)
if (!record)
return
inputs.delete(tool)
release(record)
}
@@ -0,0 +1,50 @@
import { getFileName } from './utils'
export function screenshotPoints(option, duration) {
const { number, width, height, column, begin } = option
const timeGap = duration / number
const timePoints = [begin + timeGap]
while (timePoints.length < number) {
const last = timePoints[timePoints.length - 1]
timePoints.push(last + timeGap)
}
return timePoints.map((item, index) => ({
time: item - timeGap / 2,
x: (index % column) * width,
y: Math.floor(index / column) * height,
}))
}
export function createSheet(option) {
const { number, width, height, column } = option
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = width * column
canvas.height = Math.ceil(number / column) * height + 30
context.fillStyle = 'black'
context.fillRect(0, 0, canvas.width, canvas.height)
context.font = '14px Georgia'
context.fillStyle = '#fff'
context.fillText(
`From: https://artplayer.org/, Number: ${number}, Width: ${width}, Height: ${height}, Column: ${column}`,
10,
canvas.height - 11,
)
return canvas
}
export function downloadSheet(file, url) {
const link = document.createElement('a')
const name = `${getFileName(file.name)}.png`
link.download = name
link.href = url
try {
document.body.appendChild(link)
link.click()
}
finally {
if (link.parentNode)
link.parentNode.removeChild(link)
}
return name
}