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,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
}