capture-flow 0.0.0-stage → 0.1.0

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +55 -4
  5. package/src/build.ts +186 -0
  6. package/src/capture.ts +77 -0
  7. package/src/cli.ts +139 -0
  8. package/src/index.ts +11 -0
  9. package/src/manifest.ts +98 -0
  10. package/src/render.ts +75 -0
  11. package/src/verify.ts +90 -0
  12. package/vendor/wajha/LICENSE +21 -0
  13. package/vendor/wajha/NOTICE +21 -0
  14. package/vendor/wajha/engine/background.ts +55 -0
  15. package/vendor/wajha/engine/bidi.ts +589 -0
  16. package/vendor/wajha/engine/cache.ts +96 -0
  17. package/vendor/wajha/engine/color.ts +125 -0
  18. package/vendor/wajha/engine/devices/android.ts +53 -0
  19. package/vendor/wajha/engine/devices/browser.ts +170 -0
  20. package/vendor/wajha/engine/devices/finish.ts +154 -0
  21. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  22. package/vendor/wajha/engine/devices/index.ts +143 -0
  23. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  24. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  25. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  26. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  27. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  28. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  29. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  31. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  32. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  33. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  34. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  35. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  36. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  37. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  38. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  39. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  40. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  41. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  42. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  43. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  44. package/vendor/wajha/engine/devices/paths.ts +113 -0
  45. package/vendor/wajha/engine/devices/screen.ts +75 -0
  46. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  47. package/vendor/wajha/engine/devices/slab.ts +200 -0
  48. package/vendor/wajha/engine/devices/vision.ts +43 -0
  49. package/vendor/wajha/engine/devices/watch.ts +14 -0
  50. package/vendor/wajha/engine/geometry.ts +152 -0
  51. package/vendor/wajha/engine/images.ts +96 -0
  52. package/vendor/wajha/engine/index.ts +20 -0
  53. package/vendor/wajha/engine/linebreak.ts +226 -0
  54. package/vendor/wajha/engine/markup.ts +84 -0
  55. package/vendor/wajha/engine/mock.ts +158 -0
  56. package/vendor/wajha/engine/paint.ts +474 -0
  57. package/vendor/wajha/engine/patterns.ts +263 -0
  58. package/vendor/wajha/engine/render.ts +364 -0
  59. package/vendor/wajha/engine/shapes.ts +260 -0
  60. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  61. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  62. package/vendor/wajha/engine/text.ts +694 -0
  63. package/vendor/wajha/engine/types.ts +190 -0
  64. package/vendor/wajha/export/paths.ts +197 -0
  65. package/vendor/wajha/export/png.ts +223 -0
  66. package/vendor/wajha/export/types.ts +60 -0
  67. package/vendor/wajha/model/adapt.ts +889 -0
  68. package/vendor/wajha/model/catalog.ts +369 -0
  69. package/vendor/wajha/model/clone.ts +58 -0
  70. package/vendor/wajha/model/colorMath.ts +128 -0
  71. package/vendor/wajha/model/defaults.ts +386 -0
  72. package/vendor/wajha/model/geometry.ts +95 -0
  73. package/vendor/wajha/model/ids.ts +13 -0
  74. package/vendor/wajha/model/instantiate.ts +280 -0
  75. package/vendor/wajha/model/mirror.ts +43 -0
  76. package/vendor/wajha/model/resolve.ts +87 -0
  77. package/vendor/wajha/model/rules.ts +249 -0
  78. package/vendor/wajha/model/stub.ts +4 -0
  79. package/vendor/wajha/model/types.ts +456 -0
  80. package/vendor/wajha/model/typography.ts +28 -0
  81. package/vendor/wajha/model/validate.ts +917 -0
  82. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  83. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  84. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  85. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  86. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  87. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  88. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  89. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  90. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  91. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  92. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  93. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  94. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  95. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  96. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  97. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  98. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  99. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  100. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  101. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  102. package/vendor/wajha/templates/index.ts +55 -0
  103. package/vendor/wajha/templates/kit.ts +761 -0
  104. package/vendor/wajha/ui/sortShots.ts +239 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 capture-flow contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,239 @@
1
- # Temporary Holding Version
1
+ # capture-flow
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Capture once, ship store-ready screenshots.** `capture-flow` runs your own capture commands (Playwright, `simctl`, `adb`, Maestro, fastlane — anything that writes an image), then frames those captures in a polished template and exports exact App Store and Google Play sizes.
4
+
5
+ It is a small CLI and library, not a screenshot service. The capture half is bring-your-own so it can live in any pipeline; the build half is deterministic and store-aware.
6
+
7
+ ```text
8
+ your app ──► capture commands ──► raw PNG/JPEG/WebP ──► capture-flow build ──► App Store + Google Play
9
+ (flow.json) (shots.json) exact sizes, RGB PNG
10
+ ```
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install --save-dev capture-flow
16
+ npx capture-flow --help
17
+ ```
18
+
19
+ Requires Node **22.18+**. The renderer uses `@napi-rs/canvas` (prebuilt binaries, no system Chrome), and the template fonts ship as dependencies.
20
+
21
+ To run the included Tilawah example you also need Playwright in your project:
22
+
23
+ ```bash
24
+ npm install --save-dev playwright
25
+ npx playwright install chromium
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ A **flow** describes how to capture. A **manifest** describes how to frame it.
31
+
32
+ `flow.json`
33
+
34
+ ```json
35
+ {
36
+ "manifest": "./shots.json",
37
+ "out": "./store-assets",
38
+ "captures": [
39
+ { "name": "iPhone", "command": "node capture-iphone.mjs" },
40
+ { "name": "Android", "command": "node capture-android.mjs" }
41
+ ]
42
+ }
43
+ ```
44
+
45
+ `shots.json`
46
+
47
+ ```json
48
+ {
49
+ "app": "My App",
50
+ "template": "noor",
51
+ "structure": "stores",
52
+ "languages": ["en-US", "ar-SA"],
53
+ "sizes": ["ios-6.9", "ios-6.3", "play-phone"],
54
+ "slides": [
55
+ {
56
+ "templateSlide": 0,
57
+ "headline": "Listen with *focus*",
58
+ "subtitle": "A calm player for long recordings.",
59
+ "captures": {
60
+ "iphone": "captures/iphone-01.png",
61
+ "android-phone": "captures/android-01.png"
62
+ }
63
+ }
64
+ ]
65
+ }
66
+ ```
67
+
68
+ Run the whole flow:
69
+
70
+ ```bash
71
+ npx capture-flow run --flow flow.json
72
+ ```
73
+
74
+ Or run the stages separately:
75
+
76
+ ```bash
77
+ npx capture-flow capture --flow flow.json
78
+ npx capture-flow build --manifest shots.json
79
+ npx capture-flow verify --dir store-assets
80
+ ```
81
+
82
+ ## Commands
83
+
84
+ | Command | What it does |
85
+ | --- | --- |
86
+ | `capture-flow capture --flow <file>` | Runs each flow step in order, stopping on the first failure. |
87
+ | `capture-flow build --manifest <file> [--out <dir>] [--json]` | Renders the manifest to store PNGs and writes `capture-flow-report.json`. |
88
+ | `capture-flow verify --dir <dir> [--json]` | Checks every PNG is 8-bit RGB (no alpha) at the exact size in the report, and that no planned file is missing. |
89
+ | `capture-flow run --flow <file> [--manifest <file>] [--out <dir>] [--json]` | `capture` → `build` → `verify` in one command. |
90
+
91
+ Paths inside a flow are relative to the flow file; paths inside a manifest are relative to the manifest file. Capture commands run with the caller's environment plus any `env` entries.
92
+
93
+ ## Flow schema
94
+
95
+ | Field | Required | Meaning |
96
+ | --- | --- | --- |
97
+ | `captures` | yes | Ordered array of steps. |
98
+ | `captures[].command` | yes | Shell command (run with the system shell). |
99
+ | `captures[].name` | no | Label shown in the log. |
100
+ | `captures[].cwd` | no | Working directory, relative to the flow file. |
101
+ | `captures[].env` | no | Extra environment variables for this step. |
102
+ | `manifest` | no | Manifest to build after capture; used by `run`. |
103
+ | `out` | no | Output directory for `run`. Overrides the manifest's `out`. |
104
+
105
+ Because steps are plain commands, a native flow is just as natural as a web one:
106
+
107
+ ```json
108
+ {
109
+ "manifest": "./shots.json",
110
+ "captures": [
111
+ { "name": "iOS", "command": "xcrun simctl io booted screenshot captures/ios-01.png" },
112
+ { "name": "Android", "command": "adb exec-out screencap -p > captures/android-01.png" }
113
+ ]
114
+ }
115
+ ```
116
+
117
+ ## Manifest schema
118
+
119
+ | Field | Required | Meaning |
120
+ | --- | --- | --- |
121
+ | `app` | yes | Project name (also used by the renderer). |
122
+ | `template` | yes | Template id from the list below. |
123
+ | `languages` | yes | Store locale codes, e.g. `["en-US", "ar-SA"]`. `ar-SA` exports as `ar` for Google Play. |
124
+ | `slides` | yes | Ordered slides; each maps a device family to a capture. |
125
+ | `out` | no | Output directory, relative to the manifest. Default `store-assets`. |
126
+ | `structure` | no | `stores` (default), `flat`, or `fastlane`. |
127
+ | `sizes` | no | Subset of size ids. Defaults to every default size of the families used. |
128
+ | `slides[].headline` | yes | `*word*` highlights a run; a newline is a hard break. |
129
+ | `slides[].subtitle` | no | Secondary copy. |
130
+ | `slides[].templateSlide` | no | Which template slide layout to use (defaults to the slide's index). |
131
+ | `slides[].captures` | yes | Device family → image path. |
132
+
133
+ Device families: `iphone`, `iphone-landscape`, `ipad`, `ipad-landscape`, `android-phone`, `android-landscape`, `android-tablet`, `mac`, `watch`.
134
+
135
+ Store sizes: `ios-6.9`, `ios-6.9-b`, `ios-6.5`, `ios-6.5-b`, `ios-6.3`, `ios-6.1`, `ipad-13`, `ipad-12.9`, `ipad-11`, `play-phone`, `play-phone-hd`, `play-tablet-7`, `play-tablet-10`, plus the corresponding `-land` ids. Run `build` with an unknown id to get the full list in the error.
136
+
137
+ The default `stores` layout is what the stores expect:
138
+
139
+ ```text
140
+ store-assets/
141
+ App Store/
142
+ ar-SA/
143
+ iPhone 6.9/01.png … 04.png
144
+ iPhone 6.3/01.png … 04.png
145
+ iPad 13/01.png … 04.png
146
+ Google Play/
147
+ ar/
148
+ Phone/01.png … 04.png
149
+ Tablet 10/01.png … 04.png
150
+ capture-flow-report.json
151
+ ```
152
+
153
+ ## Controlling the result
154
+
155
+ | You want to change | Where it lives |
156
+ | --- | --- |
157
+ | Which app screen is shown | Your capture command (Playwright route, simulator state, deep link). Map it to a slide with `slides[].captures`. |
158
+ | The slide layout / composition | `slides[].templateSlide` — picks one of the template's layouts (hero, tilted, hanging, …). |
159
+ | The copy | `slides[].headline` and `slides[].subtitle`. `*word*` renders as the highlighted accent run; a newline is a hard break. |
160
+ | Colours and fonts | The `template` you pick. Each built-in template ships a fixed palette, background, and heading/body typeface. |
161
+ | App light/dark screenshot | The capture script's stored setting (`settings.theme` in the example's `localStorage` blob). |
162
+ | Which store sizes | `sizes` (or omit it to get every default size of the families you captured). |
163
+
164
+ Manifest-level colour/font overrides are deliberately not exposed: a template is a designed system, and mixing in arbitrary colours usually breaks it. To go beyond the built-ins, copy a template under `vendor/wajha/templates/builtin/`, change its `theme`/`typeset`, and use its `id` in the manifest.
165
+
166
+ ### Large screens
167
+
168
+ Capture tables are just another family:
169
+
170
+ ```json
171
+ {
172
+ "templateSlide": 0,
173
+ "headline": "…",
174
+ "captures": {
175
+ "ipad": "captures/ipad-01.png",
176
+ "android-tablet": "captures/android-tablet-01.png"
177
+ }
178
+ }
179
+ ```
180
+
181
+ and export them with `ipad-13` / `play-tablet-10`. Match the frame's screen aspect so nothing is distorted:
182
+
183
+ - **iPad 13″** — 3:4, e.g. capture at 2064×2752 (CSS 768×1024 @2.6875).
184
+ - **Android tablet** — 16:10 inside a 9:16 export, e.g. capture at 1600×2560 (CSS 800×1280 @2), exported at 1440×2560.
185
+
186
+ Templates with native tablet layouts (`mono-swiss`, `noir-gold`, `obsidian`, `panorama-*`, …) compose the tablet canvas directly. Phone-first templates such as `noor` adapt their tall layout and swap the frame to iPad/Android tablet automatically.
187
+
188
+ ## Templates
189
+
190
+ | Category | Templates |
191
+ | --- | --- |
192
+ | `elegant` | `desert-dune`, `noir-gold`, `paper-and-ink` |
193
+ | `dark` | `midnight-neon`, `noor`, `obsidian` |
194
+ | `minimal` | `launchpad`, `mono-swiss`, `sage` |
195
+ | `gradient` | `aurora`, `glass-prism`, `sunset-boulevard` |
196
+ | `playful` | `bubblegum`, `clay-garden` |
197
+ | `bold` | `citrus-pop`, `loud-and-clear`, `split-tone` |
198
+ | `panorama` | `panorama-peaks`, `panorama-ribbon`, `panorama-waves` |
199
+
200
+ `noor` (deep emerald, gold geometry, Cairo/Noto Sans Arabic) is the template used by the Tilawah example.
201
+
202
+ ## Example: open-tilawa
203
+
204
+ [`examples/open-tilawa`](examples/open-tilawa) captures the deployed [Tilawah](https://tilawah.cc) web app with Playwright and builds Arabic App Store and Google Play sets:
205
+
206
+ ```bash
207
+ # Playwright lives outside the project in this workspace; point the example at it:
208
+ PLAYWRIGHT_MODULE=/path/to/node_modules/playwright \
209
+ npx capture-flow run --flow examples/open-tilawa/flow.json
210
+ ```
211
+
212
+ The generated set is checked in at `examples/open-tilawa/store-assets` (20 files: iPhone 6.9″, iPhone 6.3″, iPad 13″, Google Play phone, Google Play tablet). Three details in the example are worth copying:
213
+
214
+ - **Hydration:** the deployed app needs ~20 s to boot. The script waits for the real UI (`getByRole('button', { name: 'تشغيل' })`) rather than for `load`, and navigates with `waitUntil: 'commit'`.
215
+ - **Audio:** headless Chromium cannot use Tilawah's service-worker WebTorrent stream, so the script points `/webtorrent/…` at Tilawah's own documented `/media/` web seed. The player then shows real playback time and an active spectrum instead of a paused 0:00.
216
+ - **Large screens:** the same four screens are captured at tablet viewports (`ipad`, `android-tablet`) and mapped to `ipad-13` / `play-tablet-10` in the manifest.
217
+
218
+ ## Library
219
+
220
+ The package ships TypeScript (the CLI runs it through `tsx`), so import it from a `tsx` script or a bundler:
221
+
222
+ ```ts
223
+ import { build } from 'capture-flow';
224
+
225
+ const report = await build({ manifestPath: 'shots.json', outDir: 'store-assets' });
226
+ console.log(report.files, report.warnings);
227
+ ```
228
+
229
+ Subpaths are available for the pieces: `capture-flow/build`, `capture-flow/capture`, `capture-flow/manifest`, `capture-flow/verify`. `build` returns the files it wrote and any layout warnings (overflowing text, missing screenshots).
230
+
231
+ ## Original idea and credits
232
+
233
+ This project exists because of **[Wajha](https://github.com/M-Alkassem/Wajha)** by [M-Alkassem](https://github.com/M-Alkassem) and contributors — the original store-screenshot designer with the Canvas engine, templates, device frames and export rules.
234
+
235
+ capture-flow is a new orchestration layer around that idea: it adds the capture flow, the manifest/CLI contract, verify, and headless Node rendering. The rendering core is **vendored** from Wajha under `vendor/wajha/` (model, engine, export naming, templates, slide sorting) and is used under the MIT License. The full notice is in [`vendor/wajha/LICENSE`](vendor/wajha/LICENSE) and [`vendor/wajha/NOTICE`](vendor/wajha/NOTICE).
236
+
237
+ Wajha is MIT-licensed, copyright © 2026 Wajha contributors. capture-flow is MIT-licensed too; see [`LICENSE`](LICENSE).
238
+
239
+ If you only need to design screenshots interactively, use Wajha. If you need screenshots produced repeatedly by a pipeline, use capture-flow — and consider upstreaming improvements to the engine.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * npm entry point.
4
+ *
5
+ * The tool ships as TypeScript (its vendored engine is TypeScript too), so the
6
+ * launcher registers tsx and then loads the CLI. Keeping one source of truth
7
+ * beats a separate build output that can drift from the examples.
8
+ */
9
+ import { register } from 'tsx/esm/api';
10
+
11
+ register();
12
+ await import(new URL('../src/cli.ts', import.meta.url).href);
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "capture-flow",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Turn raw simulator and browser captures into App Store and Google Play screenshots.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "adelpro",
8
+ "keywords": [
9
+ "screenshots",
10
+ "app-store",
11
+ "google-play",
12
+ "store-assets",
13
+ "fastlane",
14
+ "playwright",
15
+ "simctl",
16
+ "adb"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "bin": {
22
+ "capture-flow": "./bin/capture-flow.mjs"
23
+ },
24
+ "exports": {
25
+ ".": "./src/index.ts",
26
+ "./build": "./src/build.ts",
27
+ "./manifest": "./src/manifest.ts",
28
+ "./capture": "./src/capture.ts",
29
+ "./verify": "./src/verify.ts"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "src",
34
+ "vendor"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsx src/cli.ts build",
38
+ "verify": "tsx src/cli.ts verify",
39
+ "typecheck": "tsc --noEmit",
40
+ "example:tilawah": "tsx src/cli.ts run --flow examples/open-tilawa/flow.json"
41
+ },
42
+ "dependencies": {
43
+ "@fontsource-variable/cairo": "^5.3.0",
44
+ "@fontsource-variable/inter": "^5.3.0",
45
+ "@fontsource-variable/noto-sans-arabic": "^5.3.0",
46
+ "@napi-rs/canvas": "^1.1.0",
47
+ "fflate": "^0.8.3",
48
+ "tsx": "^4.20.6"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^24.19.2",
52
+ "typescript": "^5.9.3"
53
+ },
54
+ "engines": {
55
+ "node": ">=22.18"
56
+ }
57
+ }
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
+ }