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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +56 -4
  5. package/skills/capture-flow/SKILL.md +248 -0
  6. package/skills/capture-flow/references/capture-recipes.md +155 -0
  7. package/skills/capture-flow/references/store-sizes.md +91 -0
  8. package/src/build.ts +186 -0
  9. package/src/capture.ts +77 -0
  10. package/src/cli.ts +139 -0
  11. package/src/index.ts +11 -0
  12. package/src/manifest.ts +98 -0
  13. package/src/render.ts +75 -0
  14. package/src/verify.ts +90 -0
  15. package/vendor/wajha/LICENSE +21 -0
  16. package/vendor/wajha/NOTICE +21 -0
  17. package/vendor/wajha/engine/background.ts +55 -0
  18. package/vendor/wajha/engine/bidi.ts +589 -0
  19. package/vendor/wajha/engine/cache.ts +96 -0
  20. package/vendor/wajha/engine/color.ts +125 -0
  21. package/vendor/wajha/engine/devices/android.ts +53 -0
  22. package/vendor/wajha/engine/devices/browser.ts +170 -0
  23. package/vendor/wajha/engine/devices/finish.ts +154 -0
  24. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  25. package/vendor/wajha/engine/devices/index.ts +143 -0
  26. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  27. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  28. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  29. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  31. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  32. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  33. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  34. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  35. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  36. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  37. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  38. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  39. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  40. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  41. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  42. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  43. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  44. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  45. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  46. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  47. package/vendor/wajha/engine/devices/paths.ts +113 -0
  48. package/vendor/wajha/engine/devices/screen.ts +75 -0
  49. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  50. package/vendor/wajha/engine/devices/slab.ts +200 -0
  51. package/vendor/wajha/engine/devices/vision.ts +43 -0
  52. package/vendor/wajha/engine/devices/watch.ts +14 -0
  53. package/vendor/wajha/engine/geometry.ts +152 -0
  54. package/vendor/wajha/engine/images.ts +96 -0
  55. package/vendor/wajha/engine/index.ts +20 -0
  56. package/vendor/wajha/engine/linebreak.ts +226 -0
  57. package/vendor/wajha/engine/markup.ts +84 -0
  58. package/vendor/wajha/engine/mock.ts +158 -0
  59. package/vendor/wajha/engine/paint.ts +474 -0
  60. package/vendor/wajha/engine/patterns.ts +263 -0
  61. package/vendor/wajha/engine/render.ts +364 -0
  62. package/vendor/wajha/engine/shapes.ts +260 -0
  63. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  64. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  65. package/vendor/wajha/engine/text.ts +694 -0
  66. package/vendor/wajha/engine/types.ts +190 -0
  67. package/vendor/wajha/export/paths.ts +197 -0
  68. package/vendor/wajha/export/png.ts +223 -0
  69. package/vendor/wajha/export/types.ts +60 -0
  70. package/vendor/wajha/model/adapt.ts +889 -0
  71. package/vendor/wajha/model/catalog.ts +369 -0
  72. package/vendor/wajha/model/clone.ts +58 -0
  73. package/vendor/wajha/model/colorMath.ts +128 -0
  74. package/vendor/wajha/model/defaults.ts +386 -0
  75. package/vendor/wajha/model/geometry.ts +95 -0
  76. package/vendor/wajha/model/ids.ts +13 -0
  77. package/vendor/wajha/model/instantiate.ts +280 -0
  78. package/vendor/wajha/model/mirror.ts +43 -0
  79. package/vendor/wajha/model/resolve.ts +87 -0
  80. package/vendor/wajha/model/rules.ts +249 -0
  81. package/vendor/wajha/model/stub.ts +4 -0
  82. package/vendor/wajha/model/types.ts +456 -0
  83. package/vendor/wajha/model/typography.ts +28 -0
  84. package/vendor/wajha/model/validate.ts +917 -0
  85. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  86. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  87. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  88. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  89. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  90. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  91. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  92. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  93. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  94. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  95. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  96. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  97. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  98. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  99. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  100. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  101. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  102. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  103. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  104. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  105. package/vendor/wajha/templates/index.ts +55 -0
  106. package/vendor/wajha/templates/kit.ts +761 -0
  107. package/vendor/wajha/ui/sortShots.ts +239 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Sorting a pile of screenshots (a dropped folder, simulator output, a
3
+ * fastlane screenshots folder) into the project: which device family and
4
+ * which language each image belongs to, and in what order. Pure: callers
5
+ * read the image sizes and do the importing.
6
+ *
7
+ * Device: an exact store or simulator resolution, helped by what the file
8
+ * name says ("Simulator Screenshot - iPad Pro …", "Screenshot_2026…" from an
9
+ * Android emulator), else the enabled family whose canvas has the closest
10
+ * shape. Language: a folder or file name that is one of the project's
11
+ * languages, as a code ("ar-SA", "de", "pt_BR"), a store locale code or a
12
+ * name ("Arabic", "العربية"). Order: natural file name order, so "2.png"
13
+ * comes before "10.png" and simulator timestamps sort by time.
14
+ */
15
+ import { ALL_SIZES, FAMILIES, FAMILY_ORDER, languageInfo } from '../model/catalog';
16
+ import type { FamilyId, LangCode } from '../model/types';
17
+
18
+ export interface ShotCandidate {
19
+ /** File name, e.g. "Simulator Screenshot - iPhone 17 Pro - 2026-10-31 at 10.11.12.png". */
20
+ name: string;
21
+ /** Folders from the dropped root down to the file, e.g. ["screenshots", "ar-SA"]. */
22
+ folders: readonly string[];
23
+ width: number;
24
+ height: number;
25
+ }
26
+
27
+ export interface SortedGroup<T extends ShotCandidate> {
28
+ family: FamilyId;
29
+ lang: LangCode;
30
+ shots: T[];
31
+ }
32
+
33
+ export interface SortPlan<T extends ShotCandidate> {
34
+ groups: SortedGroup<T>[];
35
+ /** Images for a family the project does not have, by that family. */
36
+ otherFamily: Map<FamilyId, T[]>;
37
+ /** Images whose shape matches no family of the project. */
38
+ unmatched: T[];
39
+ }
40
+
41
+ /* Device ------------------------------------------------------------------ */
42
+
43
+ type Kind = 'iphone' | 'ipad' | 'android' | 'mac' | 'watch' | 'tv' | 'vision';
44
+
45
+ /** What a path says about the device, most specific first. */
46
+ const NAME_HINTS: ReadonlyArray<readonly [RegExp, Kind]> = [
47
+ [/vision\s*pro|visionos/, 'vision'],
48
+ [/apple\s*tv|tvos/, 'tv'],
49
+ [/apple\s*watch|watchos|\bwatch\b/, 'watch'],
50
+ [/\bipad/, 'ipad'],
51
+ [/\biphone/, 'iphone'],
52
+ [/macbook|\bmacos\b|\bmac\b|desktop/, 'mac'],
53
+ [/pixel|android|galaxy|emulator|screenshot_\d/, 'android'],
54
+ ];
55
+
56
+ /** Families of each kind, in the order to try them. */
57
+ const KIND_FAMILIES: Record<Kind, FamilyId[]> = {
58
+ iphone: ['iphone', 'iphone-landscape'],
59
+ ipad: ['ipad', 'ipad-landscape'],
60
+ android: ['android-phone', 'android-landscape', 'android-tablet'],
61
+ mac: ['mac'],
62
+ watch: ['watch'],
63
+ tv: ['apple-tv'],
64
+ vision: ['vision'],
65
+ };
66
+
67
+ /**
68
+ * Simulator and emulator resolutions that are not store sizes, so a raw
69
+ * capture still lands on the right device (store sizes come from the catalog).
70
+ */
71
+ const DEVICE_SIZES: ReadonlyArray<readonly [number, number, FamilyId]> = [
72
+ [1179, 2556, 'iphone'], // iPhone 16, 15 Pro
73
+ [1080, 2340, 'iphone'], // iPhone mini
74
+ [750, 1334, 'iphone'], // iPhone SE
75
+ [1640, 2360, 'ipad'], // iPad 10th gen, iPad Air 11″
76
+ [1488, 2266, 'ipad'], // iPad mini
77
+ [1620, 2160, 'ipad'], // iPad 9th gen
78
+ [1080, 2400, 'android-phone'], // Pixel 7, 8, 9
79
+ [1344, 2992, 'android-phone'], // Pixel 8 Pro
80
+ [1600, 2560, 'android-tablet'], // Pixel Tablet
81
+ [3024, 1964, 'mac'], // MacBook Pro 14″ display
82
+ [3456, 2234, 'mac'], // MacBook Pro 16″ display
83
+ ];
84
+
85
+ /** A screenshot is a screen: compare it with the family's screen, not its slide canvas. */
86
+ const shapeOf = (f: FamilyId) => FAMILIES[f].screenAspect;
87
+
88
+ /** The same device on its side. */
89
+ const TURNED: Partial<Record<FamilyId, FamilyId>> = { iphone: 'iphone-landscape', ipad: 'ipad-landscape', 'android-phone': 'android-landscape' };
90
+
91
+ /** The feature graphic is a designed banner, never a screenshot. */
92
+ const SCREEN_FAMILIES = FAMILY_ORDER.filter((f) => f !== 'play-feature');
93
+
94
+ /** How far apart two shapes are (0 = same aspect ratio). */
95
+ const shapeDistance = (a: number, b: number) => Math.abs(Math.log(a / b));
96
+
97
+ /** Shapes further apart than this never match (about 15 %). */
98
+ const MAX_SHAPE_DISTANCE = 0.14;
99
+
100
+ function hintedKind(text: string): Kind | null {
101
+ for (const [re, kind] of NAME_HINTS) if (re.test(text)) return kind;
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * The device family an image belongs to, or null when no family fits.
107
+ * `enabled` limits the answer to the project's families; pass the whole
108
+ * catalog to learn which family an unmatched image wanted.
109
+ */
110
+ export function familyForShot(shot: ShotCandidate, enabled: readonly FamilyId[]): FamilyId | null {
111
+ const { width: w, height: h } = shot;
112
+ if (!(w > 0 && h > 0)) return null;
113
+ const kind = hintedKind(`${shot.folders.join('/')}/${shot.name}`.toLowerCase());
114
+ const allowed = (f: FamilyId) => enabled.includes(f) && (kind === null || KIND_FAMILIES[kind].includes(f));
115
+ // An exact store size, or a device resolution in either orientation.
116
+ const exact: FamilyId[] = [];
117
+ for (const size of ALL_SIZES) if (size.width === w && size.height === h) exact.push(size.family);
118
+ for (const [sw, sh, f] of DEVICE_SIZES) {
119
+ if (sw === w && sh === h) exact.push(f);
120
+ const turned = TURNED[f];
121
+ if (turned && sw === h && sh === w) exact.push(turned);
122
+ }
123
+ const match = SCREEN_FAMILIES.find((f) => exact.includes(f) && allowed(f));
124
+ if (match) return match;
125
+ // Otherwise the closest screen shape among the allowed families.
126
+ let best: FamilyId | null = null;
127
+ let bestDistance = MAX_SHAPE_DISTANCE;
128
+ for (const f of SCREEN_FAMILIES) {
129
+ if (!allowed(f)) continue;
130
+ const d = shapeDistance(h / w, shapeOf(f));
131
+ if (d < bestDistance) {
132
+ best = f;
133
+ bestDistance = d;
134
+ }
135
+ }
136
+ return best;
137
+ }
138
+
139
+ /* Language ----------------------------------------------------------------- */
140
+
141
+ /** Lower case, "_" as "-": "pt_BR" and "PT-br" read alike. */
142
+ const norm = (s: string) => s.normalize('NFC').toLowerCase().replace(/_/g, '-').trim();
143
+
144
+ /** Every way a folder or file name may spell `lang`. */
145
+ function spellings(lang: LangCode): string[] {
146
+ const info = languageInfo(lang);
147
+ const out = new Set<string>([lang, info.appStore ?? '', info.play ?? '', info.name, info.native].map(norm));
148
+ // "English (US)" also as "english"; "de-DE" also as "de" unless that is ambiguous (handled by the caller).
149
+ out.add(norm(info.name.replace(/\s*\(.*\)$/, '')));
150
+ out.add(norm(lang.split('-')[0]));
151
+ out.delete('');
152
+ return [...out];
153
+ }
154
+
155
+ /**
156
+ * The project language a path names, or null. Folder names count first
157
+ * (deepest first), then the words of the file name. A spelling two project
158
+ * languages share ("en" with English (US) and English (UK)) matches neither,
159
+ * unless it is exactly one language's own code.
160
+ */
161
+ export function langForShot(shot: ShotCandidate, languages: readonly LangCode[]): LangCode | null {
162
+ const owners = new Map<string, Set<LangCode>>();
163
+ for (const lang of languages) for (const s of spellings(lang)) owners.set(s, (owners.get(s) ?? new Set()).add(lang));
164
+ const lookup = (word: string): LangCode | null => {
165
+ const key = norm(word);
166
+ const found = owners.get(key);
167
+ if (!found) return null;
168
+ if (found.size === 1) return [...found][0];
169
+ return [...found].find((l) => norm(l) === key) ?? null;
170
+ };
171
+ for (const folder of [...shot.folders].reverse()) {
172
+ const hit = lookup(folder);
173
+ if (hit) return hit;
174
+ }
175
+ // File name words: "home-ar-SA.png", "01_de.png", "screen (Arabic).png".
176
+ const words = shot.name
177
+ .replace(/\.[a-z0-9]+$/i, '')
178
+ .split(/[\s._()[\]{},-]+/)
179
+ .filter(Boolean);
180
+ for (let i = 0; i < words.length; i++) {
181
+ // A region code keeps its hyphen in a name ("ar-SA"), so try each word with the next one first.
182
+ const hit = (i + 1 < words.length ? lookup(`${words[i]}-${words[i + 1]}`) : null) ?? lookup(words[i]);
183
+ if (hit) return hit;
184
+ }
185
+ return null;
186
+ }
187
+
188
+ /* Plan ---------------------------------------------------------------------- */
189
+
190
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
191
+
192
+ /** Natural order by folder path, then file name. */
193
+ export function compareShots(a: ShotCandidate, b: ShotCandidate): number {
194
+ return collator.compare(a.folders.join('/'), b.folders.join('/')) || collator.compare(a.name, b.name);
195
+ }
196
+
197
+ /**
198
+ * Sorts `shots` into the project's families and languages. Images with no
199
+ * language in their path go to `baseLang`. Groups come in family order, then
200
+ * project language order; images inside a group in natural name order.
201
+ */
202
+ export function planSort<T extends ShotCandidate>(shots: readonly T[], families: readonly FamilyId[], languages: readonly LangCode[]): SortPlan<T> {
203
+ const groups = new Map<string, SortedGroup<T>>();
204
+ const otherFamily = new Map<FamilyId, T[]>();
205
+ const unmatched: T[] = [];
206
+ const base = languages[0];
207
+ for (const shot of shots) {
208
+ const family = familyForShot(shot, families);
209
+ if (!family) {
210
+ const wanted = familyForShot(shot, FAMILY_ORDER);
211
+ if (wanted) otherFamily.set(wanted, [...(otherFamily.get(wanted) ?? []), shot]);
212
+ else unmatched.push(shot);
213
+ continue;
214
+ }
215
+ const lang = langForShot(shot, languages) ?? base;
216
+ const key = `${family}\u0000${lang}`;
217
+ const group = groups.get(key) ?? { family, lang, shots: [] };
218
+ group.shots.push(shot);
219
+ groups.set(key, group);
220
+ }
221
+ const order = (g: SortedGroup<T>) => FAMILY_ORDER.indexOf(g.family) * 1000 + languages.indexOf(g.lang);
222
+ const sorted = [...groups.values()].sort((a, b) => order(a) - order(b));
223
+ for (const g of sorted) g.shots.sort(compareShots);
224
+ return { groups: sorted, otherFamily, unmatched };
225
+ }
226
+
227
+ /**
228
+ * True when a plain drop should be sorted rather than poured into the
229
+ * active family and language: it came as folders, or its images belong to
230
+ * another family or name a language other than the active one.
231
+ */
232
+ export function wantsSorting(shots: readonly ShotCandidate[], families: readonly FamilyId[], languages: readonly LangCode[], active: { family: FamilyId; lang: LangCode }): boolean {
233
+ if (shots.some((s) => s.folders.length > 0)) return true;
234
+ return shots.some((s) => {
235
+ if (familyForShot(s, families) !== active.family) return true;
236
+ const lang = langForShot(s, languages);
237
+ return lang !== null && lang !== active.lang;
238
+ });
239
+ }