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.
@@ -58,10 +58,10 @@ function openDB(dbName) {
58
58
  dbCache.set(dbName, promise);
59
59
  return promise;
60
60
  }
61
- function run(dbName, store, mode2, fn) {
61
+ function run(dbName, store, mode, fn) {
62
62
  return openDB(dbName).then(
63
63
  (db) => new Promise((resolve, reject) => {
64
- const tx = db.transaction(store, mode2);
64
+ const tx = db.transaction(store, mode);
65
65
  const s = tx.objectStore(store);
66
66
  let result;
67
67
  const req = fn(s);
@@ -516,6 +516,7 @@ ul, ol { list-style: none; margin: 0; padding: 0; }
516
516
  /* Z-index scale */
517
517
  --qa-z-fab: 9990;
518
518
  --qa-z-panel: 9995;
519
+ --qa-z-capture-still: 10089;
519
520
  --qa-z-capture-dim: 10090;
520
521
  --qa-z-capture-highlight: 10092;
521
522
  --qa-z-capture-region-move: 10093;
@@ -565,6 +566,8 @@ ul, ol { list-style: none; margin: 0; padding: 0; }
565
566
  .qa-z-1 { z-index: 1; }
566
567
  .qa-z-50 { z-index: 50; }
567
568
  .qa-z-100 { z-index: 100; }
569
+ /* the frozen still, directly beneath the dim so the scrim tints it too */
570
+ .qa-z-10089 { z-index: var(--qa-z-capture-still); }
568
571
  .qa-z-10090 { z-index: var(--qa-z-capture-dim); }
569
572
  .qa-z-10092 { z-index: var(--qa-z-capture-highlight); }
570
573
  /* region-handle layering */
@@ -1783,7 +1786,7 @@ var STR = {
1783
1786
  no_evidence: "ticked, no capture",
1784
1787
  expected_label: "Expected",
1785
1788
  exact_label: "Pixel-exact shots",
1786
- 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.",
1789
+ 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.",
1787
1790
  redraw_notice: "This is a redraw of the page, not a real photo.",
1788
1791
  redraw_action: "Use real screenshots",
1789
1792
  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.",
@@ -1792,7 +1795,7 @@ var STR = {
1792
1795
  exact_off: "Back to redrawn screenshots",
1793
1796
  exact_declined: "Staying on redrawn screenshots",
1794
1797
  exact_turn_on: "Turn on",
1795
- exact_unsupported: "Needs Chrome, Edge or Brave on desktop",
1798
+ exact_unsupported: "Needs a desktop browser \u2014 phones cannot photograph the screen",
1796
1799
  sync_title: "Save to a folder",
1797
1800
  sync_hint: "Every note is written to your disk the moment you save it \u2014 nothing is lost if this browser dies.",
1798
1801
  sync_choose: "Choose folder",
@@ -1991,7 +1994,7 @@ var STR = {
1991
1994
  no_evidence: "\u0645\u064F\u0639\u0644\u0651\u0645 \u0628\u062F\u0648\u0646 \u0627\u0644\u062A\u0642\u0627\u0637",
1992
1995
  expected_label: "\u0627\u0644\u0645\u062A\u0648\u0642\u0639",
1993
1996
  exact_label: "\u0644\u0642\u0637\u0627\u062A \u0645\u0637\u0627\u0628\u0642\u0629 \u062A\u0645\u0627\u0645\u064B\u0627",
1994
- 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.",
1997
+ 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.",
1995
1998
  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.",
1996
1999
  redraw_action: "\u0627\u0633\u062A\u062E\u062F\u0645 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629",
1997
2000
  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.",
@@ -2000,7 +2003,7 @@ var STR = {
2000
2003
  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",
2001
2004
  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",
2002
2005
  exact_turn_on: "\u062A\u0641\u0639\u064A\u0644",
2003
- 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",
2006
+ 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",
2004
2007
  sync_title: "\u0627\u0644\u062D\u0641\u0638 \u0641\u064A \u0645\u062C\u0644\u062F",
2005
2008
  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.",
2006
2009
  sync_choose: "\u0627\u062E\u062A\u0631 \u0627\u0644\u0645\u062C\u0644\u062F",
@@ -2305,7 +2308,7 @@ function solveFrom(tl, br, candidates, vw, vh, frameW, frameH) {
2305
2308
  if (Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY) > SQUARENESS_TOLERANCE) return null;
2306
2309
  const originX = tl.x - centres.tl.x * scaleX;
2307
2310
  const originY = tl.y - centres.tl.y * scaleY;
2308
- const mapping2 = { scaleX, scaleY, originX, originY };
2311
+ const mapping = { scaleX, scaleY, originX, originY };
2309
2312
  const farX = originX + vw * scaleX;
2310
2313
  const farY = originY + vh * scaleY;
2311
2314
  const slack = Math.max(4, frameW * 0.01);
@@ -2324,7 +2327,7 @@ function solveFrom(tl, br, candidates, vw, vh, frameW, frameH) {
2324
2327
  verified++;
2325
2328
  }
2326
2329
  if (verified === 0) return null;
2327
- return mapping2;
2330
+ return mapping;
2328
2331
  }
2329
2332
  function mapRectToFrame(rect, m, frameW, frameH) {
2330
2333
  const x0 = m.originX + rect.left * m.scaleX;
@@ -2336,73 +2339,49 @@ function mapRectToFrame(rect, m, frameW, frameH) {
2336
2339
  if (sw < 1 || sh < 1) return null;
2337
2340
  return { sx, sy, sw, sh };
2338
2341
  }
2339
- function environmentSignature() {
2340
- if (typeof window === "undefined") return "";
2341
- return [
2342
- window.innerWidth,
2343
- window.innerHeight,
2344
- window.outerWidth,
2345
- window.outerHeight,
2346
- window.screenX,
2347
- window.screenY,
2348
- Math.round((window.devicePixelRatio || 1) * 100)
2349
- ].join("x");
2350
- }
2351
2342
 
2352
2343
  // src/lib/screenCapture.ts
2353
2344
  var ASPECT_TOLERANCE = 0.08;
2354
2345
  var CALIBRATION_SETTLE_MS = 220;
2355
2346
  var FRESH_FRAME_TIMEOUT_MS = 500;
2356
2347
  var VIDEO_READY_TIMEOUT_MS = 4e3;
2357
- var stream = null;
2358
- var track = null;
2359
- var video = null;
2360
- var grabber = null;
2361
- var declined = false;
2362
2348
  function isExactCaptureSupported() {
2363
2349
  if (typeof navigator === "undefined" || typeof document === "undefined") return false;
2364
2350
  const md = navigator.mediaDevices;
2365
2351
  return !!md && typeof md.getDisplayMedia === "function";
2366
2352
  }
2367
- var mode = "tab";
2368
- var mapping = null;
2369
- var mappingSignature = "";
2353
+ var armed = false;
2354
+ var declined = false;
2355
+ var frozen = null;
2356
+ var lastMode = null;
2357
+ function armExactCapture() {
2358
+ if (!isExactCaptureSupported()) return false;
2359
+ armed = true;
2360
+ declined = false;
2361
+ return true;
2362
+ }
2363
+ function disarmExactCapture() {
2364
+ armed = false;
2365
+ releaseFrozenFrame();
2366
+ }
2367
+ function resetExactCaptureDecline() {
2368
+ declined = false;
2369
+ }
2370
2370
  function getExactCaptureStatus() {
2371
2371
  if (!isExactCaptureSupported()) return "unsupported";
2372
- if (track && track.readyState === "live") return "live";
2372
+ if (armed) return "live";
2373
2373
  if (declined) return "declined";
2374
2374
  return "idle";
2375
2375
  }
2376
- function teardown() {
2377
- if (video) {
2378
- try {
2379
- video.pause();
2380
- } catch {
2381
- }
2382
- video.srcObject = null;
2383
- video.remove();
2384
- }
2385
- if (stream) {
2386
- for (const t of stream.getTracks()) {
2387
- try {
2388
- t.stop();
2389
- } catch {
2390
- }
2391
- }
2392
- }
2393
- video = null;
2394
- stream = null;
2395
- track = null;
2396
- grabber = null;
2397
- mode = "tab";
2398
- mapping = null;
2399
- mappingSignature = "";
2400
- }
2401
- function stopExactCapture() {
2402
- teardown();
2376
+ function getFrozenFrame() {
2377
+ return frozen;
2403
2378
  }
2404
- function resetExactCaptureDecline() {
2405
- declined = false;
2379
+ function releaseFrozenFrame() {
2380
+ if (frozen) {
2381
+ frozen.canvas.width = 0;
2382
+ frozen.canvas.height = 0;
2383
+ }
2384
+ frozen = null;
2406
2385
  }
2407
2386
  function waitFor(test, timeoutMs) {
2408
2387
  if (test()) return Promise.resolve(true);
@@ -2442,17 +2421,28 @@ function nextPresentedFrame(el) {
2442
2421
  });
2443
2422
  });
2444
2423
  }
2445
- function validateFrameMapping(frameW, frameH) {
2424
+ function looksLikeViewport(frameW, frameH) {
2446
2425
  if (!frameW || !frameH) return false;
2447
2426
  const vw = window.innerWidth;
2448
2427
  const vh = window.innerHeight;
2449
2428
  if (!vw || !vh) return false;
2450
- const frameAspect = frameW / frameH;
2451
- const viewAspect = vw / vh;
2452
- return Math.abs(frameAspect - viewAspect) / viewAspect <= ASPECT_TOLERANCE;
2429
+ return Math.abs(frameW / frameH - vw / vh) / (vw / vh) <= ASPECT_TOLERANCE;
2430
+ }
2431
+ async function withOverlayHidden(fn) {
2432
+ const hosts = Array.from(
2433
+ document.querySelectorAll("body > [data-qa-overlay]")
2434
+ );
2435
+ const previous = hosts.map((el) => el.style.visibility);
2436
+ for (const el of hosts) el.style.visibility = "hidden";
2437
+ try {
2438
+ return await fn();
2439
+ } finally {
2440
+ hosts.forEach((el, i) => {
2441
+ el.style.visibility = previous[i];
2442
+ });
2443
+ }
2453
2444
  }
2454
- async function grabFrameCanvas() {
2455
- if (!video) return null;
2445
+ async function grabFrameCanvas(video, grabber) {
2456
2446
  await nextPresentedFrame(video);
2457
2447
  let source = video;
2458
2448
  let w = video.videoWidth;
@@ -2483,11 +2473,10 @@ async function grabFrameCanvas() {
2483
2473
  bitmap?.close?.();
2484
2474
  return c;
2485
2475
  }
2486
- async function calibrate() {
2487
- if (!video) return false;
2476
+ async function calibrate(video, grabber) {
2488
2477
  const vw = window.innerWidth;
2489
2478
  const vh = window.innerHeight;
2490
- if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return false;
2479
+ if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return null;
2491
2480
  const card = document.createElement("div");
2492
2481
  card.setAttribute("data-qa-calibration", "true");
2493
2482
  card.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:#0a0a0a;pointer-events:none;contain:strict;";
@@ -2501,10 +2490,10 @@ async function calibrate() {
2501
2490
  document.body.appendChild(card);
2502
2491
  try {
2503
2492
  await new Promise((r) => setTimeout(r, CALIBRATION_SETTLE_MS));
2504
- const frame = await grabFrameCanvas();
2505
- if (!frame) return false;
2493
+ const frame = await grabFrameCanvas(video, grabber);
2494
+ if (!frame) return null;
2506
2495
  const ctx = frame.getContext("2d", { willReadFrequently: true });
2507
- if (!ctx) return false;
2496
+ if (!ctx) return null;
2508
2497
  const { data } = ctx.getImageData(0, 0, frame.width, frame.height);
2509
2498
  const found = deriveMapping(
2510
2499
  findMarkerCandidates(data, frame.width, frame.height),
@@ -2513,20 +2502,40 @@ async function calibrate() {
2513
2502
  frame.width,
2514
2503
  frame.height
2515
2504
  );
2516
- if (!found) return false;
2517
- mapping = found;
2518
- mappingSignature = environmentSignature();
2519
- return true;
2505
+ frame.width = 0;
2506
+ frame.height = 0;
2507
+ return found;
2520
2508
  } catch {
2521
- return false;
2509
+ return null;
2522
2510
  } finally {
2523
2511
  card.remove();
2524
2512
  }
2525
2513
  }
2526
- async function startExactCapture() {
2527
- if (!isExactCaptureSupported()) return false;
2528
- if (track && track.readyState === "live" && video) return true;
2529
- if (stream) teardown();
2514
+ async function freezeViewport() {
2515
+ if (!isExactCaptureSupported()) return null;
2516
+ releaseFrozenFrame();
2517
+ let stream = null;
2518
+ let el = null;
2519
+ const release = () => {
2520
+ if (el) {
2521
+ try {
2522
+ el.pause();
2523
+ } catch {
2524
+ }
2525
+ el.srcObject = null;
2526
+ el.remove();
2527
+ el = null;
2528
+ }
2529
+ if (stream) {
2530
+ for (const t of stream.getTracks()) {
2531
+ try {
2532
+ t.stop();
2533
+ } catch {
2534
+ }
2535
+ }
2536
+ stream = null;
2537
+ }
2538
+ };
2530
2539
  try {
2531
2540
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
2532
2541
  const constraints = {
@@ -2544,121 +2553,103 @@ async function startExactCapture() {
2544
2553
  surfaceSwitching: "exclude",
2545
2554
  systemAudio: "exclude"
2546
2555
  };
2547
- const s = await navigator.mediaDevices.getDisplayMedia(constraints);
2548
- const t = s.getVideoTracks()[0];
2549
- if (!t) {
2550
- for (const x of s.getTracks()) x.stop();
2556
+ stream = await navigator.mediaDevices.getDisplayMedia(constraints);
2557
+ const track = stream.getVideoTracks()[0];
2558
+ if (!track) {
2551
2559
  declined = true;
2552
- return false;
2560
+ return null;
2553
2561
  }
2554
- const settings = t.getSettings();
2562
+ const settings = track.getSettings();
2555
2563
  const sharedTab = !settings.displaySurface || settings.displaySurface === "browser";
2556
- const el = document.createElement("video");
2564
+ el = document.createElement("video");
2557
2565
  el.setAttribute("data-qa-overlay", "true");
2558
2566
  el.muted = true;
2559
2567
  el.playsInline = true;
2560
2568
  el.style.cssText = "position:fixed;top:0;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;";
2561
- el.srcObject = s;
2569
+ el.srcObject = stream;
2562
2570
  document.body.appendChild(el);
2563
2571
  try {
2564
2572
  await el.play();
2565
2573
  } catch {
2566
2574
  }
2567
- const ready = await waitFor(() => el.videoWidth > 0 && el.videoHeight > 0, VIDEO_READY_TIMEOUT_MS);
2568
- const giveUp = () => {
2569
- el.remove();
2570
- for (const x of s.getTracks()) x.stop();
2575
+ const video = el;
2576
+ const ready = await waitFor(
2577
+ () => video.videoWidth > 0 && video.videoHeight > 0,
2578
+ VIDEO_READY_TIMEOUT_MS
2579
+ );
2580
+ if (!ready) {
2571
2581
  declined = true;
2572
- return false;
2573
- };
2574
- if (!ready) return giveUp();
2575
- const isDirect = sharedTab && validateFrameMapping(el.videoWidth, el.videoHeight);
2576
- stream = s;
2577
- track = t;
2578
- video = el;
2582
+ return null;
2583
+ }
2579
2584
  const IC = window.ImageCapture;
2580
- grabber = IC ? new IC(t) : null;
2581
- if (isDirect) {
2582
- mode = "tab";
2583
- mapping = null;
2584
- mappingSignature = "";
2585
- } else {
2586
- mode = "surface";
2587
- if (!await calibrate()) {
2588
- teardown();
2589
- declined = true;
2590
- return false;
2591
- }
2585
+ const grabber = IC ? new IC(track) : null;
2586
+ const vw = window.innerWidth;
2587
+ const vh = window.innerHeight;
2588
+ const mode = sharedTab && looksLikeViewport(video.videoWidth, video.videoHeight) ? "tab" : "surface";
2589
+ const mapping = mode === "surface" ? await calibrate(video, grabber) : null;
2590
+ if (mode === "surface" && !mapping) {
2591
+ declined = true;
2592
+ return null;
2592
2593
  }
2593
- t.addEventListener("ended", () => {
2594
- teardown();
2595
- });
2596
- return true;
2594
+ const raw = await withOverlayHidden(() => grabFrameCanvas(video, grabber));
2595
+ if (!raw) return null;
2596
+ let page;
2597
+ if (mode === "surface" && mapping) {
2598
+ const box = mapRectToFrame(
2599
+ { left: 0, top: 0, width: vw, height: vh },
2600
+ mapping,
2601
+ raw.width,
2602
+ raw.height
2603
+ );
2604
+ if (!box) return null;
2605
+ page = document.createElement("canvas");
2606
+ page.width = box.sw;
2607
+ page.height = box.sh;
2608
+ const ctx = page.getContext("2d");
2609
+ if (!ctx) return null;
2610
+ ctx.drawImage(raw, box.sx, box.sy, box.sw, box.sh, 0, 0, box.sw, box.sh);
2611
+ raw.width = 0;
2612
+ raw.height = 0;
2613
+ } else {
2614
+ page = raw;
2615
+ }
2616
+ frozen = {
2617
+ canvas: page,
2618
+ mode,
2619
+ viewportWidth: vw,
2620
+ viewportHeight: vh,
2621
+ takenAt: Date.now()
2622
+ };
2623
+ lastMode = mode;
2624
+ return frozen;
2597
2625
  } catch {
2598
2626
  declined = true;
2599
- teardown();
2600
- return false;
2627
+ return null;
2628
+ } finally {
2629
+ release();
2601
2630
  }
2602
2631
  }
2603
- async function grabExactRegion(rect) {
2604
- if (!track || track.readyState !== "live" || !video) return null;
2605
- if (mode === "surface") {
2606
- if (!mapping || environmentSignature() !== mappingSignature) {
2607
- if (!await calibrate()) return null;
2608
- }
2609
- }
2610
- await nextPresentedFrame(video);
2611
- let source;
2612
- let frameW;
2613
- let frameH;
2614
- let bitmap = null;
2615
- if (grabber) {
2616
- try {
2617
- bitmap = await grabber.grabFrame();
2618
- source = bitmap;
2619
- frameW = bitmap.width;
2620
- frameH = bitmap.height;
2621
- } catch {
2622
- source = video;
2623
- frameW = video.videoWidth;
2624
- frameH = video.videoHeight;
2625
- }
2626
- } else {
2627
- source = video;
2628
- frameW = video.videoWidth;
2629
- frameH = video.videoHeight;
2630
- }
2631
- try {
2632
- let sx;
2633
- let sy;
2634
- let sw;
2635
- let sh;
2636
- if (mode === "surface") {
2637
- if (!mapping) return null;
2638
- const mapped = mapRectToFrame(rect, mapping, frameW, frameH);
2639
- if (!mapped) return null;
2640
- ({ sx, sy, sw, sh } = mapped);
2641
- } else {
2642
- if (!validateFrameMapping(frameW, frameH)) return null;
2643
- const scaleX = frameW / window.innerWidth;
2644
- const scaleY = frameH / window.innerHeight;
2645
- sx = Math.max(0, Math.min(frameW, Math.round(rect.left * scaleX)));
2646
- sy = Math.max(0, Math.min(frameH, Math.round(rect.top * scaleY)));
2647
- sw = Math.max(1, Math.min(frameW - sx, Math.round(rect.width * scaleX)));
2648
- sh = Math.max(1, Math.min(frameH - sy, Math.round(rect.height * scaleY)));
2649
- }
2650
- const canvas = document.createElement("canvas");
2651
- canvas.width = sw;
2652
- canvas.height = sh;
2653
- const ctx = canvas.getContext("2d");
2654
- if (!ctx) return null;
2655
- ctx.drawImage(source, sx, sy, sw, sh, 0, 0, sw, sh);
2656
- return canvas;
2657
- } catch {
2632
+ function cropFrozenRegion(rect) {
2633
+ const f = frozen;
2634
+ if (!f || !f.canvas.width || !f.canvas.height) return null;
2635
+ if (!f.viewportWidth || !f.viewportHeight) return null;
2636
+ if (window.innerWidth !== f.viewportWidth || window.innerHeight !== f.viewportHeight) {
2658
2637
  return null;
2659
- } finally {
2660
- if (bitmap && typeof bitmap.close === "function") bitmap.close();
2661
2638
  }
2639
+ const scaleX = f.canvas.width / f.viewportWidth;
2640
+ const scaleY = f.canvas.height / f.viewportHeight;
2641
+ const sx = Math.max(0, Math.min(f.canvas.width, Math.round(rect.left * scaleX)));
2642
+ const sy = Math.max(0, Math.min(f.canvas.height, Math.round(rect.top * scaleY)));
2643
+ const sw = Math.min(f.canvas.width - sx, Math.round(rect.width * scaleX));
2644
+ const sh = Math.min(f.canvas.height - sy, Math.round(rect.height * scaleY));
2645
+ if (sw < 1 || sh < 1) return null;
2646
+ const out = document.createElement("canvas");
2647
+ out.width = sw;
2648
+ out.height = sh;
2649
+ const ctx = out.getContext("2d");
2650
+ if (!ctx) return null;
2651
+ ctx.drawImage(f.canvas, sx, sy, sw, sh, 0, 0, sw, sh);
2652
+ return out;
2662
2653
  }
2663
2654
 
2664
2655
  // src/lib/cssColors.ts
@@ -2897,20 +2888,6 @@ function shotExtension(blob) {
2897
2888
  if (type === "image/jpeg") return "jpg";
2898
2889
  return "png";
2899
2890
  }
2900
- async function withOverlayHidden(fn) {
2901
- const hosts = Array.from(
2902
- document.querySelectorAll("body > [data-qa-overlay]")
2903
- );
2904
- const previous = hosts.map((el) => el.style.visibility);
2905
- for (const el of hosts) el.style.visibility = "hidden";
2906
- try {
2907
- return await fn();
2908
- } finally {
2909
- hosts.forEach((el, i) => {
2910
- el.style.visibility = previous[i];
2911
- });
2912
- }
2913
- }
2914
2891
  function markStuckElements() {
2915
2892
  let all;
2916
2893
  try {
@@ -3125,17 +3102,17 @@ async function captureRegion(rect, scroll, prefer = "auto") {
3125
3102
  if (!rect || rect.width < 2 || rect.height < 2) return { status: "empty" };
3126
3103
  const sx = scroll?.x ?? window.scrollX;
3127
3104
  const sy = scroll?.y ?? window.scrollY;
3128
- if (prefer !== "dom" && getExactCaptureStatus() === "live") {
3105
+ if (prefer !== "dom" && getFrozenFrame()) {
3129
3106
  const visible = intersectViewport(rect);
3130
3107
  if (!visible) return { status: "empty" };
3131
3108
  try {
3132
- const canvas = await withOverlayHidden(() => grabExactRegion(visible));
3109
+ const canvas = cropFrozenRegion(visible);
3133
3110
  if (canvas) {
3134
3111
  const blob = await encodeShot(canvas);
3135
3112
  if (blob) return { status: "ok", blob, engine: "exact" };
3136
3113
  }
3137
3114
  } catch (err) {
3138
- console.warn("[QA] exact capture failed, falling back to DOM render:", err);
3115
+ console.warn("[QA] cropping the still failed, falling back to DOM render:", err);
3139
3116
  }
3140
3117
  if (prefer === "exact") return { status: "failed" };
3141
3118
  }
@@ -4225,11 +4202,12 @@ var PANEL_OPEN_KEY = "panelOpen";
4225
4202
  var ACTIVE_TAB_KEY = "activeTab";
4226
4203
  var WALK_KEY = "walk";
4227
4204
  var SOFT_NAV_PROOF_MS = 700;
4205
+ var SIGNATURE_SEP = String.fromCharCode(0);
4228
4206
  function pageSignature() {
4229
4207
  if (typeof document === "undefined" || !document.body) return "";
4230
4208
  const body = document.body;
4231
4209
  const text = body.textContent || "";
4232
- return [document.title, body.childElementCount, text.length, text.slice(0, 200)].join("\0");
4210
+ return [document.title, body.childElementCount, text.length, text.slice(0, 200)].join(SIGNATURE_SEP);
4233
4211
  }
4234
4212
  var GUIDE_SKIPPED_KEY = "guideSkipped";
4235
4213
  var NOTICE_QUEUE_CAP = 3;
@@ -4287,8 +4265,12 @@ function QaProvider({
4287
4265
  const [loginsUsed, setLoginsUsed] = React.useState(
4288
4266
  () => new Set(storage.getJSON(LOGIN_KEY, []))
4289
4267
  );
4290
- const [exactStatus, setExactStatus] = React.useState(() => getExactCaptureStatus());
4268
+ const [exactStatus, setExactStatus] = React.useState(() => {
4269
+ if (storage.getItem(EXACT_SHOTS_KEY) === "1") armExactCapture();
4270
+ return getExactCaptureStatus();
4271
+ });
4291
4272
  const exactSupported = isExactCaptureSupported();
4273
+ const [frozenAt, setFrozenAt] = React.useState(null);
4292
4274
  const [syncState, setSyncState] = React.useState(() => getFsSyncState());
4293
4275
  const [syncTick, setSyncTick] = React.useState(0);
4294
4276
  const [lastCampaign, setLastCampaign] = React.useState(
@@ -4789,20 +4771,26 @@ function QaProvider({
4789
4771
  flushPendingDeletes();
4790
4772
  for (const timer of noticeTimers.current.values()) clearTimeout(timer);
4791
4773
  noticeTimers.current.clear();
4792
- stopExactCapture();
4774
+ releaseFrozenFrame();
4793
4775
  };
4794
4776
  }, [flushPendingDeletes]);
4795
4777
  const startCapture = React.useCallback((prefill) => {
4796
4778
  setIsOpen(false);
4797
4779
  setCapturePrefill(prefill ?? "");
4798
4780
  setCaptureActive(true);
4799
- if (storage.getItem(EXACT_SHOTS_KEY) === "1" && isExactCaptureSupported() && getExactCaptureStatus() !== "live") {
4781
+ setFrozenAt(null);
4782
+ if (storage.getItem(EXACT_SHOTS_KEY) === "1" && isExactCaptureSupported()) {
4800
4783
  resetExactCaptureDecline();
4801
- void startExactCapture().then(() => setExactStatus(getExactCaptureStatus()));
4784
+ void freezeViewport().then((frame) => {
4785
+ setFrozenAt(frame?.takenAt ?? null);
4786
+ setExactStatus(getExactCaptureStatus());
4787
+ });
4802
4788
  }
4803
4789
  }, [storage]);
4804
4790
  const endCapture = React.useCallback((reopen = true) => {
4805
4791
  setCaptureActive(false);
4792
+ releaseFrozenFrame();
4793
+ setFrozenAt(null);
4806
4794
  if (reopen) setIsOpen(true);
4807
4795
  }, []);
4808
4796
  const toggleGuide = React.useCallback((key) => {
@@ -4843,16 +4831,32 @@ function QaProvider({
4843
4831
  exportZipRef.current = exportZipFn;
4844
4832
  }, [exportZipFn]);
4845
4833
  const enableExactShots = React.useCallback(async () => {
4846
- resetExactCaptureDecline();
4847
- const ok = await startExactCapture();
4834
+ const ok = armExactCapture();
4848
4835
  setExactStatus(getExactCaptureStatus());
4849
4836
  storage.setItem(EXACT_SHOTS_KEY, ok ? "1" : "0");
4850
4837
  notify(ok ? t("exact_on") : t("exact_declined"), { tone: ok ? "success" : "info", id: "exact-shots" });
4851
4838
  return ok;
4852
4839
  }, [storage, notify, t]);
4840
+ const photographNow = React.useCallback(async () => {
4841
+ if (!armExactCapture()) {
4842
+ setExactStatus(getExactCaptureStatus());
4843
+ notify(t("exact_declined"), { tone: "info", id: "exact-shots" });
4844
+ return false;
4845
+ }
4846
+ storage.setItem(EXACT_SHOTS_KEY, "1");
4847
+ const frame = await freezeViewport();
4848
+ setFrozenAt(frame?.takenAt ?? null);
4849
+ setExactStatus(getExactCaptureStatus());
4850
+ notify(frame ? t("exact_on") : t("exact_declined"), {
4851
+ tone: frame ? "success" : "info",
4852
+ id: "exact-shots"
4853
+ });
4854
+ return !!frame;
4855
+ }, [storage, notify, t]);
4853
4856
  const disableExactShots = React.useCallback(() => {
4854
- stopExactCapture();
4857
+ disarmExactCapture();
4855
4858
  storage.setItem(EXACT_SHOTS_KEY, "0");
4859
+ setFrozenAt(null);
4856
4860
  setExactStatus(getExactCaptureStatus());
4857
4861
  }, [storage]);
4858
4862
  const rememberCampaign = React.useCallback((next) => {
@@ -5371,8 +5375,9 @@ function QaProvider({
5371
5375
  gradeStep,
5372
5376
  evidenceByStep,
5373
5377
  // v0.4 — screenshots
5374
- exactShots: { supported: exactSupported, status: exactStatus },
5378
+ exactShots: { supported: exactSupported, status: exactStatus, frozenAt },
5375
5379
  enableExactShots,
5380
+ photographNow,
5376
5381
  disableExactShots,
5377
5382
  // v0.4 — folder sync. syncTick is read here purely so this object is
5378
5383
  // rebuilt when fsSync's module state changes (path/campaign live outside
@@ -9205,7 +9210,7 @@ function CaptureMode() {
9205
9210
  compactCapture,
9206
9211
  setCompactCapture,
9207
9212
  exactShots,
9208
- enableExactShots,
9213
+ photographNow,
9209
9214
  capturePrefill
9210
9215
  } = useQa();
9211
9216
  const coarse = useCoarsePointer();
@@ -9219,6 +9224,7 @@ function CaptureMode() {
9219
9224
  const [candidate, setCandidate] = React.useState(null);
9220
9225
  const [regionMode, setRegionMode] = React.useState(false);
9221
9226
  const [shot, setShot] = React.useState(null);
9227
+ const stillRef = React.useRef(null);
9222
9228
  const [shotEngine, setShotEngine] = React.useState(null);
9223
9229
  const [shotUrl, setShotUrl] = React.useState(null);
9224
9230
  const [capturing, setCapturing] = React.useState(false);
@@ -9511,6 +9517,18 @@ function CaptureMode() {
9511
9517
  React.useEffect(() => () => {
9512
9518
  unlockPageScroll();
9513
9519
  }, []);
9520
+ React.useEffect(() => {
9521
+ const host = stillRef.current;
9522
+ const frame = getFrozenFrame();
9523
+ if (!host || !frame) return;
9524
+ const canvas = frame.canvas;
9525
+ canvas.style.cssText = "display:block;width:100%;height:100%;";
9526
+ host.appendChild(canvas);
9527
+ return () => {
9528
+ if (canvas.parentNode === host) host.removeChild(canvas);
9529
+ canvas.removeAttribute("style");
9530
+ };
9531
+ }, [exactShots.frozenAt]);
9514
9532
  const save = async () => {
9515
9533
  if (!selection || !description.trim()) return;
9516
9534
  const target = {
@@ -9569,6 +9587,16 @@ function CaptureMode() {
9569
9587
  const isRegion = !!drag?.rect || candidate?.kind === "region" || selection?.kind === "region";
9570
9588
  const confirmingRegion = phase === "confirming" && candidate?.kind === "region" && coarse;
9571
9589
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-qa-overlay": "true", "data-qa-capture-root": "true", ref: overlayRootRef, children: [
9590
+ exactShots.frozenAt !== null && /* @__PURE__ */ jsxRuntime.jsx(
9591
+ "div",
9592
+ {
9593
+ ref: stillRef,
9594
+ "aria-hidden": "true",
9595
+ "data-qa-capture-still": "true",
9596
+ className: "qa-fixed qa-inset-0 qa-z-10089",
9597
+ style: { pointerEvents: "none", overflow: "hidden" }
9598
+ }
9599
+ ),
9572
9600
  /* @__PURE__ */ jsxRuntime.jsx(
9573
9601
  "div",
9574
9602
  {
@@ -9627,7 +9655,7 @@ function CaptureMode() {
9627
9655
  ) : /* @__PURE__ */ jsxRuntime.jsxs(
9628
9656
  "button",
9629
9657
  {
9630
- onClick: () => void enableExactShots(),
9658
+ onClick: () => void photographNow(),
9631
9659
  title: t("exact_hint"),
9632
9660
  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",
9633
9661
  style: { background: "transparent", cursor: "pointer" },
@@ -10071,8 +10099,8 @@ function CaptureMode() {
10071
10099
  {
10072
10100
  type: "button",
10073
10101
  onClick: () => void (async () => {
10074
- const granted = await enableExactShots();
10075
- if (granted && selection) void runCapture(selection.rect);
10102
+ const shot2 = await photographNow();
10103
+ if (shot2 && selection) void runCapture(selection.rect);
10076
10104
  })(),
10077
10105
  title: t("exact_hint"),
10078
10106
  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",
@@ -10398,5 +10426,5 @@ function Qapture({ config }) {
10398
10426
  exports.Qapture = Qapture;
10399
10427
  exports.deleteQaDatabase = deleteQaDatabase;
10400
10428
  exports.initQaStudio = initQaStudio;
10401
- //# sourceMappingURL=chunk-6UNSUNJS.cjs.map
10402
- //# sourceMappingURL=chunk-6UNSUNJS.cjs.map
10429
+ //# sourceMappingURL=chunk-E7OFH6EM.cjs.map
10430
+ //# sourceMappingURL=chunk-E7OFH6EM.cjs.map