Ctrl K
ComunidadGuías…

scripts/gen-dock-finish.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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)`,
    );
}