Files
RecipesCam/src/dev/nativeBenchProbe.ts
T
admin e529c608f0 Keep the highlight slider off the greys, and start each mode clean
The highlight knee opened at 0.45, so dropping HIGHLIGHT pulled a mid-grey
down with the true highlights. It now opens at 0.65 (and the coefficient
drops 0.32 -> 0.22 to stay monotonic), so the slider leaves everything up to
a light grey untouched and still rolls the bright end off. Shadow is
untouched — its 0.00..0.55 knee was already right.

Switching between the library and the camera now drops the edits back to
the recipe's own values, no frame and no custom mark. A frame was picked
for the still it sat on and the mark was placed against that very photo, so
neither may follow the user across the switch (or into a live capture),
exactly like the sliders that were tuned on the photo in front of them.
2026-09-10 12:23:56 +07:00

130 lines
6.9 KiB
TypeScript

import RecipescamExport, { PhotoAdjust } from '../../modules/recipescam-export';
import { applyExposureGain, getSkiaColorMatrix } from '../utils/colorUtils';
import { getCinemaUniforms } from '../utils/cinemaShader';
import { BaseFilter, ColorAdjustments } from '../types';
const RUNS = 2;
const IDENTITY = (() => {
const m = new Array(20).fill(0);
m[0] = m[6] = m[12] = m[18] = 1;
return m;
})();
const NEUTRAL_ADJ: ColorAdjustments = {
exposure: 0,
contrast: 0,
saturation: 0,
temperature: 5500,
tint: 0,
highlight: 0,
shadow: 0,
denoise: 0,
clarity: 0,
grain: 0,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 100,
sharpening: 0,
exposureCompensation: 0,
};
/**
* P0-P3 probe harness (temporary — removed at P7). Reads the native
* RecipesCamExport "centerPixel"/"finalPixel" log lines (probe gates compare
* those against the node reference sim).
*/
export async function nativeBenchProbe(): Promise<void> {
const m = RecipescamExport as any;
try {
const pong = await m.ping();
const run = async (tag: string, src: string, o: PhotoAdjust & { matrix?: number[] } = {}) => {
const { matrix, ...adj } = o;
const dst = src.replace(/\.png$/, `-${tag}-${Date.now()}.jpg`);
const t = await m.processPhotoAsync(src, dst, matrix ?? IDENTITY, null, adj);
console.error(`[BENCH] ${tag} totalMs=${t.totalMs} decodeMs=${t.decodeMs} toneMs=${t.toneMs ?? 0} cinemaMs=${t.cinemaMs ?? 0} colorMs=${t.colorMs} clarityMs=${t.clarityMs ?? 0} grainMs=${t.grainMs ?? 0} frameMs=${t.frameMs ?? 0} encodeWriteMs=${t.encodeWriteMs}`);
};
const src: string = await m.materializeBenchAsset('wallframe');
// P1 parity: classic-neg matrix on black -> expect center ~(13,5,0).
const matrix = applyExposureGain(getSkiaColorMatrix('classic-neg' as BaseFilter, NEUTRAL_ADJ), 0);
const black = await m.materializeSolidAsset(0, 0, 0);
const blackOut = black.replace(/\.png$/, `-neg-${Date.now()}.jpg`);
await m.processColorAsync(black, blackOut, matrix, null, 95);
console.error('[BENCH] classic-neg-on-black matrix[0..4]=' + matrix.slice(0, 5).join(','));
// P2 tone parity on solid ramps (expected values computed from TONE_SKSL):
// white(255) hl=-1 -> 199; gray64 sh=+1 -> 113; white dr=1 -> 209;
// gray26 sh=+1 -> 105; gray26 hl=+1 sh=-1 -> 0.
const toneGates: Array<{ rgb: [number, number, number]; tone: { dr: number; hl: number; sh: number }; tag: string }> = [
{ rgb: [255, 255, 255], tone: { dr: 0, hl: -1, sh: 0 }, tag: 'white-hl-1' },
{ rgb: [64, 64, 64], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray64-sh+1' },
{ rgb: [255, 255, 255], tone: { dr: 1, hl: 0, sh: 0 }, tag: 'white-dr1' },
{ rgb: [26, 26, 26], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray26-sh+1' },
{ rgb: [26, 26, 26], tone: { dr: 0, hl: 1, sh: -1 }, tag: 'gray26-hl+1-sh-1' },
];
for (const g of toneGates) {
const solid = await m.materializeSolidAsset(g.rgb[0], g.rgb[1], g.rgb[2]);
await run(`tone-${g.tag}`, solid, { toneDr: g.tone.dr, toneHl: g.tone.hl, toneSh: g.tone.sh });
}
// P3 cinema parity (CINEMA_SKSL): lit+warm+glow path, shade+cool+fog path,
// and a saturated pixel to exercise the saturation mix.
const summer = getCinemaUniforms('summer')!.flat;
const winter = getCinemaUniforms('winter')!.flat;
const s192 = await m.materializeSolidAsset(192, 192, 192);
await run('cinema-summer-192', s192, { cinema: summer });
const s32 = await m.materializeSolidAsset(32, 32, 32);
await run('cinema-winter-32', s32, { cinema: winter });
const sat = await m.materializeSolidAsset(200, 120, 60);
await run('cinema-summer-sat', sat, { cinema: summer });
// P3 enhance gates on the gray ramp (gradient makes blur/conv non-trivial).
const ramp = await m.materializeRampAsset();
await run('denoise10-ramp', ramp, { denoise: 10 });
await run('clarity10-ramp', ramp, { clarity: 10 });
await run('mist-ramp', ramp, { clarity: -10 });
// Step gates: rows 0..31=0, 32..63=255. A ramp is invariant under box
// blur, so the step makes heavy blur non-trivial (expected center row32:
// denoise r=1 -> 170, mist r=4 -> 142 per the padded-boxBlur reference).
const step = await m.materializeStepAsset();
await run('denoise10-step', step, { denoise: 10 });
await run('mist-step', step, { clarity: -10 });
// Grain is structurally-gated only (float32 hash noise vs node doubles
// differ) — the run must complete and the center must stay plausible.
const gray100 = await m.materializeSolidAsset(100, 100, 100);
await run('grain10-100', gray100, { grain: 10 });
// P4 frame parity on solid gray100 64x64 — border/bar/card colors and the
// composed output geometry are fully predictable, so probeSamples assert
// exact pixels. Expected (offline verifier re-checks):
// classic-white: (1,1)=white border, (32,32)=100 photo, size 64x64.
// cinematic: (1,1)=black bar, (32,32)=100, size 64x64.
// polaroid: outH=round(64*(SIDE+DECK)+64*WIN_W)=78; (1,1)=card #FAF9F6,
// (32,32)=100 photo, (32,70)=card deck.
await run('frame-classic', gray100, { frameId: 'classic-white', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }] });
await run('frame-cinematic', gray100, { frameId: 'cinematic', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }] });
await run('frame-polaroid', gray100, { frameId: 'polaroid', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }, { x: 32, y: 70 }] });
// wallframe: export becomes the fixed 3117x4000 rotated artwork; center is
// the cover-cropped photo (100), mat samples compare against wallframe.png
// pixels offline (display->source: (dx,dy)->(dy, 3117-dx)). Art + fonts are
// resolved natively from bundled resources — embedded metro assets expose
// no readable file URI to JS (readAsStringAsync rejects 'wallframe').
await run('frame-wallframe', gray100, { frameId: 'wallframe', png: true, probeSamples: [{ x: 100, y: 1500 }, { x: 2900, y: 200 }, { x: 1559, y: 1998 }] });
// P4 GPS watermark (no frame): 400x400 gray base, stamp bottom-left.
// probeStampDiff logs stampPixelDiff/amber/white after drawing — counts > 0
// prove the emoji + amber text actually rendered (vs a silent no-op).
const gray400 = await m.materializeSolidSized(400, 400, 100, 100, 100);
await run('geo-stamp', gray400, { geo: { locality: 'HANOI TEST', timestamp: Date.now() }, png: true, probeStampDiff: true });
// Full-artwork run: provia + tone + cinema + all enhance passes, 12MP.
const provia = applyExposureGain(getSkiaColorMatrix('provia' as BaseFilter, NEUTRAL_ADJ), 0);
for (let i = 0; i < RUNS; i++) {
await run(`fullRun-${i}`, src, { matrix: provia, toneDr: 0.33, toneHl: 0.3, toneSh: 0.3, cinema: summer, denoise: 10, clarity: 10, grain: 10 });
}
console.error('[BENCH] done pong=' + pong);
} catch (e) {
console.error('[BENCH] failed', e);
}
}