autokap 1.1.6 → 1.1.7

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/dist/browser.js CHANGED
@@ -120,6 +120,138 @@ async function withHelperTimeout(label, timeoutMs, work) {
120
120
  function isLikelyFontUrl(url) {
121
121
  return /\.(?:woff2?|ttf|otf)(?:[?#]|$)/i.test(url);
122
122
  }
123
+ /**
124
+ * In-page metric probe gate: measure the width of a sample string rendered with
125
+ * the inherited font-family vs a deliberately-divergent baseline (monospace by
126
+ * default, serif when the primary is monospace). When inherited width diverges
127
+ * from both the divergent baseline AND the sans-serif fallback, we have proof
128
+ * that a real custom face is in the paint — not a system fallback dressed up
129
+ * with size-adjust (e.g. next/font's "Geist Fallback").
130
+ */
131
+ async function runMetricFontGate(page, deadlineMs) {
132
+ return await page.evaluate((deadlineDuration) => new Promise((resolve) => {
133
+ try {
134
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
135
+ const sample = 'AutoKap Aa 0123456789';
136
+ const candidates = Array.from(document.querySelectorAll('h1,h2,h3,p,a,button,label,span,[data-ak]'));
137
+ const target = candidates.find((node) => {
138
+ const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim();
139
+ const rect = node.getBoundingClientRect();
140
+ return text.length >= 3 && rect.width > 10 && rect.height > 8;
141
+ }) ?? document.body;
142
+ const inheritedFF = getComputedStyle(target).fontFamily;
143
+ const isMonoPrimary = /\b(?:mono(?:space)?|courier|consolas|menlo|sf\s*mono)\b/i.test(inheritedFF);
144
+ // Monospace is guaranteed divergent from any proportional Geist-like face.
145
+ // For mono-primary targets, swap to serif as the divergent baseline.
146
+ const divergentBaseline = isMonoPrimary ? 'serif' : 'monospace';
147
+ const fallbackBaseline = isMonoPrimary ? 'monospace' : 'sans-serif';
148
+ const probe = document.createElement('span');
149
+ probe.setAttribute('aria-hidden', 'true');
150
+ probe.style.cssText = 'position:fixed;left:-99999px;top:0;visibility:hidden;white-space:nowrap;pointer-events:none;font-size:16px;font-weight:400;font-style:normal;';
151
+ probe.textContent = sample;
152
+ document.body.appendChild(probe);
153
+ const measure = (ff) => {
154
+ probe.style.fontFamily = ff;
155
+ void probe.offsetHeight;
156
+ return probe.getBoundingClientRect().width;
157
+ };
158
+ (async () => {
159
+ const deadline = performance.now() + deadlineDuration;
160
+ while (performance.now() < deadline) {
161
+ const inherited = measure(inheritedFF);
162
+ const divergent = measure(divergentBaseline);
163
+ const fallback = measure(fallbackBaseline);
164
+ // Inherited must diverge from the divergent baseline (proves a non-mono
165
+ // shape is in the paint) AND from the bare sans-serif fallback (proves
166
+ // it isn't the system fallback that next/font's adjustFontFallback
167
+ // tunes to match Geist within ~1px).
168
+ if (Math.abs(inherited - divergent) > 2 && Math.abs(inherited - fallback) > 0.5) {
169
+ probe.remove();
170
+ resolve('metric');
171
+ return;
172
+ }
173
+ await sleep(80);
174
+ }
175
+ probe.remove();
176
+ resolve(null);
177
+ })().catch(() => {
178
+ probe.remove();
179
+ resolve(null);
180
+ });
181
+ }
182
+ catch {
183
+ resolve(null);
184
+ }
185
+ }), deadlineMs).catch(() => null);
186
+ }
187
+ /**
188
+ * CDP-based gate via CSS.getPlatformFontsForNode. This is the authoritative
189
+ * oracle: Chromium reports the actual fonts used to paint each node, with
190
+ * isCustomFont=true marking fonts loaded via @font-face (vs system fonts).
191
+ * Resolves as soon as a non-Fallback custom face is in the paint of any
192
+ * representative text node.
193
+ */
194
+ async function runCdpFontGate(page, deadlineMs) {
195
+ let client = null;
196
+ try {
197
+ client = await page.context().newCDPSession(page);
198
+ await client.send('DOM.enable');
199
+ await client.send('CSS.enable');
200
+ const deadline = Date.now() + deadlineMs;
201
+ const SELECTORS = ['h1', 'h2', 'h3', 'p', 'button', 'a', '[data-ak]', 'body'];
202
+ while (Date.now() < deadline) {
203
+ const docRes = await client.send('DOM.getDocument', { depth: 1, pierce: true });
204
+ for (const selector of SELECTORS) {
205
+ const q = await client.send('DOM.querySelector', {
206
+ nodeId: docRes.root.nodeId,
207
+ selector,
208
+ });
209
+ if (!q.nodeId)
210
+ continue;
211
+ const fontsRes = await client.send('CSS.getPlatformFontsForNode', {
212
+ nodeId: q.nodeId,
213
+ });
214
+ const fonts = fontsRes.fonts ?? [];
215
+ const hasPrimary = fonts.some((f) => f.glyphCount > 0
216
+ && f.isCustomFont === true
217
+ && !/\bFallback\b/i.test(f.familyName));
218
+ if (hasPrimary)
219
+ return 'cdp';
220
+ }
221
+ await new Promise((r) => setTimeout(r, 100));
222
+ }
223
+ return null;
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ finally {
229
+ if (client)
230
+ await client.detach().catch(() => { });
231
+ }
232
+ }
233
+ /**
234
+ * Race metric probe (in-page) and CDP query (Node-side). Resolves with the
235
+ * first signal that converges, or 'timeout' if neither does within the
236
+ * deadline. Never throws — caller always proceeds.
237
+ */
238
+ async function gateOnPaintedFont(page, gateMs) {
239
+ const startedAt = Date.now();
240
+ return new Promise((resolve) => {
241
+ let settled = false;
242
+ const finish = (via) => {
243
+ if (settled)
244
+ return;
245
+ settled = true;
246
+ resolve({ via, elapsedMs: Date.now() - startedAt });
247
+ };
248
+ runMetricFontGate(page, gateMs).then((v) => { if (v === 'metric')
249
+ finish('metric'); }).catch(() => { });
250
+ runCdpFontGate(page, gateMs).then((v) => { if (v === 'cdp')
251
+ finish('cdp'); }).catch(() => { });
252
+ setTimeout(() => finish('timeout'), gateMs + 500);
253
+ });
254
+ }
123
255
  async function logFontDiagnostics(page, stage) {
124
256
  if (!isDebugEnabled())
125
257
  return;
@@ -193,6 +325,178 @@ async function logFontDiagnostics(page, stage) {
193
325
  logger.debug(`[capture] font diagnostics ${stage} failed: ${error instanceof Error ? error.message : String(error)}`);
194
326
  }
195
327
  }
328
+ async function collectPlatformFontDiagnostics(page) {
329
+ try {
330
+ const client = await page.context().newCDPSession(page);
331
+ try {
332
+ await client.send('DOM.enable');
333
+ await client.send('CSS.enable');
334
+ const documentResponse = await client.send('DOM.getDocument', { depth: 1, pierce: true });
335
+ const selectors = [
336
+ 'body',
337
+ 'h1',
338
+ 'h2',
339
+ 'p',
340
+ 'button',
341
+ 'a',
342
+ '[data-ak]',
343
+ 'span',
344
+ ];
345
+ const diagnostics = [];
346
+ for (const selector of selectors) {
347
+ const queryResponse = await client.send('DOM.querySelector', {
348
+ nodeId: documentResponse.root.nodeId,
349
+ selector,
350
+ });
351
+ if (!queryResponse.nodeId)
352
+ continue;
353
+ const text = await page.locator(selector).first().evaluate((node) => (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 80)).catch(() => '');
354
+ const fontsResponse = await client.send('CSS.getPlatformFontsForNode', {
355
+ nodeId: queryResponse.nodeId,
356
+ });
357
+ diagnostics.push({
358
+ selector,
359
+ text,
360
+ fonts: (fontsResponse.fonts ?? []).map((font) => ({
361
+ familyName: font.familyName,
362
+ postScriptName: font.postScriptName,
363
+ isCustomFont: font.isCustomFont,
364
+ glyphCount: font.glyphCount,
365
+ })),
366
+ });
367
+ if (diagnostics.length >= 6)
368
+ break;
369
+ }
370
+ return diagnostics;
371
+ }
372
+ finally {
373
+ await client.detach().catch(() => { });
374
+ }
375
+ }
376
+ catch (error) {
377
+ return { error: error instanceof Error ? error.message : String(error) };
378
+ }
379
+ }
380
+ async function logCaptureRenderDiagnostics(page, screenshot, options, stage) {
381
+ const viewport = page.viewportSize();
382
+ const deviceScaleFactor = normalizeDeviceScaleFactor(options.deviceScaleFactor);
383
+ const metadata = await sharp(screenshot).metadata().catch(() => null);
384
+ const screenshotSize = {
385
+ width: metadata?.width ?? 0,
386
+ height: metadata?.height ?? 0,
387
+ };
388
+ const expectedScreenshotSize = viewport
389
+ ? {
390
+ width: Math.round(viewport.width * deviceScaleFactor),
391
+ height: Math.round(viewport.height * deviceScaleFactor),
392
+ }
393
+ : null;
394
+ if (expectedScreenshotSize
395
+ && (Math.abs(screenshotSize.width - expectedScreenshotSize.width) > 1
396
+ || Math.abs(screenshotSize.height - expectedScreenshotSize.height) > 1)) {
397
+ logger.warn(`[capture] Screenshot pixel size mismatch: expected ` +
398
+ `${expectedScreenshotSize.width}x${expectedScreenshotSize.height} ` +
399
+ `(viewport ${viewport?.width}x${viewport?.height} @${deviceScaleFactor}x), ` +
400
+ `got ${screenshotSize.width}x${screenshotSize.height}. Text may appear rescaled.`);
401
+ }
402
+ if (!isDebugEnabled())
403
+ return;
404
+ try {
405
+ const [runtime, platformFonts] = await Promise.all([
406
+ page.evaluate(() => {
407
+ const normalizeFamily = (family) => family.trim().replace(/^['"]|['"]$/g, '');
408
+ const isGenericFamily = (family) => /^(serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-|emoji|math|fangsong|-apple-system)$/i.test(family);
409
+ const candidates = Array.from(document.querySelectorAll('h1,h2,h3,p,a,button,label,span,[data-ak]'));
410
+ const representative = candidates.find((node) => {
411
+ const rect = node.getBoundingClientRect();
412
+ const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim();
413
+ return text.length >= 3 && rect.width > 10 && rect.height > 8;
414
+ }) ?? document.body;
415
+ const style = getComputedStyle(representative);
416
+ const sample = 'AutoKap dashboard Aa 0123456789';
417
+ const families = style.fontFamily
418
+ .split(',')
419
+ .map(normalizeFamily)
420
+ .filter(Boolean);
421
+ const primaryFamily = families.find((family) => !/\bFallback\b/i.test(family) && !isGenericFamily(family)) ?? families[0] ?? 'sans-serif';
422
+ const probe = document.createElement('span');
423
+ probe.textContent = sample;
424
+ probe.style.position = 'absolute';
425
+ probe.style.left = '-99999px';
426
+ probe.style.top = '0';
427
+ probe.style.visibility = 'hidden';
428
+ probe.style.whiteSpace = 'nowrap';
429
+ probe.style.fontSize = style.fontSize;
430
+ probe.style.fontWeight = style.fontWeight;
431
+ probe.style.fontStyle = style.fontStyle;
432
+ probe.style.letterSpacing = style.letterSpacing;
433
+ probe.style.fontFeatureSettings = style.fontFeatureSettings;
434
+ probe.style.fontVariationSettings = style.fontVariationSettings;
435
+ document.body.appendChild(probe);
436
+ const measure = (fontFamily) => {
437
+ probe.style.fontFamily = fontFamily;
438
+ return Number(probe.getBoundingClientRect().width.toFixed(3));
439
+ };
440
+ const rect = representative.getBoundingClientRect();
441
+ const metrics = {
442
+ inherited: measure(style.fontFamily),
443
+ primary: measure(`"${primaryFamily}", sans-serif`),
444
+ system: measure('system-ui, sans-serif'),
445
+ sans: measure('sans-serif'),
446
+ };
447
+ probe.remove();
448
+ return {
449
+ url: location.href,
450
+ dpr: window.devicePixelRatio,
451
+ innerWidth: window.innerWidth,
452
+ innerHeight: window.innerHeight,
453
+ outerWidth: window.outerWidth,
454
+ outerHeight: window.outerHeight,
455
+ visualViewport: window.visualViewport
456
+ ? {
457
+ width: window.visualViewport.width,
458
+ height: window.visualViewport.height,
459
+ scale: window.visualViewport.scale,
460
+ }
461
+ : null,
462
+ representative: {
463
+ tagName: representative.tagName,
464
+ text: (representative.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 80),
465
+ width: Number(rect.width.toFixed(3)),
466
+ height: Number(rect.height.toFixed(3)),
467
+ fontFamily: style.fontFamily,
468
+ primaryFamily,
469
+ fontSize: style.fontSize,
470
+ fontWeight: style.fontWeight,
471
+ letterSpacing: style.letterSpacing,
472
+ lineHeight: style.lineHeight,
473
+ fontFeatureSettings: style.fontFeatureSettings,
474
+ fontVariationSettings: style.fontVariationSettings,
475
+ },
476
+ metricProbe: {
477
+ sample,
478
+ ...metrics,
479
+ inheritedMatchesPrimary: Math.abs(metrics.inherited - metrics.primary) < 0.01,
480
+ inheritedDiffersFromSystem: Math.abs(metrics.inherited - metrics.system) >= 0.01,
481
+ },
482
+ };
483
+ }),
484
+ collectPlatformFontDiagnostics(page),
485
+ ]);
486
+ logger.debug(`[capture] render diagnostics ${stage}: ${JSON.stringify({
487
+ viewport,
488
+ requestedDeviceScaleFactor: options.deviceScaleFactor ?? null,
489
+ normalizedDeviceScaleFactor: deviceScaleFactor,
490
+ screenshotSize,
491
+ expectedScreenshotSize,
492
+ runtime,
493
+ platformFonts,
494
+ })}`);
495
+ }
496
+ catch (error) {
497
+ logger.debug(`[capture] render diagnostics ${stage} failed: ${error instanceof Error ? error.message : String(error)}`);
498
+ }
499
+ }
196
500
  /**
197
501
  * Map a BCP-47 language tag to a Playwright-compatible locale string.
198
502
  * Playwright accepts both "fr" and "fr-FR". We normalize 2-char codes to their
@@ -694,8 +998,9 @@ export class Browser {
694
998
  // next/font and other `font-display: swap` setups can report the
695
999
  // FontFaceSet as "ready" while visible text is still painted with fallback.
696
1000
  // We force-load declared and currently computed families, verify the
697
- // primary rendered families with document.fonts.check(), then wait for a
698
- // committed repaint.
1001
+ // primary rendered families with document.fonts.check(), then run a paint
1002
+ // gate (metric probe + CDP) to confirm a real custom face is in the paint.
1003
+ const startedAt = Date.now();
699
1004
  await page.evaluate(() => Promise.race([
700
1005
  (async () => {
701
1006
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -758,6 +1063,22 @@ export class Browser {
758
1063
  })(),
759
1064
  new Promise((resolve) => setTimeout(resolve, 8000)),
760
1065
  ])).catch(() => { });
1066
+ // Paint gate: race in-page metric probe + CDP CSS.getPlatformFontsForNode.
1067
+ // FontFaceSet "loaded" can lie when Blink keeps a stale shaping cache after
1068
+ // a font swap (notably on CI Linux without GPU). The gate observes the
1069
+ // actual paint state, not the FontFaceSet state. Hard 4s ceiling, never
1070
+ // blocks the screenshot — on timeout we proceed and warn so
1071
+ // logCaptureRenderDiagnostics can flag the capture for review.
1072
+ const gate = await gateOnPaintedFont(page, 4000);
1073
+ // Triple rAF — software rasterization (CI Linux, --disable-gpu) sometimes
1074
+ // needs the extra commit before the screenshot pipeline reads the buffer.
1075
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))).catch(() => { });
1076
+ if (gate.via === 'timeout') {
1077
+ logger.warn(`[capture] font paint gate timed out after ${gate.elapsedMs}ms — capture may show fallback metrics`);
1078
+ }
1079
+ else if (isDebugEnabled()) {
1080
+ logger.debug(`[capture] font paint gate resolved via=${gate.via} elapsedMs=${gate.elapsedMs} totalMs=${Date.now() - startedAt}`);
1081
+ }
761
1082
  }
762
1083
  async takeScreenshot() {
763
1084
  const page = this.ensurePage();
@@ -766,7 +1087,9 @@ export class Browser {
766
1087
  await ensureCaptureHideStyles(page);
767
1088
  await this.waitForFontsBeforeScreenshot(page);
768
1089
  await logFontDiagnostics(page, 'before screenshot');
769
- return Buffer.from(await page.screenshot({ type: 'png', fullPage: false }));
1090
+ const screenshot = Buffer.from(await page.screenshot({ type: 'png', fullPage: false }));
1091
+ await logCaptureRenderDiagnostics(page, screenshot, this.options, 'after screenshot');
1092
+ return screenshot;
770
1093
  }
771
1094
  async takeScreenshotForAI(options = {}) {
772
1095
  const page = this.ensurePage();
@@ -29,6 +29,9 @@ import { normalizeAllowedOrigins, normalizeHttpOrigin, verifySignedExecutionProg
29
29
  const MAX_CLIP_CAPTURE_DEVICE_SCALE_FACTOR = 1;
30
30
  const FETCH_PROGRAM_MAX_ATTEMPTS = 4;
31
31
  const FETCH_PROGRAM_RETRY_DELAYS_MS = [1000, 3000, 5000];
32
+ const DEFAULT_SCREENSHOT_ARTIFACT_UPLOAD_CONCURRENCY = 4;
33
+ const DEFAULT_MEDIA_ARTIFACT_UPLOAD_CONCURRENCY = 2;
34
+ const MAX_ARTIFACT_UPLOAD_CONCURRENCY = 8;
32
35
  const HEALER_SYSTEM_PROMPT = 'You repair failed deterministic browser opcodes. Respond only with JSON.';
33
36
  // ── Main entry point ────────────────────────────────────────────────
34
37
  export async function runCapture(options) {
@@ -271,86 +274,22 @@ function sleep(ms) {
271
274
  }
272
275
  async function uploadResults(config, program, result) {
273
276
  const runId = randomUUID();
274
- const totalArtifacts = result.variantResults.reduce((sum, v) => sum + v.artifacts.length, 0);
275
- let uploadedCount = 0;
276
- // Upload artifacts
277
- for (const variant of result.variantResults) {
277
+ const artifactJobs = result.variantResults.flatMap((variant) => {
278
278
  const variantSpec = program.variants.find((entry) => entry.id === variant.variantId);
279
- for (const artifact of variant.artifacts) {
280
- const formData = new FormData();
281
- const filename = buildArtifactFilename(program.presetId, variant.variantId, artifact);
282
- uploadedCount += 1;
283
- const label = artifact.captureName ?? artifact.clipName ?? filename;
284
- logger.info(`[capture] Exporting capture ${uploadedCount}/${totalArtifacts}: ${label}`);
285
- formData.append('file', new Blob([new Uint8Array(artifact.buffer)], { type: artifact.mimeType }), filename);
286
- formData.append('presetId', program.presetId);
287
- formData.append('programVersion', String(program.programVersion));
288
- formData.append('compileFingerprint', program.compileFingerprint);
289
- formData.append('runId', runId);
290
- formData.append('variantId', variant.variantId);
291
- formData.append('targetId', variantSpec?.targetId ?? variant.variantId);
292
- formData.append('targetLabel', variantSpec?.targetLabel ?? variantSpec?.deviceFrame ?? variant.variantId);
293
- formData.append('mediaMode', artifact.mediaMode);
294
- formData.append('mimeType', artifact.mimeType);
295
- formData.append('captureType', artifact.captureType ?? 'fullpage');
296
- formData.append('captureUrl', artifact.captureUrl ?? program.baseUrl);
297
- formData.append('lang', variantSpec?.locale ?? 'en');
298
- formData.append('theme', variantSpec?.theme ?? 'light');
299
- if (variantSpec?.deviceFrame) {
300
- formData.append('deviceFrame', variantSpec.deviceFrame);
301
- }
302
- const requestedDeviceScaleFactor = variantSpec?.deviceScaleFactor ?? program.outputScale ?? 2;
303
- const deviceScaleFactor = artifact.mediaMode === 'clip' && Number.isFinite(requestedDeviceScaleFactor)
304
- ? Math.min(Number(requestedDeviceScaleFactor), MAX_CLIP_CAPTURE_DEVICE_SCALE_FACTOR)
305
- : requestedDeviceScaleFactor;
306
- if (Number.isFinite(deviceScaleFactor)) {
307
- formData.append('deviceScaleFactor', String(deviceScaleFactor));
308
- }
309
- formData.append('viewport', JSON.stringify(variantSpec?.viewport ?? null));
310
- formData.append('artifactPlan', JSON.stringify(program.artifactPlan));
311
- if (artifact.altText) {
312
- formData.append('altText', artifact.altText);
313
- }
314
- if (artifact.elementSelector) {
315
- formData.append('elementSelector', artifact.elementSelector);
316
- }
317
- if (artifact.captureId) {
318
- formData.append('captureId', artifact.captureId);
319
- }
320
- if (artifact.captureName) {
321
- formData.append('captureName', artifact.captureName);
322
- }
323
- if (artifact.clipId) {
324
- formData.append('clipId', artifact.clipId);
325
- }
326
- if (artifact.clipName) {
327
- formData.append('clipName', artifact.clipName);
328
- }
329
- if (artifact.stepDescription) {
330
- formData.append('stepDescription', artifact.stepDescription);
331
- }
332
- if (typeof artifact.stepIndex === 'number') {
333
- formData.append('stepIndex', String(artifact.stepIndex));
334
- }
335
- if (artifact.tabIconData) {
336
- formData.append('tabIcon', new Blob([new Uint8Array(artifact.tabIconData)], { type: artifact.tabIconMimeType ?? 'image/png' }), 'favicon');
337
- }
338
- if (typeof artifact.durationMs === 'number') {
339
- formData.append('durationMs', String(artifact.durationMs));
340
- }
341
- if (typeof artifact.trimStartMs === 'number') {
342
- formData.append('trimStartMs', String(artifact.trimStartMs));
343
- }
344
- const response = await fetch(`${config.apiBaseUrl}/api/cli/artifacts`, {
345
- method: 'POST',
346
- headers: { 'Authorization': `Bearer ${config.apiKey}` },
347
- body: formData,
348
- });
349
- if (!response.ok) {
350
- throw new Error(`artifact upload failed for ${variant.variantId}: ${await formatServerError(response, `${config.apiBaseUrl}/api/cli/artifacts`)}`);
351
- }
352
- }
279
+ return variant.artifacts.map((artifact) => ({
280
+ artifact,
281
+ variant,
282
+ variantSpec,
283
+ }));
284
+ });
285
+ const totalArtifacts = artifactJobs.length;
286
+ const artifactUploadConcurrency = resolveArtifactUploadConcurrency(program, totalArtifacts);
287
+ if (totalArtifacts > 1) {
288
+ logger.info(`[capture] Uploading ${totalArtifacts} capture artifacts with concurrency ${artifactUploadConcurrency}`);
353
289
  }
290
+ await runWithConcurrency(artifactJobs, artifactUploadConcurrency, async (job, index) => {
291
+ await uploadArtifact(config, program, runId, totalArtifacts, index + 1, job);
292
+ });
354
293
  // Strip binary buffers from artifacts before sending. The raw PNG/video
355
294
  // buffers were already uploaded via /api/cli/artifacts above, and the
356
295
  // server strips them again before persisting. Sending them in the JSON
@@ -405,6 +344,110 @@ async function uploadResults(config, program, result) {
405
344
  throw new Error(`telemetry upload failed: ${await formatServerError(telemetryResponse, `${config.apiBaseUrl}/api/cli/telemetry`)}`);
406
345
  }
407
346
  }
347
+ async function uploadArtifact(config, program, runId, totalArtifacts, uploadNumber, job) {
348
+ const { artifact, variant, variantSpec } = job;
349
+ const formData = new FormData();
350
+ const filename = buildArtifactFilename(program.presetId, variant.variantId, artifact);
351
+ const label = artifact.captureName ?? artifact.clipName ?? filename;
352
+ logger.info(`[capture] Exporting capture ${uploadNumber}/${totalArtifacts}: ${label}`);
353
+ formData.append('file', new Blob([new Uint8Array(artifact.buffer)], { type: artifact.mimeType }), filename);
354
+ formData.append('presetId', program.presetId);
355
+ formData.append('programVersion', String(program.programVersion));
356
+ formData.append('compileFingerprint', program.compileFingerprint);
357
+ formData.append('runId', runId);
358
+ formData.append('variantId', variant.variantId);
359
+ formData.append('targetId', variantSpec?.targetId ?? variant.variantId);
360
+ formData.append('targetLabel', variantSpec?.targetLabel ?? variantSpec?.deviceFrame ?? variant.variantId);
361
+ formData.append('mediaMode', artifact.mediaMode);
362
+ formData.append('mimeType', artifact.mimeType);
363
+ formData.append('captureType', artifact.captureType ?? 'fullpage');
364
+ formData.append('captureUrl', artifact.captureUrl ?? program.baseUrl);
365
+ formData.append('lang', variantSpec?.locale ?? 'en');
366
+ formData.append('theme', variantSpec?.theme ?? 'light');
367
+ if (variantSpec?.deviceFrame) {
368
+ formData.append('deviceFrame', variantSpec.deviceFrame);
369
+ }
370
+ const requestedDeviceScaleFactor = variantSpec?.deviceScaleFactor ?? program.outputScale ?? 2;
371
+ const deviceScaleFactor = artifact.mediaMode === 'clip' && Number.isFinite(requestedDeviceScaleFactor)
372
+ ? Math.min(Number(requestedDeviceScaleFactor), MAX_CLIP_CAPTURE_DEVICE_SCALE_FACTOR)
373
+ : requestedDeviceScaleFactor;
374
+ if (Number.isFinite(deviceScaleFactor)) {
375
+ formData.append('deviceScaleFactor', String(deviceScaleFactor));
376
+ }
377
+ formData.append('viewport', JSON.stringify(variantSpec?.viewport ?? null));
378
+ formData.append('artifactPlan', JSON.stringify(program.artifactPlan));
379
+ if (artifact.altText) {
380
+ formData.append('altText', artifact.altText);
381
+ }
382
+ if (artifact.elementSelector) {
383
+ formData.append('elementSelector', artifact.elementSelector);
384
+ }
385
+ if (artifact.captureId) {
386
+ formData.append('captureId', artifact.captureId);
387
+ }
388
+ if (artifact.captureName) {
389
+ formData.append('captureName', artifact.captureName);
390
+ }
391
+ if (artifact.clipId) {
392
+ formData.append('clipId', artifact.clipId);
393
+ }
394
+ if (artifact.clipName) {
395
+ formData.append('clipName', artifact.clipName);
396
+ }
397
+ if (artifact.stepDescription) {
398
+ formData.append('stepDescription', artifact.stepDescription);
399
+ }
400
+ if (typeof artifact.stepIndex === 'number') {
401
+ formData.append('stepIndex', String(artifact.stepIndex));
402
+ }
403
+ if (artifact.tabIconData) {
404
+ formData.append('tabIcon', new Blob([new Uint8Array(artifact.tabIconData)], { type: artifact.tabIconMimeType ?? 'image/png' }), 'favicon');
405
+ }
406
+ if (typeof artifact.durationMs === 'number') {
407
+ formData.append('durationMs', String(artifact.durationMs));
408
+ }
409
+ if (typeof artifact.trimStartMs === 'number') {
410
+ formData.append('trimStartMs', String(artifact.trimStartMs));
411
+ }
412
+ const response = await fetch(`${config.apiBaseUrl}/api/cli/artifacts`, {
413
+ method: 'POST',
414
+ headers: { 'Authorization': `Bearer ${config.apiKey}` },
415
+ body: formData,
416
+ });
417
+ if (!response.ok) {
418
+ throw new Error(`artifact upload failed for ${variant.variantId}: ${await formatServerError(response, `${config.apiBaseUrl}/api/cli/artifacts`)}`);
419
+ }
420
+ }
421
+ async function runWithConcurrency(items, concurrency, worker) {
422
+ if (items.length === 0) {
423
+ return;
424
+ }
425
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
426
+ let nextIndex = 0;
427
+ await Promise.all(Array.from({ length: workerCount }, async () => {
428
+ while (true) {
429
+ const index = nextIndex;
430
+ nextIndex += 1;
431
+ if (index >= items.length) {
432
+ return;
433
+ }
434
+ await worker(items[index], index);
435
+ }
436
+ }));
437
+ }
438
+ function resolveArtifactUploadConcurrency(program, totalArtifacts) {
439
+ if (totalArtifacts <= 1) {
440
+ return 1;
441
+ }
442
+ const envValue = process.env.AUTOKAP_UPLOAD_CONCURRENCY?.trim();
443
+ const parsedEnvValue = envValue ? Number(envValue) : NaN;
444
+ const requested = Number.isFinite(parsedEnvValue) && parsedEnvValue > 0
445
+ ? parsedEnvValue
446
+ : program.mediaMode === 'screenshot'
447
+ ? DEFAULT_SCREENSHOT_ARTIFACT_UPLOAD_CONCURRENCY
448
+ : DEFAULT_MEDIA_ARTIFACT_UPLOAD_CONCURRENCY;
449
+ return Math.min(totalArtifacts, MAX_ARTIFACT_UPLOAD_CONCURRENCY, Math.max(1, Math.floor(requested)));
450
+ }
408
451
  async function formatServerError(response, requestedUrl) {
409
452
  const contentType = response.headers?.get?.('content-type') ?? '';
410
453
  const rawBody = (await response.text()).trim();
package/dist/mockup.js CHANGED
@@ -555,6 +555,18 @@ export async function applyDeviceFrame(screenshot, deviceId, options) {
555
555
  const physicalContentW = Math.round(contentW * os);
556
556
  const physicalContentH = Math.round(contentH * os);
557
557
  console.log(`[mockup] resize target: ${physicalContentW}x${physicalContentH}`);
558
+ if (screenshotMeta.width
559
+ && screenshotMeta.height
560
+ && (Math.abs(screenshotMeta.width - physicalContentW) > 1
561
+ || Math.abs(screenshotMeta.height - physicalContentH) > 1)) {
562
+ const ratioX = physicalContentW / screenshotMeta.width;
563
+ const ratioY = physicalContentH / screenshotMeta.height;
564
+ console.warn(`[mockup] screenshot will be resampled: ` +
565
+ `${screenshotMeta.width}x${screenshotMeta.height} -> ` +
566
+ `${physicalContentW}x${physicalContentH} ` +
567
+ `(x=${ratioX.toFixed(4)}, y=${ratioY.toFixed(4)}). ` +
568
+ `Text metrics may appear distorted if the capture viewport does not match the mockup content area.`);
569
+ }
558
570
  // Sample edge colors from the ORIGINAL screenshot before resize.
559
571
  // Resizing (especially large downscales with fit:'fill') averages edge pixels,
560
572
  // producing grayish artifacts that make safe area fills look darker than intended.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",