e529c608f0
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.
130 lines
6.9 KiB
TypeScript
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);
|
|
}
|
|
}
|