diff --git a/app.json b/app.json index f39fb45..4901561 100644 --- a/app.json +++ b/app.json @@ -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" diff --git a/assets/images/android-icon-background.png b/assets/images/android-icon-background.png index 5ffefc5..6959c02 100644 Binary files a/assets/images/android-icon-background.png and b/assets/images/android-icon-background.png differ diff --git a/assets/images/android-icon-foreground.png b/assets/images/android-icon-foreground.png index 3a9e501..953b9c0 100644 Binary files a/assets/images/android-icon-foreground.png and b/assets/images/android-icon-foreground.png differ diff --git a/assets/images/android-icon-monochrome.png b/assets/images/android-icon-monochrome.png index 77484eb..7c35e64 100644 Binary files a/assets/images/android-icon-monochrome.png and b/assets/images/android-icon-monochrome.png differ diff --git a/assets/images/favicon.png b/assets/images/favicon.png index 408bd74..87bf8ae 100644 Binary files a/assets/images/favicon.png and b/assets/images/favicon.png differ diff --git a/assets/images/icon.png b/assets/images/icon.png index 7165a53..c650f28 100644 Binary files a/assets/images/icon.png and b/assets/images/icon.png differ diff --git a/assets/images/splash-icon.png b/assets/images/splash-icon.png index 03d6f6b..917444c 100644 Binary files a/assets/images/splash-icon.png and b/assets/images/splash-icon.png differ diff --git a/package.json b/package.json index c39a71b..0fbca7d 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00eab7b..fafbbb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/generate-branding.mjs b/scripts/generate-branding.mjs new file mode 100644 index 0000000..4d9c301 --- /dev/null +++ b/scripts/generate-branding.mjs @@ -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); +}); +