pa_encoder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +514 -0
- package/dist/worker.js +36 -0
- package/package.json +26 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// src/live.js
|
|
2
|
+
async function startLiveCapture({
|
|
3
|
+
canvas,
|
|
4
|
+
exporter,
|
|
5
|
+
fps = 30,
|
|
6
|
+
concurrency = 2,
|
|
7
|
+
maxQueue = 8,
|
|
8
|
+
policy = "drop",
|
|
9
|
+
// "drop" | "block"
|
|
10
|
+
onProgress,
|
|
11
|
+
signal
|
|
12
|
+
} = {}) {
|
|
13
|
+
if (!(canvas instanceof HTMLCanvasElement)) {
|
|
14
|
+
throw new TypeError("canvas must be an HTMLCanvasElement");
|
|
15
|
+
}
|
|
16
|
+
if (!exporter || typeof exporter.write !== "function" || typeof exporter.finalize !== "function") {
|
|
17
|
+
throw new TypeError("exporter must have write() and finalize()");
|
|
18
|
+
}
|
|
19
|
+
if (!Number.isFinite(fps) || fps <= 0)
|
|
20
|
+
throw new TypeError("fps must be positive");
|
|
21
|
+
if (!Number.isInteger(concurrency) || concurrency < 1)
|
|
22
|
+
throw new TypeError("concurrency must be >= 1");
|
|
23
|
+
if (!Number.isInteger(maxQueue) || maxQueue < 0)
|
|
24
|
+
throw new TypeError("maxQueue must be >= 0");
|
|
25
|
+
if (policy !== "drop" && policy !== "block")
|
|
26
|
+
throw new TypeError('policy must be "drop" or "block"');
|
|
27
|
+
const worker = new Worker(new URL("./worker.js", import.meta.url), {
|
|
28
|
+
type: "module"
|
|
29
|
+
});
|
|
30
|
+
await new Promise((resolve, reject) => {
|
|
31
|
+
const onMsg = (ev) => {
|
|
32
|
+
var _a, _b;
|
|
33
|
+
if (((_a = ev.data) == null ? void 0 : _a.type) === "ready") {
|
|
34
|
+
worker.removeEventListener("message", onMsg);
|
|
35
|
+
resolve();
|
|
36
|
+
} else if (((_b = ev.data) == null ? void 0 : _b.type) === "error") {
|
|
37
|
+
worker.removeEventListener("message", onMsg);
|
|
38
|
+
reject(new Error(ev.data.message || "Worker error"));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
worker.addEventListener("message", onMsg);
|
|
42
|
+
worker.addEventListener("error", reject);
|
|
43
|
+
});
|
|
44
|
+
const stats = {
|
|
45
|
+
captured: 0,
|
|
46
|
+
encoded: 0,
|
|
47
|
+
written: 0,
|
|
48
|
+
dropped: 0,
|
|
49
|
+
queueMax: 0,
|
|
50
|
+
inFlightMax: 0,
|
|
51
|
+
lastBitmapMs: 0,
|
|
52
|
+
bitmapMsAvg: 0,
|
|
53
|
+
lastEncodeMs: 0,
|
|
54
|
+
encodeMsAvg: 0,
|
|
55
|
+
lastWriteMs: 0,
|
|
56
|
+
writeMsAvg: 0
|
|
57
|
+
};
|
|
58
|
+
let stopped = false;
|
|
59
|
+
let rafId = null;
|
|
60
|
+
let inFlight = 0;
|
|
61
|
+
const queue = [];
|
|
62
|
+
const waiters = [];
|
|
63
|
+
const ack = /* @__PURE__ */ new Map();
|
|
64
|
+
function throwIfAborted() {
|
|
65
|
+
if (signal == null ? void 0 : signal.aborted) {
|
|
66
|
+
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function wakeOneWaiter() {
|
|
70
|
+
const w = waiters.shift();
|
|
71
|
+
if (w) w();
|
|
72
|
+
}
|
|
73
|
+
async function enqueueFrame(item) {
|
|
74
|
+
if (policy === "drop") {
|
|
75
|
+
if (queue.length >= maxQueue) {
|
|
76
|
+
stats.dropped++;
|
|
77
|
+
if (typeof item.bitmap.close === "function") item.bitmap.close();
|
|
78
|
+
onProgress == null ? void 0 : onProgress({ ...stats });
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
queue.push(item);
|
|
82
|
+
stats.queueMax = Math.max(stats.queueMax, queue.length);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
while (queue.length >= maxQueue) {
|
|
86
|
+
await new Promise((r) => waiters.push(r));
|
|
87
|
+
throwIfAborted();
|
|
88
|
+
if (stopped) return false;
|
|
89
|
+
}
|
|
90
|
+
queue.push(item);
|
|
91
|
+
stats.queueMax = Math.max(stats.queueMax, queue.length);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
function dispatchIfPossible() {
|
|
95
|
+
while (!stopped && inFlight < concurrency && queue.length > 0) {
|
|
96
|
+
const item = queue.shift();
|
|
97
|
+
wakeOneWaiter();
|
|
98
|
+
inFlight++;
|
|
99
|
+
stats.inFlightMax = Math.max(stats.inFlightMax, inFlight);
|
|
100
|
+
const { seq: seq2, bitmap, w, h } = item;
|
|
101
|
+
const p = new Promise(
|
|
102
|
+
(resolve, reject) => ack.set(seq2, { resolve, reject })
|
|
103
|
+
);
|
|
104
|
+
worker.postMessage(
|
|
105
|
+
{ type: "encode", frameIndex: seq2, bitmap, width: w, height: h },
|
|
106
|
+
[bitmap]
|
|
107
|
+
);
|
|
108
|
+
p.catch(() => {
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
worker.addEventListener("message", async (ev) => {
|
|
113
|
+
const msg = ev.data;
|
|
114
|
+
if (!msg || typeof msg !== "object") return;
|
|
115
|
+
if (msg.type === "frame") {
|
|
116
|
+
const { frameIndex: seq2, blob, encodeMs } = msg;
|
|
117
|
+
stats.encoded++;
|
|
118
|
+
if (typeof encodeMs === "number") {
|
|
119
|
+
stats.lastEncodeMs = encodeMs;
|
|
120
|
+
stats.encodeMsAvg = stats.encodeMsAvg ? stats.encodeMsAvg * 0.9 + encodeMs * 0.1 : encodeMs;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const w0 = performance.now();
|
|
124
|
+
await exporter.write(seq2, blob);
|
|
125
|
+
const w1 = performance.now();
|
|
126
|
+
stats.written++;
|
|
127
|
+
const writeMs = w1 - w0;
|
|
128
|
+
stats.lastWriteMs = writeMs;
|
|
129
|
+
stats.writeMsAvg = stats.writeMsAvg ? stats.writeMsAvg * 0.9 + writeMs * 0.1 : writeMs;
|
|
130
|
+
const entry = ack.get(seq2);
|
|
131
|
+
if (entry) entry.resolve();
|
|
132
|
+
} catch (err) {
|
|
133
|
+
const entry = ack.get(seq2);
|
|
134
|
+
if (entry) entry.reject(err);
|
|
135
|
+
} finally {
|
|
136
|
+
ack.delete(seq2);
|
|
137
|
+
inFlight--;
|
|
138
|
+
dispatchIfPossible();
|
|
139
|
+
onProgress == null ? void 0 : onProgress({ ...stats });
|
|
140
|
+
}
|
|
141
|
+
} else if (msg.type === "error") {
|
|
142
|
+
console.error("Worker error:", msg.message);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
const frameIntervalMs = 1e3 / fps;
|
|
146
|
+
let accMs = 0;
|
|
147
|
+
let lastTickMs = null;
|
|
148
|
+
let seq = 0;
|
|
149
|
+
function tick(ts) {
|
|
150
|
+
if (stopped) return;
|
|
151
|
+
try {
|
|
152
|
+
throwIfAborted();
|
|
153
|
+
} catch {
|
|
154
|
+
stop().catch(() => {
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (lastTickMs == null) lastTickMs = ts;
|
|
159
|
+
const dt = ts - lastTickMs;
|
|
160
|
+
lastTickMs = ts;
|
|
161
|
+
accMs += dt;
|
|
162
|
+
while (accMs >= frameIntervalMs) {
|
|
163
|
+
accMs -= frameIntervalMs;
|
|
164
|
+
const b0 = performance.now();
|
|
165
|
+
createImageBitmap(canvas).then(async (bitmap) => {
|
|
166
|
+
const b1 = performance.now();
|
|
167
|
+
const bitmapMs = b1 - b0;
|
|
168
|
+
stats.lastBitmapMs = bitmapMs;
|
|
169
|
+
stats.bitmapMsAvg = stats.bitmapMsAvg ? stats.bitmapMsAvg * 0.9 + bitmapMs * 0.1 : bitmapMs;
|
|
170
|
+
if (stopped) {
|
|
171
|
+
if (typeof bitmap.close === "function") bitmap.close();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
stats.captured++;
|
|
175
|
+
const ok = await enqueueFrame({
|
|
176
|
+
seq: seq++,
|
|
177
|
+
bitmap,
|
|
178
|
+
w: canvas.width,
|
|
179
|
+
h: canvas.height
|
|
180
|
+
});
|
|
181
|
+
if (ok) dispatchIfPossible();
|
|
182
|
+
onProgress == null ? void 0 : onProgress({ ...stats });
|
|
183
|
+
}).catch((e) => console.error("createImageBitmap failed:", e));
|
|
184
|
+
}
|
|
185
|
+
rafId = requestAnimationFrame(tick);
|
|
186
|
+
}
|
|
187
|
+
rafId = requestAnimationFrame(tick);
|
|
188
|
+
async function stop() {
|
|
189
|
+
if (stopped) return;
|
|
190
|
+
stopped = true;
|
|
191
|
+
if (rafId != null) cancelAnimationFrame(rafId);
|
|
192
|
+
while (waiters.length) wakeOneWaiter();
|
|
193
|
+
for (const item of queue.splice(0, queue.length)) {
|
|
194
|
+
if (typeof item.bitmap.close === "function") item.bitmap.close();
|
|
195
|
+
}
|
|
196
|
+
while (inFlight > 0 || ack.size > 0) {
|
|
197
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
198
|
+
}
|
|
199
|
+
await exporter.finalize();
|
|
200
|
+
worker.terminate();
|
|
201
|
+
}
|
|
202
|
+
return { stop, stats };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/virtual_time.js
|
|
206
|
+
function installVirtualTime({
|
|
207
|
+
fps = 60,
|
|
208
|
+
hookDateNow = true,
|
|
209
|
+
hookPerformanceNow = true
|
|
210
|
+
} = {}) {
|
|
211
|
+
if (!Number.isFinite(fps) || fps <= 0)
|
|
212
|
+
throw new TypeError("fps must be positive");
|
|
213
|
+
const dtMs = 1e3 / fps;
|
|
214
|
+
const _requestAnimationFrame = window.requestAnimationFrame.bind(window);
|
|
215
|
+
const _cancelAnimationFrame = window.cancelAnimationFrame.bind(window);
|
|
216
|
+
const _dateNow = Date.now.bind(Date);
|
|
217
|
+
const _perfNow = performance.now.bind(performance);
|
|
218
|
+
let vNowMs = 0;
|
|
219
|
+
let running = true;
|
|
220
|
+
let nextId = 1;
|
|
221
|
+
const pending = /* @__PURE__ */ new Map();
|
|
222
|
+
const scheduled = [];
|
|
223
|
+
function hookedRAF(cb) {
|
|
224
|
+
if (!running) return _requestAnimationFrame(cb);
|
|
225
|
+
const id = nextId++;
|
|
226
|
+
pending.set(id, cb);
|
|
227
|
+
scheduled.push(id);
|
|
228
|
+
return id;
|
|
229
|
+
}
|
|
230
|
+
function hookedCancelRAF(id) {
|
|
231
|
+
if (!running) return _cancelAnimationFrame(id);
|
|
232
|
+
pending.delete(id);
|
|
233
|
+
}
|
|
234
|
+
window.requestAnimationFrame = hookedRAF;
|
|
235
|
+
window.cancelAnimationFrame = hookedCancelRAF;
|
|
236
|
+
if (hookDateNow) {
|
|
237
|
+
Date.now = function() {
|
|
238
|
+
return running ? vNowMs : _dateNow();
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (hookPerformanceNow) {
|
|
242
|
+
performance.now = function() {
|
|
243
|
+
return running ? vNowMs : _perfNow();
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function flushRAFFrame() {
|
|
247
|
+
const ids = scheduled.splice(0, scheduled.length);
|
|
248
|
+
for (const id of ids) {
|
|
249
|
+
const cb = pending.get(id);
|
|
250
|
+
if (!cb) continue;
|
|
251
|
+
pending.delete(id);
|
|
252
|
+
try {
|
|
253
|
+
cb(vNowMs);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.error("Error in rAF callback:", e);
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function step(frames = 1) {
|
|
261
|
+
if (!running) throw new Error("Virtual time is not running");
|
|
262
|
+
if (!Number.isInteger(frames) || frames < 1)
|
|
263
|
+
throw new TypeError("frames must be >= 1");
|
|
264
|
+
for (let i = 0; i < frames; i++) {
|
|
265
|
+
vNowMs += dtMs;
|
|
266
|
+
flushRAFFrame();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function stopVirtualTime() {
|
|
270
|
+
running = false;
|
|
271
|
+
}
|
|
272
|
+
function restore() {
|
|
273
|
+
window.requestAnimationFrame = _requestAnimationFrame;
|
|
274
|
+
window.cancelAnimationFrame = _cancelAnimationFrame;
|
|
275
|
+
if (hookDateNow) Date.now = _dateNow;
|
|
276
|
+
if (hookPerformanceNow) performance.now = _perfNow;
|
|
277
|
+
running = false;
|
|
278
|
+
pending.clear();
|
|
279
|
+
scheduled.length = 0;
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
get nowMs() {
|
|
283
|
+
return vNowMs;
|
|
284
|
+
},
|
|
285
|
+
dtMs,
|
|
286
|
+
step,
|
|
287
|
+
stopVirtualTime,
|
|
288
|
+
restore
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/virtual_capture.js
|
|
293
|
+
function resolveCanvas(canvasOrSelector) {
|
|
294
|
+
if (canvasOrSelector instanceof HTMLCanvasElement) return canvasOrSelector;
|
|
295
|
+
if (typeof canvasOrSelector === "string") {
|
|
296
|
+
const el = document.querySelector(canvasOrSelector);
|
|
297
|
+
if (el instanceof HTMLCanvasElement) return el;
|
|
298
|
+
throw new Error(`No canvas found for selector: ${canvasOrSelector}`);
|
|
299
|
+
}
|
|
300
|
+
const first = document.querySelector("canvas");
|
|
301
|
+
if (first instanceof HTMLCanvasElement) return first;
|
|
302
|
+
throw new Error("No canvas found on the page");
|
|
303
|
+
}
|
|
304
|
+
async function virtualTimeCapture({
|
|
305
|
+
// IMPORTANT: loadSketch must be called AFTER hooks are installed
|
|
306
|
+
loadSketch,
|
|
307
|
+
// () => import("...") (or any function that executes the sketch module)
|
|
308
|
+
canvas: canvasOrSelector,
|
|
309
|
+
// canvas element OR selector OR omit to use first canvas
|
|
310
|
+
fps = 60,
|
|
311
|
+
frames = 300,
|
|
312
|
+
warmupFrames = 0,
|
|
313
|
+
exporter,
|
|
314
|
+
concurrency = 2,
|
|
315
|
+
onProgress,
|
|
316
|
+
hookDateNow = true,
|
|
317
|
+
hookPerformanceNow = true,
|
|
318
|
+
signal
|
|
319
|
+
} = {}) {
|
|
320
|
+
if (typeof loadSketch !== "function") {
|
|
321
|
+
throw new TypeError(
|
|
322
|
+
"loadSketch must be a function returning a Promise (dynamic import)"
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (!exporter || typeof exporter.write !== "function" || typeof exporter.finalize !== "function") {
|
|
326
|
+
throw new TypeError("exporter must have write() and finalize()");
|
|
327
|
+
}
|
|
328
|
+
if (!Number.isFinite(fps) || fps <= 0)
|
|
329
|
+
throw new TypeError("fps must be positive");
|
|
330
|
+
if (!Number.isInteger(frames) || frames < 1)
|
|
331
|
+
throw new TypeError("frames must be >= 1");
|
|
332
|
+
if (!Number.isInteger(warmupFrames) || warmupFrames < 0)
|
|
333
|
+
throw new TypeError("warmupFrames must be >= 0");
|
|
334
|
+
if (!Number.isInteger(concurrency) || concurrency < 1)
|
|
335
|
+
throw new TypeError("concurrency must be >= 1");
|
|
336
|
+
const vt = installVirtualTime({ fps, hookDateNow, hookPerformanceNow });
|
|
337
|
+
const worker = new Worker(new URL("./worker.js", import.meta.url), {
|
|
338
|
+
type: "module"
|
|
339
|
+
});
|
|
340
|
+
const ready = new Promise((resolve, reject) => {
|
|
341
|
+
const onMsg = (ev) => {
|
|
342
|
+
var _a, _b;
|
|
343
|
+
if (((_a = ev.data) == null ? void 0 : _a.type) === "ready") {
|
|
344
|
+
worker.removeEventListener("message", onMsg);
|
|
345
|
+
resolve();
|
|
346
|
+
} else if (((_b = ev.data) == null ? void 0 : _b.type) === "error") {
|
|
347
|
+
worker.removeEventListener("message", onMsg);
|
|
348
|
+
reject(new Error(ev.data.message || "Worker error"));
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
worker.addEventListener("message", onMsg);
|
|
352
|
+
worker.addEventListener("error", reject);
|
|
353
|
+
});
|
|
354
|
+
function throwIfAborted() {
|
|
355
|
+
if (signal == null ? void 0 : signal.aborted) {
|
|
356
|
+
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
await ready;
|
|
360
|
+
await loadSketch();
|
|
361
|
+
const canvas = resolveCanvas(canvasOrSelector);
|
|
362
|
+
for (let i = 0; i < warmupFrames; i++) {
|
|
363
|
+
throwIfAborted();
|
|
364
|
+
vt.step(1);
|
|
365
|
+
}
|
|
366
|
+
let inFlight = 0;
|
|
367
|
+
const waiters = [];
|
|
368
|
+
const ack = /* @__PURE__ */ new Map();
|
|
369
|
+
function acquireSlot() {
|
|
370
|
+
if (inFlight < concurrency) return Promise.resolve();
|
|
371
|
+
return new Promise((r) => waiters.push(r));
|
|
372
|
+
}
|
|
373
|
+
function releaseSlot() {
|
|
374
|
+
inFlight--;
|
|
375
|
+
const w = waiters.shift();
|
|
376
|
+
if (w) w();
|
|
377
|
+
}
|
|
378
|
+
worker.addEventListener("message", async (ev) => {
|
|
379
|
+
const msg = ev.data;
|
|
380
|
+
if (!msg || typeof msg !== "object") return;
|
|
381
|
+
if (msg.type === "frame") {
|
|
382
|
+
const { frameIndex, blob } = msg;
|
|
383
|
+
try {
|
|
384
|
+
await exporter.write(frameIndex, blob);
|
|
385
|
+
const entry = ack.get(frameIndex);
|
|
386
|
+
if (entry) entry.resolve();
|
|
387
|
+
} catch (e) {
|
|
388
|
+
const entry = ack.get(frameIndex);
|
|
389
|
+
if (entry) entry.reject(e);
|
|
390
|
+
} finally {
|
|
391
|
+
ack.delete(frameIndex);
|
|
392
|
+
releaseSlot();
|
|
393
|
+
}
|
|
394
|
+
} else if (msg.type === "error") {
|
|
395
|
+
console.error("Worker error:", msg.message);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
try {
|
|
399
|
+
for (let i = 0; i < frames; i++) {
|
|
400
|
+
throwIfAborted();
|
|
401
|
+
vt.step(1);
|
|
402
|
+
const bitmap = await createImageBitmap(canvas);
|
|
403
|
+
await acquireSlot();
|
|
404
|
+
inFlight++;
|
|
405
|
+
const done = new Promise(
|
|
406
|
+
(resolve, reject) => ack.set(i, { resolve, reject })
|
|
407
|
+
);
|
|
408
|
+
worker.postMessage(
|
|
409
|
+
{
|
|
410
|
+
type: "encode",
|
|
411
|
+
frameIndex: i,
|
|
412
|
+
bitmap,
|
|
413
|
+
width: canvas.width,
|
|
414
|
+
height: canvas.height
|
|
415
|
+
},
|
|
416
|
+
[bitmap]
|
|
417
|
+
);
|
|
418
|
+
await done;
|
|
419
|
+
onProgress == null ? void 0 : onProgress(i + 1, frames, { nowMs: vt.nowMs });
|
|
420
|
+
}
|
|
421
|
+
while (inFlight > 0) {
|
|
422
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
423
|
+
}
|
|
424
|
+
await exporter.finalize();
|
|
425
|
+
} finally {
|
|
426
|
+
worker.terminate();
|
|
427
|
+
vt.restore();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/exporters/fs.js
|
|
432
|
+
async function createFsExporter({
|
|
433
|
+
dirNameHint = "frames",
|
|
434
|
+
filename = (i) => `frame_${String(i).padStart(6, "0")}.png`
|
|
435
|
+
} = {}) {
|
|
436
|
+
if (!("showDirectoryPicker" in window)) {
|
|
437
|
+
throw new Error("File System Access API is not supported in this browser.");
|
|
438
|
+
}
|
|
439
|
+
const dirHandle = await window.showDirectoryPicker({ id: dirNameHint });
|
|
440
|
+
return {
|
|
441
|
+
async write(frameIndex, blob) {
|
|
442
|
+
const name = filename(frameIndex);
|
|
443
|
+
const fileHandle = await dirHandle.getFileHandle(name, { create: true });
|
|
444
|
+
const writable = await fileHandle.createWritable();
|
|
445
|
+
await writable.write(blob);
|
|
446
|
+
await writable.close();
|
|
447
|
+
},
|
|
448
|
+
async finalize() {
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/exporters/zip.js
|
|
454
|
+
import JSZip from "jszip";
|
|
455
|
+
function downloadBlob(filename, blob) {
|
|
456
|
+
const url = URL.createObjectURL(blob);
|
|
457
|
+
const a = document.createElement("a");
|
|
458
|
+
a.href = url;
|
|
459
|
+
a.download = filename;
|
|
460
|
+
document.body.appendChild(a);
|
|
461
|
+
a.click();
|
|
462
|
+
a.remove();
|
|
463
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
|
464
|
+
}
|
|
465
|
+
async function createZipExporter({
|
|
466
|
+
zipName = "frames.zip",
|
|
467
|
+
filename = (i) => `frame_${String(i).padStart(6, "0")}.png`,
|
|
468
|
+
compressionLevel = 6
|
|
469
|
+
// 0..9
|
|
470
|
+
} = {}) {
|
|
471
|
+
const zip = new JSZip();
|
|
472
|
+
let count = 0;
|
|
473
|
+
return {
|
|
474
|
+
async write(frameIndex, blob) {
|
|
475
|
+
const name = filename(frameIndex);
|
|
476
|
+
const arr = await blob.arrayBuffer();
|
|
477
|
+
zip.file(name, arr);
|
|
478
|
+
count++;
|
|
479
|
+
},
|
|
480
|
+
async finalize() {
|
|
481
|
+
const out = await zip.generateAsync({
|
|
482
|
+
type: "blob",
|
|
483
|
+
compression: "DEFLATE",
|
|
484
|
+
compressionOptions: { level: compressionLevel }
|
|
485
|
+
});
|
|
486
|
+
downloadBlob(zipName, out);
|
|
487
|
+
return { files: count };
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/exporters/best.js
|
|
493
|
+
async function createBestExporter({
|
|
494
|
+
prefer = "fs",
|
|
495
|
+
// "fs" | "zip"
|
|
496
|
+
fs,
|
|
497
|
+
zip
|
|
498
|
+
} = {}) {
|
|
499
|
+
const canFS = typeof window !== "undefined" && window.isSecureContext === true && "showDirectoryPicker" in window;
|
|
500
|
+
if (prefer === "fs" && canFS) {
|
|
501
|
+
return await createFsExporter(fs);
|
|
502
|
+
}
|
|
503
|
+
if (prefer === "zip") {
|
|
504
|
+
return await createZipExporter(zip);
|
|
505
|
+
}
|
|
506
|
+
return await createZipExporter(zip);
|
|
507
|
+
}
|
|
508
|
+
export {
|
|
509
|
+
createBestExporter,
|
|
510
|
+
createFsExporter,
|
|
511
|
+
createZipExporter,
|
|
512
|
+
startLiveCapture,
|
|
513
|
+
virtualTimeCapture
|
|
514
|
+
};
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/worker.js
|
|
2
|
+
let offscreen = null;
|
|
3
|
+
let ctx = null;
|
|
4
|
+
|
|
5
|
+
self.postMessage({ type: "ready" });
|
|
6
|
+
|
|
7
|
+
self.onmessage = async (ev) => {
|
|
8
|
+
const msg = ev.data;
|
|
9
|
+
if (!msg || msg.type !== "encode") return;
|
|
10
|
+
|
|
11
|
+
const { frameIndex, bitmap, width, height } = msg;
|
|
12
|
+
|
|
13
|
+
const t0 = performance.now();
|
|
14
|
+
try {
|
|
15
|
+
if (
|
|
16
|
+
!offscreen ||
|
|
17
|
+
offscreen.width !== width ||
|
|
18
|
+
offscreen.height !== height
|
|
19
|
+
) {
|
|
20
|
+
offscreen = new OffscreenCanvas(width, height);
|
|
21
|
+
ctx = offscreen.getContext("2d", { alpha: true });
|
|
22
|
+
if (!ctx) throw new Error("Failed to get 2d context on OffscreenCanvas");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ctx.clearRect(0, 0, width, height);
|
|
26
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
27
|
+
if (typeof bitmap.close === "function") bitmap.close();
|
|
28
|
+
|
|
29
|
+
const blob = await offscreen.convertToBlob({ type: "image/png" });
|
|
30
|
+
const t1 = performance.now();
|
|
31
|
+
|
|
32
|
+
self.postMessage({ type: "frame", frameIndex, blob, encodeMs: t1 - t0 });
|
|
33
|
+
} catch (e) {
|
|
34
|
+
self.postMessage({ type: "error", message: e?.message ?? String(e) });
|
|
35
|
+
}
|
|
36
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pa_encoder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup src/index.js --format esm --clean && cp src/worker.js dist/worker.js",
|
|
16
|
+
"dev": "python -m http.server 5500",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"jszip": "^3.10.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"tsup": "^8.0.0",
|
|
24
|
+
"typescript": "^5.9.3"
|
|
25
|
+
}
|
|
26
|
+
}
|