Add offline frontend and backend benchmarks

This commit is contained in:
Dariusz L
2026-08-15 02:01:06 +02:00
parent 527372b427
commit 4aff92199c
5 changed files with 1000 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
# LayerForge offline benchmarks
These benchmarks are deliberately separate from the functional test suite. They
measure the expensive data and pixel paths without starting ComfyUI or
downloading a model.
## Frontend JavaScript
This measures the real LayerForge mask pixel helpers, layer snapshot cloning,
and state signatures:
```powershell
npm run benchmark -- --sizes 512,1024,2048 --layer-counts 1,4,8,16 --iterations 5
```
Use `--format json` for machine-readable output. The Node benchmark does not
pretend that canvas stubs represent real rasterization; use the browser harness
for that part.
## Backend Python
Run it with the embedded Python used by the active Easy ComfyUI installation:
```powershell
rtk proxy E:\AI\AI\ComfyUI\ComfyUI_Easy\All_Nodes\python_embeded\python.exe benchmarks\backend.py --sizes 512,1024,2048 --iterations 5
```
The script measures tensor/data-URL conversion, per-sample and batch PNG
serialization, plus preprocessing/postprocessing around a fake RMBG model. It
never loads a checkpoint. Set `LAYERFORGE_COMFY_ROOT` if the active ComfyUI
root differs from the project default.
## Real browser Canvas 2D and PNG export
The browser harness measures representative compositing for 1, 4, 8, and 16
layers, crop/blend/mask variants, PNG export, and the actual LayerForge distance
transform implementation. It is not a ComfyUI workflow test; it uses only a
local browser Canvas implementation.
Start a static server with the repository explicitly selected as its document
root. This works even when the terminal is currently in another directory:
```powershell
rtk proxy E:\AI\AI\ComfyUI\ComfyUI_Easy\All_Nodes\python_embeded\python.exe -m http.server 8765 --directory E:\AI\AI\ComfyUI\ComfyUI_Windows_portable\ComfyUI\custom_nodes\Comfyui-LayerForge
```
Open:
```text
http://127.0.0.1:8765/benchmarks/browser.html
```
For the larger matrix, set the size field to `512,1024,2048`. Keep the same
browser, zoom, hardware acceleration setting, and iteration count when comparing
two revisions. Benchmark output is printed in the page and is not written to the
repository.
## Interpreting results
Compare median and p95 on the same machine. Warm-up samples are discarded. A
browser render median above roughly 16 ms is likely to affect 60 FPS interaction;
PNG export is a separate operation and should not be confused with the per-frame
render budget. Model inference is intentionally excluded from the fake matting
measurement and must be benchmarked separately with a specific checkpoint and
device.
+212
View File
@@ -0,0 +1,212 @@
"""Offline LayerForge backend benchmark.
This script measures tensor/image conversion and adapter overhead only. It does
not start ComfyUI, download models, or run a real matting checkpoint.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
from pathlib import Path
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_COMFY_ROOT = Path(r"E:\AI\AI\ComfyUI\ComfyUI_Easy\All_Nodes\ComfyUI")
def _install_import_paths() -> Path:
comfy_root = Path(os.environ.get("LAYERFORGE_COMFY_ROOT", DEFAULT_COMFY_ROOT))
sys.path.insert(0, str(REPOSITORY_ROOT))
if comfy_root.exists():
sys.path.insert(0, str(comfy_root))
return comfy_root
def _parse_positive_list(value: str) -> list[int]:
result = [int(item.strip()) for item in value.split(",") if item.strip()]
if not result or any(item <= 0 for item in result):
raise argparse.ArgumentTypeError(f"Expected positive integers, got: {value}")
return result
def _percentile(sorted_values: list[float], fraction: float) -> float:
index = min(len(sorted_values) - 1, max(0, math.ceil(len(sorted_values) * fraction) - 1))
return sorted_values[index]
def _measure(operation, iterations: int) -> dict[str, float]:
for _ in range(min(2, iterations)):
operation()
samples = []
for _ in range(iterations):
started_at = time.perf_counter()
operation()
samples.append((time.perf_counter() - started_at) * 1000)
samples.sort()
return {
"median_ms": _percentile(samples, 0.5),
"p95_ms": _percentile(samples, 0.95),
"min_ms": samples[0],
"max_ms": samples[-1],
}
def _rounded(value):
if isinstance(value, dict):
return {key: _rounded(item) for key, item in value.items()}
if isinstance(value, float):
return round(value, 3)
return value
def _run_conversion_benchmarks(torch, image_utils, node_module, serialization, sizes, iterations):
from PIL import Image
output = {}
for height, width in sizes:
tensor = torch.rand((1, 3, height, width), dtype=torch.float32)
encoded = image_utils.convert_tensor_to_base64(tensor)
sample = tensor[0].permute(1, 2, 0)
mask_tensor = torch.rand((height, width), dtype=torch.float32)
def serialize_mask():
mask_array = (mask_tensor.cpu().numpy() * 255).astype("uint8")
return serialization.pil_to_data_url(Image.fromarray(mask_array, "L"))
output[f"{height}x{width}"] = {
"tensor_to_base64": _measure(
lambda: image_utils.convert_tensor_to_base64(tensor), iterations
),
"base64_to_tensor": _measure(
lambda: image_utils.convert_base64_to_tensor(encoded), iterations
),
"node_serialize_rgb_sample": _measure(
lambda: node_module.LayerForgeNode._serialize_rgb_tensor_sample(sample), iterations
),
"mask_tensor_to_base64": _measure(serialize_mask, iterations),
"encoded_bytes": len(encoded.encode("ascii")),
}
return output
def _run_batch_benchmarks(torch, node_module, batch_size: int, batch_counts, iterations):
output = {}
for batch_count in batch_counts:
batch = torch.rand((batch_count, batch_size, batch_size, 3), dtype=torch.float32)
def serialize_batch():
return [
node_module.LayerForgeNode._serialize_rgb_tensor_sample(batch[index])
for index in range(batch_count)
]
stats = _measure(serialize_batch, iterations)
output[str(batch_count)] = {
**stats,
"batch_size": batch_size,
"per_sample_median_ms": stats["median_ms"] / batch_count,
}
return output
def _run_fake_matting_benchmarks(torch, rmbg_module, sizes, iterations):
class FakeModel:
def __call__(self, image):
return torch.zeros((image.shape[0], 1, 256, 256), device=image.device)
adapter = rmbg_module.RMBG2Model(FakeModel(), torch.device("cpu"))
output = {}
for height, width in sizes:
image = torch.rand((1, height, width, 3), dtype=torch.float32)
output[f"{height}x{width}"] = _measure(
lambda: adapter.encode_image(image), iterations
)
return output
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sizes", type=_parse_positive_list, default=[512, 1024, 2048])
parser.add_argument("--iterations", type=int, default=5)
parser.add_argument("--batch-size", type=int, default=512)
parser.add_argument("--batch-counts", type=_parse_positive_list, default=[1, 4, 8, 16])
parser.add_argument("--skip-matting", action="store_true")
args = parser.parse_args()
if args.iterations < 1 or args.batch_size < 1:
parser.error("--iterations and --batch-size must be positive")
comfy_root = _install_import_paths()
try:
import torch
from python import image_utils
from python import image_serialization as serialization
from python import node as node_module
except Exception as error:
print(
"Unable to import the LayerForge runtime. Set LAYERFORGE_COMFY_ROOT "
f"to the active ComfyUI root. Current root: {comfy_root}\n{error}",
file=sys.stderr,
)
return 2
sizes = [(size, size) for size in args.sizes]
report = {
"benchmark": "layerforge-backend-offline",
"runtime": {
"python": sys.version.split()[0],
"torch": torch.__version__,
"torch_threads": torch.get_num_threads(),
"device": "cpu",
"comfy_root": str(comfy_root),
},
"config": {
"sizes": args.sizes,
"iterations": args.iterations,
"batch_size": args.batch_size,
"batch_counts": args.batch_counts,
},
"image_conversion": _run_conversion_benchmarks(
torch, image_utils, node_module, serialization, sizes, args.iterations
),
"batch_serialization": _run_batch_benchmarks(
torch, node_module, args.batch_size, args.batch_counts, args.iterations
),
"skipped": [],
}
if args.skip_matting:
report["skipped"].append({
"benchmark": "fake_matting_adapter",
"reason": "Disabled with --skip-matting",
})
else:
try:
from python.matting.backends import rmbg as rmbg_module
report["fake_matting_adapter"] = _run_fake_matting_benchmarks(
torch, rmbg_module, sizes, args.iterations
)
except Exception as error:
report["skipped"].append({
"benchmark": "fake_matting_adapter",
"reason": f"Adapter import failed: {error}",
})
print(json.dumps(_rounded(report), indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+394
View File
@@ -0,0 +1,394 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LayerForge Canvas Benchmark</title>
<style>
:root {
color-scheme: dark;
font-family: system-ui, sans-serif;
background: #1b1d21;
color: #f0f2f5;
}
body {
margin: 0;
padding: 24px;
}
button {
padding: 8px 14px;
border: 1px solid #687080;
border-radius: 5px;
background: #2c6eac;
color: white;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
label {
margin-right: 16px;
}
input {
width: 190px;
margin: 0 6px;
padding: 5px;
color: inherit;
background: #242830;
border: 1px solid #687080;
border-radius: 4px;
}
#status {
min-height: 1.4em;
margin: 16px 0;
color: #b7c4d8;
}
table {
border-collapse: collapse;
margin-top: 16px;
font-variant-numeric: tabular-nums;
}
th, td {
padding: 5px 9px;
border: 1px solid #424852;
text-align: right;
}
th:first-child, td:first-child,
th:nth-child(2), td:nth-child(2),
th:nth-child(3), td:nth-child(3) {
text-align: left;
}
pre {
max-height: 520px;
overflow: auto;
padding: 12px;
background: #111317;
border: 1px solid #424852;
}
.note {
max-width: 900px;
color: #b7c4d8;
}
</style>
</head>
<body>
<h1>LayerForge Canvas benchmark</h1>
<p class="note">
This is a ComfyUI-free browser benchmark. It measures representative Canvas 2D
compositing and PNG export, plus the real LayerForge distance-transform helpers.
It is intentionally not a replacement for a ComfyUI workflow smoke test.
</p>
<p>
<label>Sizes <input id="sizes" value="512,1024"></label>
<label>Layer counts <input id="layerCounts" value="1,4,8,16"></label>
<label>Iterations <input id="iterations" value="3" type="number" min="1" max="20"></label>
<button id="run">Run benchmark</button>
</p>
<div id="status">Ready.</div>
<div id="results"></div>
<pre id="json"></pre>
<script type="module">
import {
calculateDistanceTransform,
rasterizeDistanceFieldMask,
} from '../js/mask/mask_pixel_utils.js';
const variants = ['plain', 'crop', 'blend', 'mask'];
const outputCanvas = document.createElement('canvas');
const statusElement = document.getElementById('status');
const resultsElement = document.getElementById('results');
const jsonElement = document.getElementById('json');
const runButton = document.getElementById('run');
function parseList(value) {
return value
.split(',')
.map(item => Number.parseInt(item.trim(), 10))
.filter(Number.isFinite)
.filter(item => item > 0);
}
function percentile(sortedValues, fraction) {
const index = Math.min(
sortedValues.length - 1,
Math.max(0, Math.ceil(sortedValues.length * fraction) - 1),
);
return sortedValues[index];
}
async function measure(operation, iterations) {
for (let index = 0; index < Math.min(2, iterations); index += 1) {
await operation();
}
const samples = [];
for (let index = 0; index < iterations; index += 1) {
const startedAt = performance.now();
await operation();
samples.push(performance.now() - startedAt);
}
samples.sort((left, right) => left - right);
return {
medianMs: percentile(samples, 0.5),
p95Ms: percentile(samples, 0.95),
minMs: samples[0],
maxMs: samples[samples.length - 1],
};
}
function makeLayerCanvas(size, index) {
const layerSize = Math.max(64, Math.round(size * 0.66));
const canvas = document.createElement('canvas');
canvas.width = layerSize;
canvas.height = layerSize;
const context = canvas.getContext('2d');
const gradient = context.createLinearGradient(0, 0, layerSize, layerSize);
const hue = (index * 47) % 360;
gradient.addColorStop(0, `hsla(${hue}, 80%, 62%, 0.9)`);
gradient.addColorStop(1, `hsla(${(hue + 90) % 360}, 80%, 30%, 0.9)`);
context.fillStyle = gradient;
context.fillRect(0, 0, layerSize, layerSize);
context.fillStyle = 'rgba(255, 255, 255, 0.2)';
context.beginPath();
context.arc(layerSize * 0.5, layerSize * 0.5, layerSize * 0.32, 0, Math.PI * 2);
context.fill();
const tempCanvas = document.createElement('canvas');
tempCanvas.width = layerSize;
tempCanvas.height = layerSize;
const tempContext = tempCanvas.getContext('2d');
const blendMask = document.createElement('canvas');
blendMask.width = layerSize;
blendMask.height = layerSize;
const maskContext = blendMask.getContext('2d');
const radial = maskContext.createRadialGradient(
layerSize / 2,
layerSize / 2,
layerSize * 0.18,
layerSize / 2,
layerSize / 2,
layerSize * 0.52,
);
radial.addColorStop(0, 'rgba(255,255,255,1)');
radial.addColorStop(1, 'rgba(255,255,255,0)');
maskContext.fillStyle = radial;
maskContext.fillRect(0, 0, layerSize, layerSize);
return { canvas, tempCanvas, tempContext, blendMask };
}
function makeLayers(size, count) {
return Array.from({ length: count }, (_, index) => ({
...makeLayerCanvas(size, index),
index,
x: size / 2 + ((index % 4) - 1.5) * size * 0.035,
y: size / 2 + (Math.floor(index / 4) - 1.5) * size * 0.035,
rotation: ((index * 7) % 24) * Math.PI / 180,
opacity: 0.72 + (index % 3) * 0.08,
}));
}
function drawLayer(context, layer, variant) {
const layerSize = layer.canvas.width;
context.save();
context.translate(layer.x, layer.y);
context.rotate(layer.rotation);
context.globalAlpha = layer.opacity;
context.globalCompositeOperation = layer.index % 3 === 0 ? 'source-over' : 'multiply';
if (variant === 'blend') {
layer.tempContext.clearRect(0, 0, layerSize, layerSize);
layer.tempContext.globalCompositeOperation = 'source-over';
layer.tempContext.drawImage(layer.canvas, 0, 0);
layer.tempContext.globalCompositeOperation = 'destination-in';
layer.tempContext.drawImage(layer.blendMask, 0, 0);
context.drawImage(layer.tempCanvas, -layerSize / 2, -layerSize / 2);
} else if (variant === 'crop') {
const crop = layerSize * 0.1;
context.drawImage(
layer.canvas,
crop,
crop,
layerSize - crop * 2,
layerSize - crop * 2,
-layerSize / 2,
-layerSize / 2,
layerSize,
layerSize,
);
} else {
context.drawImage(layer.canvas, -layerSize / 2, -layerSize / 2);
}
context.restore();
}
function renderScene(size, layers, variant) {
outputCanvas.width = size;
outputCanvas.height = size;
const context = outputCanvas.getContext('2d');
context.clearRect(0, 0, size, size);
context.fillStyle = '#606060';
context.fillRect(0, 0, size, size);
for (const layer of layers) {
drawLayer(context, layer, variant);
}
if (variant === 'mask') {
const maskCanvas = document.createElement('canvas');
maskCanvas.width = size;
maskCanvas.height = size;
const maskContext = maskCanvas.getContext('2d');
const radial = maskContext.createRadialGradient(
size / 2,
size / 2,
size * 0.2,
size / 2,
size / 2,
size * 0.7,
);
radial.addColorStop(0, 'rgba(255,255,255,1)');
radial.addColorStop(1, 'rgba(255,255,255,0)');
maskContext.fillStyle = radial;
maskContext.fillRect(0, 0, size, size);
context.globalCompositeOperation = 'destination-in';
context.drawImage(maskCanvas, 0, 0);
context.globalCompositeOperation = 'source-over';
}
}
function exportPng() {
return new Promise(resolve => {
outputCanvas.toBlob(blob => resolve(blob?.size ?? 0), 'image/png');
});
}
async function benchmarkDistanceTransform(size, iterations) {
const binaryMask = 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;
binaryMask[y * size + x] = dx * dx + dy * dy <= radius * radius ? 1 : 0;
}
}
const distance = calculateDistanceTransform(binaryMask, size, size);
const output = new Uint8ClampedArray(size * size * 4);
const distanceStats = await measure(
() => calculateDistanceTransform(binaryMask, size, size),
iterations,
);
const rasterStats = await measure(
() => rasterizeDistanceFieldMask(distance, binaryMask, size * 0.1, output),
iterations,
);
return { distanceTransform: distanceStats, rasterizeDistanceField: rasterStats };
}
function renderTable(rows) {
const table = document.createElement('table');
table.innerHTML = `
<thead><tr>
<th>Size</th><th>Layers</th><th>Variant</th>
<th>Render median (ms)</th><th>Render p95 (ms)</th>
<th>PNG median (ms)</th><th>PNG bytes</th>
</tr></thead>
<tbody></tbody>`;
const body = table.querySelector('tbody');
for (const row of rows) {
const element = document.createElement('tr');
element.innerHTML = `
<td>${row.size}</td>
<td>${row.layers}</td>
<td>${row.variant}</td>
<td>${row.render.medianMs.toFixed(3)}</td>
<td>${row.render.p95Ms.toFixed(3)}</td>
<td>${row.export.medianMs.toFixed(3)}</td>
<td>${row.pngBytes}</td>`;
body.appendChild(element);
}
resultsElement.replaceChildren(table);
}
async function runBenchmark() {
const sizes = parseList(document.getElementById('sizes').value);
const layerCounts = parseList(document.getElementById('layerCounts').value);
const iterations = Math.max(1, Number.parseInt(document.getElementById('iterations').value, 10) || 1);
const rows = [];
const distanceTransforms = {};
const total = sizes.length * layerCounts.length * variants.length;
let completed = 0;
runButton.disabled = true;
resultsElement.replaceChildren();
jsonElement.textContent = '';
try {
for (const size of sizes) {
distanceTransforms[size] = await benchmarkDistanceTransform(size, iterations);
for (const layerCount of layerCounts) {
const layers = makeLayers(size, layerCount);
for (const variant of variants) {
statusElement.textContent = `Running ${completed + 1}/${total}: ${size}px, ${layerCount} layers, ${variant}`;
renderScene(size, layers, variant);
const render = await measure(() => renderScene(size, layers, variant), iterations);
const exported = await measure(exportPng, iterations);
rows.push({
size,
layers: layerCount,
variant,
render,
export: exported,
pngBytes: await exportPng(),
});
completed += 1;
}
}
}
const report = {
benchmark: 'layerforge-browser-canvas-representative',
runtime: {
userAgent: navigator.userAgent,
devicePixelRatio: window.devicePixelRatio,
},
config: { sizes, layerCounts, variants, iterations },
distanceTransforms,
rows,
};
renderTable(rows);
jsonElement.textContent = JSON.stringify(report, null, 2);
statusElement.textContent = `Complete: ${rows.length} scenarios.`;
} catch (error) {
statusElement.textContent = `Failed: ${error instanceof Error ? error.message : error}`;
throw error;
} finally {
runButton.disabled = false;
}
}
runButton.addEventListener('click', () => {
runBenchmark().catch(error => console.error(error));
});
</script>
</body>
</html>
+327
View File
@@ -0,0 +1,327 @@
#!/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;
}
+2
View File
@@ -8,6 +8,8 @@
"scripts": {
"test": "node --test tests/*.mjs",
"test:coverage": "node --experimental-test-coverage --test tests/*.mjs",
"benchmark": "node benchmarks/frontend.mjs",
"benchmark:frontend": "node benchmarks/frontend.mjs",
"lint": "eslint js/ tests/ --max-warnings=0",
"lint:fix": "eslint js/ tests/ --fix",
"test:coverage:critical": "node --experimental-test-coverage --test-coverage-include=js/canvas/canvas_history.js --test-coverage-include=js/persistence/contracts.js --test-coverage-include=js/utils/web_socket_manager.js --test-coverage-lines=65 --test-coverage-functions=65 --test-coverage-branches=50 --test tests/test_canvas_history.mjs tests/test_persistence_contracts.mjs tests/test_persistence_resilience.mjs tests/test_async_lifecycle_resilience.mjs",