UNPKG

@tomorrowevening/hermes

Version:

An extendable set of Web Tools controlled via a separate window for non-intereference with content.

149 lines (148 loc) 5.55 kB
const c = ( /* js */ ` self.onmessage = async ({ data }) => { if (data.type !== 'encode') return; const { bitmap, index, format, quality } = data; const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); bitmap.close(); const mime = format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png'; const blob = await canvas.convertToBlob({ type: mime, quality }); self.postMessage({ type: 'encoded', index, blob }); }; ` ); class d { worker; source; /** Intermediate 2D canvas used for GPU→CPU readback. Works with WebGPU and WebGL. */ transfer; ctx; opts; _capturing = !1; _captured = 0; _encoded = 0; _pending = 0; _lastFrameTime = -1 / 0; frames = /* @__PURE__ */ new Map(); constructor(s, t = {}) { this.source = s, this.transfer = document.createElement("canvas"), this.transfer.width = s.width, this.transfer.height = s.height, this.ctx = this.transfer.getContext("2d", { willReadFrequently: !0 }), this.opts = { format: t.format ?? "png", quality: t.quality ?? 0.92, prefix: t.prefix ?? "frame", padLength: t.padLength ?? 5, maxQueue: t.maxQueue ?? 32, fps: t.fps ?? 30, onProgress: t.onProgress ?? (() => { }), onError: t.onError ?? ((e) => console.error("[ImageSequenceCapturer]", e)) }; const a = new Blob([c], { type: "application/javascript" }), r = URL.createObjectURL(a); this.worker = new Worker(r), URL.revokeObjectURL(r), this.worker.onmessage = ({ data: e }) => { e.type === "encoded" && (this.frames.set(e.index, e.blob), this._pending--, this._encoded++, this.opts.onProgress(this._captured, this._encoded)); }, this.worker.onerror = (e) => this.opts.onError(new Error(e.message)); } get isCapturing() { return this._capturing; } get framesCaptured() { return this._captured; } get framesEncoded() { return this._encoded; } /** True when no frames are waiting to finish encoding. */ get isIdle() { return this._pending === 0; } /** * Resize the transfer canvas to match the source. * Call this if the canvas dimensions change between sessions. */ resize() { this.transfer.width = this.source.width, this.transfer.height = this.source.height; } /** Begin a new capture session. Clears any previously captured frames. */ start() { this._capturing = !0, this._captured = 0, this._encoded = 0, this._pending = 0, this._lastFrameTime = -1 / 0, this.frames.clear(); } stop() { this._capturing = !1; } /** * Capture the current canvas frame and queue it for encoding. * * **Call this immediately after your render call**, before the next frame begins. * This is especially important for WebGPU, where the swap chain texture is * only valid until the next `queue.submit()`. * * Returns `false` if not capturing or the worker queue is full (backpressure). */ captureFrame() { if (!this._capturing || this._pending >= this.opts.maxQueue) return !1; const s = performance.now(); if (s - this._lastFrameTime < 1e3 / this.opts.fps) return !1; this._lastFrameTime = s, this.ctx.drawImage(this.source, 0, 0); const t = this._captured++; return this._pending++, createImageBitmap(this.transfer).then((a) => { this.worker.postMessage( { type: "encode", bitmap: a, index: t, format: this.opts.format, quality: this.opts.quality }, [a] // transfer ownership — zero-copy ); }).catch((a) => { this._pending--, this.opts.onError(a); }), !0; } /** Resolves once all in-flight frames have finished encoding. */ flush() { return new Promise((s) => { const t = () => this._pending === 0 ? s() : setTimeout(t, 16); t(); }); } /** * Download all captured frames after waiting for encoding to finish. * * Prefers the **File System Access API** (`showDirectoryPicker`) so all frames * are written directly into a folder the user selects — no zip library needed. * Falls back to sequential `<a download>` clicks in browsers that don't support it. */ async download() { await this.flush(); const s = this.opts.format === "jpeg" ? "jpg" : this.opts.format, a = [...this.frames.entries()].sort(([r], [e]) => r - e).map(([r, e]) => ({ name: `${this.opts.prefix}_${String(r).padStart(this.opts.padLength, "0")}.${s}`, blob: e })); if ("showDirectoryPicker" in window) try { const r = await window.showDirectoryPicker({ mode: "readwrite", startIn: "downloads" }); await Promise.all( a.map(async ({ name: e, blob: i }) => { const n = await (await r.getFileHandle(e, { create: !0 })).createWritable(); await n.write(i), await n.close(); }) ); return; } catch (r) { if (r.name === "AbortError") return; } for (const { name: r, blob: e } of a) { const i = URL.createObjectURL(e); Object.assign(document.createElement("a"), { href: i, download: r }).click(), URL.revokeObjectURL(i), await new Promise((n) => setTimeout(n, 50)); } } /** Clear all stored frames without downloading. */ reset() { this._capturing = !1, this._captured = 0, this._encoded = 0, this._pending = 0, this.frames.clear(); } /** Terminate the worker and release all resources. */ destroy() { this.reset(), this.worker.terminate(); } } export { d as ImageSequenceCapturer };