pa_encoder 0.2.8 → 0.3.2

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 CHANGED
@@ -5,6 +5,7 @@ async function startLiveCapture({
5
5
  fps = 30,
6
6
  concurrency = 2,
7
7
  maxQueue = 8,
8
+ maxPendingCaptures = null,
8
9
  policy = "drop",
9
10
  // "drop" | "block"
10
11
  onProgress,
@@ -22,6 +23,9 @@ async function startLiveCapture({
22
23
  throw new TypeError("concurrency must be >= 1");
23
24
  if (!Number.isInteger(maxQueue) || maxQueue < 0)
24
25
  throw new TypeError("maxQueue must be >= 0");
26
+ if (maxPendingCaptures != null && (!Number.isInteger(maxPendingCaptures) || maxPendingCaptures < 1)) {
27
+ throw new TypeError("maxPendingCaptures must be >= 1");
28
+ }
25
29
  if (policy !== "drop" && policy !== "block")
26
30
  throw new TypeError('policy must be "drop" or "block"');
27
31
  const worker = new Worker(new URL("./worker.js", import.meta.url), {
@@ -32,22 +36,31 @@ async function startLiveCapture({
32
36
  var _a, _b;
33
37
  if (((_a = ev.data) == null ? void 0 : _a.type) === "ready") {
34
38
  worker.removeEventListener("message", onMsg);
39
+ worker.removeEventListener("error", onErr);
35
40
  resolve();
36
41
  } else if (((_b = ev.data) == null ? void 0 : _b.type) === "error") {
37
42
  worker.removeEventListener("message", onMsg);
43
+ worker.removeEventListener("error", onErr);
38
44
  reject(new Error(ev.data.message || "Worker error"));
39
45
  }
40
46
  };
47
+ const onErr = (ev) => {
48
+ worker.removeEventListener("message", onMsg);
49
+ worker.removeEventListener("error", onErr);
50
+ reject(ev.error || new Error("Worker failed to initialize"));
51
+ };
41
52
  worker.addEventListener("message", onMsg);
42
- worker.addEventListener("error", reject);
53
+ worker.addEventListener("error", onErr);
43
54
  });
44
55
  const stats = {
45
56
  captured: 0,
46
57
  encoded: 0,
47
58
  written: 0,
48
59
  dropped: 0,
60
+ failed: 0,
49
61
  queueMax: 0,
50
62
  inFlightMax: 0,
63
+ pendingCaptureMax: 0,
51
64
  lastBitmapMs: 0,
52
65
  bitmapMsAvg: 0,
53
66
  lastEncodeMs: 0,
@@ -55,65 +68,171 @@ async function startLiveCapture({
55
68
  lastWriteMs: 0,
56
69
  writeMsAvg: 0
57
70
  };
71
+ const maxCaptureTasks = maxPendingCaptures == null ? Math.max(1, Math.min(4, concurrency)) : maxPendingCaptures;
72
+ const frameIntervalMs = 1e3 / fps;
73
+ const maxCatchUpFrames = 4;
74
+ const maxAccumMs = frameIntervalMs * maxCatchUpFrames;
58
75
  let stopped = false;
59
76
  let rafId = null;
77
+ let fatalError = null;
60
78
  let inFlight = 0;
79
+ let pendingCaptures = 0;
80
+ let accMs = 0;
81
+ let lastTickMs = null;
82
+ let seq = 0;
61
83
  const queue = [];
62
84
  const waiters = [];
63
85
  const ack = /* @__PURE__ */ new Map();
86
+ const captureTasks = /* @__PURE__ */ new Set();
87
+ let doneSettled = false;
88
+ let resolveDone = null;
89
+ const done = new Promise((resolve) => {
90
+ resolveDone = resolve;
91
+ });
92
+ function isAbortError(err) {
93
+ return (err == null ? void 0 : err.name) === "AbortError" || (err == null ? void 0 : err.message) === "stopped" || (err == null ? void 0 : err.message) === "Aborted";
94
+ }
95
+ function emitProgress() {
96
+ onProgress == null ? void 0 : onProgress({ ...stats });
97
+ }
64
98
  function throwIfAborted() {
99
+ if (fatalError) throw fatalError;
65
100
  if (signal == null ? void 0 : signal.aborted) {
66
101
  throw signal.reason ?? new DOMException("Aborted", "AbortError");
67
102
  }
68
103
  }
69
- function wakeOneWaiter() {
70
- const w = waiters.shift();
71
- if (w) w();
104
+ function wakeAllWaiters() {
105
+ while (waiters.length) {
106
+ const w = waiters.shift();
107
+ if (w) w();
108
+ }
109
+ }
110
+ function releaseBitmap(bitmap) {
111
+ if (typeof (bitmap == null ? void 0 : bitmap.close) === "function") {
112
+ try {
113
+ bitmap.close();
114
+ } catch {
115
+ }
116
+ }
117
+ }
118
+ function settleDone(value) {
119
+ if (doneSettled) return;
120
+ doneSettled = true;
121
+ resolveDone == null ? void 0 : resolveDone(value);
122
+ }
123
+ function dispatchFrame(item) {
124
+ inFlight++;
125
+ stats.inFlightMax = Math.max(stats.inFlightMax, inFlight);
126
+ const { seq: seq2, bitmap, w, h } = item;
127
+ const p = new Promise((resolve, reject) => ack.set(seq2, { resolve, reject }));
128
+ worker.postMessage(
129
+ { type: "encode", frameIndex: seq2, bitmap, width: w, height: h },
130
+ [bitmap]
131
+ );
132
+ p.catch(() => {
133
+ });
134
+ }
135
+ function dispatchIfPossible() {
136
+ while (!stopped && inFlight < concurrency && queue.length > 0) {
137
+ const item = queue.shift();
138
+ dispatchFrame(item);
139
+ wakeAllWaiters();
140
+ }
72
141
  }
73
- async function enqueueFrame(item) {
142
+ async function pushCapturedFrame(item) {
143
+ if (stopped) {
144
+ releaseBitmap(item.bitmap);
145
+ return false;
146
+ }
147
+ if (inFlight < concurrency) {
148
+ dispatchFrame(item);
149
+ return true;
150
+ }
74
151
  if (policy === "drop") {
75
152
  if (queue.length >= maxQueue) {
76
153
  stats.dropped++;
77
- if (typeof item.bitmap.close === "function") item.bitmap.close();
78
- onProgress == null ? void 0 : onProgress({ ...stats });
154
+ releaseBitmap(item.bitmap);
79
155
  return false;
80
156
  }
81
157
  queue.push(item);
82
158
  stats.queueMax = Math.max(stats.queueMax, queue.length);
83
159
  return true;
84
160
  }
85
- while (queue.length >= maxQueue) {
161
+ if (maxQueue === 0) {
162
+ while (!stopped && inFlight >= concurrency) {
163
+ await new Promise((r) => waiters.push(r));
164
+ throwIfAborted();
165
+ }
166
+ if (stopped) {
167
+ releaseBitmap(item.bitmap);
168
+ return false;
169
+ }
170
+ dispatchFrame(item);
171
+ return true;
172
+ }
173
+ while (!stopped && queue.length >= maxQueue) {
86
174
  await new Promise((r) => waiters.push(r));
87
175
  throwIfAborted();
88
- if (stopped) return false;
176
+ }
177
+ if (stopped) {
178
+ releaseBitmap(item.bitmap);
179
+ return false;
89
180
  }
90
181
  queue.push(item);
91
182
  stats.queueMax = Math.max(stats.queueMax, queue.length);
92
183
  return true;
93
184
  }
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
- });
185
+ function canScheduleCapture() {
186
+ if (stopped) return false;
187
+ if (pendingCaptures >= maxCaptureTasks) return false;
188
+ if (policy === "drop") {
189
+ const buffered = queue.length + inFlight + pendingCaptures;
190
+ const capacity = maxQueue + concurrency;
191
+ return buffered < capacity;
110
192
  }
193
+ if (maxQueue === 0) return inFlight < concurrency;
194
+ return queue.length < maxQueue || inFlight < concurrency;
195
+ }
196
+ function scheduleCaptureFrame() {
197
+ pendingCaptures++;
198
+ stats.pendingCaptureMax = Math.max(stats.pendingCaptureMax, pendingCaptures);
199
+ let task = null;
200
+ task = (async () => {
201
+ throwIfAborted();
202
+ const w = canvas.width;
203
+ const h = canvas.height;
204
+ const b0 = performance.now();
205
+ const bitmap = await createImageBitmap(canvas);
206
+ const b1 = performance.now();
207
+ const bitmapMs = b1 - b0;
208
+ stats.lastBitmapMs = bitmapMs;
209
+ stats.bitmapMsAvg = stats.bitmapMsAvg ? stats.bitmapMsAvg * 0.9 + bitmapMs * 0.1 : bitmapMs;
210
+ stats.captured++;
211
+ await pushCapturedFrame({
212
+ seq: seq++,
213
+ bitmap,
214
+ w,
215
+ h
216
+ });
217
+ })().catch((err) => {
218
+ if (!stopped && !isAbortError(err)) {
219
+ stats.failed++;
220
+ console.error("capture failed:", err);
221
+ }
222
+ }).finally(() => {
223
+ pendingCaptures--;
224
+ captureTasks.delete(task);
225
+ dispatchIfPossible();
226
+ wakeAllWaiters();
227
+ emitProgress();
228
+ });
229
+ captureTasks.add(task);
111
230
  }
112
231
  worker.addEventListener("message", async (ev) => {
113
232
  const msg = ev.data;
114
233
  if (!msg || typeof msg !== "object") return;
115
234
  if (msg.type === "frame") {
116
- const { frameIndex: seq2, blob, encodeMs } = msg;
235
+ const { frameIndex: frameSeq, blob, encodeMs } = msg;
117
236
  stats.encoded++;
118
237
  if (typeof encodeMs === "number") {
119
238
  stats.lastEncodeMs = encodeMs;
@@ -121,31 +240,45 @@ async function startLiveCapture({
121
240
  }
122
241
  try {
123
242
  const w0 = performance.now();
124
- await exporter.write(seq2, blob);
243
+ await exporter.write(frameSeq, blob);
125
244
  const w1 = performance.now();
126
245
  stats.written++;
127
246
  const writeMs = w1 - w0;
128
247
  stats.lastWriteMs = writeMs;
129
248
  stats.writeMsAvg = stats.writeMsAvg ? stats.writeMsAvg * 0.9 + writeMs * 0.1 : writeMs;
130
- const entry = ack.get(seq2);
249
+ const entry = ack.get(frameSeq);
131
250
  if (entry) entry.resolve();
132
251
  } catch (err) {
133
- const entry = ack.get(seq2);
252
+ stats.failed++;
253
+ const entry = ack.get(frameSeq);
134
254
  if (entry) entry.reject(err);
135
255
  } finally {
136
- ack.delete(seq2);
137
- inFlight--;
256
+ ack.delete(frameSeq);
257
+ inFlight = Math.max(0, inFlight - 1);
138
258
  dispatchIfPossible();
139
- onProgress == null ? void 0 : onProgress({ ...stats });
259
+ wakeAllWaiters();
260
+ emitProgress();
140
261
  }
141
- } else if (msg.type === "error") {
142
- console.error("Worker error:", msg.message);
262
+ return;
263
+ }
264
+ if (msg.type === "error") {
265
+ const frameSeq = Number(msg.frameIndex);
266
+ const err = new Error(msg.message || "Worker error");
267
+ stats.failed++;
268
+ if (Number.isInteger(frameSeq)) {
269
+ const entry = ack.get(frameSeq);
270
+ if (entry) entry.reject(err);
271
+ ack.delete(frameSeq);
272
+ inFlight = Math.max(0, inFlight - 1);
273
+ dispatchIfPossible();
274
+ wakeAllWaiters();
275
+ } else {
276
+ fatalError = err;
277
+ }
278
+ console.error("Worker error:", err.message);
279
+ emitProgress();
143
280
  }
144
281
  });
145
- const frameIntervalMs = 1e3 / fps;
146
- let accMs = 0;
147
- let lastTickMs = null;
148
- let seq = 0;
149
282
  function tick(ts) {
150
283
  if (stopped) return;
151
284
  try {
@@ -159,47 +292,64 @@ async function startLiveCapture({
159
292
  const dt = ts - lastTickMs;
160
293
  lastTickMs = ts;
161
294
  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;
295
+ if (accMs > maxAccumMs) {
296
+ const dropFrames = Math.floor((accMs - maxAccumMs) / frameIntervalMs);
297
+ if (dropFrames > 0) {
298
+ stats.dropped += dropFrames;
299
+ accMs -= dropFrames * frameIntervalMs;
300
+ }
301
+ if (accMs > maxAccumMs) accMs = maxAccumMs;
302
+ }
303
+ let loopGuard = 0;
304
+ while (accMs >= frameIntervalMs && loopGuard < maxCatchUpFrames) {
305
+ if (!canScheduleCapture()) {
306
+ if (policy === "drop") {
307
+ stats.dropped++;
308
+ accMs -= frameIntervalMs;
309
+ loopGuard++;
310
+ continue;
173
311
  }
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));
312
+ break;
313
+ }
314
+ accMs -= frameIntervalMs;
315
+ scheduleCaptureFrame();
316
+ loopGuard++;
184
317
  }
318
+ emitProgress();
185
319
  rafId = requestAnimationFrame(tick);
186
320
  }
187
321
  rafId = requestAnimationFrame(tick);
188
322
  async function stop() {
189
- if (stopped) return;
323
+ if (stopped) return done;
190
324
  stopped = true;
191
325
  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));
326
+ let stopError = null;
327
+ try {
328
+ wakeAllWaiters();
329
+ const tasks = Array.from(captureTasks);
330
+ if (tasks.length > 0) {
331
+ await Promise.allSettled(tasks);
332
+ }
333
+ for (const item of queue.splice(0, queue.length)) {
334
+ releaseBitmap(item.bitmap);
335
+ }
336
+ while (inFlight > 0 || ack.size > 0) {
337
+ await new Promise((r) => setTimeout(r, 25));
338
+ }
339
+ await exporter.finalize();
340
+ } catch (err) {
341
+ stopError = err;
342
+ throw err;
343
+ } finally {
344
+ try {
345
+ worker.terminate();
346
+ } catch {
347
+ }
348
+ settleDone({ error: stopError ?? fatalError ?? null });
198
349
  }
199
- await exporter.finalize();
200
- worker.terminate();
350
+ return done;
201
351
  }
202
- return { stop, stats };
352
+ return { stop, stats, done };
203
353
  }
204
354
 
205
355
  // src/virtual_time.js
@@ -140,6 +140,20 @@
140
140
  word-break: break-word;
141
141
  }
142
142
 
143
+ .stats {
144
+ margin-top: 12px;
145
+ border: 1px solid rgba(255, 255, 255, 0.12);
146
+ border-radius: 12px;
147
+ padding: 10px;
148
+ background: rgba(0, 0, 0, 0.22);
149
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
150
+ "Liberation Mono", "Courier New", monospace;
151
+ font-size: 12.5px;
152
+ line-height: 1.4;
153
+ white-space: pre-wrap;
154
+ word-break: break-word;
155
+ }
156
+
143
157
  /* Inputs */
144
158
  button,
145
159
  input,
@@ -418,7 +432,26 @@
418
432
  <label>MaxQueue</label>
419
433
  <input id="maxQueue" type="number" min="0" step="1" value="8" />
420
434
  <label>Duration</label>
421
- <input id="duration" type="number" min="1" step="1" value="10" />
435
+ <input id="duration" type="number" min="0" step="1" value="10" />
436
+ </div>
437
+
438
+ <div class="row2">
439
+ <label>PendingCap</label>
440
+ <input
441
+ id="maxPendingCaptures"
442
+ type="number"
443
+ min="1"
444
+ step="1"
445
+ value="2"
446
+ />
447
+ <label>Stats(ms)</label>
448
+ <input
449
+ id="statsIntervalMs"
450
+ type="number"
451
+ min="50"
452
+ step="10"
453
+ value="180"
454
+ />
422
455
  </div>
423
456
  </details>
424
457
 
@@ -442,6 +475,8 @@
442
475
  <button id="btnStop" class="btnDanger" disabled>Stop</button>
443
476
  </div>
444
477
 
478
+ <div id="stats" class="stats">status: idle</div>
479
+
445
480
  <div class="hint">
446
481
  Hotkeys (only when not recording):
447
482
  <br />
@@ -455,6 +490,8 @@
455
490
  <br />
456
491
  - Space: start
457
492
  <br />
493
+ Duration=0 means manual stop.
494
+ <br />
458
495
  During recording, keyboard is not captured by the UI. Use Focus
459
496
  Sketch if needed, then click the canvas to ensure focus.
460
497
  </div>
@@ -14,6 +14,8 @@ const policyEl = $("policy");
14
14
  const concurrencyEl = $("concurrency");
15
15
  const maxQueueEl = $("maxQueue");
16
16
  const durationEl = $("duration");
17
+ const maxPendingCapturesEl = $("maxPendingCaptures");
18
+ const statsIntervalMsEl = $("statsIntervalMs");
17
19
 
18
20
  const framesEl = $("frames");
19
21
  const warmupEl = $("warmup");
@@ -33,6 +35,7 @@ const btnPassthrough = $("btnPassthrough");
33
35
  const btnFocus = $("btnFocus");
34
36
 
35
37
  const logEl = $("log");
38
+ const statsEl = $("stats");
36
39
  const preview = $("preview");
37
40
 
38
41
  let running = false;
@@ -42,11 +45,37 @@ const docks = ["dock-br", "dock-bl", "dock-tr", "dock-tl"];
42
45
  let dockIndex = 0;
43
46
 
44
47
  let passthrough = false;
48
+ let lastStatusLog = "";
49
+
50
+ const LOG_MAX_LINES = 220;
51
+ const logLines = [];
52
+ const runtimeState = {
53
+ status: "idle",
54
+ message: "",
55
+ progressDone: 0,
56
+ progressTotal: 0,
57
+ stats: null,
58
+ };
59
+
60
+ function toPositiveInt(v, fallback) {
61
+ const n = Number(v);
62
+ if (!Number.isInteger(n) || n <= 0) return fallback;
63
+ return n;
64
+ }
65
+
66
+ function toNonNegativeNumber(v, fallback) {
67
+ const n = Number(v);
68
+ if (!Number.isFinite(n) || n < 0) return fallback;
69
+ return n;
70
+ }
45
71
 
46
72
  function appendLog(line) {
47
- const s = String(line);
48
- const needsNL = logEl.textContent && !logEl.textContent.endsWith("\n");
49
- logEl.textContent += (needsNL ? "\n" : "") + s;
73
+ const s = String(line ?? "");
74
+ logLines.push(s);
75
+ if (logLines.length > LOG_MAX_LINES) {
76
+ logLines.splice(0, logLines.length - LOG_MAX_LINES);
77
+ }
78
+ logEl.textContent = logLines.join("\n");
50
79
  logEl.scrollTop = logEl.scrollHeight;
51
80
  }
52
81
 
@@ -65,6 +94,52 @@ function setRunning(v) {
65
94
  }
66
95
  }
67
96
 
97
+ function nfmt(v, d = 2) {
98
+ if (!Number.isFinite(v)) return "-";
99
+ return Number(v).toFixed(d);
100
+ }
101
+
102
+ function renderStats() {
103
+ const s = runtimeState.stats || null;
104
+ const lines = [];
105
+
106
+ lines.push(
107
+ `status: ${runtimeState.status}${
108
+ runtimeState.message ? ` (${runtimeState.message})` : ""
109
+ }`
110
+ );
111
+
112
+ if (runtimeState.progressTotal > 0) {
113
+ lines.push(`progress: ${runtimeState.progressDone}/${runtimeState.progressTotal}`);
114
+ }
115
+
116
+ if (!s) {
117
+ lines.push("captured: - encoded: - written: -");
118
+ lines.push("dropped: - failed: -");
119
+ lines.push("bitmap ms: -/- encode ms: -/- write ms: -/-");
120
+ lines.push("queueMax: - inFlightMax: - pendingMax: -");
121
+ } else {
122
+ lines.push(
123
+ `captured: ${s.captured ?? 0} encoded: ${s.encoded ?? 0} written: ${
124
+ s.written ?? 0
125
+ }`
126
+ );
127
+ lines.push(`dropped: ${s.dropped ?? 0} failed: ${s.failed ?? 0}`);
128
+ lines.push(
129
+ `bitmap ms: ${nfmt(s.lastBitmapMs)}/${nfmt(s.bitmapMsAvg)} ` +
130
+ `encode ms: ${nfmt(s.lastEncodeMs)}/${nfmt(s.encodeMsAvg)} ` +
131
+ `write ms: ${nfmt(s.lastWriteMs)}/${nfmt(s.writeMsAvg)}`
132
+ );
133
+ lines.push(
134
+ `queueMax: ${s.queueMax ?? 0} inFlightMax: ${
135
+ s.inFlightMax ?? 0
136
+ } pendingMax: ${s.pendingCaptureMax ?? 0}`
137
+ );
138
+ }
139
+
140
+ statsEl.textContent = lines.join("\n");
141
+ }
142
+
68
143
  function buildPreviewUrl(entry) {
69
144
  const e = entry || "/src/main.js";
70
145
  return `/__pa_encoder__/ui/preview.html?entry=${encodeURIComponent(e)}`;
@@ -161,9 +236,13 @@ function syncModeUI() {
161
236
  }
162
237
 
163
238
  function currentPayload() {
164
- const fps = Number(fpsEl.value || "60");
239
+ const fps = Math.max(1, Math.floor(Number(fpsEl.value || "60")));
165
240
  const kind = modeEl.value === "frame" ? "frame" : "live";
166
241
  const canvasSelector = selectedCanvasSelector();
242
+ const statsIntervalMs = Math.max(
243
+ 50,
244
+ toPositiveInt(statsIntervalMsEl.value, 180)
245
+ );
167
246
 
168
247
  if (kind === "frame") {
169
248
  const frames = Math.max(1, Math.floor(Number(framesEl.value || "300")));
@@ -174,11 +253,17 @@ function currentPayload() {
174
253
  frames,
175
254
  warmup,
176
255
  canvasSelector,
256
+ statsIntervalMs,
177
257
  ...exporterPayload(),
178
258
  };
179
259
  }
180
260
 
181
- const duration = Math.max(1, Number(durationEl.value || "10"));
261
+ const duration = toNonNegativeNumber(durationEl.value, 10);
262
+ const maxPendingCaptures = Math.max(
263
+ 1,
264
+ toPositiveInt(maxPendingCapturesEl.value, 2)
265
+ );
266
+
182
267
  return {
183
268
  kind: "live",
184
269
  fps,
@@ -187,6 +272,8 @@ function currentPayload() {
187
272
  policy: policyEl.value,
188
273
  concurrency: Math.max(1, Math.floor(Number(concurrencyEl.value || "2"))),
189
274
  maxQueue: Math.max(0, Math.floor(Number(maxQueueEl.value || "8"))),
275
+ maxPendingCaptures,
276
+ statsIntervalMs,
190
277
  ...exporterPayload(),
191
278
  };
192
279
  }
@@ -201,6 +288,12 @@ function start() {
201
288
  appendLog(
202
289
  `start: ${payload.kind} fps=${payload.fps} canvas=${payload.canvasSelector}`
203
290
  );
291
+ runtimeState.status = "running";
292
+ runtimeState.message = "starting";
293
+ runtimeState.progressDone = 0;
294
+ runtimeState.progressTotal = 0;
295
+ runtimeState.stats = null;
296
+ renderStats();
204
297
 
205
298
  // start recording -> prefer passthrough + focus canvas
206
299
  setPassthrough(true, { silent: true });
@@ -219,7 +312,12 @@ function start() {
219
312
  }
220
313
 
221
314
  clearTimeout(stopTimer);
222
- stopTimer = setTimeout(() => stop(), payload.duration * 1000);
315
+ if (payload.duration > 0) {
316
+ stopTimer = setTimeout(() => stop(), payload.duration * 1000);
317
+ } else {
318
+ stopTimer = null;
319
+ appendLog("live duration=0 (manual stop)");
320
+ }
223
321
  }
224
322
 
225
323
  function stop() {
@@ -317,7 +415,14 @@ window.addEventListener("message", (ev) => {
317
415
  if (msg.type === "pa_status") {
318
416
  const status = msg.status || "";
319
417
  const message = msg.message || "";
320
- appendLog(message ? `${status}: ${message}` : String(status));
418
+ const line = message ? `${status}: ${message}` : String(status);
419
+ if (line !== lastStatusLog) {
420
+ appendLog(line);
421
+ lastStatusLog = line;
422
+ }
423
+ runtimeState.status = status || "idle";
424
+ runtimeState.message = message || "";
425
+ renderStats();
321
426
 
322
427
  if (status === "idle") {
323
428
  setRunning(false);
@@ -332,21 +437,25 @@ window.addEventListener("message", (ev) => {
332
437
  }
333
438
 
334
439
  if (msg.type === "pa_progress") {
335
- const done = Number(msg.done ?? 0);
336
- const total = Number(msg.total ?? 0);
337
- appendLog(`progress: ${done}/${total}`);
440
+ runtimeState.progressDone = Number(msg.done ?? 0);
441
+ runtimeState.progressTotal = Number(msg.total ?? 0);
442
+ renderStats();
338
443
  return;
339
444
  }
340
445
 
341
446
  if (msg.type === "pa_stats") {
342
447
  const safe = { ...msg };
343
448
  delete safe.type;
344
- appendLog(`stats: ${JSON.stringify(safe)}`);
449
+ runtimeState.stats = safe;
450
+ renderStats();
345
451
  return;
346
452
  }
347
453
 
348
454
  if (msg.type === "pa_error") {
349
455
  appendLog(`ERROR: ${msg.message || "unknown error"}`);
456
+ runtimeState.status = "error";
457
+ runtimeState.message = msg.message || "unknown error";
458
+ renderStats();
350
459
  setRunning(false);
351
460
  }
352
461
  });
@@ -361,6 +470,7 @@ window.addEventListener("message", (ev) => {
361
470
 
362
471
  setRunning(false);
363
472
  syncModeUI();
473
+ renderStats();
364
474
 
365
475
  fillCanvasDropdown([]);
366
476
  appendLog("loading preview...");