Files
RecipesCam/web/spike/w0.ts
T

176 lines
6.1 KiB
TypeScript

// W0 parity/perf spike: run the real web render pipeline in Node (no browser)
// over sample photos and report time + determinism. Usage:
// W0_SAMPLES=<dir with .jpg> W0_OUT=<dir> corepack pnpm exec tsx spike/w0.ts
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { initSkia, Skia } from '../src/engine/skiaShim';
import { renderPhoto, type RenderAssets } from '../src/engine/exportEngine';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from '../../src/utils/defaultRecipes';
import type { FrameId, Recipe } from '../../src/types';
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(here, '../..');
const SAMPLE_DIR = process.env.W0_SAMPLES ?? '/tmp/w0';
const OUT_DIR = process.env.W0_OUT ?? '/tmp/w0-out';
const ab = (p: string): ArrayBuffer => {
const b = fs.readFileSync(p);
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer;
};
const assets: RenderAssets = {
textFont: ab(path.join(repo, 'assets/Cousine-Regular.ttf')),
emojiFont: ab(path.join(repo, 'assets/NotoEmoji-GPS.ttf')),
wallframe: new Uint8Array(fs.readFileSync(path.join(repo, 'wallframe.png'))),
};
// One recipe per film sim + a "everything on" torture case + a cinema grade.
const heavy: Recipe = {
id: 'w0-heavy',
name: 'W0 HEAVY',
frameId: 'none',
useGeotag: true,
baseFilter: 'classic-neg',
cinema: 'autumn',
adjustments: {
...DEFAULT_ADJUSTMENTS,
exposure: 0.6,
exposureCompensation: 2,
contrast: 12,
saturation: 8,
vibrance: 6,
highlight: -4,
shadow: 5,
temperature: 6100,
tint: 3,
colorChrome: 'weak',
colorChromeBlue: 'weak',
denoise: 3,
clarity: 4,
sharpening: 3,
grain: 4,
hdf: 4,
vignette: 3,
},
};
const QUICK = process.env.W0_QUICK === '1';
const CASES: { label: string; recipe: Recipe; frame: FrameId; extra?: Record<string, unknown> }[] = [
...FILM_SIMS.map((s) => ({ label: `sim:${s.id}`, recipe: filmSimToRecipe(s), frame: 'none' as FrameId })),
{ label: 'heavy', recipe: heavy, frame: 'none' },
{ label: 'heavy+polaroid', recipe: heavy, frame: 'polaroid' },
{ label: 'heavy+classic', recipe: heavy, frame: 'classic-white' },
{
label: 'heavy+wallframe',
recipe: heavy,
frame: 'wallframe',
extra: {
gpsWatermark: { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true },
useGeotag: true,
},
},
{ label: 'heavy+wm-rot90', recipe: heavy, frame: 'none', extra: {
watermark: { text: 'RECIPESCAM', x: 0.5, y: 0.9 },
watermarkRotation: 90,
} },
];
const GPS = {
latitude: 21.0285,
longitude: 105.8542,
locality: 'HÀ NỘI',
timestamp: Date.now(),
};
async function main() {
const t0 = performance.now();
await initSkia();
console.log(`CanvasKit init: ${(performance.now() - t0).toFixed(0)}ms`);
fs.mkdirSync(OUT_DIR, { recursive: true });
const samples = fs.readdirSync(SAMPLE_DIR).filter((f) => /\.jpe?g$/i.test(f)).sort();
if (samples.length === 0) throw new Error(`no sample jpgs in ${SAMPLE_DIR}`);
console.log(`samples: ${samples.join(', ')}`);
// Stats on the encoded file: a render that silently produced a blank frame
// would still "succeed", so mean/stddev of the decoded output is the cheap
// proof it contains a picture.
const stats = (bytes: Uint8Array) => {
const img = Skia.Image.MakeImageFromEncoded(bytes);
if (!img) return 'decode-fail';
const w = img.width();
const h = img.height();
const px = img.readPixels(0, 0, {
width: w, height: h, colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB,
});
img.dispose();
if (!px) return 'read-fail';
let sum = 0;
let sum2 = 0;
let n = 0;
for (let i = 0; i < px.length; i += 4) {
const l = 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2];
sum += l;
sum2 += l * l;
n++;
}
const mean = sum / n;
return `mean=${mean.toFixed(1)} sd=${Math.sqrt(Math.max(0, sum2 / n - mean * mean)).toFixed(1)}`;
};
const rows: string[] = [];
for (const sample of samples) {
const source = new Uint8Array(fs.readFileSync(path.join(SAMPLE_DIR, sample)));
const base = sample.replace(/\.jpe?g$/i, '');
const cases = QUICK ? CASES.slice(0, 2).concat(CASES.filter((x) => /heavy/.test(x.label)).slice(0, 2)) : CASES;
for (const c of cases) {
const dpi = 300;
const input = {
source,
recipe: c.recipe,
frameId: c.frame,
useGeotag: (c.extra?.useGeotag as boolean) ?? false,
gpsInfo: GPS,
options: {
dpi,
sharpen: true,
watermark: (c.extra?.watermark as never) ?? null,
watermarkRotation: (c.extra?.watermarkRotation as number) ?? 0,
gpsWatermark: (c.extra?.gpsWatermark as never) ?? null,
wallframeLandscape: false,
},
assets,
};
const t = performance.now();
const out = await renderPhoto(input);
const ms = performance.now() - t;
if (!out) {
console.log(`FAIL ${sample} ${c.label}`);
continue;
}
const file = path.join(OUT_DIR, `${base}__${c.label.replace(/[^a-z0-9+]/gi, '_')}.jpg`);
fs.writeFileSync(file, out.bytes);
const jfif = new DataView(out.bytes.buffer, out.bytes.byteOffset).getUint16(14, false);
rows.push(`${sample}\t${c.label}\t${out.width}x${out.height}\t${(out.bytes.length / 1024).toFixed(0)}KB\t${ms.toFixed(0)}ms\tdpi=${jfif}\t${stats(out.bytes)}`);
}
}
console.log('\nsample\tcase\tsize\tencoded\ttime\tdpi');
console.log(rows.join('\n'));
// Determinism: the same input twice must produce identical bytes (a renderer
// that is stable in Node is the baseline the browser worker must match).
const source = new Uint8Array(fs.readFileSync(path.join(SAMPLE_DIR, samples[0])));
const args = { source, recipe: heavy, frameId: 'none' as FrameId, assets };
const a = await renderPhoto(args);
const b = await renderPhoto(args);
console.log('\ndeterministic:', !!a && !!b && Buffer.compare(Buffer.from(a.bytes), Buffer.from(b.bytes)) === 0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});