autokap 1.1.6 → 1.1.8
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-pool.js +10 -0
- package/dist/browser.js +429 -3
- package/dist/cli-runner.js +121 -78
- package/dist/mockup.js +12 -0
- package/package.json +1 -1
package/dist/browser-pool.js
CHANGED
|
@@ -6,6 +6,16 @@ export const CHROMIUM_ARGS = [
|
|
|
6
6
|
'--no-sandbox',
|
|
7
7
|
'--disable-setuid-sandbox',
|
|
8
8
|
'--disable-dev-shm-usage',
|
|
9
|
+
// Disable font hinting to match macOS Core Text subpixel positioning.
|
|
10
|
+
// Linux Chromium defaults to "medium" hinting which pixel-snaps glyph
|
|
11
|
+
// advance widths — this widens text horizontally and shifts wrap points
|
|
12
|
+
// vs a Mac developer's local view. Validated via Docker repro on
|
|
13
|
+
// autokap.app: with default hinting "Vos screenshots en autopilote"
|
|
14
|
+
// wraps as "...screenshots en / autopilote", on Mac and with
|
|
15
|
+
// hinting=none it wraps as "...screenshots / en autopilote" (identical
|
|
16
|
+
// to local). Trade-off: minor blurriness at <12px sizes; acceptable for
|
|
17
|
+
// marketing screenshots where layout fidelity matters more.
|
|
18
|
+
'--font-render-hinting=none',
|
|
9
19
|
] : []),
|
|
10
20
|
// No GPU on headless servers
|
|
11
21
|
'--disable-gpu',
|
package/dist/browser.js
CHANGED
|
@@ -120,6 +120,172 @@ async function withHelperTimeout(label, timeoutMs, work) {
|
|
|
120
120
|
function isLikelyFontUrl(url) {
|
|
121
121
|
return /\.(?:woff2?|ttf|otf)(?:[?#]|$)/i.test(url);
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Strip C0/C1 control characters (except TAB/LF/CR) and DEL from text nodes
|
|
125
|
+
* before screenshot. macOS Core Text renders these as zero-width/invisible by
|
|
126
|
+
* default; Linux Skia + most webfonts render them as `.notdef` tofu boxes
|
|
127
|
+
* because the font's unicode-range claims coverage of U+0000-007F but the
|
|
128
|
+
* actual font file has no glyph for control characters. Examples seen in
|
|
129
|
+
* customer sites: `\x1B[32m...\x1B[0m` ANSI escapes embedded in mock terminal
|
|
130
|
+
* output, BOM-like markers in CMS-pasted text, etc.
|
|
131
|
+
*
|
|
132
|
+
* This normalizes Linux capture output to match what a developer sees on
|
|
133
|
+
* their Mac. Idempotent — safe to run once per screenshot.
|
|
134
|
+
*
|
|
135
|
+
* Preserves: U+0009 TAB, U+000A LF, U+000D CR (semantic whitespace).
|
|
136
|
+
* Strips: U+0000-0008, U+000B, U+000C, U+000E-001F, U+007F (DEL), U+0080-009F.
|
|
137
|
+
*/
|
|
138
|
+
async function stripInvisibleControlChars(page) {
|
|
139
|
+
await page.evaluate(() => {
|
|
140
|
+
const RE = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]', 'g');
|
|
141
|
+
const walk = (node) => {
|
|
142
|
+
if (node.nodeType === 3) {
|
|
143
|
+
const original = node.nodeValue ?? '';
|
|
144
|
+
if (original.length > 0 && RE.test(original)) {
|
|
145
|
+
node.nodeValue = original.replace(RE, '');
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (node.nodeType !== 1)
|
|
150
|
+
return;
|
|
151
|
+
for (const child of Array.from(node.childNodes))
|
|
152
|
+
walk(child);
|
|
153
|
+
};
|
|
154
|
+
walk(document.body);
|
|
155
|
+
}).catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* In-page metric probe gate: measure the width of a sample string rendered with
|
|
159
|
+
* the inherited font-family vs a deliberately-divergent baseline (monospace by
|
|
160
|
+
* default, serif when the primary is monospace). When inherited width diverges
|
|
161
|
+
* from both the divergent baseline AND the sans-serif fallback, we have proof
|
|
162
|
+
* that a real custom face is in the paint — not a system fallback dressed up
|
|
163
|
+
* with size-adjust (e.g. next/font's "Geist Fallback").
|
|
164
|
+
*/
|
|
165
|
+
async function runMetricFontGate(page, deadlineMs) {
|
|
166
|
+
return await page.evaluate((deadlineDuration) => new Promise((resolve) => {
|
|
167
|
+
try {
|
|
168
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
169
|
+
const sample = 'AutoKap Aa 0123456789';
|
|
170
|
+
const candidates = Array.from(document.querySelectorAll('h1,h2,h3,p,a,button,label,span,[data-ak]'));
|
|
171
|
+
const target = candidates.find((node) => {
|
|
172
|
+
const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
173
|
+
const rect = node.getBoundingClientRect();
|
|
174
|
+
return text.length >= 3 && rect.width > 10 && rect.height > 8;
|
|
175
|
+
}) ?? document.body;
|
|
176
|
+
const inheritedFF = getComputedStyle(target).fontFamily;
|
|
177
|
+
const isMonoPrimary = /\b(?:mono(?:space)?|courier|consolas|menlo|sf\s*mono)\b/i.test(inheritedFF);
|
|
178
|
+
// Monospace is guaranteed divergent from any proportional Geist-like face.
|
|
179
|
+
// For mono-primary targets, swap to serif as the divergent baseline.
|
|
180
|
+
const divergentBaseline = isMonoPrimary ? 'serif' : 'monospace';
|
|
181
|
+
const fallbackBaseline = isMonoPrimary ? 'monospace' : 'sans-serif';
|
|
182
|
+
const probe = document.createElement('span');
|
|
183
|
+
probe.setAttribute('aria-hidden', 'true');
|
|
184
|
+
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;';
|
|
185
|
+
probe.textContent = sample;
|
|
186
|
+
document.body.appendChild(probe);
|
|
187
|
+
const measure = (ff) => {
|
|
188
|
+
probe.style.fontFamily = ff;
|
|
189
|
+
void probe.offsetHeight;
|
|
190
|
+
return probe.getBoundingClientRect().width;
|
|
191
|
+
};
|
|
192
|
+
(async () => {
|
|
193
|
+
const deadline = performance.now() + deadlineDuration;
|
|
194
|
+
while (performance.now() < deadline) {
|
|
195
|
+
const inherited = measure(inheritedFF);
|
|
196
|
+
const divergent = measure(divergentBaseline);
|
|
197
|
+
const fallback = measure(fallbackBaseline);
|
|
198
|
+
// Inherited must diverge from the divergent baseline (proves a non-mono
|
|
199
|
+
// shape is in the paint) AND from the bare sans-serif fallback (proves
|
|
200
|
+
// it isn't the system fallback that next/font's adjustFontFallback
|
|
201
|
+
// tunes to match Geist within ~1px).
|
|
202
|
+
if (Math.abs(inherited - divergent) > 2 && Math.abs(inherited - fallback) > 0.5) {
|
|
203
|
+
probe.remove();
|
|
204
|
+
resolve('metric');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
await sleep(80);
|
|
208
|
+
}
|
|
209
|
+
probe.remove();
|
|
210
|
+
resolve(null);
|
|
211
|
+
})().catch(() => {
|
|
212
|
+
probe.remove();
|
|
213
|
+
resolve(null);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
resolve(null);
|
|
218
|
+
}
|
|
219
|
+
}), deadlineMs).catch(() => null);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* CDP-based gate via CSS.getPlatformFontsForNode. This is the authoritative
|
|
223
|
+
* oracle: Chromium reports the actual fonts used to paint each node, with
|
|
224
|
+
* isCustomFont=true marking fonts loaded via @font-face (vs system fonts).
|
|
225
|
+
* Resolves as soon as a non-Fallback custom face is in the paint of any
|
|
226
|
+
* representative text node.
|
|
227
|
+
*/
|
|
228
|
+
async function runCdpFontGate(page, deadlineMs) {
|
|
229
|
+
let client = null;
|
|
230
|
+
try {
|
|
231
|
+
client = await page.context().newCDPSession(page);
|
|
232
|
+
await client.send('DOM.enable');
|
|
233
|
+
await client.send('CSS.enable');
|
|
234
|
+
const deadline = Date.now() + deadlineMs;
|
|
235
|
+
const SELECTORS = ['h1', 'h2', 'h3', 'p', 'button', 'a', '[data-ak]', 'body'];
|
|
236
|
+
while (Date.now() < deadline) {
|
|
237
|
+
const docRes = await client.send('DOM.getDocument', { depth: 1, pierce: true });
|
|
238
|
+
for (const selector of SELECTORS) {
|
|
239
|
+
const q = await client.send('DOM.querySelector', {
|
|
240
|
+
nodeId: docRes.root.nodeId,
|
|
241
|
+
selector,
|
|
242
|
+
});
|
|
243
|
+
if (!q.nodeId)
|
|
244
|
+
continue;
|
|
245
|
+
const fontsRes = await client.send('CSS.getPlatformFontsForNode', {
|
|
246
|
+
nodeId: q.nodeId,
|
|
247
|
+
});
|
|
248
|
+
const fonts = fontsRes.fonts ?? [];
|
|
249
|
+
const hasPrimary = fonts.some((f) => f.glyphCount > 0
|
|
250
|
+
&& f.isCustomFont === true
|
|
251
|
+
&& !/\bFallback\b/i.test(f.familyName));
|
|
252
|
+
if (hasPrimary)
|
|
253
|
+
return 'cdp';
|
|
254
|
+
}
|
|
255
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
if (client)
|
|
264
|
+
await client.detach().catch(() => { });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Race metric probe (in-page) and CDP query (Node-side). Resolves with the
|
|
269
|
+
* first signal that converges, or 'timeout' if neither does within the
|
|
270
|
+
* deadline. Never throws — caller always proceeds.
|
|
271
|
+
*/
|
|
272
|
+
async function gateOnPaintedFont(page, gateMs) {
|
|
273
|
+
const startedAt = Date.now();
|
|
274
|
+
return new Promise((resolve) => {
|
|
275
|
+
let settled = false;
|
|
276
|
+
const finish = (via) => {
|
|
277
|
+
if (settled)
|
|
278
|
+
return;
|
|
279
|
+
settled = true;
|
|
280
|
+
resolve({ via, elapsedMs: Date.now() - startedAt });
|
|
281
|
+
};
|
|
282
|
+
runMetricFontGate(page, gateMs).then((v) => { if (v === 'metric')
|
|
283
|
+
finish('metric'); }).catch(() => { });
|
|
284
|
+
runCdpFontGate(page, gateMs).then((v) => { if (v === 'cdp')
|
|
285
|
+
finish('cdp'); }).catch(() => { });
|
|
286
|
+
setTimeout(() => finish('timeout'), gateMs + 500);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
123
289
|
async function logFontDiagnostics(page, stage) {
|
|
124
290
|
if (!isDebugEnabled())
|
|
125
291
|
return;
|
|
@@ -193,6 +359,242 @@ async function logFontDiagnostics(page, stage) {
|
|
|
193
359
|
logger.debug(`[capture] font diagnostics ${stage} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
194
360
|
}
|
|
195
361
|
}
|
|
362
|
+
async function collectPlatformFontDiagnostics(page) {
|
|
363
|
+
try {
|
|
364
|
+
// Step 1: in-page, find diverse visible text-bearing elements and compute
|
|
365
|
+
// a path-based CSS selector that uniquely identifies each. We sample
|
|
366
|
+
// across many tag types so badges/pills/labels (where font bugs hide) are
|
|
367
|
+
// covered — the previous version only captured h1/h2/p/button/a/body and
|
|
368
|
+
// missed all <span> content like the AssistantBadges in the hero.
|
|
369
|
+
const candidates = await page.evaluate(() => {
|
|
370
|
+
const computeSelector = (el) => {
|
|
371
|
+
const parts = [];
|
|
372
|
+
let cur = el;
|
|
373
|
+
let safety = 8;
|
|
374
|
+
while (cur && cur.tagName !== 'BODY' && cur.tagName !== 'HTML' && safety-- > 0) {
|
|
375
|
+
const tag = cur.tagName.toLowerCase();
|
|
376
|
+
const parent = cur.parentElement;
|
|
377
|
+
if (!parent)
|
|
378
|
+
break;
|
|
379
|
+
const sameTagSiblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
380
|
+
if (sameTagSiblings.length === 1) {
|
|
381
|
+
parts.unshift(tag);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const idx = sameTagSiblings.indexOf(cur) + 1;
|
|
385
|
+
parts.unshift(`${tag}:nth-of-type(${idx})`);
|
|
386
|
+
}
|
|
387
|
+
cur = parent;
|
|
388
|
+
}
|
|
389
|
+
return ['body', ...parts].join(' > ');
|
|
390
|
+
};
|
|
391
|
+
const TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'button', 'label', 'li', 'td', 'span', 'code', 'pre', 'kbd', 'small'];
|
|
392
|
+
const out = [];
|
|
393
|
+
const seen = new Set();
|
|
394
|
+
for (const tag of TAGS) {
|
|
395
|
+
if (out.length >= 25)
|
|
396
|
+
break;
|
|
397
|
+
const elements = Array.from(document.querySelectorAll(tag));
|
|
398
|
+
let perTagCount = 0;
|
|
399
|
+
for (const el of elements) {
|
|
400
|
+
if (perTagCount >= 3 || out.length >= 25)
|
|
401
|
+
break;
|
|
402
|
+
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
403
|
+
if (text.length < 3)
|
|
404
|
+
continue;
|
|
405
|
+
const rect = el.getBoundingClientRect();
|
|
406
|
+
if (rect.width < 4 || rect.height < 4)
|
|
407
|
+
continue;
|
|
408
|
+
const style = getComputedStyle(el);
|
|
409
|
+
if (style.visibility === 'hidden' || style.display === 'none')
|
|
410
|
+
continue;
|
|
411
|
+
const selector = computeSelector(el);
|
|
412
|
+
if (seen.has(selector))
|
|
413
|
+
continue;
|
|
414
|
+
seen.add(selector);
|
|
415
|
+
out.push({
|
|
416
|
+
selector,
|
|
417
|
+
tag,
|
|
418
|
+
classes: el.className.toString().slice(0, 120),
|
|
419
|
+
text: text.slice(0, 60),
|
|
420
|
+
fontFamily: style.fontFamily,
|
|
421
|
+
fontSize: style.fontSize,
|
|
422
|
+
fontWeight: style.fontWeight,
|
|
423
|
+
});
|
|
424
|
+
perTagCount++;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return out;
|
|
428
|
+
});
|
|
429
|
+
const client = await page.context().newCDPSession(page);
|
|
430
|
+
try {
|
|
431
|
+
await client.send('DOM.enable');
|
|
432
|
+
await client.send('CSS.enable');
|
|
433
|
+
const documentResponse = await client.send('DOM.getDocument', { depth: -1, pierce: true });
|
|
434
|
+
const diagnostics = [];
|
|
435
|
+
for (const candidate of candidates) {
|
|
436
|
+
if (diagnostics.length >= 20)
|
|
437
|
+
break;
|
|
438
|
+
try {
|
|
439
|
+
const queryResponse = await client.send('DOM.querySelector', {
|
|
440
|
+
nodeId: documentResponse.root.nodeId,
|
|
441
|
+
selector: candidate.selector,
|
|
442
|
+
});
|
|
443
|
+
if (!queryResponse.nodeId)
|
|
444
|
+
continue;
|
|
445
|
+
const fontsResponse = await client.send('CSS.getPlatformFontsForNode', {
|
|
446
|
+
nodeId: queryResponse.nodeId,
|
|
447
|
+
});
|
|
448
|
+
diagnostics.push({
|
|
449
|
+
selector: candidate.selector,
|
|
450
|
+
tag: candidate.tag,
|
|
451
|
+
classes: candidate.classes,
|
|
452
|
+
text: candidate.text,
|
|
453
|
+
fontFamily: candidate.fontFamily,
|
|
454
|
+
fontSize: candidate.fontSize,
|
|
455
|
+
fontWeight: candidate.fontWeight,
|
|
456
|
+
fonts: (fontsResponse.fonts ?? []).map((font) => ({
|
|
457
|
+
familyName: font.familyName,
|
|
458
|
+
postScriptName: font.postScriptName,
|
|
459
|
+
isCustomFont: font.isCustomFont,
|
|
460
|
+
glyphCount: font.glyphCount,
|
|
461
|
+
})),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// Skip nodes that fail to resolve — page may be re-rendering.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return diagnostics;
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
await client.detach().catch(() => { });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async function logCaptureRenderDiagnostics(page, screenshot, options, stage) {
|
|
479
|
+
const viewport = page.viewportSize();
|
|
480
|
+
const deviceScaleFactor = normalizeDeviceScaleFactor(options.deviceScaleFactor);
|
|
481
|
+
const metadata = await sharp(screenshot).metadata().catch(() => null);
|
|
482
|
+
const screenshotSize = {
|
|
483
|
+
width: metadata?.width ?? 0,
|
|
484
|
+
height: metadata?.height ?? 0,
|
|
485
|
+
};
|
|
486
|
+
const expectedScreenshotSize = viewport
|
|
487
|
+
? {
|
|
488
|
+
width: Math.round(viewport.width * deviceScaleFactor),
|
|
489
|
+
height: Math.round(viewport.height * deviceScaleFactor),
|
|
490
|
+
}
|
|
491
|
+
: null;
|
|
492
|
+
if (expectedScreenshotSize
|
|
493
|
+
&& (Math.abs(screenshotSize.width - expectedScreenshotSize.width) > 1
|
|
494
|
+
|| Math.abs(screenshotSize.height - expectedScreenshotSize.height) > 1)) {
|
|
495
|
+
logger.warn(`[capture] Screenshot pixel size mismatch: expected ` +
|
|
496
|
+
`${expectedScreenshotSize.width}x${expectedScreenshotSize.height} ` +
|
|
497
|
+
`(viewport ${viewport?.width}x${viewport?.height} @${deviceScaleFactor}x), ` +
|
|
498
|
+
`got ${screenshotSize.width}x${screenshotSize.height}. Text may appear rescaled.`);
|
|
499
|
+
}
|
|
500
|
+
if (!isDebugEnabled())
|
|
501
|
+
return;
|
|
502
|
+
try {
|
|
503
|
+
const [runtime, platformFonts] = await Promise.all([
|
|
504
|
+
page.evaluate(() => {
|
|
505
|
+
const normalizeFamily = (family) => family.trim().replace(/^['"]|['"]$/g, '');
|
|
506
|
+
const isGenericFamily = (family) => /^(serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-|emoji|math|fangsong|-apple-system)$/i.test(family);
|
|
507
|
+
const candidates = Array.from(document.querySelectorAll('h1,h2,h3,p,a,button,label,span,[data-ak]'));
|
|
508
|
+
const representative = candidates.find((node) => {
|
|
509
|
+
const rect = node.getBoundingClientRect();
|
|
510
|
+
const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
511
|
+
return text.length >= 3 && rect.width > 10 && rect.height > 8;
|
|
512
|
+
}) ?? document.body;
|
|
513
|
+
const style = getComputedStyle(representative);
|
|
514
|
+
const sample = 'AutoKap dashboard Aa 0123456789';
|
|
515
|
+
const families = style.fontFamily
|
|
516
|
+
.split(',')
|
|
517
|
+
.map(normalizeFamily)
|
|
518
|
+
.filter(Boolean);
|
|
519
|
+
const primaryFamily = families.find((family) => !/\bFallback\b/i.test(family) && !isGenericFamily(family)) ?? families[0] ?? 'sans-serif';
|
|
520
|
+
const probe = document.createElement('span');
|
|
521
|
+
probe.textContent = sample;
|
|
522
|
+
probe.style.position = 'absolute';
|
|
523
|
+
probe.style.left = '-99999px';
|
|
524
|
+
probe.style.top = '0';
|
|
525
|
+
probe.style.visibility = 'hidden';
|
|
526
|
+
probe.style.whiteSpace = 'nowrap';
|
|
527
|
+
probe.style.fontSize = style.fontSize;
|
|
528
|
+
probe.style.fontWeight = style.fontWeight;
|
|
529
|
+
probe.style.fontStyle = style.fontStyle;
|
|
530
|
+
probe.style.letterSpacing = style.letterSpacing;
|
|
531
|
+
probe.style.fontFeatureSettings = style.fontFeatureSettings;
|
|
532
|
+
probe.style.fontVariationSettings = style.fontVariationSettings;
|
|
533
|
+
document.body.appendChild(probe);
|
|
534
|
+
const measure = (fontFamily) => {
|
|
535
|
+
probe.style.fontFamily = fontFamily;
|
|
536
|
+
return Number(probe.getBoundingClientRect().width.toFixed(3));
|
|
537
|
+
};
|
|
538
|
+
const rect = representative.getBoundingClientRect();
|
|
539
|
+
const metrics = {
|
|
540
|
+
inherited: measure(style.fontFamily),
|
|
541
|
+
primary: measure(`"${primaryFamily}", sans-serif`),
|
|
542
|
+
system: measure('system-ui, sans-serif'),
|
|
543
|
+
sans: measure('sans-serif'),
|
|
544
|
+
};
|
|
545
|
+
probe.remove();
|
|
546
|
+
return {
|
|
547
|
+
url: location.href,
|
|
548
|
+
dpr: window.devicePixelRatio,
|
|
549
|
+
innerWidth: window.innerWidth,
|
|
550
|
+
innerHeight: window.innerHeight,
|
|
551
|
+
outerWidth: window.outerWidth,
|
|
552
|
+
outerHeight: window.outerHeight,
|
|
553
|
+
visualViewport: window.visualViewport
|
|
554
|
+
? {
|
|
555
|
+
width: window.visualViewport.width,
|
|
556
|
+
height: window.visualViewport.height,
|
|
557
|
+
scale: window.visualViewport.scale,
|
|
558
|
+
}
|
|
559
|
+
: null,
|
|
560
|
+
representative: {
|
|
561
|
+
tagName: representative.tagName,
|
|
562
|
+
text: (representative.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
563
|
+
width: Number(rect.width.toFixed(3)),
|
|
564
|
+
height: Number(rect.height.toFixed(3)),
|
|
565
|
+
fontFamily: style.fontFamily,
|
|
566
|
+
primaryFamily,
|
|
567
|
+
fontSize: style.fontSize,
|
|
568
|
+
fontWeight: style.fontWeight,
|
|
569
|
+
letterSpacing: style.letterSpacing,
|
|
570
|
+
lineHeight: style.lineHeight,
|
|
571
|
+
fontFeatureSettings: style.fontFeatureSettings,
|
|
572
|
+
fontVariationSettings: style.fontVariationSettings,
|
|
573
|
+
},
|
|
574
|
+
metricProbe: {
|
|
575
|
+
sample,
|
|
576
|
+
...metrics,
|
|
577
|
+
inheritedMatchesPrimary: Math.abs(metrics.inherited - metrics.primary) < 0.01,
|
|
578
|
+
inheritedDiffersFromSystem: Math.abs(metrics.inherited - metrics.system) >= 0.01,
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
}),
|
|
582
|
+
collectPlatformFontDiagnostics(page),
|
|
583
|
+
]);
|
|
584
|
+
logger.debug(`[capture] render diagnostics ${stage}: ${JSON.stringify({
|
|
585
|
+
viewport,
|
|
586
|
+
requestedDeviceScaleFactor: options.deviceScaleFactor ?? null,
|
|
587
|
+
normalizedDeviceScaleFactor: deviceScaleFactor,
|
|
588
|
+
screenshotSize,
|
|
589
|
+
expectedScreenshotSize,
|
|
590
|
+
runtime,
|
|
591
|
+
platformFonts,
|
|
592
|
+
})}`);
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
logger.debug(`[capture] render diagnostics ${stage} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
196
598
|
/**
|
|
197
599
|
* Map a BCP-47 language tag to a Playwright-compatible locale string.
|
|
198
600
|
* Playwright accepts both "fr" and "fr-FR". We normalize 2-char codes to their
|
|
@@ -694,8 +1096,9 @@ export class Browser {
|
|
|
694
1096
|
// next/font and other `font-display: swap` setups can report the
|
|
695
1097
|
// FontFaceSet as "ready" while visible text is still painted with fallback.
|
|
696
1098
|
// We force-load declared and currently computed families, verify the
|
|
697
|
-
// primary rendered families with document.fonts.check(), then
|
|
698
|
-
//
|
|
1099
|
+
// primary rendered families with document.fonts.check(), then run a paint
|
|
1100
|
+
// gate (metric probe + CDP) to confirm a real custom face is in the paint.
|
|
1101
|
+
const startedAt = Date.now();
|
|
699
1102
|
await page.evaluate(() => Promise.race([
|
|
700
1103
|
(async () => {
|
|
701
1104
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -758,6 +1161,22 @@ export class Browser {
|
|
|
758
1161
|
})(),
|
|
759
1162
|
new Promise((resolve) => setTimeout(resolve, 8000)),
|
|
760
1163
|
])).catch(() => { });
|
|
1164
|
+
// Paint gate: race in-page metric probe + CDP CSS.getPlatformFontsForNode.
|
|
1165
|
+
// FontFaceSet "loaded" can lie when Blink keeps a stale shaping cache after
|
|
1166
|
+
// a font swap (notably on CI Linux without GPU). The gate observes the
|
|
1167
|
+
// actual paint state, not the FontFaceSet state. Hard 4s ceiling, never
|
|
1168
|
+
// blocks the screenshot — on timeout we proceed and warn so
|
|
1169
|
+
// logCaptureRenderDiagnostics can flag the capture for review.
|
|
1170
|
+
const gate = await gateOnPaintedFont(page, 4000);
|
|
1171
|
+
// Triple rAF — software rasterization (CI Linux, --disable-gpu) sometimes
|
|
1172
|
+
// needs the extra commit before the screenshot pipeline reads the buffer.
|
|
1173
|
+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))).catch(() => { });
|
|
1174
|
+
if (gate.via === 'timeout') {
|
|
1175
|
+
logger.warn(`[capture] font paint gate timed out after ${gate.elapsedMs}ms — capture may show fallback metrics`);
|
|
1176
|
+
}
|
|
1177
|
+
else if (isDebugEnabled()) {
|
|
1178
|
+
logger.debug(`[capture] font paint gate resolved via=${gate.via} elapsedMs=${gate.elapsedMs} totalMs=${Date.now() - startedAt}`);
|
|
1179
|
+
}
|
|
761
1180
|
}
|
|
762
1181
|
async takeScreenshot() {
|
|
763
1182
|
const page = this.ensurePage();
|
|
@@ -765,8 +1184,15 @@ export class Browser {
|
|
|
765
1184
|
await page.mouse.move(0, 0);
|
|
766
1185
|
await ensureCaptureHideStyles(page);
|
|
767
1186
|
await this.waitForFontsBeforeScreenshot(page);
|
|
1187
|
+
// Strip C0/C1 control chars from text nodes — Linux Skia renders them as
|
|
1188
|
+
// .notdef tofu, macOS Core Text renders them invisibly. Normalize Linux
|
|
1189
|
+
// capture to match Mac (validated via Docker repro on autokap.app's
|
|
1190
|
+
// terminal block where ANSI escape literals were producing tofu boxes).
|
|
1191
|
+
await stripInvisibleControlChars(page);
|
|
768
1192
|
await logFontDiagnostics(page, 'before screenshot');
|
|
769
|
-
|
|
1193
|
+
const screenshot = Buffer.from(await page.screenshot({ type: 'png', fullPage: false }));
|
|
1194
|
+
await logCaptureRenderDiagnostics(page, screenshot, this.options, 'after screenshot');
|
|
1195
|
+
return screenshot;
|
|
770
1196
|
}
|
|
771
1197
|
async takeScreenshotForAI(options = {}) {
|
|
772
1198
|
const page = this.ensurePage();
|
package/dist/cli-runner.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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.
|