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
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Public contract of the rendering engine.
3
+ *
4
+ * The engine is pure TypeScript on top of the Canvas 2D API. It never touches
5
+ * the DOM, storage or the store: everything it needs comes in through
6
+ * `RenderInput`, so the same code draws the editor preview, thumbnails,
7
+ * template previews, exports, and runs in Node tests on @napi-rs/canvas.
8
+ *
9
+ * Browser-compat rules (WKWebView on macOS 11+, WebView2, WebKitGTK):
10
+ * - Do not rely on `ctx.filter`; blur with shadowBlur or downscale tricks.
11
+ * - Feature-detect `ctx.letterSpacing` and `ctx.roundRect`.
12
+ * - Text shaping comes from `fillText` with `ctx.direction` set; lines that
13
+ * mix directions are cut into one-direction pieces first (bidi.ts), since
14
+ * WebKit misplaces the later direction runs of a single call vertically.
15
+ */
16
+ import type {
17
+ Color,
18
+ DeviceElement,
19
+ DeviceFrameId,
20
+ FamilyId,
21
+ LangCode,
22
+ LanguageTypography,
23
+ Slide,
24
+ SlideLayout,
25
+ TextStyle,
26
+ Theme,
27
+ } from '../model/types';
28
+
29
+ export type Ctx2D = CanvasRenderingContext2D;
30
+
31
+ /** Anything drawImage accepts that also reports its pixel size. */
32
+ export type ImageLike = CanvasImageSource & { width: number; height: number };
33
+
34
+ export interface RenderEnv {
35
+ /** Creates a scratch canvas for offscreen effects (blur, patterns, masks). */
36
+ createCanvas(width: number, height: number): { canvas: ImageLike; ctx: Ctx2D };
37
+ }
38
+
39
+ export interface RenderInput {
40
+ /** Output size in pixels. */
41
+ width: number;
42
+ height: number;
43
+ family: FamilyId;
44
+ slide: Slide;
45
+ /** Already resolved for `family` (see model/resolve.ts). */
46
+ layout: SlideLayout;
47
+ theme: Theme;
48
+ lang: LangCode;
49
+ /** Fallback language for missing texts and screenshots. */
50
+ baseLang: LangCode;
51
+ rtl: boolean;
52
+ /**
53
+ * Draw the slide mirrored for a right-to-left listing: elements trade
54
+ * sides (model/mirror.ts) and the background flips. Text, screenshots and
55
+ * pictures still read normally.
56
+ */
57
+ mirror?: boolean;
58
+ /** Font and line spacing of `lang`, over each text's own style (model/typography.ts). */
59
+ typography?: LanguageTypography | null;
60
+ /** Position of this slide, for panorama backgrounds and mock-screen seeds. */
61
+ slideIndex: number;
62
+ slideCount: number;
63
+ /** Decoded image for an asset id, or null while it is not loaded yet. */
64
+ getImage(assetId: string): ImageLike | null;
65
+ /** Height / width of an asset from its AssetInfo, so layout never waits on decoding. */
66
+ getAssetAspect(assetId: string): number | null;
67
+ /**
68
+ * What to draw in a device whose slot has no screenshot:
69
+ * "mock" = generated fake app screen, "empty" = neutral screen.
70
+ */
71
+ placeholder: 'mock' | 'empty';
72
+ env: RenderEnv;
73
+ }
74
+
75
+ /** Element bounds in output pixels, used for hit-testing and selection UI. */
76
+ export interface ElementBox {
77
+ id: string;
78
+ /** Center */
79
+ cx: number;
80
+ cy: number;
81
+ /** Unrotated size */
82
+ w: number;
83
+ h: number;
84
+ /** degrees clockwise */
85
+ rotation: number;
86
+ }
87
+
88
+ export interface RenderResult {
89
+ boxes: ElementBox[];
90
+ /** Device element ids whose slot had no screenshot for `lang` (after fallback). */
91
+ missingShots: string[];
92
+ /** Text element ids that still overflow `maxLines` at the minimum size. */
93
+ overflowingText: string[];
94
+ /** Device element ids whose screenshot is resolved but not decoded yet. */
95
+ pendingImages: string[];
96
+ }
97
+
98
+ /* ------------------------------------------------------------------ */
99
+ /* Devices */
100
+ /* ------------------------------------------------------------------ */
101
+
102
+ export interface ScreenRect {
103
+ /** Relative to the device's top-left corner, pixels. */
104
+ x: number;
105
+ y: number;
106
+ w: number;
107
+ h: number;
108
+ /** Screen corner radius, pixels. */
109
+ radius: number;
110
+ }
111
+
112
+ export interface DeviceGeometry {
113
+ /** Outer size of the device artwork, pixels. */
114
+ width: number;
115
+ height: number;
116
+ screen: ScreenRect;
117
+ }
118
+
119
+ export interface DrawDeviceOptions {
120
+ /** drawDevice also paints `el.shadow` (shaped like the device body) and honours `el.style`. */
121
+ el: DeviceElement;
122
+ /** Outer device width in pixels. */
123
+ widthPx: number;
124
+ /** u = min(canvas width, height) in pixels; shadow blur/offsets are fractions of it. */
125
+ unit: number;
126
+ /** Screen height / width. */
127
+ screenAspect: number;
128
+ /** Screenshot, or null to draw the placeholder. */
129
+ screen: ImageLike | null;
130
+ placeholder: 'mock' | 'empty';
131
+ /** Seed + palette for mock screens. */
132
+ mock: { seed: number; palette: Color[]; dark: boolean };
133
+ env: RenderEnv;
134
+ }
135
+
136
+ /* ------------------------------------------------------------------ */
137
+ /* Text */
138
+ /* ------------------------------------------------------------------ */
139
+
140
+ export interface TextRun {
141
+ text: string;
142
+ highlighted: boolean;
143
+ /** Run width in pixels at the final font size. */
144
+ width: number;
145
+ }
146
+
147
+ export interface TextLine {
148
+ runs: TextRun[];
149
+ width: number;
150
+ }
151
+
152
+ export interface TextLayout {
153
+ lines: TextLine[];
154
+ /** Final font size in pixels after auto-fit. */
155
+ fontPx: number;
156
+ lineHeightPx: number;
157
+ /** Width of the box in pixels (the element width). */
158
+ boxWidth: number;
159
+ /** Total block height in pixels. */
160
+ height: number;
161
+ /** True when the text still needs more than `maxLines` at the minimum size. */
162
+ overflow: boolean;
163
+ }
164
+
165
+ export interface LayoutTextInput {
166
+ text: string;
167
+ style: TextStyle;
168
+ /** Text box width in pixels. */
169
+ boxWidth: number;
170
+ /** u = min(canvas width, height) in pixels. */
171
+ unit: number;
172
+ rtl: boolean;
173
+ lang: LangCode;
174
+ }
175
+
176
+ /* ------------------------------------------------------------------ */
177
+ /* Function signatures implemented in src/engine */
178
+ /* ------------------------------------------------------------------ */
179
+
180
+ export type RenderSlideFn = (ctx: Ctx2D, input: RenderInput) => RenderResult;
181
+ export type DeviceGeometryFn = (frame: DeviceFrameId, widthPx: number, screenAspect: number) => DeviceGeometry;
182
+ /** Draws the device centered at the current origin (caller translates/rotates). */
183
+ export type DrawDeviceFn = (ctx: Ctx2D, opts: DrawDeviceOptions) => DeviceGeometry;
184
+ export type LayoutTextFn = (ctx: Ctx2D, input: LayoutTextInput) => TextLayout;
185
+ export type DrawMockScreenFn = (
186
+ ctx: Ctx2D,
187
+ width: number,
188
+ height: number,
189
+ opts: { seed: number; palette: Color[]; dark: boolean; family?: FamilyId },
190
+ ) => void;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Folder and file naming for the "stores", "fastlane" and "flat" export
3
+ * structures (ARCHITECTURE 6.5), plus the sanitiser applied to every path
4
+ * segment, so user data (language codes, names) can never escape the export
5
+ * folder or produce a name some file system refuses.
6
+ */
7
+ import { languageInfo, sizeById } from '../model/catalog';
8
+ import type { ExportStructure, FamilyId, StoreId } from '../model/types';
9
+ import type { ExportJob } from './types';
10
+
11
+ /** Longest segment we emit, in UTF-16 code units: at most 240 UTF-8 bytes, under every file system's 255 limit. */
12
+ export const MAX_SEGMENT_LENGTH = 80;
13
+
14
+ /** Characters Windows forbids in names; "/" and "\\" are separators everywhere else too. */
15
+ const FORBIDDEN = new Set(['/', '\\', ':', '*', '?', '"', '<', '>', '|']);
16
+
17
+ /** Device names Windows reserves regardless of extension ("nul.png" is as broken as "NUL"). */
18
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
19
+
20
+ /**
21
+ * Invisible directional formatting characters. They have no business in a
22
+ * file name and the overrides allow "RLO" tricks that disguise extensions.
23
+ * Zero-width joiners stay: Persian words and emoji sequences need them.
24
+ */
25
+ function isBidiControl(cp: number): boolean {
26
+ return cp === 0x061c || cp === 0x200e || cp === 0x200f || (cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069);
27
+ }
28
+
29
+ function isControl(cp: number): boolean {
30
+ return cp < 0x20 || (cp >= 0x7f && cp <= 0x9f);
31
+ }
32
+
33
+ /** Iterating by code points joins surrogate pairs, so a code point in this range is an unpaired half: no file system accepts it. */
34
+ function isLoneSurrogate(cp: number): boolean {
35
+ return cp >= 0xd800 && cp <= 0xdfff;
36
+ }
37
+
38
+ let graphemes: Intl.Segmenter | null | undefined;
39
+
40
+ /** Shared grapheme segmenter, or null where Intl.Segmenter is missing (older WebKit). */
41
+ function graphemeSegmenter(): Intl.Segmenter | null {
42
+ if (graphemes === undefined) {
43
+ graphemes = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
44
+ }
45
+ return graphemes;
46
+ }
47
+
48
+ /** Cuts `s` to at most `max` code units without splitting an emoji, a combining sequence or a surrogate pair. */
49
+ function truncate(s: string, max: number): string {
50
+ if (s.length <= max) return s;
51
+ let out = '';
52
+ const seg = graphemeSegmenter();
53
+ if (seg) {
54
+ for (const { segment } of seg.segment(s)) {
55
+ if (out.length + segment.length > max) break;
56
+ out += segment;
57
+ }
58
+ }
59
+ // A single cluster longer than the limit (or no Segmenter): fall back to code points.
60
+ if (out === '') {
61
+ for (const ch of s) {
62
+ if (out.length + ch.length > max) break;
63
+ out += ch;
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ const trimDotsAndSpaces = (s: string) => s.replace(/^[. ]+|[. ]+$/g, '');
70
+
71
+ /**
72
+ * Makes `name` safe as one path segment on macOS, Windows and Linux: strips
73
+ * `/ \ : * ? " < > |`, control and bidi-control characters and unpaired
74
+ * surrogates, collapses whitespace, trims leading/trailing dots and spaces
75
+ * (hidden files, Windows quirks), keeps at most 80 code units, renames
76
+ * Windows device names ("CON" -> "CON_") and never returns an empty string
77
+ * ("Untitled").
78
+ */
79
+ export function sanitizeSegment(name: string): string {
80
+ let out = '';
81
+ // NFC first so a name typed on macOS (decomposed) and Windows (composed) maps to one folder.
82
+ for (const ch of String(name).normalize('NFC').replace(/\s+/g, ' ')) {
83
+ const cp = ch.codePointAt(0) ?? 0;
84
+ if (FORBIDDEN.has(ch) || isControl(cp) || isBidiControl(cp) || isLoneSurrogate(cp)) continue;
85
+ out += ch;
86
+ }
87
+ out = trimDotsAndSpaces(out.replace(/ {2,}/g, ' '));
88
+ const dot = out.indexOf('.');
89
+ const stem = (dot < 0 ? out : out.slice(0, dot)).trimEnd();
90
+ if (WINDOWS_RESERVED.test(stem)) out = `${stem}_${dot < 0 ? '' : out.slice(dot)}`;
91
+ out = trimDotsAndSpaces(truncate(out, MAX_SEGMENT_LENGTH));
92
+ return out === '' ? 'Untitled' : out;
93
+ }
94
+
95
+ /**
96
+ * fastlane deliver files screenshots by resolution, and several of our
97
+ * sizes land in the same App Store Connect screenshot set (from deliver's
98
+ * app_screenshot.rb). Two sizes in one set would upload every slide twice.
99
+ */
100
+ const DELIVER_SETS: Record<string, string> = {
101
+ '1320x2868': 'APP_IPHONE_67',
102
+ '1290x2796': 'APP_IPHONE_67',
103
+ '1260x2736': 'APP_IPHONE_67',
104
+ '1284x2778': 'APP_IPHONE_65',
105
+ '1242x2688': 'APP_IPHONE_65',
106
+ '1206x2622': 'APP_IPHONE_61',
107
+ '1179x2556': 'APP_IPHONE_61',
108
+ '1170x2532': 'APP_IPHONE_58',
109
+ '1125x2436': 'APP_IPHONE_58',
110
+ '1080x2340': 'APP_IPHONE_58',
111
+ // "12.9" plus "2nd generation" in the file name would select APP_IPAD_PRO_129; our names never say that.
112
+ '2064x2752': 'APP_IPAD_PRO_3GEN_129',
113
+ '2048x2732': 'APP_IPAD_PRO_3GEN_129',
114
+ '1668x2420': 'APP_IPAD_PRO_3GEN_11',
115
+ '1668x2388': 'APP_IPAD_PRO_3GEN_11',
116
+ '1640x2360': 'APP_IPAD_PRO_3GEN_11',
117
+ '1488x2266': 'APP_IPAD_PRO_3GEN_11',
118
+ '2880x1800': 'APP_DESKTOP',
119
+ '2560x1600': 'APP_DESKTOP',
120
+ '1440x900': 'APP_DESKTOP',
121
+ '1280x800': 'APP_DESKTOP',
122
+ '422x514': 'APP_WATCH_ULTRA',
123
+ '410x502': 'APP_WATCH_ULTRA',
124
+ '416x496': 'APP_WATCH_SERIES_10',
125
+ '396x484': 'APP_WATCH_SERIES_7',
126
+ '368x448': 'APP_WATCH_SERIES_4',
127
+ '312x390': 'APP_WATCH_SERIES_3',
128
+ '3840x2160': 'APP_APPLE_TV',
129
+ '1920x1080': 'APP_APPLE_TV',
130
+ };
131
+
132
+ /**
133
+ * The App Store Connect screenshot set fastlane deliver files an App Store
134
+ * size under; a unique key for unknown resolutions. deliver takes phones and
135
+ * tablets in either orientation, so a landscape size shares its portrait
136
+ * size's set. Apple TV 4K and Vision Pro share a resolution, so the family
137
+ * tells them apart.
138
+ */
139
+ export function deliverScreenshotSet(size: { width: number; height: number; family?: FamilyId }): string {
140
+ if (size.family === 'vision') return 'APP_APPLE_VISION_PRO';
141
+ const key = `${size.width}x${size.height}`;
142
+ return DELIVER_SETS[key] ?? DELIVER_SETS[`${size.height}x${size.width}`] ?? `SIZE_${key}`;
143
+ }
144
+
145
+ /** The store's locale code for a project language, or null when that store has none. */
146
+ export function storeLocale(lang: string, store: StoreId): string | null {
147
+ const info = languageInfo(lang);
148
+ return store === 'app-store' ? info.appStore : info.play;
149
+ }
150
+
151
+ /** "App Store" / "Google Play", as used in folder names and messages. */
152
+ export function storeLabel(store: StoreId): string {
153
+ return store === 'app-store' ? 'App Store' : 'Google Play';
154
+ }
155
+
156
+ /**
157
+ * Relative POSIX path of one exported file:
158
+ * - stores: "App Store/<locale>/<size folder>/NN.ext", "Google Play/<locale>/<size folder>/NN.ext",
159
+ * feature graphic "Google Play/<locale>/Feature graphic.ext"
160
+ * - fastlane: "fastlane/screenshots/<locale>/NN_<size id>.ext" (deliver),
161
+ * "fastlane/metadata/android/<locale>/images/<supplyFolder>/NN.ext" (supply),
162
+ * feature graphic ".../images/featureGraphic.ext"
163
+ * - flat: "<size id>/<lang>/NN.ext"
164
+ * NN is the 1-based slide number with two digits. Throws for an unknown size
165
+ * or a language the size's store has no locale for (planExport skips those).
166
+ *
167
+ * `projectName` is part of the contract but no structure contains it: files
168
+ * go straight into the folder the user picked, which `pickExportTarget`
169
+ * already names after the project.
170
+ */
171
+ export function exportPath(structure: ExportStructure, job: Omit<ExportJob, 'path'>, ext: 'png' | 'jpg', _projectName: string): string {
172
+ const size = sizeById(job.sizeId);
173
+ if (!size) throw new Error(`Unknown export size "${job.sizeId}"`);
174
+ if (!Number.isInteger(job.slideIndex) || job.slideIndex < 0) throw new RangeError(`Invalid slide index ${job.slideIndex}`);
175
+ const nn = String(job.slideIndex + 1).padStart(2, '0');
176
+ const feature = size.supplyFolder === 'featureGraphic';
177
+
178
+ if (structure === 'flat') return `${sanitizeSegment(size.id)}/${sanitizeSegment(job.lang)}/${nn}.${ext}`;
179
+ if (structure !== 'stores' && structure !== 'fastlane') throw new Error(`Unknown export structure "${String(structure)}"`);
180
+
181
+ const code = storeLocale(job.lang, size.store);
182
+ if (code === null) throw new Error(`${languageInfo(job.lang).name} has no ${storeLabel(size.store)} locale code`);
183
+ const locale = sanitizeSegment(code);
184
+
185
+ if (size.store === 'app-store') {
186
+ return structure === 'stores'
187
+ ? `App Store/${locale}/${sanitizeSegment(size.folder)}/${nn}.${ext}`
188
+ : `fastlane/screenshots/${locale}/${nn}_${sanitizeSegment(size.id)}.${ext}`;
189
+ }
190
+ if (structure === 'stores') {
191
+ return feature ? `Google Play/${locale}/Feature graphic.${ext}` : `Google Play/${locale}/${sanitizeSegment(size.folder)}/${nn}.${ext}`;
192
+ }
193
+ const images = `fastlane/metadata/android/${locale}/images`;
194
+ if (feature) return `${images}/featureGraphic.${ext}`;
195
+ if (!size.supplyFolder) throw new Error(`Export size "${size.id}" has no fastlane supply folder`);
196
+ return `${images}/${size.supplyFolder}/${nn}.${ext}`;
197
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Minimal, fast PNG encoder for export renders.
3
+ *
4
+ * Output is always 8-bit truecolour without alpha (colour type 2): Google
5
+ * Play rejects screenshots with an alpha channel and App Store Connect
6
+ * rejects transparency, so any alpha in the canvas pixels is composited over
7
+ * white here. Rows use the adaptive filter heuristic libpng uses (the filter
8
+ * with the smallest sum of absolute signed bytes wins), then fflate deflates
9
+ * the whole image in one go.
10
+ */
11
+ import { zlibSync } from 'fflate';
12
+
13
+ const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14
+ /** Bytes per pixel of the encoded image (RGB). */
15
+ const BPP = 3;
16
+ /** Largest IDAT payload we write. Any size is legal; 1 MiB keeps chunks friendly to streaming readers. */
17
+ export const MAX_IDAT_BYTES = 1 << 20;
18
+ const MAX_DIMENSION = 0x7fffffff;
19
+
20
+ const CRC_TABLE = (() => {
21
+ const table = new Int32Array(256);
22
+ for (let n = 0; n < 256; n++) {
23
+ let c = n;
24
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
25
+ table[n] = c;
26
+ }
27
+ return table;
28
+ })();
29
+
30
+ /** CRC-32 (ISO 3309, as used by PNG chunks) of `data[start..end)`. Pass a previous result as `seed` to continue it. */
31
+ export function crc32(data: Uint8Array, start = 0, end = data.length, seed = 0): number {
32
+ let c = ~seed;
33
+ for (let i = start; i < end; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
34
+ return ~c >>> 0;
35
+ }
36
+
37
+ /** Throws a RangeError unless `rgba` holds exactly width x height RGBA pixels. */
38
+ export function assertPngInput(rgba: ArrayLike<number>, width: number, height: number): void {
39
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > MAX_DIMENSION || height > MAX_DIMENSION) {
40
+ throw new RangeError(`Invalid PNG size ${width} x ${height}`);
41
+ }
42
+ const expected = width * height * 4;
43
+ if (rgba.length !== expected) throw new RangeError(`Expected ${expected} bytes of RGBA pixels for ${width} x ${height}, got ${rgba.length}`);
44
+ }
45
+
46
+ const LEVELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
47
+
48
+ function deflateLevel(level: number): (typeof LEVELS)[number] {
49
+ if (!Number.isFinite(level)) return 6;
50
+ return LEVELS[Math.min(9, Math.max(0, Math.round(level)))];
51
+ }
52
+
53
+ /**
54
+ * Copies one row of RGBA pixels into `out` as RGB composited over white.
55
+ * Canvas ImageData is not premultiplied, so a pixel with alpha `a` becomes
56
+ * c * a/255 + 255 * (1 - a/255), rounded to nearest (no ties exist).
57
+ */
58
+ function compositeRow(rgba: Uint8ClampedArray | Uint8Array, src: number, out: Uint8Array, width: number): void {
59
+ for (let x = 0, i = src, j = 0; x < width; x++, i += 4, j += 3) {
60
+ const a = rgba[i + 3];
61
+ if (a === 255) {
62
+ out[j] = rgba[i];
63
+ out[j + 1] = rgba[i + 1];
64
+ out[j + 2] = rgba[i + 2];
65
+ } else if (a === 0) {
66
+ out[j] = 255;
67
+ out[j + 1] = 255;
68
+ out[j + 2] = 255;
69
+ } else {
70
+ out[j] = 255 - ((((255 - rgba[i]) * a + 127) / 255) | 0);
71
+ out[j + 1] = 255 - ((((255 - rgba[i + 1]) * a + 127) / 255) | 0);
72
+ out[j + 2] = 255 - ((((255 - rgba[i + 2]) * a + 127) / 255) | 0);
73
+ }
74
+ }
75
+ }
76
+
77
+ /** |b| for a filtered byte read as a signed int8, the per-byte cost of the MSAD heuristic. */
78
+ const cost = (v: number) => (v < 128 ? v : 256 - v);
79
+
80
+ function paeth(a: number, b: number, c: number): number {
81
+ const p = a + b - c;
82
+ const pa = p > a ? p - a : a - p;
83
+ const pb = p > b ? p - b : b - p;
84
+ const pc = p > c ? p - c : c - p;
85
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
86
+ }
87
+
88
+ /** Filter type (0 None, 1 Sub, 2 Up, 3 Average, 4 Paeth) with the smallest cost for this row; earlier types win ties. */
89
+ function chooseFilter(cur: Uint8Array, prev: Uint8Array, stride: number): number {
90
+ let none = 0;
91
+ let sub = 0;
92
+ let up = 0;
93
+ let avg = 0;
94
+ let pae = 0;
95
+ // The first pixel has no left neighbour (a = c = 0), which removes branches from the main loop.
96
+ const head = Math.min(BPP, stride);
97
+ for (let i = 0; i < head; i++) {
98
+ const x = cur[i];
99
+ const b = prev[i];
100
+ none += cost(x);
101
+ sub += cost(x);
102
+ up += cost((x - b) & 255);
103
+ avg += cost((x - (b >> 1)) & 255);
104
+ pae += cost((x - b) & 255);
105
+ }
106
+ for (let i = BPP; i < stride; i++) {
107
+ const x = cur[i];
108
+ const a = cur[i - BPP];
109
+ const b = prev[i];
110
+ const c = prev[i - BPP];
111
+ none += cost(x);
112
+ sub += cost((x - a) & 255);
113
+ up += cost((x - b) & 255);
114
+ avg += cost((x - ((a + b) >> 1)) & 255);
115
+ pae += cost((x - paeth(a, b, c)) & 255);
116
+ }
117
+ let best = 0;
118
+ let min = none;
119
+ if (sub < min) {
120
+ best = 1;
121
+ min = sub;
122
+ }
123
+ if (up < min) {
124
+ best = 2;
125
+ min = up;
126
+ }
127
+ if (avg < min) {
128
+ best = 3;
129
+ min = avg;
130
+ }
131
+ return pae < min ? 4 : best;
132
+ }
133
+
134
+ /** Writes the filter byte and the filtered row at `out[o]`. */
135
+ function writeRow(out: Uint8Array, o: number, filter: number, cur: Uint8Array, prev: Uint8Array, stride: number): void {
136
+ out[o] = filter;
137
+ const d = o + 1;
138
+ const head = Math.min(BPP, stride);
139
+ switch (filter) {
140
+ case 0:
141
+ out.set(cur, d);
142
+ break;
143
+ case 1:
144
+ for (let i = 0; i < head; i++) out[d + i] = cur[i];
145
+ for (let i = BPP; i < stride; i++) out[d + i] = cur[i] - cur[i - BPP];
146
+ break;
147
+ case 2:
148
+ for (let i = 0; i < stride; i++) out[d + i] = cur[i] - prev[i];
149
+ break;
150
+ case 3:
151
+ for (let i = 0; i < head; i++) out[d + i] = cur[i] - (prev[i] >> 1);
152
+ for (let i = BPP; i < stride; i++) out[d + i] = cur[i] - ((cur[i - BPP] + prev[i]) >> 1);
153
+ break;
154
+ default:
155
+ for (let i = 0; i < head; i++) out[d + i] = cur[i] - prev[i];
156
+ for (let i = BPP; i < stride; i++) out[d + i] = cur[i] - paeth(cur[i - BPP], prev[i], prev[i - BPP]);
157
+ }
158
+ }
159
+
160
+ /** Composites and filters every row into the byte stream that IDAT compresses. */
161
+ function filterImage(rgba: Uint8ClampedArray | Uint8Array, width: number, height: number): Uint8Array {
162
+ const stride = width * BPP;
163
+ const out = new Uint8Array(height * (stride + 1));
164
+ // Uint8Array stores wrap modulo 256, which is exactly the filter arithmetic.
165
+ let prev = new Uint8Array(stride);
166
+ let cur = new Uint8Array(stride);
167
+ for (let y = 0; y < height; y++) {
168
+ compositeRow(rgba, y * width * 4, cur, width);
169
+ writeRow(out, y * (stride + 1), chooseFilter(cur, prev, stride), cur, prev, stride);
170
+ const t = prev;
171
+ prev = cur;
172
+ cur = t;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ const ascii = (s: string) => Array.from(s, (ch) => ch.charCodeAt(0));
178
+ const IHDR = ascii('IHDR');
179
+ const IDAT = ascii('IDAT');
180
+ const IEND = ascii('IEND');
181
+
182
+ /** Writes one chunk (length, type, data, CRC of type + data) at `o`; returns the offset after it. */
183
+ function writeChunk(png: Uint8Array, view: DataView, o: number, type: number[], data: Uint8Array): number {
184
+ view.setUint32(o, data.length);
185
+ png.set(type, o + 4);
186
+ png.set(data, o + 8);
187
+ const end = o + 8 + data.length;
188
+ view.setUint32(end, crc32(png, o + 4, end));
189
+ return end + 4;
190
+ }
191
+
192
+ /** encodePng with the precise buffer type, so the worker can transfer the result. */
193
+ export function encodePngBuffer(rgba: Uint8ClampedArray | Uint8Array, width: number, height: number, level = 6): Uint8Array<ArrayBuffer> {
194
+ assertPngInput(rgba, width, height);
195
+ const idat = zlibSync(filterImage(rgba, width, height), { level: deflateLevel(level) });
196
+
197
+ const header = new Uint8Array(13);
198
+ const hv = new DataView(header.buffer);
199
+ hv.setUint32(0, width);
200
+ hv.setUint32(4, height);
201
+ header[8] = 8; // bit depth
202
+ header[9] = 2; // colour type: truecolour, no alpha
203
+ // compression, filter and interlace methods stay 0
204
+
205
+ const idatChunks = Math.max(1, Math.ceil(idat.length / MAX_IDAT_BYTES));
206
+ const png = new Uint8Array(SIGNATURE.length + (12 + 13) + idat.length + 12 * idatChunks + 12);
207
+ const view = new DataView(png.buffer);
208
+ png.set(SIGNATURE, 0);
209
+ let o = writeChunk(png, view, SIGNATURE.length, IHDR, header);
210
+ for (let start = 0; start < idat.length; start += MAX_IDAT_BYTES) {
211
+ o = writeChunk(png, view, o, IDAT, idat.subarray(start, Math.min(idat.length, start + MAX_IDAT_BYTES)));
212
+ }
213
+ writeChunk(png, view, o, IEND, new Uint8Array(0));
214
+ return png;
215
+ }
216
+
217
+ /**
218
+ * RGBA (canvas ImageData order) -> PNG bytes with color type 2 (RGB, 8-bit).
219
+ * Alpha is composited over white. `level` is the deflate level 0..9.
220
+ */
221
+ export function encodePng(rgba: Uint8ClampedArray | Uint8Array, width: number, height: number, level = 6): Uint8Array {
222
+ return encodePngBuffer(rgba, width, height, level);
223
+ }
@@ -0,0 +1,60 @@
1
+ /** Shapes shared by the export modules; re-exported from `./index`. */
2
+ import type { FamilyId, LangCode, Project } from '../model/types';
3
+ type ExportTarget = string;
4
+
5
+ export interface ExportJob {
6
+ family: FamilyId;
7
+ sizeId: string;
8
+ width: number;
9
+ height: number;
10
+ lang: LangCode;
11
+ slideIndex: number;
12
+ /** Relative POSIX path inside the export target, e.g. "App Store/en-US/iPhone 6.9/01.png". */
13
+ path: string;
14
+ }
15
+
16
+ export interface ExportWarning {
17
+ kind: 'missing-screenshot' | 'text-overflow' | 'too-many-slides' | 'no-locale-code';
18
+ message: string;
19
+ slideIndex?: number;
20
+ family?: FamilyId;
21
+ lang?: LangCode;
22
+ }
23
+
24
+ export interface ExportPlan {
25
+ jobs: ExportJob[];
26
+ warnings: ExportWarning[];
27
+ }
28
+
29
+ export interface ExportProgress {
30
+ done: number;
31
+ total: number;
32
+ current: ExportJob | null;
33
+ }
34
+
35
+ export interface ExportReport {
36
+ written: number;
37
+ failed: { job: ExportJob; error: string }[];
38
+ cancelled: boolean;
39
+ target: ExportTarget;
40
+ ms: number;
41
+ }
42
+
43
+ export interface RunExportOptions {
44
+ plan: ExportPlan;
45
+ target: ExportTarget;
46
+ format: 'png' | 'jpeg';
47
+ jpegQuality: number;
48
+ /** Renders one job to encoded file bytes (provided by the runtime). */
49
+ renderJob(job: ExportJob, format: 'png' | 'jpeg', jpegQuality: number): Promise<Uint8Array>;
50
+ write(target: ExportTarget, path: string, data: Uint8Array): Promise<void>;
51
+ onProgress?(p: ExportProgress): void;
52
+ signal?: AbortSignal;
53
+ }
54
+
55
+ export interface CsvImportResult {
56
+ project: Project;
57
+ updatedCells: number;
58
+ addedLanguages: LangCode[];
59
+ skippedRows: number;
60
+ }