refactor(auto-thumbnail): [PKG-AUTO-THUMB-03] own extraction jobs and encoded URLs

This commit is contained in:
Harvey Zhao committed 2026-09-13 06:55:34 +08:00
1 parent 9cd6659ab4
commit 84721f19ed
21 files changed
+2641 -154

No files matched your search

@@ -0,0 +1,80 @@
# Auto-thumbnail maintenance
This package registers an asynchronous ArtPlayer plugin and generates progressive
JPEG thumbnail sheets using a separate media element. Its public result remains
`{ name: 'artplayerPluginAutoThumbnail' }`; the registration Promise does not wait
for video processing.
## Modules and ownership
- `src/index.js` owns ArtPlayer subscriptions and the public factory. Options are
read at each `video:loadedmetadata` event, including subsequent changes to the
original options object. `restart` cancels the old decoder immediately. `destroy`
closes the session and removes subscriptions. Partial registration is rolled back.
- `src/options.js` preserves truthy defaults and computes the ten-column layout.
Coercible numeric JS inputs and fractional frame counts retain their previous
behavior; raw option values are forwarded to the thumbnail configuration.
Invalid/nonfinite dimensions fail before canvas allocation. Canvas dimensions
are checked against integer/IDL bounds, not a universal memory budget.
- `src/session.js` owns one active extraction job and the generated object URLs.
Guards prevent obsolete callbacks from publishing or scheduling another frame.
Cleanup runs all registered actions even if one fails. Installing a replacement
before cleaning up its predecessor makes synchronous host reentry observable.
- `src/extraction.js` owns the private video and canvas, metadata/seek/error
handlers, and the frame/encode sequence. Each valid draw is encoded before the
next seek. The final decoder is paused, cleared and reset; the final sheet URL
remains owned by the session until another usable sheet replaces it or destroy.
Only the entry module receives ArtPlayer. Extraction receives a guarded job and a
configuration snapshot. These internal modules do not add public player fields,
plugin methods, or a dependency on a particular core implementation.
## Compatibility and intentional fixes
The factory/global name, Promise registration, result name, default width 160,
count 100, scale 1, ten columns, aspect-ratio height, and time formula
`duration * index / number` are retained. An explicit URL takes precedence over
`art.option.url`; the fallback is read lazily only when no explicit URL is supplied.
Duration and sheet dimensions are captured at metadata time; later duration changes
do not move the remaining samples within the current sheet.
The old `height` option remains ignored at runtime.
The previous empty first encode is removed. Encoding is serial, duplicate native
callbacks are consumed once, and source replacement/destruction invalidates old
metadata, seek and Blob callbacks. Failure does not revoke an earlier usable
preview. Only generated URLs are revoked; a foreign public thumbnail URL is not
owned by this package. A failed host setter releases the newly created URL.
Extraction failures are observed with `console.warn('ArtPlayer auto-thumbnail failed:', error)`
after cleanup. The already-resolved registration Promise cannot represent later
media failures. Native callbacks do not escape with an unhandled exception. A
registration failure still rejects its original Promise with the original error.
## Verification and remaining work
Use the root Yarn toolchain:
```sh
yarn test:auto-thumbnail
yarn test:browser test/browser/auto-thumbnail-lifecycle.spec.js
yarn build artplayer-plugin-auto-thumbnail
```
`ARTPLAYER_AUTO_THUMBNAIL_BASELINE=1` selects the frozen workspace for candidate
regression tests. `ARTPLAYER_AUTO_THUMBNAIL_ARTIFACT` selects an actual bundle.
Historical contract/failure tests remain separate and continue to reproduce old
defects; their passing status does not mean those defects should remain.
This is an intermediate `PKG-AUTO-THUMB-03` implementation. Native lifecycle
validation does not prove correct pixels: the detached decoder path still needs
the recorded Windows WebKit frame/rendering fix (`AUTO-THUMB-PIXEL-01`). Hidden
rendering must retain source detail; a 1x1 element producing an averaged pixel is
not a correct fix. Frame timing, additional reentry/cleanup boundaries and resource
budgets still need the remaining task03 work.
Task04 converts these modules to strict TypeScript and resolves historical public
declaration/CommonJS differences. Task05 verifies old/final cores and actual
devices; task06 covers installed package entries and the real demo/editor. The
types and package version are unchanged at this checkpoint. Use
`refactor/baselines/auto-thumbnail-contract.md` and `auto-thumbnail-failures.md` as
the historical evidence map; do not regenerate those baselines from this source.
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginAutoThumbnail=t(),define(function(){return t.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";return function(e){return async t=>(t.on("video:loadedmetadata",()=>{const n=e.url||t.option.url,o=e.width||160,i=e.number||100,u=e.scale||1;!function({url:e,width:t,number:n},o){const i=document.createElement("video");i.crossOrigin="anonymous",i.src=e,i.onloadedmetadata=()=>{const e=i.duration,u=document.createElement("canvas"),a=u.getContext("2d"),l=Math.floor(t*i.videoHeight/i.videoWidth);u.width=10*t,u.height=l*Math.ceil(n/10);let r=null;!function d(c){u.toBlob(e=>{URL.revokeObjectURL(r),r=URL.createObjectURL(e),o({url:r,height:l})},"image/jpeg"),c>=n||(i.currentTime=e*c/n,i.onseeked=()=>{a.drawImage(i,c%10*t,Math.floor(c/10)*l,t,l),d(c+1)})}(0)}}({url:n,width:o,number:i},e=>{t.thumbnails={...e,column:10,number:i,width:o,scale:u}})}),{name:"artplayerPluginAutoThumbnail"})}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";function e(e,t){const i=document.createElement("video");e.own(()=>i.load()),e.own(()=>i.removeAttribute("src")),e.own(()=>i.pause());for(const n of["onloadedmetadata","onseeked","onerror"])e.own(()=>{i[n]=null});e.active()&&(i.crossOrigin="anonymous",i.onerror=e.guard(()=>{throw i.error||new Error("Auto-thumbnail media failed to load")}),i.onloadedmetadata=e.guard(()=>{i.onloadedmetadata=null;const n=i.duration,{height:r,canvasWidth:o,canvasHeight:a}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const i=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),r=Math.trunc(i*Math.ceil(e.number/10));if(![i,n,r].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:i,canvasWidth:n,canvasHeight:r}}(t,{duration:n,videoHeight:i.videoHeight,videoWidth:i.videoWidth});if(!e.active())return;const u=document.createElement("canvas");if(!e.active())return;u.width=o,u.height=a;const d=u.getContext("2d");if(!d)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let c=0;const s=e.guard(()=>{c>=t.number?e.dispose():(i.onseeked=e.guard(()=>{if(i.onseeked=null,d.drawImage(i,c%10*t.width,Math.floor(c/10)*r,t.width,r),!e.active())return;let n=!1;u.toBlob(e.guard(i=>{n||(n=!0,e.publish(i,{height:r,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(c+=1,s()))}),"image/jpeg")}),e.active()&&(i.currentTime=n*c/t.number))});s()}),e.active()&&(i.src=t.url))}function t(e){let t,i=!1;for(const r of e)try{r()}catch(n){i||(i=!0,t=n)}return{failed:i,failure:t}}return function(i){return async n=>{const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),o=function(e,i){let n,r,o=!1;const a=new Set;function u(e){a.delete(e)&&URL.revokeObjectURL(e)}function d(){const e=n;n=void 0,e?.dispose()}return{start:function(){if(o)return;const d=n,c=[];let s=!1;const l={active:()=>!o&&!s&&n===l,own(e){if(s){const n=t([e]);n.failed&&i(n.failure)}else c.push(e)},dispose(){if(s)return;s=!0,n===l&&(n=void 0);const e=t(c.splice(0).reverse());e.failed&&i(e.failure)},fail(e){l.active()&&(l.dispose(),i(e))},guard:e=>(...t)=>{if(l.active())try{return e(...t)}catch(i){l.fail(i)}},publish(n,o){if(!l.active())return;if(!n)throw new Error("Auto-thumbnail encoding returned no Blob");const d=URL.createObjectURL(n);if(a.add(d),!l.active())return void u(d);const c=r;r=d;try{e({url:d,...o})}catch(s){r===d&&(r=a.has(c)?c:void 0);const e=t([()=>u(d)]);throw e.failed&&i(e.failure),s}u(c)}};return n=l,d?.dispose(),l},cancel:d,destroy:function(){if(o)return;o=!0;const e=t([d,...[...a].map(e=>()=>u(e))]);r=void 0,e.failed&&i(e.failure)},get closed(){return o}}}(e=>{n.thumbnails=e},r),a=[],u=()=>{const t=o.start();t&&t.guard(()=>{const r=function(e,t){const i={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(i.width))||i.width<=0||!Number.isFinite(Number(i.number))||i.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return i}(i,()=>n.option.url);t.active()&&e(t,r)})()},d=()=>{const e=a.splice(0),i=t([o.destroy,...e]);i.failed&&r(i.failure)};try{for(const[e,t]of[["destroy",d],["restart",o.cancel],["video:loadedmetadata",u]]){if(o.closed)break;a.push(()=>n.off(e,t)),n.on(e,t),o.closed&&n.off(e,t)}}catch(c){throw d(),c}return{name:"artplayerPluginAutoThumbnail"}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginAutoThumbnail=t(),define(function(){return t.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,r=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,i=(t,n,r)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[n]=r;return function(e){return l=>{return u=null,c=null,d=function*(){return l.on("video:loadedmetadata",()=>{const u=e.url||l.option.url,c=e.width||160,d=e.number||100,s=e.scale||1;!function({url:e,width:t,number:n},r){const o=document.createElement("video");o.crossOrigin="anonymous",o.src=e,o.onloadedmetadata=()=>{const e=o.duration,a=document.createElement("canvas"),i=a.getContext("2d"),l=Math.floor(t*o.videoHeight/o.videoWidth);a.width=10*t,a.height=l*Math.ceil(n/10);let u=null;!function c(d){a.toBlob(e=>{URL.revokeObjectURL(u),u=URL.createObjectURL(e),r({url:u,height:l})},"image/jpeg"),d>=n||(o.currentTime=e*d/n,o.onseeked=()=>{i.drawImage(o,d%10*t,Math.floor(d/10)*l,t,l),c(d+1)})}(0)}}({url:u,width:c,number:d},e=>{var u;l.thumbnails=(u=((e,t)=>{for(var n in t||(t={}))o.call(t,n)&&i(e,n,t[n]);if(r)for(var n of r(t))a.call(t,n)&&i(e,n,t[n]);return e})({},e),t(u,n({column:10,number:d,width:c,scale:s})))})}),{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var n=e=>{try{o(d.next(e))}catch(n){t(n)}},r=e=>{try{o(d.throw(e))}catch(n){t(n)}},o=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,r);o((d=d.apply(u,c)).next())});var u,c,d}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginAutoThumbnail=t(),define(function(){return e.artplayerPluginAutoThumbnail})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAutoThumbnail=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,r=Object.prototype.hasOwnProperty,n=Object.prototype.propertyIsEnumerable,i=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n;function o(e,t){const r=document.createElement("video");e.own(()=>r.load()),e.own(()=>r.removeAttribute("src")),e.own(()=>r.pause());for(const n of["onloadedmetadata","onseeked","onerror"])e.own(()=>{r[n]=null});e.active()&&(r.crossOrigin="anonymous",r.onerror=e.guard(()=>{throw r.error||new Error("Auto-thumbnail media failed to load")}),r.onloadedmetadata=e.guard(()=>{r.onloadedmetadata=null;const n=r.duration,{height:i,canvasWidth:o,canvasHeight:a}=function(e,t){if(!Number.isFinite(t.duration)||t.duration<=0||!Number.isFinite(t.videoWidth)||t.videoWidth<=0||!Number.isFinite(t.videoHeight)||t.videoHeight<=0)throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");const r=Math.floor(e.width*t.videoHeight/t.videoWidth),n=Math.trunc(10*e.width),i=Math.trunc(r*Math.ceil(e.number/10));if(![r,n,i].every(e=>Number.isSafeInteger(e)&&e>0&&e<=4294967295))throw new RangeError("Auto-thumbnail canvas dimensions are invalid");return{height:r,canvasWidth:n,canvasHeight:i}}(t,{duration:n,videoHeight:r.videoHeight,videoWidth:r.videoWidth});if(!e.active())return;const u=document.createElement("canvas");if(!e.active())return;u.width=o,u.height=a;const d=u.getContext("2d");if(!d)throw new Error("Auto-thumbnail canvas context is unavailable");if(!e.active())return;let c=0;const l=e.guard(()=>{c>=t.number?e.dispose():(r.onseeked=e.guard(()=>{if(r.onseeked=null,d.drawImage(r,c%10*t.width,Math.floor(c/10)*i,t.width,i),!e.active())return;let n=!1;u.toBlob(e.guard(r=>{n||(n=!0,e.publish(r,{height:i,column:10,number:t.number,width:t.width,scale:t.scale}),e.active()&&(c+=1,l()))}),"image/jpeg")}),e.active()&&(r.currentTime=n*c/t.number))});l()}),e.active()&&(r.src=t.url))}function a(e){let t,r=!1;for(const i of e)try{i()}catch(n){r||(r=!0,t=n)}return{failed:r,failure:t}}function u(e,o){let u,d,c=!1;const l=new Set;function s(e){l.delete(e)&&URL.revokeObjectURL(e)}function f(){const e=u;u=void 0,null==e||e.dispose()}return{start:function(){if(c)return;const f=u,h=[];let m=!1;const v={active:()=>!c&&!m&&u===v,own(e){if(m){const t=a([e]);t.failed&&o(t.failure)}else h.push(e)},dispose(){if(m)return;m=!0,u===v&&(u=void 0);const e=a(h.splice(0).reverse());e.failed&&o(e.failure)},fail(e){v.active()&&(v.dispose(),o(e))},guard:e=>(...t)=>{if(v.active())try{return e(...t)}catch(r){v.fail(r)}},publish(u,c){if(!v.active())return;if(!u)throw new Error("Auto-thumbnail encoding returned no Blob");const f=URL.createObjectURL(u);if(l.add(f),!v.active())return void s(f);const h=d;d=f;try{e(((e,o)=>{for(var a in o||(o={}))r.call(o,a)&&i(e,a,o[a]);if(t)for(var a of t(o))n.call(o,a)&&i(e,a,o[a]);return e})({url:f},c))}catch(m){d===f&&(d=l.has(h)?h:void 0);const e=a([()=>s(f)]);throw e.failed&&o(e.failure),m}s(h)}};return u=v,null==f||f.dispose(),v},cancel:f,destroy:function(){if(c)return;c=!0;const e=a([f,...[...l].map(e=>()=>s(e))]);d=void 0,e.failed&&o(e.failure)},get closed(){return c}}}return function(e){return t=>{return r=null,n=null,i=function*(){const r=e=>console.warn("ArtPlayer auto-thumbnail failed:",e),n=u(e=>{t.thumbnails=e},r),i=[],d=()=>{const r=n.start();r&&r.guard(()=>{const n=function(e,t){const r={url:e.url||t(),width:e.width||160,number:e.number||100,scale:e.scale||1};if(!Number.isFinite(Number(r.width))||r.width<=0||!Number.isFinite(Number(r.number))||r.number<=0)throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");return r}(e,()=>t.option.url);r.active()&&o(r,n)})()},c=()=>{const e=i.splice(0),t=a([n.destroy,...e]);t.failed&&r(t.failure)};try{for(const[e,r]of[["destroy",c],["restart",n.cancel],["video:loadedmetadata",d]]){if(n.closed)break;i.push(()=>t.off(e,r)),t.on(e,r),n.closed&&t.off(e,r)}}catch(l){throw c(),l}return{name:"artplayerPluginAutoThumbnail"}},new Promise((e,t)=>{var o=e=>{try{u(i.next(e))}catch(r){t(r)}},a=e=>{try{u(i.throw(e))}catch(r){t(r)}},u=t=>t.done?e(t.value):Promise.resolve(t.value).then(o,a);u((i=i.apply(r,n)).next())});var r,n,i}}});
@@ -4,58 +4,251 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
function create({ url, width, number }, callback) {
function readOptions(option, getFallbackUrl) {
const config = {
url: option.url || getFallbackUrl(),
width: option.width || 160,
number: option.number || 100,
scale: option.scale || 1
};
if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
throw new TypeError("Auto-thumbnail width and number must be finite positive numbers");
return config;
}
function sheetSize(config, video) {
if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
throw new TypeError("Auto-thumbnail requires finite media duration and dimensions");
const height = Math.floor(config.width * video.videoHeight / video.videoWidth);
const canvasWidth = Math.trunc(config.width * 10);
const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10));
if (![height, canvasWidth, canvasHeight].every((value) => Number.isSafeInteger(value) && value > 0 && value <= 4294967295))
throw new RangeError("Auto-thumbnail canvas dimensions are invalid");
return { height, canvasWidth, canvasHeight };
}
function extract(job, config) {
const video = document.createElement("video");
job.own(() => video.load());
job.own(() => video.removeAttribute("src"));
job.own(() => video.pause());
for (const property of ["onloadedmetadata", "onseeked", "onerror"])
job.own(() => {
video[property] = null;
});
if (!job.active())
return;
video.crossOrigin = "anonymous";
video.src = url;
video.onloadedmetadata = () => {
video.onerror = job.guard(() => {
throw video.error || new Error("Auto-thumbnail media failed to load");
});
video.onloadedmetadata = job.guard(() => {
video.onloadedmetadata = null;
const duration = video.duration;
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
duration,
videoHeight: video.videoHeight,
videoWidth: video.videoWidth
});
if (!job.active())
return;
const canvas = document.createElement("canvas");
if (!job.active())
return;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext("2d");
const height = Math.floor(width * video.videoHeight / video.videoWidth);
canvas.width = width * 10;
canvas.height = height * Math.ceil(number / 10);
let blobUrl = null;
function seekAndDraw(index) {
canvas.toBlob((blob) => {
URL.revokeObjectURL(blobUrl);
blobUrl = URL.createObjectURL(blob);
callback({
url: blobUrl,
height
});
}, "image/jpeg");
if (index >= number)
if (!ctx)
throw new Error("Auto-thumbnail canvas context is unavailable");
if (!job.active())
return;
let index = 0;
const seek = job.guard(() => {
if (index >= config.number) {
job.dispose();
return;
video.currentTime = duration * index / number;
video.onseeked = () => {
ctx.drawImage(video, index % 10 * width, Math.floor(index / 10) * height, width, height);
seekAndDraw(index + 1);
};
}
video.onseeked = job.guard(() => {
video.onseeked = null;
ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height);
if (!job.active())
return;
let delivered = false;
canvas.toBlob(job.guard((blob) => {
if (delivered)
return;
delivered = true;
job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale });
if (job.active()) {
index += 1;
seek();
}
}), "image/jpeg");
});
if (job.active())
video.currentTime = duration * index / config.number;
});
seek();
});
if (job.active())
video.src = config.url;
}
function cleanupAll(actions) {
let failure;
let failed = false;
for (const action of actions) {
try {
action();
} catch (error) {
if (!failed) {
failed = true;
failure = error;
}
}
}
return { failed, failure };
}
function createSession(publish, report) {
let closed = false;
let current;
let lastUrl;
const urls = /* @__PURE__ */ new Set();
function release(url) {
if (urls.delete(url))
URL.revokeObjectURL(url);
}
function cancel() {
const previous = current;
current = void 0;
previous?.dispose();
}
function destroy() {
if (closed)
return;
closed = true;
const result = cleanupAll([cancel, ...[...urls].map((url) => () => release(url))]);
lastUrl = void 0;
if (result.failed)
report(result.failure);
}
function start() {
if (closed)
return;
const previous = current;
const actions = [];
let disposed = false;
const job = {
active: () => !closed && !disposed && current === job,
own(action) {
if (disposed) {
const result = cleanupAll([action]);
if (result.failed)
report(result.failure);
} else {
actions.push(action);
}
},
dispose() {
if (disposed)
return;
disposed = true;
if (current === job)
current = void 0;
const result = cleanupAll(actions.splice(0).reverse());
if (result.failed)
report(result.failure);
},
fail(error) {
if (!job.active())
return;
job.dispose();
report(error);
},
guard(callback) {
return (...args) => {
if (!job.active())
return;
try {
return callback(...args);
} catch (error) {
job.fail(error);
}
};
},
publish(blob, config) {
if (!job.active())
return;
if (!blob)
throw new Error("Auto-thumbnail encoding returned no Blob");
const url = URL.createObjectURL(blob);
urls.add(url);
if (!job.active()) {
release(url);
return;
}
const previousUrl = lastUrl;
lastUrl = url;
try {
publish({ url, ...config });
} catch (error) {
if (lastUrl === url)
lastUrl = urls.has(previousUrl) ? previousUrl : void 0;
const result = cleanupAll([() => release(url)]);
if (result.failed)
report(result.failure);
throw error;
}
release(previousUrl);
}
};
current = job;
previous?.dispose();
return job;
}
return {
start,
cancel,
destroy,
get closed() {
return closed;
}
seekAndDraw(0);
};
}
function artplayerPluginAutoThumbnail(option) {
return async (art) => {
art.on("video:loadedmetadata", () => {
const url = option.url || art.option.url;
const width = option.width || 160;
const number = option.number || 100;
const scale = option.scale || 1;
create({ url, width, number }, (config) => {
art.thumbnails = {
...config,
column: 10,
number,
width,
scale
};
});
});
return {
name: "artplayerPluginAutoThumbnail"
const report = (error) => console.warn("ArtPlayer auto-thumbnail failed:", error);
const session = createSession((config) => {
art.thumbnails = config;
}, report);
const subscriptions = [];
const onMetadata = () => {
const job = session.start();
if (job) {
job.guard(() => {
const config = readOptions(option, () => art.option.url);
if (job.active())
extract(job, config);
})();
}
};
const onDestroy = () => {
const actions = subscriptions.splice(0);
const result = cleanupAll([session.destroy, ...actions]);
if (result.failed)
report(result.failure);
};
try {
for (const [name, callback] of [["destroy", onDestroy], ["restart", session.cancel], ["video:loadedmetadata", onMetadata]]) {
if (session.closed)
break;
subscriptions.push(() => art.off(name, callback));
art.on(name, callback);
if (session.closed)
art.off(name, callback);
}
} catch (error) {
onDestroy();
throw error;
}
return { name: "artplayerPluginAutoThumbnail" };
};
}
export {
@@ -0,0 +1,66 @@
import { sheetSize } from './options'
export default function extract(job, config) {
const video = document.createElement('video')
job.own(() => video.load())
job.own(() => video.removeAttribute('src'))
job.own(() => video.pause())
for (const property of ['onloadedmetadata', 'onseeked', 'onerror'])
job.own(() => { video[property] = null })
if (!job.active())
return
video.crossOrigin = 'anonymous'
video.onerror = job.guard(() => {
throw video.error || new Error('Auto-thumbnail media failed to load')
})
video.onloadedmetadata = job.guard(() => {
video.onloadedmetadata = null
const duration = video.duration
const { height, canvasWidth, canvasHeight } = sheetSize(config, {
duration,
videoHeight: video.videoHeight,
videoWidth: video.videoWidth,
})
if (!job.active())
return
const canvas = document.createElement('canvas')
if (!job.active())
return
canvas.width = canvasWidth
canvas.height = canvasHeight
const ctx = canvas.getContext('2d')
if (!ctx)
throw new Error('Auto-thumbnail canvas context is unavailable')
if (!job.active())
return
let index = 0
const seek = job.guard(() => {
if (index >= config.number) {
job.dispose()
return
}
video.onseeked = job.guard(() => {
video.onseeked = null
ctx.drawImage(video, (index % 10) * config.width, Math.floor(index / 10) * height, config.width, height)
if (!job.active())
return
let delivered = false
canvas.toBlob(job.guard((blob) => {
if (delivered)
return
delivered = true
job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale })
if (job.active()) {
index += 1
seek()
}
}), 'image/jpeg')
})
if (job.active())
video.currentTime = duration * index / config.number
})
seek()
})
if (job.active())
video.src = config.url
}
@@ -1,64 +1,44 @@
function create({ url, width, number }, callback) {
const video = document.createElement('video')
video.crossOrigin = 'anonymous'
video.src = url
video.onloadedmetadata = () => {
const duration = video.duration
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const height = Math.floor((width * video.videoHeight) / video.videoWidth)
canvas.width = width * 10
canvas.height = height * Math.ceil(number / 10)
let blobUrl = null
function seekAndDraw(index) {
canvas.toBlob((blob) => {
URL.revokeObjectURL(blobUrl)
blobUrl = URL.createObjectURL(blob)
callback({
url: blobUrl,
height,
})
}, 'image/jpeg')
if (index >= number)
return
video.currentTime = (duration * index) / number
video.onseeked = () => {
ctx.drawImage(video, (index % 10) * width, Math.floor(index / 10) * height, width, height)
seekAndDraw(index + 1)
}
}
seekAndDraw(0)
}
}
import extract from './extraction'
import { readOptions } from './options'
import createSession, { cleanupAll } from './session'
export default function artplayerPluginAutoThumbnail(option) {
return async (art) => {
art.on('video:loadedmetadata', () => {
const url = option.url || art.option.url
const width = option.width || 160
const number = option.number || 100
const scale = option.scale || 1
create({ url, width, number }, (config) => {
art.thumbnails = {
...config,
column: 10,
number,
width,
scale,
}
})
})
return {
name: 'artplayerPluginAutoThumbnail',
const report = error => console.warn('ArtPlayer auto-thumbnail failed:', error)
const session = createSession((config) => {
art.thumbnails = config
}, report)
const subscriptions = []
const onMetadata = () => {
const job = session.start()
if (job) {
job.guard(() => {
const config = readOptions(option, () => art.option.url)
if (job.active())
extract(job, config)
})()
}
}
const onDestroy = () => {
const actions = subscriptions.splice(0)
const result = cleanupAll([session.destroy, ...actions])
if (result.failed)
report(result.failure)
}
try {
for (const [name, callback] of [['destroy', onDestroy], ['restart', session.cancel], ['video:loadedmetadata', onMetadata]]) {
if (session.closed)
break
subscriptions.push(() => art.off(name, callback))
art.on(name, callback)
if (session.closed)
art.off(name, callback)
}
}
catch (error) {
onDestroy()
throw error
}
return { name: 'artplayerPluginAutoThumbnail' }
}
}
@@ -0,0 +1,22 @@
export function readOptions(option, getFallbackUrl) {
const config = {
url: option.url || getFallbackUrl(),
width: option.width || 160,
number: option.number || 100,
scale: option.scale || 1,
}
if (!Number.isFinite(Number(config.width)) || config.width <= 0 || !Number.isFinite(Number(config.number)) || config.number <= 0)
throw new TypeError('Auto-thumbnail width and number must be finite positive numbers')
return config
}
export function sheetSize(config, video) {
if (!Number.isFinite(video.duration) || video.duration <= 0 || !Number.isFinite(video.videoWidth) || video.videoWidth <= 0 || !Number.isFinite(video.videoHeight) || video.videoHeight <= 0)
throw new TypeError('Auto-thumbnail requires finite media duration and dimensions')
const height = Math.floor(config.width * video.videoHeight / video.videoWidth)
const canvasWidth = Math.trunc(config.width * 10)
const canvasHeight = Math.trunc(height * Math.ceil(config.number / 10))
if (![height, canvasWidth, canvasHeight].every(value => Number.isSafeInteger(value) && value > 0 && value <= 0xFFFFFFFF))
throw new RangeError('Auto-thumbnail canvas dimensions are invalid')
return { height, canvasWidth, canvasHeight }
}
@@ -0,0 +1,130 @@
export function cleanupAll(actions) {
let failure
let failed = false
for (const action of actions) {
try {
action()
}
catch (error) {
if (!failed) {
failed = true
failure = error
}
}
}
return { failed, failure }
}
export default function createSession(publish, report) {
let closed = false
let current
let lastUrl
const urls = new Set()
function release(url) {
if (urls.delete(url))
URL.revokeObjectURL(url)
}
function cancel() {
const previous = current
current = undefined
previous?.dispose()
}
function destroy() {
if (closed)
return
closed = true
const result = cleanupAll([cancel, ...[...urls].map(url => () => release(url))])
lastUrl = undefined
if (result.failed)
report(result.failure)
}
function start() {
if (closed)
return
// Install the replacement before cleanup, which can reenter through host APIs.
const previous = current
const actions = []
let disposed = false
const job = {
active: () => !closed && !disposed && current === job,
own(action) {
if (disposed) {
const result = cleanupAll([action])
if (result.failed)
report(result.failure)
}
else {
actions.push(action)
}
},
dispose() {
if (disposed)
return
disposed = true
if (current === job)
current = undefined
const result = cleanupAll(actions.splice(0).reverse())
if (result.failed)
report(result.failure)
},
fail(error) {
if (!job.active())
return
job.dispose()
report(error)
},
guard(callback) {
return (...args) => {
if (!job.active())
return
try {
return callback(...args)
}
catch (error) {
job.fail(error)
}
}
},
publish(blob, config) {
if (!job.active())
return
if (!blob)
throw new Error('Auto-thumbnail encoding returned no Blob')
const url = URL.createObjectURL(blob)
urls.add(url)
if (!job.active()) {
release(url)
return
}
const previousUrl = lastUrl
lastUrl = url
try {
publish({ url, ...config })
}
catch (error) {
if (lastUrl === url)
lastUrl = urls.has(previousUrl) ? previousUrl : undefined
const result = cleanupAll([() => release(url)])
if (result.failed)
report(result.failure)
throw error
}
release(previousUrl)
},
}
current = job
previous?.dispose()
return job
}
return {
start,
cancel,
destroy,
get closed() { return closed },
}
}