qapture2 0.7.9 → 0.8.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.
@@ -51,10 +51,10 @@ function openDB(dbName) {
51
51
  dbCache.set(dbName, promise);
52
52
  return promise;
53
53
  }
54
- function run(dbName, store, mode2, fn) {
54
+ function run(dbName, store, mode, fn) {
55
55
  return openDB(dbName).then(
56
56
  (db) => new Promise((resolve, reject) => {
57
- const tx = db.transaction(store, mode2);
57
+ const tx = db.transaction(store, mode);
58
58
  const s = tx.objectStore(store);
59
59
  let result;
60
60
  const req = fn(s);
@@ -509,6 +509,7 @@ ul, ol { list-style: none; margin: 0; padding: 0; }
509
509
  /* Z-index scale */
510
510
  --qa-z-fab: 9990;
511
511
  --qa-z-panel: 9995;
512
+ --qa-z-capture-still: 10089;
512
513
  --qa-z-capture-dim: 10090;
513
514
  --qa-z-capture-highlight: 10092;
514
515
  --qa-z-capture-region-move: 10093;
@@ -558,6 +559,8 @@ ul, ol { list-style: none; margin: 0; padding: 0; }
558
559
  .qa-z-1 { z-index: 1; }
559
560
  .qa-z-50 { z-index: 50; }
560
561
  .qa-z-100 { z-index: 100; }
562
+ /* the frozen still, directly beneath the dim so the scrim tints it too */
563
+ .qa-z-10089 { z-index: var(--qa-z-capture-still); }
561
564
  .qa-z-10090 { z-index: var(--qa-z-capture-dim); }
562
565
  .qa-z-10092 { z-index: var(--qa-z-capture-highlight); }
563
566
  /* region-handle layering */
@@ -1776,7 +1779,7 @@ var STR = {
1776
1779
  no_evidence: "ticked, no capture",
1777
1780
  expected_label: "Expected",
1778
1781
  exact_label: "Pixel-exact shots",
1779
- exact_hint: "Screenshots become a real photo instead of a redraw. Asks once to share this tab (or, in Safari and Firefox, this window); nothing leaves your device.",
1782
+ exact_hint: "Screenshots become a real photo instead of a redraw. Each capture asks to share this tab (or, in Safari and Firefox, this window), takes one frame, and hands the screen straight back \u2014 nothing keeps recording, and nothing leaves your device.",
1780
1783
  redraw_notice: "This is a redraw of the page, not a real photo.",
1781
1784
  redraw_action: "Use real screenshots",
1782
1785
  redraw_no_engine: "This browser cannot photograph the screen at all, so charts and canvases may come out wrong. Open the app on a desktop browser for true screenshots.",
@@ -1785,7 +1788,7 @@ var STR = {
1785
1788
  exact_off: "Back to redrawn screenshots",
1786
1789
  exact_declined: "Staying on redrawn screenshots",
1787
1790
  exact_turn_on: "Turn on",
1788
- exact_unsupported: "Needs Chrome, Edge or Brave on desktop",
1791
+ exact_unsupported: "Needs a desktop browser \u2014 phones cannot photograph the screen",
1789
1792
  sync_title: "Save to a folder",
1790
1793
  sync_hint: "Every note is written to your disk the moment you save it \u2014 nothing is lost if this browser dies.",
1791
1794
  sync_choose: "Choose folder",
@@ -1984,7 +1987,7 @@ var STR = {
1984
1987
  no_evidence: "\u0645\u064F\u0639\u0644\u0651\u0645 \u0628\u062F\u0648\u0646 \u0627\u0644\u062A\u0642\u0627\u0637",
1985
1988
  expected_label: "\u0627\u0644\u0645\u062A\u0648\u0642\u0639",
1986
1989
  exact_label: "\u0644\u0642\u0637\u0627\u062A \u0645\u0637\u0627\u0628\u0642\u0629 \u062A\u0645\u0627\u0645\u064B\u0627",
1987
- exact_hint: "\u062A\u0635\u0628\u062D \u0627\u0644\u0644\u0642\u0637\u0629 \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629 \u0628\u062F\u0644 \u0625\u0639\u0627\u062F\u0629 \u0627\u0644\u0631\u0633\u0645. \u064A\u064F\u0637\u0644\u0628 \u0627\u0644\u0625\u0630\u0646 \u0645\u0631\u0629 \u0648\u0627\u062D\u062F\u0629 \u0644\u0645\u0634\u0627\u0631\u0643\u0629 \u0627\u0644\u062A\u0628\u0648\u064A\u0628 (\u0623\u0648 \u0627\u0644\u0646\u0627\u0641\u0630\u0629 \u0641\u064A Safari \u0648Firefox)\u060C \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
1990
+ exact_hint: "\u062A\u0635\u0628\u062D \u0627\u0644\u0644\u0642\u0637\u0629 \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629 \u0628\u062F\u0644 \u0625\u0639\u0627\u062F\u0629 \u0627\u0644\u0631\u0633\u0645. \u062A\u0637\u0644\u0628 \u0643\u0644 \u0644\u0642\u0637\u0629 \u0625\u0630\u0646 \u0645\u0634\u0627\u0631\u0643\u0629 \u0627\u0644\u062A\u0628\u0648\u064A\u0628 (\u0623\u0648 \u0627\u0644\u0646\u0627\u0641\u0630\u0629 \u0641\u064A Safari \u0648Firefox)\u060C \u062A\u0623\u062E\u0630 \u0625\u0637\u0627\u0631\u064B\u0627 \u0648\u0627\u062D\u062F\u064B\u0627\u060C \u062B\u0645 \u062A\u0639\u064A\u062F \u0627\u0644\u0634\u0627\u0634\u0629 \u0641\u0648\u0631\u064B\u0627 \u2014 \u0644\u0627 \u062A\u0633\u062C\u064A\u0644 \u0645\u0633\u062A\u0645\u0631\u060C \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
1988
1991
  redraw_notice: "\u0647\u0630\u0647 \u0625\u0639\u0627\u062F\u0629 \u0631\u0633\u0645 \u0644\u0644\u0635\u0641\u062D\u0629\u060C \u0648\u0644\u064A\u0633\u062A \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629.",
1989
1992
  redraw_action: "\u0627\u0633\u062A\u062E\u062F\u0645 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629",
1990
1993
  redraw_no_engine: "\u0644\u0627 \u064A\u0633\u062A\u0637\u064A\u0639 \u0647\u0630\u0627 \u0627\u0644\u0645\u062A\u0635\u0641\u062D \u062A\u0635\u0648\u064A\u0631 \u0627\u0644\u0634\u0627\u0634\u0629 \u0625\u0637\u0644\u0627\u0642\u064B\u0627\u060C \u0644\u0630\u0627 \u0642\u062F \u062A\u0638\u0647\u0631 \u0627\u0644\u0645\u062E\u0637\u0637\u0627\u062A \u0648\u0627\u0644\u0644\u0648\u062D\u0627\u062A \u0628\u0634\u0643\u0644 \u062E\u0627\u0637\u0626. \u0627\u0641\u062A\u062D \u0627\u0644\u062A\u0637\u0628\u064A\u0642 \u0639\u0644\u0649 \u0645\u062A\u0635\u0641\u062D \u0633\u0637\u062D \u0645\u0643\u062A\u0628 \u0644\u0644\u062D\u0635\u0648\u0644 \u0639\u0644\u0649 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629.",
@@ -1993,7 +1996,7 @@ var STR = {
1993
1996
  exact_off: "\u0627\u0644\u0639\u0648\u062F\u0629 \u0625\u0644\u0649 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627",
1994
1997
  exact_declined: "\u0633\u0646\u0628\u0642\u0649 \u0639\u0644\u0649 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u064F\u0639\u0627\u062F \u0631\u0633\u0645\u0647\u0627",
1995
1998
  exact_turn_on: "\u062A\u0641\u0639\u064A\u0644",
1996
- exact_unsupported: "\u064A\u062A\u0637\u0644\u0628 Chrome \u0623\u0648 Edge \u0623\u0648 Brave \u0639\u0644\u0649 \u0633\u0637\u062D \u0627\u0644\u0645\u0643\u062A\u0628",
1999
+ exact_unsupported: "\u064A\u062A\u0637\u0644\u0628 \u0645\u062A\u0635\u0641\u062D \u0633\u0637\u062D \u0645\u0643\u062A\u0628 \u2014 \u0627\u0644\u0647\u0648\u0627\u062A\u0641 \u0644\u0627 \u062A\u0633\u062A\u0637\u064A\u0639 \u062A\u0635\u0648\u064A\u0631 \u0627\u0644\u0634\u0627\u0634\u0629",
1997
2000
  sync_title: "\u0627\u0644\u062D\u0641\u0638 \u0641\u064A \u0645\u062C\u0644\u062F",
1998
2001
  sync_hint: "\u062A\u064F\u0643\u062A\u0628 \u0643\u0644 \u0645\u0644\u0627\u062D\u0638\u0629 \u0639\u0644\u0649 \u0627\u0644\u0642\u0631\u0635 \u0644\u062D\u0638\u0629 \u062D\u0641\u0638\u0647\u0627 \u2014 \u0644\u0627 \u0634\u064A\u0621 \u064A\u0636\u064A\u0639 \u0625\u0630\u0627 \u062A\u0639\u0637\u0651\u0644 \u0627\u0644\u0645\u062A\u0635\u0641\u062D.",
1999
2002
  sync_choose: "\u0627\u062E\u062A\u0631 \u0627\u0644\u0645\u062C\u0644\u062F",
@@ -2298,7 +2301,7 @@ function solveFrom(tl, br, candidates, vw, vh, frameW, frameH) {
2298
2301
  if (Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY) > SQUARENESS_TOLERANCE) return null;
2299
2302
  const originX = tl.x - centres.tl.x * scaleX;
2300
2303
  const originY = tl.y - centres.tl.y * scaleY;
2301
- const mapping2 = { scaleX, scaleY, originX, originY };
2304
+ const mapping = { scaleX, scaleY, originX, originY };
2302
2305
  const farX = originX + vw * scaleX;
2303
2306
  const farY = originY + vh * scaleY;
2304
2307
  const slack = Math.max(4, frameW * 0.01);
@@ -2317,7 +2320,7 @@ function solveFrom(tl, br, candidates, vw, vh, frameW, frameH) {
2317
2320
  verified++;
2318
2321
  }
2319
2322
  if (verified === 0) return null;
2320
- return mapping2;
2323
+ return mapping;
2321
2324
  }
2322
2325
  function mapRectToFrame(rect, m, frameW, frameH) {
2323
2326
  const x0 = m.originX + rect.left * m.scaleX;
@@ -2329,73 +2332,49 @@ function mapRectToFrame(rect, m, frameW, frameH) {
2329
2332
  if (sw < 1 || sh < 1) return null;
2330
2333
  return { sx, sy, sw, sh };
2331
2334
  }
2332
- function environmentSignature() {
2333
- if (typeof window === "undefined") return "";
2334
- return [
2335
- window.innerWidth,
2336
- window.innerHeight,
2337
- window.outerWidth,
2338
- window.outerHeight,
2339
- window.screenX,
2340
- window.screenY,
2341
- Math.round((window.devicePixelRatio || 1) * 100)
2342
- ].join("x");
2343
- }
2344
2335
 
2345
2336
  // src/lib/screenCapture.ts
2346
2337
  var ASPECT_TOLERANCE = 0.08;
2347
2338
  var CALIBRATION_SETTLE_MS = 220;
2348
2339
  var FRESH_FRAME_TIMEOUT_MS = 500;
2349
2340
  var VIDEO_READY_TIMEOUT_MS = 4e3;
2350
- var stream = null;
2351
- var track = null;
2352
- var video = null;
2353
- var grabber = null;
2354
- var declined = false;
2355
2341
  function isExactCaptureSupported() {
2356
2342
  if (typeof navigator === "undefined" || typeof document === "undefined") return false;
2357
2343
  const md = navigator.mediaDevices;
2358
2344
  return !!md && typeof md.getDisplayMedia === "function";
2359
2345
  }
2360
- var mode = "tab";
2361
- var mapping = null;
2362
- var mappingSignature = "";
2346
+ var armed = false;
2347
+ var declined = false;
2348
+ var frozen = null;
2349
+ var lastMode = null;
2350
+ function armExactCapture() {
2351
+ if (!isExactCaptureSupported()) return false;
2352
+ armed = true;
2353
+ declined = false;
2354
+ return true;
2355
+ }
2356
+ function disarmExactCapture() {
2357
+ armed = false;
2358
+ releaseFrozenFrame();
2359
+ }
2360
+ function resetExactCaptureDecline() {
2361
+ declined = false;
2362
+ }
2363
2363
  function getExactCaptureStatus() {
2364
2364
  if (!isExactCaptureSupported()) return "unsupported";
2365
- if (track && track.readyState === "live") return "live";
2365
+ if (armed) return "live";
2366
2366
  if (declined) return "declined";
2367
2367
  return "idle";
2368
2368
  }
2369
- function teardown() {
2370
- if (video) {
2371
- try {
2372
- video.pause();
2373
- } catch {
2374
- }
2375
- video.srcObject = null;
2376
- video.remove();
2377
- }
2378
- if (stream) {
2379
- for (const t of stream.getTracks()) {
2380
- try {
2381
- t.stop();
2382
- } catch {
2383
- }
2384
- }
2385
- }
2386
- video = null;
2387
- stream = null;
2388
- track = null;
2389
- grabber = null;
2390
- mode = "tab";
2391
- mapping = null;
2392
- mappingSignature = "";
2393
- }
2394
- function stopExactCapture() {
2395
- teardown();
2369
+ function getFrozenFrame() {
2370
+ return frozen;
2396
2371
  }
2397
- function resetExactCaptureDecline() {
2398
- declined = false;
2372
+ function releaseFrozenFrame() {
2373
+ if (frozen) {
2374
+ frozen.canvas.width = 0;
2375
+ frozen.canvas.height = 0;
2376
+ }
2377
+ frozen = null;
2399
2378
  }
2400
2379
  function waitFor(test, timeoutMs) {
2401
2380
  if (test()) return Promise.resolve(true);
@@ -2435,17 +2414,28 @@ function nextPresentedFrame(el) {
2435
2414
  });
2436
2415
  });
2437
2416
  }
2438
- function validateFrameMapping(frameW, frameH) {
2417
+ function looksLikeViewport(frameW, frameH) {
2439
2418
  if (!frameW || !frameH) return false;
2440
2419
  const vw = window.innerWidth;
2441
2420
  const vh = window.innerHeight;
2442
2421
  if (!vw || !vh) return false;
2443
- const frameAspect = frameW / frameH;
2444
- const viewAspect = vw / vh;
2445
- return Math.abs(frameAspect - viewAspect) / viewAspect <= ASPECT_TOLERANCE;
2422
+ return Math.abs(frameW / frameH - vw / vh) / (vw / vh) <= ASPECT_TOLERANCE;
2423
+ }
2424
+ async function withOverlayHidden(fn) {
2425
+ const hosts = Array.from(
2426
+ document.querySelectorAll("body > [data-qa-overlay]")
2427
+ );
2428
+ const previous = hosts.map((el) => el.style.visibility);
2429
+ for (const el of hosts) el.style.visibility = "hidden";
2430
+ try {
2431
+ return await fn();
2432
+ } finally {
2433
+ hosts.forEach((el, i) => {
2434
+ el.style.visibility = previous[i];
2435
+ });
2436
+ }
2446
2437
  }
2447
- async function grabFrameCanvas() {
2448
- if (!video) return null;
2438
+ async function grabFrameCanvas(video, grabber) {
2449
2439
  await nextPresentedFrame(video);
2450
2440
  let source = video;
2451
2441
  let w = video.videoWidth;
@@ -2476,11 +2466,10 @@ async function grabFrameCanvas() {
2476
2466
  bitmap?.close?.();
2477
2467
  return c;
2478
2468
  }
2479
- async function calibrate() {
2480
- if (!video) return false;
2469
+ async function calibrate(video, grabber) {
2481
2470
  const vw = window.innerWidth;
2482
2471
  const vh = window.innerHeight;
2483
- if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return false;
2472
+ if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return null;
2484
2473
  const card = document.createElement("div");
2485
2474
  card.setAttribute("data-qa-calibration", "true");
2486
2475
  card.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:#0a0a0a;pointer-events:none;contain:strict;";
@@ -2494,10 +2483,10 @@ async function calibrate() {
2494
2483
  document.body.appendChild(card);
2495
2484
  try {
2496
2485
  await new Promise((r) => setTimeout(r, CALIBRATION_SETTLE_MS));
2497
- const frame = await grabFrameCanvas();
2498
- if (!frame) return false;
2486
+ const frame = await grabFrameCanvas(video, grabber);
2487
+ if (!frame) return null;
2499
2488
  const ctx = frame.getContext("2d", { willReadFrequently: true });
2500
- if (!ctx) return false;
2489
+ if (!ctx) return null;
2501
2490
  const { data } = ctx.getImageData(0, 0, frame.width, frame.height);
2502
2491
  const found = deriveMapping(
2503
2492
  findMarkerCandidates(data, frame.width, frame.height),
@@ -2506,20 +2495,40 @@ async function calibrate() {
2506
2495
  frame.width,
2507
2496
  frame.height
2508
2497
  );
2509
- if (!found) return false;
2510
- mapping = found;
2511
- mappingSignature = environmentSignature();
2512
- return true;
2498
+ frame.width = 0;
2499
+ frame.height = 0;
2500
+ return found;
2513
2501
  } catch {
2514
- return false;
2502
+ return null;
2515
2503
  } finally {
2516
2504
  card.remove();
2517
2505
  }
2518
2506
  }
2519
- async function startExactCapture() {
2520
- if (!isExactCaptureSupported()) return false;
2521
- if (track && track.readyState === "live" && video) return true;
2522
- if (stream) teardown();
2507
+ async function freezeViewport() {
2508
+ if (!isExactCaptureSupported()) return null;
2509
+ releaseFrozenFrame();
2510
+ let stream = null;
2511
+ let el = null;
2512
+ const release = () => {
2513
+ if (el) {
2514
+ try {
2515
+ el.pause();
2516
+ } catch {
2517
+ }
2518
+ el.srcObject = null;
2519
+ el.remove();
2520
+ el = null;
2521
+ }
2522
+ if (stream) {
2523
+ for (const t of stream.getTracks()) {
2524
+ try {
2525
+ t.stop();
2526
+ } catch {
2527
+ }
2528
+ }
2529
+ stream = null;
2530
+ }
2531
+ };
2523
2532
  try {
2524
2533
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
2525
2534
  const constraints = {
@@ -2537,121 +2546,103 @@ async function startExactCapture() {
2537
2546
  surfaceSwitching: "exclude",
2538
2547
  systemAudio: "exclude"
2539
2548
  };
2540
- const s = await navigator.mediaDevices.getDisplayMedia(constraints);
2541
- const t = s.getVideoTracks()[0];
2542
- if (!t) {
2543
- for (const x of s.getTracks()) x.stop();
2549
+ stream = await navigator.mediaDevices.getDisplayMedia(constraints);
2550
+ const track = stream.getVideoTracks()[0];
2551
+ if (!track) {
2544
2552
  declined = true;
2545
- return false;
2553
+ return null;
2546
2554
  }
2547
- const settings = t.getSettings();
2555
+ const settings = track.getSettings();
2548
2556
  const sharedTab = !settings.displaySurface || settings.displaySurface === "browser";
2549
- const el = document.createElement("video");
2557
+ el = document.createElement("video");
2550
2558
  el.setAttribute("data-qa-overlay", "true");
2551
2559
  el.muted = true;
2552
2560
  el.playsInline = true;
2553
2561
  el.style.cssText = "position:fixed;top:0;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;";
2554
- el.srcObject = s;
2562
+ el.srcObject = stream;
2555
2563
  document.body.appendChild(el);
2556
2564
  try {
2557
2565
  await el.play();
2558
2566
  } catch {
2559
2567
  }
2560
- const ready = await waitFor(() => el.videoWidth > 0 && el.videoHeight > 0, VIDEO_READY_TIMEOUT_MS);
2561
- const giveUp = () => {
2562
- el.remove();
2563
- for (const x of s.getTracks()) x.stop();
2568
+ const video = el;
2569
+ const ready = await waitFor(
2570
+ () => video.videoWidth > 0 && video.videoHeight > 0,
2571
+ VIDEO_READY_TIMEOUT_MS
2572
+ );
2573
+ if (!ready) {
2564
2574
  declined = true;
2565
- return false;
2566
- };
2567
- if (!ready) return giveUp();
2568
- const isDirect = sharedTab && validateFrameMapping(el.videoWidth, el.videoHeight);
2569
- stream = s;
2570
- track = t;
2571
- video = el;
2575
+ return null;
2576
+ }
2572
2577
  const IC = window.ImageCapture;
2573
- grabber = IC ? new IC(t) : null;
2574
- if (isDirect) {
2575
- mode = "tab";
2576
- mapping = null;
2577
- mappingSignature = "";
2578
- } else {
2579
- mode = "surface";
2580
- if (!await calibrate()) {
2581
- teardown();
2582
- declined = true;
2583
- return false;
2584
- }
2578
+ const grabber = IC ? new IC(track) : null;
2579
+ const vw = window.innerWidth;
2580
+ const vh = window.innerHeight;
2581
+ const mode = sharedTab && looksLikeViewport(video.videoWidth, video.videoHeight) ? "tab" : "surface";
2582
+ const mapping = mode === "surface" ? await calibrate(video, grabber) : null;
2583
+ if (mode === "surface" && !mapping) {
2584
+ declined = true;
2585
+ return null;
2585
2586
  }
2586
- t.addEventListener("ended", () => {
2587
- teardown();
2588
- });
2589
- return true;
2587
+ const raw = await withOverlayHidden(() => grabFrameCanvas(video, grabber));
2588
+ if (!raw) return null;
2589
+ let page;
2590
+ if (mode === "surface" && mapping) {
2591
+ const box = mapRectToFrame(
2592
+ { left: 0, top: 0, width: vw, height: vh },
2593
+ mapping,
2594
+ raw.width,
2595
+ raw.height
2596
+ );
2597
+ if (!box) return null;
2598
+ page = document.createElement("canvas");
2599
+ page.width = box.sw;
2600
+ page.height = box.sh;
2601
+ const ctx = page.getContext("2d");
2602
+ if (!ctx) return null;
2603
+ ctx.drawImage(raw, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
2604
+ raw.width = 0;
2605
+ raw.height = 0;
2606
+ } else {
2607
+ page = raw;
2608
+ }
2609
+ frozen = {
2610
+ canvas: page,
2611
+ mode,
2612
+ viewportWidth: vw,
2613
+ viewportHeight: vh,
2614
+ takenAt: Date.now()
2615
+ };
2616
+ lastMode = mode;
2617
+ return frozen;
2590
2618
  } catch {
2591
2619
  declined = true;
2592
- teardown();
2593
- return false;
2620
+ return null;
2621
+ } finally {
2622
+ release();
2594
2623
  }
2595
2624
  }
2596
- async function grabExactRegion(rect) {
2597
- if (!track || track.readyState !== "live" || !video) return null;
2598
- if (mode === "surface") {
2599
- if (!mapping || environmentSignature() !== mappingSignature) {
2600
- if (!await calibrate()) return null;
2601
- }
2602
- }
2603
- await nextPresentedFrame(video);
2604
- let source;
2605
- let frameW;
2606
- let frameH;
2607
- let bitmap = null;
2608
- if (grabber) {
2609
- try {
2610
- bitmap = await grabber.grabFrame();
2611
- source = bitmap;
2612
- frameW = bitmap.width;
2613
- frameH = bitmap.height;
2614
- } catch {
2615
- source = video;
2616
- frameW = video.videoWidth;
2617
- frameH = video.videoHeight;
2618
- }
2619
- } else {
2620
- source = video;
2621
- frameW = video.videoWidth;
2622
- frameH = video.videoHeight;
2623
- }
2624
- try {
2625
- let sx;
2626
- let sy;
2627
- let sw;
2628
- let sh;
2629
- if (mode === "surface") {
2630
- if (!mapping) return null;
2631
- const mapped = mapRectToFrame(rect, mapping, frameW, frameH);
2632
- if (!mapped) return null;
2633
- ({ sx, sy, sw, sh } = mapped);
2634
- } else {
2635
- if (!validateFrameMapping(frameW, frameH)) return null;
2636
- const scaleX = frameW / window.innerWidth;
2637
- const scaleY = frameH / window.innerHeight;
2638
- sx = Math.max(0, Math.min(frameW, Math.round(rect.left * scaleX)));
2639
- sy = Math.max(0, Math.min(frameH, Math.round(rect.top * scaleY)));
2640
- sw = Math.max(1, Math.min(frameW - sx, Math.round(rect.width * scaleX)));
2641
- sh = Math.max(1, Math.min(frameH - sy, Math.round(rect.height * scaleY)));
2642
- }
2643
- const canvas = document.createElement("canvas");
2644
- canvas.width = sw;
2645
- canvas.height = sh;
2646
- const ctx = canvas.getContext("2d");
2647
- if (!ctx) return null;
2648
- ctx.drawImage(source, sx, sy, sw, sh, 0, 0, sw, sh);
2649
- return canvas;
2650
- } catch {
2625
+ function cropFrozenRegion(rect) {
2626
+ const f = frozen;
2627
+ if (!f || !f.canvas.width || !f.canvas.height) return null;
2628
+ if (!f.viewportWidth || !f.viewportHeight) return null;
2629
+ if (window.innerWidth !== f.viewportWidth || window.innerHeight !== f.viewportHeight) {
2651
2630
  return null;
2652
- } finally {
2653
- if (bitmap && typeof bitmap.close === "function") bitmap.close();
2654
2631
  }
2632
+ const scaleX = f.canvas.width / f.viewportWidth;
2633
+ const scaleY = f.canvas.height / f.viewportHeight;
2634
+ const sx = Math.max(0, Math.min(f.canvas.width, Math.round(rect.left * scaleX)));
2635
+ const sy = Math.max(0, Math.min(f.canvas.height, Math.round(rect.top * scaleY)));
2636
+ const sw = Math.min(f.canvas.width - sx, Math.round(rect.width * scaleX));
2637
+ const sh = Math.min(f.canvas.height - sy, Math.round(rect.height * scaleY));
2638
+ if (sw < 1 || sh < 1) return null;
2639
+ const out = document.createElement("canvas");
2640
+ out.width = sw;
2641
+ out.height = sh;
2642
+ const ctx = out.getContext("2d");
2643
+ if (!ctx) return null;
2644
+ ctx.drawImage(f.canvas, sx, sy, sw, sh, 0, 0, sw, sh);
2645
+ return out;
2655
2646
  }
2656
2647
 
2657
2648
  // src/lib/cssColors.ts
@@ -2890,20 +2881,6 @@ function shotExtension(blob) {
2890
2881
  if (type === "image/jpeg") return "jpg";
2891
2882
  return "png";
2892
2883
  }
2893
- async function withOverlayHidden(fn) {
2894
- const hosts = Array.from(
2895
- document.querySelectorAll("body > [data-qa-overlay]")
2896
- );
2897
- const previous = hosts.map((el) => el.style.visibility);
2898
- for (const el of hosts) el.style.visibility = "hidden";
2899
- try {
2900
- return await fn();
2901
- } finally {
2902
- hosts.forEach((el, i) => {
2903
- el.style.visibility = previous[i];
2904
- });
2905
- }
2906
- }
2907
2884
  function markStuckElements() {
2908
2885
  let all;
2909
2886
  try {
@@ -3118,17 +3095,17 @@ async function captureRegion(rect, scroll, prefer = "auto") {
3118
3095
  if (!rect || rect.width < 2 || rect.height < 2) return { status: "empty" };
3119
3096
  const sx = scroll?.x ?? window.scrollX;
3120
3097
  const sy = scroll?.y ?? window.scrollY;
3121
- if (prefer !== "dom" && getExactCaptureStatus() === "live") {
3098
+ if (prefer !== "dom" && getFrozenFrame()) {
3122
3099
  const visible = intersectViewport(rect);
3123
3100
  if (!visible) return { status: "empty" };
3124
3101
  try {
3125
- const canvas = await withOverlayHidden(() => grabExactRegion(visible));
3102
+ const canvas = cropFrozenRegion(visible);
3126
3103
  if (canvas) {
3127
3104
  const blob = await encodeShot(canvas);
3128
3105
  if (blob) return { status: "ok", blob, engine: "exact" };
3129
3106
  }
3130
3107
  } catch (err) {
3131
- console.warn("[QA] exact capture failed, falling back to DOM render:", err);
3108
+ console.warn("[QA] cropping the still failed, falling back to DOM render:", err);
3132
3109
  }
3133
3110
  if (prefer === "exact") return { status: "failed" };
3134
3111
  }
@@ -4218,11 +4195,12 @@ var PANEL_OPEN_KEY = "panelOpen";
4218
4195
  var ACTIVE_TAB_KEY = "activeTab";
4219
4196
  var WALK_KEY = "walk";
4220
4197
  var SOFT_NAV_PROOF_MS = 700;
4198
+ var SIGNATURE_SEP = String.fromCharCode(0);
4221
4199
  function pageSignature() {
4222
4200
  if (typeof document === "undefined" || !document.body) return "";
4223
4201
  const body = document.body;
4224
4202
  const text = body.textContent || "";
4225
- return [document.title, body.childElementCount, text.length, text.slice(0, 200)].join("\0");
4203
+ return [document.title, body.childElementCount, text.length, text.slice(0, 200)].join(SIGNATURE_SEP);
4226
4204
  }
4227
4205
  var GUIDE_SKIPPED_KEY = "guideSkipped";
4228
4206
  var NOTICE_QUEUE_CAP = 3;
@@ -4280,8 +4258,12 @@ function QaProvider({
4280
4258
  const [loginsUsed, setLoginsUsed] = useState(
4281
4259
  () => new Set(storage.getJSON(LOGIN_KEY, []))
4282
4260
  );
4283
- const [exactStatus, setExactStatus] = useState(() => getExactCaptureStatus());
4261
+ const [exactStatus, setExactStatus] = useState(() => {
4262
+ if (storage.getItem(EXACT_SHOTS_KEY) === "1") armExactCapture();
4263
+ return getExactCaptureStatus();
4264
+ });
4284
4265
  const exactSupported = isExactCaptureSupported();
4266
+ const [frozenAt, setFrozenAt] = useState(null);
4285
4267
  const [syncState, setSyncState] = useState(() => getFsSyncState());
4286
4268
  const [syncTick, setSyncTick] = useState(0);
4287
4269
  const [lastCampaign, setLastCampaign] = useState(
@@ -4782,20 +4764,26 @@ function QaProvider({
4782
4764
  flushPendingDeletes();
4783
4765
  for (const timer of noticeTimers.current.values()) clearTimeout(timer);
4784
4766
  noticeTimers.current.clear();
4785
- stopExactCapture();
4767
+ releaseFrozenFrame();
4786
4768
  };
4787
4769
  }, [flushPendingDeletes]);
4788
4770
  const startCapture = useCallback((prefill) => {
4789
4771
  setIsOpen(false);
4790
4772
  setCapturePrefill(prefill ?? "");
4791
4773
  setCaptureActive(true);
4792
- if (storage.getItem(EXACT_SHOTS_KEY) === "1" && isExactCaptureSupported() && getExactCaptureStatus() !== "live") {
4774
+ setFrozenAt(null);
4775
+ if (storage.getItem(EXACT_SHOTS_KEY) === "1" && isExactCaptureSupported()) {
4793
4776
  resetExactCaptureDecline();
4794
- void startExactCapture().then(() => setExactStatus(getExactCaptureStatus()));
4777
+ void freezeViewport().then((frame) => {
4778
+ setFrozenAt(frame?.takenAt ?? null);
4779
+ setExactStatus(getExactCaptureStatus());
4780
+ });
4795
4781
  }
4796
4782
  }, [storage]);
4797
4783
  const endCapture = useCallback((reopen = true) => {
4798
4784
  setCaptureActive(false);
4785
+ releaseFrozenFrame();
4786
+ setFrozenAt(null);
4799
4787
  if (reopen) setIsOpen(true);
4800
4788
  }, []);
4801
4789
  const toggleGuide = useCallback((key) => {
@@ -4836,16 +4824,32 @@ function QaProvider({
4836
4824
  exportZipRef.current = exportZipFn;
4837
4825
  }, [exportZipFn]);
4838
4826
  const enableExactShots = useCallback(async () => {
4839
- resetExactCaptureDecline();
4840
- const ok = await startExactCapture();
4827
+ const ok = armExactCapture();
4841
4828
  setExactStatus(getExactCaptureStatus());
4842
4829
  storage.setItem(EXACT_SHOTS_KEY, ok ? "1" : "0");
4843
4830
  notify(ok ? t("exact_on") : t("exact_declined"), { tone: ok ? "success" : "info", id: "exact-shots" });
4844
4831
  return ok;
4845
4832
  }, [storage, notify, t]);
4833
+ const photographNow = useCallback(async () => {
4834
+ if (!armExactCapture()) {
4835
+ setExactStatus(getExactCaptureStatus());
4836
+ notify(t("exact_declined"), { tone: "info", id: "exact-shots" });
4837
+ return false;
4838
+ }
4839
+ storage.setItem(EXACT_SHOTS_KEY, "1");
4840
+ const frame = await freezeViewport();
4841
+ setFrozenAt(frame?.takenAt ?? null);
4842
+ setExactStatus(getExactCaptureStatus());
4843
+ notify(frame ? t("exact_on") : t("exact_declined"), {
4844
+ tone: frame ? "success" : "info",
4845
+ id: "exact-shots"
4846
+ });
4847
+ return !!frame;
4848
+ }, [storage, notify, t]);
4846
4849
  const disableExactShots = useCallback(() => {
4847
- stopExactCapture();
4850
+ disarmExactCapture();
4848
4851
  storage.setItem(EXACT_SHOTS_KEY, "0");
4852
+ setFrozenAt(null);
4849
4853
  setExactStatus(getExactCaptureStatus());
4850
4854
  }, [storage]);
4851
4855
  const rememberCampaign = useCallback((next) => {
@@ -5364,8 +5368,9 @@ function QaProvider({
5364
5368
  gradeStep,
5365
5369
  evidenceByStep,
5366
5370
  // v0.4 — screenshots
5367
- exactShots: { supported: exactSupported, status: exactStatus },
5371
+ exactShots: { supported: exactSupported, status: exactStatus, frozenAt },
5368
5372
  enableExactShots,
5373
+ photographNow,
5369
5374
  disableExactShots,
5370
5375
  // v0.4 — folder sync. syncTick is read here purely so this object is
5371
5376
  // rebuilt when fsSync's module state changes (path/campaign live outside
@@ -9198,7 +9203,7 @@ function CaptureMode() {
9198
9203
  compactCapture,
9199
9204
  setCompactCapture,
9200
9205
  exactShots,
9201
- enableExactShots,
9206
+ photographNow,
9202
9207
  capturePrefill
9203
9208
  } = useQa();
9204
9209
  const coarse = useCoarsePointer();
@@ -9212,6 +9217,7 @@ function CaptureMode() {
9212
9217
  const [candidate, setCandidate] = useState(null);
9213
9218
  const [regionMode, setRegionMode] = useState(false);
9214
9219
  const [shot, setShot] = useState(null);
9220
+ const stillRef = useRef(null);
9215
9221
  const [shotEngine, setShotEngine] = useState(null);
9216
9222
  const [shotUrl, setShotUrl] = useState(null);
9217
9223
  const [capturing, setCapturing] = useState(false);
@@ -9504,6 +9510,18 @@ function CaptureMode() {
9504
9510
  useEffect(() => () => {
9505
9511
  unlockPageScroll();
9506
9512
  }, []);
9513
+ useEffect(() => {
9514
+ const host = stillRef.current;
9515
+ const frame = getFrozenFrame();
9516
+ if (!host || !frame) return;
9517
+ const canvas = frame.canvas;
9518
+ canvas.style.cssText = "display:block;width:100%;height:100%;";
9519
+ host.appendChild(canvas);
9520
+ return () => {
9521
+ if (canvas.parentNode === host) host.removeChild(canvas);
9522
+ canvas.removeAttribute("style");
9523
+ };
9524
+ }, [exactShots.frozenAt]);
9507
9525
  const save = async () => {
9508
9526
  if (!selection || !description.trim()) return;
9509
9527
  const target = {
@@ -9562,6 +9580,16 @@ function CaptureMode() {
9562
9580
  const isRegion = !!drag?.rect || candidate?.kind === "region" || selection?.kind === "region";
9563
9581
  const confirmingRegion = phase === "confirming" && candidate?.kind === "region" && coarse;
9564
9582
  return /* @__PURE__ */ jsxs("div", { "data-qa-overlay": "true", "data-qa-capture-root": "true", ref: overlayRootRef, children: [
9583
+ exactShots.frozenAt !== null && /* @__PURE__ */ jsx(
9584
+ "div",
9585
+ {
9586
+ ref: stillRef,
9587
+ "aria-hidden": "true",
9588
+ "data-qa-capture-still": "true",
9589
+ className: "qa-fixed qa-inset-0 qa-z-10089",
9590
+ style: { pointerEvents: "none", overflow: "hidden" }
9591
+ }
9592
+ ),
9565
9593
  /* @__PURE__ */ jsx(
9566
9594
  "div",
9567
9595
  {
@@ -9620,7 +9648,7 @@ function CaptureMode() {
9620
9648
  ) : /* @__PURE__ */ jsxs(
9621
9649
  "button",
9622
9650
  {
9623
- onClick: () => void enableExactShots(),
9651
+ onClick: () => void photographNow(),
9624
9652
  title: t("exact_hint"),
9625
9653
  className: "qa-tap qa-flex qa-items-center qa-gap-1 qa-rounded-full qa-border qa-border-white-40 qa-px-2 qa-py-0.5 qa-text-11 qa-text-hi qa-hover-bg-white-15",
9626
9654
  style: { background: "transparent", cursor: "pointer" },
@@ -10064,8 +10092,8 @@ function CaptureMode() {
10064
10092
  {
10065
10093
  type: "button",
10066
10094
  onClick: () => void (async () => {
10067
- const granted = await enableExactShots();
10068
- if (granted && selection) void runCapture(selection.rect);
10095
+ const shot2 = await photographNow();
10096
+ if (shot2 && selection) void runCapture(selection.rect);
10069
10097
  })(),
10070
10098
  title: t("exact_hint"),
10071
10099
  className: "qa-tap qa-inline-flex qa-items-center qa-gap-1 qa-rounded-full qa-border qa-border-subtle qa-px-1.5 qa-py-0.5 qa-text-10 qa-text-hi qa-focus-ring",
@@ -10389,5 +10417,5 @@ function Qapture({ config }) {
10389
10417
  }
10390
10418
 
10391
10419
  export { Qapture, deleteQaDatabase, initQaStudio };
10392
- //# sourceMappingURL=chunk-G6O4SIEH.js.map
10393
- //# sourceMappingURL=chunk-G6O4SIEH.js.map
10420
+ //# sourceMappingURL=chunk-RJV6FTIB.js.map
10421
+ //# sourceMappingURL=chunk-RJV6FTIB.js.map