This commit is contained in:
eric
2026-04-05 01:06:05 -05:00
parent 3bc1599763
commit 9f8b02ea4f
10 changed files with 314 additions and 2 deletions

View File

@@ -10,7 +10,7 @@
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#2D2AF6"
},
"ios": {
"supportsTablet": true
@@ -19,7 +19,7 @@
"package": "com.douyin.recorder.remote",
"usesCleartextTraffic": true,
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"backgroundColor": "#2D2AF6",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 438 KiB

View File

@@ -3,6 +3,7 @@
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"generate:branding": "node ./scripts/generate-branding.mjs",
"start": "expo start",
"android": "expo run:android",
"run:android": "expo run:android",
@@ -38,6 +39,7 @@
},
"devDependencies": {
"@types/react": "~19.2.2",
"pngjs": "^7.0.0",
"typescript": "~5.9.2"
},
"private": true,

9
pnpm-lock.yaml generated
View File

@@ -87,6 +87,9 @@ importers:
'@types/react':
specifier: ~19.2.2
version: 19.2.14
pngjs:
specifier: ^7.0.0
version: 7.0.0
typescript:
specifier: ~5.9.2
version: 5.9.3
@@ -2571,6 +2574,10 @@ packages:
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
engines: {node: '>=4.0.0'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@@ -6285,6 +6292,8 @@ snapshots:
pngjs@3.4.0: {}
pngjs@7.0.0: {}
postcss-value-parser@4.2.0: {}
postcss@8.4.49:

View File

@@ -0,0 +1,301 @@
import fs from "node:fs/promises";
import path from "node:path";
import { PNG } from "pngjs";
const root = path.resolve(import.meta.dirname, "..");
const outDir = path.join(root, "assets", "images");
const COLORS = {
indigo: [0x2d, 0x2a, 0xf6, 0xff],
cyan: [0x27, 0xd7, 0xff, 0xff],
white: [0xff, 0xff, 0xff, 0xff],
red: [0xff, 0x3b, 0x30, 0xff],
transparent: [0x00, 0x00, 0x00, 0x00],
};
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function mixRGBA(c1, c2, t) {
return [
Math.round(lerp(c1[0], c2[0], t)),
Math.round(lerp(c1[1], c2[1], t)),
Math.round(lerp(c1[2], c2[2], t)),
Math.round(lerp(c1[3], c2[3], t)),
];
}
function putPixel(img, x, y, rgba) {
const idx = (img.width * y + x) << 2;
img.data[idx] = rgba[0];
img.data[idx + 1] = rgba[1];
img.data[idx + 2] = rgba[2];
img.data[idx + 3] = rgba[3];
}
function over(dst, src) {
const sa = src[3] / 255;
const da = dst[3] / 255;
const outA = sa + da * (1 - sa);
if (outA <= 0) return [0, 0, 0, 0];
const outR = (src[0] * sa + dst[0] * da * (1 - sa)) / outA;
const outG = (src[1] * sa + dst[1] * da * (1 - sa)) / outA;
const outB = (src[2] * sa + dst[2] * da * (1 - sa)) / outA;
return [Math.round(outR), Math.round(outG), Math.round(outB), Math.round(outA * 255)];
}
function sampleGradient(x, y, size) {
const t = clamp01((x + y) / (2 * (size - 1)));
return mixRGBA(COLORS.indigo, COLORS.cyan, t);
}
function sdRoundRect(px, py, cx, cy, hw, hh, r) {
const x = Math.abs(px - cx) - hw;
const y = Math.abs(py - cy) - hh;
const ax = Math.max(x, 0);
const ay = Math.max(y, 0);
const outside = Math.hypot(ax, ay) - r;
const inside = Math.min(Math.max(x, y), 0);
return outside + inside;
}
function sdCircle(px, py, cx, cy, r) {
return Math.hypot(px - cx, py - cy) - r;
}
function aaCoverage(sd, feather) {
// Signed distance -> coverage (1 inside, 0 outside) with soft edge.
return clamp01(0.5 - sd / feather);
}
function render({ size, supersample = 3, draw, background = "gradient" }) {
const hi = size * supersample;
const img = new PNG({ width: hi, height: hi });
for (let y = 0; y < hi; y++) {
for (let x = 0; x < hi; x++) {
const bx =
background === "transparent"
? COLORS.transparent
: sampleGradient(x / supersample, y / supersample, size);
putPixel(img, x, y, bx);
}
}
draw(img, supersample);
// Downsample (box filter).
const out = new PNG({ width: size, height: size });
const n = supersample * supersample;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let r = 0,
g = 0,
b = 0,
a = 0;
for (let sy = 0; sy < supersample; sy++) {
for (let sx = 0; sx < supersample; sx++) {
const hx = x * supersample + sx;
const hy = y * supersample + sy;
const idx = (hi * hy + hx) << 2;
r += img.data[idx];
g += img.data[idx + 1];
b += img.data[idx + 2];
a += img.data[idx + 3];
}
}
putPixel(out, x, y, [
Math.round(r / n),
Math.round(g / n),
Math.round(b / n),
Math.round(a / n),
]);
}
}
return out;
}
function drawCamera({ includeRedDot, strokePx, glow = false, silhouette = false, roundedIcon = false }) {
return (img, ss) => {
const size = img.width / ss;
const hi = img.width;
const cx = size / 2;
const cy = size / 2;
const bodyW = size * 0.56;
const bodyH = size * 0.36;
const r = size * 0.06;
const lensR = size * 0.10;
const topBumpW = size * 0.18;
const topBumpH = size * 0.08;
const bodyCx = cx;
const bodyCy = cy;
const bumpCx = cx - bodyW * 0.38 + topBumpW / 2;
const bumpCy = cy - bodyH / 2 - topBumpH * 0.05;
const dotCx = cx + bodyW * 0.36;
const dotCy = cy - bodyH * 0.28;
const dotR = size * 0.04;
const feather = 0.9; // in hi-res pixels
const stroke = strokePx;
function applyOver(x, y, color) {
const idx = (img.width * y + x) << 2;
const dst = [img.data[idx], img.data[idx + 1], img.data[idx + 2], img.data[idx + 3]];
const out = over(dst, color);
img.data[idx] = out[0];
img.data[idx + 1] = out[1];
img.data[idx + 2] = out[2];
img.data[idx + 3] = out[3];
}
for (let y = 0; y < hi; y++) {
for (let x = 0; x < hi; x++) {
const px = (x + 0.5) / ss;
const py = (y + 0.5) / ss;
// Optional rounded icon mask (for 1024 icon and favicon).
if (roundedIcon) {
const sdMask = sdRoundRect(px, py, cx, cy, size / 2, size / 2, size * 0.215);
const m = aaCoverage(sdMask, 1.25 / ss);
if (m < 1) {
// apply alpha mask by scaling current alpha
const idx = (img.width * y + x) << 2;
img.data[idx + 3] = Math.round(img.data[idx + 3] * m);
}
}
if (silhouette) {
const sdBody = sdRoundRect(px, py, bodyCx, bodyCy, bodyW / 2, bodyH / 2, r);
const sdBump = sdRoundRect(px, py, bumpCx, bumpCy, topBumpW / 2, topBumpH / 2, r * 0.6);
const inside = Math.min(sdBody, sdBump);
const hole = sdCircle(px, py, cx, cy, lensR * 0.62);
const sd = Math.max(inside, -hole); // even-odd hole
const cov = aaCoverage(sd, feather / ss);
if (cov > 0) applyOver(x, y, [COLORS.white[0], COLORS.white[1], COLORS.white[2], Math.round(255 * cov)]);
continue;
}
// Outline stroke using distance to shapes.
const sdBody = sdRoundRect(px, py, bodyCx, bodyCy, bodyW / 2, bodyH / 2, r);
const sdBump = sdRoundRect(px, py, bumpCx, bumpCy, topBumpW / 2, topBumpH / 2, r * 0.6);
const sdLens = sdCircle(px, py, cx, cy, lensR);
const d = Math.min(Math.abs(sdBody), Math.abs(sdBump), Math.abs(sdLens));
const cov = aaCoverage(d - stroke / 2, feather / ss);
if (cov > 0) {
const alpha = Math.round(255 * cov);
applyOver(x, y, [COLORS.white[0], COLORS.white[1], COLORS.white[2], alpha]);
}
if (includeRedDot) {
const sdDot = sdCircle(px, py, dotCx, dotCy, dotR);
const covDot = aaCoverage(sdDot, feather / ss);
if (covDot > 0) {
applyOver(x, y, [COLORS.red[0], COLORS.red[1], COLORS.red[2], Math.round(255 * covDot)]);
}
}
if (glow) {
// soft radial glow behind the logo area
const glowR = size * 0.38;
const gd = sdCircle(px, py, cx, cy, glowR);
const gCov = clamp01(1 - Math.max(gd, 0) / (size * 0.22));
if (gCov > 0) {
applyOver(x, y, [255, 255, 255, Math.round(38 * gCov)]);
}
}
}
}
};
}
async function writePng(filePath, png) {
const buf = PNG.sync.write(png);
await fs.writeFile(filePath, buf);
}
async function main() {
await fs.mkdir(outDir, { recursive: true });
// icon.png (1024)
await writePng(
path.join(outDir, "icon.png"),
render({
size: 1024,
supersample: 3,
background: "gradient",
draw: drawCamera({ includeRedDot: true, strokePx: 18, roundedIcon: true }),
})
);
// splash-icon.png (2048)
await writePng(
path.join(outDir, "splash-icon.png"),
render({
size: 2048,
supersample: 2,
background: "gradient",
draw: drawCamera({ includeRedDot: true, strokePx: 26, glow: true }),
})
);
// android-icon-background.png (1024)
await writePng(
path.join(outDir, "android-icon-background.png"),
render({ size: 1024, supersample: 2, background: "gradient", draw: () => {} })
);
// android-icon-foreground.png (1024, transparent bg)
await writePng(
path.join(outDir, "android-icon-foreground.png"),
render({
size: 1024,
supersample: 3,
background: "transparent",
draw: drawCamera({ includeRedDot: true, strokePx: 20 }),
})
);
// android-icon-monochrome.png (1024, transparent bg)
await writePng(
path.join(outDir, "android-icon-monochrome.png"),
render({
size: 1024,
supersample: 3,
background: "transparent",
draw: drawCamera({ includeRedDot: false, strokePx: 0, silhouette: true }),
})
);
// favicon.png (48)
await writePng(
path.join(outDir, "favicon.png"),
render({
size: 48,
supersample: 6,
background: "gradient",
draw: drawCamera({ includeRedDot: true, strokePx: 2, roundedIcon: true }),
})
);
// eslint-disable-next-line no-console
console.log("Branding assets generated in assets/images/");
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});