qapture2 0.7.5 → 0.7.6

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/README.md CHANGED
@@ -353,13 +353,22 @@ v0.7.3 a redrawn capture says so under the preview and offers a one-tap **Use
353
353
  real screenshots**, which grants tab-share and re-shoots the same selection
354
354
  with the exact engine below.
355
355
 
356
- That upgrade is Chromium-only, because it depends on `preferCurrentTab`. On
357
- Safari and Firefox the redraw is the only engine there is so from v0.7.4 the
358
- card says so outright and points you at Chrome, rather than leaving you to
359
- conclude the tool is broken. **If your app draws anything with inline SVG, a
360
- `<canvas>`, or video, do your screenshot QA in Chrome.** A redraw will render
361
- an SVG chart as bare outlines and a canvas as a blank box, every time, in any
362
- tool built this way.
356
+ **From v0.7.6 this works in Safari and Firefox too.** They have no tab capture,
357
+ but they can share a *window*, and Qapture works out where your page sits
358
+ inside that frame by measuring, not by guessing at toolbar heights. On the
359
+ first capture the page is covered for a fifth of a second by a dark card with
360
+ four coloured corners; the engine photographs it, finds the corners and solves
361
+ for the mapping. That dark flash is the measurement. If it can't verify the
362
+ result it refuses the grant and falls back to redrawing, because a screenshot
363
+ of the wrong pixels is worse than no screenshot.
364
+
365
+ Chromium still takes the direct path (`preferCurrentTab`), unchanged and still
366
+ 0.0px. Only iOS/iPadOS have no Screen Capture API at all, and there the card
367
+ says so.
368
+
369
+ **If your app draws anything with inline SVG, a `<canvas>`, or video, turn real
370
+ screenshots on.** A redraw will render an SVG chart as bare outlines and a
371
+ canvas as a blank box, every time, in any tool built this way.
363
372
 
364
373
  ### `exact` — opt-in, pixel-for-pixel
365
374
 
@@ -58,10 +58,10 @@ function openDB(dbName) {
58
58
  dbCache.set(dbName, promise);
59
59
  return promise;
60
60
  }
61
- function run(dbName, store, mode, fn) {
61
+ function run(dbName, store, mode2, fn) {
62
62
  return openDB(dbName).then(
63
63
  (db) => new Promise((resolve, reject) => {
64
- const tx = db.transaction(store, mode);
64
+ const tx = db.transaction(store, mode2);
65
65
  const s = tx.objectStore(store);
66
66
  let result;
67
67
  const req = fn(s);
@@ -1781,10 +1781,10 @@ var STR = {
1781
1781
  no_evidence: "ticked, no capture",
1782
1782
  expected_label: "Expected",
1783
1783
  exact_label: "Pixel-exact shots",
1784
- exact_hint: "Screenshots become a real photo of the tab instead of a redraw. Asks once to share this tab; nothing leaves your device.",
1784
+ 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.",
1785
1785
  redraw_notice: "This is a redraw of the page, not a real photo.",
1786
1786
  redraw_action: "Use real screenshots",
1787
- redraw_no_engine: "This browser cannot photograph a tab, so charts and canvases may come out wrong. Open the app in Chrome, Edge or Brave for true screenshots.",
1787
+ 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.",
1788
1788
  fab_title: "Qapture \u2014 click to open, drag to move it out of the way",
1789
1789
  exact_on: "Pixel-exact screenshots on",
1790
1790
  exact_off: "Back to redrawn screenshots",
@@ -1979,10 +1979,10 @@ var STR = {
1979
1979
  no_evidence: "\u0645\u064F\u0639\u0644\u0651\u0645 \u0628\u062F\u0648\u0646 \u0627\u0644\u062A\u0642\u0627\u0637",
1980
1980
  expected_label: "\u0627\u0644\u0645\u062A\u0648\u0642\u0639",
1981
1981
  exact_label: "\u0644\u0642\u0637\u0627\u062A \u0645\u0637\u0627\u0628\u0642\u0629 \u062A\u0645\u0627\u0645\u064B\u0627",
1982
- exact_hint: "\u062A\u0635\u0628\u062D \u0627\u0644\u0644\u0642\u0637\u0629 \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629 \u0644\u0644\u062A\u0628\u0648\u064A\u0628 \u0628\u062F\u0644 \u0625\u0639\u0627\u062F\u0629 \u0631\u0633\u0645\u0647. \u064A\u064F\u0637\u0644\u0628 \u0627\u0644\u0625\u0630\u0646 \u0645\u0631\u0629 \u0648\u0627\u062D\u062F\u0629\u060C \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
1982
+ 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.",
1983
1983
  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.",
1984
1984
  redraw_action: "\u0627\u0633\u062A\u062E\u062F\u0645 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629",
1985
- 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\u062A\u0628\u0648\u064A\u0628\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 \u0641\u064A Chrome \u0623\u0648 Edge \u0623\u0648 Brave \u0644\u0644\u062D\u0635\u0648\u0644 \u0639\u0644\u0649 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629.",
1985
+ 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.",
1986
1986
  fab_title: "Qapture \u2014 \u0627\u0636\u063A\u0637 \u0644\u0644\u0641\u062A\u062D\u060C \u0648\u0627\u0633\u062D\u0628 \u0644\u062A\u062D\u0631\u064A\u0643\u0647 \u0645\u0646 \u0645\u0643\u0627\u0646\u0647",
1987
1987
  exact_on: "\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u0637\u0627\u0628\u0642\u0629",
1988
1988
  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",
@@ -2182,8 +2182,168 @@ function computeCoverage(journey, guideChecked, skipped) {
2182
2182
  };
2183
2183
  }
2184
2184
 
2185
+ // src/lib/frameCalibration.ts
2186
+ var MARKER_SIZE = 28;
2187
+ var MARKERS = [
2188
+ { key: "tl", color: [255, 0, 0] },
2189
+ { key: "tr", color: [0, 255, 0] },
2190
+ { key: "bl", color: [0, 0, 255] },
2191
+ { key: "br", color: [255, 0, 255] }
2192
+ ];
2193
+ function markerCentres(vw, vh) {
2194
+ const h = MARKER_SIZE / 2;
2195
+ return {
2196
+ tl: { x: h, y: h },
2197
+ tr: { x: vw - h, y: h },
2198
+ bl: { x: h, y: vh - h },
2199
+ br: { x: vw - h, y: vh - h }
2200
+ };
2201
+ }
2202
+ function dist2(r, g, b, c) {
2203
+ const dr = r - c[0];
2204
+ const dg = g - c[1];
2205
+ const db = b - c[2];
2206
+ return dr * dr + dg * dg + db * db;
2207
+ }
2208
+ var COLOR_TOLERANCE_2 = 90 * 90;
2209
+ var CLUSTER_CELL = 8;
2210
+ var MAX_CANDIDATES = 4;
2211
+ function findMarkerCandidates(data, width, height) {
2212
+ const cols = Math.ceil(width / CLUSTER_CELL);
2213
+ const rows = Math.ceil(height / CLUSTER_CELL);
2214
+ const cells = {};
2215
+ for (const m of MARKERS) cells[m.key] = new Int32Array(cols * rows);
2216
+ for (let y = 0; y < height; y++) {
2217
+ const row = y * width * 4;
2218
+ const cy = y / CLUSTER_CELL | 0;
2219
+ for (let x = 0; x < width; x++) {
2220
+ const i = row + x * 4;
2221
+ if (data[i + 3] < 128) continue;
2222
+ const r = data[i];
2223
+ const g = data[i + 1];
2224
+ const b = data[i + 2];
2225
+ if (Math.max(r, g, b) - Math.min(r, g, b) < 60) continue;
2226
+ for (const m of MARKERS) {
2227
+ if (dist2(r, g, b, m.color) <= COLOR_TOLERANCE_2) {
2228
+ cells[m.key][cy * cols + (x / CLUSTER_CELL | 0)]++;
2229
+ break;
2230
+ }
2231
+ }
2232
+ }
2233
+ }
2234
+ const out = {};
2235
+ for (const m of MARKERS) {
2236
+ const grid = cells[m.key];
2237
+ const seen = new Uint8Array(cols * rows);
2238
+ const blobs = [];
2239
+ for (let start = 0; start < grid.length; start++) {
2240
+ if (!grid[start] || seen[start]) continue;
2241
+ const queue = [start];
2242
+ seen[start] = 1;
2243
+ let sumX = 0;
2244
+ let sumY = 0;
2245
+ let total = 0;
2246
+ while (queue.length) {
2247
+ const idx = queue.pop();
2248
+ const n = grid[idx];
2249
+ const cx = idx % cols;
2250
+ const cy = idx / cols | 0;
2251
+ sumX += (cx * CLUSTER_CELL + CLUSTER_CELL / 2) * n;
2252
+ sumY += (cy * CLUSTER_CELL + CLUSTER_CELL / 2) * n;
2253
+ total += n;
2254
+ for (let dy = -1; dy <= 1; dy++) {
2255
+ for (let dx = -1; dx <= 1; dx++) {
2256
+ const nx = cx + dx;
2257
+ const ny = cy + dy;
2258
+ if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
2259
+ const nIdx = ny * cols + nx;
2260
+ if (seen[nIdx] || !grid[nIdx]) continue;
2261
+ seen[nIdx] = 1;
2262
+ queue.push(nIdx);
2263
+ }
2264
+ }
2265
+ }
2266
+ if (total < 16) continue;
2267
+ blobs.push({ x: sumX / total, y: sumY / total, count: total });
2268
+ }
2269
+ blobs.sort((a, b) => b.count - a.count);
2270
+ out[m.key] = blobs.slice(0, MAX_CANDIDATES);
2271
+ }
2272
+ return out;
2273
+ }
2274
+ var VERIFY_TOLERANCE = 0.02;
2275
+ var SQUARENESS_TOLERANCE = 0.05;
2276
+ function deriveMapping(candidates, vw, vh, frameW, frameH) {
2277
+ const tls = candidates.tl ?? [];
2278
+ const brs = candidates.br ?? [];
2279
+ for (const tl of tls) {
2280
+ for (const br of brs) {
2281
+ const m = solveFrom(tl, br, candidates, vw, vh, frameW, frameH);
2282
+ if (m) return m;
2283
+ }
2284
+ }
2285
+ return null;
2286
+ }
2287
+ function solveFrom(tl, br, candidates, vw, vh, frameW, frameH) {
2288
+ if (!(vw > MARKER_SIZE) || !(vh > MARKER_SIZE)) return null;
2289
+ const centres = markerCentres(vw, vh);
2290
+ const spanCssX = centres.br.x - centres.tl.x;
2291
+ const spanCssY = centres.br.y - centres.tl.y;
2292
+ if (spanCssX <= 0 || spanCssY <= 0) return null;
2293
+ const scaleX = (br.x - tl.x) / spanCssX;
2294
+ const scaleY = (br.y - tl.y) / spanCssY;
2295
+ if (!(scaleX > 0.05) || !(scaleY > 0.05)) return null;
2296
+ if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY)) return null;
2297
+ if (Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY) > SQUARENESS_TOLERANCE) return null;
2298
+ const originX = tl.x - centres.tl.x * scaleX;
2299
+ const originY = tl.y - centres.tl.y * scaleY;
2300
+ const mapping2 = { scaleX, scaleY, originX, originY };
2301
+ const farX = originX + vw * scaleX;
2302
+ const farY = originY + vh * scaleY;
2303
+ const slack = Math.max(4, frameW * 0.01);
2304
+ if (originX < -slack || originY < -slack || farX > frameW + slack || farY > frameH + slack) {
2305
+ return null;
2306
+ }
2307
+ const tolerance = Math.max(6, Math.max(frameW, frameH) * VERIFY_TOLERANCE);
2308
+ let verified = 0;
2309
+ for (const key of ["tr", "bl"]) {
2310
+ const hits = candidates[key] ?? [];
2311
+ if (!hits.length) continue;
2312
+ const want = centres[key];
2313
+ const px = originX + want.x * scaleX;
2314
+ const py = originY + want.y * scaleY;
2315
+ if (!hits.some((h) => Math.hypot(px - h.x, py - h.y) <= tolerance)) return null;
2316
+ verified++;
2317
+ }
2318
+ if (verified === 0) return null;
2319
+ return mapping2;
2320
+ }
2321
+ function mapRectToFrame(rect, m, frameW, frameH) {
2322
+ const x0 = m.originX + rect.left * m.scaleX;
2323
+ const y0 = m.originY + rect.top * m.scaleY;
2324
+ const sx = Math.max(0, Math.min(frameW, Math.round(x0)));
2325
+ const sy = Math.max(0, Math.min(frameH, Math.round(y0)));
2326
+ const sw = Math.min(frameW - sx, Math.round(rect.width * m.scaleX));
2327
+ const sh = Math.min(frameH - sy, Math.round(rect.height * m.scaleY));
2328
+ if (sw < 1 || sh < 1) return null;
2329
+ return { sx, sy, sw, sh };
2330
+ }
2331
+ function environmentSignature() {
2332
+ if (typeof window === "undefined") return "";
2333
+ return [
2334
+ window.innerWidth,
2335
+ window.innerHeight,
2336
+ window.outerWidth,
2337
+ window.outerHeight,
2338
+ window.screenX,
2339
+ window.screenY,
2340
+ Math.round((window.devicePixelRatio || 1) * 100)
2341
+ ].join("x");
2342
+ }
2343
+
2185
2344
  // src/lib/screenCapture.ts
2186
2345
  var ASPECT_TOLERANCE = 0.08;
2346
+ var CALIBRATION_SETTLE_MS = 220;
2187
2347
  var FRESH_FRAME_TIMEOUT_MS = 500;
2188
2348
  var VIDEO_READY_TIMEOUT_MS = 4e3;
2189
2349
  var stream = null;
@@ -2191,22 +2351,14 @@ var track = null;
2191
2351
  var video = null;
2192
2352
  var grabber = null;
2193
2353
  var declined = false;
2194
- function supportsPreferCurrentTab() {
2195
- const nav = navigator;
2196
- const brands = nav.userAgentData?.brands;
2197
- if (Array.isArray(brands) && brands.length) {
2198
- return brands.some((b) => /chromium/i.test(b.brand));
2199
- }
2200
- const ua = navigator.userAgent || "";
2201
- if (/CriOS|FxiOS|EdgiOS|Firefox/i.test(ua)) return false;
2202
- return /Chrome|Chromium|Edg\//i.test(ua);
2203
- }
2204
2354
  function isExactCaptureSupported() {
2205
2355
  if (typeof navigator === "undefined" || typeof document === "undefined") return false;
2206
2356
  const md = navigator.mediaDevices;
2207
- if (!md || typeof md.getDisplayMedia !== "function") return false;
2208
- return supportsPreferCurrentTab();
2357
+ return !!md && typeof md.getDisplayMedia === "function";
2209
2358
  }
2359
+ var mode = "tab";
2360
+ var mapping = null;
2361
+ var mappingSignature = "";
2210
2362
  function getExactCaptureStatus() {
2211
2363
  if (!isExactCaptureSupported()) return "unsupported";
2212
2364
  if (track && track.readyState === "live") return "live";
@@ -2234,6 +2386,9 @@ function teardown() {
2234
2386
  stream = null;
2235
2387
  track = null;
2236
2388
  grabber = null;
2389
+ mode = "tab";
2390
+ mapping = null;
2391
+ mappingSignature = "";
2237
2392
  }
2238
2393
  function stopExactCapture() {
2239
2394
  teardown();
@@ -2288,6 +2443,78 @@ function validateFrameMapping(frameW, frameH) {
2288
2443
  const viewAspect = vw / vh;
2289
2444
  return Math.abs(frameAspect - viewAspect) / viewAspect <= ASPECT_TOLERANCE;
2290
2445
  }
2446
+ async function grabFrameCanvas() {
2447
+ if (!video) return null;
2448
+ await nextPresentedFrame(video);
2449
+ let source = video;
2450
+ let w = video.videoWidth;
2451
+ let h = video.videoHeight;
2452
+ let bitmap = null;
2453
+ if (grabber) {
2454
+ try {
2455
+ bitmap = await grabber.grabFrame();
2456
+ source = bitmap;
2457
+ w = bitmap.width;
2458
+ h = bitmap.height;
2459
+ } catch {
2460
+ }
2461
+ }
2462
+ if (!w || !h) {
2463
+ bitmap?.close?.();
2464
+ return null;
2465
+ }
2466
+ const c = document.createElement("canvas");
2467
+ c.width = w;
2468
+ c.height = h;
2469
+ const ctx = c.getContext("2d", { willReadFrequently: true });
2470
+ if (!ctx) {
2471
+ bitmap?.close?.();
2472
+ return null;
2473
+ }
2474
+ ctx.drawImage(source, 0, 0);
2475
+ bitmap?.close?.();
2476
+ return c;
2477
+ }
2478
+ async function calibrate() {
2479
+ if (!video) return false;
2480
+ const vw = window.innerWidth;
2481
+ const vh = window.innerHeight;
2482
+ if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return false;
2483
+ const card = document.createElement("div");
2484
+ card.setAttribute("data-qa-calibration", "true");
2485
+ card.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:#0a0a0a;pointer-events:none;contain:strict;";
2486
+ const centres = markerCentres(vw, vh);
2487
+ for (const m of MARKERS) {
2488
+ const dot = document.createElement("div");
2489
+ const c = centres[m.key];
2490
+ dot.style.cssText = `position:absolute;width:${MARKER_SIZE}px;height:${MARKER_SIZE}px;left:${c.x - MARKER_SIZE / 2}px;top:${c.y - MARKER_SIZE / 2}px;background:rgb(${m.color[0]},${m.color[1]},${m.color[2]});`;
2491
+ card.appendChild(dot);
2492
+ }
2493
+ document.body.appendChild(card);
2494
+ try {
2495
+ await new Promise((r) => setTimeout(r, CALIBRATION_SETTLE_MS));
2496
+ const frame = await grabFrameCanvas();
2497
+ if (!frame) return false;
2498
+ const ctx = frame.getContext("2d", { willReadFrequently: true });
2499
+ if (!ctx) return false;
2500
+ const { data } = ctx.getImageData(0, 0, frame.width, frame.height);
2501
+ const found = deriveMapping(
2502
+ findMarkerCandidates(data, frame.width, frame.height),
2503
+ vw,
2504
+ vh,
2505
+ frame.width,
2506
+ frame.height
2507
+ );
2508
+ if (!found) return false;
2509
+ mapping = found;
2510
+ mappingSignature = environmentSignature();
2511
+ return true;
2512
+ } catch {
2513
+ return false;
2514
+ } finally {
2515
+ card.remove();
2516
+ }
2517
+ }
2291
2518
  async function startExactCapture() {
2292
2519
  if (!isExactCaptureSupported()) return false;
2293
2520
  if (track && track.readyState === "live" && video) return true;
@@ -2317,11 +2544,7 @@ async function startExactCapture() {
2317
2544
  return false;
2318
2545
  }
2319
2546
  const settings = t.getSettings();
2320
- if (settings.displaySurface && settings.displaySurface !== "browser") {
2321
- for (const x of s.getTracks()) x.stop();
2322
- declined = true;
2323
- return false;
2324
- }
2547
+ const sharedTab = !settings.displaySurface || settings.displaySurface === "browser";
2325
2548
  const el = document.createElement("video");
2326
2549
  el.setAttribute("data-qa-overlay", "true");
2327
2550
  el.muted = true;
@@ -2334,17 +2557,31 @@ async function startExactCapture() {
2334
2557
  } catch {
2335
2558
  }
2336
2559
  const ready = await waitFor(() => el.videoWidth > 0 && el.videoHeight > 0, VIDEO_READY_TIMEOUT_MS);
2337
- if (!ready || !validateFrameMapping(el.videoWidth, el.videoHeight)) {
2560
+ const giveUp = () => {
2338
2561
  el.remove();
2339
2562
  for (const x of s.getTracks()) x.stop();
2340
2563
  declined = true;
2341
2564
  return false;
2342
- }
2565
+ };
2566
+ if (!ready) return giveUp();
2567
+ const isDirect = sharedTab && validateFrameMapping(el.videoWidth, el.videoHeight);
2343
2568
  stream = s;
2344
2569
  track = t;
2345
2570
  video = el;
2346
2571
  const IC = window.ImageCapture;
2347
2572
  grabber = IC ? new IC(t) : null;
2573
+ if (isDirect) {
2574
+ mode = "tab";
2575
+ mapping = null;
2576
+ mappingSignature = "";
2577
+ } else {
2578
+ mode = "surface";
2579
+ if (!await calibrate()) {
2580
+ teardown();
2581
+ declined = true;
2582
+ return false;
2583
+ }
2584
+ }
2348
2585
  t.addEventListener("ended", () => {
2349
2586
  teardown();
2350
2587
  });
@@ -2357,6 +2594,11 @@ async function startExactCapture() {
2357
2594
  }
2358
2595
  async function grabExactRegion(rect) {
2359
2596
  if (!track || track.readyState !== "live" || !video) return null;
2597
+ if (mode === "surface") {
2598
+ if (!mapping || environmentSignature() !== mappingSignature) {
2599
+ if (!await calibrate()) return null;
2600
+ }
2601
+ }
2360
2602
  await nextPresentedFrame(video);
2361
2603
  let source;
2362
2604
  let frameW;
@@ -2379,13 +2621,24 @@ async function grabExactRegion(rect) {
2379
2621
  frameH = video.videoHeight;
2380
2622
  }
2381
2623
  try {
2382
- if (!validateFrameMapping(frameW, frameH)) return null;
2383
- const scaleX = frameW / window.innerWidth;
2384
- const scaleY = frameH / window.innerHeight;
2385
- const sx = Math.max(0, Math.min(frameW, Math.round(rect.left * scaleX)));
2386
- const sy = Math.max(0, Math.min(frameH, Math.round(rect.top * scaleY)));
2387
- const sw = Math.max(1, Math.min(frameW - sx, Math.round(rect.width * scaleX)));
2388
- const sh = Math.max(1, Math.min(frameH - sy, Math.round(rect.height * scaleY)));
2624
+ let sx;
2625
+ let sy;
2626
+ let sw;
2627
+ let sh;
2628
+ if (mode === "surface") {
2629
+ if (!mapping) return null;
2630
+ const mapped = mapRectToFrame(rect, mapping, frameW, frameH);
2631
+ if (!mapped) return null;
2632
+ ({ sx, sy, sw, sh } = mapped);
2633
+ } else {
2634
+ if (!validateFrameMapping(frameW, frameH)) return null;
2635
+ const scaleX = frameW / window.innerWidth;
2636
+ const scaleY = frameH / window.innerHeight;
2637
+ sx = Math.max(0, Math.min(frameW, Math.round(rect.left * scaleX)));
2638
+ sy = Math.max(0, Math.min(frameH, Math.round(rect.top * scaleY)));
2639
+ sw = Math.max(1, Math.min(frameW - sx, Math.round(rect.width * scaleX)));
2640
+ sh = Math.max(1, Math.min(frameH - sy, Math.round(rect.height * scaleY)));
2641
+ }
2389
2642
  const canvas = document.createElement("canvas");
2390
2643
  canvas.width = sw;
2391
2644
  canvas.height = sh;
@@ -10101,5 +10354,5 @@ function Qapture({ config }) {
10101
10354
  exports.Qapture = Qapture;
10102
10355
  exports.deleteQaDatabase = deleteQaDatabase;
10103
10356
  exports.initQaStudio = initQaStudio;
10104
- //# sourceMappingURL=chunk-COQKFGKZ.cjs.map
10105
- //# sourceMappingURL=chunk-COQKFGKZ.cjs.map
10357
+ //# sourceMappingURL=chunk-CNNNWLTX.cjs.map
10358
+ //# sourceMappingURL=chunk-CNNNWLTX.cjs.map
@@ -51,10 +51,10 @@ function openDB(dbName) {
51
51
  dbCache.set(dbName, promise);
52
52
  return promise;
53
53
  }
54
- function run(dbName, store, mode, fn) {
54
+ function run(dbName, store, mode2, fn) {
55
55
  return openDB(dbName).then(
56
56
  (db) => new Promise((resolve, reject) => {
57
- const tx = db.transaction(store, mode);
57
+ const tx = db.transaction(store, mode2);
58
58
  const s = tx.objectStore(store);
59
59
  let result;
60
60
  const req = fn(s);
@@ -1774,10 +1774,10 @@ var STR = {
1774
1774
  no_evidence: "ticked, no capture",
1775
1775
  expected_label: "Expected",
1776
1776
  exact_label: "Pixel-exact shots",
1777
- exact_hint: "Screenshots become a real photo of the tab instead of a redraw. Asks once to share this tab; nothing leaves your device.",
1777
+ 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.",
1778
1778
  redraw_notice: "This is a redraw of the page, not a real photo.",
1779
1779
  redraw_action: "Use real screenshots",
1780
- redraw_no_engine: "This browser cannot photograph a tab, so charts and canvases may come out wrong. Open the app in Chrome, Edge or Brave for true screenshots.",
1780
+ 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.",
1781
1781
  fab_title: "Qapture \u2014 click to open, drag to move it out of the way",
1782
1782
  exact_on: "Pixel-exact screenshots on",
1783
1783
  exact_off: "Back to redrawn screenshots",
@@ -1972,10 +1972,10 @@ var STR = {
1972
1972
  no_evidence: "\u0645\u064F\u0639\u0644\u0651\u0645 \u0628\u062F\u0648\u0646 \u0627\u0644\u062A\u0642\u0627\u0637",
1973
1973
  expected_label: "\u0627\u0644\u0645\u062A\u0648\u0642\u0639",
1974
1974
  exact_label: "\u0644\u0642\u0637\u0627\u062A \u0645\u0637\u0627\u0628\u0642\u0629 \u062A\u0645\u0627\u0645\u064B\u0627",
1975
- exact_hint: "\u062A\u0635\u0628\u062D \u0627\u0644\u0644\u0642\u0637\u0629 \u0635\u0648\u0631\u0629 \u062D\u0642\u064A\u0642\u064A\u0629 \u0644\u0644\u062A\u0628\u0648\u064A\u0628 \u0628\u062F\u0644 \u0625\u0639\u0627\u062F\u0629 \u0631\u0633\u0645\u0647. \u064A\u064F\u0637\u0644\u0628 \u0627\u0644\u0625\u0630\u0646 \u0645\u0631\u0629 \u0648\u0627\u062D\u062F\u0629\u060C \u0648\u0644\u0627 \u0634\u064A\u0621 \u064A\u063A\u0627\u062F\u0631 \u062C\u0647\u0627\u0632\u0643.",
1975
+ 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.",
1976
1976
  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.",
1977
1977
  redraw_action: "\u0627\u0633\u062A\u062E\u062F\u0645 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629",
1978
- 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\u062A\u0628\u0648\u064A\u0628\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 \u0641\u064A Chrome \u0623\u0648 Edge \u0623\u0648 Brave \u0644\u0644\u062D\u0635\u0648\u0644 \u0639\u0644\u0649 \u0644\u0642\u0637\u0627\u062A \u062D\u0642\u064A\u0642\u064A\u0629.",
1978
+ 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.",
1979
1979
  fab_title: "Qapture \u2014 \u0627\u0636\u063A\u0637 \u0644\u0644\u0641\u062A\u062D\u060C \u0648\u0627\u0633\u062D\u0628 \u0644\u062A\u062D\u0631\u064A\u0643\u0647 \u0645\u0646 \u0645\u0643\u0627\u0646\u0647",
1980
1980
  exact_on: "\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 \u0627\u0644\u0644\u0642\u0637\u0627\u062A \u0627\u0644\u0645\u0637\u0627\u0628\u0642\u0629",
1981
1981
  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",
@@ -2175,8 +2175,168 @@ function computeCoverage(journey, guideChecked, skipped) {
2175
2175
  };
2176
2176
  }
2177
2177
 
2178
+ // src/lib/frameCalibration.ts
2179
+ var MARKER_SIZE = 28;
2180
+ var MARKERS = [
2181
+ { key: "tl", color: [255, 0, 0] },
2182
+ { key: "tr", color: [0, 255, 0] },
2183
+ { key: "bl", color: [0, 0, 255] },
2184
+ { key: "br", color: [255, 0, 255] }
2185
+ ];
2186
+ function markerCentres(vw, vh) {
2187
+ const h = MARKER_SIZE / 2;
2188
+ return {
2189
+ tl: { x: h, y: h },
2190
+ tr: { x: vw - h, y: h },
2191
+ bl: { x: h, y: vh - h },
2192
+ br: { x: vw - h, y: vh - h }
2193
+ };
2194
+ }
2195
+ function dist2(r, g, b, c) {
2196
+ const dr = r - c[0];
2197
+ const dg = g - c[1];
2198
+ const db = b - c[2];
2199
+ return dr * dr + dg * dg + db * db;
2200
+ }
2201
+ var COLOR_TOLERANCE_2 = 90 * 90;
2202
+ var CLUSTER_CELL = 8;
2203
+ var MAX_CANDIDATES = 4;
2204
+ function findMarkerCandidates(data, width, height) {
2205
+ const cols = Math.ceil(width / CLUSTER_CELL);
2206
+ const rows = Math.ceil(height / CLUSTER_CELL);
2207
+ const cells = {};
2208
+ for (const m of MARKERS) cells[m.key] = new Int32Array(cols * rows);
2209
+ for (let y = 0; y < height; y++) {
2210
+ const row = y * width * 4;
2211
+ const cy = y / CLUSTER_CELL | 0;
2212
+ for (let x = 0; x < width; x++) {
2213
+ const i = row + x * 4;
2214
+ if (data[i + 3] < 128) continue;
2215
+ const r = data[i];
2216
+ const g = data[i + 1];
2217
+ const b = data[i + 2];
2218
+ if (Math.max(r, g, b) - Math.min(r, g, b) < 60) continue;
2219
+ for (const m of MARKERS) {
2220
+ if (dist2(r, g, b, m.color) <= COLOR_TOLERANCE_2) {
2221
+ cells[m.key][cy * cols + (x / CLUSTER_CELL | 0)]++;
2222
+ break;
2223
+ }
2224
+ }
2225
+ }
2226
+ }
2227
+ const out = {};
2228
+ for (const m of MARKERS) {
2229
+ const grid = cells[m.key];
2230
+ const seen = new Uint8Array(cols * rows);
2231
+ const blobs = [];
2232
+ for (let start = 0; start < grid.length; start++) {
2233
+ if (!grid[start] || seen[start]) continue;
2234
+ const queue = [start];
2235
+ seen[start] = 1;
2236
+ let sumX = 0;
2237
+ let sumY = 0;
2238
+ let total = 0;
2239
+ while (queue.length) {
2240
+ const idx = queue.pop();
2241
+ const n = grid[idx];
2242
+ const cx = idx % cols;
2243
+ const cy = idx / cols | 0;
2244
+ sumX += (cx * CLUSTER_CELL + CLUSTER_CELL / 2) * n;
2245
+ sumY += (cy * CLUSTER_CELL + CLUSTER_CELL / 2) * n;
2246
+ total += n;
2247
+ for (let dy = -1; dy <= 1; dy++) {
2248
+ for (let dx = -1; dx <= 1; dx++) {
2249
+ const nx = cx + dx;
2250
+ const ny = cy + dy;
2251
+ if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
2252
+ const nIdx = ny * cols + nx;
2253
+ if (seen[nIdx] || !grid[nIdx]) continue;
2254
+ seen[nIdx] = 1;
2255
+ queue.push(nIdx);
2256
+ }
2257
+ }
2258
+ }
2259
+ if (total < 16) continue;
2260
+ blobs.push({ x: sumX / total, y: sumY / total, count: total });
2261
+ }
2262
+ blobs.sort((a, b) => b.count - a.count);
2263
+ out[m.key] = blobs.slice(0, MAX_CANDIDATES);
2264
+ }
2265
+ return out;
2266
+ }
2267
+ var VERIFY_TOLERANCE = 0.02;
2268
+ var SQUARENESS_TOLERANCE = 0.05;
2269
+ function deriveMapping(candidates, vw, vh, frameW, frameH) {
2270
+ const tls = candidates.tl ?? [];
2271
+ const brs = candidates.br ?? [];
2272
+ for (const tl of tls) {
2273
+ for (const br of brs) {
2274
+ const m = solveFrom(tl, br, candidates, vw, vh, frameW, frameH);
2275
+ if (m) return m;
2276
+ }
2277
+ }
2278
+ return null;
2279
+ }
2280
+ function solveFrom(tl, br, candidates, vw, vh, frameW, frameH) {
2281
+ if (!(vw > MARKER_SIZE) || !(vh > MARKER_SIZE)) return null;
2282
+ const centres = markerCentres(vw, vh);
2283
+ const spanCssX = centres.br.x - centres.tl.x;
2284
+ const spanCssY = centres.br.y - centres.tl.y;
2285
+ if (spanCssX <= 0 || spanCssY <= 0) return null;
2286
+ const scaleX = (br.x - tl.x) / spanCssX;
2287
+ const scaleY = (br.y - tl.y) / spanCssY;
2288
+ if (!(scaleX > 0.05) || !(scaleY > 0.05)) return null;
2289
+ if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY)) return null;
2290
+ if (Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY) > SQUARENESS_TOLERANCE) return null;
2291
+ const originX = tl.x - centres.tl.x * scaleX;
2292
+ const originY = tl.y - centres.tl.y * scaleY;
2293
+ const mapping2 = { scaleX, scaleY, originX, originY };
2294
+ const farX = originX + vw * scaleX;
2295
+ const farY = originY + vh * scaleY;
2296
+ const slack = Math.max(4, frameW * 0.01);
2297
+ if (originX < -slack || originY < -slack || farX > frameW + slack || farY > frameH + slack) {
2298
+ return null;
2299
+ }
2300
+ const tolerance = Math.max(6, Math.max(frameW, frameH) * VERIFY_TOLERANCE);
2301
+ let verified = 0;
2302
+ for (const key of ["tr", "bl"]) {
2303
+ const hits = candidates[key] ?? [];
2304
+ if (!hits.length) continue;
2305
+ const want = centres[key];
2306
+ const px = originX + want.x * scaleX;
2307
+ const py = originY + want.y * scaleY;
2308
+ if (!hits.some((h) => Math.hypot(px - h.x, py - h.y) <= tolerance)) return null;
2309
+ verified++;
2310
+ }
2311
+ if (verified === 0) return null;
2312
+ return mapping2;
2313
+ }
2314
+ function mapRectToFrame(rect, m, frameW, frameH) {
2315
+ const x0 = m.originX + rect.left * m.scaleX;
2316
+ const y0 = m.originY + rect.top * m.scaleY;
2317
+ const sx = Math.max(0, Math.min(frameW, Math.round(x0)));
2318
+ const sy = Math.max(0, Math.min(frameH, Math.round(y0)));
2319
+ const sw = Math.min(frameW - sx, Math.round(rect.width * m.scaleX));
2320
+ const sh = Math.min(frameH - sy, Math.round(rect.height * m.scaleY));
2321
+ if (sw < 1 || sh < 1) return null;
2322
+ return { sx, sy, sw, sh };
2323
+ }
2324
+ function environmentSignature() {
2325
+ if (typeof window === "undefined") return "";
2326
+ return [
2327
+ window.innerWidth,
2328
+ window.innerHeight,
2329
+ window.outerWidth,
2330
+ window.outerHeight,
2331
+ window.screenX,
2332
+ window.screenY,
2333
+ Math.round((window.devicePixelRatio || 1) * 100)
2334
+ ].join("x");
2335
+ }
2336
+
2178
2337
  // src/lib/screenCapture.ts
2179
2338
  var ASPECT_TOLERANCE = 0.08;
2339
+ var CALIBRATION_SETTLE_MS = 220;
2180
2340
  var FRESH_FRAME_TIMEOUT_MS = 500;
2181
2341
  var VIDEO_READY_TIMEOUT_MS = 4e3;
2182
2342
  var stream = null;
@@ -2184,22 +2344,14 @@ var track = null;
2184
2344
  var video = null;
2185
2345
  var grabber = null;
2186
2346
  var declined = false;
2187
- function supportsPreferCurrentTab() {
2188
- const nav = navigator;
2189
- const brands = nav.userAgentData?.brands;
2190
- if (Array.isArray(brands) && brands.length) {
2191
- return brands.some((b) => /chromium/i.test(b.brand));
2192
- }
2193
- const ua = navigator.userAgent || "";
2194
- if (/CriOS|FxiOS|EdgiOS|Firefox/i.test(ua)) return false;
2195
- return /Chrome|Chromium|Edg\//i.test(ua);
2196
- }
2197
2347
  function isExactCaptureSupported() {
2198
2348
  if (typeof navigator === "undefined" || typeof document === "undefined") return false;
2199
2349
  const md = navigator.mediaDevices;
2200
- if (!md || typeof md.getDisplayMedia !== "function") return false;
2201
- return supportsPreferCurrentTab();
2350
+ return !!md && typeof md.getDisplayMedia === "function";
2202
2351
  }
2352
+ var mode = "tab";
2353
+ var mapping = null;
2354
+ var mappingSignature = "";
2203
2355
  function getExactCaptureStatus() {
2204
2356
  if (!isExactCaptureSupported()) return "unsupported";
2205
2357
  if (track && track.readyState === "live") return "live";
@@ -2227,6 +2379,9 @@ function teardown() {
2227
2379
  stream = null;
2228
2380
  track = null;
2229
2381
  grabber = null;
2382
+ mode = "tab";
2383
+ mapping = null;
2384
+ mappingSignature = "";
2230
2385
  }
2231
2386
  function stopExactCapture() {
2232
2387
  teardown();
@@ -2281,6 +2436,78 @@ function validateFrameMapping(frameW, frameH) {
2281
2436
  const viewAspect = vw / vh;
2282
2437
  return Math.abs(frameAspect - viewAspect) / viewAspect <= ASPECT_TOLERANCE;
2283
2438
  }
2439
+ async function grabFrameCanvas() {
2440
+ if (!video) return null;
2441
+ await nextPresentedFrame(video);
2442
+ let source = video;
2443
+ let w = video.videoWidth;
2444
+ let h = video.videoHeight;
2445
+ let bitmap = null;
2446
+ if (grabber) {
2447
+ try {
2448
+ bitmap = await grabber.grabFrame();
2449
+ source = bitmap;
2450
+ w = bitmap.width;
2451
+ h = bitmap.height;
2452
+ } catch {
2453
+ }
2454
+ }
2455
+ if (!w || !h) {
2456
+ bitmap?.close?.();
2457
+ return null;
2458
+ }
2459
+ const c = document.createElement("canvas");
2460
+ c.width = w;
2461
+ c.height = h;
2462
+ const ctx = c.getContext("2d", { willReadFrequently: true });
2463
+ if (!ctx) {
2464
+ bitmap?.close?.();
2465
+ return null;
2466
+ }
2467
+ ctx.drawImage(source, 0, 0);
2468
+ bitmap?.close?.();
2469
+ return c;
2470
+ }
2471
+ async function calibrate() {
2472
+ if (!video) return false;
2473
+ const vw = window.innerWidth;
2474
+ const vh = window.innerHeight;
2475
+ if (vw <= MARKER_SIZE || vh <= MARKER_SIZE) return false;
2476
+ const card = document.createElement("div");
2477
+ card.setAttribute("data-qa-calibration", "true");
2478
+ card.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:#0a0a0a;pointer-events:none;contain:strict;";
2479
+ const centres = markerCentres(vw, vh);
2480
+ for (const m of MARKERS) {
2481
+ const dot = document.createElement("div");
2482
+ const c = centres[m.key];
2483
+ dot.style.cssText = `position:absolute;width:${MARKER_SIZE}px;height:${MARKER_SIZE}px;left:${c.x - MARKER_SIZE / 2}px;top:${c.y - MARKER_SIZE / 2}px;background:rgb(${m.color[0]},${m.color[1]},${m.color[2]});`;
2484
+ card.appendChild(dot);
2485
+ }
2486
+ document.body.appendChild(card);
2487
+ try {
2488
+ await new Promise((r) => setTimeout(r, CALIBRATION_SETTLE_MS));
2489
+ const frame = await grabFrameCanvas();
2490
+ if (!frame) return false;
2491
+ const ctx = frame.getContext("2d", { willReadFrequently: true });
2492
+ if (!ctx) return false;
2493
+ const { data } = ctx.getImageData(0, 0, frame.width, frame.height);
2494
+ const found = deriveMapping(
2495
+ findMarkerCandidates(data, frame.width, frame.height),
2496
+ vw,
2497
+ vh,
2498
+ frame.width,
2499
+ frame.height
2500
+ );
2501
+ if (!found) return false;
2502
+ mapping = found;
2503
+ mappingSignature = environmentSignature();
2504
+ return true;
2505
+ } catch {
2506
+ return false;
2507
+ } finally {
2508
+ card.remove();
2509
+ }
2510
+ }
2284
2511
  async function startExactCapture() {
2285
2512
  if (!isExactCaptureSupported()) return false;
2286
2513
  if (track && track.readyState === "live" && video) return true;
@@ -2310,11 +2537,7 @@ async function startExactCapture() {
2310
2537
  return false;
2311
2538
  }
2312
2539
  const settings = t.getSettings();
2313
- if (settings.displaySurface && settings.displaySurface !== "browser") {
2314
- for (const x of s.getTracks()) x.stop();
2315
- declined = true;
2316
- return false;
2317
- }
2540
+ const sharedTab = !settings.displaySurface || settings.displaySurface === "browser";
2318
2541
  const el = document.createElement("video");
2319
2542
  el.setAttribute("data-qa-overlay", "true");
2320
2543
  el.muted = true;
@@ -2327,17 +2550,31 @@ async function startExactCapture() {
2327
2550
  } catch {
2328
2551
  }
2329
2552
  const ready = await waitFor(() => el.videoWidth > 0 && el.videoHeight > 0, VIDEO_READY_TIMEOUT_MS);
2330
- if (!ready || !validateFrameMapping(el.videoWidth, el.videoHeight)) {
2553
+ const giveUp = () => {
2331
2554
  el.remove();
2332
2555
  for (const x of s.getTracks()) x.stop();
2333
2556
  declined = true;
2334
2557
  return false;
2335
- }
2558
+ };
2559
+ if (!ready) return giveUp();
2560
+ const isDirect = sharedTab && validateFrameMapping(el.videoWidth, el.videoHeight);
2336
2561
  stream = s;
2337
2562
  track = t;
2338
2563
  video = el;
2339
2564
  const IC = window.ImageCapture;
2340
2565
  grabber = IC ? new IC(t) : null;
2566
+ if (isDirect) {
2567
+ mode = "tab";
2568
+ mapping = null;
2569
+ mappingSignature = "";
2570
+ } else {
2571
+ mode = "surface";
2572
+ if (!await calibrate()) {
2573
+ teardown();
2574
+ declined = true;
2575
+ return false;
2576
+ }
2577
+ }
2341
2578
  t.addEventListener("ended", () => {
2342
2579
  teardown();
2343
2580
  });
@@ -2350,6 +2587,11 @@ async function startExactCapture() {
2350
2587
  }
2351
2588
  async function grabExactRegion(rect) {
2352
2589
  if (!track || track.readyState !== "live" || !video) return null;
2590
+ if (mode === "surface") {
2591
+ if (!mapping || environmentSignature() !== mappingSignature) {
2592
+ if (!await calibrate()) return null;
2593
+ }
2594
+ }
2353
2595
  await nextPresentedFrame(video);
2354
2596
  let source;
2355
2597
  let frameW;
@@ -2372,13 +2614,24 @@ async function grabExactRegion(rect) {
2372
2614
  frameH = video.videoHeight;
2373
2615
  }
2374
2616
  try {
2375
- if (!validateFrameMapping(frameW, frameH)) return null;
2376
- const scaleX = frameW / window.innerWidth;
2377
- const scaleY = frameH / window.innerHeight;
2378
- const sx = Math.max(0, Math.min(frameW, Math.round(rect.left * scaleX)));
2379
- const sy = Math.max(0, Math.min(frameH, Math.round(rect.top * scaleY)));
2380
- const sw = Math.max(1, Math.min(frameW - sx, Math.round(rect.width * scaleX)));
2381
- const sh = Math.max(1, Math.min(frameH - sy, Math.round(rect.height * scaleY)));
2617
+ let sx;
2618
+ let sy;
2619
+ let sw;
2620
+ let sh;
2621
+ if (mode === "surface") {
2622
+ if (!mapping) return null;
2623
+ const mapped = mapRectToFrame(rect, mapping, frameW, frameH);
2624
+ if (!mapped) return null;
2625
+ ({ sx, sy, sw, sh } = mapped);
2626
+ } else {
2627
+ if (!validateFrameMapping(frameW, frameH)) return null;
2628
+ const scaleX = frameW / window.innerWidth;
2629
+ const scaleY = frameH / window.innerHeight;
2630
+ sx = Math.max(0, Math.min(frameW, Math.round(rect.left * scaleX)));
2631
+ sy = Math.max(0, Math.min(frameH, Math.round(rect.top * scaleY)));
2632
+ sw = Math.max(1, Math.min(frameW - sx, Math.round(rect.width * scaleX)));
2633
+ sh = Math.max(1, Math.min(frameH - sy, Math.round(rect.height * scaleY)));
2634
+ }
2382
2635
  const canvas = document.createElement("canvas");
2383
2636
  canvas.width = sw;
2384
2637
  canvas.height = sh;
@@ -10092,5 +10345,5 @@ function Qapture({ config }) {
10092
10345
  }
10093
10346
 
10094
10347
  export { Qapture, deleteQaDatabase, initQaStudio };
10095
- //# sourceMappingURL=chunk-FBFESAG6.js.map
10096
- //# sourceMappingURL=chunk-FBFESAG6.js.map
10348
+ //# sourceMappingURL=chunk-NM3IDVEL.js.map
10349
+ //# sourceMappingURL=chunk-NM3IDVEL.js.map
package/dist/index.cjs CHANGED
@@ -1,24 +1,24 @@
1
1
  'use strict';
2
2
 
3
- var chunkCOQKFGKZ_cjs = require('./chunk-COQKFGKZ.cjs');
3
+ var chunkCNNNWLTX_cjs = require('./chunk-CNNNWLTX.cjs');
4
4
 
5
5
 
6
6
 
7
7
  Object.defineProperty(exports, "QaStudio", {
8
8
  enumerable: true,
9
- get: function () { return chunkCOQKFGKZ_cjs.Qapture; }
9
+ get: function () { return chunkCNNNWLTX_cjs.Qapture; }
10
10
  });
11
11
  Object.defineProperty(exports, "Qapture", {
12
12
  enumerable: true,
13
- get: function () { return chunkCOQKFGKZ_cjs.Qapture; }
13
+ get: function () { return chunkCNNNWLTX_cjs.Qapture; }
14
14
  });
15
15
  Object.defineProperty(exports, "deleteQaDatabase", {
16
16
  enumerable: true,
17
- get: function () { return chunkCOQKFGKZ_cjs.deleteQaDatabase; }
17
+ get: function () { return chunkCNNNWLTX_cjs.deleteQaDatabase; }
18
18
  });
19
19
  Object.defineProperty(exports, "initQaStudio", {
20
20
  enumerable: true,
21
- get: function () { return chunkCOQKFGKZ_cjs.initQaStudio; }
21
+ get: function () { return chunkCNNNWLTX_cjs.initQaStudio; }
22
22
  });
23
23
  //# sourceMappingURL=index.cjs.map
24
24
  //# sourceMappingURL=index.cjs.map
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { Qapture as QaStudio, Qapture, deleteQaDatabase, initQaStudio } from './chunk-FBFESAG6.js';
1
+ export { Qapture as QaStudio, Qapture, deleteQaDatabase, initQaStudio } from './chunk-NM3IDVEL.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/dist/next.cjs CHANGED
@@ -1,21 +1,21 @@
1
1
  "use client";
2
2
  'use strict';
3
3
 
4
- var chunkCOQKFGKZ_cjs = require('./chunk-COQKFGKZ.cjs');
4
+ var chunkCNNNWLTX_cjs = require('./chunk-CNNNWLTX.cjs');
5
5
 
6
6
 
7
7
 
8
8
  Object.defineProperty(exports, "QaStudio", {
9
9
  enumerable: true,
10
- get: function () { return chunkCOQKFGKZ_cjs.Qapture; }
10
+ get: function () { return chunkCNNNWLTX_cjs.Qapture; }
11
11
  });
12
12
  Object.defineProperty(exports, "Qapture", {
13
13
  enumerable: true,
14
- get: function () { return chunkCOQKFGKZ_cjs.Qapture; }
14
+ get: function () { return chunkCNNNWLTX_cjs.Qapture; }
15
15
  });
16
16
  Object.defineProperty(exports, "initQaStudio", {
17
17
  enumerable: true,
18
- get: function () { return chunkCOQKFGKZ_cjs.initQaStudio; }
18
+ get: function () { return chunkCNNNWLTX_cjs.initQaStudio; }
19
19
  });
20
20
  //# sourceMappingURL=next.cjs.map
21
21
  //# sourceMappingURL=next.cjs.map
package/dist/next.js CHANGED
@@ -1,4 +1,4 @@
1
1
  "use client";
2
- export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-FBFESAG6.js';
2
+ export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-NM3IDVEL.js';
3
3
  //# sourceMappingURL=next.js.map
4
4
  //# sourceMappingURL=next.js.map
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkCOQKFGKZ_cjs = require('./chunk-COQKFGKZ.cjs');
3
+ var chunkCNNNWLTX_cjs = require('./chunk-CNNNWLTX.cjs');
4
4
 
5
5
  // src/standalone.ts
6
6
  if (typeof window !== "undefined" && typeof customElements !== "undefined" && !customElements.get("qapture-widget")) {
@@ -38,7 +38,7 @@ if (typeof window !== "undefined" && typeof customElements !== "undefined" && !c
38
38
 
39
39
  Object.defineProperty(exports, "initQaStudio", {
40
40
  enumerable: true,
41
- get: function () { return chunkCOQKFGKZ_cjs.initQaStudio; }
41
+ get: function () { return chunkCNNNWLTX_cjs.initQaStudio; }
42
42
  });
43
43
  //# sourceMappingURL=standalone.cjs.map
44
44
  //# sourceMappingURL=standalone.cjs.map
@@ -1,4 +1,4 @@
1
- export { initQaStudio } from './chunk-FBFESAG6.js';
1
+ export { initQaStudio } from './chunk-NM3IDVEL.js';
2
2
 
3
3
  // src/standalone.ts
4
4
  if (typeof window !== "undefined" && typeof customElements !== "undefined" && !customElements.get("qapture-widget")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qapture2",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Drop-in, AI-aware in-browser QA capture widget. A tester annotates the live app (element/region + auto-screenshot + note), tracks a graded testing journey, and exports a ZIP your own coding agent reads. Ships zero AI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -86,6 +86,7 @@
86
86
  "selector-smoke": "node scripts/selector-smoke.mjs",
87
87
  "capture-timeout-smoke": "node scripts/capture-timeout-smoke.mjs",
88
88
  "fs-sync-smoke": "node scripts/fs-sync-smoke.mjs",
89
+ "frame-calibration-smoke": "node scripts/frame-calibration-smoke.mjs",
89
90
  "standalone-smoke": "node scripts/standalone-smoke.mjs",
90
91
  "browser-test": "node scripts/browser-test.mjs",
91
92
  "capture-accuracy-test": "node scripts/capture-accuracy-test.mjs",
@@ -94,7 +95,7 @@
94
95
  "loop-features-test": "node scripts/loop-features-test.mjs",
95
96
  "walk-test": "node scripts/walk-test.mjs",
96
97
  "modern-css-test": "node scripts/modern-css-test.mjs",
97
- "verify": "npm run typecheck && npm run typecheck:bin && npm run build && npm run smoke && npm run export-smoke && npm run cli-smoke && npm run cli-detectors-smoke && npm run selector-smoke && npm run capture-timeout-smoke && npm run fs-sync-smoke && npm run standalone-smoke",
98
+ "verify": "npm run typecheck && npm run typecheck:bin && npm run build && npm run smoke && npm run export-smoke && npm run cli-smoke && npm run cli-detectors-smoke && npm run selector-smoke && npm run capture-timeout-smoke && npm run fs-sync-smoke && npm run frame-calibration-smoke && npm run standalone-smoke",
98
99
  "play": "npm --prefix playground run dev"
99
100
  },
100
101
  "engines": {