capture-flow 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +256 -2
- package/bin/capture-flow.mjs +12 -0
- package/package.json +56 -4
- package/skills/capture-flow/SKILL.md +248 -0
- package/skills/capture-flow/references/capture-recipes.md +155 -0
- package/skills/capture-flow/references/store-sizes.md +91 -0
- package/src/build.ts +186 -0
- package/src/capture.ts +77 -0
- package/src/cli.ts +139 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +98 -0
- package/src/render.ts +75 -0
- package/src/verify.ts +90 -0
- package/vendor/wajha/LICENSE +21 -0
- package/vendor/wajha/NOTICE +21 -0
- package/vendor/wajha/engine/background.ts +55 -0
- package/vendor/wajha/engine/bidi.ts +589 -0
- package/vendor/wajha/engine/cache.ts +96 -0
- package/vendor/wajha/engine/color.ts +125 -0
- package/vendor/wajha/engine/devices/android.ts +53 -0
- package/vendor/wajha/engine/devices/browser.ts +170 -0
- package/vendor/wajha/engine/devices/finish.ts +154 -0
- package/vendor/wajha/engine/devices/geometry.ts +326 -0
- package/vendor/wajha/engine/devices/index.ts +143 -0
- package/vendor/wajha/engine/devices/ipad.ts +21 -0
- package/vendor/wajha/engine/devices/iphone.ts +55 -0
- package/vendor/wajha/engine/devices/macbook.ts +187 -0
- package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
- package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
- package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
- package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
- package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
- package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
- package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
- package/vendor/wajha/engine/devices/mock/art.ts +557 -0
- package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
- package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
- package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
- package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
- package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
- package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
- package/vendor/wajha/engine/devices/paths.ts +113 -0
- package/vendor/wajha/engine/devices/screen.ts +75 -0
- package/vendor/wajha/engine/devices/shadow.ts +124 -0
- package/vendor/wajha/engine/devices/slab.ts +200 -0
- package/vendor/wajha/engine/devices/vision.ts +43 -0
- package/vendor/wajha/engine/devices/watch.ts +14 -0
- package/vendor/wajha/engine/geometry.ts +152 -0
- package/vendor/wajha/engine/images.ts +96 -0
- package/vendor/wajha/engine/index.ts +20 -0
- package/vendor/wajha/engine/linebreak.ts +226 -0
- package/vendor/wajha/engine/markup.ts +84 -0
- package/vendor/wajha/engine/mock.ts +158 -0
- package/vendor/wajha/engine/paint.ts +474 -0
- package/vendor/wajha/engine/patterns.ts +263 -0
- package/vendor/wajha/engine/render.ts +364 -0
- package/vendor/wajha/engine/shapes.ts +260 -0
- package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
- package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
- package/vendor/wajha/engine/text.ts +694 -0
- package/vendor/wajha/engine/types.ts +190 -0
- package/vendor/wajha/export/paths.ts +197 -0
- package/vendor/wajha/export/png.ts +223 -0
- package/vendor/wajha/export/types.ts +60 -0
- package/vendor/wajha/model/adapt.ts +889 -0
- package/vendor/wajha/model/catalog.ts +369 -0
- package/vendor/wajha/model/clone.ts +58 -0
- package/vendor/wajha/model/colorMath.ts +128 -0
- package/vendor/wajha/model/defaults.ts +386 -0
- package/vendor/wajha/model/geometry.ts +95 -0
- package/vendor/wajha/model/ids.ts +13 -0
- package/vendor/wajha/model/instantiate.ts +280 -0
- package/vendor/wajha/model/mirror.ts +43 -0
- package/vendor/wajha/model/resolve.ts +87 -0
- package/vendor/wajha/model/rules.ts +249 -0
- package/vendor/wajha/model/stub.ts +4 -0
- package/vendor/wajha/model/types.ts +456 -0
- package/vendor/wajha/model/typography.ts +28 -0
- package/vendor/wajha/model/validate.ts +917 -0
- package/vendor/wajha/templates/builtin/aurora.ts +163 -0
- package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
- package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
- package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
- package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
- package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
- package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
- package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
- package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
- package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
- package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
- package/vendor/wajha/templates/builtin/noor.ts +107 -0
- package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
- package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
- package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
- package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
- package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
- package/vendor/wajha/templates/builtin/sage.ts +94 -0
- package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
- package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
- package/vendor/wajha/templates/index.ts +55 -0
- package/vendor/wajha/templates/kit.ts +761 -0
- package/vendor/wajha/ui/sortShots.ts +239 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Capture recipes
|
|
2
|
+
|
|
3
|
+
`capture-flow` never takes screenshots itself. Each flow step is a shell command, so
|
|
4
|
+
use whatever produces correct native-resolution images.
|
|
5
|
+
|
|
6
|
+
Before building, check every raw capture:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
magick identify -format '%f %wx%h %[channels]\n' captures/*.png
|
|
10
|
+
# macOS without ImageMagick:
|
|
11
|
+
sips -g pixelWidth -g pixelHeight captures/iphone-01.png
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The output must be opaque (no alpha). Flatten if needed:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
magick in.png -background '#000000' -alpha remove -alpha off out.png
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Web app with Playwright
|
|
21
|
+
|
|
22
|
+
Install Playwright outside the project if you do not want it in dependencies:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install --prefix /tmp/pw playwright
|
|
26
|
+
PLAYWRIGHT_BROWSERS_PATH=/tmp/pw npx --prefix /tmp/pw playwright install chromium
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`capture.mjs`:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
import { chromium } from 'playwright';
|
|
33
|
+
|
|
34
|
+
const browser = await chromium.launch();
|
|
35
|
+
const context = await browser.newContext({
|
|
36
|
+
viewport: { width: 440, height: 956 },
|
|
37
|
+
deviceScaleFactor: 3, // 440×956 → 1320×2868
|
|
38
|
+
isMobile: true,
|
|
39
|
+
hasTouch: true,
|
|
40
|
+
locale: 'ar-SA',
|
|
41
|
+
timezoneId: 'Asia/Riyadh',
|
|
42
|
+
colorScheme: 'dark',
|
|
43
|
+
serviceWorkers: 'block', // stale caches hide new UI
|
|
44
|
+
});
|
|
45
|
+
const page = await context.newPage();
|
|
46
|
+
|
|
47
|
+
// Seed settings before the app boots (consent, theme, onboarding).
|
|
48
|
+
await context.addInitScript(() => {
|
|
49
|
+
localStorage.setItem('app:telemetry', 'off');
|
|
50
|
+
localStorage.setItem('app:onboarded', 'true');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await page.goto('https://example.com', { waitUntil: 'commit', timeout: 120_000 });
|
|
54
|
+
await page.getByRole('button', { name: 'تشغيل' }).waitFor({ timeout: 120_000 });
|
|
55
|
+
await page.waitForTimeout(700); // let fonts, audio state, and animation settle
|
|
56
|
+
|
|
57
|
+
await page.screenshot({ path: 'captures/iphone-01.png' });
|
|
58
|
+
await browser.close();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Rules of thumb:
|
|
62
|
+
|
|
63
|
+
- `waitUntil: 'commit'` plus an explicit UI wait beats `networkidle` on hydrated apps.
|
|
64
|
+
- Hydration on a cold CDN can take 20 s. Give `waitFor` a real timeout.
|
|
65
|
+
- Take one browser context per family, or resize the viewport and repeat.
|
|
66
|
+
- Keep `omitBackground` off; transparency fails `verify`.
|
|
67
|
+
- If a consent dialog appears, dismiss it once with a real click **and** seed the
|
|
68
|
+
setting, so retries stay clean.
|
|
69
|
+
- For audio/video states, start playback and wait for the visible time counter to move.
|
|
70
|
+
|
|
71
|
+
### When the app depends on a service worker or peer transport
|
|
72
|
+
|
|
73
|
+
If playback in the browser uses a transport the headless run cannot provide (WebTorrent,
|
|
74
|
+
WebRTC), point the request at a local/web seed instead of screenshotting an empty player:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
await context.route('**/webtorrent/**', async (route) => {
|
|
78
|
+
const url = new URL(route.request().url());
|
|
79
|
+
await route.continue({ url: url.toString().replace('/webtorrent/', '/media/') });
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Document the fallback in the capture script so the next person knows why it exists.
|
|
84
|
+
|
|
85
|
+
## iOS Simulator
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
xcrun simctl boot "iPhone 17 Pro Max" 2>/dev/null || true
|
|
89
|
+
xcrun simctl bootstatus booted -b
|
|
90
|
+
|
|
91
|
+
# Deterministic status bar.
|
|
92
|
+
xcrun simctl status_bar booted override \
|
|
93
|
+
--time "9:41" --batteryState charged --batteryLevel 100 \
|
|
94
|
+
--wifiBars 3 --cellularBars 4
|
|
95
|
+
|
|
96
|
+
# Drive the app: deep link, Maestro, or XCTest.
|
|
97
|
+
xcrun simctl openurl booted "myapp://player"
|
|
98
|
+
|
|
99
|
+
xcrun simctl io booted screenshot captures/iphone-01.png
|
|
100
|
+
xcrun simctl status_bar booted clear
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`simctl io screenshot` captures at the simulated device's native pixel size. Pick a
|
|
104
|
+
device whose native resolution matches the size you export (iPhone 17 Pro Max →
|
|
105
|
+
1320 × 2868). For iPad, boot an iPad Pro 13″ simulator.
|
|
106
|
+
|
|
107
|
+
## Android emulator or device
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
adb shell screencap -p /sdcard/shot.png
|
|
111
|
+
adb pull /sdcard/shot.png captures/android-01.png
|
|
112
|
+
# or, without a temp file:
|
|
113
|
+
adb exec-out screencap -p > captures/android-01.png
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Deterministic status bar:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
adb shell settings put global sysui_demo_allowed 1
|
|
120
|
+
adb shell am broadcast -a com.android.systemui.demo -e command enter
|
|
121
|
+
adb shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0941
|
|
122
|
+
adb shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false
|
|
123
|
+
adb shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Use an emulator profile with the right resolution (1080 × 2400 for a modern phone,
|
|
127
|
+
1600 × 2560 for a 10-inch tablet in landscape) and set the locale:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
adb shell "setprop persist.sys.locale ar-SA; setprop persist.sys.language ar; setprop persist.sys.country SA"
|
|
131
|
+
adb shell am force-stop com.example.app
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Maestro
|
|
135
|
+
|
|
136
|
+
```yaml
|
|
137
|
+
appId: com.example.app
|
|
138
|
+
---
|
|
139
|
+
- launchApp:
|
|
140
|
+
clearState: false
|
|
141
|
+
- tapOn: "Get started"
|
|
142
|
+
- takeScreenshot: captures/android-01
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`takeScreenshot` writes `<name>.png` in the working directory of the Maestro run.
|
|
146
|
+
|
|
147
|
+
## Native app deep links
|
|
148
|
+
|
|
149
|
+
Prefer a deep link or launch argument over scripted tapping: it is stable across UI
|
|
150
|
+
changes and skips onboarding.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
xcrun simctl openurl booted "myapp://track/123?autoplay=1"
|
|
154
|
+
adb shell am start -a android.intent.action.VIEW -d "myapp://track/123?autoplay=1" com.example.app
|
|
155
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Store sizes
|
|
2
|
+
|
|
3
|
+
Pixel sizes exported by `capture-flow build`. `default` means the size is produced
|
|
4
|
+
when `sizes` is omitted and the family has a capture. `required` marks the size the
|
|
5
|
+
store currently requires.
|
|
6
|
+
|
|
7
|
+
## App Store — iPhone
|
|
8
|
+
|
|
9
|
+
| id | pixels | devices | default | required |
|
|
10
|
+
| --- | --- | --- | --- | --- |
|
|
11
|
+
| `ios-6.9` | 1320 × 2868 | iPhone 17 Pro Max, 16 Pro Max | yes | yes |
|
|
12
|
+
| `ios-6.9-b` | 1290 × 2796 | iPhone 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max | no | no |
|
|
13
|
+
| `ios-6.5` | 1284 × 2778 | iPhone 14 Plus, 13 Pro Max, 12 Pro Max | no | no |
|
|
14
|
+
| `ios-6.5-b` | 1242 × 2688 | iPhone 11 Pro Max, XS Max | no | no |
|
|
15
|
+
| `ios-6.3` | 1206 × 2622 | iPhone 17 Pro, 17, 16 Pro | yes | yes |
|
|
16
|
+
| `ios-6.1` | 1170 × 2532 | iPhone 17e, 16e, 14, 13, 13 Pro, 12, 12 Pro | no | no |
|
|
17
|
+
|
|
18
|
+
## App Store — iPad
|
|
19
|
+
|
|
20
|
+
| id | pixels | devices | default | required |
|
|
21
|
+
| --- | --- | --- | --- | --- |
|
|
22
|
+
| `ipad-13` | 2064 × 2752 | iPad Pro 13″ (M4, M5), iPad Air 13″ | yes | yes |
|
|
23
|
+
| `ipad-12.9` | 2048 × 2732 | iPad Pro 12.9″ (2nd–6th gen) | no | no |
|
|
24
|
+
| `ipad-11` | 1668 × 2420 | iPad Pro 11″ (M4), iPad Air 11″ | no | no |
|
|
25
|
+
|
|
26
|
+
## Google Play — phone
|
|
27
|
+
|
|
28
|
+
| id | pixels | devices | default | required |
|
|
29
|
+
| --- | --- | --- | --- | --- |
|
|
30
|
+
| `play-phone` | 1080 × 1920 | all Android phones (9:16) | yes | yes |
|
|
31
|
+
| `play-phone-hd` | 1440 × 2560 | all Android phones (9:16, high resolution) | no | no |
|
|
32
|
+
|
|
33
|
+
## Google Play — tablet
|
|
34
|
+
|
|
35
|
+
| id | pixels | devices | default | required |
|
|
36
|
+
| --- | --- | --- | --- | --- |
|
|
37
|
+
| `play-tablet-7` | 1080 × 1920 | 7-inch Android tablets | yes | no |
|
|
38
|
+
| `play-tablet-10` | 1440 × 2560 | 10-inch Android tablets | yes | no |
|
|
39
|
+
|
|
40
|
+
## App Store — Mac
|
|
41
|
+
|
|
42
|
+
| id | pixels | default | required |
|
|
43
|
+
| --- | --- | --- | --- |
|
|
44
|
+
| `mac-2880` | 2880 × 1800 | yes | yes |
|
|
45
|
+
| `mac-2560` | 2560 × 1600 | no | no |
|
|
46
|
+
| `mac-1440` | 1440 × 900 | no | no |
|
|
47
|
+
| `mac-1280` | 1280 × 800 | no | no |
|
|
48
|
+
|
|
49
|
+
## Apple Watch
|
|
50
|
+
|
|
51
|
+
| id | pixels | default | required |
|
|
52
|
+
| --- | --- | --- | --- |
|
|
53
|
+
| `watch-ultra-3` | 422 × 514 | yes | yes |
|
|
54
|
+
| `watch-11` | 416 × 496 | no | no |
|
|
55
|
+
| `watch-ultra-2` | 410 × 502 | no | no |
|
|
56
|
+
| `watch-9` | 396 × 484 | no | no |
|
|
57
|
+
| `watch-6` | 368 × 448 | no | no |
|
|
58
|
+
| `watch-3` | 312 × 390 | no | no |
|
|
59
|
+
|
|
60
|
+
## Apple TV and Vision Pro
|
|
61
|
+
|
|
62
|
+
| id | pixels | default | required |
|
|
63
|
+
| --- | --- | --- | --- |
|
|
64
|
+
| `tv-4k` | 3840 × 2160 | yes | yes |
|
|
65
|
+
| `tv-hd` | 1920 × 1080 | no | no |
|
|
66
|
+
| `vision-pro` | 3840 × 2160 | yes | yes |
|
|
67
|
+
|
|
68
|
+
## Google Play feature graphic
|
|
69
|
+
|
|
70
|
+
| id | pixels | default | required |
|
|
71
|
+
| --- | --- | --- | --- |
|
|
72
|
+
| `play-feature` | 1024 × 500 | yes | yes |
|
|
73
|
+
|
|
74
|
+
## Landscape
|
|
75
|
+
|
|
76
|
+
Append `-land` and swap width and height. Supported for `iphone*`, `ipad*`, and
|
|
77
|
+
`play-phone*` sizes (for example `ios-6.9-land` = 2868 × 1320). Use the matching
|
|
78
|
+
`*-landscape` family with the capture taken in landscape.
|
|
79
|
+
|
|
80
|
+
## Capture aspect ratios
|
|
81
|
+
|
|
82
|
+
The device screen area uses these ratios. Match them to avoid cropping:
|
|
83
|
+
|
|
84
|
+
| Family | Screen aspect (w:h) | Notes |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `iphone` | 1320:2868 | 19.5:9, capture at 1320 × 2868 |
|
|
87
|
+
| `ipad` | 3:4 | 2064 × 2752 |
|
|
88
|
+
| `android-phone` | 20:9 | 1080 × 2400 or 1080 × 1920; 9:16 export crops top/bottom |
|
|
89
|
+
| `android-tablet` | 16:10 | 1600 × 2560 |
|
|
90
|
+
| `mac` | 16:10 | 2880 × 1800 |
|
|
91
|
+
| `watch` | 422:514 | — |
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* capture-flow build: manifest + captures -> exact store screenshots.
|
|
3
|
+
*/
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import type { AssetInfo, FamilyId, LangCode, Project } from '../vendor/wajha/model/types';
|
|
8
|
+
import type { ImageLike } from '../vendor/wajha/engine/types';
|
|
9
|
+
import { FAMILIES, FAMILY_ORDER, sizeById } from '../vendor/wajha/model/catalog';
|
|
10
|
+
import { projectFromTemplate } from '../vendor/wajha/model/instantiate';
|
|
11
|
+
import { registerBundledFonts } from '../vendor/wajha/engine/testing/canvasNode';
|
|
12
|
+
import { BUILTIN_TEMPLATES } from '../vendor/wajha/templates';
|
|
13
|
+
import { exportPath } from '../vendor/wajha/export/paths';
|
|
14
|
+
import type { ShotManifest } from './manifest';
|
|
15
|
+
import { parseManifest } from './manifest';
|
|
16
|
+
import { loadCapture, renderSlidePng } from './render';
|
|
17
|
+
|
|
18
|
+
export interface BuildOptions {
|
|
19
|
+
manifestPath: string;
|
|
20
|
+
outDir?: string;
|
|
21
|
+
log?: (line: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BuildReport {
|
|
25
|
+
outDir: string;
|
|
26
|
+
files: { path: string; width: number; height: number; family: FamilyId; lang: LangCode; slide: number }[];
|
|
27
|
+
warnings: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isFamily = (value: string): value is FamilyId => Object.prototype.hasOwnProperty.call(FAMILIES, value);
|
|
31
|
+
|
|
32
|
+
const extOf = (path: string): 'png' | 'jpg' | 'webp' => {
|
|
33
|
+
const lower = path.toLowerCase();
|
|
34
|
+
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'jpg';
|
|
35
|
+
if (lower.endsWith('.webp')) return 'webp';
|
|
36
|
+
return 'png';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const assetId = (bytes: Uint8Array, ext: string) => `${createHash('sha256').update(bytes).digest('hex')}.${ext}`;
|
|
40
|
+
|
|
41
|
+
function pickTemplate(id: string) {
|
|
42
|
+
const template = BUILTIN_TEMPLATES.find((t) => t.id === id);
|
|
43
|
+
if (!template) {
|
|
44
|
+
throw new Error(`Unknown template "${id}". Built-in templates: ${BUILTIN_TEMPLATES.map((t) => t.id).join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
return template;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** All sizes the manifest asks for, or every default size of the used families. */
|
|
50
|
+
function pickSizes(manifest: ShotManifest, families: FamilyId[]) {
|
|
51
|
+
if (manifest.sizes && manifest.sizes.length > 0) {
|
|
52
|
+
return manifest.sizes.map((id) => {
|
|
53
|
+
const size = sizeById(id);
|
|
54
|
+
if (!size) throw new Error(`Unknown export size "${id}"`);
|
|
55
|
+
return size;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return FAMILY_ORDER.filter((f) => families.includes(f)).flatMap((f) => FAMILIES[f].sizes.filter((s) => s.default));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function build(opts: BuildOptions): Promise<BuildReport> {
|
|
62
|
+
const log = opts.log ?? (() => {});
|
|
63
|
+
const manifestPath = resolve(opts.manifestPath);
|
|
64
|
+
const manifestDir = dirname(manifestPath);
|
|
65
|
+
const manifest = parseManifest(JSON.parse(readFileSync(manifestPath, 'utf8')));
|
|
66
|
+
|
|
67
|
+
const families = FAMILY_ORDER.filter((f) =>
|
|
68
|
+
manifest.slides.some((slide) => Object.prototype.hasOwnProperty.call(slide.captures, f)),
|
|
69
|
+
);
|
|
70
|
+
if (families.length === 0) throw new Error('No slide has a capture for a known device family');
|
|
71
|
+
|
|
72
|
+
for (const slide of manifest.slides) {
|
|
73
|
+
for (const family of Object.keys(slide.captures)) {
|
|
74
|
+
if (!isFamily(family)) throw new Error(`Unknown device family "${family}" (known: ${FAMILY_ORDER.join(', ')})`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const template = pickTemplate(manifest.template);
|
|
79
|
+
const languages = [...new Set(manifest.languages)] as LangCode[];
|
|
80
|
+
const project: Project = projectFromTemplate(template, {
|
|
81
|
+
name: manifest.app,
|
|
82
|
+
families,
|
|
83
|
+
languages,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const chosen = manifest.slides.map((slide, i) => {
|
|
87
|
+
const index = slide.templateSlide ?? i;
|
|
88
|
+
const source = project.slides[index];
|
|
89
|
+
if (!source) throw new Error(`slides[${i}] wants template slide ${index}, but "${template.id}" has ${project.slides.length}`);
|
|
90
|
+
return source;
|
|
91
|
+
});
|
|
92
|
+
project.slides = chosen;
|
|
93
|
+
|
|
94
|
+
const images = new Map<string, ImageLike>();
|
|
95
|
+
const warnings: string[] = [];
|
|
96
|
+
|
|
97
|
+
for (const [index, slide] of project.slides.entries()) {
|
|
98
|
+
const spec = manifest.slides[index];
|
|
99
|
+
for (const lang of languages) {
|
|
100
|
+
slide.texts[lang] = {
|
|
101
|
+
...(slide.texts[lang] ?? {}),
|
|
102
|
+
headline: spec.headline,
|
|
103
|
+
...(spec.subtitle !== undefined ? { subtitle: spec.subtitle } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
for (const [family, file] of Object.entries(spec.captures)) {
|
|
107
|
+
if (!isFamily(family)) continue;
|
|
108
|
+
const absolute = resolve(manifestDir, file);
|
|
109
|
+
let bytes: Uint8Array;
|
|
110
|
+
try {
|
|
111
|
+
bytes = readFileSync(absolute);
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error(`slides[${index}] capture not found: ${absolute}`);
|
|
114
|
+
}
|
|
115
|
+
const ext = extOf(absolute);
|
|
116
|
+
const id = assetId(bytes, ext);
|
|
117
|
+
if (!project.assets[id]) {
|
|
118
|
+
const image = await loadCapture(absolute);
|
|
119
|
+
const info: AssetInfo = {
|
|
120
|
+
id,
|
|
121
|
+
name: file.split(/[\\/]/).pop() ?? `capture.${ext}`,
|
|
122
|
+
width: image.width,
|
|
123
|
+
height: image.height,
|
|
124
|
+
bytes: bytes.byteLength,
|
|
125
|
+
addedAt: Date.now(),
|
|
126
|
+
};
|
|
127
|
+
project.assets[id] = info;
|
|
128
|
+
}
|
|
129
|
+
images.set(id, await loadCapture(absolute));
|
|
130
|
+
for (const lang of languages) {
|
|
131
|
+
slide.shots[family] = { ...(slide.shots[family] ?? {}), main: { ...(slide.shots[family]?.main ?? {}), [lang]: id } };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const outDir = resolve(opts.outDir ?? resolve(manifestDir, manifest.out ?? 'store-assets'));
|
|
137
|
+
const structure = manifest.structure ?? 'stores';
|
|
138
|
+
const sizes = pickSizes(manifest, families);
|
|
139
|
+
const files: BuildReport['files'] = [];
|
|
140
|
+
|
|
141
|
+
registerBundledFonts();
|
|
142
|
+
|
|
143
|
+
for (const size of sizes) {
|
|
144
|
+
if (!families.includes(size.family)) continue;
|
|
145
|
+
for (const lang of languages) {
|
|
146
|
+
for (const [slideIndex, slide] of project.slides.entries()) {
|
|
147
|
+
const shot = slide.shots[size.family]?.main?.[lang] ?? null;
|
|
148
|
+
if (!shot) warnings.push(`slide ${slideIndex + 1} (${size.family}, ${lang}) has no screenshot`);
|
|
149
|
+
const rendered = renderSlidePng({
|
|
150
|
+
project,
|
|
151
|
+
slide,
|
|
152
|
+
slideIndex,
|
|
153
|
+
family: size.family,
|
|
154
|
+
lang,
|
|
155
|
+
width: size.width,
|
|
156
|
+
height: size.height,
|
|
157
|
+
images,
|
|
158
|
+
});
|
|
159
|
+
if (rendered.result.overflowingText.length > 0) {
|
|
160
|
+
warnings.push(`slide ${slideIndex + 1} (${size.family}, ${lang}) text overflows at ${size.width}x${size.height}`);
|
|
161
|
+
}
|
|
162
|
+
if (rendered.result.missingShots.length > 0) {
|
|
163
|
+
warnings.push(`slide ${slideIndex + 1} (${size.family}, ${lang}) uses a placeholder screen`);
|
|
164
|
+
}
|
|
165
|
+
const relative = exportPath(
|
|
166
|
+
structure,
|
|
167
|
+
{ family: size.family, sizeId: size.id, width: size.width, height: size.height, lang, slideIndex },
|
|
168
|
+
'png',
|
|
169
|
+
manifest.app,
|
|
170
|
+
);
|
|
171
|
+
const target = resolve(outDir, relative);
|
|
172
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
173
|
+
writeFileSync(target, rendered.bytes);
|
|
174
|
+
files.push({ path: relative.replaceAll('\\', '/'), width: size.width, height: size.height, family: size.family, lang, slide: slideIndex + 1 });
|
|
175
|
+
log(`${relative} ${size.width}x${size.height}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
writeFileSync(
|
|
181
|
+
resolve(outDir, 'capture-flow-report.json'),
|
|
182
|
+
`${JSON.stringify({ app: manifest.app, template: template.id, structure, files, warnings }, null, 2)}\n`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return { outDir, files, warnings };
|
|
186
|
+
}
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture flow: the tool never pretends to know your simulator.
|
|
3
|
+
* A flow lists shell commands (Playwright, simctl, adb, Maestro, fastlane,
|
|
4
|
+
* anything) that write raw captures; `run` executes them, then builds and
|
|
5
|
+
* verifies. Commands inherit the caller's environment.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export interface CaptureStep {
|
|
12
|
+
name?: string;
|
|
13
|
+
command: string;
|
|
14
|
+
/** Relative to the flow file. */
|
|
15
|
+
cwd?: string;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CaptureFlow {
|
|
20
|
+
/** Manifest to build after capturing (relative to the flow file). */
|
|
21
|
+
manifest?: string;
|
|
22
|
+
out?: string;
|
|
23
|
+
captures: CaptureStep[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
27
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
28
|
+
|
|
29
|
+
export function parseFlow(raw: unknown): CaptureFlow {
|
|
30
|
+
if (!isRecord(raw)) throw new Error('Invalid flow: expected an object');
|
|
31
|
+
if (!Array.isArray(raw.captures) || raw.captures.length === 0) {
|
|
32
|
+
throw new Error('Invalid flow: "captures" must be a non-empty array of steps');
|
|
33
|
+
}
|
|
34
|
+
const captures = raw.captures.map((step, i): CaptureStep => {
|
|
35
|
+
if (!isRecord(step)) throw new Error(`Invalid flow: captures[${i}] must be an object`);
|
|
36
|
+
if (typeof step.command !== 'string' || step.command.trim() === '') {
|
|
37
|
+
throw new Error(`Invalid flow: captures[${i}].command must be a non-empty string`);
|
|
38
|
+
}
|
|
39
|
+
if (step.cwd !== undefined && typeof step.cwd !== 'string') throw new Error(`Invalid flow: captures[${i}].cwd must be a string`);
|
|
40
|
+
let env: Record<string, string> | undefined;
|
|
41
|
+
if (step.env !== undefined) {
|
|
42
|
+
if (!isRecord(step.env)) throw new Error(`Invalid flow: captures[${i}].env must be an object`);
|
|
43
|
+
env = Object.fromEntries(Object.entries(step.env).map(([k, v]) => [k, String(v)]));
|
|
44
|
+
}
|
|
45
|
+
return { name: typeof step.name === 'string' ? step.name : undefined, command: step.command, cwd: step.cwd, env };
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
manifest: typeof raw.manifest === 'string' ? raw.manifest : undefined,
|
|
49
|
+
out: typeof raw.out === 'string' ? raw.out : undefined,
|
|
50
|
+
captures,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadFlow(path: string): CaptureFlow {
|
|
55
|
+
return parseFlow(JSON.parse(readFileSync(path, 'utf8')));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Runs each step in order and stops at the first failure. */
|
|
59
|
+
export function runCaptureFlow(flowPath: string, log: (line: string) => void = () => {}): void {
|
|
60
|
+
const absolute = resolve(flowPath);
|
|
61
|
+
const base = dirname(absolute);
|
|
62
|
+
const flow = loadFlow(absolute);
|
|
63
|
+
|
|
64
|
+
for (const [index, step] of flow.captures.entries()) {
|
|
65
|
+
const label = step.name ?? `capture ${index + 1}`;
|
|
66
|
+
const cwd = step.cwd ? resolve(base, step.cwd) : base;
|
|
67
|
+
log(`▶ ${label}: ${step.command}`);
|
|
68
|
+
const result = spawnSync(step.command, {
|
|
69
|
+
cwd,
|
|
70
|
+
shell: true,
|
|
71
|
+
stdio: 'inherit',
|
|
72
|
+
env: { ...process.env, ...step.env },
|
|
73
|
+
});
|
|
74
|
+
if (result.error) throw new Error(`${label} failed to start: ${result.error.message}`);
|
|
75
|
+
if (result.status !== 0) throw new Error(`${label} exited with code ${result.status ?? 'unknown'}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* capture-flow CLI.
|
|
4
|
+
*
|
|
5
|
+
* capture-flow capture --flow flow.json
|
|
6
|
+
* capture-flow build --manifest shots.json [--out dir]
|
|
7
|
+
* capture-flow verify --dir dir
|
|
8
|
+
* capture-flow run --flow flow.json [--manifest shots.json] [--out dir]
|
|
9
|
+
*/
|
|
10
|
+
import { dirname, resolve } from 'node:path';
|
|
11
|
+
import { build } from './build';
|
|
12
|
+
import { loadFlow, runCaptureFlow } from './capture';
|
|
13
|
+
import { verify } from './verify';
|
|
14
|
+
|
|
15
|
+
interface Args {
|
|
16
|
+
command: string;
|
|
17
|
+
manifest?: string;
|
|
18
|
+
flow?: string;
|
|
19
|
+
out?: string;
|
|
20
|
+
dir?: string;
|
|
21
|
+
json: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv: string[]): Args {
|
|
25
|
+
const [command = 'help', ...rest] = argv;
|
|
26
|
+
const args: Args = { command, json: false };
|
|
27
|
+
for (let i = 0; i < rest.length; i++) {
|
|
28
|
+
const arg = rest[i];
|
|
29
|
+
if (arg === '--json') args.json = true;
|
|
30
|
+
else if (arg === '--manifest') args.manifest = rest[++i];
|
|
31
|
+
else if (arg === '--flow') args.flow = rest[++i];
|
|
32
|
+
else if (arg === '--out') args.out = rest[++i];
|
|
33
|
+
else if (arg === '--dir') args.dir = rest[++i];
|
|
34
|
+
else if (arg.startsWith('--manifest=')) args.manifest = arg.slice('--manifest='.length);
|
|
35
|
+
else if (arg.startsWith('--flow=')) args.flow = arg.slice('--flow='.length);
|
|
36
|
+
else if (arg.startsWith('--out=')) args.out = arg.slice('--out='.length);
|
|
37
|
+
else if (arg.startsWith('--dir=')) args.dir = arg.slice('--dir='.length);
|
|
38
|
+
else throw new Error(`Unknown argument "${arg}"`);
|
|
39
|
+
}
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function help(): void {
|
|
44
|
+
console.log(`capture-flow — store screenshots from raw captures
|
|
45
|
+
|
|
46
|
+
capture-flow capture --flow flow.json
|
|
47
|
+
capture-flow build --manifest shots.json [--out dir] [--json]
|
|
48
|
+
capture-flow verify --dir store-assets [--json]
|
|
49
|
+
capture-flow run --flow flow.json [--manifest shots.json] [--out dir] [--json]
|
|
50
|
+
|
|
51
|
+
Flow and manifest paths are relative to their own file.
|
|
52
|
+
Capture steps are shell commands: Playwright, simctl, adb, Maestro, anything.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main(): Promise<void> {
|
|
56
|
+
const args = parseArgs(process.argv.slice(2));
|
|
57
|
+
|
|
58
|
+
if (args.command === 'help' || args.command === '--help' || args.command === '-h') {
|
|
59
|
+
help();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const doBuild = async (manifestPath: string, outDir?: string, print = true) => {
|
|
64
|
+
const report = await build({
|
|
65
|
+
manifestPath,
|
|
66
|
+
outDir,
|
|
67
|
+
log: args.json || !print ? undefined : (line) => console.log(line),
|
|
68
|
+
});
|
|
69
|
+
if (!print) return report;
|
|
70
|
+
if (args.json) {
|
|
71
|
+
console.log(JSON.stringify({ outDir: report.outDir, files: report.files, warnings: report.warnings }, null, 2));
|
|
72
|
+
} else {
|
|
73
|
+
console.log(`\n${report.files.length} files in ${report.outDir}`);
|
|
74
|
+
for (const warning of report.warnings) console.warn(`warning: ${warning}`);
|
|
75
|
+
}
|
|
76
|
+
return report;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (args.command === 'capture') {
|
|
80
|
+
if (!args.flow) throw new Error('capture needs --flow <file>');
|
|
81
|
+
runCaptureFlow(args.flow, args.json ? undefined : (line) => console.log(line));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (args.command === 'build') {
|
|
86
|
+
if (!args.manifest) throw new Error('build needs --manifest <file>');
|
|
87
|
+
await doBuild(args.manifest, args.out);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (args.command === 'run') {
|
|
92
|
+
if (!args.flow) throw new Error('run needs --flow <file>');
|
|
93
|
+
const flow = loadFlow(resolve(args.flow));
|
|
94
|
+
runCaptureFlow(args.flow, args.json ? undefined : (line) => console.log(line));
|
|
95
|
+
const manifest = args.manifest ?? flow.manifest;
|
|
96
|
+
if (!manifest) throw new Error('run needs a manifest: set "manifest" in the flow or pass --manifest');
|
|
97
|
+
const base = dirname(resolve(args.flow));
|
|
98
|
+
const report = await doBuild(
|
|
99
|
+
resolve(base, manifest),
|
|
100
|
+
args.out ?? (flow.out ? resolve(base, flow.out) : undefined),
|
|
101
|
+
!args.json,
|
|
102
|
+
);
|
|
103
|
+
const result = verify(report.outDir);
|
|
104
|
+
if (args.json) {
|
|
105
|
+
console.log(
|
|
106
|
+
JSON.stringify({ outDir: report.outDir, files: report.files, warnings: report.warnings, verify: result }, null, 2),
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
for (const error of result.errors) console.error(`error: ${error}`);
|
|
110
|
+
console.log(result.ok ? `verified ${result.files.length} files` : `verification failed: ${result.errors.length} errors`);
|
|
111
|
+
}
|
|
112
|
+
if (!result.ok) process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (args.command === 'verify') {
|
|
117
|
+
if (!args.dir) throw new Error('verify needs --dir <directory>');
|
|
118
|
+
const result = verify(args.dir);
|
|
119
|
+
if (args.json) {
|
|
120
|
+
console.log(JSON.stringify(result, null, 2));
|
|
121
|
+
} else {
|
|
122
|
+
for (const file of result.files) {
|
|
123
|
+
console.log(`${file.path} ${file.width}x${file.height} RGB${file.colorType === 2 ? '' : ` (colour type ${file.colorType})`}`);
|
|
124
|
+
}
|
|
125
|
+
for (const error of result.errors) console.error(`error: ${error}`);
|
|
126
|
+
console.log(result.ok ? `\nOK — ${result.files.length} files` : `\nFAILED — ${result.errors.length} errors`);
|
|
127
|
+
}
|
|
128
|
+
if (!result.ok) process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
help();
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
main().catch((error: unknown) => {
|
|
137
|
+
console.error(`capture-flow: ${error instanceof Error ? error.message : String(error)}`);
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library entry point.
|
|
3
|
+
*
|
|
4
|
+
* The CLI is the primary interface; these exports are for pipelines that want
|
|
5
|
+
* to call the stages directly. This package ships TypeScript, so consumers run
|
|
6
|
+
* it through tsx (or a bundler) just like the CLI does.
|
|
7
|
+
*/
|
|
8
|
+
export { build, type BuildOptions, type BuildReport } from './build';
|
|
9
|
+
export { loadFlow, parseFlow, runCaptureFlow, type CaptureFlow, type CaptureStep } from './capture';
|
|
10
|
+
export { parseManifest, type ShotManifest, type ShotSlide } from './manifest';
|
|
11
|
+
export { verify, type VerifyResult, type VerifiedFile } from './verify';
|