scripts/gen-dock-finish.js
Rama main · réplica de lectura del repositorio original.
// Generate the pre-dithered "glass finish" overlays for the bottom dock
// (BottomChromeBackdrop): a warm gold breath + warm-ivory edge sheen + film
// grain, baked into one RGBA PNG per dock mode.
//
// Why baked instead of live LinearGradients
// -----------------------------------------
// The dock's washes are ultra-low-alpha ramps (peak 0.055–0.10) stretched
// across the whole pane. An 8-bit framebuffer only has ~14–26 discrete codes
// for ramps that shallow, so the GPU renders them as flat bands with hard
// 1-code step edges. Worse, the original steel wash faded OUT left-to-right
// while the gold faded IN, and where the composite crossed NEUTRAL GRAY
// (~70–75% of the pane width) R, G and B shared the same fractional part —
// all three channels stepped down at the SAME pixel column. That coordinated 1-code
// luminance cliff is a visible vertical line on an OLED near black (the
// "~75% line"). Paint.DITHER_FLAG (patched into react-native-linear-gradient)
// is a no-op on modern hardware-accelerated Android, and the 5%-opacity
// grain overlay delivers only ~±0.45 code — an order of magnitude too weak
// to break a coherent edge. No live-gradient geometry can fix this; the
// quantization has to be noise-shaped at authoring time.
//
// Strategy
// --------
// Compose the washes + film grain in float (straight math, no 8-bit
// intermediates), then quantize with TWO sub-perceptual dithers: ±0.5-code
// noise on alpha (randomizes the background-transmission rounding) and a
// shared ±0.5-code luma offset on the premul targets (randomizes the
// decode-premultiply rounding of the pane's own emission), with the straight
// color re-solved against the chosen alpha so the delivered contribution
// stays mean-exact. Android premultiplies on decode, which rounds those
// jittered products to spatially-noisy integer codes — proper dithering,
// living in the pixels, independent of anything Skia does and independent
// of how strong the VISIBLE film grain is tuned.
//
// The washes span corner-to-corner (0,0 → 1,1) of each pane, so stretching
// the PNG over a different device width is mathematically identical to
// re-running a corner-to-corner gradient on that pane.
//
// Outputs (1080 wide = 360dp @3x, the LG V600 target; other densities
// stretch, which is fine for noise + diagonal washes). One PNG per chrome
// pane geometry — the bottom dock's two modes plus the Home top bar:
// apps/android/assets/chrome-finish-mini.png 1080x504 (tab bar + mini, 168dp)
// apps/android/assets/chrome-finish-bare.png 1080x258 (tab bar only, 86dp)
// apps/android/assets/chrome-finish-top.png 1080x276 (Home top bar, 92dp)
const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');
const WIDTH = 1080;
const PANES = [
{ file: 'chrome-finish-mini.png', height: 504 }, // (86 + 82)dp * 3
{ file: 'chrome-finish-bare.png', height: 258 }, // 86dp * 3
{ file: 'chrome-finish-top.png', height: 276 }, // TOP_CHROME_HEIGHT 92dp * 3
];
// Keep these in lockstep with the recipe documented in BottomChromeBackdrop.
// BLACK GLASS pass (2026-07-03): the steel wash is GONE — a cool blue-grey
// lift is exactly the cast Jacob vetoed on near-black lacquer. What remains
// is a faint warm breath (gold, bottom-right) and a warm-ivory edge light
// (tungsten, not studio-white) — felt, not seen.
const GOLD = { a: 0.04, b: 138, g: 192, r: 212 }; // alpha 0 -> 0.04 along t
// Sheen: warm ivory 0.022 @t=0, 0.006 @t=0.35, 0 @t=1 (piecewise linear).
const SHEEN = { b: 236, g: 248, r: 255 };
const SHEEN_HEAD = 0.022;
const SHEEN_KNEE = 0.006;
const SHEEN_KNEE_T = 0.35;
// FILM GRAIN (the 80's-home-movie / quality-60's-print ask): visible silver
// halide texture, not digital static. Structure: a faint warm FOG floor —
// the lifted black of projected film base, and the emission the grain
// modulates — plus ZERO-MEAN signed grain added straight to the composited
// premul. The old approach (mid-grey texture at N% opacity) is banned: its
// mean is a flat grey veil (+19 codes at 15% — greyer, LESS translucent),
// the exact cast this pass removes. Grain must redistribute light, not add
// it. Two octaves: fine 1dp cells (the halide) under a coarser clump octave
// (grains cluster on real film). Neutral luma only; color noise reads VHS.
const FILM_FOG = { a: 0.035, b: 118, g: 124, r: 128 }; // warm-neutral base fog
// Pure-taste knob, no floor: banding safety does NOT ride on the grain —
// the quantizer below carries its own sub-perceptual luma dither. Delivered
// film texture sd ≈ source sd (~4.5 after octave mix) × gain. History:
// 0.35 read as "a glitch" on device, 0.18 still "way too much", 0.06 still
// a notch loud — the spec is SUBTLE, a whisper of halide you only resolve
// when you look for it.
const GRAIN_GAIN = 0.03;
// Fine octave: 3 physical px per grain cell @3x (1dp — same cell size the
// old repeat-tiled overlay had).
const GRAIN_SCALE = 3;
// Clump octave: coarser + weighted under the fine grain, sampled from an
// offset region of the same texture so the octaves decorrelate. Tileable
// source, so its mid-pane wrap is seamless. Keep the weight modest: at high
// contrast the 7px clusters are exactly what reads as "glitch" instead of
// halide.
const GRAIN_CLUMP_SCALE = 7;
const GRAIN_CLUMP_WEIGHT = 0.4;
const GRAIN_CLUMP_OFFSET_X = 97;
const GRAIN_CLUMP_OFFSET_Y = 53;
const mulberry32 = (seed) => {
let a = seed | 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
const grainPng = PNG.sync.read(
fs.readFileSync(path.resolve(__dirname, '../apps/android/assets/dither.png')),
);
const noiseAt = (x, y, scale, offsetX, offsetY) => {
const gx = (Math.floor(x / scale) + offsetX) % grainPng.width;
const gy = (Math.floor(y / scale) + offsetY) % grainPng.height;
// Signed: the source texture is zero-mean noise centered on 128.
return grainPng.data[(gy * grainPng.width + gx) * 4] - 128;
};
// Signed, zero-mean film grain in framebuffer-code units.
const grainAt = (x, y) => {
const fine = noiseAt(x, y, GRAIN_SCALE, 0, 0);
const clump = noiseAt(x, y, GRAIN_CLUMP_SCALE, GRAIN_CLUMP_OFFSET_X, GRAIN_CLUMP_OFFSET_Y);
// Octave sum normalized back to the source's amplitude, so GRAIN_GAIN
// stays the single strength knob.
return (GRAIN_GAIN * (fine + clump * GRAIN_CLUMP_WEIGHT)) / (1 + GRAIN_CLUMP_WEIGHT);
};
// Straight-alpha src-over of layer (r,g,b,a) onto premultiplied accumulator.
const over = (acc, r, g, b, a) => {
acc.r = r * a + acc.r * (1 - a);
acc.g = g * a + acc.g * (1 - a);
acc.b = b * a + acc.b * (1 - a);
acc.a = a + acc.a * (1 - a);
};
for (const pane of PANES) {
const { file, height } = pane;
const png = new PNG({ colorType: 6, height, width: WIDTH });
const rand = mulberry32(0x0d0cf1a5 ^ height);
const v2 = WIDTH * WIDTH + height * height;
let maxErr = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < WIDTH; x++) {
const t = Math.min(1, Math.max(0, (x * WIDTH + y * height) / v2));
const acc = { a: 0, b: 0, g: 0, r: 0 };
over(acc, GOLD.r, GOLD.g, GOLD.b, GOLD.a * t);
const sheenAlpha =
t < SHEEN_KNEE_T
? SHEEN_HEAD + (SHEEN_KNEE - SHEEN_HEAD) * (t / SHEEN_KNEE_T)
: SHEEN_KNEE * (1 - (t - SHEEN_KNEE_T) / (1 - SHEEN_KNEE_T));
over(acc, SHEEN.r, SHEEN.g, SHEEN.b, sheenAlpha);
over(acc, FILM_FOG.r, FILM_FOG.g, FILM_FOG.b, FILM_FOG.a);
// Grain modulates the pane's own emission: a signed premultiplied
// add with alpha untouched — grain redistributes light, it does
// not occlude what is behind the glass.
const grain = grainAt(x, y);
const ceiling = acc.a * 255;
acc.r = Math.min(ceiling, Math.max(0, acc.r + grain));
acc.g = Math.min(ceiling, Math.max(0, acc.g + grain));
acc.b = Math.min(ceiling, Math.max(0, acc.b + grain));
// Two independent dithers make the whole quantization noise-shaped
// WITHOUT the visible grain's help (GRAIN_GAIN is free to be
// taste-only): alpha jitters ±0.5 code, randomizing the rounding
// of the background-transmission ramp; and a shared per-pixel
// luma offset jitters the premul targets ±0.5 code, randomizing
// the decode-premultiply rounding of the pane's own emission.
// Shared across R/G/B so the dither is colorless — independent
// streams would add chroma speckle. At 1px/±0.5 code this is the
// display's noise floor, not texture.
const lumaDither = rand() - 0.5;
// Floor: alpha8 may never quantize below the premul peak (incl.
// dither headroom), or the straight color clamps at 255.
const minAlpha8 = Math.ceil(Math.max(acc.r, acc.g, acc.b) + 0.5);
const alpha8 = Math.min(
255,
Math.max(1, minAlpha8, Math.round(acc.a * 255 + (rand() - 0.5))),
);
const idx = (y * WIDTH + x) * 4;
png.data[idx + 3] = alpha8;
for (const [channel, offset] of [
[acc.r, 0],
[acc.g, 1],
[acc.b, 2],
]) {
const target = Math.max(0, channel + lumaDither);
const c8 = Math.min(255, Math.max(0, Math.round((target * 255) / alpha8)));
png.data[idx + offset] = c8;
const delivered = (c8 * alpha8) / 255;
const err = Math.abs(delivered - target);
if (err > maxErr) maxErr = err;
}
}
}
const outPath = path.resolve(__dirname, '../apps/android/assets', file);
fs.writeFileSync(outPath, PNG.sync.write(png));
const kb = Math.round(fs.statSync(outPath).size / 1024);
console.log(
`wrote ${outPath} (${WIDTH}x${height}, ${kb} KB, max premul error ${maxErr.toFixed(3)} codes)`,
);
}