refactor(thumbnail): [PKG-TOOL-THUMB-04] type runtime modules and retain emitter attribution

This commit is contained in:
Harvey Zhao committed 2026-09-13 04:45:01 +08:00
1 parent efd9f42fb9
commit 15485b6420
37 files changed
+8446 -152

No files matched your search

@@ -1,23 +1,25 @@
# Thumbnail tool maintenance map
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.
The runtime responsibilities were separated under PKG-TOOL-THUMB-03 and are now
strict TypeScript under PKG-TOOL-THUMB-04. Published declarations and installed
entrypoint checks are still in progress; runtime migration is not release readiness.
| File | Responsibility |
| --- | --- |
| 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 |
| src/index.ts | Public class, historical method names, construction and destruction entry |
| src/lifecycle.ts | Private state, source/input generations, cancellation and resource release |
| src/source.ts | File loading, native metadata/error listeners and source/thumbnail Blob URLs |
| src/extraction.ts | Owned metadata wait and serial frame job, callback/error completion and cancellation |
| src/input.ts | Option validation/clamps, file-input wrapper creation, listener registration/replacement and release |
| src/sheet.ts | Midpoint grid, canvas/footer geometry, temporary download anchor |
| src/emitter.ts | Typed local adaptation of tiny-emitter; preserves on/once/emit/off behavior |
| src/utils.ts | Pure clamp and filename helpers; unused sleep/serial helpers removed |
| src/types.ts | Internal option, frame, event tuple, job and lifecycle contracts; no runtime output |
The entry delegates to input/source/extraction/sheet; none imports the entry.
The entry delegates to input/source/extraction/sheet. Helpers import its type only;
the imports are erased and do not create a runtime dependency on 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.
only filename calculation. types.ts contains no imports or executable state.
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,
@@ -76,6 +78,7 @@ Use Yarn and the repository scripts:
```sh
yarn test:thumbnail
yarn typecheck
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
```
@@ -96,7 +99,42 @@ Safari/WebKit evidence in 05. The independent tool example is
`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.
complete. Continue published declarations and installed consumers in 04; preserve
old tests and the default delay/height boundary.
See [task plan](../../refactor/plan.md) and [risk ledger](../../refactor/risks.json).
## Type and provenance boundaries
All eight executable source modules and the shared type module are checked with
strict, noUncheckedIndexedAccess, noImplicitOverride, skipLibCheck=false and no
ambient Node/test globals. Public fields use declare so TypeScript does not add
early undefined properties or change constructor property order. File/URL/density
fields retain their absence until the corresponding operation publishes them.
Unknown option fields remain unknown, and custom string/number/symbol events keep
an open payload boundary; built-in events have precise tuples. This source typing
is not yet the missing public npm declaration entrypoint.
Assertions are limited to existing runtime boundaries: a temporary empty option
object before setup validation; the input/wrapper conversion; event target and
dataTransfer/files supplied by native input/drop events; private records inserted
before lookup; nonempty/dense frame arrays; a successful 2D canvas context; and
the old property read of message on arbitrary thrown values. These assertions
preserve existing runtime validation/errors rather than adding new coercions.
The constructor and destroy rollback paths also intentionally tolerate fields
that have not yet been assigned. DOM listener casts connect known event names to
the matching bound callbacks. Keep new assertions equally local and documented.
The emitter structurally corresponds to tiny-emitter 2.1.0, which the recovered
3.5.31 manifest declared as ^2.1.0. The exact original copied revision is unknown.
The pinned [reference files](../../refactor/baselines/thumbnail-vendor/sources.json)
and [MIT notice](THIRD_PARTY_NOTICES) retain upstream attribution. The repository
build includes the complete notice in main, legacy and ESM bundle headers.
Final packed contents remain a distribution gate. Do not replace this local
emitter with the core emitter as part of a type-only change: dispatch semantics
and historical prototype-key handling need their own behavior review.
`test/thumbnail-runtime.test.js` compares upstream, recovered/historical bundles
and the selected candidate for dispatch behavior, and checks candidate public
descriptors and validation. `test/thumbnail-vendor.test.js` verifies fixed source
bytes, complete notices and dist/docs equality. `test/types/thumbnail-runtime.ts`
checks source consumers with positive cases and six rejected invalid uses.
@@ -0,0 +1,26 @@
ArtPlayer thumbnail tool bundled third-party notices
tiny-emitter - reference 2.1.0, locally adapted to a TypeScript class
https://github.com/scottcorgan/tiny-emitter/tree/2.1.0
The MIT License (MIT)
Copyright (c) 2017 Scott Corgan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -3,5 +3,32 @@
* Github: https://github.com/zhw2590582/ArtPlayer
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*
* ArtPlayer thumbnail tool bundled third-party notices
*
* tiny-emitter - reference 2.1.0, locally adapted to a TypeScript class
* https://github.com/scottcorgan/tiny-emitter/tree/2.1.0
*
* The MIT License (MIT)
*
* Copyright (c) 2017 Scott Corgan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!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
!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,o=t;function i(...t){r.off(e,i),o.apply(n,t)}return i._=t,this.on(e,i,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}retLine truncated
@@ -3,5 +3,32 @@
* Github: https://github.com/zhw2590582/ArtPlayer
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*
* ArtPlayer thumbnail tool bundled third-party notices
*
* tiny-emitter - reference 2.1.0, locally adapted to a TypeScript class
* https://github.com/scottcorgan/tiny-emitter/tree/2.1.0
*
* The MIT License (MIT)
*
* Copyright (c) 2017 Scott Corgan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!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
!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,o=t;function i(...t){r.off(e,i),o.apply(n,t)}return i._=t,this.on(e,i,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.remLine truncated
@@ -3,6 +3,33 @@
* Github: https://github.com/zhw2590582/ArtPlayer
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*
* ArtPlayer thumbnail tool bundled third-party notices
*
* tiny-emitter - reference 2.1.0, locally adapted to a TypeScript class
* https://github.com/scottcorgan/tiny-emitter/tree/2.1.0
*
* The MIT License (MIT)
*
* Copyright (c) 2017 Scott Corgan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
class Emitter {
on(name, fn, ctx) {
@@ -12,15 +39,17 @@ class Emitter {
}
once(name, fn, ctx) {
const self = this;
const callback = fn;
function listener(...args) {
self.off(name, listener);
fn.apply(ctx, args);
callback.apply(ctx, args);
}
listener._ = fn;
return this.on(name, listener, ctx);
}
emit(name, ...data) {
const evtArr = ((this.e || (this.e = {}))[name] || []).slice();
const e = this.e || (this.e = {});
const evtArr = (e[name] || []).slice();
for (let i = 0; i < evtArr.length; i += 1) {
evtArr[i].fn.apply(evtArr[i].ctx, data);
}
@@ -1,44 +0,0 @@
export default class Emitter {
on(name, fn, ctx) {
const e = this.e || (this.e = {});
(e[name] || (e[name] = [])).push({ fn, ctx })
return this
}
once(name, fn, ctx) {
const self = this
function listener(...args) {
self.off(name, listener)
fn.apply(ctx, args)
}
listener._ = fn
return this.on(name, listener, ctx)
}
emit(name, ...data) {
const evtArr = ((this.e || (this.e = {}))[name] || []).slice()
for (let i = 0; i < evtArr.length; i += 1) {
evtArr[i].fn.apply(evtArr[i].ctx, data)
}
return this
}
off(name, callback) {
const e = this.e || (this.e = {})
const evts = e[name]
const liveEvents = []
if (evts && callback) {
for (let i = 0, len = evts.length; i < len; i += 1) {
if (evts[i].fn !== callback && evts[i].fn._ !== callback)
liveEvents.push(evts[i])
}
}
if (liveEvents.length) {
e[name] = liveEvents
}
else {
delete e[name]
}
return this
}
}
@@ -0,0 +1,58 @@
// Locally adapted from tiny-emitter; upstream reference 2.1.0. See THIRD_PARTY_NOTICES.
type EventMap<Events> = { [Name in keyof Events]: readonly unknown[] }
type Listener<Args extends unknown[]> = ((...args: Args) => unknown) & { _?: (...args: Args) => unknown }
interface Registration<Args extends unknown[]> {
fn: Listener<Args>
ctx: unknown
}
type Registry<Events extends EventMap<Events>> = { [Name in keyof Events]?: Registration<[...Events[Name]]>[] }
export default class Emitter<Events extends EventMap<Events> = Record<PropertyKey, unknown[]>> {
declare e?: Registry<Events>
on<Name extends keyof Events, Context>(name: Name, fn: (this: Context, ...args: [...Events[Name]]) => unknown, ctx?: Context): this {
const e: Registry<Events> = this.e || (this.e = {});
(e[name] || (e[name] = [])).push({ fn, ctx })
return this
}
once<Name extends keyof Events, Context>(name: Name, fn: (this: Context, ...args: [...Events[Name]]) => unknown, ctx?: Context): this {
// eslint-disable-next-line ts/no-this-alias -- Preserve the owning emitter independently of callback ctx.
const self = this
const callback: Listener<[...Events[Name]]> = fn
function listener(...args: [...Events[Name]]) {
self.off(name, listener)
callback.apply(ctx, args)
}
listener._ = fn
return this.on(name, listener, ctx)
}
emit<Name extends keyof Events>(name: Name, ...data: [...Events[Name]]): this {
const e: Registry<Events> = this.e || (this.e = {})
const evtArr = (e[name] || []).slice()
for (let i = 0; i < evtArr.length; i += 1) {
evtArr[i]!.fn.apply(evtArr[i]!.ctx, data)
}
return this
}
off<Name extends keyof Events>(name: Name, callback?: Listener<[...Events[Name]]>): this {
const e: Registry<Events> = this.e || (this.e = {})
const evts = e[name]
const liveEvents: Registration<[...Events[Name]]>[] = []
if (evts && callback) {
for (let i = 0, len = evts.length; i < len; i += 1) {
if (evts[i]!.fn !== callback && evts[i]!.fn._ !== callback)
liveEvents.push(evts[i]!)
}
}
if (liveEvents.length) {
e[name] = liveEvents
}
else {
delete e[name]
}
return this
}
}
@@ -1,12 +1,14 @@
import type ArtplayerToolThumbnail from './index'
import type { Cleanup, ExtractionJob, LifecycleState } from './types'
import { cancellation, cleanupAll, stateFor } from './lifecycle'
import { replaceThumbnail } from './source'
import { clamp } from './utils'
function prepare(tool, job) {
function prepare(tool: ArtplayerToolThumbnail, job: ExtractionJob) {
const { video } = job
const { width, number, begin, end } = tool.option
const height = (video.videoHeight / video.videoWidth) * width
const guard = (condition, message) => {
const guard = (condition: unknown, message: string) => {
try {
tool.errorHandle(condition, message)
}
@@ -28,23 +30,23 @@ function prepare(tool, job) {
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')
const context = canvas.getContext('2d')!
tool.emit('canvas', canvas)
return { width, height, number, points, canvas, context }
}
function createJob(tool, state) {
function createJob(tool: ArtplayerToolThumbnail, state: LifecycleState) {
let epoch = state.epoch
const video = tool.video
let resolve
let reject
let timer
let resolve!: () => void
let reject!: (error: unknown) => void
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
let running = false
let job
let job: ExtractionJob
let frameCleanup = () => {}
const listeners = []
const promise = new Promise((yes, no) => {
const listeners: Cleanup[] = []
const promise = new Promise<void>((yes, no) => {
resolve = yes
reject = no
})
@@ -62,7 +64,7 @@ function createJob(tool, state) {
cleanupAll([() => clearTimeout(timer), frameCleanup, ...listeners.splice(0)])
return true
}
function fail(error, report = true) {
function fail(error: unknown, report = true) {
if (settled)
return
try {
@@ -73,7 +75,7 @@ function createJob(tool, state) {
}
if (report) {
try {
tool.emit('error', error?.message)
tool.emit('error', (error as { message?: string } | null | undefined)?.message)
}
catch (listenerError) {
error = listenerError
@@ -92,7 +94,7 @@ function createJob(tool, state) {
catch (error) {
let failure = error
try {
tool.emit('error', error?.message)
tool.emit('error', (error as { message?: string } | null | undefined)?.message)
}
catch (listenerError) {
failure = listenerError
@@ -100,7 +102,7 @@ function createJob(tool, state) {
reject(failure)
}
}
function extract(plan) {
function extract(plan: ReturnType<typeof prepare>) {
if (!live())
return
running = true
@@ -113,7 +115,7 @@ function createJob(tool, state) {
complete()
return
}
const point = plan.points[index]
const point = plan.points[index]!
let drawing = false
let encoded = false
const previous = video.oncanplay
@@ -211,7 +213,7 @@ function createJob(tool, state) {
}],
['loadedmetadata', () => ready()],
['durationchange', () => ready()],
]) {
] as const) {
listeners.push(() => video.removeEventListener(name, callback))
video.addEventListener(name, callback)
if (!live()) {
@@ -231,7 +233,7 @@ function createJob(tool, state) {
return promise
}
export function startExtraction(tool) {
export function startExtraction(tool: ArtplayerToolThumbnail): Promise<void> {
const state = stateFor(tool)
if (state.closed) {
const promise = Promise.reject(cancellation('destroyed'))
@@ -1,3 +1,4 @@
import type { InputOptions, RuntimeOptions, ThumbnailEvents } from './types'
import Emitter from './emitter'
import { startExtraction } from './extraction'
import { connectInput, releaseInput, setupInput } from './input'
@@ -5,11 +6,21 @@ import { cleanupAll, closeState, releaseMedia, stateFor } from './lifecycle'
import { createSheet, downloadSheet, screenshotPoints } from './sheet'
import { loadSource } from './source'
export default class ArtplayerToolThumbnail extends Emitter {
constructor(option = {}) {
export default class ArtplayerToolThumbnail extends Emitter<ThumbnailEvents> {
declare processing: boolean
declare option: RuntimeOptions
declare video: HTMLVideoElement
declare duration: number
declare density: number | undefined
declare file: File | undefined
declare videoUrl: string | undefined
declare thumbnailUrl: string | undefined
constructor(option: InputOptions = {}) {
super()
this.processing = false
this.option = {}
// setup validates the temporary empty options before construction can succeed.
this.option = {} as RuntimeOptions
try {
this.setup(Object.assign({}, ArtplayerToolThumbnail.DEFAULTS, option))
this.video = ArtplayerToolThumbnail.creatVideo()
@@ -44,19 +55,19 @@ export default class ArtplayerToolThumbnail extends Emitter {
}
}
static ondragover(event) {
static ondragover(event: DragEvent) {
event.preventDefault()
}
ondrop(event) {
ondrop(event: DragEvent) {
if (stateFor(this).closed)
return
event.preventDefault()
const file = event.dataTransfer.files[0]
const file = event.dataTransfer!.files[0]
this.loadVideo(file)
}
setup(option = {}) {
setup(option: InputOptions = {}) {
if (!stateFor(this).closed)
setupInput(this, option)
return this
@@ -80,15 +91,15 @@ export default class ArtplayerToolThumbnail extends Emitter {
return video
}
inputChange(event) {
inputChange(event: Event) {
if (stateFor(this).closed)
return
const file = this.option.fileInput.files[0]
const file = this.option.fileInput.files![0]
this.loadVideo(file)
event.target.value = ''
;(event.target as HTMLInputElement).value = ''
}
loadVideo(file) {
loadVideo(file?: File | null) {
loadSource(this, file)
}
@@ -112,12 +123,12 @@ 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 name = downloadSheet(this.file, this.thumbnailUrl)
const name = downloadSheet(this.file!, this.thumbnailUrl!)
this.emit('download', name)
return this
}
errorHandle(condition, msg) {
errorHandle(condition: unknown, msg: string) {
if (!condition) {
this.emit('error', msg)
throw new Error(msg)
@@ -1,10 +1,21 @@
import type ArtplayerToolThumbnail from './index'
import type { InputOptions } from './types'
import { cancellation, stateFor } from './lifecycle'
import { clamp } from './utils'
const inputs = new WeakMap()
type Registration = readonly [name: string, callback: EventListener]
interface InputRecord {
input: HTMLInputElement
wrapper: HTMLElement | null
position: string
listeners: Registration[]
callbacks: Registration[] | null
}
function release(record) {
let failure
const inputs = new WeakMap<ArtplayerToolThumbnail, InputRecord>()
function release(record: InputRecord) {
let failure: unknown
for (const [name, callback] of record.listeners.splice(0)) {
try {
record.input.removeEventListener(name, callback)
@@ -28,7 +39,7 @@ function release(record) {
throw failure
}
function connect(record, callbacks) {
function connect(record: InputRecord, callbacks: Registration[]) {
try {
for (const [name, callback] of callbacks) {
record.listeners.push([name, callback])
@@ -44,12 +55,12 @@ function connect(record, callbacks) {
}
}
export function setupInput(tool, patch) {
export function setupInput(tool: ArtplayerToolThumbnail, patch: InputOptions) {
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
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`)
@@ -65,9 +76,10 @@ export function setupInput(tool, patch) {
tool.option = option
return option
}
const record = { input: target, wrapper: null, position: '', listeners: [], callbacks: previous?.callbacks || null }
// A wrapper is replaced by its owned file input before the record is committed.
const record: InputRecord = { input: target as HTMLInputElement, wrapper: null, position: '', listeners: [], callbacks: previous?.callbacks || null }
try {
if (!(target.tagName === 'INPUT' && target.type === 'file')) {
if (!(target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'file')) {
record.wrapper = target
record.position = target.style.position
record.input = document.createElement('input')
@@ -98,13 +110,14 @@ export function setupInput(tool, patch) {
return option
}
export function connectInput(tool, dragover) {
const record = inputs.get(tool)
record.callbacks = [['change', tool.inputChange], ['dragover', dragover], ['drop', tool.ondrop]]
export function connectInput(tool: ArtplayerToolThumbnail, dragover: (event: DragEvent) => void) {
const record = inputs.get(tool)!
// These callbacks are registered only for their matching DOM event names.
record.callbacks = [['change', tool.inputChange], ['dragover', dragover as EventListener], ['drop', tool.ondrop as EventListener]]
connect(record, record.callbacks)
}
export function releaseInput(tool) {
export function releaseInput(tool: ArtplayerToolThumbnail) {
const record = inputs.get(tool)
if (!record)
return
@@ -1,6 +1,9 @@
const states = new WeakMap()
import type ArtplayerToolThumbnail from './index'
import type { Cleanup, LifecycleState } from './types'
export function stateFor(tool) {
const states = new WeakMap<ArtplayerToolThumbnail, LifecycleState>()
export function stateFor(tool: ArtplayerToolThumbnail): LifecycleState {
if (!states.has(tool)) {
states.set(tool, {
closed: false,
@@ -16,24 +19,24 @@ export function stateFor(tool) {
loading: false,
})
}
return states.get(tool)
return states.get(tool)!
}
export function cancellation(reason) {
export function cancellation(reason: string) {
const error = new Error(`Thumbnail task cancelled: ${reason}`)
error.name = 'AbortError'
return error
}
export function revoke(urls, url) {
export function revoke(urls: Set<string>, url: string | undefined) {
if (!url)
return
urls.delete(url)
URL.revokeObjectURL(url)
}
export function cleanupAll(callbacks) {
let failure
export function cleanupAll(callbacks: Cleanup[]) {
let failure: unknown
for (const callback of callbacks) {
try {
callback()
@@ -46,7 +49,7 @@ export function cleanupAll(callbacks) {
throw failure
}
export function closeState(tool, reason = 'destroyed') {
export function closeState(tool: ArtplayerToolThumbnail, reason = 'destroyed') {
const state = stateFor(tool)
if (state.closed)
return false
@@ -56,11 +59,11 @@ export function closeState(tool, reason = 'destroyed') {
return true
}
export function releaseMedia(tool) {
export function releaseMedia(tool: ArtplayerToolThumbnail) {
const state = stateFor(tool)
const sources = new Set([...state.sourceUrls, tool.videoUrl])
const thumbnails = new Set([...state.thumbnailUrls, tool.thumbnailUrl])
const media = []
const media: Cleanup[] = []
for (const video of new Set([state.video, tool.video])) {
if (video) {
media.push(
@@ -1,11 +1,12 @@
import type { ScreenshotPoint, SheetOptions } from './types'
import { getFileName } from './utils'
export function screenshotPoints(option, duration) {
export function screenshotPoints(option: SheetOptions, duration: number): ScreenshotPoint[] {
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]
const last = timePoints[timePoints.length - 1]!
timePoints.push(last + timeGap)
}
return timePoints.map((item, index) => ({
@@ -15,10 +16,10 @@ export function screenshotPoints(option, duration) {
}))
}
export function createSheet(option) {
export function createSheet(option: SheetOptions) {
const { number, width, height, column } = option
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
const context = canvas.getContext('2d')!
canvas.width = width * column
canvas.height = Math.ceil(number / column) * height + 30
context.fillStyle = 'black'
@@ -33,7 +34,7 @@ export function createSheet(option) {
return canvas
}
export function downloadSheet(file, url) {
export function downloadSheet(file: File, url: string) {
const link = document.createElement('a')
const name = `${getFileName(file.name)}.png`
link.download = name
@@ -1,6 +1,8 @@
import type ArtplayerToolThumbnail from './index'
import type { Cleanup } from './types'
import { cleanupAll, revoke, stateFor } from './lifecycle'
export function loadSource(tool, file) {
export function loadSource(tool: ArtplayerToolThumbnail, file?: File | null) {
const state = stateFor(tool)
if (!file || state.closed)
return
@@ -23,7 +25,7 @@ export function loadSource(tool, file) {
state.loading = true
const current = () => !state.closed && state.epoch === epoch
const previousListeners = state.sourceListeners
const listeners = []
const listeners: Cleanup[] = []
state.sourceListeners = listeners
let pendingCleanup = () => {}
let reported = false
@@ -50,7 +52,7 @@ export function loadSource(tool, file) {
revoke(state.sourceUrls, url)
return
}
for (const [name, callback] of [['error', error], ['loadedmetadata', ready]]) {
for (const [name, callback] of [['error', error], ['loadedmetadata', ready]] as const) {
pendingCleanup = () => video.removeEventListener(name, callback)
listeners.push(pendingCleanup)
video.addEventListener(name, callback)
@@ -94,7 +96,7 @@ export function loadSource(tool, file) {
}
}
export function replaceThumbnail(tool, blob, live) {
export function replaceThumbnail(tool: ArtplayerToolThumbnail, blob: Blob, live: () => boolean) {
const state = stateFor(tool)
const url = URL.createObjectURL(blob)
state.thumbnailUrls.add(url)
@@ -0,0 +1,62 @@
export interface SheetOptions {
number: number
width: number
height: number
column: number
begin: number
end: number
}
export interface InputOptions extends Partial<SheetOptions> {
fileInput?: Element
[name: string]: unknown
}
export interface RuntimeOptions extends SheetOptions {
fileInput: HTMLInputElement
[name: string]: unknown
}
export interface ScreenshotPoint {
time: number
x: number
y: number
}
export interface ThumbnailEvents extends Record<PropertyKey, unknown[]> {
file: [file: File]
video: [video: HTMLVideoElement]
canvas: [canvas: HTMLCanvasElement]
update: [url: string, progress: number]
done: []
download: [name: string]
error: [message: string | undefined]
destroy: []
}
export type Cleanup = () => void
export interface ExtractionJob {
video: HTMLVideoElement
promise: Promise<void>
ready: (synchronous?: boolean) => void
fail: (error: unknown, report?: boolean) => void
cancel: (reason: string) => void
adopt: (epoch: number) => boolean
reported?: unknown
wasReported?: boolean
}
export interface LifecycleState {
closed: boolean
epoch: number
inputEpoch: number
job: ExtractionJob | null
video: HTMLVideoElement | null
sourceUrl: string | null
thumbnailUrl: string | null
sourceUrls: Set<string>
thumbnailUrls: Set<string>
sourceListeners: Cleanup[]
loading: boolean
}
@@ -1,17 +0,0 @@
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
export function runPromisesInSeries(ps) {
return ps.reduce((p, next) => p.then(next), Promise.resolve())
}
export function getFileName(name) {
const nameArray = name.split('.')
nameArray.pop()
return nameArray.join('.')
}
export function clamp(num, a, b) {
return Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b))
}
@@ -0,0 +1,9 @@
export function getFileName(name: string) {
const nameArray = name.split('.')
nameArray.pop()
return nameArray.join('.')
}
export function clamp(num: number, a: number, b: number) {
return Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b))
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts"]
}