autokap 1.0.7 → 1.0.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/assets/cursors/macos.svg +4 -0
- package/assets/cursors/windows.svg +15 -0
- package/assets/skill/OPCODE-REFERENCE.md +607 -0
- package/assets/skill/README.md +39 -0
- package/assets/skill/SKILL.md +453 -468
- package/assets/skill/STUDIO-SKILL.md +476 -0
- package/assets/skill/references/examples.md +104 -0
- package/assets/skill/references/interactive-demo.md +225 -0
- package/assets/skill/references/mock-data.md +178 -0
- package/dist/action-verifier.d.ts +29 -0
- package/dist/action-verifier.js +133 -0
- package/dist/agent-action-recovery.d.ts +45 -0
- package/dist/agent-action-recovery.js +370 -0
- package/dist/agent-message-utils.d.ts +21 -0
- package/dist/agent-message-utils.js +77 -0
- package/dist/agent-url-utils.d.ts +30 -0
- package/dist/agent-url-utils.js +138 -0
- package/dist/agent.d.ts +92 -8
- package/dist/agent.js +2936 -781
- package/dist/ak-tree.d.ts +39 -0
- package/dist/ak-tree.js +368 -0
- package/dist/alt-text.d.ts +26 -0
- package/dist/alt-text.js +55 -0
- package/dist/auth-capture.d.ts +17 -0
- package/dist/auth-capture.js +164 -0
- package/dist/benchmark.d.ts +59 -0
- package/dist/benchmark.js +135 -0
- package/dist/browser-bar.d.ts +14 -6
- package/dist/browser-bar.js +145 -8
- package/dist/browser-pool.d.ts +7 -0
- package/dist/browser-pool.js +15 -5
- package/dist/browser-utils.d.ts +31 -0
- package/dist/browser-utils.js +97 -0
- package/dist/browser.d.ts +51 -1
- package/dist/browser.js +1481 -31
- package/dist/capture-alt-text.js +2 -1
- package/dist/capture-language-preflight.js +14 -0
- package/dist/capture-llm-page-identity.js +22 -10
- package/dist/capture-page-identity.d.ts +5 -7
- package/dist/capture-page-identity.js +211 -78
- package/dist/capture-preset-credentials.d.ts +50 -0
- package/dist/capture-preset-credentials.js +127 -0
- package/dist/capture-request-plan.d.ts +2 -2
- package/dist/capture-request-plan.js +64 -16
- package/dist/capture-run-optimizer.js +48 -33
- package/dist/capture-selector-memory.d.ts +5 -0
- package/dist/capture-selector-memory.js +18 -0
- package/dist/capture-strategy.d.ts +36 -0
- package/dist/capture-strategy.js +95 -0
- package/dist/capture-studio-sync.d.ts +1 -0
- package/dist/capture-studio-sync.js +9 -3
- package/dist/capture-surface-contract.d.ts +36 -0
- package/dist/capture-surface-contract.js +299 -0
- package/dist/capture-transition-engine.d.ts +28 -0
- package/dist/capture-transition-engine.js +292 -0
- package/dist/capture-variant-state.d.ts +2 -0
- package/dist/capture-variant-state.js +26 -0
- package/dist/capture-verification.d.ts +35 -0
- package/dist/capture-verification.js +95 -0
- package/dist/capture-viewport-lock.d.ts +48 -0
- package/dist/capture-viewport-lock.js +74 -0
- package/dist/circuit-breaker.d.ts +42 -0
- package/dist/circuit-breaker.js +119 -0
- package/dist/cli-config.d.ts +8 -1
- package/dist/cli-config.js +62 -6
- package/dist/cli-contract.d.ts +15 -0
- package/dist/cli-contract.js +167 -0
- package/dist/cli-runner-local.d.ts +12 -0
- package/dist/cli-runner-local.js +102 -0
- package/dist/cli-runner.d.ts +34 -0
- package/dist/cli-runner.js +433 -0
- package/dist/cli-utils.d.ts +0 -1
- package/dist/cli-utils.js +2 -5
- package/dist/cli.js +1005 -267
- package/dist/clip-orchestrator.js +9 -2
- package/dist/clip-postprocess.js +25 -16
- package/dist/cookie-dismiss.d.ts +2 -0
- package/dist/cookie-dismiss.js +48 -13
- package/dist/cost-logging.d.ts +8 -0
- package/dist/cost-logging.js +160 -46
- package/dist/cost-resolution-monitor.d.ts +16 -0
- package/dist/cost-resolution-monitor.js +34 -0
- package/dist/credential-templates.js +2 -2
- package/dist/cursor-overlay-script.d.ts +6 -0
- package/dist/cursor-overlay-script.js +169 -0
- package/dist/dom-css-purger.d.ts +65 -0
- package/dist/dom-css-purger.js +333 -0
- package/dist/dom-font-inliner.d.ts +45 -0
- package/dist/dom-font-inliner.js +148 -0
- package/dist/dom-patch-resolver.d.ts +52 -0
- package/dist/dom-patch-resolver.js +242 -0
- package/dist/dom-serializer.d.ts +82 -0
- package/dist/dom-serializer.js +378 -0
- package/dist/element-capture.d.ts +1 -41
- package/dist/element-capture.js +202 -446
- package/dist/env-validation.d.ts +5 -0
- package/dist/env-validation.js +29 -0
- package/dist/execution-schema.d.ts +4423 -0
- package/dist/execution-schema.js +507 -0
- package/dist/execution-types.d.ts +886 -0
- package/dist/execution-types.js +65 -0
- package/dist/fonts-loader.d.ts +14 -0
- package/dist/fonts-loader.js +55 -0
- package/dist/hybrid-navigator.js +12 -12
- package/dist/index.d.ts +9 -6
- package/dist/index.js +10 -4
- package/dist/legacy/agent-action-recovery.d.ts +45 -0
- package/dist/legacy/agent-action-recovery.js +370 -0
- package/dist/legacy/agent-message-utils.d.ts +21 -0
- package/dist/legacy/agent-message-utils.js +77 -0
- package/dist/legacy/agent-url-utils.d.ts +30 -0
- package/dist/legacy/agent-url-utils.js +138 -0
- package/dist/legacy/agent.d.ts +226 -0
- package/dist/legacy/agent.js +6666 -0
- package/dist/legacy/clip-orchestrator.d.ts +148 -0
- package/dist/legacy/clip-orchestrator.js +957 -0
- package/dist/legacy/credential-templates.d.ts +5 -0
- package/dist/legacy/credential-templates.js +60 -0
- package/dist/legacy/hybrid-navigator.d.ts +138 -0
- package/dist/legacy/hybrid-navigator.js +468 -0
- package/dist/legacy/llm-usage.d.ts +17 -0
- package/dist/legacy/llm-usage.js +45 -0
- package/dist/legacy/prompt-cache.d.ts +10 -0
- package/dist/legacy/prompt-cache.js +24 -0
- package/dist/legacy/prompts.d.ts +175 -0
- package/dist/legacy/prompts.js +1038 -0
- package/dist/legacy/tools.d.ts +4 -0
- package/dist/legacy/tools.js +216 -0
- package/dist/legacy/video-agent.d.ts +143 -0
- package/dist/legacy/video-agent.js +4788 -0
- package/dist/legacy/video-observation.d.ts +36 -0
- package/dist/legacy/video-observation.js +192 -0
- package/dist/legacy/video-planner.d.ts +12 -0
- package/dist/legacy/video-planner.js +501 -0
- package/dist/legacy/video-prompts.d.ts +37 -0
- package/dist/legacy/video-prompts.js +569 -0
- package/dist/legacy/video-tools.d.ts +3 -0
- package/dist/legacy/video-tools.js +59 -0
- package/dist/legacy/video-variant-state.d.ts +29 -0
- package/dist/legacy/video-variant-state.js +80 -0
- package/dist/legacy/vision-model.d.ts +17 -0
- package/dist/legacy/vision-model.js +74 -0
- package/dist/llm-healer.d.ts +63 -0
- package/dist/llm-healer.js +166 -0
- package/dist/llm-provider.d.ts +29 -0
- package/dist/llm-provider.js +80 -0
- package/dist/logger.d.ts +6 -2
- package/dist/logger.js +15 -1
- package/dist/mockup-html.js +35 -25
- package/dist/mockup.d.ts +95 -2
- package/dist/mockup.js +427 -166
- package/dist/mouse-animation.d.ts +2 -2
- package/dist/mouse-animation.js +34 -20
- package/dist/opcode-actions.d.ts +42 -0
- package/dist/opcode-actions.js +511 -0
- package/dist/opcode-runner.d.ts +51 -0
- package/dist/opcode-runner.js +770 -0
- package/dist/openrouter-client.d.ts +40 -0
- package/dist/openrouter-client.js +16 -0
- package/dist/overlay-engine.d.ts +24 -0
- package/dist/overlay-engine.js +176 -0
- package/dist/postcondition.d.ts +16 -0
- package/dist/postcondition.js +269 -0
- package/dist/program-patcher.d.ts +25 -0
- package/dist/program-patcher.js +44 -0
- package/dist/prompts.d.ts +13 -5
- package/dist/prompts.js +224 -351
- package/dist/provider-config.d.ts +12 -0
- package/dist/provider-config.js +15 -0
- package/dist/recovery-chain.d.ts +37 -0
- package/dist/recovery-chain.js +350 -0
- package/dist/remote-browser.d.ts +28 -4
- package/dist/remote-browser.js +60 -5
- package/dist/safari-browser-bar.d.ts +15 -0
- package/dist/safari-browser-bar.js +95 -0
- package/dist/safari-toolbar-asset.d.ts +15 -0
- package/dist/safari-toolbar-asset.js +12 -0
- package/dist/security.d.ts +2 -1
- package/dist/security.js +49 -10
- package/dist/selector-resolver.d.ts +34 -0
- package/dist/selector-resolver.js +181 -0
- package/dist/semantic-resolver.d.ts +35 -0
- package/dist/semantic-resolver.js +161 -0
- package/dist/server-capture-runtime.d.ts +5 -3
- package/dist/server-capture-runtime.js +42 -95
- package/dist/server-credit-usage.d.ts +2 -2
- package/dist/server-project-webhooks.d.ts +15 -1
- package/dist/server-project-webhooks.js +34 -8
- package/dist/server-screenshot-watermark.js +27 -5
- package/dist/session-profile.js +164 -1
- package/dist/sf-pro-symbols.d.ts +1 -0
- package/dist/sf-pro-symbols.js +55 -0
- package/dist/skill-packaging.d.ts +28 -0
- package/dist/skill-packaging.js +169 -0
- package/dist/smart-wait.d.ts +27 -0
- package/dist/smart-wait.js +81 -0
- package/dist/status-bar-render.d.ts +20 -0
- package/dist/status-bar-render.js +410 -0
- package/dist/status-bar.d.ts +9 -0
- package/dist/status-bar.js +298 -14
- package/dist/svg-browser-bar.d.ts +33 -0
- package/dist/svg-browser-bar.js +206 -0
- package/dist/svg-status-bar.d.ts +36 -0
- package/dist/svg-status-bar.js +597 -0
- package/dist/svg-text.d.ts +61 -0
- package/dist/svg-text.js +118 -0
- package/dist/tools.js +89 -451
- package/dist/types.d.ts +240 -5
- package/dist/types.js +23 -1
- package/dist/v2/action-verifier.d.ts +29 -0
- package/dist/v2/action-verifier.js +133 -0
- package/dist/v2/alt-text.d.ts +26 -0
- package/dist/v2/alt-text.js +55 -0
- package/dist/v2/benchmark.d.ts +59 -0
- package/dist/v2/benchmark.js +135 -0
- package/dist/v2/capture-strategy.d.ts +30 -0
- package/dist/v2/capture-strategy.js +67 -0
- package/dist/v2/capture-verification.d.ts +35 -0
- package/dist/v2/capture-verification.js +95 -0
- package/dist/v2/circuit-breaker.d.ts +42 -0
- package/dist/v2/circuit-breaker.js +119 -0
- package/dist/v2/cli-runner-local.d.ts +11 -0
- package/dist/v2/cli-runner-local.js +91 -0
- package/dist/v2/cli-runner.d.ts +34 -0
- package/dist/v2/cli-runner.js +300 -0
- package/dist/v2/compiler-prompts.d.ts +27 -0
- package/dist/v2/compiler-prompts.js +123 -0
- package/dist/v2/compiler.d.ts +37 -0
- package/dist/v2/compiler.js +147 -0
- package/dist/v2/explorer.d.ts +41 -0
- package/dist/v2/explorer.js +56 -0
- package/dist/v2/index.d.ts +37 -0
- package/dist/v2/index.js +31 -0
- package/dist/v2/llm-healer.d.ts +62 -0
- package/dist/v2/llm-healer.js +166 -0
- package/dist/v2/llm-provider.d.ts +29 -0
- package/dist/v2/llm-provider.js +80 -0
- package/dist/v2/opcode-runner.d.ts +47 -0
- package/dist/v2/opcode-runner.js +634 -0
- package/dist/v2/overlay-engine.d.ts +24 -0
- package/dist/v2/overlay-engine.js +150 -0
- package/dist/v2/postcondition.d.ts +16 -0
- package/dist/v2/postcondition.js +249 -0
- package/dist/v2/program-patcher.d.ts +25 -0
- package/dist/v2/program-patcher.js +44 -0
- package/dist/v2/recovery-chain.d.ts +30 -0
- package/dist/v2/recovery-chain.js +368 -0
- package/dist/v2/schema.d.ts +2580 -0
- package/dist/v2/schema.js +295 -0
- package/dist/v2/selector-resolver.d.ts +34 -0
- package/dist/v2/selector-resolver.js +181 -0
- package/dist/v2/semantic-resolver.d.ts +35 -0
- package/dist/v2/semantic-resolver.js +161 -0
- package/dist/v2/smart-wait.d.ts +27 -0
- package/dist/v2/smart-wait.js +81 -0
- package/dist/v2/types.d.ts +444 -0
- package/dist/v2/types.js +19 -0
- package/dist/v2/web-playwright-local.d.ts +69 -0
- package/dist/v2/web-playwright-local.js +392 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +5 -0
- package/dist/video-agent.js +18 -13
- package/dist/video-planner.js +2 -1
- package/dist/video-prompts.js +3 -3
- package/dist/web-playwright-local.d.ts +126 -0
- package/dist/web-playwright-local.js +819 -0
- package/dist/ws-auth.js +4 -1
- package/dist/ws-broadcast.d.ts +34 -0
- package/dist/ws-broadcast.js +85 -0
- package/dist/ws-connection-limits.d.ts +12 -0
- package/dist/ws-connection-limits.js +44 -0
- package/dist/ws-handler-utils.d.ts +32 -0
- package/dist/ws-handler-utils.js +139 -0
- package/dist/ws-handler.js +294 -164
- package/dist/ws-metrics-server.d.ts +9 -0
- package/dist/ws-metrics-server.js +31 -0
- package/dist/ws-server.js +41 -1
- package/package.json +51 -34
package/dist/mockup.js
CHANGED
|
@@ -2,7 +2,9 @@ import { readFile, readdir } from 'fs/promises';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import sharp from 'sharp';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import {
|
|
5
|
+
import { renderStatusBarBuffer } from './status-bar-render.js';
|
|
6
|
+
import { generateBrowserBarSvg } from './browser-bar.js';
|
|
7
|
+
import { computeMockupLayout } from './mockup-html.js';
|
|
6
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
9
|
const __dirname = path.dirname(__filename);
|
|
8
10
|
const DEVICES_DIR = path.join(__dirname, '..', 'assets', 'devices');
|
|
@@ -30,6 +32,16 @@ const DEFAULT_MOCKUP_OPTIONS = {
|
|
|
30
32
|
browserBar: {},
|
|
31
33
|
windowBorder: { color: '', width: 0, radius: 0 },
|
|
32
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Strip anything that is not a valid CSS color token.
|
|
37
|
+
* Accepts hex (#fff, #aabbcc, #aabbccdd), rgb/rgba/hsl/hsla/oklch functions,
|
|
38
|
+
* and named CSS colors. Rejects anything else to prevent CSS injection.
|
|
39
|
+
*/
|
|
40
|
+
const CSS_COLOR_RE = /^(#[0-9a-f]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lch|lab)\([^)]{1,80}\)|[a-z]{3,24}|transparent)$/i;
|
|
41
|
+
function sanitizeCssColor(value) {
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
return CSS_COLOR_RE.test(trimmed) ? trimmed : 'transparent';
|
|
44
|
+
}
|
|
33
45
|
const CONFIG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
34
46
|
let configCache = null;
|
|
35
47
|
// In-memory cache for downloaded frame asset Buffers
|
|
@@ -39,13 +51,17 @@ export function invalidateDeviceConfigCache() {
|
|
|
39
51
|
configCache = null;
|
|
40
52
|
frameBufferCache.clear();
|
|
41
53
|
}
|
|
42
|
-
function
|
|
54
|
+
function resolveSupportedOrientation(config, requestedOrientation) {
|
|
43
55
|
const supported = config.supportedOrientations ?? [];
|
|
44
56
|
if (supported.length > 0) {
|
|
45
|
-
return supported
|
|
57
|
+
return supported.includes(requestedOrientation)
|
|
58
|
+
? requestedOrientation
|
|
59
|
+
: (supported[0] ?? requestedOrientation);
|
|
46
60
|
}
|
|
47
61
|
const available = Object.keys(config.orientations ?? {});
|
|
48
|
-
return available
|
|
62
|
+
return available.includes(requestedOrientation)
|
|
63
|
+
? requestedOrientation
|
|
64
|
+
: (available[0] ?? config.frameOrientation ?? requestedOrientation);
|
|
49
65
|
}
|
|
50
66
|
function normalizeBrowserSafeAreaTop(configuredTop, dpr, fallbackLogicalTop) {
|
|
51
67
|
const rawTop = configuredTop ?? 0;
|
|
@@ -86,6 +102,7 @@ function resolveOrientationConfig(config, requestedOrientation) {
|
|
|
86
102
|
if (orientationData) {
|
|
87
103
|
return {
|
|
88
104
|
screen: orientationData.screen,
|
|
105
|
+
viewport: orientationData.viewport,
|
|
89
106
|
safeArea: orientationData.safeArea ?? { top: 0, bottom: 0 },
|
|
90
107
|
statusBar: orientationData.statusBar,
|
|
91
108
|
homeIndicator: orientationData.homeIndicator,
|
|
@@ -94,6 +111,7 @@ function resolveOrientationConfig(config, requestedOrientation) {
|
|
|
94
111
|
frameDarkUrl: orientationData.frameDarkUrl,
|
|
95
112
|
windowBorder: orientationData.windowBorder,
|
|
96
113
|
browserBarZones: orientationData.browserBarZones,
|
|
114
|
+
browserStyle: orientationData.browserStyle,
|
|
97
115
|
frameRotation: orientationData.frameRotation ?? 0,
|
|
98
116
|
needsRotation: false,
|
|
99
117
|
disableOverlays: false,
|
|
@@ -106,14 +124,20 @@ function resolveOrientationConfig(config, requestedOrientation) {
|
|
|
106
124
|
const disableOverlays = config.category === 'phone' && needsRotation;
|
|
107
125
|
return {
|
|
108
126
|
screen: config.screen ?? { logicalWidth: 0, logicalHeight: 0, scale: 1, cornerRadius: 0 },
|
|
127
|
+
viewport: config.viewport,
|
|
109
128
|
safeArea: config.safeArea ?? { top: 0, bottom: 0 },
|
|
110
129
|
statusBar: config.statusBar,
|
|
111
130
|
homeIndicator: config.homeIndicator,
|
|
112
131
|
frame: config.frame,
|
|
113
|
-
|
|
132
|
+
frameUrl: config._rowFrameUrl,
|
|
133
|
+
frameDarkUrl: config.frameDarkUrl,
|
|
134
|
+
windowBorder: config.windowBorder,
|
|
135
|
+
browserBarZones: config.browserBarZones,
|
|
136
|
+
browserStyle: config.browserStyle,
|
|
137
|
+
frameRotation: config.frameRotation ?? 0,
|
|
114
138
|
needsRotation,
|
|
115
139
|
disableOverlays,
|
|
116
|
-
frameBehindContent: false,
|
|
140
|
+
frameBehindContent: config.frameBehindContent ?? false,
|
|
117
141
|
};
|
|
118
142
|
}
|
|
119
143
|
async function loadDeviceConfigs() {
|
|
@@ -124,7 +148,7 @@ async function loadDeviceConfigs() {
|
|
|
124
148
|
const { url: supabaseUrl, serviceKey: supabaseServiceKey } = getSupabaseMockupConfig();
|
|
125
149
|
if (supabaseUrl && supabaseServiceKey) {
|
|
126
150
|
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`, {
|
|
151
|
+
const res = await fetch(`${supabaseUrl}/rest/v1/device_mockups?is_active=eq.true&order=category.asc,name.asc&select=id,slug,config,frame_url`, {
|
|
128
152
|
headers: {
|
|
129
153
|
apikey: supabaseServiceKey,
|
|
130
154
|
Authorization: `Bearer ${supabaseServiceKey}`,
|
|
@@ -138,6 +162,9 @@ async function loadDeviceConfigs() {
|
|
|
138
162
|
if (row.frame_url)
|
|
139
163
|
config._rowFrameUrl = row.frame_url;
|
|
140
164
|
configs.set(row.id, config);
|
|
165
|
+
if (row.slug) {
|
|
166
|
+
configs.set(row.slug, config);
|
|
167
|
+
}
|
|
141
168
|
}
|
|
142
169
|
configCache = { configs, expiresAt: Date.now() + CONFIG_CACHE_TTL_MS };
|
|
143
170
|
return configs;
|
|
@@ -252,15 +279,31 @@ export async function resolveDeviceFrameDescriptor(id, options) {
|
|
|
252
279
|
const config = configs.get(id);
|
|
253
280
|
if (!config)
|
|
254
281
|
return null;
|
|
255
|
-
|
|
282
|
+
// Normalize against supported orientations so stale extra configs in Supabase
|
|
283
|
+
// do not override landscape-only desktop/tablet frames.
|
|
284
|
+
const requestedOrientation = options?.orientation ?? config.frameOrientation ?? 'portrait';
|
|
285
|
+
const orientation = resolveSupportedOrientation(config, requestedOrientation);
|
|
256
286
|
const resolved = resolveOrientationConfig(config, orientation);
|
|
257
287
|
const geometry = computeResolvedFrameGeometry(resolved);
|
|
288
|
+
// When auto-rotation is applied (legacy device with needsRotation), the frame
|
|
289
|
+
// geometry is already correctly rotated by computeResolvedFrameGeometry(), but
|
|
290
|
+
// screen logical dimensions still reflect the native orientation. Swap them so
|
|
291
|
+
// that consumers (e.g. resolveDeviceViewport) compute the correct viewport.
|
|
292
|
+
const screen = resolved.needsRotation
|
|
293
|
+
? {
|
|
294
|
+
logicalWidth: resolved.screen.logicalHeight,
|
|
295
|
+
logicalHeight: resolved.screen.logicalWidth,
|
|
296
|
+
scale: resolved.screen.scale,
|
|
297
|
+
cornerRadius: resolved.screen.cornerRadius,
|
|
298
|
+
}
|
|
299
|
+
: resolved.screen;
|
|
258
300
|
return {
|
|
259
301
|
id: config.id,
|
|
260
302
|
name: config.name,
|
|
261
303
|
category: config.category,
|
|
262
304
|
orientation,
|
|
263
|
-
|
|
305
|
+
viewport: resolved.viewport,
|
|
306
|
+
screen,
|
|
264
307
|
safeArea: resolved.safeArea,
|
|
265
308
|
statusBar: resolved.statusBar,
|
|
266
309
|
homeIndicator: resolved.homeIndicator,
|
|
@@ -276,6 +319,7 @@ export async function resolveDeviceFrameDescriptor(id, options) {
|
|
|
276
319
|
screenRect: geometry.screenRect,
|
|
277
320
|
frameRotation: geometry.frameRotation,
|
|
278
321
|
frameBehindContent: resolved.frameBehindContent,
|
|
322
|
+
browserStyle: resolved.browserStyle,
|
|
279
323
|
disableOverlays: resolved.disableOverlays,
|
|
280
324
|
};
|
|
281
325
|
}
|
|
@@ -307,6 +351,33 @@ export async function rasterizeDeviceFrame(descriptor, outputScale) {
|
|
|
307
351
|
}
|
|
308
352
|
return rendered;
|
|
309
353
|
}
|
|
354
|
+
async function rasterizeFrameAssetBuffer(options) {
|
|
355
|
+
const sourceRasterWidth = Math.max(1, Math.round(options.sourceWidth * options.outputScale));
|
|
356
|
+
const sourceRasterHeight = Math.max(1, Math.round(options.sourceHeight * options.outputScale));
|
|
357
|
+
const targetRasterWidth = Math.max(1, Math.round(options.targetWidth * options.outputScale));
|
|
358
|
+
const targetRasterHeight = Math.max(1, Math.round(options.targetHeight * options.outputScale));
|
|
359
|
+
const svgDensity = Math.max(72, Math.round(72 * options.outputScale));
|
|
360
|
+
let rendered = options.frameType === "svg"
|
|
361
|
+
? await sharp(options.rawFrame, { density: svgDensity })
|
|
362
|
+
.resize(sourceRasterWidth, sourceRasterHeight)
|
|
363
|
+
.png()
|
|
364
|
+
.toBuffer()
|
|
365
|
+
: await sharp(options.rawFrame)
|
|
366
|
+
.resize(sourceRasterWidth, sourceRasterHeight)
|
|
367
|
+
.png()
|
|
368
|
+
.toBuffer();
|
|
369
|
+
if (options.frameRotation !== 0) {
|
|
370
|
+
rendered = await sharp(rendered).rotate(options.frameRotation).png().toBuffer();
|
|
371
|
+
}
|
|
372
|
+
const renderedMeta = await sharp(rendered).metadata();
|
|
373
|
+
if (renderedMeta.width !== targetRasterWidth || renderedMeta.height !== targetRasterHeight) {
|
|
374
|
+
rendered = await sharp(rendered)
|
|
375
|
+
.resize(targetRasterWidth, targetRasterHeight)
|
|
376
|
+
.png()
|
|
377
|
+
.toBuffer();
|
|
378
|
+
}
|
|
379
|
+
return rendered;
|
|
380
|
+
}
|
|
310
381
|
// ── Public API ─────────────────────────────────────────────────────────
|
|
311
382
|
export async function getDeviceFrames() {
|
|
312
383
|
const configs = await loadDeviceConfigs();
|
|
@@ -321,20 +392,75 @@ export async function getDeviceFrame(id) {
|
|
|
321
392
|
return undefined;
|
|
322
393
|
return { id: c.id, name: c.name, category: c.category, viewport: c.viewport };
|
|
323
394
|
}
|
|
324
|
-
|
|
395
|
+
// ── Sharp Compositing Helpers ──────────────────────────────────────────
|
|
396
|
+
/** Rasterize an SVG string to a PNG buffer using resvg-js.
|
|
397
|
+
* Resolves external image references (e.g. favicon URLs) before rendering. */
|
|
398
|
+
async function rasterizeSvg(svg, width) {
|
|
399
|
+
const { Resvg } = await import('@resvg/resvg-js');
|
|
400
|
+
const opts = {
|
|
401
|
+
fitTo: { mode: 'width', value: width },
|
|
402
|
+
};
|
|
403
|
+
const resvg = new Resvg(svg, opts);
|
|
404
|
+
// Resolve external image references (e.g. <image href="https://..."/>)
|
|
405
|
+
const imagesToLoad = resvg.imagesToResolve();
|
|
406
|
+
for (const href of imagesToLoad) {
|
|
407
|
+
if (href.startsWith('http://') || href.startsWith('https://')) {
|
|
408
|
+
try {
|
|
409
|
+
const res = await fetch(href);
|
|
410
|
+
if (res.ok) {
|
|
411
|
+
resvg.resolveImage(href, Buffer.from(await res.arrayBuffer()));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
// Skip unresolvable images — fallback globe icon is already inline
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const rendered = resvg.render();
|
|
420
|
+
return Buffer.from(rendered.asPng());
|
|
421
|
+
}
|
|
422
|
+
/** Create a solid-color PNG rectangle. */
|
|
423
|
+
async function createColorRect(width, height, color) {
|
|
424
|
+
const rgba = parseCssColor(color);
|
|
425
|
+
return sharp({
|
|
426
|
+
create: { width: Math.max(1, width), height: Math.max(1, height), channels: 4, background: rgba },
|
|
427
|
+
}).png().toBuffer();
|
|
428
|
+
}
|
|
429
|
+
/** Parse a CSS color string (#hex or rgb()) into sharp-compatible RGBA. */
|
|
430
|
+
function parseCssColor(color) {
|
|
431
|
+
if (color.startsWith('#')) {
|
|
432
|
+
const hex = color.slice(1);
|
|
433
|
+
if (hex.length === 3) {
|
|
434
|
+
return { r: parseInt(hex[0] + hex[0], 16), g: parseInt(hex[1] + hex[1], 16), b: parseInt(hex[2] + hex[2], 16), alpha: 1 };
|
|
435
|
+
}
|
|
436
|
+
if (hex.length === 6) {
|
|
437
|
+
return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), alpha: 1 };
|
|
438
|
+
}
|
|
439
|
+
if (hex.length === 8) {
|
|
440
|
+
return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), alpha: parseInt(hex.slice(6, 8), 16) / 255 };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
444
|
+
if (rgbMatch) {
|
|
445
|
+
return { r: +rgbMatch[1], g: +rgbMatch[2], b: +rgbMatch[3], alpha: 1 };
|
|
446
|
+
}
|
|
447
|
+
const rgbaMatch = color.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/);
|
|
448
|
+
if (rgbaMatch) {
|
|
449
|
+
return { r: +rgbaMatch[1], g: +rgbaMatch[2], b: +rgbaMatch[3], alpha: +rgbaMatch[4] };
|
|
450
|
+
}
|
|
451
|
+
// Fallback: transparent
|
|
452
|
+
return { r: 0, g: 0, b: 0, alpha: 0 };
|
|
453
|
+
}
|
|
454
|
+
export async function applyDeviceFrame(screenshot, deviceId, options) {
|
|
325
455
|
const configs = await loadDeviceConfigs();
|
|
326
456
|
const config = configs.get(deviceId);
|
|
327
457
|
if (!config)
|
|
328
458
|
throw new Error(`Unknown device frame: ${deviceId}`);
|
|
329
459
|
const opts = { ...DEFAULT_MOCKUP_OPTIONS, ...options };
|
|
330
460
|
const requestedOrientation = opts.orientation ?? 'portrait';
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
// bogus extra orientations.
|
|
335
|
-
const effectiveOrientation = config.category === 'browser'
|
|
336
|
-
? resolveBrowserOrientation(config, requestedOrientation)
|
|
337
|
-
: requestedOrientation;
|
|
461
|
+
// Normalize against supported orientations so stale extra configs in Supabase
|
|
462
|
+
// do not override landscape-only desktop/tablet/browser frames.
|
|
463
|
+
const effectiveOrientation = resolveSupportedOrientation(config, requestedOrientation);
|
|
338
464
|
const resolved = resolveOrientationConfig(config, effectiveOrientation);
|
|
339
465
|
// Disable overlays if rotation requires it (legacy phone rotation)
|
|
340
466
|
if (resolved.disableOverlays) {
|
|
@@ -346,10 +472,8 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
|
|
|
346
472
|
}
|
|
347
473
|
const scale = resolved.screen?.scale ?? 1;
|
|
348
474
|
const isBrowserDevice = config.category === 'browser';
|
|
349
|
-
const
|
|
350
|
-
|
|
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));
|
|
475
|
+
const os = Math.max(0.5, Math.min(4, opts.outputScale));
|
|
476
|
+
const geometry = computeResolvedFrameGeometry(resolved);
|
|
353
477
|
console.log(`[mockup] applyDeviceFrame: id=${deviceId}, category=${config.category}, orientation=${requestedOrientation}`);
|
|
354
478
|
console.log(`[mockup] hasOrientationConfig=${!!config.orientations?.[requestedOrientation]}, orientations=${JSON.stringify(Object.keys(config.orientations ?? {}))}`);
|
|
355
479
|
console.log(`[mockup] resolved.screen:`, JSON.stringify(resolved.screen));
|
|
@@ -359,56 +483,29 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
|
|
|
359
483
|
// Browser devices can work without a frame image
|
|
360
484
|
const hasFrame = !!(resolved.frameUrl || resolved.frame.asset);
|
|
361
485
|
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
486
|
let geo = {
|
|
377
487
|
frameWidth: geometry.frameWidth,
|
|
378
488
|
frameHeight: geometry.frameHeight,
|
|
379
489
|
screenRect: geometry.screenRect,
|
|
380
490
|
};
|
|
381
|
-
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
491
|
+
// For frameless browser devices: use device config logical dimensions as reference.
|
|
492
|
+
// The screenshot is resized to fit the content area (sharp fit:'fill').
|
|
493
|
+
// This matches how the Studio client renders frameless browsers.
|
|
494
|
+
// Safari uses a 52px visible toolbar (cropped from the 88px asset);
|
|
495
|
+
// Chrome uses a two-row 86px toolbar.
|
|
496
|
+
const BROWSER_BAR_HEIGHT = resolved.browserStyle === 'safari' ? 52 : 86;
|
|
385
497
|
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
498
|
const logicalW = resolved.screen?.logicalWidth || 1440;
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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);
|
|
499
|
+
const logicalH = resolved.screen?.logicalHeight || 900;
|
|
500
|
+
// Normalize safe area top for Chrome toolbar
|
|
501
|
+
const safeAreaTop = normalizeBrowserSafeAreaTop(resolved.safeArea.top, 1, // logical pixels — pixelScale (os) handles final scaling
|
|
502
|
+
BROWSER_BAR_HEIGHT);
|
|
401
503
|
resolved.safeArea = { ...resolved.safeArea, top: safeAreaTop };
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
};
|
|
406
|
-
|
|
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}`);
|
|
504
|
+
// Frame = device config logical dimensions (not screenshot dimensions)
|
|
505
|
+
geo.frameWidth = logicalW;
|
|
506
|
+
geo.frameHeight = logicalH;
|
|
507
|
+
geo.screenRect = { x: 0, y: 0, width: logicalW, height: logicalH };
|
|
508
|
+
console.log(`[mockup] frameless browser: logicalW=${logicalW}, logicalH=${logicalH}, os=${os}, geo=${geo.frameWidth}x${geo.frameHeight}`);
|
|
412
509
|
}
|
|
413
510
|
else if (!hasFrame && geo.frameWidth === 0 && geo.frameHeight === 0) {
|
|
414
511
|
// Non-browser frameless fallback
|
|
@@ -417,9 +514,18 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
|
|
|
417
514
|
if (geo.screenRect.width === 0)
|
|
418
515
|
geo.screenRect = { x: 0, y: 0, width: geo.frameWidth, height: geo.frameHeight };
|
|
419
516
|
}
|
|
420
|
-
if (
|
|
421
|
-
|
|
422
|
-
frameData = await
|
|
517
|
+
if (hasFrame) {
|
|
518
|
+
const rawFrame = await loadFrameAsset(config, resolved);
|
|
519
|
+
frameData = await rasterizeFrameAssetBuffer({
|
|
520
|
+
rawFrame,
|
|
521
|
+
frameType: resolved.frame.type,
|
|
522
|
+
sourceWidth: resolved.frame.width,
|
|
523
|
+
sourceHeight: resolved.frame.height,
|
|
524
|
+
targetWidth: geo.frameWidth,
|
|
525
|
+
targetHeight: geo.frameHeight,
|
|
526
|
+
frameRotation: geometry.frameRotation,
|
|
527
|
+
outputScale: os,
|
|
528
|
+
});
|
|
423
529
|
}
|
|
424
530
|
// Always compute content area with all safe areas visible — this matches
|
|
425
531
|
// the browser viewport the screenshot was captured at.
|
|
@@ -446,25 +552,36 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
|
|
|
446
552
|
// Get incoming screenshot dimensions for logging
|
|
447
553
|
const screenshotMeta = await sharp(screenshot).metadata();
|
|
448
554
|
console.log(`[mockup] input screenshot: ${screenshotMeta.width}x${screenshotMeta.height}`);
|
|
449
|
-
|
|
450
|
-
|
|
555
|
+
const physicalContentW = Math.round(contentW * os);
|
|
556
|
+
const physicalContentH = Math.round(contentH * os);
|
|
557
|
+
console.log(`[mockup] resize target: ${physicalContentW}x${physicalContentH}`);
|
|
558
|
+
// Sample edge colors from the ORIGINAL screenshot before resize.
|
|
559
|
+
// Resizing (especially large downscales with fit:'fill') averages edge pixels,
|
|
560
|
+
// producing grayish artifacts that make safe area fills look darker than intended.
|
|
561
|
+
const colors = await sampleEdgeColors(screenshot);
|
|
562
|
+
// Resize screenshot to physical content dimensions (contentW*os × contentH*os).
|
|
563
|
+
// The mockup HTML container is sized at logical dimensions (contentW × contentH)
|
|
564
|
+
// with the <img> at width:100%;height:100%. Playwright renders the mockup at
|
|
565
|
+
// deviceScaleFactor=os, so the physical-resolution image maps 1:1 to output pixels.
|
|
451
566
|
const screenshotForMockup = await sharp(screenshot)
|
|
452
|
-
.resize(
|
|
567
|
+
.resize(physicalContentW, physicalContentH, { fit: 'fill' })
|
|
453
568
|
.png()
|
|
454
569
|
.toBuffer();
|
|
455
|
-
const screenshotBase64 = screenshotForMockup.toString('base64');
|
|
456
|
-
// Sample edge colors for dynamic safe area fills
|
|
457
|
-
const colors = await sampleEdgeColors(screenshotForMockup);
|
|
458
570
|
if (opts.safeAreaTopColor)
|
|
459
|
-
colors.topColor = opts.safeAreaTopColor;
|
|
571
|
+
colors.topColor = sanitizeCssColor(opts.safeAreaTopColor);
|
|
460
572
|
if (opts.safeAreaBottomColor)
|
|
461
|
-
colors.bottomColor = opts.safeAreaBottomColor;
|
|
573
|
+
colors.bottomColor = sanitizeCssColor(opts.safeAreaBottomColor);
|
|
462
574
|
if (opts.safeAreaLeftColor)
|
|
463
|
-
colors.leftColor = opts.safeAreaLeftColor;
|
|
575
|
+
colors.leftColor = sanitizeCssColor(opts.safeAreaLeftColor);
|
|
464
576
|
if (opts.safeAreaRightColor)
|
|
465
|
-
colors.rightColor = opts.safeAreaRightColor;
|
|
466
|
-
|
|
467
|
-
|
|
577
|
+
colors.rightColor = sanitizeCssColor(opts.safeAreaRightColor);
|
|
578
|
+
console.log(`[mockup] sampled colors: top=${colors.topColor} bottom=${colors.bottomColor} left=${colors.leftColor} right=${colors.rightColor}`);
|
|
579
|
+
// Determine color scheme: use explicit override if provided, otherwise auto-detect from edge colors.
|
|
580
|
+
// Laptops (MacBook) always use dark menu bar (white text on black background).
|
|
581
|
+
const isLaptop = config.category === 'laptop';
|
|
582
|
+
const autoColorScheme = isLaptop
|
|
583
|
+
? 'dark'
|
|
584
|
+
: (opts.colorScheme ?? (isDarkBackground(colors.topColor) ? 'dark' : 'light'));
|
|
468
585
|
const lum = parseLuminance(colors.bottomColor);
|
|
469
586
|
const hiColor = lum > 170 ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.5)';
|
|
470
587
|
// Browser devices: swap frame to dark variant if needed
|
|
@@ -473,96 +590,262 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
|
|
|
473
590
|
const darkRes = await fetch(resolved.frameDarkUrl);
|
|
474
591
|
if (darkRes.ok) {
|
|
475
592
|
const darkRaw = Buffer.from(await darkRes.arrayBuffer());
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
:
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
593
|
+
frameData = await rasterizeFrameAssetBuffer({
|
|
594
|
+
rawFrame: darkRaw,
|
|
595
|
+
frameType: resolved.frame.type,
|
|
596
|
+
sourceWidth: resolved.frame.width,
|
|
597
|
+
sourceHeight: resolved.frame.height,
|
|
598
|
+
targetWidth: geo.frameWidth,
|
|
599
|
+
targetHeight: geo.frameHeight,
|
|
600
|
+
frameRotation: geometry.frameRotation,
|
|
601
|
+
outputScale: os,
|
|
602
|
+
});
|
|
487
603
|
}
|
|
488
604
|
}
|
|
489
605
|
catch {
|
|
490
606
|
// Keep light frame as fallback
|
|
491
607
|
}
|
|
492
608
|
}
|
|
493
|
-
const
|
|
609
|
+
const rawWindowBorder = opts.windowBorder?.width ? opts.windowBorder : resolved.windowBorder;
|
|
494
610
|
const browserWindowBorder = isBrowserDevice
|
|
495
|
-
? normalizeBrowserWindowBorder(
|
|
611
|
+
? normalizeBrowserWindowBorder(rawWindowBorder ? { ...rawWindowBorder, color: sanitizeCssColor(rawWindowBorder.color) } : rawWindowBorder, 1)
|
|
496
612
|
: undefined;
|
|
497
613
|
// Safe area toggles: when hidden, make fills transparent instead of removing them.
|
|
498
614
|
// This keeps the content area (screenshot placement) at the viewport-matching size.
|
|
499
615
|
const safeAreaFillColors = {
|
|
500
|
-
top: opts.showSafeAreaTop ? colors.topColor : 'transparent',
|
|
616
|
+
top: isLaptop ? '#000000' : (opts.showSafeAreaTop ? colors.topColor : 'transparent'),
|
|
501
617
|
bottom: opts.showSafeAreaBottom ? colors.bottomColor : 'transparent',
|
|
502
618
|
left: opts.showSafeAreaLeft ? colors.leftColor : 'transparent',
|
|
503
619
|
right: opts.showSafeAreaRight ? colors.rightColor : 'transparent',
|
|
504
620
|
};
|
|
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
621
|
const wbw = browserWindowBorder?.width ?? 0;
|
|
543
|
-
const renderW = Math.round(
|
|
544
|
-
const renderH = Math.round(
|
|
545
|
-
|
|
546
|
-
|
|
622
|
+
const renderW = Math.round(geo.frameWidth);
|
|
623
|
+
const renderH = Math.round(geo.frameHeight);
|
|
624
|
+
const cornerRadius = (resolved.screen?.cornerRadius ?? 0) * scale;
|
|
625
|
+
console.log(`[mockup] composeMockup: ${renderW}x${renderH} @${os}x, showBrowserBar=${isBrowserDevice}, windowBorderWidth=${wbw}`);
|
|
626
|
+
// ── Sharp compositing — layer-by-layer mockup assembly ──
|
|
627
|
+
// All dimensions in physical pixels (logical * os).
|
|
628
|
+
const pw = Math.round(renderW * os);
|
|
629
|
+
const ph = Math.round(renderH * os);
|
|
630
|
+
const p = (v) => Math.round(v * os);
|
|
631
|
+
// Browser window border is painted as an overlay within the existing geometry.
|
|
632
|
+
// It must not change the mockup's intrinsic size or shift the screen rect.
|
|
633
|
+
const sr = { x: geo.screenRect.x, y: geo.screenRect.y, width: geo.screenRect.width, height: geo.screenRect.height };
|
|
634
|
+
const compositeInputs = [];
|
|
635
|
+
// z:0 — Frame behind content
|
|
636
|
+
if (frameData && resolved.frameBehindContent) {
|
|
637
|
+
compositeInputs.push({ input: frameData, left: 0, top: 0 });
|
|
638
|
+
}
|
|
639
|
+
// z:1 — Screen background with rounded corners + safe area fills
|
|
640
|
+
// Build the screen content as a sub-composition with rounded-corner mask
|
|
641
|
+
const screenW = p(sr.width);
|
|
642
|
+
const screenH = p(sr.height);
|
|
643
|
+
if (screenW > 0 && screenH > 0) {
|
|
644
|
+
const screenLayers = [];
|
|
645
|
+
// Safe area fill rectangles (relative to screen rect origin)
|
|
646
|
+
const showTop = resolved.safeArea.top > 0;
|
|
647
|
+
const showBottom = resolved.safeArea.bottom > 0;
|
|
648
|
+
const showLeft = (resolved.safeArea.left ?? 0) > 0;
|
|
649
|
+
const showRight = (resolved.safeArea.right ?? 0) > 0;
|
|
650
|
+
const topPx = showTop ? resolved.safeArea.top * scale : 0;
|
|
651
|
+
const bottomPx = showBottom ? resolved.safeArea.bottom * scale : 0;
|
|
652
|
+
const leftPx = showLeft ? (resolved.safeArea.left ?? 0) * scale : 0;
|
|
653
|
+
const rightPx = showRight ? (resolved.safeArea.right ?? 0) * scale : 0;
|
|
654
|
+
if (showLeft && safeAreaFillColors.left !== 'transparent') {
|
|
655
|
+
screenLayers.push({
|
|
656
|
+
input: await createColorRect(p(leftPx), screenH, safeAreaFillColors.left),
|
|
657
|
+
left: 0, top: 0,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
if (showRight && safeAreaFillColors.right !== 'transparent') {
|
|
661
|
+
screenLayers.push({
|
|
662
|
+
input: await createColorRect(p(rightPx), screenH, safeAreaFillColors.right),
|
|
663
|
+
left: screenW - p(rightPx), top: 0,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
if (showTop && safeAreaFillColors.top !== 'transparent') {
|
|
667
|
+
screenLayers.push({
|
|
668
|
+
input: await createColorRect(screenW - p(leftPx) - p(rightPx), p(topPx), safeAreaFillColors.top),
|
|
669
|
+
left: p(leftPx), top: 0,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (showBottom && safeAreaFillColors.bottom !== 'transparent') {
|
|
673
|
+
screenLayers.push({
|
|
674
|
+
input: await createColorRect(screenW - p(leftPx) - p(rightPx), p(bottomPx), safeAreaFillColors.bottom),
|
|
675
|
+
left: p(leftPx), top: screenH - p(bottomPx),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
// z:2 — Screenshot content
|
|
679
|
+
screenLayers.push({
|
|
680
|
+
input: screenshotForMockup,
|
|
681
|
+
left: p(layout.contentArea.x - sr.x),
|
|
682
|
+
top: p(layout.contentArea.y - sr.y),
|
|
683
|
+
});
|
|
684
|
+
// z:4 — Home indicator
|
|
685
|
+
if (opts.showHomeIndicator && resolved.homeIndicator && resolved.safeArea.bottom > 0) {
|
|
686
|
+
const hi = resolved.homeIndicator;
|
|
687
|
+
const hiW = p(hi.width * scale);
|
|
688
|
+
const hiH = p(hi.height * scale);
|
|
689
|
+
const hiR = p(hi.cornerRadius * scale);
|
|
690
|
+
const hiLeft = p((sr.width - hi.width * scale) / 2);
|
|
691
|
+
const hiTop = p(sr.height - hi.bottomOffset * scale - hi.height * scale);
|
|
692
|
+
const hiSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${hiW}" height="${hiH}"><rect width="${hiW}" height="${hiH}" rx="${hiR}" fill="${hiColor}"/></svg>`;
|
|
693
|
+
screenLayers.push({
|
|
694
|
+
input: await rasterizeSvg(hiSvg, hiW),
|
|
695
|
+
left: hiLeft, top: hiTop,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
// z:5 — Status bar or Browser bar
|
|
699
|
+
// Status bar uses satori (CSS flexbox engine) for pixel-perfect parity
|
|
700
|
+
// with the client HTML rendering. Generated at logical dimensions, then
|
|
701
|
+
// rasterized at physical pixel width.
|
|
702
|
+
const showStatusBar = !isBrowserDevice && (isLaptop || opts.showStatusBar);
|
|
703
|
+
if (showStatusBar && resolved.statusBar && resolved.safeArea.top > 0) {
|
|
704
|
+
const sbW_logical = Math.round(sr.width);
|
|
705
|
+
const sbH_logical = Math.round(resolved.safeArea.top * scale);
|
|
706
|
+
const sbPng = await renderStatusBarBuffer({
|
|
707
|
+
config: { ...opts.statusBar, colorScheme: autoColorScheme },
|
|
708
|
+
width: sbW_logical,
|
|
709
|
+
height: sbH_logical,
|
|
710
|
+
scale,
|
|
711
|
+
deviceType: resolved.statusBar.type ?? 'iphone-dynamic-island',
|
|
712
|
+
layout: resolved.statusBar.layout,
|
|
713
|
+
}, screenW);
|
|
714
|
+
screenLayers.push({ input: sbPng, left: 0, top: 0 });
|
|
715
|
+
}
|
|
716
|
+
if (isBrowserDevice && resolved.safeArea.top > 0) {
|
|
717
|
+
// Browser bar uses viewBox-based scaling internally, so it can accept
|
|
718
|
+
// either logical or physical dimensions. We pass physical for consistency
|
|
719
|
+
// with the compositing canvas.
|
|
720
|
+
const bbW = screenW;
|
|
721
|
+
const bbH = p(resolved.safeArea.top * scale);
|
|
722
|
+
const bbSvg = generateBrowserBarSvg({
|
|
723
|
+
config: {
|
|
724
|
+
...opts.browserBar,
|
|
725
|
+
colorScheme: autoColorScheme,
|
|
726
|
+
style: resolved.browserStyle ?? 'chrome',
|
|
727
|
+
},
|
|
728
|
+
width: bbW,
|
|
729
|
+
height: bbH,
|
|
730
|
+
scale,
|
|
731
|
+
});
|
|
732
|
+
screenLayers.push({
|
|
733
|
+
input: await rasterizeSvg(bbSvg, bbW),
|
|
734
|
+
left: 0, top: 0,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
// Compose screen content, then apply rounded-corner mask
|
|
738
|
+
let screenBuffer = await sharp({
|
|
739
|
+
create: { width: screenW, height: screenH, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
740
|
+
}).composite(screenLayers).png().toBuffer();
|
|
741
|
+
// Apply rounded corner mask if radius > 0
|
|
742
|
+
if (cornerRadius > 0) {
|
|
743
|
+
const cr = p(cornerRadius);
|
|
744
|
+
const borderRadius = isLaptop ? `${cr},${cr},0,0` : `${cr}`;
|
|
745
|
+
const maskSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${screenW}" height="${screenH}"><rect width="${screenW}" height="${screenH}" rx="${borderRadius.split(',')[0]}" ry="${borderRadius.split(',')[0]}" fill="white"/></svg>`;
|
|
746
|
+
const maskBuffer = await rasterizeSvg(maskSvg, screenW);
|
|
747
|
+
// For laptop: only top corners are rounded
|
|
748
|
+
let finalMask;
|
|
749
|
+
if (isLaptop) {
|
|
750
|
+
const laptopMaskSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${screenW}" height="${screenH}">
|
|
751
|
+
<path d="M${cr},0 L${screenW - cr},0 Q${screenW},0 ${screenW},${cr} L${screenW},${screenH} L0,${screenH} L0,${cr} Q0,0 ${cr},0 Z" fill="white"/>
|
|
752
|
+
</svg>`;
|
|
753
|
+
finalMask = await rasterizeSvg(laptopMaskSvg, screenW);
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
finalMask = maskBuffer;
|
|
757
|
+
}
|
|
758
|
+
// Apply mask as alpha channel
|
|
759
|
+
screenBuffer = await sharp(screenBuffer)
|
|
760
|
+
.composite([{ input: finalMask, blend: 'dest-in' }])
|
|
761
|
+
.png().toBuffer();
|
|
762
|
+
}
|
|
763
|
+
compositeInputs.push({ input: screenBuffer, left: p(sr.x), top: p(sr.y) });
|
|
764
|
+
}
|
|
765
|
+
// z:8 — Frame on top (when !frameBehindContent)
|
|
766
|
+
if (frameData && !resolved.frameBehindContent) {
|
|
767
|
+
compositeInputs.push({ input: frameData, left: 0, top: 0 });
|
|
768
|
+
}
|
|
769
|
+
// z:9 — Window border (browser frameless)
|
|
770
|
+
if (browserWindowBorder && browserWindowBorder.width > 0) {
|
|
771
|
+
const bw = p(browserWindowBorder.width);
|
|
772
|
+
const br = p(browserWindowBorder.radius ?? 0);
|
|
773
|
+
const bc = sanitizeCssColor(browserWindowBorder.color);
|
|
774
|
+
const borderSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${pw}" height="${ph}">
|
|
775
|
+
<rect x="${bw / 2}" y="${bw / 2}" width="${pw - bw}" height="${ph - bw}" rx="${br}" ry="${br}" fill="none" stroke="${bc}" stroke-width="${bw}"/>
|
|
776
|
+
</svg>`;
|
|
777
|
+
compositeInputs.push({
|
|
778
|
+
input: await rasterizeSvg(borderSvg, pw),
|
|
779
|
+
left: 0, top: 0,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
// Final composition
|
|
783
|
+
return sharp({
|
|
784
|
+
create: { width: pw, height: ph, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
785
|
+
}).composite(compositeInputs).png().toBuffer();
|
|
547
786
|
}
|
|
548
787
|
// ── Edge Color Sampling ────────────────────────────────────────────────
|
|
549
|
-
async function sampleEdgeColors(
|
|
550
|
-
const meta = await sharp(
|
|
788
|
+
async function sampleEdgeColors(screenshot) {
|
|
789
|
+
const meta = await sharp(screenshot).metadata();
|
|
551
790
|
const w = meta.width ?? 1;
|
|
552
791
|
const h = meta.height ?? 1;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
792
|
+
// Sample a centered strip slightly inset from each edge:
|
|
793
|
+
// - Center third horizontally (for top/bottom) or vertically (for left/right)
|
|
794
|
+
// to avoid scrollbars and corner artefacts
|
|
795
|
+
// - Inset 2px from the edge to skip viewport border artefacts
|
|
796
|
+
// - Use MEDIAN instead of MEAN to ignore dark foreground elements (text, icons)
|
|
797
|
+
// that drag the average down and produce falsely darker safe area fills
|
|
798
|
+
const inset = Math.min(2, Math.floor(Math.min(w, h) / 10));
|
|
799
|
+
const stripH = Math.max(1, Math.min(6, Math.floor(h / 20)));
|
|
800
|
+
const stripW = Math.max(1, Math.min(6, Math.floor(w / 20)));
|
|
801
|
+
const cX = Math.floor(w / 3);
|
|
802
|
+
const cW = Math.max(1, Math.floor(w / 3));
|
|
803
|
+
const cY = Math.floor(h / 3);
|
|
804
|
+
const cH = Math.max(1, Math.floor(h / 3));
|
|
805
|
+
const [topRgb, bottomRgb, leftRgb, rightRgb] = await Promise.all([
|
|
806
|
+
medianColor(screenshot, { left: cX, top: inset, width: cW, height: stripH }),
|
|
807
|
+
medianColor(screenshot, { left: cX, top: h - inset - stripH, width: cW, height: stripH }),
|
|
808
|
+
medianColor(screenshot, { left: inset, top: cY, width: stripW, height: cH }),
|
|
809
|
+
medianColor(screenshot, { left: w - inset - stripW, top: cY, width: stripW, height: cH }),
|
|
558
810
|
]);
|
|
559
811
|
return {
|
|
560
|
-
topColor:
|
|
561
|
-
bottomColor:
|
|
562
|
-
leftColor:
|
|
563
|
-
rightColor:
|
|
812
|
+
topColor: snapEdgeColor(topRgb),
|
|
813
|
+
bottomColor: snapEdgeColor(bottomRgb),
|
|
814
|
+
leftColor: snapEdgeColor(leftRgb),
|
|
815
|
+
rightColor: snapEdgeColor(rightRgb),
|
|
564
816
|
};
|
|
565
817
|
}
|
|
818
|
+
/** Extract the median RGB color from a region — robust to dark text/icons on light backgrounds */
|
|
819
|
+
async function medianColor(buf, region) {
|
|
820
|
+
const { data, info } = await sharp(buf)
|
|
821
|
+
.extract(region)
|
|
822
|
+
.removeAlpha()
|
|
823
|
+
.raw()
|
|
824
|
+
.toBuffer({ resolveWithObject: true });
|
|
825
|
+
const n = info.width * info.height;
|
|
826
|
+
const r = new Array(n);
|
|
827
|
+
const g = new Array(n);
|
|
828
|
+
const b = new Array(n);
|
|
829
|
+
for (let i = 0; i < n; i++) {
|
|
830
|
+
r[i] = data[i * 3];
|
|
831
|
+
g[i] = data[i * 3 + 1];
|
|
832
|
+
b[i] = data[i * 3 + 2];
|
|
833
|
+
}
|
|
834
|
+
r.sort((a, b) => a - b);
|
|
835
|
+
g.sort((a, b) => a - b);
|
|
836
|
+
b.sort((a, b) => a - b);
|
|
837
|
+
const mid = Math.floor(n / 2);
|
|
838
|
+
return `rgb(${r[mid]},${g[mid]},${b[mid]})`;
|
|
839
|
+
}
|
|
840
|
+
/** Snap near-white and near-black edge colors to pure values */
|
|
841
|
+
function snapEdgeColor(rgb) {
|
|
842
|
+
const lum = parseLuminance(rgb);
|
|
843
|
+
if (lum > 240)
|
|
844
|
+
return 'rgb(255,255,255)';
|
|
845
|
+
if (lum < 15)
|
|
846
|
+
return 'rgb(0,0,0)';
|
|
847
|
+
return rgb;
|
|
848
|
+
}
|
|
566
849
|
function channelsToRgb(channels) {
|
|
567
850
|
const r = Math.round(channels[0].mean);
|
|
568
851
|
const g = Math.round(channels[1].mean);
|
|
@@ -583,26 +866,4 @@ function parseLuminance(rgbStr) {
|
|
|
583
866
|
const [r, g, b] = match.map(Number);
|
|
584
867
|
return r * 0.299 + g * 0.587 + b * 0.114;
|
|
585
868
|
}
|
|
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
869
|
//# sourceMappingURL=mockup.js.map
|