176 lines
6.1 KiB
TypeScript
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);
|
|
});
|