gipity 1.0.428 → 1.0.429

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.
@@ -2,12 +2,13 @@ import { Command, Option } from 'commander';
2
2
  import { mkdirSync, writeFileSync } from 'fs';
3
3
  import { dirname, join, resolve as resolvePath } from 'path';
4
4
  import { postForTarEntries } from '../api.js';
5
- import { getProjectRoot } from '../config.js';
5
+ import { getConfig, getProjectRoot } from '../config.js';
6
6
  import { getAuth } from '../auth.js';
7
7
  import { brand, bold, muted, success, warning } from '../colors.js';
8
8
  import { formatSize } from '../utils.js';
9
9
  import { run } from '../helpers/index.js';
10
10
  import { withSpinner } from '../progress.js';
11
+ import { uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
11
12
  /** `mobile` and `tablet` name a real handset/tablet rather than just a window size.
12
13
  * The server emulates it end to end - mobile user-agent, DPR, and crucially TOUCH,
13
14
  * so a page that gates its mobile UI on `'ontouchstart' in window` or
@@ -40,14 +41,36 @@ function printAuthLine(auth) {
40
41
  ? `${label('Auth')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
41
42
  : `${warning('Auth: session NOT established')}${auth.detail ? ` — ${auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
42
43
  }
43
- /** A failed --action still yields a screenshot - of the page the action never
44
+ /** A failed pre-capture script still yields a screenshot - of the page it never
44
45
  * touched. Silence there is the trap: the image looks plausible and the command
45
- * reports success, so the caller trusts a capture of the wrong state. Say so. */
46
- function printActionErrorLine(actionError) {
46
+ * reports success, so the caller trusts a capture of the wrong state. Say so
47
+ * and say WHICH half failed, since --wait-for and --action ride the same script:
48
+ * a gate that never matched is a different problem (and a different fix) from a
49
+ * click that threw. */
50
+ export function printActionErrorLine(actionError) {
47
51
  if (!actionError)
48
52
  return;
53
+ if (/wait-for:/.test(actionError)) {
54
+ console.log(`${warning('⚠ --wait-for never matched:')} ${actionError.replace(/^EvalError:\s*/, '')} ${muted('(the image below is the state the page WAS in when the gate gave up)')}`);
55
+ return;
56
+ }
49
57
  console.log(`${warning('⚠ --action failed:')} ${actionError} ${muted('(this image shows the page BEFORE the action ran)')}`);
50
58
  }
59
+ /** The browser sandbox has one wall-clock budget for the entire capture, and
60
+ * --action's own runtime spends it. When the sandbox times out on a run that
61
+ * passed --action, the server's message ("platform-side failure, the page was
62
+ * never reached") is the whole story only if the action was cheap — so append
63
+ * the lever the caller actually has. Exported for tests. */
64
+ export function augmentSandboxTimeout(message) {
65
+ if (!/did not respond within/i.test(message))
66
+ return message;
67
+ return (`${message}\n` +
68
+ `A pre-capture script was set (--action and/or the --wait-for gate), and its runtime is spent INSIDE ` +
69
+ `that same sandbox budget — a script that waits on slow work (a WASM/model download, a network fetch, ` +
70
+ `a long animation) can exhaust the budget on its own. Keep --action to the interaction itself (a click, ` +
71
+ `a keypress), let the page do the slow work on load, and absorb that with --wait <ms> or a tighter ` +
72
+ `--wait-for '<selector>' gate instead.`);
73
+ }
51
74
  function fmtPerformance(p) {
52
75
  const parts = [
53
76
  `TTFB ${fmtMs(p.ttfb)}`,
@@ -74,13 +97,15 @@ function dimSuffix(vp) {
74
97
  const dpr = vp.deviceScaleFactor ?? 1;
75
98
  return dpr === 1 ? `${vp.width}x${vp.height}` : `${vp.width}x${vp.height}@${dpr}`;
76
99
  }
77
- /** Default screenshot directory: `<project-root>/.gipity/screenshots`, falling
78
- * back to `./.gipity/screenshots` in one-off mode (no linked project). `.gipity/`
79
- * is sync-ignored, so these verification artifacts never sync to Gipity or
80
- * deploy to the CDN - and they stay out of the project root. */
100
+ /** Default screenshot directory: `<project-root>/screenshots`, falling back
101
+ * to `./screenshots` in one-off mode (no linked project). Screenshots are
102
+ * part of the project's build history: the dir syncs to Gipity (the server
103
+ * also persists captures to VFS `screenshots/` directly same names, so
104
+ * sync reconciles by content hash) but is excluded from every deploy
105
+ * server-side. Timestamped filenames make the history browsable. */
81
106
  function defaultScreenshotDir() {
82
107
  const root = getProjectRoot();
83
- return join(root ?? '.', '.gipity', 'screenshots');
108
+ return join(root ?? '.', 'screenshots');
84
109
  }
85
110
  /** `yyyy-mm-dd_hh-mm-ss` per the repo timestamp convention - sorts chronologically,
86
111
  * filesystem-safe. One stamp per invocation; viewport suffixes keep multi-shot
@@ -155,23 +180,60 @@ function appendOption(value, previous = []) {
155
180
  // the common guesses as hidden decoys (taking a value, so they swallow the
156
181
  // script) and redirect precisely — same pattern as `page eval`'s JS_DECOY_FLAGS.
157
182
  const ACTION_DECOY_FLAGS = ['--eval', '--js', '--javascript', '--script', '--code', '--exec'];
183
+ /** A capture is worthless if it fires before the app reaches the state you meant
184
+ * to photograph, and the only lever used to be a blind millisecond delay — so a
185
+ * camera/vision app got screenshotted with a guessed duration (`--wait 22000`).
186
+ * `--wait-for '<selector>'` is the same deterministic gate `page eval` and
187
+ * `page inspect` take, and it runs here as the head of the pre-capture script:
188
+ * poll for the element, then let any --action run. Timing out THROWS, so the
189
+ * server reports it (the shot still happens) instead of silently handing back a
190
+ * picture of the wrong moment. Exported for tests. */
191
+ export function buildWaitForGate(selector, timeoutMs) {
192
+ const sel = JSON.stringify(selector);
193
+ return (`await (async () => { const __t0 = Date.now(); ` +
194
+ `while (Date.now() - __t0 < ${timeoutMs}) { ` +
195
+ `try { if (document.querySelector(${sel})) return; } catch (e) { throw new Error('wait-for: invalid selector ' + ${sel}); } ` +
196
+ `await new Promise((r) => setTimeout(r, 100)); } ` +
197
+ `throw new Error('wait-for: nothing matched ' + ${sel} + ' within ${timeoutMs}ms — the page never reached that state (raise --wait-timeout, fix the selector, or check the app actually gets there headlessly)'); })();`);
198
+ }
199
+ /** Max ms the selector gate may wait. The gate runs inside the capture's sandbox
200
+ * exec budget (page load + delay + script + settle + render), and 15s is what
201
+ * that budget actually covers — asking for more would blow the whole capture and
202
+ * come back as "the browser sandbox did not respond", blaming the platform for a
203
+ * wait the CLI accepted. A state that takes longer than this to appear is not a
204
+ * screenshot problem: gate it on `page eval --wait-for` (30s) and read it there. */
205
+ export const WAIT_FOR_MAX_MS = 15_000;
206
+ export const WAIT_FOR_DEFAULT_MS = 15_000;
207
+ /** The server's cap on the post-load delay. Over it, the request is rejected by
208
+ * schema validation with no mention of the flag — clamp and explain instead. */
209
+ export const MAX_POST_LOAD_DELAY_MS = 30_000;
210
+ /** A camera app has nothing to photograph 1s after load: getUserMedia has to come
211
+ * up and the vision model (WASM + weights) has to download before the app can
212
+ * draw a single box or label. Same window `page eval --camera` takes — without it
213
+ * every camera screenshot is a picture of a loading screen, and the caller is
214
+ * left guessing a duration. */
215
+ export const CAMERA_DEFAULT_DELAY_MS = 15_000;
158
216
  export const pageScreenshotCommand = new Command('screenshot')
159
217
  .description('Screenshot a web page')
160
218
  .argument('<url>', 'URL to screenshot')
161
- // No commander default: a default here makes opts.postLoadDelay always set,
162
- // so the `?? opts.wait` merge below would never see the --wait alias. Default
163
- // is applied in the merge instead.
164
- .option('--post-load-delay <ms>', 'Delay after DOMContentLoaded before capture, in ms (default: 1000)')
219
+ // No commander default: a default here would make the value always set, so the
220
+ // merge below could not tell "caller chose a delay" from "nobody did" — which
221
+ // is what --camera's model-load default and the --wait-for gate both hinge on.
222
+ .option('--wait <ms>', `Sleep this many ms after DOMContentLoaded before capturing (default 1000, max ${MAX_POST_LOAD_DELAY_MS}). With --camera it defaults to ${CAMERA_DEFAULT_DELAY_MS} so the vision model is up. Same flag as page eval/inspect.`)
223
+ .option('--wait-for <selector>', `Wait until this CSS selector appears before capturing, then capture (deterministic - beats guessing a --wait). Same flag as page eval/inspect. Gate on what you are photographing ('[data-vision="ready"]', '#verdict:not(:empty)'). Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`)
224
+ .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (default ${WAIT_FOR_DEFAULT_MS}, max ${WAIT_FOR_MAX_MS}). Timing out is reported - the capture still happens, so you see the state it got stuck in.`)
165
225
  .option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs as an async function body, so const/await and app-relative import(\'./…\') work. Runs after the post-load delay, then settles again before the shot. If it throws, the capture still happens and the failure is reported.')
166
226
  .option('--full', 'Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.')
167
227
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
168
228
  .option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
169
229
  .option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
170
230
  .option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
171
- .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)')
231
+ .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly. The video feed is a built-in test pattern — to capture what the app does with a REAL frame (a hand, a face, an object), use --camera <path> instead.')
232
+ .option('--camera <path>', 'Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser\'s WEBCAM feed, then capture — so the shot shows the app reacting to a frame you chose (detected gesture, boxes, labels). Implies --fake-media.')
172
233
  .option('--auth', 'Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
234
+ .option('--ephemeral', 'Skip the project screenshot history: do not persist this capture to Gipity (screenshots/ in the project). Local file is still written.')
173
235
  .option('--json', 'Output JSON metadata instead of a friendly summary')
174
- .addOption(new Option('--wait <ms>', 'Alias for --post-load-delay').hideHelp())
236
+ .addOption(new Option('--post-load-delay <ms>', 'Alias for --wait').hideHelp())
175
237
  // `--full-page` is the Puppeteer/Playwright name for this (their `fullPage`),
176
238
  // so agents reach for it by reflex. Accept it as a hidden alias for `--full`
177
239
  // rather than reject it as an unknown option and send them on a --help detour.
@@ -184,13 +246,41 @@ export const pageScreenshotCommand = new Command('screenshot')
184
246
  pageScreenshotCommand.error(`error: ${decoy} is not a flag on screenshot — use --action "<js>" to run JavaScript in the page ` +
185
247
  `before the capture, e.g. gipity page screenshot "<url>" --action "document.getElementById('play').click()"`);
186
248
  }
187
- // --wait is a hidden alias for --post-load-delay (agents reach for it because
188
- // sibling `page inspect`/`eval` name the flag --wait). Canonical name wins if
189
- // both given; fall back to the 1000ms default when neither is set.
190
- const delayRaw = opts.postLoadDelay ?? opts.wait ?? '1000';
191
- const postLoadDelayMs = delayRaw !== undefined ? parseInt(String(delayRaw), 10) : undefined;
192
- if (postLoadDelayMs !== undefined && (!Number.isFinite(postLoadDelayMs) || postLoadDelayMs < 0)) {
193
- throw new Error('--post-load-delay must be a non-negative integer (ms)');
249
+ // --wait is the canonical name (it is what `page inspect`/`page eval` call it);
250
+ // --post-load-delay stays as a hidden alias. Whether the caller named EITHER is
251
+ // what decides the defaults below, so keep the raw "was it set" signal.
252
+ const delayRaw = opts.wait ?? opts.postLoadDelay;
253
+ const chosenDelay = delayRaw !== undefined;
254
+ let postLoadDelayMs = chosenDelay ? parseInt(String(delayRaw), 10) : 1000;
255
+ if (!Number.isFinite(postLoadDelayMs) || postLoadDelayMs < 0) {
256
+ throw new Error('--wait must be a non-negative integer (ms)');
257
+ }
258
+ if (postLoadDelayMs > MAX_POST_LOAD_DELAY_MS) {
259
+ console.error(warning(`--wait ${postLoadDelayMs}ms exceeds the ${MAX_POST_LOAD_DELAY_MS}ms cap — using ${MAX_POST_LOAD_DELAY_MS}ms. ` +
260
+ `Waiting longer is rarely the fix: gate on the state you want with --wait-for '<selector>' instead of guessing a duration.`));
261
+ postLoadDelayMs = MAX_POST_LOAD_DELAY_MS;
262
+ }
263
+ const waitForTimeoutRaw = opts.waitTimeout !== undefined ? parseInt(String(opts.waitTimeout), 10) : WAIT_FOR_DEFAULT_MS;
264
+ if (!Number.isFinite(waitForTimeoutRaw) || waitForTimeoutRaw < 0) {
265
+ throw new Error('--wait-timeout must be a non-negative integer (ms)');
266
+ }
267
+ const waitForTimeoutMs = Math.min(waitForTimeoutRaw, WAIT_FOR_MAX_MS);
268
+ if (waitForTimeoutRaw > WAIT_FOR_MAX_MS) {
269
+ console.error(warning(`--wait-timeout ${waitForTimeoutRaw}ms exceeds the ${WAIT_FOR_MAX_MS}ms the capture's browser budget covers — using ${WAIT_FOR_MAX_MS}ms. ` +
270
+ `A state that takes longer than that to appear is not a screenshot problem: watch for it with ` +
271
+ `\`gipity page eval <url> --wait-for '<selector>' --wait-timeout 30000\` (a wider budget), then capture it.`));
272
+ }
273
+ if (opts.waitTimeout !== undefined && !opts.waitFor) {
274
+ throw new Error("--wait-timeout only means something with --wait-for '<selector>' (it bounds that gate)");
275
+ }
276
+ // A camera app is a loading screen at the 1s default: the model has to come up
277
+ // first. Give it the same window `page eval --camera` takes, unless the caller
278
+ // said otherwise — either with their own --wait, or by gating on --wait-for,
279
+ // which ends the moment the app is actually ready.
280
+ if (opts.camera && !chosenDelay && !opts.waitFor) {
281
+ postLoadDelayMs = CAMERA_DEFAULT_DELAY_MS;
282
+ console.error(muted(`--camera: waiting ${CAMERA_DEFAULT_DELAY_MS / 1000}s before the capture so the camera and the app's vision ` +
283
+ `model finish loading. Override with --wait <ms>, or use --wait-for '<ready-selector>' to shoot the moment it's ready.`));
194
284
  }
195
285
  const deviceNames = splitCsv(opts.device);
196
286
  const viewportStrs = splitCsv(opts.viewport);
@@ -204,23 +294,79 @@ export const pageScreenshotCommand = new Command('screenshot')
204
294
  // Server defaults to 1280×720 when viewports is omitted - don't send it in
205
295
  // the no-flag case so the filename stays unsuffixed (no viewport segment).
206
296
  const userSpecifiedViewports = customViewports.length > 0;
297
+ // Filenames are decided before the request so the server can persist the
298
+ // captures to the project VFS under the SAME names the local files get —
299
+ // the local screenshots/ dir and the VFS screenshot history stay 1:1 and
300
+ // sync reconciles them by content hash instead of duplicating.
301
+ const slug = slugFromUrl(url);
302
+ const ts = timestampSlug();
303
+ const shotName = (vp) => defaultFilename(slug, ts, vp && userSpecifiedViewports ? dimSuffix(vp) : undefined);
304
+ const names = userSpecifiedViewports ? customViewports.map(shotName) : [shotName()];
305
+ const projectGuid = getConfig()?.projectGuid;
306
+ const save = !opts.ephemeral && projectGuid ? { project_guid: projectGuid, names } : undefined;
307
+ // The webcam frame is hosted in the project's public file store for the
308
+ // browser container to fetch, so it needs a linked project. Validate the
309
+ // file locally first — a bad file type costs one instant error, not an
310
+ // upload plus an opaque browser-side failure.
311
+ let camera;
312
+ if (opts.camera) {
313
+ assertCameraFile(opts.camera);
314
+ if (!projectGuid)
315
+ throw new Error('--camera needs a linked project (the frame is hosted for the browser to fetch) — run `gipity link` first.');
316
+ console.log(muted(`Hosting camera feed ${opts.camera}…`));
317
+ camera = await uploadCameraFeed(projectGuid, opts.camera);
318
+ }
319
+ // The pre-capture script the server runs after the post-load delay: the
320
+ // --wait-for gate first (so the shot waits for the state, deterministically),
321
+ // then the caller's --action. Both are the same in-page primitive, so they
322
+ // compose into one script rather than needing a second server round-trip.
323
+ const preCapture = [
324
+ opts.waitFor ? buildWaitForGate(opts.waitFor, waitForTimeoutMs) : '',
325
+ opts.action ?? '',
326
+ ].filter(Boolean).join('\n');
207
327
  const body = {
208
328
  url,
209
329
  postLoadDelayMs,
210
330
  full: !!(opts.full || opts.fullPage),
211
331
  reloadBetween: opts.reloadBetween !== false,
212
332
  ...(userSpecifiedViewports ? { viewports: customViewports } : {}),
213
- ...(opts.fakeMedia ? { fakeMedia: true } : {}),
333
+ // A camera feed is played into the synthetic devices, so --camera implies
334
+ // --fake-media rather than failing on the pairing.
335
+ ...(opts.fakeMedia || camera ? { fakeMedia: true } : {}),
336
+ ...(camera ? { cameraUrl: camera.url } : {}),
214
337
  ...(opts.auth ? { auth: true } : {}),
215
- ...(opts.action ? { action: opts.action } : {}),
338
+ ...(preCapture ? { action: preCapture } : {}),
339
+ ...(save ? { save } : {}),
216
340
  };
217
341
  // Load + render across viewports runs server-side and can take many
218
342
  // seconds; animate the wait, then clear so the saved-files summary is the
219
343
  // result. JSON mode skips the spinner (shares stdout).
220
344
  const doShoot = () => postForTarEntries('/tools/browser/screenshot', body);
221
- const entries = opts.json
222
- ? await doShoot()
223
- : await withSpinner('Capturing…', doShoot, { done: null });
345
+ let entries;
346
+ try {
347
+ try {
348
+ entries = opts.json
349
+ ? await doShoot()
350
+ : await withSpinner('Capturing…', doShoot, { done: null });
351
+ }
352
+ catch (err) {
353
+ // The sandbox budget covers the WHOLE capture — load, --action, settle,
354
+ // render. The server's timeout text (rightly) says the page was never
355
+ // reached, which reads as "nothing you can do" and leaves a long-running
356
+ // --action looking innocent. Name it, so the retry is an informed one.
357
+ throw preCapture ? new Error(augmentSandboxTimeout(err.message)) : err;
358
+ }
359
+ }
360
+ finally {
361
+ if (camera) {
362
+ try {
363
+ await deleteFixture(projectGuid, camera.guid);
364
+ }
365
+ catch (err) {
366
+ console.error(warning(`⚠ Could not auto-delete camera feed "${camera.name}" (${camera.guid}) — still hosted at ${camera.url}: ${err.message}`));
367
+ }
368
+ }
369
+ }
224
370
  const metaEntry = entries.find((e) => e.name === 'meta.json');
225
371
  if (!metaEntry)
226
372
  throw new Error('Server response missing meta.json');
@@ -229,16 +375,12 @@ export const pageScreenshotCommand = new Command('screenshot')
229
375
  if (pngs.length !== meta.screenshots.length) {
230
376
  throw new Error(`Server returned ${pngs.length} PNGs but ${meta.screenshots.length} metadata entries`);
231
377
  }
232
- const slug = slugFromUrl(url);
233
- const ts = timestampSlug();
234
378
  const dir = defaultScreenshotDir();
235
379
  const savedFiles = [];
236
380
  for (let i = 0; i < pngs.length; i++) {
237
- const shot = meta.screenshots[i];
238
- const suffix = userSpecifiedViewports ? dimSuffix(shot.viewport) : undefined;
239
381
  const target = opts.output
240
382
  ? opts.output
241
- : join(dir, defaultFilename(slug, ts, suffix));
383
+ : join(dir, names[i] ?? shotName(meta.screenshots[i].viewport));
242
384
  // Create the target's parent dir so a `-o` path under a not-yet-existing
243
385
  // directory (e.g. .gipity/screenshots/home.png) writes cleanly instead of
244
386
  // failing with a raw ENOENT and forcing a manual `mkdir -p`.
@@ -271,10 +413,22 @@ export const pageScreenshotCommand = new Command('screenshot')
271
413
  size_bytes: s.screenshotSizeBytes,
272
414
  full_page: meta.full,
273
415
  phase: s.phase,
416
+ ...(s.vfs ? { gipity: s.vfs } : {}),
274
417
  })),
275
418
  }));
276
419
  return;
277
420
  }
421
+ /** One-line VFS-history status for a screenshot ("saved to Gipity" or why not). */
422
+ const printVfsLine = (s) => {
423
+ if (!s.vfs)
424
+ return;
425
+ if ('error' in s.vfs) {
426
+ console.log(`${warning('⚠ Gipity save failed:')} ${s.vfs.error} ${muted('(local file is fine)')}`);
427
+ }
428
+ else {
429
+ console.log(`${label('Gipity history')} ${s.vfs.path} ${muted('(synced, not deployed)')}`);
430
+ }
431
+ };
278
432
  if (meta.screenshots.length === 1) {
279
433
  const s = meta.screenshots[0];
280
434
  console.log(`${brand('Screenshot')} ${bold(url)}`);
@@ -295,6 +449,7 @@ export const pageScreenshotCommand = new Command('screenshot')
295
449
  if (s.width && s.height)
296
450
  console.log(`${label('Screenshot dims')} ${s.width} × ${s.height}`);
297
451
  console.log(`${label('Screenshot file')} ${success(savedFiles[0])}`);
452
+ printVfsLine(s);
298
453
  return;
299
454
  }
300
455
  console.log(`${brand('Loading')} ${bold(url)} ${muted(`once → ${meta.screenshots.length} viewports`)}`);
@@ -318,20 +473,21 @@ export const pageScreenshotCommand = new Command('screenshot')
318
473
  if (s.width && s.height)
319
474
  console.log(`${label('Screenshot dims')} ${s.width} × ${s.height}`);
320
475
  console.log(`${label('Screenshot file')} ${success(savedFiles[i])}`);
476
+ printVfsLine(s);
321
477
  }
322
478
  }));
323
479
  // Register the JS-intent guesses as hidden decoys (value-taking, so they swallow
324
480
  // the script) — the action turns any of them into the "--action" redirect above.
325
481
  for (const f of ACTION_DECOY_FLAGS)
326
482
  pageScreenshotCommand.addOption(new Option(`${f} <value>`).hideHelp());
327
- // `screenshot` captures the page AFTER load + settle (+ optional --action). There
328
- // is no scroll-to-a-position or wait-for-a-selector lever (agents reach for
329
- // --scroll/--selector and get an unknown-option detour) — but `--full` does walk
330
- // the page top→bottom→top before the shot so scroll-reveal content paints in.
331
- // State the supported levers right here, so the help (rendered on any bad flag,
332
- // and this 'after' block survives `| tail`/`| grep`) ends the hunt in one shot.
333
- // --action covers "click, then shoot"; --full + crop covers off-screen regions
334
- // (and triggers reveals); `page eval` reads data without a picture.
483
+ // `screenshot` captures the page AFTER load + settle (+ optional --wait-for gate
484
+ // and --action). There is no scroll-to-a-position lever (agents reach for
485
+ // --scroll and get an unknown-option detour) — but `--full` does walk the page
486
+ // top→bottom→top before the shot so scroll-reveal content paints in. State the
487
+ // supported levers right here, so the help (rendered on any bad flag, and this
488
+ // 'after' block survives `| tail`/`| grep`) ends the hunt in one shot.
489
+ // --wait-for covers "shoot once it's ready"; --action covers "click, then shoot";
490
+ // --full + crop covers off-screen regions; `page eval` reads data, no picture.
335
491
  pageScreenshotCommand.addHelpText('after', `
336
492
  Examples:
337
493
  gipity page screenshot "https://dev.gipity.ai/me/app/"
@@ -339,11 +495,21 @@ Examples:
339
495
  gipity page screenshot "https://dev.gipity.ai/me/app/" --device mobile,desktop
340
496
  gipity page screenshot "https://dev.gipity.ai/me/app/" \\
341
497
  --action "document.getElementById('play').click()" # capture an in-game frame
498
+ # Camera / vision app: play a real frame in as the webcam and shoot once the app
499
+ # says it's ready — same --camera / --wait-for flags as 'page eval', no guessed delay:
500
+ gipity page screenshot "https://dev.gipity.ai/me/app/" --camera fist.png \\
501
+ --wait-for '[data-vision="ready"]'
502
+
503
+ Waiting for the page to reach a state before the shot?
504
+ --wait-for '<selector>' gates the capture on the app's own signal (deterministic).
505
+ Reach for --wait <ms> only when there is nothing to gate on — a guessed duration
506
+ either shoots too early or wastes the difference.
342
507
 
343
508
  Capturing a state that needs an interaction (start a game, open a menu, dismiss a modal)?
344
- Use --action to run JS in the page before the shot — it fires after the post-load
345
- delay, then settles again so the result has painted. Do NOT hand-roll a 'page eval'
346
- that returns a base64 image: the eval result is capped (~16KB) and truncates the PNG.
509
+ Use --action to run JS in the page before the shot — it fires after the wait
510
+ (and after any --wait-for gate), then settles again so the result has painted. Do
511
+ NOT hand-roll a 'page eval' that returns a base64 image: the eval result is capped
512
+ (~16KB) and truncates the PNG.
347
513
 
348
514
  Capturing an off-screen region or reading element data?
349
515
  • --full captures the ENTIRE scrollable page (then crop to the region).
@@ -41,6 +41,7 @@ import { homedir } from 'os';
41
41
  import { join } from 'path';
42
42
  import { getDevice } from '../relay/state.js';
43
43
  import { deviceFetch } from '../relay/device-http.js';
44
+ import { ImageBlockRewriter } from '../relay/media-upload.js';
44
45
  import { getConfig } from '../config.js';
45
46
  import { parseTranscript, } from '../capture/sources/claude-code.js';
46
47
  const CAPTURE_DIR = join(homedir(), '.gipity', 'capture-state');
@@ -360,7 +361,14 @@ async function handleStopFamily(convGuid, hook, isSubagent, minIntervalMs = 0) {
360
361
  // Replay from top; server dedupes via the source_uuid unique index.
361
362
  result = parseTranscript(content, null);
362
363
  }
363
- const ok = await postEntries(convGuid, result.entries);
364
+ // Base64 image blocks (Read-of-image tool results) upload to VFS and
365
+ // become image_ref blocks — same no-inline-base64 chokepoint as the
366
+ // daemon's stream path. Content-hash storage keeps replays idempotent.
367
+ const rewriter = new ImageBlockRewriter(convGuid);
368
+ if (hook.cwd)
369
+ rewriter.setCwd(hook.cwd);
370
+ const entries = await rewriter.rewrite(result.entries);
371
+ const ok = await postEntries(convGuid, entries);
364
372
  if (ok) {
365
373
  // Stamp the flush time even when no new lines landed, so the throttle
366
374
  // above measures from the last attempt. Keep the watermark if unchanged.