@vosjs/cli 0.41.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,6 +90,19 @@ vos plan take --reuse # re-time that cut onto
90
90
 
91
91
  **The wall check.** Once the first navigation settles, and before a frame is captured, `record`, `create` and `--dry-run` ask whether the recorder landed where it was sent. A take that met a sign-in instead is refused with exit 4 and a sentence (`asked for /dashboard, landed on /login: no session for app.acme.com`): the asked URL answered 401 or 403, the recorder was sent to an identity provider or a sign-in path, or the page is a sign-in form (one password field, or a one-time-code field) rendered in place. A redirect somewhere else with no sign-in in sight, which is what a site that shows strangers a public page looks like, is refused under `--strict` and in a rehearsal, and said as a warning otherwise. The check runs before a re-record clears anything, so a session that expired since the last take never costs the footage it failed to replace. The way past a wall is a session: `--storage-state <file>`, minted from the test auth the project already has wherever that exists. `--allow-wall` records the page anyway (a video OF a sign-in page is a legitimate take), and the take's `meta.wall` and its digest then say so.
92
92
 
93
+ **What the frame shows.** Getting past a login puts the account's own data in the picture, and asking for a demo account does not hold that line: a person asked to sign in signs in as themselves. So the recorder looks. After the page opens and after every step it reads the text visible in the viewport and reports the KIND of thing it saw and where, never the string: an email address (one on `example.com` or a `.test`, `.example`, `.invalid` or `.localhost` domain is demo data and is not reported), something shaped like an API key or a JWT, a card number that passes the Luhn check, a masked card's visible tail. They land in `meta.exposures[]` (`step`, `kind`, `selector`, `rect`, `seen`), in the `record` and `create` done events, at the end of a rehearsal (before anything is recorded), as warnings in `vos validate <take>`, and in the digest's `take.exposures`. They warn; they do not fail a take, because a product may legitimately show addresses. The `selector` reaches that element and no other, so it can be pasted into a mask.
94
+
95
+ **`mask`** in `actions.json` hides a selector BEFORE the first frame is captured and keeps it hidden across navigations and re-renders, so the real value is never in a frame, never in the recording, never pushed:
96
+
97
+ ```json
98
+ "mask": [
99
+ { "selector": "nav > span", "as": "text", "text": "jane@acme.test" },
100
+ { "selector": ".card-number" }
101
+ ]
102
+ ```
103
+
104
+ `as: "blur"` (the default) blurs the element; `as: "text"` swaps its words, which reads as a product where a blur reads as a redaction. Use `text` for IDENTIFIERS (an email, a name, an account id), never for product copy or numbers: the video stays true to the product. A form control is always blurred, since writing into an input would change what the app submits. A mask whose selector reached no element hid nothing, so it fails `--strict` and a rehearsal (exit 2) and is named; `meta.masks[]` records each mask's `hits`.
105
+
93
106
  **Rehearse before you record.** `vos record … --dry-run` runs every step against the real page, in order, because a later selector usually exists only after an earlier click. Nothing is captured and nothing is written: the pointer lands instead of travelling, every pause is cut to a beat, and the take directory beside it keeps its footage, its cut and its script exactly as they were (a real re-record moves `doc.json` aside; a rehearsal does not). Selector lookups keep their whole timeout, so a miss here is a miss in the take. It prints each step with the rect it resolved, in capture px, which are the rects a pin or `vos callout --step` reads, and exits 2 on any miss or a first load that never settled. Add `--dry-run` to the exact command you were about to run; `--storage-state` and `--browser-arg=` apply to it too, and the `Next:` line it prints carries them, so the command it hands you records what it rehearsed.
94
107
 
95
108
  **Digest first.** `vos digest <take>` is how an agent sees a recording without reading the video. It writes `digest/digest.json`: one moment per thing the cursor track says mattered (click clusters, typing sessions, scroll runs, dwells, idle gaps, head, tail, and frame-diff scene changes), each with source and output extents, a normalized `focus` and `rect` you can copy into a zoom span, per-second `activity`, and the planners' `proposed` span ids; plus one footage frame and a crop around the target per moment, and `sheet.png`, the contact sheet. Read the JSON, then the sheet, then a crop only where you must decide. `vos validate` then warns when a zoom does not contain what was clicked under it, and `vos frames --at-moments` renders the composed output at every moment so a still and its footage crop share an id.
@@ -129,6 +129,24 @@ function validateActions(value) {
129
129
  }
130
130
  }
131
131
  }
132
+ if (obj.mask !== void 0) {
133
+ if (!Array.isArray(obj.mask)) {
134
+ errors.push("mask must be an array of { selector, as?, text? }");
135
+ } else {
136
+ obj.mask.forEach((raw, i) => {
137
+ const at2 = `mask[${i}]`;
138
+ const m = raw ?? {};
139
+ if (typeof m.selector !== "string" || !m.selector.trim())
140
+ errors.push(`${at2}: needs a selector`);
141
+ if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
142
+ errors.push(`${at2}: as must be "blur" or "text"`);
143
+ if (m.as === "text" && (typeof m.text !== "string" || !m.text))
144
+ errors.push(`${at2}: as "text" needs the text to show instead`);
145
+ if (m.as !== "text" && m.text !== void 0)
146
+ errors.push(`${at2}: text is only read with as "text"`);
147
+ });
148
+ }
149
+ }
132
150
  if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
133
151
  errors.push("steps must be a non-empty array");
134
152
  return errors;
@@ -6758,6 +6776,199 @@ var WALL_PROBE = `(() => {
6758
6776
  }
6759
6777
  })()`;
6760
6778
 
6779
+ // src/plugin/exposure.ts
6780
+ var EXPOSURE_MATCHERS_SRC = `(() => {
6781
+ const EMAIL = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,})/g
6782
+ const SAFE_HOST = /(^|\\.)example\\.(com|org|net)$|\\.(test|example|invalid|localhost)$/i
6783
+ const KEY = new RegExp([
6784
+ '\\\\b[srp]k_(?:live|test)_[A-Za-z0-9]{8,}',
6785
+ '\\\\bgh[pousr]_[A-Za-z0-9]{20,}',
6786
+ '\\\\bgithub_pat_[A-Za-z0-9_]{20,}',
6787
+ '\\\\bxox[abprs]-[A-Za-z0-9-]{10,}',
6788
+ '\\\\bAKIA[0-9A-Z]{16}\\\\b',
6789
+ '\\\\bAIza[0-9A-Za-z_-]{30,}',
6790
+ '\\\\bvos_(?:sk|rg|ho)_[A-Za-z0-9]{8,}',
6791
+ '\\\\beyJ[A-Za-z0-9_-]{8,}\\\\.eyJ[A-Za-z0-9_-]{8,}\\\\.[A-Za-z0-9_-]{8,}',
6792
+ ].join('|'))
6793
+ const CARD = /(?:^|[^0-9])((?:[0-9][ -]?){12,18}[0-9])(?![0-9])/g
6794
+ const TAIL = /[\\u2022\\u00b7*xX]{2,}[ -]?[0-9]{4}(?![0-9])/
6795
+ const luhn = (digits) => {
6796
+ let sum = 0, alt = false
6797
+ for (let i = digits.length - 1; i >= 0; i--) {
6798
+ let d = digits.charCodeAt(i) - 48
6799
+ if (alt) { d *= 2; if (d > 9) d -= 9 }
6800
+ sum += d; alt = !alt
6801
+ }
6802
+ return sum % 10 === 0
6803
+ }
6804
+ const kindsIn = (text) => {
6805
+ const kinds = []
6806
+ if (!text || text.length < 6) return kinds
6807
+ EMAIL.lastIndex = 0
6808
+ for (let m; (m = EMAIL.exec(text)); ) {
6809
+ if (!SAFE_HOST.test(m[1])) { kinds.push('email'); break }
6810
+ }
6811
+ if (KEY.test(text)) kinds.push('key')
6812
+ CARD.lastIndex = 0
6813
+ for (let m; (m = CARD.exec(text)); ) {
6814
+ const digits = m[1].replace(/[ -]/g, '')
6815
+ if (digits.length >= 13 && digits.length <= 19 && !/^0+$/.test(digits) && luhn(digits)) { kinds.push('card'); break }
6816
+ }
6817
+ if (TAIL.test(text)) kinds.push('card-tail')
6818
+ return kinds
6819
+ }
6820
+ return { kindsIn }
6821
+ })()`;
6822
+ var EXPOSURE_PROBE = `(() => {
6823
+ const { kindsIn } = ${EXPOSURE_MATCHERS_SRC}
6824
+ const vw = innerWidth, vh = innerHeight
6825
+ // A selector that reaches THIS element and no other, so it can be pasted
6826
+ // into "mask" as it is: climb until the path is unique, and say which
6827
+ // sibling wherever a tag-and-class alone would match several.
6828
+ const segment = (el) => {
6829
+ const tid = el.getAttribute('data-testid')
6830
+ if (tid) return el.tagName.toLowerCase() + '[data-testid="' + tid + '"]'
6831
+ if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id)) return el.tagName.toLowerCase() + '#' + el.id
6832
+ const cls = [...el.classList].filter((c) => /^[A-Za-z_-][\\w-]*$/.test(c)).slice(0, 2)
6833
+ let seg = el.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '')
6834
+ const p = el.parentElement
6835
+ if (p && [...p.children].filter((c) => c.matches(seg)).length > 1)
6836
+ seg += ':nth-child(' + ([...p.children].indexOf(el) + 1) + ')'
6837
+ return seg
6838
+ }
6839
+ const name = (el) => {
6840
+ const parts = []
6841
+ for (let cur = el; cur && cur !== document.body && cur !== document.documentElement; cur = cur.parentElement) {
6842
+ parts.unshift(segment(cur))
6843
+ let n = 2
6844
+ try { n = document.querySelectorAll(parts.join(' > ')).length } catch {}
6845
+ // a bare tag is unique HERE and nowhere else: give it its parent too
6846
+ const bare = parts.length === 1 && !/[#.\\[]/.test(parts[0])
6847
+ if ((n === 1 && !bare) || parts.length >= 5) break
6848
+ }
6849
+ return parts.join(' > ')
6850
+ }
6851
+ const seen = (el) => {
6852
+ if (el.closest('[data-vos-masked]')) return null
6853
+ const r = el.getBoundingClientRect()
6854
+ if (r.width < 2 || r.height < 2) return null
6855
+ if (r.bottom <= 0 || r.right <= 0 || r.top >= vh || r.left >= vw) return null
6856
+ const s = getComputedStyle(el)
6857
+ if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity === 0) return null
6858
+ return r
6859
+ }
6860
+ const out = [], keys = new Set()
6861
+ const add = (el, text) => {
6862
+ const kinds = kindsIn(text)
6863
+ if (!kinds.length) return
6864
+ const r = seen(el)
6865
+ if (!r) return
6866
+ const selector = name(el)
6867
+ for (const kind of kinds) {
6868
+ const k = kind + '|' + selector
6869
+ if (keys.has(k) || out.length >= 40) continue
6870
+ keys.add(k)
6871
+ out.push({ kind, selector, rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
6872
+ }
6873
+ }
6874
+ const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT)
6875
+ for (let n; (n = walker.nextNode()); ) {
6876
+ const el = n.parentElement
6877
+ if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(el.tagName)) continue
6878
+ add(el, n.nodeValue)
6879
+ }
6880
+ for (const el of document.querySelectorAll('input, textarea')) {
6881
+ if (/^(password|hidden|file|checkbox|radio)$/.test(el.type)) continue
6882
+ add(el, el.value)
6883
+ }
6884
+ const hits = {}
6885
+ for (const [sel, n] of Object.entries(window.__vosMaskHits || {})) hits[sel] = n
6886
+ return { exposures: out, maskHits: hits }
6887
+ })()`;
6888
+ function maskInitScript(masks) {
6889
+ const rules = masks.map((m) => ({
6890
+ selector: m.selector,
6891
+ as: m.as === "text" ? "text" : "blur",
6892
+ text: m.text ?? ""
6893
+ }));
6894
+ return `(() => {
6895
+ const RULES = ${JSON.stringify(rules)}
6896
+ const hits = (window.__vosMaskHits = window.__vosMaskHits || {})
6897
+ for (const r of RULES) hits[r.selector] = hits[r.selector] || 0
6898
+ const css = RULES.map((r) => r.selector + '[data-vos-masked="blur"]{filter:blur(7px) !important;user-select:none !important}').join('\\n')
6899
+ const paint = () => {
6900
+ const root = document.head || document.documentElement
6901
+ if (!root || document.getElementById('__vos-mask-style')) return
6902
+ const style = document.createElement('style')
6903
+ style.id = '__vos-mask-style'
6904
+ style.textContent = css
6905
+ root.appendChild(style)
6906
+ }
6907
+ const apply = () => {
6908
+ paint()
6909
+ for (const r of RULES) {
6910
+ let found
6911
+ try { found = document.querySelectorAll(r.selector) } catch { continue }
6912
+ if (found.length > hits[r.selector]) hits[r.selector] = found.length
6913
+ for (const el of found) {
6914
+ const form = /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)
6915
+ const mode = r.as === 'text' && !form ? 'text' : 'blur'
6916
+ if (el.getAttribute('data-vos-masked') !== mode) el.setAttribute('data-vos-masked', mode)
6917
+ if (mode !== 'text') continue
6918
+ const texts = []
6919
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
6920
+ for (let n; (n = w.nextNode()); ) texts.push(n)
6921
+ if (!texts.length) { if (el.textContent !== r.text) el.textContent = r.text; continue }
6922
+ texts.forEach((n, i) => { const want = i === 0 ? r.text : ''; if (n.nodeValue !== want) n.nodeValue = want })
6923
+ }
6924
+ }
6925
+ }
6926
+ new MutationObserver(apply).observe(document, { childList: true, subtree: true, characterData: true })
6927
+ document.addEventListener('DOMContentLoaded', apply)
6928
+ apply()
6929
+ })()`;
6930
+ }
6931
+ var ExposureLog = class {
6932
+ byKey = /* @__PURE__ */ new Map();
6933
+ maskHits = /* @__PURE__ */ new Map();
6934
+ constructor(masks = []) {
6935
+ for (const m of masks) this.maskHits.set(m.selector, 0);
6936
+ }
6937
+ add(step, scan) {
6938
+ if (!scan) return;
6939
+ for (const e of scan.exposures ?? []) {
6940
+ const key = `${e.kind}|${e.selector}`;
6941
+ const had = this.byKey.get(key);
6942
+ if (had) had.seen += 1;
6943
+ else this.byKey.set(key, { step, ...e, seen: 1 });
6944
+ }
6945
+ for (const [sel, n] of Object.entries(scan.maskHits ?? {})) {
6946
+ if (n > (this.maskHits.get(sel) ?? 0)) this.maskHits.set(sel, n);
6947
+ }
6948
+ }
6949
+ exposures() {
6950
+ return [...this.byKey.values()];
6951
+ }
6952
+ masks(rules) {
6953
+ return rules.map((m) => ({
6954
+ selector: m.selector,
6955
+ as: m.as === "text" ? "text" : "blur",
6956
+ hits: this.maskHits.get(m.selector) ?? 0
6957
+ }));
6958
+ }
6959
+ };
6960
+ var KIND_WORDS = {
6961
+ email: "an email address",
6962
+ key: "something shaped like an API key or token",
6963
+ card: "a card number",
6964
+ "card-tail": "a card's last digits"
6965
+ };
6966
+ function exposureLine(e) {
6967
+ const when = e.step < 0 ? "as the page opened" : `at step #${e.step}`;
6968
+ return `${KIND_WORDS[e.kind]} in ${e.selector} (${when}, ${e.rect.x},${e.rect.y} ${e.rect.w}\xD7${e.rect.h})`;
6969
+ }
6970
+ var EXPOSURE_ADVICE = 'The recording shows these. Record from a demo account, or hide them before the camera rolls with "mask" in actions.json: { "selector": "\u2026", "as": "text", "text": "demo@acme.test" } swaps the words, "as": "blur" blurs. Then re-record.';
6971
+
6761
6972
  // src/plugin/recorder.ts
6762
6973
  var realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
6763
6974
  var DRY_PAUSE_MS = 120;
@@ -6785,7 +6996,14 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6785
6996
  deviceScaleFactor: 1,
6786
6997
  ...opts.storageState ? { storageState: opts.storageState } : {}
6787
6998
  });
6999
+ const masks = actions.mask ?? [];
7000
+ if (masks.length) await context.addInitScript(maskInitScript(masks));
7001
+ const exposureLog = new ExposureLog(masks);
6788
7002
  const page = await context.newPage();
7003
+ const scanExposures = async (step) => {
7004
+ const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
7005
+ exposureLog.add(step, scan);
7006
+ };
6789
7007
  page.on("console", (m) => {
6790
7008
  if (m.type() === "error") log(` [page error] ${m.text()}`);
6791
7009
  });
@@ -6815,6 +7033,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6815
7033
  throw e;
6816
7034
  }
6817
7035
  }
7036
+ await scanExposures(-1);
6818
7037
  const cdp = await context.newCDPSession(page);
6819
7038
  const frames = [];
6820
7039
  let frameIdx = 0;
@@ -7074,6 +7293,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
7074
7293
  gestureMs,
7075
7294
  wallMs: now() - stepStart
7076
7295
  });
7296
+ await scanExposures(stepIdx);
7077
7297
  }
7078
7298
  if (!capped) await sleep(TRAILING_HOLD_MS);
7079
7299
  if (capped || capReached(now(), maxSeconds)) {
@@ -7104,7 +7324,9 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
7104
7324
  platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "windows" : "linux",
7105
7325
  producer: "cli",
7106
7326
  steps: stepSpans,
7107
- ...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {}
7327
+ ...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {},
7328
+ ...exposureLog.exposures().length ? { exposures: exposureLog.exposures() } : {},
7329
+ ...masks.length ? { masks: exposureLog.masks(masks) } : {}
7108
7330
  };
7109
7331
  const freezes = [];
7110
7332
  for (let i = 1; i < frames.length; i++) {
@@ -7140,6 +7362,8 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
7140
7362
  skipped,
7141
7363
  navTimeout,
7142
7364
  wall,
7365
+ exposures: exposureLog.exposures(),
7366
+ masks: exposureLog.masks(masks),
7143
7367
  freezes,
7144
7368
  freezePct,
7145
7369
  capped
@@ -10575,8 +10799,23 @@ function strictReason(rec) {
10575
10799
  const parts = [`${rec.skipped.length} skipped step(s)`];
10576
10800
  if (rec.navTimeout) parts.push("networkidle timeout");
10577
10801
  if (rec.capped) parts.push("stopped at --max-duration");
10802
+ const missed = maskMisses(rec);
10803
+ if (missed.length)
10804
+ parts.push(
10805
+ `${missed.length} mask(s) that hid nothing (${missed.join(", ")}): whatever they were for may be showing`
10806
+ );
10578
10807
  return parts.join(" + ");
10579
10808
  }
10809
+ function maskMisses(rec) {
10810
+ return (rec.masks ?? []).filter((m) => m.hits === 0).map((m) => m.selector);
10811
+ }
10812
+ function exposureNote(rec) {
10813
+ if (!rec.exposures.length) return "";
10814
+ return `
10815
+ EXPOSED in the frame (${rec.exposures.length}):
10816
+ ${rec.exposures.map((e) => ` - ${exposureLine(e)}`).join("\n")}
10817
+ ${EXPOSURE_ADVICE}`;
10818
+ }
10580
10819
  function takeBrowserArgs(multi) {
10581
10820
  const given = multi["browser-arg"];
10582
10821
  return (given ?? []).filter(Boolean);
@@ -10674,7 +10913,7 @@ async function cmdRecord(argv) {
10674
10913
  const what = s.selector ? ` ${s.selector}` : "";
10675
10914
  return ` ${s.skipped ? "\u2717" : "\u2713"} #${s.step}${s.id ? ` (${s.id})` : ""} ${s.do}${what}${s.skipped ? " NOT FOUND" : rect}${s.navigated ? " \u2192 navigated" : ""}`;
10676
10915
  });
10677
- const failed2 = rec.skipped.length > 0 || rec.navTimeout;
10916
+ const failed2 = rec.skipped.length > 0 || rec.navTimeout || maskMisses(rec).length > 0;
10678
10917
  const carried = [
10679
10918
  ...strFlag(flags, "storage-state") ? [`--storage-state ${strFlag(flags, "storage-state")}`] : [],
10680
10919
  ...browserArgs.map((a) => `--browser-arg=${a}`),
@@ -10688,11 +10927,13 @@ async function cmdRecord(argv) {
10688
10927
  seconds: +((Date.now() - started) / 1e3).toFixed(1),
10689
10928
  steps,
10690
10929
  skipped: rec.skipped,
10691
- navTimeout: rec.navTimeout
10930
+ navTimeout: rec.navTimeout,
10931
+ exposures: rec.exposures,
10932
+ masks: rec.masks
10692
10933
  },
10693
10934
  `${failed2 ? "REHEARSAL FAILED" : "Rehearsal passed"}: ${steps.length - rec.skipped.length}/${steps.length} steps resolved in ${((Date.now() - started) / 1e3).toFixed(1)}s${rec.navTimeout ? " (the first load never reached networkidle)" : ""}
10694
10935
  ${lines.join("\n")}
10695
- ${failed2 ? "Fix the script, rehearse again, then record." : `Rects are capture px (the step rects a pin or a callout reads). Next: vos record --actions ${actionsPath} --out ${nextOut}${carried ? ` ${carried}` : ""} --strict`}`
10936
+ ${maskMisses(rec).length ? `MASK hid nothing: ${maskMisses(rec).join(", ")} (the selector reached no element). ` : ""}${failed2 ? "Fix the script, rehearse again, then record." : `Rects are capture px (the step rects a pin or a callout reads). Next: vos record --actions ${actionsPath} --out ${nextOut}${carried ? ` ${carried}` : ""} --strict`}${exposureNote(rec)}`
10696
10937
  );
10697
10938
  return failed2 ? EXIT_USAGE : EXIT_OK;
10698
10939
  } finally {
@@ -10734,7 +10975,7 @@ ${lines.join("\n")}
10734
10975
  const plan = await planTake(outDir, { backdrop });
10735
10976
  const clicks = rec.events.filter((e) => e.type === "down").length;
10736
10977
  const strict = flags.strict === true;
10737
- const strictFail = strict && (rec.skipped.length > 0 || rec.navTimeout || rec.capped);
10978
+ const strictFail = strict && (rec.skipped.length > 0 || rec.navTimeout || rec.capped || maskMisses(rec).length > 0);
10738
10979
  const skippedNote = rec.skipped.length ? `
10739
10980
  SKIPPED ${rec.skipped.length} step(s): ${rec.skipped.map((s) => `#${s.step} ${s.do} ${s.selector}`).join(", ")}` : "";
10740
10981
  r.done(
@@ -10753,11 +10994,13 @@ ${lines.join("\n")}
10753
10994
  capped: rec.capped,
10754
10995
  pace: rec.pace,
10755
10996
  ...rec.wall ? { wall: rec.wall } : {},
10997
+ exposures: rec.exposures,
10998
+ ...rec.masks.length ? { masks: rec.masks } : {},
10756
10999
  ...strictFail ? { strictFailed: true } : {}
10757
11000
  },
10758
- strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}` : `Take ready: ${outDir}
11001
+ strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}${exposureNote(rec)}` : `Take ready: ${outDir}
10759
11002
  ${(rec.meta.durationMs / 1e3).toFixed(1)}s \xB7 ${rec.frames.length} frames \xB7 ${rec.events.length} cursor events \xB7 ${clicks} clicks \xB7 ${plan.doc.zoom.length} zoom spans planned \xB7 ${rec.freezePct}% frozen${rec.freezePct >= 25 ? " \u26A0 keep motion in frame or trim" : ""}${skippedNote}
10760
- Next: edit ${join18(outDir, "doc.json")} (optional), then: vos render ${outDir}`
11003
+ Next: edit ${join18(outDir, "doc.json")} (optional), then: vos render ${outDir}${exposureNote(rec)}`
10761
11004
  );
10762
11005
  return strictFail ? EXIT_USAGE : EXIT_OK;
10763
11006
  } finally {
@@ -10828,7 +11071,7 @@ async function cmdCreate2(argv) {
10828
11071
  await releaseFrames(paths, flags, r.log);
10829
11072
  r.event({ event: "phase", phase: "plan" });
10830
11073
  await planTake(outDir, { backdrop });
10831
- if (flags.strict === true && (rec.skipped.length > 0 || rec.navTimeout || rec.capped)) {
11074
+ if (flags.strict === true && (rec.skipped.length > 0 || rec.navTimeout || rec.capped || maskMisses(rec).length > 0)) {
10832
11075
  r.done(
10833
11076
  {
10834
11077
  take: outDir,
@@ -10872,10 +11115,12 @@ async function cmdCreate2(argv) {
10872
11115
  navTimeout: rec.navTimeout,
10873
11116
  freezes: rec.freezes,
10874
11117
  freezePct: rec.freezePct,
11118
+ exposures: rec.exposures,
11119
+ ...rec.masks.length ? { masks: rec.masks } : {},
10875
11120
  ...flags.draft === true ? { draft: true } : {}
10876
11121
  },
10877
11122
  `Created ${result.out} (${(result.bytes / 1024).toFixed(0)} KB, ${result.width}x${result.height}@${result.fps}, ${result.duration.toFixed(1)}s, ${result.zoomSpans} zoom spans, ${result.clicks} click effects${result.audio ? ", audio" : ""}${flags.draft === true ? ", DRAFT" : ""})
10878
- take: ${outDir} \u2014 inspect with: vos frames ${outDir} --at-zooms; edit doc.json and re-render with: vos render ${outDir}`
11123
+ take: ${outDir} \u2014 inspect with: vos frames ${outDir} --at-zooms; edit doc.json and re-render with: vos render ${outDir}${exposureNote(rec)}`
10879
11124
  );
10880
11125
  return EXIT_OK;
10881
11126
  } finally {
@@ -11440,12 +11685,14 @@ async function cmdDigest(argv) {
11440
11685
  outputDuration: d.take.outputDuration,
11441
11686
  hasCursor: d.take.hasCursor,
11442
11687
  ...d.take.wall ? { wall: d.take.wall } : {},
11688
+ ...d.take.exposures.length ? { exposures: d.take.exposures } : {},
11443
11689
  tokensEstimate: d.images.tokensEstimate,
11444
11690
  bytes: result.bytes
11445
11691
  },
11446
11692
  `Wrote ${result.file} \u2014 ${d.moments.length} moments (${Object.entries(
11447
11693
  kinds
11448
- ).map(([k, n]) => `${n} ${k}`).join(", ")}), ${d.images.full} frames + ${d.images.crop} crops` + (d.images.sheet ? `, sheet.png` : "") + ` \u2014 ~${d.images.tokensEstimate} image tokens to read them all` + (d.take.hasCursor ? "" : "\n no cursor track: moments are head/tail/scenes only \u2014 pace by activity, zoom only where the ask names a place") + (d.take.wall ? `
11694
+ ).map(([k, n]) => `${n} ${k}`).join(", ")}), ${d.images.full} frames + ${d.images.crop} crops` + (d.images.sheet ? `, sheet.png` : "") + ` \u2014 ~${d.images.tokensEstimate} image tokens to read them all` + (d.take.hasCursor ? "" : "\n no cursor track: moments are head/tail/scenes only \u2014 pace by activity, zoom only where the ask names a place") + (d.take.exposures.length ? `
11695
+ EXPOSED in the footage: ${d.take.exposures.map((e) => `${e.kind} in ${e.selector}`).join("; ")} \u2014 look at those frames before this take goes anywhere` : "") + (d.take.wall ? `
11449
11696
  WALL: asked for ${d.take.wall.asked}, recorded ${d.take.wall.landed} \u2014 this footage may be the wrong page; re-record with a session before cutting it` : "") + `
11450
11697
  Read digest.json, then the sheet, then a crop only where you must decide.`
11451
11698
  );
@@ -11709,6 +11956,18 @@ async function cmdValidate(argv) {
11709
11956
  );
11710
11957
  if (mismatch) warnings.push(mismatch);
11711
11958
  }
11959
+ for (const e of take.meta.exposures ?? [])
11960
+ warnings.push(`the recording shows ${exposureLine(e)}`);
11961
+ if (take.meta.exposures?.length) warnings.push(EXPOSURE_ADVICE);
11962
+ for (const m of take.meta.masks ?? [])
11963
+ if (m.hits === 0)
11964
+ warnings.push(
11965
+ `mask "${m.selector}" reached no element, so it hid nothing: whatever it was for may be showing`
11966
+ );
11967
+ if (take.meta.wall)
11968
+ warnings.push(
11969
+ `recorded past a wall: asked for ${take.meta.wall.asked}, landed on ${take.meta.wall.landed}. This footage may be the wrong page`
11970
+ );
11712
11971
  if (!take.doc) problems.push("missing doc.json (run plan)");
11713
11972
  if (take.doc) {
11714
11973
  const lint = lintDoc(take.doc);
@@ -11989,4 +12248,4 @@ export {
11989
12248
  verbHelp,
11990
12249
  run
11991
12250
  };
11992
- //# sourceMappingURL=chunk-4QVEV3HX.js.map
12251
+ //# sourceMappingURL=chunk-7RMULTDM.js.map