autokap 1.0.6 → 1.0.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.
Files changed (130) hide show
  1. package/assets/chrome/ios-statusbar-comparison-reference.jpg +0 -0
  2. package/assets/chrome/ios-statusbar-dark-reference.jpg +0 -0
  3. package/assets/chrome/ios-statusbar-light-reference.jpg +0 -0
  4. package/assets/devices/ipad-pro-11-m4.json +52 -0
  5. package/assets/devices/iphone-16-pro.json +53 -0
  6. package/assets/devices/macbook-air-13.json +45 -0
  7. package/assets/frames/MacBook Air 13.svg +242 -0
  8. package/assets/frames/Status bar - iPhone.png +0 -0
  9. Menu bar- iPad.png +0 -0
  10. package/assets/frames/iPad Pro M4 11_.png +0 -0
  11. package/assets/frames/iPhone 16 Pro.png +0 -0
  12. package/assets/icons/Cellular Connection.svg +3 -0
  13. package/assets/icons/Union.svg +6 -0
  14. package/assets/icons/Wifi.svg +3 -0
  15. package/assets/icons/battery.svg +5 -0
  16. package/assets/icons/battery_charging.svg +8 -0
  17. package/dist/abort.d.ts +5 -0
  18. package/dist/abort.js +44 -0
  19. package/dist/agent.d.ts +142 -0
  20. package/dist/agent.js +4511 -0
  21. package/dist/billing-operation-logging.d.ts +38 -0
  22. package/dist/billing-operation-logging.js +248 -0
  23. package/dist/browser-bar.d.ts +40 -0
  24. package/dist/browser-bar.js +147 -0
  25. package/dist/browser.d.ts +25 -0
  26. package/dist/browser.js +177 -9
  27. package/dist/capture-alt-text.d.ts +12 -0
  28. package/dist/capture-alt-text.js +51 -0
  29. package/dist/capture-encryption.d.ts +10 -0
  30. package/dist/capture-encryption.js +41 -0
  31. package/dist/capture-language-preflight.d.ts +41 -0
  32. package/dist/capture-language-preflight.js +286 -0
  33. package/dist/capture-llm-page-identity.d.ts +15 -0
  34. package/dist/capture-llm-page-identity.js +116 -0
  35. package/dist/capture-model-resolution.d.ts +9 -0
  36. package/dist/capture-model-resolution.js +21 -0
  37. package/dist/capture-page-identity.d.ts +9 -0
  38. package/dist/capture-page-identity.js +219 -0
  39. package/dist/capture-preset-credentials.d.ts +12 -0
  40. package/dist/capture-preset-credentials.js +57 -0
  41. package/dist/capture-request-plan.d.ts +58 -0
  42. package/dist/capture-request-plan.js +216 -0
  43. package/dist/capture-run-optimizer.d.ts +139 -0
  44. package/dist/capture-run-optimizer.js +848 -0
  45. package/dist/capture-selector-memory.d.ts +26 -0
  46. package/dist/capture-selector-memory.js +327 -0
  47. package/dist/capture-session-profile-encryption.d.ts +2 -0
  48. package/dist/capture-session-profile-encryption.js +22 -0
  49. package/dist/capture-step-timeout.d.ts +10 -0
  50. package/dist/capture-step-timeout.js +30 -0
  51. package/dist/capture-studio-sync.d.ts +22 -0
  52. package/dist/capture-studio-sync.js +166 -0
  53. package/dist/capture-variant-state.d.ts +54 -0
  54. package/dist/capture-variant-state.js +156 -0
  55. package/dist/cli.js +15 -0
  56. package/dist/clip-orchestrator.d.ts +148 -0
  57. package/dist/clip-orchestrator.js +950 -0
  58. package/dist/clip-postprocess.d.ts +42 -0
  59. package/dist/clip-postprocess.js +192 -0
  60. package/dist/cost-logging.d.ts +27 -0
  61. package/dist/cost-logging.js +128 -0
  62. package/dist/credential-templates.d.ts +5 -0
  63. package/dist/credential-templates.js +60 -0
  64. package/dist/element-capture.d.ts +53 -0
  65. package/dist/element-capture.js +766 -0
  66. package/dist/hybrid-navigator.d.ts +138 -0
  67. package/dist/hybrid-navigator.js +468 -0
  68. package/dist/index.d.ts +15 -0
  69. package/dist/index.js +11 -0
  70. package/dist/llm-usage.d.ts +17 -0
  71. package/dist/llm-usage.js +45 -0
  72. package/dist/mockup-html.d.ts +119 -0
  73. package/dist/mockup-html.js +253 -0
  74. package/dist/mockup.d.ts +94 -0
  75. package/dist/mockup.js +608 -0
  76. package/dist/mouse-animation.d.ts +46 -0
  77. package/dist/mouse-animation.js +100 -0
  78. package/dist/overlay-utils.d.ts +14 -0
  79. package/dist/overlay-utils.js +13 -0
  80. package/dist/posthog.d.ts +4 -0
  81. package/dist/posthog.js +26 -0
  82. package/dist/prompt-cache.d.ts +10 -0
  83. package/dist/prompt-cache.js +24 -0
  84. package/dist/prompts.d.ts +167 -0
  85. package/dist/prompts.js +1165 -0
  86. package/dist/remote-browser.d.ts +191 -0
  87. package/dist/remote-browser.js +305 -0
  88. package/dist/security.d.ts +20 -0
  89. package/dist/security.js +569 -0
  90. package/dist/server-capture-runtime.d.ts +123 -0
  91. package/dist/server-capture-runtime.js +638 -0
  92. package/dist/server-credit-usage.d.ts +12 -0
  93. package/dist/server-credit-usage.js +41 -0
  94. package/dist/server-posthog.d.ts +2 -0
  95. package/dist/server-posthog.js +16 -0
  96. package/dist/server-project-webhooks.d.ts +45 -0
  97. package/dist/server-project-webhooks.js +97 -0
  98. package/dist/server-screenshot-watermark.d.ts +7 -0
  99. package/dist/server-screenshot-watermark.js +38 -0
  100. package/dist/session-profile.d.ts +86 -0
  101. package/dist/session-profile.js +1373 -0
  102. package/dist/sf-pro-fonts.d.ts +4 -0
  103. package/dist/sf-pro-fonts.js +7 -0
  104. package/dist/status-bar-l10n.d.ts +14 -0
  105. package/dist/status-bar-l10n.js +177 -0
  106. package/dist/status-bar.d.ts +44 -0
  107. package/dist/status-bar.js +336 -0
  108. package/dist/tools.d.ts +4 -0
  109. package/dist/tools.js +578 -0
  110. package/dist/video-agent.d.ts +143 -0
  111. package/dist/video-agent.js +4783 -0
  112. package/dist/video-observation.d.ts +36 -0
  113. package/dist/video-observation.js +192 -0
  114. package/dist/video-planner.d.ts +12 -0
  115. package/dist/video-planner.js +500 -0
  116. package/dist/video-prompts.d.ts +37 -0
  117. package/dist/video-prompts.js +554 -0
  118. package/dist/video-tools.d.ts +3 -0
  119. package/dist/video-tools.js +59 -0
  120. package/dist/video-variant-state.d.ts +29 -0
  121. package/dist/video-variant-state.js +80 -0
  122. package/dist/vision-model.d.ts +17 -0
  123. package/dist/vision-model.js +74 -0
  124. package/dist/ws-auth.d.ts +20 -0
  125. package/dist/ws-auth.js +67 -0
  126. package/dist/ws-handler.d.ts +10 -0
  127. package/dist/ws-handler.js +1663 -0
  128. package/dist/ws-server.d.ts +9 -0
  129. package/dist/ws-server.js +52 -0
  130. package/package.json +93 -39
package/dist/mockup.js ADDED
@@ -0,0 +1,608 @@
1
+ import { readFile, readdir } from 'fs/promises';
2
+ import path from 'path';
3
+ import sharp from 'sharp';
4
+ import { fileURLToPath } from 'url';
5
+ import { generateMockupPage, computeMockupLayout } from './mockup-html.js';
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const DEVICES_DIR = path.join(__dirname, '..', 'assets', 'devices');
9
+ const FRAMES_DIR = path.join(__dirname, '..', 'assets', 'frames');
10
+ function getSupabaseMockupConfig() {
11
+ return {
12
+ url: process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL,
13
+ serviceKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
14
+ };
15
+ }
16
+ const DEFAULT_MOCKUP_OPTIONS = {
17
+ orientation: 'portrait',
18
+ outputScale: 2,
19
+ showStatusBar: true,
20
+ showSafeAreaTop: true,
21
+ showSafeAreaBottom: true,
22
+ showSafeAreaLeft: true,
23
+ showSafeAreaRight: true,
24
+ showHomeIndicator: true,
25
+ safeAreaTopColor: '',
26
+ safeAreaBottomColor: '',
27
+ safeAreaLeftColor: '',
28
+ safeAreaRightColor: '',
29
+ statusBar: {},
30
+ browserBar: {},
31
+ windowBorder: { color: '', width: 0, radius: 0 },
32
+ };
33
+ const CONFIG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
34
+ let configCache = null;
35
+ // In-memory cache for downloaded frame asset Buffers
36
+ const frameBufferCache = new Map();
37
+ const FRAME_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
38
+ export function invalidateDeviceConfigCache() {
39
+ configCache = null;
40
+ frameBufferCache.clear();
41
+ }
42
+ function resolveBrowserOrientation(config, requestedOrientation) {
43
+ const supported = config.supportedOrientations ?? [];
44
+ if (supported.length > 0) {
45
+ return supported[0] ?? requestedOrientation;
46
+ }
47
+ const available = Object.keys(config.orientations ?? {});
48
+ return available[0] ?? config.frameOrientation ?? requestedOrientation;
49
+ }
50
+ function normalizeBrowserSafeAreaTop(configuredTop, dpr, fallbackLogicalTop) {
51
+ const rawTop = configuredTop ?? 0;
52
+ if (rawTop <= 0) {
53
+ return fallbackLogicalTop * dpr;
54
+ }
55
+ // Browser configs store safeArea.top in logical px, but some historical rows
56
+ // may already contain a DPR-scaled physical value. Normalize both shapes.
57
+ const logicalTop = rawTop > fallbackLogicalTop * 1.5
58
+ ? rawTop / Math.max(dpr, 1)
59
+ : rawTop;
60
+ return logicalTop * dpr;
61
+ }
62
+ function normalizeBrowserWindowBorder(windowBorder, dpr) {
63
+ if (!windowBorder) {
64
+ return undefined;
65
+ }
66
+ return {
67
+ ...windowBorder,
68
+ radius: windowBorder.radius > 0
69
+ ? windowBorder.radius * dpr
70
+ : windowBorder.radius,
71
+ };
72
+ }
73
+ function normalizeBrowserCornerRadius(cornerRadius, dpr) {
74
+ if (!cornerRadius || cornerRadius <= 0) {
75
+ return 0;
76
+ }
77
+ return cornerRadius * dpr;
78
+ }
79
+ /**
80
+ * Resolve the orientation config for a given orientation.
81
+ * Prefers per-orientation configs; falls back to rotation logic for legacy format.
82
+ */
83
+ function resolveOrientationConfig(config, requestedOrientation) {
84
+ // Check for per-orientation config (new format)
85
+ const orientationData = config.orientations?.[requestedOrientation];
86
+ if (orientationData) {
87
+ return {
88
+ screen: orientationData.screen,
89
+ safeArea: orientationData.safeArea ?? { top: 0, bottom: 0 },
90
+ statusBar: orientationData.statusBar,
91
+ homeIndicator: orientationData.homeIndicator,
92
+ frame: orientationData.frame,
93
+ frameUrl: orientationData.frameUrl ?? config._rowFrameUrl,
94
+ frameDarkUrl: orientationData.frameDarkUrl,
95
+ windowBorder: orientationData.windowBorder,
96
+ browserBarZones: orientationData.browserBarZones,
97
+ frameRotation: orientationData.frameRotation ?? 0,
98
+ needsRotation: false,
99
+ disableOverlays: false,
100
+ frameBehindContent: orientationData.frameBehindContent ?? false,
101
+ };
102
+ }
103
+ // Legacy: single config, may need auto-rotation
104
+ const nativeOrientation = config.frameOrientation ?? 'portrait';
105
+ const needsRotation = requestedOrientation !== nativeOrientation;
106
+ const disableOverlays = config.category === 'phone' && needsRotation;
107
+ return {
108
+ screen: config.screen ?? { logicalWidth: 0, logicalHeight: 0, scale: 1, cornerRadius: 0 },
109
+ safeArea: config.safeArea ?? { top: 0, bottom: 0 },
110
+ statusBar: config.statusBar,
111
+ homeIndicator: config.homeIndicator,
112
+ frame: config.frame,
113
+ frameRotation: 0,
114
+ needsRotation,
115
+ disableOverlays,
116
+ frameBehindContent: false,
117
+ };
118
+ }
119
+ async function loadDeviceConfigs() {
120
+ if (configCache && configCache.expiresAt > Date.now()) {
121
+ return configCache.configs;
122
+ }
123
+ // Try Supabase first
124
+ const { url: supabaseUrl, serviceKey: supabaseServiceKey } = getSupabaseMockupConfig();
125
+ if (supabaseUrl && supabaseServiceKey) {
126
+ try {
127
+ const res = await fetch(`${supabaseUrl}/rest/v1/device_mockups?is_active=eq.true&order=category.asc,name.asc&select=id,config,frame_url`, {
128
+ headers: {
129
+ apikey: supabaseServiceKey,
130
+ Authorization: `Bearer ${supabaseServiceKey}`,
131
+ },
132
+ });
133
+ if (res.ok) {
134
+ const rows = await res.json();
135
+ const configs = new Map();
136
+ for (const row of rows) {
137
+ const config = row.config;
138
+ if (row.frame_url)
139
+ config._rowFrameUrl = row.frame_url;
140
+ configs.set(row.id, config);
141
+ }
142
+ configCache = { configs, expiresAt: Date.now() + CONFIG_CACHE_TTL_MS };
143
+ return configs;
144
+ }
145
+ }
146
+ catch {
147
+ // Fall through to filesystem
148
+ }
149
+ }
150
+ // Fallback: local filesystem
151
+ const files = await readdir(DEVICES_DIR);
152
+ const configs = new Map();
153
+ for (const file of files) {
154
+ if (!file.endsWith('.json'))
155
+ continue;
156
+ const raw = await readFile(path.join(DEVICES_DIR, file), 'utf-8');
157
+ const config = JSON.parse(raw);
158
+ configs.set(config.id, config);
159
+ }
160
+ configCache = { configs, expiresAt: Date.now() + CONFIG_CACHE_TTL_MS };
161
+ return configs;
162
+ }
163
+ async function loadFrameAsset(config, resolved) {
164
+ // Check for orientation-specific frame URL first
165
+ const frameUrl = resolved.frameUrl;
166
+ const cacheKey = frameUrl ?? config.id;
167
+ if (frameUrl) {
168
+ const cached = frameBufferCache.get(cacheKey);
169
+ if (cached && cached.expiresAt > Date.now())
170
+ return cached.buffer;
171
+ try {
172
+ const res = await fetch(frameUrl);
173
+ if (res.ok) {
174
+ const buffer = Buffer.from(await res.arrayBuffer());
175
+ frameBufferCache.set(cacheKey, { buffer, expiresAt: Date.now() + FRAME_CACHE_TTL_MS });
176
+ return buffer;
177
+ }
178
+ }
179
+ catch {
180
+ // Fall through to filesystem
181
+ }
182
+ }
183
+ // Fallback: local filesystem
184
+ return readFile(path.join(FRAMES_DIR, resolved.frame.asset));
185
+ }
186
+ function computeResolvedFrameGeometry(resolved) {
187
+ const rotation = resolved.frameRotation || (resolved.needsRotation ? 90 : 0);
188
+ const is90or270 = rotation % 180 !== 0;
189
+ if (rotation !== 0) {
190
+ if (is90or270) {
191
+ if (resolved.needsRotation) {
192
+ const origSr = resolved.frame.screenRect;
193
+ const origH = resolved.frame.height;
194
+ return {
195
+ frameWidth: resolved.frame.height,
196
+ frameHeight: resolved.frame.width,
197
+ frameRotation: rotation,
198
+ screenRect: {
199
+ x: origH - origSr.y - origSr.height,
200
+ y: origSr.x,
201
+ width: origSr.height,
202
+ height: origSr.width,
203
+ },
204
+ };
205
+ }
206
+ return {
207
+ frameWidth: resolved.frame.height,
208
+ frameHeight: resolved.frame.width,
209
+ frameRotation: rotation,
210
+ screenRect: { ...resolved.frame.screenRect },
211
+ };
212
+ }
213
+ return {
214
+ frameWidth: resolved.frame.width,
215
+ frameHeight: resolved.frame.height,
216
+ frameRotation: rotation,
217
+ screenRect: { ...resolved.frame.screenRect },
218
+ };
219
+ }
220
+ return {
221
+ frameWidth: resolved.frame.width,
222
+ frameHeight: resolved.frame.height,
223
+ frameRotation: 0,
224
+ screenRect: { ...resolved.frame.screenRect },
225
+ };
226
+ }
227
+ async function loadFrameAssetFromDescriptor(descriptor) {
228
+ const cacheKey = descriptor.frameUrl ?? `${descriptor.id}:${descriptor.orientation}`;
229
+ const cached = frameBufferCache.get(cacheKey);
230
+ if (cached && cached.expiresAt > Date.now())
231
+ return cached.buffer;
232
+ if (descriptor.frameUrl) {
233
+ try {
234
+ const res = await fetch(descriptor.frameUrl);
235
+ if (res.ok) {
236
+ const buffer = Buffer.from(await res.arrayBuffer());
237
+ frameBufferCache.set(cacheKey, {
238
+ buffer,
239
+ expiresAt: Date.now() + FRAME_CACHE_TTL_MS,
240
+ });
241
+ return buffer;
242
+ }
243
+ }
244
+ catch {
245
+ // Fall through to filesystem
246
+ }
247
+ }
248
+ return readFile(path.join(FRAMES_DIR, descriptor.frame.asset));
249
+ }
250
+ export async function resolveDeviceFrameDescriptor(id, options) {
251
+ const configs = await loadDeviceConfigs();
252
+ const config = configs.get(id);
253
+ if (!config)
254
+ return null;
255
+ const orientation = options?.orientation ?? config.frameOrientation ?? 'portrait';
256
+ const resolved = resolveOrientationConfig(config, orientation);
257
+ const geometry = computeResolvedFrameGeometry(resolved);
258
+ return {
259
+ id: config.id,
260
+ name: config.name,
261
+ category: config.category,
262
+ orientation,
263
+ screen: resolved.screen,
264
+ safeArea: resolved.safeArea,
265
+ statusBar: resolved.statusBar,
266
+ homeIndicator: resolved.homeIndicator,
267
+ frame: {
268
+ type: resolved.frame.type,
269
+ asset: resolved.frame.asset,
270
+ width: resolved.frame.width,
271
+ height: resolved.frame.height,
272
+ },
273
+ frameUrl: resolved.frameUrl,
274
+ frameWidth: geometry.frameWidth,
275
+ frameHeight: geometry.frameHeight,
276
+ screenRect: geometry.screenRect,
277
+ frameRotation: geometry.frameRotation,
278
+ frameBehindContent: resolved.frameBehindContent,
279
+ disableOverlays: resolved.disableOverlays,
280
+ };
281
+ }
282
+ export async function rasterizeDeviceFrame(descriptor, outputScale) {
283
+ const rawFrame = await loadFrameAssetFromDescriptor(descriptor);
284
+ const isSvg = descriptor.frame.type === 'svg';
285
+ const rasterWidth = Math.round(descriptor.frame.width * outputScale);
286
+ const rasterHeight = Math.round(descriptor.frame.height * outputScale);
287
+ const svgDensity = Math.max(72, Math.round(72 * outputScale));
288
+ let rendered = isSvg
289
+ ? await sharp(rawFrame, { density: svgDensity })
290
+ .resize(rasterWidth, rasterHeight)
291
+ .png()
292
+ .toBuffer()
293
+ : await sharp(rawFrame)
294
+ .resize(rasterWidth, rasterHeight)
295
+ .png()
296
+ .toBuffer();
297
+ if (descriptor.frameRotation !== 0) {
298
+ rendered = await sharp(rendered).rotate(descriptor.frameRotation).png().toBuffer();
299
+ }
300
+ const renderedMeta = await sharp(rendered).metadata();
301
+ if (renderedMeta.width !== Math.round(descriptor.frameWidth * outputScale) ||
302
+ renderedMeta.height !== Math.round(descriptor.frameHeight * outputScale)) {
303
+ rendered = await sharp(rendered)
304
+ .resize(Math.round(descriptor.frameWidth * outputScale), Math.round(descriptor.frameHeight * outputScale))
305
+ .png()
306
+ .toBuffer();
307
+ }
308
+ return rendered;
309
+ }
310
+ // ── Public API ─────────────────────────────────────────────────────────
311
+ export async function getDeviceFrames() {
312
+ const configs = await loadDeviceConfigs();
313
+ return Array.from(configs.values()).map(({ id, name, category, viewport }) => ({
314
+ id, name, category, viewport,
315
+ }));
316
+ }
317
+ export async function getDeviceFrame(id) {
318
+ const configs = await loadDeviceConfigs();
319
+ const c = configs.get(id);
320
+ if (!c)
321
+ return undefined;
322
+ return { id: c.id, name: c.name, category: c.category, viewport: c.viewport };
323
+ }
324
+ export async function applyDeviceFrame(screenshot, deviceId, browserContext, options) {
325
+ const configs = await loadDeviceConfigs();
326
+ const config = configs.get(deviceId);
327
+ if (!config)
328
+ throw new Error(`Unknown device frame: ${deviceId}`);
329
+ const opts = { ...DEFAULT_MOCKUP_OPTIONS, ...options };
330
+ const requestedOrientation = opts.orientation ?? 'portrait';
331
+ // Resolve orientation-specific config
332
+ // Browser devices ignore requested orientation. Capture uses a single browser
333
+ // geometry, and Studio must mirror that behavior even if stale configs contain
334
+ // bogus extra orientations.
335
+ const effectiveOrientation = config.category === 'browser'
336
+ ? resolveBrowserOrientation(config, requestedOrientation)
337
+ : requestedOrientation;
338
+ const resolved = resolveOrientationConfig(config, effectiveOrientation);
339
+ // Disable overlays if rotation requires it (legacy phone rotation)
340
+ if (resolved.disableOverlays) {
341
+ opts.showStatusBar = false;
342
+ opts.showSafeAreaTop = false;
343
+ opts.showSafeAreaBottom = false;
344
+ opts.showSafeAreaLeft = false;
345
+ opts.showSafeAreaRight = false;
346
+ }
347
+ const scale = resolved.screen?.scale ?? 1;
348
+ const isBrowserDevice = config.category === 'browser';
349
+ const hasFrameEarly = !!(resolved.frameUrl || resolved.frame.asset);
350
+ // For frameless browser devices, the screenshot is already at final pixel resolution
351
+ // (captured at the device's native viewport). outputScale would double it again.
352
+ const os = (isBrowserDevice && !hasFrameEarly) ? 1 : Math.max(0.5, Math.min(4, opts.outputScale));
353
+ console.log(`[mockup] applyDeviceFrame: id=${deviceId}, category=${config.category}, orientation=${requestedOrientation}`);
354
+ console.log(`[mockup] hasOrientationConfig=${!!config.orientations?.[requestedOrientation]}, orientations=${JSON.stringify(Object.keys(config.orientations ?? {}))}`);
355
+ console.log(`[mockup] resolved.screen:`, JSON.stringify(resolved.screen));
356
+ console.log(`[mockup] resolved.frame: w=${resolved.frame.width} h=${resolved.frame.height} asset="${resolved.frame.asset}" url=${resolved.frameUrl ? 'yes' : 'no'}`);
357
+ console.log(`[mockup] resolved.safeArea:`, JSON.stringify(resolved.safeArea));
358
+ console.log(`[mockup] scale=${scale}, outputScale=${os}`);
359
+ // Browser devices can work without a frame image
360
+ const hasFrame = !!(resolved.frameUrl || resolved.frame.asset);
361
+ let frameData = null;
362
+ if (hasFrame) {
363
+ // Load and potentially rotate frame asset
364
+ const rawFrame = await loadFrameAsset(config, resolved);
365
+ const isSvg = resolved.frame.type === 'svg';
366
+ frameData = isSvg
367
+ ? await sharp(rawFrame, { density: 72 * os })
368
+ .resize(Math.round(resolved.frame.width * os), Math.round(resolved.frame.height * os))
369
+ .png().toBuffer()
370
+ : await sharp(rawFrame)
371
+ .resize(Math.round(resolved.frame.width * os), Math.round(resolved.frame.height * os))
372
+ .png().toBuffer();
373
+ }
374
+ // Apply frame rotation
375
+ const geometry = computeResolvedFrameGeometry(resolved);
376
+ let geo = {
377
+ frameWidth: geometry.frameWidth,
378
+ frameHeight: geometry.frameHeight,
379
+ screenRect: geometry.screenRect,
380
+ };
381
+ let browserDeviceResolutionFactor = 1;
382
+ // For frameless browser devices: derive dimensions from the actual screenshot
383
+ // and ensure safe area top is set for the Chrome toolbar (86px @ 1x = 129px @ 1.5x)
384
+ const BROWSER_BAR_HEIGHT = 86; // Chrome toolbar logical height at 1x
385
+ if (isBrowserDevice && !hasFrame) {
386
+ const sMeta = await sharp(screenshot).metadata();
387
+ const sw = sMeta.width ?? 1440;
388
+ const sh = sMeta.height ?? 900;
389
+ // Detect the actual DPR from the screenshot dimensions vs the device's logical viewport.
390
+ // This handles the case where the caller doesn't know the original capture DPR (e.g. Studio editor).
391
+ const logicalW = resolved.screen?.logicalWidth || 1440;
392
+ const inferredDpr = logicalW > 0 && sw > logicalW
393
+ ? Math.round(sw / logicalW * 10) / 10
394
+ : 1;
395
+ // Use whichever is larger: the explicitly requested outputScale or the inferred DPR.
396
+ // This ensures the browser bar is correctly sized even when outputScale=1 is passed
397
+ // but the screenshot is at a higher pixel density.
398
+ const dpr = Math.max(inferredDpr, Math.max(0.5, Math.min(4, opts.outputScale)));
399
+ browserDeviceResolutionFactor = dpr;
400
+ const safeAreaTop = normalizeBrowserSafeAreaTop(resolved.safeArea.top, dpr, BROWSER_BAR_HEIGHT);
401
+ resolved.safeArea = { ...resolved.safeArea, top: safeAreaTop };
402
+ resolved.screen = {
403
+ ...resolved.screen,
404
+ cornerRadius: normalizeBrowserCornerRadius(resolved.screen?.cornerRadius, dpr),
405
+ };
406
+ const saTop = resolved.safeArea.top * scale;
407
+ // Frame = screenshot width × (screenshot height + safe area for toolbar)
408
+ geo.frameWidth = sw;
409
+ geo.frameHeight = sh + saTop;
410
+ geo.screenRect = { x: 0, y: 0, width: sw, height: sh + saTop };
411
+ console.log(`[mockup] frameless browser: screenshot=${sw}x${sh}, logicalW=${logicalW}, inferredDpr=${inferredDpr}, dpr=${dpr}, safeAreaTop=${saTop}, geo=${geo.frameWidth}x${geo.frameHeight}`);
412
+ }
413
+ else if (!hasFrame && geo.frameWidth === 0 && geo.frameHeight === 0) {
414
+ // Non-browser frameless fallback
415
+ geo.frameWidth = geo.screenRect.width || resolved.screen?.logicalWidth || 1440;
416
+ geo.frameHeight = geo.screenRect.height || resolved.screen?.logicalHeight || 900;
417
+ if (geo.screenRect.width === 0)
418
+ geo.screenRect = { x: 0, y: 0, width: geo.frameWidth, height: geo.frameHeight };
419
+ }
420
+ if (frameData && geometry.frameRotation !== 0) {
421
+ // Rotate the frame image by the specified angle
422
+ frameData = await sharp(frameData).rotate(geometry.frameRotation).png().toBuffer();
423
+ }
424
+ // Always compute content area with all safe areas visible — this matches
425
+ // the browser viewport the screenshot was captured at.
426
+ // Safe area toggles only affect the visual overlays (fills, status bar),
427
+ // NOT the screenshot sizing or placement.
428
+ const layout = computeMockupLayout({
429
+ frameSrc: '',
430
+ frameWidth: geo.frameWidth,
431
+ frameHeight: geo.frameHeight,
432
+ frameRotation: 0,
433
+ screenRect: geo.screenRect,
434
+ cornerRadius: (resolved.screen?.cornerRadius ?? 0) * scale,
435
+ safeArea: resolved.safeArea,
436
+ scale,
437
+ showSafeAreaTop: true,
438
+ showSafeAreaBottom: true,
439
+ showSafeAreaLeft: true,
440
+ showSafeAreaRight: true,
441
+ });
442
+ const contentW = layout.contentArea.width;
443
+ const contentH = layout.contentArea.height;
444
+ console.log(`[mockup] hasFrame=${hasFrame}, geo: fw=${geo.frameWidth} fh=${geo.frameHeight} sr=${JSON.stringify(geo.screenRect)}`);
445
+ console.log(`[mockup] layout: container=${layout.containerWidth}x${layout.containerHeight} content=${contentW}x${contentH} contentArea=${JSON.stringify(layout.contentArea)}`);
446
+ // Get incoming screenshot dimensions for logging
447
+ const screenshotMeta = await sharp(screenshot).metadata();
448
+ console.log(`[mockup] input screenshot: ${screenshotMeta.width}x${screenshotMeta.height}`);
449
+ console.log(`[mockup] resize target: ${Math.round(contentW * os)}x${Math.round(contentH * os)}`);
450
+ // Resize screenshot to outputScale× of the content area for crisp rendering
451
+ const screenshotForMockup = await sharp(screenshot)
452
+ .resize(Math.round(contentW * os), Math.round(contentH * os), { fit: 'fill' })
453
+ .png()
454
+ .toBuffer();
455
+ const screenshotBase64 = screenshotForMockup.toString('base64');
456
+ // Sample edge colors for dynamic safe area fills
457
+ const colors = await sampleEdgeColors(screenshotForMockup);
458
+ if (opts.safeAreaTopColor)
459
+ colors.topColor = opts.safeAreaTopColor;
460
+ if (opts.safeAreaBottomColor)
461
+ colors.bottomColor = opts.safeAreaBottomColor;
462
+ if (opts.safeAreaLeftColor)
463
+ colors.leftColor = opts.safeAreaLeftColor;
464
+ if (opts.safeAreaRightColor)
465
+ colors.rightColor = opts.safeAreaRightColor;
466
+ // Determine color scheme from edge colors
467
+ const autoColorScheme = isDarkBackground(colors.topColor) ? 'dark' : 'light';
468
+ const lum = parseLuminance(colors.bottomColor);
469
+ const hiColor = lum > 170 ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.5)';
470
+ // Browser devices: swap frame to dark variant if needed
471
+ if (frameData && isBrowserDevice && autoColorScheme === 'dark' && resolved.frameDarkUrl) {
472
+ try {
473
+ const darkRes = await fetch(resolved.frameDarkUrl);
474
+ if (darkRes.ok) {
475
+ const darkRaw = Buffer.from(await darkRes.arrayBuffer());
476
+ const isSvg = resolved.frame.type === 'svg';
477
+ frameData = isSvg
478
+ ? await sharp(darkRaw, { density: 72 * os })
479
+ .resize(Math.round(resolved.frame.width * os), Math.round(resolved.frame.height * os))
480
+ .png().toBuffer()
481
+ : await sharp(darkRaw)
482
+ .resize(Math.round(resolved.frame.width * os), Math.round(resolved.frame.height * os))
483
+ .png().toBuffer();
484
+ if (geometry.frameRotation !== 0) {
485
+ frameData = await sharp(frameData).rotate(geometry.frameRotation).png().toBuffer();
486
+ }
487
+ }
488
+ }
489
+ catch {
490
+ // Keep light frame as fallback
491
+ }
492
+ }
493
+ const frameBase64 = frameData ? frameData.toString('base64') : '';
494
+ const browserWindowBorder = isBrowserDevice
495
+ ? normalizeBrowserWindowBorder(opts.windowBorder?.width ? opts.windowBorder : resolved.windowBorder, browserDeviceResolutionFactor)
496
+ : undefined;
497
+ // Safe area toggles: when hidden, make fills transparent instead of removing them.
498
+ // This keeps the content area (screenshot placement) at the viewport-matching size.
499
+ const safeAreaFillColors = {
500
+ top: opts.showSafeAreaTop ? colors.topColor : 'transparent',
501
+ bottom: opts.showSafeAreaBottom ? colors.bottomColor : 'transparent',
502
+ left: opts.showSafeAreaLeft ? colors.leftColor : 'transparent',
503
+ right: opts.showSafeAreaRight ? colors.rightColor : 'transparent',
504
+ };
505
+ // Use shared HTML generator (single source of truth for mockup rendering)
506
+ // Always pass showSafeArea=true so the content area matches the viewport.
507
+ const html = generateMockupPage({
508
+ frameSrc: frameBase64 ? `data:image/png;base64,${frameBase64}` : undefined,
509
+ frameWidth: geo.frameWidth,
510
+ frameHeight: geo.frameHeight,
511
+ frameRotation: 0, // frame already rotated by sharp
512
+ frameBehindContent: resolved.frameBehindContent,
513
+ screenRect: geo.screenRect,
514
+ cornerRadius: (resolved.screen?.cornerRadius ?? 0) * scale,
515
+ screenBackground: 'transparent',
516
+ safeArea: resolved.safeArea,
517
+ scale,
518
+ showSafeAreaTop: true,
519
+ showSafeAreaBottom: true,
520
+ showSafeAreaLeft: true,
521
+ showSafeAreaRight: true,
522
+ safeAreaColors: safeAreaFillColors,
523
+ statusBar: !isBrowserDevice && resolved.statusBar ? {
524
+ height: resolved.statusBar.height,
525
+ width: resolved.statusBar.width,
526
+ type: resolved.statusBar.type ?? 'iphone-dynamic-island',
527
+ layout: resolved.statusBar.layout,
528
+ } : undefined,
529
+ showStatusBar: !isBrowserDevice && opts.showStatusBar,
530
+ statusBarConfig: { ...opts.statusBar, colorScheme: autoColorScheme },
531
+ colorScheme: autoColorScheme,
532
+ showBrowserBar: isBrowserDevice,
533
+ browserBarConfig: isBrowserDevice ? { ...opts.browserBar, colorScheme: autoColorScheme } : undefined,
534
+ homeIndicator: resolved.homeIndicator,
535
+ showHomeIndicator: opts.showHomeIndicator,
536
+ homeIndicatorColor: hiColor,
537
+ windowBorder: browserWindowBorder,
538
+ contentHtml: `<img style="width:100%;height:100%;display:block" src="data:image/png;base64,${screenshotBase64}">`,
539
+ pixelScale: os,
540
+ });
541
+ // Compute final render size — must match the HTML page container (including window border)
542
+ const wbw = browserWindowBorder?.width ?? 0;
543
+ const renderW = Math.round((geo.frameWidth + wbw * 2) * os);
544
+ const renderH = Math.round((geo.frameHeight + wbw * 2) * os);
545
+ console.log(`[mockup] renderMockup: ${renderW}x${renderH}, showBrowserBar=${isBrowserDevice}, windowBorderWidth=${wbw}, browserDpr=${browserDeviceResolutionFactor}`);
546
+ return renderMockup(browserContext, html, renderW, renderH);
547
+ }
548
+ // ── Edge Color Sampling ────────────────────────────────────────────────
549
+ async function sampleEdgeColors(preparedScreenshot) {
550
+ const meta = await sharp(preparedScreenshot).metadata();
551
+ const w = meta.width ?? 1;
552
+ const h = meta.height ?? 1;
553
+ const [topStats, bottomStats, leftStats, rightStats] = await Promise.all([
554
+ sharp(preparedScreenshot).extract({ left: 0, top: 0, width: w, height: 1 }).stats(),
555
+ sharp(preparedScreenshot).extract({ left: 0, top: h - 1, width: w, height: 1 }).stats(),
556
+ sharp(preparedScreenshot).extract({ left: 0, top: 0, width: 1, height: h }).stats(),
557
+ sharp(preparedScreenshot).extract({ left: w - 1, top: 0, width: 1, height: h }).stats(),
558
+ ]);
559
+ return {
560
+ topColor: channelsToRgb(topStats.channels),
561
+ bottomColor: channelsToRgb(bottomStats.channels),
562
+ leftColor: channelsToRgb(leftStats.channels),
563
+ rightColor: channelsToRgb(rightStats.channels),
564
+ };
565
+ }
566
+ function channelsToRgb(channels) {
567
+ const r = Math.round(channels[0].mean);
568
+ const g = Math.round(channels[1].mean);
569
+ const b = Math.round(channels[2].mean);
570
+ return `rgb(${r},${g},${b})`;
571
+ }
572
+ function isDarkBackground(rgbStr) {
573
+ const match = rgbStr.match(/\d+/g);
574
+ if (!match)
575
+ return false;
576
+ const [r, g, b] = match.map(Number);
577
+ return r * 0.299 + g * 0.587 + b * 0.114 < 128;
578
+ }
579
+ function parseLuminance(rgbStr) {
580
+ const match = rgbStr.match(/\d+/g);
581
+ if (!match)
582
+ return 128;
583
+ const [r, g, b] = match.map(Number);
584
+ return r * 0.299 + g * 0.587 + b * 0.114;
585
+ }
586
+ // ── Playwright Renderer ────────────────────────────────────────────────
587
+ async function renderMockup(browserContext, html, width, height) {
588
+ const browser = browserContext.browser();
589
+ if (!browser)
590
+ throw new Error('Browser not available for mockup rendering');
591
+ const renderCtx = await browser.newContext({
592
+ viewport: { width, height },
593
+ deviceScaleFactor: 1,
594
+ });
595
+ const page = await renderCtx.newPage();
596
+ try {
597
+ await page.setContent(html, { waitUntil: 'load' });
598
+ await page.evaluate(() => document.fonts.ready);
599
+ await page.waitForTimeout(50);
600
+ const buffer = await page.screenshot({ type: 'png', fullPage: false, omitBackground: true });
601
+ return Buffer.from(buffer);
602
+ }
603
+ finally {
604
+ await page.close();
605
+ await renderCtx.close();
606
+ }
607
+ }
608
+ //# sourceMappingURL=mockup.js.map
@@ -0,0 +1,46 @@
1
+ import type { Page } from 'playwright';
2
+ export interface BezierMoveOptions {
3
+ /** Total duration in milliseconds. Default: proportional to distance (300–800ms) */
4
+ durationMs?: number;
5
+ /** Number of intermediate points. Default: 30 */
6
+ steps?: number;
7
+ }
8
+ /**
9
+ * Move the mouse from `from` to `to` along a cubic Bezier curve with natural
10
+ * human-like motion: ease-in-out timing, randomized control points, micro-jitter.
11
+ */
12
+ export declare function moveMouse(page: Page, from: {
13
+ x: number;
14
+ y: number;
15
+ }, to: {
16
+ x: number;
17
+ y: number;
18
+ }, options?: BezierMoveOptions): Promise<void>;
19
+ /**
20
+ * Move the mouse to `target` with Bezier curve animation and click.
21
+ *
22
+ * @param fromCurrent - Optional current mouse position. If omitted, the click
23
+ * is performed at `target` without a preceding Bezier move (first action).
24
+ */
25
+ export declare function animatedClick(page: Page, target: {
26
+ x: number;
27
+ y: number;
28
+ }, fromCurrent?: {
29
+ x: number;
30
+ y: number;
31
+ }, options?: BezierMoveOptions): Promise<void>;
32
+ /**
33
+ * Move the mouse to `target` (for hover/highlight actions) without clicking.
34
+ */
35
+ export declare function animatedHover(page: Page, target: {
36
+ x: number;
37
+ y: number;
38
+ }, fromCurrent?: {
39
+ x: number;
40
+ y: number;
41
+ }, options?: BezierMoveOptions): Promise<void>;
42
+ /**
43
+ * Type text into the currently focused element at a human-like typing speed.
44
+ * Assumes the field is already focused (via a preceding click).
45
+ */
46
+ export declare function humanType(page: Page, text: string): Promise<void>;