328 lines
10 KiB
JavaScript
328 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { performance } from 'node:perf_hooks';
|
|
|
|
import {
|
|
applyLuminanceAsAlpha,
|
|
calculateDistanceTransform,
|
|
fillInverseAlphaMask,
|
|
rasterizeDistanceFieldMask,
|
|
} from '../js/mask/mask_pixel_utils.js';
|
|
import { HistoryStack } from '../js/canvas/canvas_history.js';
|
|
import { cloneLayers, getStateSignature } from '../js/utils/common_utils.js';
|
|
|
|
const DEFAULT_SIZES = [512, 1024, 2048];
|
|
const DEFAULT_LAYER_COUNTS = [1, 4, 8, 16];
|
|
const DEFAULT_ITERATIONS = 5;
|
|
|
|
function parseList(value, fallback) {
|
|
if (value === undefined) return fallback;
|
|
|
|
const parsed = value
|
|
.split(',')
|
|
.map(item => Number.parseInt(item.trim(), 10))
|
|
.filter(Number.isFinite);
|
|
|
|
if (parsed.length === 0 || parsed.some(item => item <= 0)) {
|
|
throw new Error(`Expected a comma-separated list of positive integers, got: ${value}`);
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function parseOptions(argv) {
|
|
const options = {
|
|
format: 'table',
|
|
sizes: DEFAULT_SIZES,
|
|
layerCounts: DEFAULT_LAYER_COUNTS,
|
|
iterations: DEFAULT_ITERATIONS,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === '--help' || argument === '-h') {
|
|
options.help = true;
|
|
continue;
|
|
}
|
|
|
|
const value = argv[index + 1];
|
|
if (value === undefined) {
|
|
throw new Error(`Missing value for ${argument}`);
|
|
}
|
|
|
|
if (argument === '--format') {
|
|
if (!['table', 'json'].includes(value)) {
|
|
throw new Error(`Unsupported format: ${value}`);
|
|
}
|
|
options.format = value;
|
|
} else if (argument === '--sizes') {
|
|
options.sizes = parseList(value, DEFAULT_SIZES);
|
|
} else if (argument === '--layer-counts') {
|
|
options.layerCounts = parseList(value, DEFAULT_LAYER_COUNTS);
|
|
} else if (argument === '--iterations') {
|
|
options.iterations = Number.parseInt(value, 10);
|
|
if (!Number.isFinite(options.iterations) || options.iterations < 1) {
|
|
throw new Error(`Expected --iterations to be a positive integer, got: ${value}`);
|
|
}
|
|
} else {
|
|
throw new Error(`Unknown option: ${argument}`);
|
|
}
|
|
|
|
index += 1;
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`LayerForge offline frontend benchmark
|
|
|
|
Usage:
|
|
npm run benchmark -- [options]
|
|
|
|
Options:
|
|
--sizes 512,1024,2048 Pixel benchmark sizes
|
|
--layer-counts 1,4,8,16 History/model benchmark sizes
|
|
--iterations 5 Timed samples per operation
|
|
--format table|json Output format (default: table)
|
|
`);
|
|
}
|
|
|
|
function percentile(sortedValues, percentileValue) {
|
|
const index = Math.min(
|
|
sortedValues.length - 1,
|
|
Math.max(0, Math.ceil(sortedValues.length * percentileValue) - 1),
|
|
);
|
|
return sortedValues[index];
|
|
}
|
|
|
|
function measureSync(operation, iterations) {
|
|
const warmupCount = Math.min(2, iterations);
|
|
for (let index = 0; index < warmupCount; index += 1) {
|
|
operation();
|
|
}
|
|
|
|
const samples = [];
|
|
for (let index = 0; index < iterations; index += 1) {
|
|
const startedAt = performance.now();
|
|
operation();
|
|
samples.push(performance.now() - startedAt);
|
|
}
|
|
|
|
const sorted = samples.slice().sort((left, right) => left - right);
|
|
return {
|
|
medianMs: percentile(sorted, 0.5),
|
|
p95Ms: percentile(sorted, 0.95),
|
|
minMs: sorted[0],
|
|
maxMs: sorted[sorted.length - 1],
|
|
};
|
|
}
|
|
|
|
function createBinaryMask(size) {
|
|
const mask = new Uint8Array(size * size);
|
|
const center = size / 2;
|
|
const radius = size * 0.36;
|
|
|
|
for (let y = 0; y < size; y += 1) {
|
|
for (let x = 0; x < size; x += 1) {
|
|
const dx = x - center;
|
|
const dy = y - center;
|
|
mask[y * size + x] = dx * dx + dy * dy <= radius * radius ? 1 : 0;
|
|
}
|
|
}
|
|
|
|
return mask;
|
|
}
|
|
|
|
function createImageData(size) {
|
|
const data = new Uint8ClampedArray(size * size * 4);
|
|
for (let index = 0; index < data.length; index += 4) {
|
|
data[index] = index & 255;
|
|
data[index + 1] = (index >> 3) & 255;
|
|
data[index + 2] = (index >> 5) & 255;
|
|
data[index + 3] = 255;
|
|
}
|
|
return { data };
|
|
}
|
|
|
|
function benchmarkPixelOperations(size, iterations) {
|
|
const pixelCount = size * size;
|
|
const binaryMask = createBinaryMask(size);
|
|
const distanceMap = calculateDistanceTransform(binaryMask, size, size);
|
|
const rasterizedData = new Uint8ClampedArray(pixelCount * 4);
|
|
const imageData = createImageData(size);
|
|
const visibilityData = { data: new Uint8ClampedArray(pixelCount * 4) };
|
|
const maskData = { data: new Uint8ClampedArray(pixelCount * 4).fill(255) };
|
|
|
|
return {
|
|
distanceTransform: measureSync(
|
|
() => calculateDistanceTransform(binaryMask, size, size),
|
|
iterations,
|
|
),
|
|
rasterizeDistanceField: measureSync(
|
|
() => rasterizeDistanceFieldMask(distanceMap, binaryMask, size * 0.1, rasterizedData),
|
|
iterations,
|
|
),
|
|
luminanceToAlpha: measureSync(
|
|
() => applyLuminanceAsAlpha(imageData),
|
|
iterations,
|
|
),
|
|
inverseAlphaMask: measureSync(
|
|
() => fillInverseAlphaMask(visibilityData, maskData),
|
|
iterations,
|
|
),
|
|
};
|
|
}
|
|
|
|
function createLayerModel(count, size) {
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
id: `benchmark-layer-${index}`,
|
|
x: (index % 4) * size * 0.04,
|
|
y: Math.floor(index / 4) * size * 0.04,
|
|
width: size * 0.72,
|
|
height: size * 0.72,
|
|
originalWidth: size,
|
|
originalHeight: size,
|
|
rotation: (index * 7) % 30,
|
|
zIndex: index,
|
|
blendMode: index % 3 === 0 ? 'normal' : 'multiply',
|
|
opacity: 0.7 + ((index % 3) * 0.1),
|
|
flipH: index % 5 === 0,
|
|
flipV: index % 7 === 0,
|
|
blendArea: index % 2 === 0 ? 25 : 0,
|
|
cropBounds: index % 3 === 0
|
|
? { x: size * 0.1, y: size * 0.1, width: size * 0.8, height: size * 0.8 }
|
|
: undefined,
|
|
imageId: `benchmark-image-${index}`,
|
|
image: { src: 'data:image/png;base64,benchmark-placeholder' },
|
|
}));
|
|
}
|
|
|
|
function benchmarkHistory(count, size, iterations) {
|
|
const layers = createLayerModel(count, size);
|
|
const history = new HistoryStack({
|
|
clone: cloneLayers,
|
|
equals: (left, right) => getStateSignature(left) === getStateSignature(right),
|
|
historyLimit: 100,
|
|
});
|
|
|
|
return {
|
|
cloneLayers: measureSync(() => cloneLayers(layers), iterations),
|
|
stateSignature: measureSync(() => getStateSignature(layers), iterations),
|
|
pushSnapshot: measureSync(() => {
|
|
history.clear();
|
|
history.push(layers);
|
|
}, iterations),
|
|
estimatedSnapshotBytes: new TextEncoder().encode(JSON.stringify(cloneLayers(layers))).byteLength,
|
|
};
|
|
}
|
|
|
|
function roundStats(stats) {
|
|
return Object.fromEntries(
|
|
Object.entries(stats).map(([key, value]) => [
|
|
key,
|
|
typeof value === 'number' ? Number(value.toFixed(3)) : value,
|
|
]),
|
|
);
|
|
}
|
|
|
|
function printTable(report) {
|
|
console.log('LayerForge offline frontend benchmark');
|
|
console.log(`Node ${report.runtime.node} on ${report.runtime.platform} ${report.runtime.arch}`);
|
|
console.log(`Iterations: ${report.config.iterations}`);
|
|
console.log('');
|
|
console.log('Pixel operations');
|
|
console.log('size operation median p95');
|
|
|
|
for (const [size, operations] of Object.entries(report.pixelOperations)) {
|
|
for (const [operation, stats] of Object.entries(operations)) {
|
|
console.log(
|
|
`${size.padStart(4)} ${operation.padEnd(25)} `
|
|
+ `${stats.medianMs.toFixed(3).padStart(7)} ms `
|
|
+ `${stats.p95Ms.toFixed(3).padStart(7)} ms`,
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log('');
|
|
console.log('Layer model/history operations');
|
|
console.log('layers operation median p95');
|
|
for (const [count, operations] of Object.entries(report.layerModel)) {
|
|
for (const operation of ['cloneLayers', 'stateSignature', 'pushSnapshot']) {
|
|
const stats = operations[operation];
|
|
console.log(
|
|
`${count.padStart(6)} ${operation.padEnd(25)} `
|
|
+ `${stats.medianMs.toFixed(3).padStart(7)} ms `
|
|
+ `${stats.p95Ms.toFixed(3).padStart(7)} ms`,
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log('');
|
|
console.log('Browser Canvas render/export: run benchmarks/browser.html in a browser.');
|
|
}
|
|
|
|
function main() {
|
|
const options = parseOptions(process.argv.slice(2));
|
|
if (options.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
const report = {
|
|
benchmark: 'layerforge-frontend-offline',
|
|
runtime: {
|
|
node: process.version,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
},
|
|
config: {
|
|
sizes: options.sizes,
|
|
layerCounts: options.layerCounts,
|
|
iterations: options.iterations,
|
|
},
|
|
pixelOperations: {},
|
|
layerModel: {},
|
|
skipped: [
|
|
{
|
|
benchmark: 'browser-canvas-render-export',
|
|
reason: 'Use benchmarks/browser.html with a real browser Canvas implementation.',
|
|
},
|
|
],
|
|
};
|
|
|
|
for (const size of options.sizes) {
|
|
report.pixelOperations[size] = Object.fromEntries(
|
|
Object.entries(benchmarkPixelOperations(size, options.iterations))
|
|
.map(([name, stats]) => [name, roundStats(stats)]),
|
|
);
|
|
}
|
|
|
|
const historySize = options.sizes[0];
|
|
for (const count of options.layerCounts) {
|
|
report.layerModel[count] = {
|
|
...benchmarkHistory(count, historySize, options.iterations),
|
|
};
|
|
report.layerModel[count] = Object.fromEntries(
|
|
Object.entries(report.layerModel[count]).map(([name, value]) => [
|
|
name,
|
|
typeof value === 'object' ? roundStats(value) : value,
|
|
]),
|
|
);
|
|
}
|
|
|
|
if (options.format === 'json') {
|
|
console.log(JSON.stringify(report, null, 2));
|
|
return;
|
|
}
|
|
|
|
printTable(report);
|
|
}
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
}
|