Open History to see the last commit
1#!/usr/bin/env node
2
3// Android masks a 72dp safe area inside 108dp; full-bleed backgrounds and a separate wordmark avoid double silhouettes/cropping.
4
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as Console from "effect/Console";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import sharp from "sharp";
13
14type IconVariant = "dev" | "nightly" | "prod";
15
16const ADAPTIVE_CANVAS = 432;
17const SPLASH_CANVAS = 1152;
18const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 };
19const WORDMARK_FRACTION = 0.48;
20const COMPOSER_CANVAS_PT = 1024;
21const SVG_DENSITY = 300;
22const OUTPUT_DIRECTORY = "apps/mobile/assets";
23const PRODUCTION_BACKGROUND_COLOR = "#000000";
24
25export class AndroidIconRenderError extends Schema.TaggedError<AndroidIconRenderError>()(
26 "AndroidIconRenderError",
27 { layer: Schema.String, cause: Schema.Defect() },
28) {}
29
30const wordmarkTransform = (size: number) => {
31 const scale = (size * WORDMARK_FRACTION) / TEXT.width;
32 const tx = (size - TEXT.width * scale) / 2 - TEXT.x * scale;
33 const ty = (size - TEXT.height * scale) / 2 - TEXT.y * scale;
34 return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) scale(${scale.toFixed(4)})`;
35};
36
37const canvasSvg = (size: number, inner: string) =>
38 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none">${inner}</svg>`;
39
40// Remove the iOS 10pt corner clip; Android's own mask needs full bleed.
41const fullBleed = (svg: string) =>
42 svg.replace(/<rect width="128" height="128" rx="10"\/>/, '<rect width="128" height="128"/>');
43
44const rasterize = (layer: string, svg: string, width: number, height = width) =>
45 Effect.tryPromise({
46 try: () =>
47 sharp(Buffer.from(svg), { density: SVG_DENSITY }).resize(width, height).png().toBuffer(),
48 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
49 });
50
51const composite = (
52 layer: string,
53 base: Buffer,
54 overlays: ReadonlyArray<{ input: Buffer; left?: number; top?: number }>,
55) =>
56 Effect.tryPromise({
57 try: () =>
58 sharp(base)
59 .composite([...overlays])
60 .png()
61 .toBuffer(),
62 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
63 });
64
65const solidCanvas = (layer: string, size: number, background: string) =>
66 Effect.tryPromise({
67 try: () =>
68 sharp({ create: { width: size, height: size, channels: 4, background } })
69 .png()
70 .toBuffer(),
71 catch: (cause) => new AndroidIconRenderError({ layer, cause }),
72 });
73
74const readLayerSource = Effect.fn("androidIcons.readLayerSource")(function* (
75 repositoryRoot: string,
76 variant: IconVariant,
77 file: string,
78) {
79 const fs = yield* FileSystem.FileSystem;
80 const path = yield* Path.Path;
81 return yield* fs.readFileString(
82 path.join(repositoryRoot, "assets", variant, "app-icon.icon", "Assets", file),
83 );
84});
85
86const renderForeground = Effect.fn("androidIcons.renderForeground")(function* (
87 repositoryRoot: string,
88 size: number,
89) {
90 const text = yield* readLayerSource(repositoryRoot, "prod", "text.svg");
91 const paths = text.match(/<path[^>]*\/>/g) ?? [];
92 return yield* rasterize(
93 "foreground",
94 canvasSvg(size, `<g transform="${wordmarkTransform(size)}">${paths.join("")}</g>`),
95 size,
96 );
97});
98
99const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")(
100 function* (repositoryRoot: string, size: number) {
101 const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg");
102 const defs = annotations.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? "";
103 const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, "");
104 const paper = yield* readLayerSource(repositoryRoot, "dev", "background.svg");
105 const background = yield* rasterize("dev-background", fullBleed(paper), size);
106 const overlay = yield* rasterize(
107 "dev-annotations",
108 canvasSvg(size, `${defs}<g transform="${wordmarkTransform(size)}">${body}</g>`),
109 size,
110 );
111 return yield* composite("dev-background", background, [{ input: overlay }]);
112 },
113);
114
115const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground")(function* (
116 repositoryRoot: string,
117 size: number,
118) {
119 // Match assets/nightly/app-icon.icon/icon.json; omit blur, which Icon Composer ignores and rasterization smears.
120 const clouds = [
121 { file: "cloud-lower-left.svg", scale: 25, translation: [-309.6375, 268.66077693836917] },
122 {
123 file: "cloud-upper-right.svg",
124 scale: 15,
125 translation: [387.9605131881942, -134.30064713259117],
126 },
127 ] as const;
128 const k = size / COMPOSER_CANVAS_PT;
129 const overlays = yield* Effect.forEach(clouds, (cloud) =>
130 Effect.gen(function* () {
131 const width = Math.round(64 * cloud.scale * k);
132 const height = Math.round(32 * cloud.scale * k);
133 const left = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[0]) * k - width / 2);
134 const top = Math.round((COMPOSER_CANVAS_PT / 2 + cloud.translation[1]) * k - height / 2);
135 const source = yield* readLayerSource(repositoryRoot, "nightly", cloud.file);
136 const png = yield* rasterize(
137 cloud.file,
138 source.replace(/ filter="url\(#soft\)"/, ""),
139 width,
140 height,
141 );
142 const x0 = Math.max(0, left);
143 const y0 = Math.max(0, top);
144 const x1 = Math.min(size, left + width);
145 const y1 = Math.min(size, top + height);
146 const clipped = yield* Effect.tryPromise({
147 try: () =>
148 sharp(png)
149 .extract({ left: x0 - left, top: y0 - top, width: x1 - x0, height: y1 - y0 })
150 .png()
151 .toBuffer(),
152 catch: (cause) => new AndroidIconRenderError({ layer: cloud.file, cause }),
153 });
154 return { input: clipped, left: x0, top: y0 };
155 }),
156 );
157 const sky = yield* readLayerSource(repositoryRoot, "nightly", "background.svg");
158 const background = yield* rasterize("nightly-background", fullBleed(sky), size);
159 return yield* composite("nightly-background", background, overlays);
160});
161
162const renderBackground = Effect.fn("androidIcons.renderBackground")(function* (
163 repositoryRoot: string,
164 variant: IconVariant,
165 size: number,
166) {
167 switch (variant) {
168 case "dev":
169 return yield* renderDevelopmentBackground(repositoryRoot, size);
170 case "nightly":
171 return yield* renderNightlyBackground(repositoryRoot, size);
172 case "prod":
173 return yield* solidCanvas("prod-background", size, PRODUCTION_BACKGROUND_COLOR);
174 }
175});
176
177const renderSplashIcon = Effect.fn("androidIcons.renderSplashIcon")(function* (
178 repositoryRoot: string,
179 variant: IconVariant,
180) {
181 const background = yield* renderBackground(repositoryRoot, variant, SPLASH_CANVAS);
182 const foreground = yield* renderForeground(repositoryRoot, SPLASH_CANVAS);
183 return yield* composite(`${variant}-splash`, background, [{ input: foreground }]);
184});
185
186const exportAndroidIcons = Effect.gen(function* () {
187 const fs = yield* FileSystem.FileSystem;
188 const path = yield* Path.Path;
189 const repositoryRoot = path.resolve(import.meta.dirname, "..");
190 const outputs = [
191 ["android-icon-foreground.png", yield* renderForeground(repositoryRoot, ADAPTIVE_CANVAS)],
192 [
193 "android-icon-background-dev.png",
194 yield* renderDevelopmentBackground(repositoryRoot, ADAPTIVE_CANVAS),
195 ],
196 [
197 "android-icon-background-nightly.png",
198 yield* renderNightlyBackground(repositoryRoot, ADAPTIVE_CANVAS),
199 ],
200 ["android-splash-icon-dev.png", yield* renderSplashIcon(repositoryRoot, "dev")],
201 ["android-splash-icon-nightly.png", yield* renderSplashIcon(repositoryRoot, "nightly")],
202 ["android-splash-icon-prod.png", yield* renderSplashIcon(repositoryRoot, "prod")],
203 ] as const;
204 for (const [name, contents] of outputs) {
205 yield* fs.writeFile(path.join(repositoryRoot, OUTPUT_DIRECTORY, name), contents);
206 yield* Console.log(`wrote ${OUTPUT_DIRECTORY}/${name}`);
207 }
208});
209
210if (import.meta.main) {
211 exportAndroidIcons.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
212}