refactor(thumbnail): [PKG-TOOL-THUMB-04] preserve approved published defaults

This commit is contained in:
Harvey Zhao committed 2026-09-15 23:54:24 +08:00
1 parent 490e157717
commit b5f8b5e28e
37 files changed
+11172 -136

No files matched your search

+10 -1
View File
@@ -2,7 +2,7 @@
/** Extract PNG thumbnail sheets from a locally selected video file. */
declare class ArtplayerToolThumbnail {
constructor(option?: ArtplayerToolThumbnail.Option)
static readonly DEFAULTS: ArtplayerToolThumbnail.SheetOptions
static readonly DEFAULTS: ArtplayerToolThumbnail.DefaultOptions
static ondragover(event: DragEvent): void
static creatVideo(): HTMLVideoElement
processing: boolean
@@ -39,13 +39,22 @@ declare namespace ArtplayerToolThumbnail {
begin: number
end: number
}
type Compatibility = 'published-3.5' | 'workspace-4.4'
interface DefaultOptions extends SheetOptions {
delay: number
}
/** fileInput must be a file input or an Element wrapper when constructing. */
interface Option extends Partial<SheetOptions> {
fileInput?: Element
compatibility?: Compatibility
/** Published mode delay in milliseconds, clamped to 10-1000. */
delay?: number
[name: string]: unknown
}
interface ResolvedOption extends SheetOptions {
fileInput: HTMLInputElement
compatibility?: Compatibility
delay?: number
[name: string]: unknown
}
interface ScreenshotPoint {
+1 -1
View File
@@ -31,4 +31,4 @@
* 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";const e=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);class t{on(t,n,r){const o=this.e||(this.e={});let i=e(o,t)?o[t]:void 0;return i||(i=[],Object.defineProperty(o,t,{value:i,enumerable:!0,configurable:!0,writable:!0})),i.push({fn:n,ctx:r}),this}once(e,t,n){const r=this,o=t;let i=!1;function s(...t){i||(i=!0,r.off(e,s),o.apply(n,t))}return s._=t,this.on(e,s,n)}emit(t,...n){const r=this.e||(this.e={}),o=(e(r,t)&&r[t]||[]).slice();for(let e=0;e<o.length;e+=1)o[e].fn.apply(o[e].ctx,n);return this}off(t,n){const r=this.e||(this.e={}),o=e(r,t)?r[t]:void 0,i=[];if(o&&n)for(let e=0,s=o.length;e<s;e+=1)o[e].fn!==n&&o[e].fn._!==n&&i.push(o[e]);return i.length?r[t]=i:delete r[t],this}}const n=new WeakMap;function r(e){return n.has(e)||n.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}),n.get(e)}function o(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function i(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function s(e){let t,n=!1;for(const o of e)try{o()}catch(r){n||(n=!0,t=r)}if(n)throw t}function c(e,t="destroyed"){const n=r(e);return!n.closed&&(n.closed=!0,n.epoch++,n.job?.cancel(t),!0)}function a(e){const t=r(e),n=new Set([...t.sourceUrls,e.videoUrl]),o=new Set([...t.thumbnailUrls,e.thumbnailUrl]),c=[];for(const r of new Set([t.video,e.video]))r&&c.push(()=>r.pause?.(),()=>r.removeAttribute?.("src"),()=>r.load?.(),()=>r.parentNode?.removeChild(r));s([...t.sourceListeners.splice(0),...c,...[...n].map(e=>()=>i(t.sourceUrls,e)),...[...o].map(e=>()=>i(t.thumbnailUrls,e))])}function l(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function u(e,t){let n=t.epoch;const c=e.video;let a,u,d,h,p=!1,f=!1,m=()=>{};const b=[],y=new Promise((e,t)=>{a=e,u=t});y.catch(()=>{});const g=()=>!p&&!t.closed&&t.epoch===n&&t.job===h;function v(){return!p&&(p=!0,t.job===h&&(t.job=null,e.processing=!1),s([()=>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 n=0;const o=()=>{if(!g())return;if(n===t.points.length)return void function(){if(g())try{v(),e.emit("done"),a()}catch(t){let r=t;try{e.emit("error",t?.message)}catch(n){r=n}u(r)}}();const s=t.points[n];let l=!1,d=!1;const h=c.oncanplay,p=()=>{if(!(!g()||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),!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,n){const o=r(e),s=URL.createObjectURL(t);if(o.thumbnailUrls.add(s),!n())return i(o.thumbnailUrls,s),null;const c=new Set([...o.thumbnailUrls,e.thumbnailUrl]);o.thumbnailUrl=s,e.thumbnailUrl=s;for(const r of c)r&&r!==s&&i(o.thumbnailUrls,r);return n()?s:null}(e,s,g);if(!c)return;e.emit("update",c,(n+1)/t.number),n++,Promise.resolve().then(o)}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(o)}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,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),g()||clearTimeout(d)}h={video:c,promise:y,ready:E,fail:w,cancel(e){w(o(e),!1)},adopt:t=>!p&&!f&&c===e.video&&(n=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.removeEventLisLine 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";const e=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);class t{on(t,o,n){const r=this.e||(this.e={});let i=e(r,t)?r[t]:void 0;return i||(i=[],Object.defineProperty(r,t,{value:i,enumerable:!0,configurable:!0,writable:!0})),i.push({fn:o,ctx:n}),this}once(e,t,o){const n=this,r=t;let i=!1;function s(...t){i||(i=!0,n.off(e,s),r.apply(o,t))}return s._=t,this.on(e,s,o)}emit(t,...o){const n=this.e||(this.e={}),r=(e(n,t)&&n[t]||[]).slice();for(let e=0;e<r.length;e+=1)r[e].fn.apply(r[e].ctx,o);return this}off(t,o){const n=this.e||(this.e={}),r=e(n,t)?n[t]:void 0,i=[];if(r&&o)for(let e=0,s=r.length;e<s;e+=1)r[e].fn!==o&&r[e].fn._!==o&&i.push(r[e]);return i.length?n[t]=i:delete n[t],this}}const o=new WeakMap;function n(e){return o.has(e)||o.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}),o.get(e)}function r(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function i(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function s(e){let t,o=!1;for(const r of e)try{r()}catch(n){o||(o=!0,t=n)}if(o)throw t}function c(e,t="destroyed"){const o=n(e);return!o.closed&&(o.closed=!0,o.epoch++,o.job?.cancel(t),!0)}function a(e){const t=n(e),o=new Set([...t.sourceUrls,e.videoUrl]),r=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));s([...t.sourceListeners.splice(0),...c,...[...o].map(e=>()=>i(t.sourceUrls,e)),...[...r].map(e=>()=>i(t.thumbnailUrls,e))])}function l(e,t,o){return Math.max(Math.min(e,Math.max(t,o)),Math.min(t,o))}function u(e){const t="workspace-4.4"===e.compatibility;return{delay:t?null:e.delay,aspectHeight:t,resetInput:t}}function d(e,t){let o=t.epoch;const c=e.video;let a,d,h,p,f=!1,m=!1,b=()=>{};const y=[],g=new Promise((e,t)=>{a=e,d=t});g.catch(()=>{});const v=()=>!f&&!t.closed&&t.epoch===o&&t.job===p;function w(){return!f&&(f=!0,t.job===p&&(t.job=null,e.processing=!1),s([()=>clearTimeout(h),b,...y.splice(0)]),!0)}function U(t,o=!0){if(!f){try{w()}catch(n){t||(t=n)}if(o)try{e.emit("error",t?.message)}catch(r){t=r}d(t)}}function E(){if(v())try{w(),e.emit("done"),a()}catch(t){let n=t;try{e.emit("error",t?.message)}catch(o){n=o}d(n)}}function T(t){if(!v())return;m=!0,e.processing=!0;let o=0;const r=()=>{if(!v())return;if(o===t.points.length)return void(null!==t.delay?h=setTimeout(E,2*Number(t.delay)):E());const s=t.points[o];let a=!1,l=!1,u=null===t.delay;const d=c.oncanplay,p=()=>{if(!(!v()||!u||a||c.seeking||void 0!==c.readyState&&c.readyState<2)){a=!0;try{if(b(),t.context.drawImage(c,s.x,s.y,t.width,t.height),!v())return;t.canvas.toBlob(s=>{if(v()&&!l){l=!0;try{if(!s)throw new Error("Unable to create thumbnail image");const c=function(e,t,o){const r=n(e),s=URL.createObjectURL(t);if(r.thumbnailUrls.add(s),!o())return i(r.thumbnailUrls,s),null;const c=new Set([...r.thumbnailUrls,e.thumbnailUrl]);r.thumbnailUrl=s,e.thumbnailUrl=s;for(const n of c)n&&n!==s&&i(r.thumbnailUrls,n);return o()?s:null}(e,s,v);if(!c)return;e.emit("update",c,(o+1)/t.number),o++,Promise.resolve().then(r)}catch(c){U(c)}}})}catch(d){U(d)}}};b=()=>{c.oncanplay===p&&(c.oncanplay=d),c.removeEventListener("seeked",p)};try{c.oncanplay=p,c.addEventListener("seeked",p),c.currentTime=s.time,null!==t.delay?(h=setTimeout(()=>{u=!0,p()},t.delay),v()||clearTimeout(h)):c.readyState>=2&&!c.seeking&&Promise.resolve().then(p)}catch(f){U(f)}};Promise.resolve().then(r)}function j(o=!1){if(v()&&!m)if(clearTimeout(h),!t.loading&&c.duration){m=!0;try{const t=function(e,t){const{video:o}=t,{width:n,number:r,begin:i,end:s}=e.option,c=u(e.option),a=c.aspectHeight?o.videoHeight/o.videoWidth*n:e.option.height,d=(o,n)=>{try{e.errorHandle(o,n)}catch(r){throw t.reported=r,t.wasReported=!0,r}};e.option.height=a,e.option.begin=l(i,0,o.duration),e.option.end=l(s||o.duration,i,o.duration),d(e.option.end>e.option.begin,"End time must be greater than the start time"),d(Number.isFinite(o.duration),"Video duration must be finite"),e.duration=e.option.end-e.option.begin,e.density=r/e.duration,d(e.file&&o,"Please select the video file first"),d(!e.processing,"There is currently a task in progress, please wait a moment..."),d(e.density<=1,`The preview density cannot be greater than 1, but got ${e.density}`);const h=e.creatScreenshotDate(),p=e.creatCanvas(),f=p.getContext("2d");return e.emit("canvas",p),{width:n,height:a,number:r,points:h,canvas:p,context:f,delay:c.delay}}(e,p);T(t)}catch(n){if(U(n,!o&&!(p.wasReported&&n===p.reported)),o)throw n}}else h=setTimeout(()=>j(),1e3),v()Line truncated
@@ -31,4 +31,4 @@
* 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";const e=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);class t{on(t,n,r){const o=this.e||(this.e={});let i=e(o,t)?o[t]:void 0;return i||(i=[],Object.defineProperty(o,t,{value:i,enumerable:!0,configurable:!0,writable:!0})),i.push({fn:n,ctx:r}),this}once(e,t,n){const r=this,o=t;let i=!1;function s(...t){i||(i=!0,r.off(e,s),o.apply(n,t))}return s._=t,this.on(e,s,n)}emit(t,...n){const r=this.e||(this.e={}),o=(e(r,t)&&r[t]||[]).slice();for(let e=0;e<o.length;e+=1)o[e].fn.apply(o[e].ctx,n);return this}off(t,n){const r=this.e||(this.e={}),o=e(r,t)?r[t]:void 0,i=[];if(o&&n)for(let e=0,s=o.length;e<s;e+=1)o[e].fn!==n&&o[e].fn._!==n&&i.push(o[e]);return i.length?r[t]=i:delete r[t],this}}const n=new WeakMap;function r(e){return n.has(e)||n.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}),n.get(e)}function o(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function i(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function s(e){let t,n=!1;for(const o of e)try{o()}catch(r){n||(n=!0,t=r)}if(n)throw t}function c(e,t="destroyed"){var n;const o=r(e);return!o.closed&&(o.closed=!0,o.epoch++,null==(n=o.job)||n.cancel(t),!0)}function l(e){const t=r(e),n=new Set([...t.sourceUrls,e.videoUrl]),o=new Set([...t.thumbnailUrls,e.thumbnailUrl]),c=[];for(const r of new Set([t.video,e.video]))r&&c.push(()=>{var e;return null==(e=r.pause)?void 0:e.call(r)},()=>{var e;return null==(e=r.removeAttribute)?void 0:e.call(r,"src")},()=>{var e;return null==(e=r.load)?void 0:e.call(r)},()=>{var e;return null==(e=r.parentNode)?void 0:e.removeChild(r)});s([...t.sourceListeners.splice(0),...c,...[...n].map(e=>()=>i(t.sourceUrls,e)),...[...o].map(e=>()=>i(t.thumbnailUrls,e))])}function a(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function u(e,t){let n=t.epoch;const c=e.video;let l,u,d,h,p=!1,f=!1,m=()=>{};const b=[],v=new Promise((e,t)=>{l=e,u=t});v.catch(()=>{});const y=()=>!p&&!t.closed&&t.epoch===n&&t.job===h;function g(){return!p&&(p=!0,t.job===h&&(t.job=null,e.processing=!1),s([()=>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 n=0;const o=()=>{if(!y())return;if(n===t.points.length)return void function(){if(y())try{g(),e.emit("done"),l()}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[n];let a=!1,d=!1;const h=c.oncanplay,p=()=>{if(!(!y()||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),!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,n){const o=r(e),s=URL.createObjectURL(t);if(o.thumbnailUrls.add(s),!n())return i(o.thumbnailUrls,s),null;const c=new Set([...o.thumbnailUrls,e.thumbnailUrl]);o.thumbnailUrl=s,e.thumbnailUrl=s;for(const r of c)r&&r!==s&&i(o.thumbnailUrls,r);return n()?s:null}(e,s,y);if(!c)return;e.emit("update",c,(n+1)/t.number),n++,Promise.resolve().then(o)}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(o)}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,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),y()||clearTimeout(d)}h={video:c,promise:v,ready:E,fail:w,cancel(e){w(o(e),!1)},adopt:t=>!p&&!f&&c===e.video&&(n=t,!0)},t.job=h;tryLine 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";const e=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);class t{on(t,n,o){const r=this.e||(this.e={});let i=e(r,t)?r[t]:void 0;return i||(i=[],Object.defineProperty(r,t,{value:i,enumerable:!0,configurable:!0,writable:!0})),i.push({fn:n,ctx:o}),this}once(e,t,n){const o=this,r=t;let i=!1;function s(...t){i||(i=!0,o.off(e,s),r.apply(n,t))}return s._=t,this.on(e,s,n)}emit(t,...n){const o=this.e||(this.e={}),r=(e(o,t)&&o[t]||[]).slice();for(let e=0;e<r.length;e+=1)r[e].fn.apply(r[e].ctx,n);return this}off(t,n){const o=this.e||(this.e={}),r=e(o,t)?o[t]:void 0,i=[];if(r&&n)for(let e=0,s=r.length;e<s;e+=1)r[e].fn!==n&&r[e].fn._!==n&&i.push(r[e]);return i.length?o[t]=i:delete o[t],this}}const n=new WeakMap;function o(e){return n.has(e)||n.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}),n.get(e)}function r(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function i(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function s(e){let t,n=!1;for(const r of e)try{r()}catch(o){n||(n=!0,t=o)}if(n)throw t}function l(e,t="destroyed"){var n;const r=o(e);return!r.closed&&(r.closed=!0,r.epoch++,null==(n=r.job)||n.cancel(t),!0)}function a(e){const t=o(e),n=new Set([...t.sourceUrls,e.videoUrl]),r=new Set([...t.thumbnailUrls,e.thumbnailUrl]),l=[];for(const o of new Set([t.video,e.video]))o&&l.push(()=>{var e;return null==(e=o.pause)?void 0:e.call(o)},()=>{var e;return null==(e=o.removeAttribute)?void 0:e.call(o,"src")},()=>{var e;return null==(e=o.load)?void 0:e.call(o)},()=>{var e;return null==(e=o.parentNode)?void 0:e.removeChild(o)});s([...t.sourceListeners.splice(0),...l,...[...n].map(e=>()=>i(t.sourceUrls,e)),...[...r].map(e=>()=>i(t.thumbnailUrls,e))])}function c(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function u(e){const t="workspace-4.4"===e.compatibility;return{delay:t?null:e.delay,aspectHeight:t,resetInput:t}}function d(e,t){let n=t.epoch;const l=e.video;let a,d,h,p,f=!1,m=!1,b=()=>{};const y=[],v=new Promise((e,t)=>{a=e,d=t});v.catch(()=>{});const g=()=>!f&&!t.closed&&t.epoch===n&&t.job===p;function w(){return!f&&(f=!0,t.job===p&&(t.job=null,e.processing=!1),s([()=>clearTimeout(h),b,...y.splice(0)]),!0)}function U(t,n=!0){if(!f){try{w()}catch(o){t||(t=o)}if(n)try{e.emit("error",null==t?void 0:t.message)}catch(r){t=r}d(t)}}function E(){if(g())try{w(),e.emit("done"),a()}catch(t){let o=t;try{e.emit("error",null==t?void 0:t.message)}catch(n){o=n}d(o)}}function T(t){if(!g())return;m=!0,e.processing=!0;let n=0;const r=()=>{if(!g())return;if(n===t.points.length)return void(null!==t.delay?h=setTimeout(E,2*Number(t.delay)):E());const s=t.points[n];let a=!1,c=!1,u=null===t.delay;const d=l.oncanplay,p=()=>{if(!(!g()||!u||a||l.seeking||void 0!==l.readyState&&l.readyState<2)){a=!0;try{if(b(),t.context.drawImage(l,s.x,s.y,t.width,t.height),!g())return;t.canvas.toBlob(s=>{if(g()&&!c){c=!0;try{if(!s)throw new Error("Unable to create thumbnail image");const l=function(e,t,n){const r=o(e),s=URL.createObjectURL(t);if(r.thumbnailUrls.add(s),!n())return i(r.thumbnailUrls,s),null;const l=new Set([...r.thumbnailUrls,e.thumbnailUrl]);r.thumbnailUrl=s,e.thumbnailUrl=s;for(const o of l)o&&o!==s&&i(r.thumbnailUrls,o);return n()?s:null}(e,s,g);if(!l)return;e.emit("update",l,(n+1)/t.number),n++,Promise.resolve().then(r)}catch(l){U(l)}}})}catch(d){U(d)}}};b=()=>{l.oncanplay===p&&(l.oncanplay=d),l.removeEventListener("seeked",p)};try{l.oncanplay=p,l.addEventListener("seeked",p),l.currentTime=s.time,null!==t.delay?(h=setTimeout(()=>{u=!0,p()},t.delay),g()||clearTimeout(h)):l.readyState>=2&&!l.seeking&&Promise.resolve().then(p)}catch(f){U(f)}};Promise.resolve().then(r)}function j(n=!1){if(g()&&!m)if(clearTimeout(h),!t.loading&&l.duration){m=!0;try{const t=function(e,t){const{video:n}=t,{width:o,number:r,begin:i,end:s}=e.option,l=u(e.option),a=l.aspectHeight?n.videoHeight/n.videoWidth*o:e.option.height,d=(n,o)=>{try{e.errorHandle(n,o)}catch(r){throw t.reported=r,t.wasReported=!0,r}};e.option.height=a,e.option.begin=c(i,0,n.duration),e.option.end=c(s||n.duration,i,n.duration),d(e.option.end>e.option.begin,"End time must be greater than the start time"),d(Number.isFinite(n.duration),"Video duration must be finite"),e.duration=e.option.end-e.option.begin,e.density=r/e.duration,d(e.file&&n,"Please select the video file first"),d(!e.processing,"There is currently a task in progress, please wait a moment..."),d(e.density<=1,`The preview density cannot be greater than 1, but got ${e.density}`);const h=e.creatScreenshotDate(),p=e.creatCanvas(),f=p.getContext("2d");return e.emiLine truncated
+67 -21
View File
@@ -160,12 +160,39 @@ function releaseMedia(tool) {
...[...thumbnails].map((url) => () => revoke(state.thumbnailUrls, url))
]);
}
function getFileName(name) {
const nameArray = name.split(".");
nameArray.pop();
return nameArray.join(".");
}
function clamp(num, a, b) {
return Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b));
}
function thumbnailPolicy(option) {
const workspace = option.compatibility === "workspace-4.4";
return { delay: workspace ? null : option.delay, aspectHeight: workspace, resetInput: workspace };
}
function normalizePolicy(option, validate) {
validate(option.compatibility === void 0 || option.compatibility === "published-3.5" || option.compatibility === "workspace-4.4", "The compatibility option must be published-3.5 or workspace-4.4");
const workspace = thumbnailPolicy(option).aspectHeight;
const fields = workspace ? ["number", "width", "column", "begin", "end"] : ["delay", "number", "width", "height", "column", "begin", "end"];
for (const name of fields)
validate(typeof option[name] === "number", `The '${name}' is not a number`);
option.number = clamp(option.number, 10, 1e3);
option.width = clamp(option.width, 10, 1e3);
option.column = clamp(option.column, 1, 1e3);
if (!workspace) {
option.delay = clamp(Number(option.delay), 10, 1e3);
option.height = clamp(option.height, 10, 1e3);
}
}
function loadSource(tool, file) {
const state = stateFor(tool);
if (!file || state.closed)
return;
const initialEpoch = state.epoch;
const video = tool.video;
const { delay } = thumbnailPolicy(tool.option);
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)
@@ -187,11 +214,15 @@ function loadSource(tool, file) {
state.sourceListeners = listeners;
let pendingCleanup = () => {
};
let notificationTimer;
const cancelNotification = () => clearTimeout(notificationTimer);
listeners.push(cancelNotification);
let reported = false;
const error = () => {
if (!current() || reported || video.currentSrc && video.currentSrc !== url)
return;
reported = true;
cancelNotification();
state.loading = false;
const failure = new Error(`Unable to load video: media error ${video.error?.code || 0}`);
if (state.job)
@@ -238,8 +269,19 @@ function loadSource(tool, file) {
if (previous !== url)
revoke(state.sourceUrls, previous);
}
if (current())
tool.emit("video", video);
if (current() && !reported) {
if (delay !== null) {
notificationTimer = setTimeout(() => {
cancelNotification();
if (current() && !reported)
tool.emit("video", video);
}, delay);
if (!current() || reported)
cancelNotification();
} else {
tool.emit("video", video);
}
}
} catch (error2) {
if (current()) {
state.loading = false;
@@ -270,18 +312,11 @@ function replaceThumbnail(tool, blob, live) {
}
return live() ? url : null;
}
function getFileName(name) {
const nameArray = name.split(".");
nameArray.pop();
return nameArray.join(".");
}
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 policy = thumbnailPolicy(tool.option);
const height = policy.aspectHeight ? video.videoHeight / video.videoWidth * width : tool.option.height;
const guard = (condition, message) => {
try {
tool.errorHandle(condition, message);
@@ -305,7 +340,7 @@ function prepare(tool, job) {
const canvas = tool.creatCanvas();
const context = canvas.getContext("2d");
tool.emit("canvas", canvas);
return { width, height, number, points, canvas, context };
return { width, height, number, points, canvas, context, delay: policy.delay };
}
function createJob(tool, state) {
let epoch = state.epoch;
@@ -381,15 +416,19 @@ function createJob(tool, state) {
if (!live())
return;
if (index === plan.points.length) {
complete();
if (plan.delay !== null)
timer = setTimeout(complete, Number(plan.delay) * 2);
else
complete();
return;
}
const point = plan.points[index];
let drawing = false;
let encoded = false;
let delayElapsed = plan.delay === null;
const previous = video.oncanplay;
const draw = () => {
if (!live() || drawing || video.seeking || video.readyState !== void 0 && video.readyState < 2)
if (!live() || !delayElapsed || drawing || video.seeking || video.readyState !== void 0 && video.readyState < 2)
return;
drawing = true;
try {
@@ -427,8 +466,16 @@ function createJob(tool, state) {
video.oncanplay = draw;
video.addEventListener("seeked", draw);
video.currentTime = point.time;
if (video.readyState >= 2 && !video.seeking)
if (plan.delay !== null) {
timer = setTimeout(() => {
delayElapsed = true;
draw();
}, plan.delay);
if (!live())
clearTimeout(timer);
} else if (video.readyState >= 2 && !video.seeking) {
Promise.resolve().then(draw);
}
} catch (error) {
fail(error);
}
@@ -552,11 +599,7 @@ function setupInput(tool, patch) {
const option = Object.assign({}, tool.option, patch);
const target = option.fileInput;
tool.errorHandle(target instanceof Element, "The 'fileInput' is not a Element");
for (const name of ["number", "width", "column", "begin", "end"])
tool.errorHandle(typeof option[name] === "number", `The '${name}' is not a number`);
option.number = clamp(option.number, 10, 1e3);
option.width = clamp(option.width, 10, 1e3);
option.column = clamp(option.column, 1, 1e3);
normalizePolicy(option, (condition, message) => tool.errorHandle(condition, message));
if (!current())
throw cancellation("input setup superseded");
const previous = inputs.get(tool);
@@ -681,6 +724,7 @@ class ArtplayerToolThumbnail extends Emitter {
}
static get DEFAULTS() {
return {
delay: 300,
number: 60,
width: 160,
height: 90,
@@ -723,9 +767,11 @@ class ArtplayerToolThumbnail extends Emitter {
inputChange(event) {
if (stateFor(this).closed)
return;
const { resetInput } = thumbnailPolicy(this.option);
const file = this.option.fileInput.files[0];
this.loadVideo(file);
event.target.value = "";
if (resetInput)
event.target.value = "";
}
loadVideo(file) {
loadSource(this, file);
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:llm && yarn build:vconsole && yarn build:console && yarn build:site-notices && yarn build:test && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/danmuku-mask-lifecycle.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/jassub-registration.test.js test/jassub-runtime.test.js test/jassub-vendor-lifecycle.test.js test/jassub-frame-polyfill.test.js test/jassub-offscreen.test.js test/multiple-subtitles-ass.test.js test/multiple-subtitles-caption.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-entities.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-exports.test.js test/auto-thumbnail-canvas.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-encoding.test.js test/auto-thumbnail-metadata.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail-registration.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast-exports.test.js test/vast.test.js test/vast-lifecycle.test.js test/vast-compatibility.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js test/asr-media-error.test.js test/thumbnail-cleanup-errors.test.js test/dash-cleanup-errors.test.js test/hls-example.test.js test/dash-example.test.js",
"test:unit": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/danmuku-mask-lifecycle.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/jassub-registration.test.js test/jassub-runtime.test.js test/jassub-vendor-lifecycle.test.js test/jassub-frame-polyfill.test.js test/jassub-offscreen.test.js test/multiple-subtitles-ass.test.js test/multiple-subtitles-caption.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-entities.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-exports.test.js test/auto-thumbnail-canvas.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-encoding.test.js test/auto-thumbnail-metadata.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail-registration.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast-exports.test.js test/vast.test.js test/vast-lifecycle.test.js test/vast-compatibility.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js test/asr-media-error.test.js test/thumbnail-cleanup-errors.test.js test/dash-cleanup-errors.test.js test/hls-example.test.js test/dash-example.test.js test/thumbnail-policy.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -85,7 +85,7 @@
"generate:media:hls": "node scripts/generate-hls-fixture.mjs",
"test:iframe": "node --test test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe.test.js test/iframe-lifecycle.test.js refactor/scripts/iframe-contract.test.mjs refactor/scripts/iframe-types.test.mjs",
"test:iframe-types-package": "node refactor/scripts/iframe-package-types.mjs",
"test:thumbnail": "node --test test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs refactor/scripts/thumbnail-types.test.mjs test/thumbnail-cleanup-errors.test.js",
"test:thumbnail": "node --test test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs refactor/scripts/thumbnail-types.test.mjs test/thumbnail-cleanup-errors.test.js test/thumbnail-policy.test.js",
"test:thumbnail-types-package": "node refactor/scripts/thumbnail-package-types.mjs",
"test:auto-thumbnail": "node --test test/auto-thumbnail-exports.test.js test/auto-thumbnail-canvas.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-encoding.test.js test/auto-thumbnail-metadata.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail-registration.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs refactor/scripts/auto-thumbnail-types.test.mjs",
"test:vtt-thumbnail": "node --test test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js refactor/scripts/vtt-thumbnail-contract.test.mjs refactor/scripts/vtt-thumbnail-types.test.mjs",
@@ -2,14 +2,13 @@
The runtime responsibilities were separated under PKG-TOOL-THUMB-03 and are now
strict TypeScript under PKG-TOOL-THUMB-04. Public declarations and isolated installed
entrypoints now have checks; the historical default-policy decision and final
integration/release gates remain open.
entrypoints now have checks; final integration/release gates remain open.
The pending choice is now specified in the
[compatibility proposal](../../refactor/thumbnail-compatibility-decision.md):
The user approved the
[compatibility decision](../../refactor/thumbnail-compatibility-decision.md) on 2026-09-15:
recovered 3.5.31 defaults versus opt-in workspace 4.4.0 behavior, including the
class-level DEFAULTS boundary. It is not approved or implemented; do not infer
permission from the approved VAST or other package decisions.
class-level DEFAULTS boundary. The root constructor now defaults to the published
policy; `compatibility: 'workspace-4.4'` selects the previous workspace behavior.
| File | Responsibility |
| --- | --- |
@@ -18,6 +17,7 @@ permission from the approved VAST or other package decisions.
| 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/policy.ts | Approved mode selection and numeric normalization; no DOM or timer ownership |
| 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 |
@@ -30,8 +30,8 @@ Input/source use lifecycle state, extraction uses lifecycle/source, and sheet us
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,
file/video event order, public method spelling and return values stay unchanged.
existing instance-field order and bound inputChange/ondrop methods. Public method
spelling and return values stay unchanged. DEFAULTS includes published delay: 300.
Input setup validates options before changing DOM or committed options. A wrapper
receives one owned input; repeated setup with that wrapper reuses it. Replacement
@@ -62,8 +62,9 @@ behavior, checks first-error identity across nested cleanup and verifies resourc
## 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
Loading emits file before assigning video.src, then video after the configured
delay in the default published policy, or synchronously in workspace mode.
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
@@ -76,7 +77,15 @@ Preflight stays synchronous when metadata is ready. Canvas precedes processing=t
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
Published mode keeps configured height (clamped to 10-1000), waits delay after each
seek and delay * 2 after the last update before done. Delay defaults to 300 and is
clamped to 10-1000. Workspace mode derives height from video aspect ratio, ignores
delay and resets the selected input value; published mode retains that value.
Source notifications and extraction waits own their timers separately, so replacement,
error and destroy cancel both without late events. A null policy delay means no
timer; NaN retains the historical asynchronous browser timer behavior.
Frames wait for readiness/seek as well as any policy delay, 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
@@ -117,14 +126,16 @@ 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 published declarations and installed consumers in 04; preserve
old tests and the default delay/height boundary. The [public type guide](types/README.md)
complete. Preserve old tests and the approved delay/height boundary. The new
`test/thumbnail-policy.test.js` covers both modes, owned delays and cancellation;
browser extraction checks both policies against the frozen historical bundles.
The [public type guide](types/README.md)
documents declaration ownership, module entries, events and installed consumers.
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
All nine 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
+22 -1
View File
@@ -4,9 +4,30 @@ Thumbnail tool for ArtPlayer
Implementation and migration notes: [ARCHITECTURE.md](./ARCHITECTURE.md).
The default constructor preserves published 3.5.31 behavior: fixed `height: 90`,
`delay: 300` before the video event and each frame, and twice that delay before
completion. Numeric height/delay values are clamped to 10-1000. File selection
retains the input value. Existing methods, including `creatCanvas` and
`creatScreenshotDate`, retain their names.
Consumers of the unpublished 4.4 workspace behavior should select it explicitly:
```js
const tool = new ArtplayerToolThumbnail({
fileInput: document.querySelector('input[type=file]'),
compatibility: 'workspace-4.4',
})
```
This mode calculates height from the video aspect ratio, emits `video` synchronously,
has no fixed extraction delays and clears the file input value. Class-level
`DEFAULTS` always describes the published policy, including `delay`; it does not
vary by instance. Both modes wait for frame readiness and cancel owned work on
source replacement or destruction.
## Demo
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-tool-thumbnail/index.js&example=thumbnail)
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-tool-thumbnail/index.js&example=tool.thumbnail)
## License
@@ -31,4 +31,4 @@
* 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";const e=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);class t{on(t,n,r){const o=this.e||(this.e={});let i=e(o,t)?o[t]:void 0;return i||(i=[],Object.defineProperty(o,t,{value:i,enumerable:!0,configurable:!0,writable:!0})),i.push({fn:n,ctx:r}),this}once(e,t,n){const r=this,o=t;let i=!1;function s(...t){i||(i=!0,r.off(e,s),o.apply(n,t))}return s._=t,this.on(e,s,n)}emit(t,...n){const r=this.e||(this.e={}),o=(e(r,t)&&r[t]||[]).slice();for(let e=0;e<o.length;e+=1)o[e].fn.apply(o[e].ctx,n);return this}off(t,n){const r=this.e||(this.e={}),o=e(r,t)?r[t]:void 0,i=[];if(o&&n)for(let e=0,s=o.length;e<s;e+=1)o[e].fn!==n&&o[e].fn._!==n&&i.push(o[e]);return i.length?r[t]=i:delete r[t],this}}const n=new WeakMap;function r(e){return n.has(e)||n.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}),n.get(e)}function o(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function i(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function s(e){let t,n=!1;for(const o of e)try{o()}catch(r){n||(n=!0,t=r)}if(n)throw t}function c(e,t="destroyed"){const n=r(e);return!n.closed&&(n.closed=!0,n.epoch++,n.job?.cancel(t),!0)}function a(e){const t=r(e),n=new Set([...t.sourceUrls,e.videoUrl]),o=new Set([...t.thumbnailUrls,e.thumbnailUrl]),c=[];for(const r of new Set([t.video,e.video]))r&&c.push(()=>r.pause?.(),()=>r.removeAttribute?.("src"),()=>r.load?.(),()=>r.parentNode?.removeChild(r));s([...t.sourceListeners.splice(0),...c,...[...n].map(e=>()=>i(t.sourceUrls,e)),...[...o].map(e=>()=>i(t.thumbnailUrls,e))])}function l(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function u(e,t){let n=t.epoch;const c=e.video;let a,u,d,h,p=!1,f=!1,m=()=>{};const b=[],y=new Promise((e,t)=>{a=e,u=t});y.catch(()=>{});const g=()=>!p&&!t.closed&&t.epoch===n&&t.job===h;function v(){return!p&&(p=!0,t.job===h&&(t.job=null,e.processing=!1),s([()=>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 n=0;const o=()=>{if(!g())return;if(n===t.points.length)return void function(){if(g())try{v(),e.emit("done"),a()}catch(t){let r=t;try{e.emit("error",t?.message)}catch(n){r=n}u(r)}}();const s=t.points[n];let l=!1,d=!1;const h=c.oncanplay,p=()=>{if(!(!g()||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),!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,n){const o=r(e),s=URL.createObjectURL(t);if(o.thumbnailUrls.add(s),!n())return i(o.thumbnailUrls,s),null;const c=new Set([...o.thumbnailUrls,e.thumbnailUrl]);o.thumbnailUrl=s,e.thumbnailUrl=s;for(const r of c)r&&r!==s&&i(o.thumbnailUrls,r);return n()?s:null}(e,s,g);if(!c)return;e.emit("update",c,(n+1)/t.number),n++,Promise.resolve().then(o)}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(o)}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,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),g()||clearTimeout(d)}h={video:c,promise:y,ready:E,fail:w,cancel(e){w(o(e),!1)},adopt:t=>!p&&!f&&c===e.video&&(n=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.removeEventLisLine 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";const e=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);class t{on(t,o,n){const r=this.e||(this.e={});let i=e(r,t)?r[t]:void 0;return i||(i=[],Object.defineProperty(r,t,{value:i,enumerable:!0,configurable:!0,writable:!0})),i.push({fn:o,ctx:n}),this}once(e,t,o){const n=this,r=t;let i=!1;function s(...t){i||(i=!0,n.off(e,s),r.apply(o,t))}return s._=t,this.on(e,s,o)}emit(t,...o){const n=this.e||(this.e={}),r=(e(n,t)&&n[t]||[]).slice();for(let e=0;e<r.length;e+=1)r[e].fn.apply(r[e].ctx,o);return this}off(t,o){const n=this.e||(this.e={}),r=e(n,t)?n[t]:void 0,i=[];if(r&&o)for(let e=0,s=r.length;e<s;e+=1)r[e].fn!==o&&r[e].fn._!==o&&i.push(r[e]);return i.length?n[t]=i:delete n[t],this}}const o=new WeakMap;function n(e){return o.has(e)||o.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}),o.get(e)}function r(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function i(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function s(e){let t,o=!1;for(const r of e)try{r()}catch(n){o||(o=!0,t=n)}if(o)throw t}function c(e,t="destroyed"){const o=n(e);return!o.closed&&(o.closed=!0,o.epoch++,o.job?.cancel(t),!0)}function a(e){const t=n(e),o=new Set([...t.sourceUrls,e.videoUrl]),r=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));s([...t.sourceListeners.splice(0),...c,...[...o].map(e=>()=>i(t.sourceUrls,e)),...[...r].map(e=>()=>i(t.thumbnailUrls,e))])}function l(e,t,o){return Math.max(Math.min(e,Math.max(t,o)),Math.min(t,o))}function u(e){const t="workspace-4.4"===e.compatibility;return{delay:t?null:e.delay,aspectHeight:t,resetInput:t}}function d(e,t){let o=t.epoch;const c=e.video;let a,d,h,p,f=!1,m=!1,b=()=>{};const y=[],g=new Promise((e,t)=>{a=e,d=t});g.catch(()=>{});const v=()=>!f&&!t.closed&&t.epoch===o&&t.job===p;function w(){return!f&&(f=!0,t.job===p&&(t.job=null,e.processing=!1),s([()=>clearTimeout(h),b,...y.splice(0)]),!0)}function U(t,o=!0){if(!f){try{w()}catch(n){t||(t=n)}if(o)try{e.emit("error",t?.message)}catch(r){t=r}d(t)}}function E(){if(v())try{w(),e.emit("done"),a()}catch(t){let n=t;try{e.emit("error",t?.message)}catch(o){n=o}d(n)}}function T(t){if(!v())return;m=!0,e.processing=!0;let o=0;const r=()=>{if(!v())return;if(o===t.points.length)return void(null!==t.delay?h=setTimeout(E,2*Number(t.delay)):E());const s=t.points[o];let a=!1,l=!1,u=null===t.delay;const d=c.oncanplay,p=()=>{if(!(!v()||!u||a||c.seeking||void 0!==c.readyState&&c.readyState<2)){a=!0;try{if(b(),t.context.drawImage(c,s.x,s.y,t.width,t.height),!v())return;t.canvas.toBlob(s=>{if(v()&&!l){l=!0;try{if(!s)throw new Error("Unable to create thumbnail image");const c=function(e,t,o){const r=n(e),s=URL.createObjectURL(t);if(r.thumbnailUrls.add(s),!o())return i(r.thumbnailUrls,s),null;const c=new Set([...r.thumbnailUrls,e.thumbnailUrl]);r.thumbnailUrl=s,e.thumbnailUrl=s;for(const n of c)n&&n!==s&&i(r.thumbnailUrls,n);return o()?s:null}(e,s,v);if(!c)return;e.emit("update",c,(o+1)/t.number),o++,Promise.resolve().then(r)}catch(c){U(c)}}})}catch(d){U(d)}}};b=()=>{c.oncanplay===p&&(c.oncanplay=d),c.removeEventListener("seeked",p)};try{c.oncanplay=p,c.addEventListener("seeked",p),c.currentTime=s.time,null!==t.delay?(h=setTimeout(()=>{u=!0,p()},t.delay),v()||clearTimeout(h)):c.readyState>=2&&!c.seeking&&Promise.resolve().then(p)}catch(f){U(f)}};Promise.resolve().then(r)}function j(o=!1){if(v()&&!m)if(clearTimeout(h),!t.loading&&c.duration){m=!0;try{const t=function(e,t){const{video:o}=t,{width:n,number:r,begin:i,end:s}=e.option,c=u(e.option),a=c.aspectHeight?o.videoHeight/o.videoWidth*n:e.option.height,d=(o,n)=>{try{e.errorHandle(o,n)}catch(r){throw t.reported=r,t.wasReported=!0,r}};e.option.height=a,e.option.begin=l(i,0,o.duration),e.option.end=l(s||o.duration,i,o.duration),d(e.option.end>e.option.begin,"End time must be greater than the start time"),d(Number.isFinite(o.duration),"Video duration must be finite"),e.duration=e.option.end-e.option.begin,e.density=r/e.duration,d(e.file&&o,"Please select the video file first"),d(!e.processing,"There is currently a task in progress, please wait a moment..."),d(e.density<=1,`The preview density cannot be greater than 1, but got ${e.density}`);const h=e.creatScreenshotDate(),p=e.creatCanvas(),f=p.getContext("2d");return e.emit("canvas",p),{width:n,height:a,number:r,points:h,canvas:p,context:f,delay:c.delay}}(e,p);T(t)}catch(n){if(U(n,!o&&!(p.wasReported&&n===p.reported)),o)throw n}}else h=setTimeout(()=>j(),1e3),v()Line truncated
@@ -31,4 +31,4 @@
* 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";const e=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);class t{on(t,n,r){const o=this.e||(this.e={});let i=e(o,t)?o[t]:void 0;return i||(i=[],Object.defineProperty(o,t,{value:i,enumerable:!0,configurable:!0,writable:!0})),i.push({fn:n,ctx:r}),this}once(e,t,n){const r=this,o=t;let i=!1;function s(...t){i||(i=!0,r.off(e,s),o.apply(n,t))}return s._=t,this.on(e,s,n)}emit(t,...n){const r=this.e||(this.e={}),o=(e(r,t)&&r[t]||[]).slice();for(let e=0;e<o.length;e+=1)o[e].fn.apply(o[e].ctx,n);return this}off(t,n){const r=this.e||(this.e={}),o=e(r,t)?r[t]:void 0,i=[];if(o&&n)for(let e=0,s=o.length;e<s;e+=1)o[e].fn!==n&&o[e].fn._!==n&&i.push(o[e]);return i.length?r[t]=i:delete r[t],this}}const n=new WeakMap;function r(e){return n.has(e)||n.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}),n.get(e)}function o(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function i(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function s(e){let t,n=!1;for(const o of e)try{o()}catch(r){n||(n=!0,t=r)}if(n)throw t}function c(e,t="destroyed"){var n;const o=r(e);return!o.closed&&(o.closed=!0,o.epoch++,null==(n=o.job)||n.cancel(t),!0)}function l(e){const t=r(e),n=new Set([...t.sourceUrls,e.videoUrl]),o=new Set([...t.thumbnailUrls,e.thumbnailUrl]),c=[];for(const r of new Set([t.video,e.video]))r&&c.push(()=>{var e;return null==(e=r.pause)?void 0:e.call(r)},()=>{var e;return null==(e=r.removeAttribute)?void 0:e.call(r,"src")},()=>{var e;return null==(e=r.load)?void 0:e.call(r)},()=>{var e;return null==(e=r.parentNode)?void 0:e.removeChild(r)});s([...t.sourceListeners.splice(0),...c,...[...n].map(e=>()=>i(t.sourceUrls,e)),...[...o].map(e=>()=>i(t.thumbnailUrls,e))])}function a(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function u(e,t){let n=t.epoch;const c=e.video;let l,u,d,h,p=!1,f=!1,m=()=>{};const b=[],v=new Promise((e,t)=>{l=e,u=t});v.catch(()=>{});const y=()=>!p&&!t.closed&&t.epoch===n&&t.job===h;function g(){return!p&&(p=!0,t.job===h&&(t.job=null,e.processing=!1),s([()=>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 n=0;const o=()=>{if(!y())return;if(n===t.points.length)return void function(){if(y())try{g(),e.emit("done"),l()}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[n];let a=!1,d=!1;const h=c.oncanplay,p=()=>{if(!(!y()||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),!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,n){const o=r(e),s=URL.createObjectURL(t);if(o.thumbnailUrls.add(s),!n())return i(o.thumbnailUrls,s),null;const c=new Set([...o.thumbnailUrls,e.thumbnailUrl]);o.thumbnailUrl=s,e.thumbnailUrl=s;for(const r of c)r&&r!==s&&i(o.thumbnailUrls,r);return n()?s:null}(e,s,y);if(!c)return;e.emit("update",c,(n+1)/t.number),n++,Promise.resolve().then(o)}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(o)}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,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),y()||clearTimeout(d)}h={video:c,promise:v,ready:E,fail:w,cancel(e){w(o(e),!1)},adopt:t=>!p&&!f&&c===e.video&&(n=t,!0)},t.job=h;tryLine 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";const e=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);class t{on(t,n,o){const r=this.e||(this.e={});let i=e(r,t)?r[t]:void 0;return i||(i=[],Object.defineProperty(r,t,{value:i,enumerable:!0,configurable:!0,writable:!0})),i.push({fn:n,ctx:o}),this}once(e,t,n){const o=this,r=t;let i=!1;function s(...t){i||(i=!0,o.off(e,s),r.apply(n,t))}return s._=t,this.on(e,s,n)}emit(t,...n){const o=this.e||(this.e={}),r=(e(o,t)&&o[t]||[]).slice();for(let e=0;e<r.length;e+=1)r[e].fn.apply(r[e].ctx,n);return this}off(t,n){const o=this.e||(this.e={}),r=e(o,t)?o[t]:void 0,i=[];if(r&&n)for(let e=0,s=r.length;e<s;e+=1)r[e].fn!==n&&r[e].fn._!==n&&i.push(r[e]);return i.length?o[t]=i:delete o[t],this}}const n=new WeakMap;function o(e){return n.has(e)||n.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}),n.get(e)}function r(e){const t=new Error(`Thumbnail task cancelled: ${e}`);return t.name="AbortError",t}function i(e,t){t&&(e.delete(t),URL.revokeObjectURL(t))}function s(e){let t,n=!1;for(const r of e)try{r()}catch(o){n||(n=!0,t=o)}if(n)throw t}function l(e,t="destroyed"){var n;const r=o(e);return!r.closed&&(r.closed=!0,r.epoch++,null==(n=r.job)||n.cancel(t),!0)}function a(e){const t=o(e),n=new Set([...t.sourceUrls,e.videoUrl]),r=new Set([...t.thumbnailUrls,e.thumbnailUrl]),l=[];for(const o of new Set([t.video,e.video]))o&&l.push(()=>{var e;return null==(e=o.pause)?void 0:e.call(o)},()=>{var e;return null==(e=o.removeAttribute)?void 0:e.call(o,"src")},()=>{var e;return null==(e=o.load)?void 0:e.call(o)},()=>{var e;return null==(e=o.parentNode)?void 0:e.removeChild(o)});s([...t.sourceListeners.splice(0),...l,...[...n].map(e=>()=>i(t.sourceUrls,e)),...[...r].map(e=>()=>i(t.thumbnailUrls,e))])}function c(e,t,n){return Math.max(Math.min(e,Math.max(t,n)),Math.min(t,n))}function u(e){const t="workspace-4.4"===e.compatibility;return{delay:t?null:e.delay,aspectHeight:t,resetInput:t}}function d(e,t){let n=t.epoch;const l=e.video;let a,d,h,p,f=!1,m=!1,b=()=>{};const y=[],v=new Promise((e,t)=>{a=e,d=t});v.catch(()=>{});const g=()=>!f&&!t.closed&&t.epoch===n&&t.job===p;function w(){return!f&&(f=!0,t.job===p&&(t.job=null,e.processing=!1),s([()=>clearTimeout(h),b,...y.splice(0)]),!0)}function U(t,n=!0){if(!f){try{w()}catch(o){t||(t=o)}if(n)try{e.emit("error",null==t?void 0:t.message)}catch(r){t=r}d(t)}}function E(){if(g())try{w(),e.emit("done"),a()}catch(t){let o=t;try{e.emit("error",null==t?void 0:t.message)}catch(n){o=n}d(o)}}function T(t){if(!g())return;m=!0,e.processing=!0;let n=0;const r=()=>{if(!g())return;if(n===t.points.length)return void(null!==t.delay?h=setTimeout(E,2*Number(t.delay)):E());const s=t.points[n];let a=!1,c=!1,u=null===t.delay;const d=l.oncanplay,p=()=>{if(!(!g()||!u||a||l.seeking||void 0!==l.readyState&&l.readyState<2)){a=!0;try{if(b(),t.context.drawImage(l,s.x,s.y,t.width,t.height),!g())return;t.canvas.toBlob(s=>{if(g()&&!c){c=!0;try{if(!s)throw new Error("Unable to create thumbnail image");const l=function(e,t,n){const r=o(e),s=URL.createObjectURL(t);if(r.thumbnailUrls.add(s),!n())return i(r.thumbnailUrls,s),null;const l=new Set([...r.thumbnailUrls,e.thumbnailUrl]);r.thumbnailUrl=s,e.thumbnailUrl=s;for(const o of l)o&&o!==s&&i(r.thumbnailUrls,o);return n()?s:null}(e,s,g);if(!l)return;e.emit("update",l,(n+1)/t.number),n++,Promise.resolve().then(r)}catch(l){U(l)}}})}catch(d){U(d)}}};b=()=>{l.oncanplay===p&&(l.oncanplay=d),l.removeEventListener("seeked",p)};try{l.oncanplay=p,l.addEventListener("seeked",p),l.currentTime=s.time,null!==t.delay?(h=setTimeout(()=>{u=!0,p()},t.delay),g()||clearTimeout(h)):l.readyState>=2&&!l.seeking&&Promise.resolve().then(p)}catch(f){U(f)}};Promise.resolve().then(r)}function j(n=!1){if(g()&&!m)if(clearTimeout(h),!t.loading&&l.duration){m=!0;try{const t=function(e,t){const{video:n}=t,{width:o,number:r,begin:i,end:s}=e.option,l=u(e.option),a=l.aspectHeight?n.videoHeight/n.videoWidth*o:e.option.height,d=(n,o)=>{try{e.errorHandle(n,o)}catch(r){throw t.reported=r,t.wasReported=!0,r}};e.option.height=a,e.option.begin=c(i,0,n.duration),e.option.end=c(s||n.duration,i,n.duration),d(e.option.end>e.option.begin,"End time must be greater than the start time"),d(Number.isFinite(n.duration),"Video duration must be finite"),e.duration=e.option.end-e.option.begin,e.density=r/e.duration,d(e.file&&n,"Please select the video file first"),d(!e.processing,"There is currently a task in progress, please wait a moment..."),d(e.density<=1,`The preview density cannot be greater than 1, but got ${e.density}`);const h=e.creatScreenshotDate(),p=e.creatCanvas(),f=p.getContext("2d");return e.emiLine truncated
@@ -160,12 +160,39 @@ function releaseMedia(tool) {
...[...thumbnails].map((url) => () => revoke(state.thumbnailUrls, url))
]);
}
function getFileName(name) {
const nameArray = name.split(".");
nameArray.pop();
return nameArray.join(".");
}
function clamp(num, a, b) {
return Math.max(Math.min(num, Math.max(a, b)), Math.min(a, b));
}
function thumbnailPolicy(option) {
const workspace = option.compatibility === "workspace-4.4";
return { delay: workspace ? null : option.delay, aspectHeight: workspace, resetInput: workspace };
}
function normalizePolicy(option, validate) {
validate(option.compatibility === void 0 || option.compatibility === "published-3.5" || option.compatibility === "workspace-4.4", "The compatibility option must be published-3.5 or workspace-4.4");
const workspace = thumbnailPolicy(option).aspectHeight;
const fields = workspace ? ["number", "width", "column", "begin", "end"] : ["delay", "number", "width", "height", "column", "begin", "end"];
for (const name of fields)
validate(typeof option[name] === "number", `The '${name}' is not a number`);
option.number = clamp(option.number, 10, 1e3);
option.width = clamp(option.width, 10, 1e3);
option.column = clamp(option.column, 1, 1e3);
if (!workspace) {
option.delay = clamp(Number(option.delay), 10, 1e3);
option.height = clamp(option.height, 10, 1e3);
}
}
function loadSource(tool, file) {
const state = stateFor(tool);
if (!file || state.closed)
return;
const initialEpoch = state.epoch;
const video = tool.video;
const { delay } = thumbnailPolicy(tool.option);
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)
@@ -187,11 +214,15 @@ function loadSource(tool, file) {
state.sourceListeners = listeners;
let pendingCleanup = () => {
};
let notificationTimer;
const cancelNotification = () => clearTimeout(notificationTimer);
listeners.push(cancelNotification);
let reported = false;
const error = () => {
if (!current() || reported || video.currentSrc && video.currentSrc !== url)
return;
reported = true;
cancelNotification();
state.loading = false;
const failure = new Error(`Unable to load video: media error ${video.error?.code || 0}`);
if (state.job)
@@ -238,8 +269,19 @@ function loadSource(tool, file) {
if (previous !== url)
revoke(state.sourceUrls, previous);
}
if (current())
tool.emit("video", video);
if (current() && !reported) {
if (delay !== null) {
notificationTimer = setTimeout(() => {
cancelNotification();
if (current() && !reported)
tool.emit("video", video);
}, delay);
if (!current() || reported)
cancelNotification();
} else {
tool.emit("video", video);
}
}
} catch (error2) {
if (current()) {
state.loading = false;
@@ -270,18 +312,11 @@ function replaceThumbnail(tool, blob, live) {
}
return live() ? url : null;
}
function getFileName(name) {
const nameArray = name.split(".");
nameArray.pop();
return nameArray.join(".");
}
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 policy = thumbnailPolicy(tool.option);
const height = policy.aspectHeight ? video.videoHeight / video.videoWidth * width : tool.option.height;
const guard = (condition, message) => {
try {
tool.errorHandle(condition, message);
@@ -305,7 +340,7 @@ function prepare(tool, job) {
const canvas = tool.creatCanvas();
const context = canvas.getContext("2d");
tool.emit("canvas", canvas);
return { width, height, number, points, canvas, context };
return { width, height, number, points, canvas, context, delay: policy.delay };
}
function createJob(tool, state) {
let epoch = state.epoch;
@@ -381,15 +416,19 @@ function createJob(tool, state) {
if (!live())
return;
if (index === plan.points.length) {
complete();
if (plan.delay !== null)
timer = setTimeout(complete, Number(plan.delay) * 2);
else
complete();
return;
}
const point = plan.points[index];
let drawing = false;
let encoded = false;
let delayElapsed = plan.delay === null;
const previous = video.oncanplay;
const draw = () => {
if (!live() || drawing || video.seeking || video.readyState !== void 0 && video.readyState < 2)
if (!live() || !delayElapsed || drawing || video.seeking || video.readyState !== void 0 && video.readyState < 2)
return;
drawing = true;
try {
@@ -427,8 +466,16 @@ function createJob(tool, state) {
video.oncanplay = draw;
video.addEventListener("seeked", draw);
video.currentTime = point.time;
if (video.readyState >= 2 && !video.seeking)
if (plan.delay !== null) {
timer = setTimeout(() => {
delayElapsed = true;
draw();
}, plan.delay);
if (!live())
clearTimeout(timer);
} else if (video.readyState >= 2 && !video.seeking) {
Promise.resolve().then(draw);
}
} catch (error) {
fail(error);
}
@@ -552,11 +599,7 @@ function setupInput(tool, patch) {
const option = Object.assign({}, tool.option, patch);
const target = option.fileInput;
tool.errorHandle(target instanceof Element, "The 'fileInput' is not a Element");
for (const name of ["number", "width", "column", "begin", "end"])
tool.errorHandle(typeof option[name] === "number", `The '${name}' is not a number`);
option.number = clamp(option.number, 10, 1e3);
option.width = clamp(option.width, 10, 1e3);
option.column = clamp(option.column, 1, 1e3);
normalizePolicy(option, (condition, message) => tool.errorHandle(condition, message));
if (!current())
throw cancellation("input setup superseded");
const previous = inputs.get(tool);
@@ -681,6 +724,7 @@ class ArtplayerToolThumbnail extends Emitter {
}
static get DEFAULTS() {
return {
delay: 300,
number: 60,
width: 160,
height: 90,
@@ -723,9 +767,11 @@ class ArtplayerToolThumbnail extends Emitter {
inputChange(event) {
if (stateFor(this).closed)
return;
const { resetInput } = thumbnailPolicy(this.option);
const file = this.option.fileInput.files[0];
this.loadVideo(file);
event.target.value = "";
if (resetInput)
event.target.value = "";
}
loadVideo(file) {
loadSource(this, file);
@@ -1,13 +1,15 @@
import type ArtplayerToolThumbnail from './index'
import type { Cleanup, ExtractionJob, LifecycleState } from './types'
import { cancellation, cleanupAll, stateFor } from './lifecycle'
import { thumbnailPolicy } from './policy'
import { replaceThumbnail } from './source'
import { clamp } from './utils'
function prepare(tool: ArtplayerToolThumbnail, job: ExtractionJob) {
const { video } = job
const { width, number, begin, end } = tool.option
const height = (video.videoHeight / video.videoWidth) * width
const policy = thumbnailPolicy(tool.option)
const height = policy.aspectHeight ? (video.videoHeight / video.videoWidth) * width : tool.option.height
const guard = (condition: unknown, message: string) => {
try {
tool.errorHandle(condition, message)
@@ -32,7 +34,7 @@ function prepare(tool: ArtplayerToolThumbnail, job: ExtractionJob) {
const canvas = tool.creatCanvas()
const context = canvas.getContext('2d')!
tool.emit('canvas', canvas)
return { width, height, number, points, canvas, context }
return { width, height, number, points, canvas, context, delay: policy.delay }
}
function createJob(tool: ArtplayerToolThumbnail, state: LifecycleState) {
@@ -112,15 +114,19 @@ function createJob(tool: ArtplayerToolThumbnail, state: LifecycleState) {
if (!live())
return
if (index === plan.points.length) {
complete()
if (plan.delay !== null)
timer = setTimeout(complete, Number(plan.delay) * 2)
else
complete()
return
}
const point = plan.points[index]!
let drawing = false
let encoded = false
let delayElapsed = plan.delay === null
const previous = video.oncanplay
const draw = () => {
if (!live() || drawing || video.seeking || (video.readyState !== undefined && video.readyState < 2))
if (!live() || !delayElapsed || drawing || video.seeking || (video.readyState !== undefined && video.readyState < 2))
return
drawing = true
try {
@@ -160,8 +166,17 @@ function createJob(tool: ArtplayerToolThumbnail, state: LifecycleState) {
video.oncanplay = draw
video.addEventListener('seeked', draw)
video.currentTime = point.time
if (video.readyState >= 2 && !video.seeking)
if (plan.delay !== null) {
timer = setTimeout(() => {
delayElapsed = true
draw()
}, plan.delay)
if (!live())
clearTimeout(timer)
}
else if (video.readyState >= 2 && !video.seeking) {
Promise.resolve().then(draw)
}
}
catch (error) {
fail(error)
@@ -3,6 +3,7 @@ import Emitter from './emitter'
import { startExtraction } from './extraction'
import { connectInput, releaseInput, setupInput } from './input'
import { cleanupAll, closeState, releaseMedia, stateFor } from './lifecycle'
import { thumbnailPolicy } from './policy'
import { createSheet, downloadSheet, screenshotPoints } from './sheet'
import { loadSource } from './source'
@@ -46,6 +47,7 @@ export default class ArtplayerToolThumbnail extends Emitter<ThumbnailEvents> {
static get DEFAULTS() {
return {
delay: 300,
number: 60,
width: 160,
height: 90,
@@ -94,9 +96,11 @@ export default class ArtplayerToolThumbnail extends Emitter<ThumbnailEvents> {
inputChange(event: Event) {
if (stateFor(this).closed)
return
const { resetInput } = thumbnailPolicy(this.option)
const file = this.option.fileInput.files![0]
this.loadVideo(file)
;(event.target as HTMLInputElement).value = ''
if (resetInput)
(event.target as HTMLInputElement).value = ''
}
loadVideo(file?: File | null) {
@@ -1,7 +1,7 @@
import type ArtplayerToolThumbnail from './index'
import type { InputOptions } from './types'
import { cancellation, stateFor } from './lifecycle'
import { clamp } from './utils'
import { normalizePolicy } from './policy'
type Registration = readonly [name: string, callback: EventListener]
interface InputRecord {
@@ -62,11 +62,7 @@ export function setupInput(tool: ArtplayerToolThumbnail, patch: InputOptions) {
const option = Object.assign({}, tool.option, patch)
const target = option.fileInput!
tool.errorHandle(target instanceof Element, 'The \'fileInput\' is not a Element')
for (const name of ['number', 'width', 'column', 'begin', 'end'])
tool.errorHandle(typeof option[name] === 'number', `The '${name}' is not a number`)
option.number = clamp(option.number, 10, 1000)
option.width = clamp(option.width, 10, 1000)
option.column = clamp(option.column, 1, 1000)
normalizePolicy(option, (condition, message) => tool.errorHandle(condition, message))
if (!current())
throw cancellation('input setup superseded')
@@ -0,0 +1,22 @@
import type { RuntimeOptions } from './types'
import { clamp } from './utils'
export function thumbnailPolicy(option: RuntimeOptions) {
const workspace = option.compatibility === 'workspace-4.4'
return { delay: workspace ? null : option.delay, aspectHeight: workspace, resetInput: workspace }
}
export function normalizePolicy(option: RuntimeOptions, validate: (condition: unknown, message: string) => void) {
validate(option.compatibility === undefined || option.compatibility === 'published-3.5' || option.compatibility === 'workspace-4.4', 'The compatibility option must be published-3.5 or workspace-4.4')
const workspace = thumbnailPolicy(option).aspectHeight
const fields = workspace ? ['number', 'width', 'column', 'begin', 'end'] : ['delay', 'number', 'width', 'height', 'column', 'begin', 'end']
for (const name of fields)
validate(typeof option[name] === 'number', `The '${name}' is not a number`)
option.number = clamp(option.number, 10, 1000)
option.width = clamp(option.width, 10, 1000)
option.column = clamp(option.column, 1, 1000)
if (!workspace) {
option.delay = clamp(Number(option.delay), 10, 1000)
option.height = clamp(option.height, 10, 1000)
}
}
@@ -1,6 +1,7 @@
import type ArtplayerToolThumbnail from './index'
import type { Cleanup } from './types'
import { cleanupAll, revoke, stateFor } from './lifecycle'
import { thumbnailPolicy } from './policy'
export function loadSource(tool: ArtplayerToolThumbnail, file?: File | null) {
const state = stateFor(tool)
@@ -8,6 +9,7 @@ export function loadSource(tool: ArtplayerToolThumbnail, file?: File | null) {
return
const initialEpoch = state.epoch
const video = tool.video
const { delay } = thumbnailPolicy(tool.option)
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)
@@ -28,11 +30,15 @@ export function loadSource(tool: ArtplayerToolThumbnail, file?: File | null) {
const listeners: Cleanup[] = []
state.sourceListeners = listeners
let pendingCleanup = () => {}
let notificationTimer: ReturnType<typeof setTimeout> | undefined
const cancelNotification = () => clearTimeout(notificationTimer)
listeners.push(cancelNotification)
let reported = false
const error = () => {
if (!current() || reported || (video.currentSrc && video.currentSrc !== url))
return
reported = true
cancelNotification()
state.loading = false
const failure = new Error(`Unable to load video: media error ${video.error?.code || 0}`)
if (state.job)
@@ -79,8 +85,20 @@ export function loadSource(tool: ArtplayerToolThumbnail, file?: File | null) {
if (previous !== url)
revoke(state.sourceUrls, previous)
}
if (current())
tool.emit('video', video)
if (current() && !reported) {
if (delay !== null) {
notificationTimer = setTimeout(() => {
cancelNotification()
if (current() && !reported)
tool.emit('video', video)
}, delay)
if (!current() || reported)
cancelNotification()
}
else {
tool.emit('video', video)
}
}
}
catch (error) {
if (current()) {
@@ -9,11 +9,15 @@ export interface SheetOptions {
export interface InputOptions extends Partial<SheetOptions> {
fileInput?: Element
delay?: number
compatibility?: 'published-3.5' | 'workspace-4.4'
[name: string]: unknown
}
export interface RuntimeOptions extends SheetOptions {
fileInput: HTMLInputElement
delay?: number
compatibility?: 'published-3.5' | 'workspace-4.4'
[name: string]: unknown
}
@@ -4,13 +4,13 @@ The declaration describes the existing tool class, not an ArtPlayer plugin facto
The previous workspace manifest pointed to a missing declaration. There is no
recovered legacy declaration to claim as an exact TypeScript baseline.
| Entry | Declaration | Runtime |
| --- | --- | --- |
| Root ESM import | artplayer-tool-thumbnail.d.mts | Default constructor in dist/artplayer-tool-thumbnail.mjs |
| Root require | artplayer-tool-thumbnail.d.cts | Direct constructor in dist/artplayer-tool-thumbnail.js |
| Older Node resolution / TS 4.3 | artplayer-tool-thumbnail.d.ts | main points to the direct CommonJS constructor |
| artplayer-tool-thumbnail/legacy | d.cts or typesVersions fallback | Direct legacy constructor; native ESM gets its default |
| Browser script / editor | Generated docs/assets/ts declaration | ArtplayerToolThumbnail global |
| Entry | Declaration | Runtime |
| ------------------------------- | ------------------------------------ | -------------------------------------------------------- |
| Root ESM import | artplayer-tool-thumbnail.d.mts | Default constructor in dist/artplayer-tool-thumbnail.mjs |
| Root require | artplayer-tool-thumbnail.d.cts | Direct constructor in dist/artplayer-tool-thumbnail.js |
| Older Node resolution / TS 4.3 | artplayer-tool-thumbnail.d.ts | main points to the direct CommonJS constructor |
| artplayer-tool-thumbnail/legacy | d.cts or typesVersions fallback | Direct legacy constructor; native ESM gets its default |
| Browser script / editor | Generated docs/assets/ts declaration | ArtplayerToolThumbnail global |
The d.ts file is the public source of truth. d.cts forwards its export assignment;
d.mts exposes the default constructor and type-only named exports. No runtime
@@ -44,7 +44,7 @@ and source replacement/destroy reject with AbortError. Error events usually cont
strings, but callback throws can supply any message value, so narrow unknown first.
Source/declaration constructors are mutually checked in test/types/thumbnail-source.ts.
Current and TS 4.3.5 consumers include ten invalid uses. Installed checks pack/install
Current and TS 4.3.5 consumers include twelve invalid uses. Installed checks pack/install
outside the workspace, offline with an unchanged frozen lock, verify every file
and exercise native Node require/import without a DOM.
@@ -57,4 +57,9 @@ yarn test:browser test/browser/thumbnail-editor-types.spec.js
build:ts selects package declaration entries rather than auxiliary types and emits
the uppercase global. Actual file extraction, old-core composition and the full
demo remain separate checks. The 3.5.31 versus 4.4.0 default policy remains pending.
demo remain separate checks. The approved default is published 3.5.31;
`compatibility: 'workspace-4.4'` opts into the previous workspace behavior.
`Compatibility` names these two modes. `DefaultOptions` extends `SheetOptions`
with the numeric static delay; existing `SheetOptions` literals need no new field.
`Option.delay` is optional and numeric; workspace mode ignores it, including
explicit undefined. The public resolved option therefore keeps delay optional.
@@ -1,2 +1,2 @@
export { default } from './artplayer-tool-thumbnail.cjs'
export type { EventArgs, EventRegistry, Events, Listener, Option, ResolvedOption, ScreenshotPoint, SheetOptions } from './artplayer-tool-thumbnail.js'
export type { Compatibility, DefaultOptions, EventArgs, EventRegistry, Events, Listener, Option, ResolvedOption, ScreenshotPoint, SheetOptions } from './artplayer-tool-thumbnail.js'
@@ -2,7 +2,7 @@
declare class ArtplayerToolThumbnail {
constructor(option?: ArtplayerToolThumbnail.Option)
static readonly DEFAULTS: ArtplayerToolThumbnail.SheetOptions
static readonly DEFAULTS: ArtplayerToolThumbnail.DefaultOptions
static ondragover(event: DragEvent): void
static creatVideo(): HTMLVideoElement
@@ -44,14 +44,25 @@ declare namespace ArtplayerToolThumbnail {
end: number
}
type Compatibility = 'published-3.5' | 'workspace-4.4'
interface DefaultOptions extends SheetOptions {
delay: number
}
/** fileInput must be a file input or an Element wrapper when constructing. */
interface Option extends Partial<SheetOptions> {
fileInput?: Element
compatibility?: Compatibility
/** Published mode delay in milliseconds, clamped to 10-1000. */
delay?: number
[name: string]: unknown
}
interface ResolvedOption extends SheetOptions {
fileInput: HTMLInputElement
compatibility?: Compatibility
delay?: number
[name: string]: unknown
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,91 @@
# PKG-TOOL-THUMB-04 approved Thumbnail policy and migration completion
The user approved published 3.5.31 behavior as the default, with the unpublished
workspace behavior explicitly selected through `compatibility: 'workspace-4.4'`.
This completes the source/type migration task, including its earlier TypeScript,
declaration, installed-entry and emitter checkpoints. It does not complete 05/06,
physical-device evidence, formal reviews or publication readiness.
## Implementation
`src/policy.ts` selects and validates the policy. The public constructor restores
`DEFAULTS.delay: 300` and published key order. Published mode validates/clamps
height and delay to 10-1000, retains configured height and the selected input
value, delays the video event and each frame, and waits twice the delay before
done. Workspace mode keeps aspect-ratio height, synchronous video notification,
no fixed extraction delays and input reset. The class DEFAULTS always represents
the published policy; this boundary was explicitly approved.
Source and extraction continue owning their resources separately. Source changes,
native errors and destroy cancel pending notification, frame and completion
timers. Frame readiness and seek completion are required in addition to the
published delay. Cancellation settles the Promise without late update/done or
new URLs. NaN retains the old asynchronous timer boundary rather than being
mistaken for the workspace no-timer mode. Existing public spellings, descriptors,
custom fields, event payloads, synchronous validation and return values remain.
The authored declarations add optional compatibility/delay and exported
Compatibility/DefaultOptions types. SheetOptions still accepts old literals
without a delay field. Source and public constructors remain mutually assignable.
The generated local Monaco declarations are regenerated; its real worker compiles
the new mode/default types and executes constructor/event/cleanup code.
README, architecture and type ownership guides describe both policies. No new
dependency, lockfile or version change is needed. `test:thumbnail` and `test:unit`
now include the new policy regression suite. Bundles and docs copies were built
through the repository build script; source remains authoritative.
## Verification
Node 24.21.0, Yarn Classic 1.22.22, Windows. Evidence:
[thumbnail-policy-validation.json](../baselines/thumbnail-policy-validation.json).
- The initial new policy tests failed 6/7 against the previous implementation.
Final `yarn test:thumbnail`: 136 passed, no failure/skip/cancellation, 14.968 s.
This includes frozen historical tests and current/old TypeScript consumers.
- Strict source tsc and scoped source/type/test/script lint pass. Five public
compiler/resolution modes reject all 12 invalid calls. Native CommonJS/no-interop
fixtures and both-direction source/declaration assignment pass.
- Main/legacy/ESM build succeeds. Final external Yarn pack/install evidence is
`.cache/thumbnail-package-types-IH4Ouy`: offline frozen reinstall, installed-file
hashes, native require/import and 12 compiler cases pass. Five cases characterize
the frozen workspace missing-entry defect; seven exercise candidate consumers.
Installed legacy runtime/policy/lifecycle subset: 63/63, 2.770 s; the final
legacy bytes are identical to that tested snapshot. The final packed files are
checked against the working tree in the evidence recorder.
- Three-engine source browser run: 90 passed, 0 failed/skipped/flaky, 66.423 s.
Installed main browser run: 30 passed, 0 failed/skipped/flaky, 33.414 s.
Chromium 153.0.8010.12 and Firefox 155.0 verify actual file selection, configured
versus aspect height, PNG pixels, repeated extraction, URL release, held native
encoding cancellation and latest-source-only updates. Published video/frame/done
delays have lower-bound assertions; owned-timer tests verify exact scheduling.
- Windows WebKit 26.6 cannot decode the tested native Blob URLs (error 4), while
HTTP controls load. There are 23 annotated capability-control source cases and
10 installed cases. These are not successful extraction or physical Safari
evidence. Other passing historical tests also include exact old-defect controls.
The first broad runtime attempt exposed fixtures that assumed no fixed delays;
those now explicitly select workspace mode. A waiting worker was stopped and its
unfinished run is not counted as passing. An initial strict error on optional
delay was fixed through numeric normalization after runtime validation.
The first browser run had 89 passes and one Chromium failure: selecting an
identical file while published mode retains input.value did not trigger change,
so starting a second job correctly rejected it. The replacement test now clears
selection before choosing the file again. Both modes retain the original strict
AbortError/latest-source assertions. Final reports and the initial trace are
archived separately; no retry, weaker assertion or production input reset hides
that failure.
## Status and continuation
PKG-TOOL-THUMB-04 is done; THUMB-COMPAT-01 is resolved for the approved default
policy and migration boundary. No complete original npm archive or historical
declaration has been recovered, and 05/06 retain integration/distribution work.
Next implementation work includes the recorded installed Danmuku CPU-gap failure
and remaining package/release preparation. Finish implementation and required
tests, then await user direction for reviews; do not push or publish.
Rollback requires reverting this task's source, declarations, generated outputs,
tests and documentation together. Returning to the prior workspace default would
also reverse the approved compatibility decision and must not be done silently.
+3 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 282 项,范围 22 个包及工作区/示例。
状态:todo 43 / doing 21 / blocked 0 / done 218 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 43 / doing 20 / blocked 0 / done 219 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -396,7 +396,7 @@
| PKG-TOOL-THUMB-01 | artplayer-tool-thumbnail<br>核对包契约与历史用法 | BASE-05 | 构造/defaults/事件/方法/历史拼写、.esm.js 入口和缺失 types | 源码/声明/README/demo/可恢复发布内容差异已登记;公开形状和版本范围冻结;区分旧 npm 的 main/CSS 与当前工作区无 main、.esm.js/类型缺失,不把工作区状态冒称旧 npm 发布事实 | H | done |
| PKG-TOOL-THUMB-02 | artplayer-tool-thumbnail<br>建立特有行为与错误测试 | PKG-TOOL-THUMB-01, ENG-03, ENG-05 | 文件输入/拖放、抽帧网格、begin/end、进度/失败/重复任务 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-TOOL-THUMB-03 | artplayer-tool-thumbnail<br>整理内部职责与资源 | PKG-TOOL-THUMB-02, CORE-01 | 输入、抽帧队列、网格导出、URL/监听清理分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-TOOL-THUMB-04 | artplayer-tool-thumbnail<br>迁移自有源码和公开类型 | PKG-TOOL-THUMB-03, ENG-04, ENG-06, CORE-07, PKG-TOOL-THUMB-07 | 补齐真实 API 声明,自有 emitter/utils TS 化 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;为 BASE-DIST-01 提供兼容入口/声明及消费者验证 | H | doing |
| PKG-TOOL-THUMB-04 | artplayer-tool-thumbnail<br>迁移自有源码和公开类型 | PKG-TOOL-THUMB-03, ENG-04, ENG-06, CORE-07, PKG-TOOL-THUMB-07 | 补齐真实 API 声明,自有 emitter/utils TS 化 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;为 BASE-DIST-01 提供兼容入口/声明及消费者验证 | H | done |
| PKG-TOOL-THUMB-05 | artplayer-tool-thumbnail<br>验证新旧核心和组合 | PKG-TOOL-THUMB-04, CORE-22 | 工具独立浏览器使用,生成缩略图在核心中显示 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-TOOL-THUMB-06 | artplayer-tool-thumbnail<br>验证分发并同步文档 | PKG-TOOL-THUMB-05, ENG-07 | tool.thumbnail.js、历史 ESM 兼容文件、types 路径和 tarball | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
| PKG-TOOL-THUMB-07 | artplayer-tool-thumbnail<br>恢复清理和销毁中的原始异常传播 | PKG-TOOL-THUMB-03 | 清理记录与异常值分离,保留首个 falsy 抛出值并继续释放资源;历史对照、真实DOM、分发验证及维护记录 | 恢复旧 destroy 监听器同步异常语义,0/false/undefined/NaN等不被吞掉或覆盖;后续清理、重复销毁与正常抽帧回归通过,默认兼容决策不受影响 | M | done |
@@ -673,7 +673,7 @@
- PKG-TOOL-THUMB-01: [记录](baselines/thumbnail-release.json) [记录](baselines/thumbnail-contract.md) [记录](changes/2026-09-13-PKG-TOOL-THUMB-01-contract.md) [记录](baselines/thumbnail-contract-validation.json)
- PKG-TOOL-THUMB-02: [记录](baselines/thumbnail-behavior-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-02-behavior.md)
- PKG-TOOL-THUMB-03: [记录](changes/2026-09-13-PKG-TOOL-THUMB-03-input-checkpoint.md) [记录](baselines/thumbnail-input-checkpoint.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-03-lifecycle.md) [记录](baselines/thumbnail-lifecycle-validation.json)
- PKG-TOOL-THUMB-04: [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-runtime-types.md) [记录](baselines/thumbnail-runtime-types-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-public-types.md) [记录](baselines/thumbnail-public-types-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-emitter.md) [记录](baselines/thumbnail-emitter-validation.json) [记录](thumbnail-compatibility-decision.md) [记录](changes/2026-09-15-PKG-TOOL-THUMB-04-policy-proposal.md)
- PKG-TOOL-THUMB-04: [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-runtime-types.md) [记录](baselines/thumbnail-runtime-types-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-public-types.md) [记录](baselines/thumbnail-public-types-validation.json) [记录](changes/2026-09-13-PKG-TOOL-THUMB-04-emitter.md) [记录](baselines/thumbnail-emitter-validation.json) [记录](thumbnail-compatibility-decision.md) [记录](changes/2026-09-15-PKG-TOOL-THUMB-04-policy-proposal.md) [记录](changes/2026-09-15-PKG-TOOL-THUMB-04-approved-policy.md) [记录](baselines/thumbnail-policy-validation.json)
- SITE-01: [记录](site-inventory.md) [记录](baselines/site-inventory.json) [记录](baselines/site-provenance.json) [记录](baselines/demo-additions.json) [记录](changes/2026-09-14-SITE-01-site-inventory.md) [记录](baselines/site-inventory-validation.json)
- SITE-SMOKE-01: [记录](changes/2026-09-14-SITE-SMOKE-01-documentation-smoke.md) [记录](baselines/docs-smoke-validation.json) [记录](scripts/docs-smoke.test.mjs)
- SITE-02: [记录](changes/2026-09-14-SITE-02-editor-declarations.md) [记录](baselines/editor-declarations-validation.json) [记录](changes/2026-09-14-SITE-SMOKE-01-documentation-smoke.md)
+13
View File
@@ -1,5 +1,18 @@
# 进度与证据
## PKG-TOOL-THUMB-04 Thumbnail 策略与源码迁移完成
用户批准已落实:默认保留 npm 3.5.31,显式 workspace-4.4 保留未发布工作区行为。
策略集中到 policy.ts,延迟由 source/job 各自管理;类型、迁移说明与构建产物同步。
136 项 Node 测试、严格类型、隔离安装 12 编译器案例通过;安装 legacy 63 项通过。
三引擎源码浏览器 90 项、安装 main 30 项通过,其中分别 23/10 项为 WebKit
能力对照,不能算截图提取或真实 Safari 通过。首次同文件重选夹具失败已修正并归档。
见[完成记录](changes/2026-09-15-PKG-TOOL-THUMB-04-approved-policy.md)、
[证据](baselines/thumbnail-policy-validation.json)。04 done,THUMB-COMPAT-01 resolved;
05/06、设备与整体发布门槛保留。当前 219 done、20 doing、43 todo,共282项。
下一步修复已捕获的 DANMUKU-INSTALLED-GAP-01,并继续剩余实施/版本准备。
实施完成后等待用户指导复盘;未启动复盘、推送或发布。
## CI-01 完整 Windows WebKit 安装包回归
固定bb2c12df3,复用5e0447fc5打包的20包run-cRduve;77文件/845项:694通过、
+1 -1
View File
@@ -177,7 +177,7 @@
| IFRAME-LIFE-01 | open / 已复现 | Iframe timestamp IDs and unowned pending requests/timers lack terminal cleanup | PKG-IFRAME-02, PKG-IFRAME-03, PKG-IFRAME-05 |
| IFRAME-TRUST-01 | open / 已复现 | Iframe executable commit protocol accepts messages without source/origin validation | PKG-IFRAME-03, PKG-IFRAME-05 |
| IFRAME-DIST-01 | open / 已复现 | Iframe tool name is unavailable at registry observation and old archive includes distinct helper exports | PKG-IFRAME-04, PKG-IFRAME-06 |
| THUMB-COMPAT-01 | open / 已复现 | Thumbnail recovered 3.5.31 and workspace 4.4.0 have conflicting height and event timing defaults | PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-05 |
| THUMB-COMPAT-01 | resolved / 已复现 | Thumbnail recovered 3.5.31 and workspace 4.4.0 have conflicting height and event timing defaults | PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-05 |
| THUMB-LIFE-01 | open / 已复现 | Thumbnail drop binding and repeated destroy fail; pending work and replacement resource ownership need repair | PKG-TOOL-THUMB-02, PKG-TOOL-THUMB-03, PKG-TOOL-THUMB-05 |
| THUMB-PROVENANCE-01 | resolved / 源码/产物事实 | Thumbnail local emitter attribution and historical distribution completeness remain unverified | PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-06 |
| THUMB-MEDIA-01 | open / 已复现 | Windows WebKit native Blob URLs cannot load tested MP4 media despite successful HTTP controls | PKG-TOOL-THUMB-05, PKG-TOOL-THUMB-06 |
+12 -4
View File
@@ -4125,9 +4125,9 @@
"PKG-TOOL-THUMB-04",
"PKG-TOOL-THUMB-05"
],
"compatibleResolution": "Freeze both implementations and derive a concrete compatibility policy before changing defaults; no silent claim that identical calls have identical historical semantics.",
"compatibleResolution": "User approved published 3.5.31 default behavior and explicit workspace-4.4 mode, including class DEFAULTS. Implemented as an owned policy without restoring historical resource defects.",
"closureCriteria": "Explicit documented compatibility decision, old/candidate tests and migration examples covering delay, height, input reset and readiness.",
"status": "open",
"status": "resolved",
"evidence": [
"refactor/baselines/thumbnail-contract.md",
"refactor/baselines/thumbnail-release.json",
@@ -4139,9 +4139,17 @@
"refactor/changes/2026-09-13-PKG-TOOL-THUMB-03-lifecycle.md",
"refactor/changes/2026-09-13-PKG-TOOL-THUMB-04-public-types.md",
"refactor/thumbnail-compatibility-decision.md",
"refactor/changes/2026-09-15-PKG-TOOL-THUMB-04-policy-proposal.md"
"refactor/changes/2026-09-15-PKG-TOOL-THUMB-04-policy-proposal.md",
"refactor/changes/2026-09-15-PKG-TOOL-THUMB-04-approved-policy.md",
"refactor/baselines/thumbnail-policy-validation.json"
],
"workspaceState": "Concrete proposal prepared: recovered 3.5.31 runtime defaults and optional workspace-4.4 mode, explicitly including the class-level DEFAULTS difference, delay/height validation, event/frame timing and input reset. Frozen contract/behavior tests rerun 62/62. User choice remains pending; no mode/default change implemented or inferred from other package approvals."
"workspaceState": "Default fixed/clamped height and delay, delayed video/frame/done and retained input value implemented; explicit workspace mode keeps aspect height, no policy delays and input reset. Source and installed browser checks plus strict current/old types pass within documented Windows WebKit Blob capability limits. Package integration/distribution and physical devices remain separate gates.",
"resolutionEvidence": [
"refactor/changes/2026-09-15-PKG-TOOL-THUMB-04-approved-policy.md",
"refactor/baselines/thumbnail-policy-validation.json",
"refactor/thumbnail-compatibility-decision.md"
],
"resolutionRationale": "Explicit user approval and migration examples, frozen/default/workspace tests, source and installed real PNG checks on Chromium/Firefox, owned cancellation timing and declaration consumers close the conflicting-default policy. Windows WebKit native Blob limitation is recorded, not treated as successful extraction."
},
{
"id": "THUMB-LIFE-01",
+12 -6
View File
@@ -6,8 +6,8 @@ import ts from 'typescript'
import compat from 'typescript-compat'
import { checkFiles, packedFiles } from '../../scripts/package-check.mjs'
import { consumerDirectory, removeConsumer, run, workspace, writeJson } from '../../scripts/package-consumer.mjs'
import { verifyThumbnailContract } from './thumbnail-contract.mjs'
import { hash, readMember } from './releases.mjs'
import { verifyThumbnailContract } from './thumbnail-contract.mjs'
async function main() {
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Use yarn test:thumbnail-types-package')
@@ -20,7 +20,8 @@ async function main() {
fs.mkdirSync(frozen)
const prefix = 'packages/artplayer-tool-thumbnail/'
for (const [file, text] of contract.source) {
if (!file.startsWith(prefix)) continue
if (!file.startsWith(prefix))
continue
const target = path.join(frozen, file.slice(prefix.length))
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, text)
@@ -74,7 +75,8 @@ ${label === 'candidate' ? `const legacy = require('${pkg.name}/legacy'); assert.
fs.writeFileSync(path.join(output, `${label}-runtime.log`), observation)
runtime.push({ package: label, ...JSON.parse(observation.trim()) })
const modes = [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm'], [compat, 'node10-commonjs']]
if (label === 'candidate') modes.push([ts, 'nodenext-cjs-no-interop'], [compat, 'node10-commonjs-no-interop'])
if (label === 'candidate')
modes.push([ts, 'nodenext-cjs-no-interop'], [compat, 'node10-commonjs-no-interop'])
for (const [compiler, mode] of modes) {
const next = mode.startsWith('nodenext')
const noInterop = mode.endsWith('no-interop')
@@ -92,15 +94,19 @@ ${label === 'candidate' ? `const legacy = require('${pkg.name}/legacy'); assert.
}
const diagnostics = compile(source)
writeJson(path.join(output, `${label}-${compiler.version}-${mode}.json`), diagnostics)
if (label === 'candidate') assert.deepEqual(diagnostics, [], `${compiler.version} ${mode}`)
if (label === 'candidate') {
assert.deepEqual(diagnostics, [], `${compiler.version} ${mode}`)
}
else {
assert.deepEqual(diagnostics.map(item => item.code), [mode === 'node10-commonjs' ? 2307 : 7016], 'Only the exact frozen missing entry/declaration diagnostic is expected')
}
const negative = label === 'candidate' ? compile(source.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, '')) : []
if (label === 'candidate') assert.equal(negative.length, noInterop ? 1 : 10)
if (label === 'candidate')
assert.equal(negative.length, noInterop ? 1 : 12)
matrix.push({ package: label, compiler: compiler.version, mode, diagnostics, negative })
}
if (label === 'candidate') fs.cpSync(installed, path.join(output, 'installed-artifacts'), { recursive: true })
if (label === 'candidate')
fs.cpSync(installed, path.join(output, 'installed-artifacts'), { recursive: true })
}
finally { removeConsumer(consumer) }
}
+4 -4
View File
@@ -2,11 +2,11 @@ import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Public type and editor contract runner.
import test from 'node:test'
import { ESLint } from 'eslint'
import ts from 'typescript'
import compat from 'typescript-compat'
import { ESLint } from 'eslint'
import { checkConsumer } from '../../scripts/typecheck.mjs'
import { checkPluginEditorDeclaration, generatePluginEditorDeclaration } from '../../scripts/plugin-editor-types.mjs'
import { checkConsumer } from '../../scripts/typecheck.mjs'
const modes = [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm'], [compat, 'node10-commonjs']]
const invalid = source => source.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, '')
@@ -15,7 +15,7 @@ test('Thumbnail public declarations check actual callbacks, custom events and in
const source = fs.readFileSync('test/types/thumbnail.ts', 'utf8')
for (const [compiler, mode] of modes) {
assert.deepEqual(checkConsumer(compiler, mode, source), [], `${compiler.version} ${mode}`)
assert.equal(checkConsumer(compiler, mode, invalid(source)).length, 10, `${compiler.version} ${mode} negatives`)
assert.equal(checkConsumer(compiler, mode, invalid(source)).length, 12, `${compiler.version} ${mode} negatives`)
}
const commonjs = fs.readFileSync('test/types/thumbnail-commonjs.cts', 'utf8')
for (const [compiler, mode] of [[ts, 'nodenext-cjs'], [compat, 'node10-commonjs']]) {
@@ -67,7 +67,7 @@ const bad = player.mediabunny?.levels;`
assert.equal(checkPluginEditorDeclaration(code, core, invalid(consumer), compiler).length, 1)
}
assert.throws(() => generatePluginEditorDeclaration(source, 'artplayerProxyMediabunny'), /Unsupported editor type re-export/)
assert.throws(() => generatePluginEditorDeclaration(source, 'artplayerProxyMediabunny', { './media': media + '\nexport interface Extra {}' }), /explicitly cover/)
assert.throws(() => generatePluginEditorDeclaration(source, 'artplayerProxyMediabunny', { './media': `${media}\nexport interface Extra {}` }), /explicitly cover/)
const common = fs.readFileSync('docs/assets/js/common.js', 'utf8')
assert(common.includes('./assets/ts/artplayer-tool-thumbnail.d.ts'))
assert(!common.includes('./assets/ts/media.d.ts'))
+4 -2
View File
@@ -4238,7 +4238,7 @@
"CORE-07",
"PKG-TOOL-THUMB-07"
],
"status": "doing",
"status": "done",
"risk": "H",
"deliverable": "补齐真实 API 声明,自有 emitter/utils TS 化",
"acceptance": "严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;为 BASE-DIST-01 提供兼容入口/声明及消费者验证",
@@ -4250,7 +4250,9 @@
"changes/2026-09-13-PKG-TOOL-THUMB-04-emitter.md",
"baselines/thumbnail-emitter-validation.json",
"thumbnail-compatibility-decision.md",
"changes/2026-09-15-PKG-TOOL-THUMB-04-policy-proposal.md"
"changes/2026-09-15-PKG-TOOL-THUMB-04-policy-proposal.md",
"changes/2026-09-15-PKG-TOOL-THUMB-04-approved-policy.md",
"baselines/thumbnail-policy-validation.json"
]
},
{
+10 -7
View File
@@ -1,7 +1,10 @@
# Thumbnail 默认行为兼容决策(待用户确认)
# Thumbnail 默认行为兼容决策(已批准,已实施)
状态:proposal,尚未批准或实施。责任任务 PKG-TOOL-THUMB-04,风险 THUMB-COMPAT-01。
本文件把已有待答问题具体化;不把其他包的兼容授权推广到本工具。
状态:approved,源码、类型及本机验证完成。责任任务 PKG-TOOL-THUMB-04,风险 THUMB-COMPAT-01。
2026-09-15 用户明确批准:“接受:npm 行为默认,工作区行为显式选择(推荐)”。
下列方案现在可直接实施,无需再次询问;批准不代替源码、类型和浏览器验证。
实施结果见 [完成记录](changes/2026-09-15-PKG-TOOL-THUMB-04-approved-policy.md);
05/06、设备和发布门槛仍独立保留,Windows WebKit 能力对照不计截图提取通过。
## 已复现的冲突
@@ -23,7 +26,7 @@
变成 90 高;loadVideo 后同步注册 video 监听器在两版也会得到不同结果。
不能通过 TS 重命名或内部拆文件同时满足这两种默认值和时序。
## 推荐方案:恢复已发布行为为默认,显式选择工作区模式
## 已批准方案:恢复已发布行为为默认,显式选择工作区模式
1. 根构造器默认选择恢复的 3.5.31 行为,支持旧 delay 参数,DEFAULTS 恢复
delay: 300;配置 height 保留原钳制,video 延迟、每帧/完成延迟及 input
@@ -33,7 +36,7 @@
显式 compatibility: 'published-3.5' 可表达默认选择。
3. 类级 DEFAULTS 没有实例上下文,统一表示根默认的 3.5.31 配置。依赖工作区
DEFAULTS 精确字段集合的代码需要明确选择/保存自己的配置;不能承诺它同时
返回两套对象。这个差异也属于本次待批准范围。
返回两套对象。这个差异也属于本次已批准范围。
4. 保留已实现的 URL/监听器/定时器所有权、销毁与切源取消、错误结算、输入
清理和 emitter 修复。不会恢复旧版错绑 this、陈旧回调、资源泄漏等缺陷。
定时等待不取代真实帧就绪;修复慢解码导致错误截图的路径单独记录和验证。
@@ -51,8 +54,8 @@ const workspaceTool = new ArtplayerToolThumbnail({
})
```
另一种可选决策是保留工作区 4.4.0 为默认,并接受已发布 3.5.31 用户需要选择
兼容模式。这会把迁移成本放到已发布旧用户,不作为当前推荐。
未选择的替代方案是保留工作区 4.4.0 为默认,并要求已发布 3.5.31 用户选择
兼容模式;用户已经选择上面的 npm 默认方案。
## 批准后实施与验收
+4 -2
View File
@@ -22,7 +22,9 @@ test('Monaco consumes Thumbnail global declarations and runs the actual typed co
}))
const model = api.editor.createModel(`
const input = document.createElement('input'); input.type = 'file'; document.body.appendChild(input);
const options: ArtplayerToolThumbnail.Option = { fileInput: input, number: 10 };
const mode: ArtplayerToolThumbnail.Compatibility = 'workspace-4.4';
const defaults: ArtplayerToolThumbnail.DefaultOptions = ArtplayerToolThumbnail.DEFAULTS;
const options: ArtplayerToolThumbnail.Option = { fileInput: input, number: 10, compatibility: mode, delay: defaults.delay };
const tool: ArtplayerToolThumbnail = new ArtplayerToolThumbnail(options);
let last = 0;
tool.on('custom', (value: number) => { last = value; });
@@ -32,7 +34,7 @@ const video: HTMLVideoElement = tool.video;
tool.destroy(); tool.destroy(); input.remove();
function mediaTypes(player: artplayerProxyMediabunny.MediaBunnyPlayer): number | undefined { return player.mediabunny?.currentTime; }
`, 'typescript', api.Uri.parse('file:///thumbnail-consumer.ts'))
const invalid = api.editor.createModel("new ArtplayerToolThumbnail({ fileInput: '#input' });", 'typescript', api.Uri.parse('file:///thumbnail-invalid.ts'))
const invalid = api.editor.createModel('new ArtplayerToolThumbnail({ fileInput: \'#input\' });', 'typescript', api.Uri.parse('file:///thumbnail-invalid.ts'))
try {
const worker = await (await types.getTypeScriptWorker())(model.uri, invalid.uri)
const syntax = await worker.getSyntacticDiagnostics(model.uri.toString())
+1 -1
View File
@@ -12,7 +12,7 @@ test('Thumbnail input ownership replaces real DOM listeners and generated inputs
await page.addScriptTag({ content: implementation.code })
await page.evaluate(() => {
window.inputEvents = []
window.inputTool = new window.ArtplayerToolThumbnail({ fileInput: document.querySelector('#first') })
window.inputTool = new window.ArtplayerToolThumbnail({ fileInput: document.querySelector('#first'), compatibility: 'workspace-4.4' })
window.firstInput = window.inputTool.option.fileInput
window.inputTool.on('file', file => window.inputEvents.push(file.name))
window.inputTool.setup({ fileInput: document.querySelector('#first') })
+22 -7
View File
@@ -27,7 +27,12 @@ async function capabilityControl(page, testInfo, lifecycle) {
return true
}
const implementations = [...thumbnailHistorical(), { ...await thumbnailCandidate(), inputOwnership: true, lifecycle: true }]
const candidate = await thumbnailCandidate()
const implementations = [
...thumbnailHistorical(),
{ ...candidate, inputOwnership: true, lifecycle: true, publishedPolicy: true },
{ ...candidate, name: `${candidate.name}-workspace-policy`, inputOwnership: true, lifecycle: true, compatibility: 'workspace-4.4' },
]
for (const implementation of implementations) {
test.describe(`Thumbnail tool ${implementation.name}`, () => {
test.beforeEach(async ({ page }, testInfo) => {
@@ -61,7 +66,7 @@ for (const implementation of implementations) {
}
URL.revokeObjectURL(blobUrl)
})
await page.evaluate(() => {
await page.evaluate((compatibility) => {
window.created = []
window.revoked = []
const create = URL.createObjectURL.bind(URL)
@@ -75,11 +80,11 @@ for (const implementation of implementations) {
revoked.push(url)
revoke(url)
}
window.tool = new ArtplayerToolThumbnail({ fileInput: document.querySelector('#input'), number: 10, width: 80, height: 30, column: 3, begin: 0, end: 20 })
window.tool = new ArtplayerToolThumbnail({ fileInput: document.querySelector('#input'), number: 10, width: 80, height: 30, column: 3, begin: 0, end: 20, compatibility })
window.observed = []
for (const event of ['file', 'video', 'canvas', 'update', 'done', 'error', 'destroy'])
tool.on(event, (...args) => observed.push({ event, progress: event === 'update' ? args[1] : null, processing: tool.processing }))
})
tool.on(event, (...args) => observed.push({ event, at: performance.now(), progress: event === 'update' ? args[1] : null, processing: tool.processing }))
}, implementation.compatibility)
await testInfo.attach('thumbnail-runtime', { contentType: 'application/json', body: JSON.stringify({ name: implementation.name, sha256: hash(implementation.code), recoveredIndividualFile: implementation.legacy, originalNpmArchive: false, media: path.relative(process.cwd(), sample) }) })
})
@@ -117,12 +122,20 @@ for (const implementation of implementations) {
return { width: image.width, height: image.height, sourceWidth, sourceHeight, cellHeight: tool.option.height, mime: blob.type, bytes: blob.size, nonBlack, initialEvents, events: observed, first, second, created, revoked, connected: tool.video.isConnected, retiredReadyState: tool.video.readyState, retiredSrc: tool.video.getAttribute('src') }
})
expect(result.width).toBe(240)
expect(result.cellHeight).toBe(implementation.legacy ? 30 : result.sourceHeight / result.sourceWidth * 80)
expect(result.cellHeight).toBe(implementation.legacy || implementation.publishedPolicy ? 30 : result.sourceHeight / result.sourceWidth * 80)
expect(result.height).toBe(Math.trunc(result.cellHeight * 4 + 30))
expect(result.mime).toBe('image/png')
expect(result.bytes).toBeGreaterThan(100)
expect(result.nonBlack).toBeGreaterThan(100)
expect(result.initialEvents.filter(item => item.event === 'update').map(item => item.progress)).toEqual(Array.from({ length: 10 }, (_, i) => (i + 1) / 10))
if (implementation.publishedPolicy) {
const events = result.initialEvents
expect(events.find(item => item.event === 'video').at - events.find(item => item.event === 'file').at).toBeGreaterThanOrEqual(298)
const frames = events.filter(item => item.event === 'canvas' || item.event === 'update')
for (let index = 1; index < frames.length; index++)
expect(frames[index].at - frames[index - 1].at).toBeGreaterThanOrEqual(298)
expect(events.find(item => item.event === 'done').at - frames.at(-1).at).toBeGreaterThanOrEqual(598)
}
expect(result.events.filter(item => item.event === 'done')).toHaveLength(2)
expect(result.first).not.toBe(result.second)
expect([...result.created].sort()).toEqual([...result.revoked].sort())
@@ -177,7 +190,7 @@ for (const implementation of implementations) {
expect(result.revoked).toEqual(implementation.lifecycle ? result.created : [result.created[1]])
expect(result.revoked.includes(failed.first)).toBe(!!implementation.lifecycle)
expect(result.repeated).toBe(implementation.inputOwnership ? undefined : 'NotFoundError')
expect(result.value === '').toBe(!implementation.legacy)
expect(result.value === '').toBe(!(implementation.legacy || implementation.publishedPolicy))
await testInfo.attach('thumbnail-media-failure', { contentType: 'application/json', body: JSON.stringify({ failed, result }) })
})
@@ -255,6 +268,8 @@ for (const implementation of implementations) {
})
})
await expect.poll(() => page.evaluate(() => typeof window.releaseFirst)).toBe('function')
// Published policy retains the value; selecting the same file need not fire change.
await page.locator('#input').setInputFiles([])
await page.locator('#input').setInputFiles(sample)
const result = await page.evaluate(async () => {
const source = tool.videoUrl
+2 -2
View File
@@ -7,7 +7,7 @@ const implementation = await thumbnailCandidate()
function setup(wrapper = false) {
const env = thumbnailEnvironment(implementation)
const target = new env.Element(wrapper ? 'div' : 'input')
const instance = new env.Factory({ fileInput: target, number: 10, width: 20, height: 30, column: 3 })
const instance = new env.Factory({ fileInput: target, number: 10, width: 20, height: 30, column: 3, compatibility: 'workspace-4.4' })
return { ...env, target, instance }
}
@@ -17,7 +17,7 @@ test('Thumbnail input ownership preserves class methods, constructor fields and
assert.equal(exported.default, undefined)
assert.deepEqual(Object.keys(instance), ['processing', 'option', 'video', 'duration', 'inputChange', 'ondrop'])
assert.deepEqual(Object.getOwnPropertyNames(Factory.prototype), ['constructor', 'ondrop', 'setup', 'inputChange', 'loadVideo', 'start', 'creatScreenshotDate', 'creatCanvas', 'download', 'errorHandle', 'destroy'])
assert.deepEqual(Object.keys(Factory.DEFAULTS), ['number', 'width', 'height', 'column', 'begin', 'end'])
assert.deepEqual(Object.keys(Factory.DEFAULTS), ['delay', 'number', 'width', 'height', 'column', 'begin', 'end'])
assert.equal(instance.setup({ custom: 7 }), instance)
assert.equal(instance.option.custom, 7)
instance.destroy()
+3 -2
View File
@@ -16,7 +16,8 @@ function observe(promise) {
}
function setup(ready = true) {
const env = thumbnailEnvironment(implementation)
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10, width: 40, column: 3 })
// These lifecycle fixtures retain the explicitly selected workspace timing.
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10, width: 40, column: 3, compatibility: 'workspace-4.4' })
env.instance.loadVideo({ name: 'first.mp4', type: 'video/mp4' })
if (ready) {
env.instance.video.duration = 100
@@ -409,7 +410,7 @@ test('Thumbnail URL creation reentrant load preserves the newer source and revok
test('Thumbnail start before the first file retains its legacy wait-for-selection behavior', async () => {
const env = thumbnailEnvironment(implementation)
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10 })
env.instance = new env.Factory({ fileInput: new env.Element('input'), number: 10, compatibility: 'workspace-4.4' })
const state = observe(env.instance.start())
env.instance.loadVideo({ name: 'first.mp4', type: 'video/mp4' })
env.instance.video.duration = 100
+188
View File
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Approved runtime policy compatibility.
import test from 'node:test'
import { thumbnailCandidate, thumbnailEnvironment, thumbnailHistorical } from './helpers/thumbnail.js'
const candidate = await thumbnailCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
function create(option = {}) {
const env = thumbnailEnvironment(candidate)
env.input = new env.Element('input')
env.tool = new env.Factory({ fileInput: env.input, number: 10, width: 40, height: 25, ...option })
return env
}
function tick(env, delay) {
const selected = [...env.timers].find(([, timer]) => Object.is(timer.delay, delay))
assert(selected, `Expected owned timer for ${delay} ms`)
env.timers.delete(selected[0])
selected[1].callback()
}
function ready(env) {
env.tool.video.duration = 100
env.tool.video.readyState = 2
env.tool.video.videoWidth = 160
env.tool.video.videoHeight = 90
env.tool.loadVideo({ name: 'sample.mp4', type: 'video/mp4' })
for (const fn of env.tool.video.handlers.get('loadedmetadata'))
fn()
}
test('Thumbnail root defaults match the recovered published profile, including key order', () => {
const actual = create()
const old = thumbnailEnvironment(thumbnailHistorical().find(item => item.legacy))
assert.deepEqual(Object.keys(actual.Factory.DEFAULTS), Object.keys(old.Factory.DEFAULTS))
for (const key of Object.keys(old.Factory.DEFAULTS))
assert.equal(actual.Factory.DEFAULTS[key], old.Factory.DEFAULTS[key])
actual.tool.destroy()
})
test('Thumbnail published policy validates and clamps delay/height while workspace preserves its inputs', () => {
const published = create({ height: 2, delay: 4000 })
assert.equal(published.tool.option.height, 10)
assert.equal(published.tool.option.delay, 1000)
assert.throws(() => published.tool.setup({ delay: 'bad' }), /delay.*number/)
assert.throws(() => published.tool.setup({ height: 'bad' }), /height.*number/)
assert.throws(() => published.tool.setup({ compatibility: 'unknown' }), /compatibility/)
const workspace = create({ compatibility: 'workspace-4.4', height: 2, delay: 4000 })
assert.equal(workspace.tool.option.height, 2)
assert.equal(workspace.tool.option.delay, 4000)
published.tool.destroy()
workspace.tool.destroy()
})
for (const compatibility of [undefined, 'published-3.5', 'workspace-4.4']) {
test(`Thumbnail ${compatibility || 'default'} preserves file/video timing and input reset policy`, () => {
const env = create({ compatibility, delay: 25 })
const seen = []
env.input.files = [{ name: 'sample.mp4', type: 'video/mp4' }]
env.input.value = 'selected'
env.tool.on('file', () => seen.push(['file', env.tool.video.src]))
env.tool.on('video', video => seen.push(['video', video.src]))
env.tool.inputChange({ target: env.input })
const workspace = compatibility === 'workspace-4.4'
assert.deepEqual(seen, workspace ? [['file', undefined], ['video', env.tool.videoUrl]] : [['file', undefined]])
assert.equal(env.input.value, workspace ? '' : 'selected')
if (!workspace) {
tick(env, 25)
assert.deepEqual(seen[1], ['video', env.tool.videoUrl])
}
env.tool.destroy()
assert.equal(env.timers.size, 0)
})
}
test('Thumbnail default extraction keeps configured height and requires delay plus frame readiness', async () => {
const env = create({ delay: 25 })
ready(env)
tick(env, 25)
env.tool.video.seeking = true
const work = env.tool.start()
await flush()
assert.equal(env.tool.option.height, 25)
tick(env, 25)
assert.equal(env.operations.filter(op => op.name === 'drawImage').length, 0)
env.tool.video.seeking = false
env.tool.video.oncanplay()
await flush()
assert.equal(env.operations.filter(op => op.name === 'drawImage').length, 1)
env.tool.destroy()
await assert.rejects(work, { name: 'AbortError' })
assert.equal(env.timers.size, 0)
})
test('Thumbnail delayed video notifications are cancelled by replacement, failure and destroy', () => {
for (const action of ['replace', 'error', 'destroy']) {
const env = create({ delay: 25 })
const seen = []
env.tool.on('video', () => seen.push(env.tool.file.name)).on('error', () => {})
env.tool.loadVideo({ name: 'old.mp4', type: 'video/mp4' })
const stale = [...env.timers.values()].find(timer => timer.delay === 25)
assert(stale)
if (action === 'replace') {
env.tool.loadVideo({ name: 'new.mp4', type: 'video/mp4' })
}
else if (action === 'error') {
for (const fn of env.tool.video.handlers.get('error'))
fn()
}
else {
env.tool.destroy()
}
stale.callback()
assert.deepEqual(seen, [])
if (action === 'replace') {
tick(env, 25)
assert.deepEqual(seen, ['new.mp4'])
}
env.tool.destroy()
assert.equal(env.timers.size, 0)
}
})
test('Thumbnail numeric NaN delay keeps the historical asynchronous timer boundary', () => {
const env = create({ delay: Number.NaN })
let events = 0
env.tool.on('video', () => events++)
env.tool.loadVideo({ name: 'sample.mp4', type: 'video/mp4' })
assert.equal(events, 0)
tick(env, Number.NaN)
assert.equal(events, 1)
env.tool.destroy()
})
for (const finish of ['complete', 'destroy', 'replace', 'error']) {
test(`Thumbnail default final delay owns completion and ${finish} settlement`, async () => {
const env = create({ delay: 25 })
ready(env)
tick(env, 25)
let done = 0
const updates = []
env.tool.on('done', () => done++).on('update', (_, progress) => updates.push(progress))
const work = env.tool.start()
await flush()
for (let frame = 0; frame < 10; frame++) {
tick(env, 25)
await flush()
}
assert.deepEqual(updates, Array.from({ length: 10 }, (_, index) => (index + 1) / 10))
assert.equal(done, 0)
assert.equal(env.tool.processing, true)
const final = [...env.timers.values()].find(timer => timer.delay === 50)
assert(final)
if (finish === 'complete') {
tick(env, 50)
await work
assert.equal(done, 1)
}
else {
if (finish === 'destroy') {
env.tool.destroy()
}
else if (finish === 'replace') {
env.tool.loadVideo({ name: 'next.mp4', type: 'video/mp4' })
}
else {
env.tool.on('error', () => {})
for (const fn of [...env.tool.video.handlers.get('error')])
fn()
}
await assert.rejects(work, finish === 'error' ? /Unable to load video/ : { name: 'AbortError' })
final.callback()
assert.equal(done, 0)
}
assert.equal(env.tool.processing, false)
env.tool.destroy()
assert.equal(env.timers.size, 0)
})
}
test('Thumbnail explicit workspace extraction retains aspect height with no policy timers', async () => {
const env = create({ compatibility: 'workspace-4.4', delay: 25 })
ready(env)
const work = env.tool.start()
await work
assert.equal(env.tool.option.height, 22.5)
assert.equal(env.operations.filter(op => op.name === 'drawImage').length, 10)
assert.equal(env.timers.size, 0)
env.tool.destroy()
})
+5 -4
View File
@@ -6,9 +6,9 @@ import vm from 'node:vm'
import { thumbnailCandidate, thumbnailEnvironment, thumbnailHistorical } from './helpers/thumbnail.js'
const candidate = await thumbnailCandidate()
function create(implementation) {
function create(implementation, option = {}) {
const env = thumbnailEnvironment(implementation)
return { ...env, tool: new env.Factory({ fileInput: new env.Element('input') }) }
return { ...env, tool: new env.Factory({ fileInput: new env.Element('input'), ...option }) }
}
const referenceModule = { exports: {} }
vm.runInNewContext(fs.readFileSync(new URL('../refactor/baselines/thumbnail-vendor/tiny-emitter-2.1.0.txt', import.meta.url), 'utf8'), { module: referenceModule })
@@ -39,7 +39,8 @@ test('Thumbnail typed facade preserves descriptors, lazy fields, default class a
assert.equal(tool.option.custom, metadata)
assert.equal(tool.option.width, 21.5)
assert.notEqual(Factory.DEFAULTS, Factory.DEFAULTS)
assert.deepEqual(Object.keys(Factory.DEFAULTS), Object.keys(baseline.Factory.DEFAULTS))
const published = thumbnailEnvironment(thumbnailHistorical().find(item => item.legacy))
assert.deepEqual(Object.keys(Factory.DEFAULTS), Object.keys(published.Factory.DEFAULTS))
assert(Number.isNaN(Factory.DEFAULTS.end))
tool.destroy()
})
@@ -132,7 +133,7 @@ test('Thumbnail typed facade retains synchronous validation, option mutation and
test('Thumbnail error event preserves arbitrary thrown message values and Promise rejection identity', async () => {
for (const failure of [{ message: 7 }, { message: { detail: 'failure' } }, 3, null]) {
const { tool } = create(candidate)
const { tool } = create(candidate, { compatibility: 'workspace-4.4' })
tool.setup({ number: 10 })
tool.file = { name: 'sample.mp4' }
tool.video.duration = 100
+12 -2
View File
@@ -1,4 +1,4 @@
import type { EventArgs, Option, ScreenshotPoint } from 'artplayer-tool-thumbnail'
import type { Compatibility, DefaultOptions, EventArgs, Option, ScreenshotPoint } from 'artplayer-tool-thumbnail'
import Thumbnail from 'artplayer-tool-thumbnail'
import Legacy from 'artplayer-tool-thumbnail/legacy'
@@ -32,8 +32,14 @@ tool.off('file').setup({ width: 100 }).download()
tool.loadVideo(input.files?.[0])
tool.loadVideo(null)
const defaults: Thumbnail.SheetOptions = Thumbnail.DEFAULTS
const publishedDefaults: DefaultOptions = Thumbnail.DEFAULTS
const delay: number = publishedDefaults.delay
const mode: Compatibility = 'workspace-4.4'
tool.setup({ compatibility: mode, delay: undefined })
legacy.setup({ compatibility: 'published-3.5', delay: 300, height: 25 })
const sheet: Thumbnail.SheetOptions = { number: 10, width: 80, height: 25, column: 3, begin: 0, end: 20 }
const end: void = tool.destroy()
void [legacy, duration, file, url, pending, canvas, points, extension, defaults, end]
void [legacy, duration, file, url, pending, canvas, points, extension, defaults, delay, sheet, end]
// @ts-expect-error Numeric width is required.
tool.setup({ width: '80' })
@@ -55,4 +61,8 @@ const earlyDensity: number = tool.density
tool.on('error', (message: string) => message.length)
// @ts-expect-error Built-in done does not carry a file.
tool.emit('done', input.files?.[0])
// @ts-expect-error Compatibility accepts only the two supported policies.
tool.setup({ compatibility: 'latest' })
// @ts-expect-error Published delay is a numeric option.
tool.setup({ delay: '300' })
void [wrongInput, wrongResult, WrongAlias, earlyDensity]