/*! * artplayer-plugin-auto-thumbnail.js v2.0.0 * Github: https://github.com/zhw2590582/ArtPlayer * (c) 2017-2026 Harvey Zhao * Released under the MIT License. */ function createEncoder(job, canvas) { let pending; function clear() { const previous = pending; pending = void 0; if (previous && previous.timer !== null) clearTimeout(previous.timer); } job.own(clear); return (publish) => { if (!job.active()) return; if (pending) throw new Error("Auto-thumbnail encoding is already pending"); const current = { timer: null }; pending = current; const active = () => job.active() && pending === current; current.timer = setTimeout(job.guard(() => { if (active()) throw new Error("Auto-thumbnail encoding timed out"); }), 3e4); if (!active()) { clearTimeout(current.timer); return; } canvas.toBlob(job.guard((blob) => { if (!active()) return; clear(); if (job.active()) publish(blob); }), "image/jpeg"); }; } 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 (url !== void 0 && 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 = previousUrl !== void 0 && 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; } }; } function createFrameReader(job, video) { const presentedFrames = typeof video.requestVideoFrameCallback === "function" && typeof video.cancelVideoFrameCallback === "function"; let pending; function clear() { const previous = pending; pending = void 0; const result = cleanupAll([ () => { video.onloadeddata = null; }, () => { video.onseeked = null; }, () => { if (previous && previous.timer !== null) clearTimeout(previous.timer); }, () => { if (previous && previous.frame !== null) video.cancelVideoFrameCallback(previous.frame); } ]); if (result.failed) throw result.failure; } job.own(clear); return (target, draw) => { clear(); if (!job.active()) return; const current = { frame: null, timer: null, sought: false, presented: !presentedFrames, retries: 0, sequence: 0, started: false }; pending = current; const active = () => job.active() && pending === current; const finish = job.guard(() => { if (!active() || !current.sought || !current.presented) return; clear(); if (job.active()) draw(); }); const requestFrame = () => { if (!presentedFrames) return; const sequence = ++current.sequence; current.presented = false; const previous = current.frame; current.frame = null; if (previous !== null) video.cancelVideoFrameCallback(previous); if (!active()) return; let delivered = false; const id = video.requestVideoFrameCallback(job.guard(() => { if (!active() || sequence !== current.sequence || delivered) return; delivered = true; current.frame = null; current.presented = true; finish(); })); if (active() && sequence === current.sequence && !delivered) current.frame = id; else video.cancelVideoFrameCallback(id); }; const seek = () => { current.sought = false; requestFrame(); if (active()) video.currentTime = target; }; const begin = job.guard(() => { if (!active() || current.started) return; current.started = true; video.onloadeddata = null; video.onseeked = job.guard(() => { if (!active() || video.seeking) return; const time = video.currentTime; if (!active()) return; if (!Number.isFinite(time) || Math.abs(time - target) > 0.05) { if (++current.retries > 3) throw new Error("Auto-thumbnail seek did not reach the requested time"); seek(); return; } current.sought = true; finish(); }); seek(); }); current.timer = setTimeout(job.guard(() => { if (active()) throw new Error("Auto-thumbnail frame readiness timed out"); }), 3e4); if (!active()) { clearTimeout(current.timer); return; } const ready = !presentedFrames || video.readyState >= 2; if (!active()) return; if (!ready) video.onloadeddata = begin; else begin(); }; } 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 createVideo(job) { const video = document.createElement("video"); job.own(() => video.remove()); job.own(() => video.load()); job.own(() => video.removeAttribute("src")); job.own(() => video.pause()); for (const property of ["onloadedmetadata", "onloadeddata", "onseeked", "onerror"]) job.own(() => { video[property] = null; }); if (!job.active()) return video; video.crossOrigin = "anonymous"; video.muted = true; video.playsInline = true; video.tabIndex = -1; video.setAttribute("aria-hidden", "true"); video.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;display:block;width:auto;height:auto;max-width:none;max-height:none"; document.documentElement.appendChild(video); if (!job.active()) video.remove(); return video; } function extract(job, config) { const video = createVideo(job); if (!job.active()) return; let metadataTimer = null; const clearMetadataTimer = () => { const previous = metadataTimer; metadataTimer = null; if (previous !== null) clearTimeout(previous); }; job.own(clearMetadataTimer); video.onerror = job.guard(() => { throw video.error || new Error("Auto-thumbnail media failed to load"); }); video.onloadedmetadata = job.guard(() => { clearMetadataTimer(); video.onloadedmetadata = null; const duration = video.duration; const videoHeight = video.videoHeight; const videoWidth = video.videoWidth; const { height, canvasWidth, canvasHeight } = sheetSize(config, { duration, videoHeight, videoWidth }); if (!job.active()) return; video.width = videoWidth; video.height = videoHeight; video.style.width = `${videoWidth}px`; video.style.height = `${videoHeight}px`; const canvas = document.createElement("canvas"); job.own(() => { canvas.height = 0; }); job.own(() => { canvas.width = 0; }); if (!job.active()) return; canvas.width = canvasWidth; if (!job.active()) return; canvas.height = canvasHeight; if (!job.active()) return; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Auto-thumbnail canvas context is unavailable"); if (!job.active()) return; const readFrame = createFrameReader(job, video); const encode = createEncoder(job, canvas); let index = 0; const seek = job.guard(() => { if (index >= config.number) { job.dispose(); return; } readFrame(duration * index / config.number, job.guard(() => { ctx.drawImage(video, index % 10 * config.width, Math.floor(index / 10) * height, config.width, height); if (!job.active()) return; encode((blob) => { job.publish(blob, { height, column: 10, number: config.number, width: config.width, scale: config.scale }); if (job.active()) { index += 1; seek(); } }); })); }); seek(); }); if (!job.active()) return; metadataTimer = setTimeout(job.guard(() => { if (metadataTimer !== null) throw new Error("Auto-thumbnail metadata timed out"); }), 3e4); if (!job.active()) { clearMetadataTimer(); return; } video.src = config.url; } function artplayerPluginAutoThumbnail(option) { return async (art) => { if (art.isDestroy) 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 { const events = [["destroy", onDestroy], ["restart", session.cancel], ["video:loadedmetadata", onMetadata]]; for (const [name, callback] of events) { 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" }; }; } artplayerPluginAutoThumbnail.default = artplayerPluginAutoThumbnail; export { artplayerPluginAutoThumbnail as default };