mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
426 lines
16 KiB
JavaScript
426 lines
16 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {mkdir, readFile, writeFile} from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import {performance} from 'node:perf_hooks';
|
|
import {isMainThread, parentPort, Worker, workerData} from 'node:worker_threads';
|
|
import {encode as encodePng} from 'fast-png';
|
|
import {encode as encodeJpeg} from 'jpeg-js';
|
|
import {
|
|
assemble_apng_frames,
|
|
assemble_gif_frame_chunks,
|
|
crop_and_rotate_apng,
|
|
crop_and_rotate_gif,
|
|
crop_and_rotate_image,
|
|
crop_rotate_rgba,
|
|
decode_apng_frames,
|
|
decode_gif_frames,
|
|
encode_apng_frame_payload,
|
|
encode_apng_frames,
|
|
encode_gif_frame_chunk,
|
|
encode_gif_frames,
|
|
initSync,
|
|
is_animated_image,
|
|
} from '../pkgs/libfluxcore/libfluxcore.js';
|
|
|
|
const wasmUrl = new URL('../pkgs/libfluxcore/libfluxcore_bg.wasm', import.meta.url);
|
|
const mediaCacheDir = new URL('../.cache/libfluxcore-bench-media/', import.meta.url);
|
|
const workerCount = Math.max(1, Math.min(4, os.availableParallelism?.() ?? os.cpus().length ?? 1));
|
|
const profile = process.argv.find((arg) => arg.startsWith('--profile='))?.slice('--profile='.length) ?? 'standard';
|
|
const downloadRealMediaOnly = process.argv.includes('--download-realmedia');
|
|
const offlineRealMedia = process.argv.includes('--offline') || process.env.FLUXCORE_BENCH_OFFLINE === '1';
|
|
const userAgent = 'Fluxer libfluxcore benchmark (https://fluxer.app)';
|
|
const realMediaAssets = [
|
|
{
|
|
id: 'jpeg-fronalpstock',
|
|
format: 'jpeg',
|
|
fileName: 'fronalpstock_big.jpg',
|
|
expectedBytes: 14_679_474,
|
|
url: 'https://upload.wikimedia.org/wikipedia/commons/3/3f/Fronalpstock_big.jpg',
|
|
source: 'https://commons.wikimedia.org/wiki/File:Fronalpstock_big.jpg',
|
|
},
|
|
{
|
|
id: 'png-snr-demo',
|
|
format: 'png',
|
|
fileName: 'snr_image_demonstration.png',
|
|
expectedBytes: 1_146_718,
|
|
url: 'https://upload.wikimedia.org/wikipedia/commons/f/f9/SNR_image_demonstration.png',
|
|
source: 'https://commons.wikimedia.org/wiki/File:SNR_image_demonstration.png',
|
|
},
|
|
{
|
|
id: 'gif-gerridae',
|
|
format: 'gif',
|
|
fileName: 'gerridae_1200x675.gif',
|
|
expectedBytes: 6_318_395,
|
|
url: 'https://upload.wikimedia.org/wikipedia/commons/f/f0/%22%2Barya%2B%22_Gerridae_-_Anggang_kayak_-_laba-laba_air_-_Lengkongwetan_2020_1.gif',
|
|
source:
|
|
'https://commons.wikimedia.org/wiki/File:%22%2Barya%2B%22_Gerridae_-_Anggang_kayak_-_laba-laba_air_-_Lengkongwetan_2020_1.gif',
|
|
},
|
|
{
|
|
id: 'apng-human-male',
|
|
format: 'apng',
|
|
fileName: '201803_human_male_anim.png',
|
|
expectedBytes: 8_150_411,
|
|
url: 'https://upload.wikimedia.org/wikipedia/commons/c/c7/201803_Human_Male_anim.png',
|
|
source: 'https://commons.wikimedia.org/wiki/File:201803_Human_Male_anim.png',
|
|
},
|
|
{
|
|
id: 'webp-samsung-note',
|
|
format: 'webp',
|
|
fileName: 'samsung_galaxy_note.webp',
|
|
expectedBytes: 7_326_306,
|
|
url: 'https://upload.wikimedia.org/wikipedia/commons/5/59/Samsung_Galaxy_Note.WebP',
|
|
source: 'https://commons.wikimedia.org/wiki/File:Samsung_Galaxy_Note.WebP',
|
|
},
|
|
{
|
|
id: 'avif-hato',
|
|
format: 'avif',
|
|
fileName: 'hato.profile0.8bpc.yuv420.avif',
|
|
expectedBytes: 259_104,
|
|
url: 'https://raw.githubusercontent.com/link-u/avif-sample-images/master/hato.profile0.8bpc.yuv420.avif',
|
|
source: 'https://github.com/link-u/avif-sample-images',
|
|
},
|
|
];
|
|
|
|
function initWasm(bytes) {
|
|
initSync({module: bytes});
|
|
}
|
|
|
|
function makeFrame(width, height, seed) {
|
|
const rgba = new Uint8Array(width * height * 4);
|
|
for (let index = 0, pixel = 0; index < rgba.length; index += 4, pixel += 1) {
|
|
const x = pixel % width;
|
|
const y = Math.floor(pixel / width);
|
|
rgba[index] = (x * 3 + seed * 17) & 0xff;
|
|
rgba[index + 1] = (y * 5 + seed * 29) & 0xff;
|
|
rgba[index + 2] = ((x ^ y) + seed * 11) & 0xff;
|
|
rgba[index + 3] = 255;
|
|
}
|
|
return {rgba, width, height, delayMs: 40};
|
|
}
|
|
|
|
function batches(frames, count) {
|
|
const out = Array.from({length: count}, () => []);
|
|
for (let index = 0; index < frames.length; index += 1) out[index % count].push({frame: frames[index], index});
|
|
return out.filter((batch) => batch.length > 0);
|
|
}
|
|
|
|
async function encodeInWorkers(kind, frames, wasmBytes, options = {}) {
|
|
const jobs = batches(frames, Math.min(workerCount, frames.length));
|
|
const workers = jobs.map(() => new Worker(new URL(import.meta.url), {workerData: {wasmBytes}}));
|
|
try {
|
|
const results = await Promise.all(
|
|
jobs.map(
|
|
(batch, index) =>
|
|
new Promise((resolve, reject) => {
|
|
const worker = workers[index];
|
|
const payload = batch.map(({frame, index: frameIndex}) => ({
|
|
frame: {
|
|
rgba: options.transferFrames ? frame.rgba : frame.rgba.slice(),
|
|
width: frame.width,
|
|
height: frame.height,
|
|
delayMs: frame.delayMs,
|
|
},
|
|
index: frameIndex,
|
|
}));
|
|
worker.once('message', (message) => {
|
|
if (message.ok) resolve(message.frames);
|
|
else reject(new Error(message.error || 'worker encode failed'));
|
|
});
|
|
worker.once('error', reject);
|
|
worker.postMessage(
|
|
{kind, frames: payload},
|
|
payload.map(({frame}) => frame.rgba.buffer),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
const ordered = new Array(frames.length);
|
|
for (const result of results) {
|
|
for (const item of result) ordered[item.index] = item.frame;
|
|
}
|
|
if (kind === 'apng') return assemble_apng_frames(ordered);
|
|
return assemble_gif_frame_chunks(ordered);
|
|
} finally {
|
|
await Promise.allSettled(workers.map((worker) => worker.terminate()));
|
|
}
|
|
}
|
|
|
|
async function transformInWorkers(frames, cropParams, wasmBytes) {
|
|
const jobs = batches(frames, Math.min(workerCount, frames.length));
|
|
const workers = jobs.map(() => new Worker(new URL(import.meta.url), {workerData: {wasmBytes}}));
|
|
try {
|
|
const results = await Promise.all(
|
|
jobs.map(
|
|
(batch, index) =>
|
|
new Promise((resolve, reject) => {
|
|
const worker = workers[index];
|
|
const payload = batch.map(({frame, index: frameIndex}) => ({
|
|
frame: {
|
|
rgba: frame.rgba,
|
|
width: frame.width,
|
|
height: frame.height,
|
|
delayMs: frame.delayMs,
|
|
},
|
|
index: frameIndex,
|
|
}));
|
|
worker.once('message', (message) => {
|
|
if (message.ok) resolve(message.frames);
|
|
else reject(new Error(message.error || 'worker transform failed'));
|
|
});
|
|
worker.once('error', reject);
|
|
worker.postMessage(
|
|
{kind: 'transform', frames: payload, cropParams},
|
|
payload.map(({frame}) => frame.rgba.buffer),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
const ordered = new Array(frames.length);
|
|
for (const result of results) {
|
|
for (const item of result) ordered[item.index] = item.frame;
|
|
}
|
|
return ordered;
|
|
} finally {
|
|
await Promise.allSettled(workers.map((worker) => worker.terminate()));
|
|
}
|
|
}
|
|
|
|
function benchSync(label, iterations, fn) {
|
|
fn();
|
|
const start = performance.now();
|
|
let result;
|
|
for (let index = 0; index < iterations; index += 1) result = fn();
|
|
const elapsed = performance.now() - start;
|
|
const bytes = result?.byteLength ?? result?.rgba?.byteLength ?? 0;
|
|
console.log(`${label}: ${(elapsed / iterations).toFixed(2)} ms/op (${iterations} iters, ${bytes} bytes last result)`);
|
|
}
|
|
|
|
async function benchAsync(label, iterations, fn) {
|
|
await fn();
|
|
const start = performance.now();
|
|
let result;
|
|
for (let index = 0; index < iterations; index += 1) result = await fn();
|
|
const elapsed = performance.now() - start;
|
|
const bytes = result?.byteLength ?? 0;
|
|
console.log(`${label}: ${(elapsed / iterations).toFixed(2)} ms/op (${iterations} iters, ${bytes} bytes last result)`);
|
|
}
|
|
|
|
function benchOnce(label, fn) {
|
|
const start = performance.now();
|
|
const result = fn();
|
|
const elapsed = performance.now() - start;
|
|
const bytes = result?.byteLength ?? result?.rgba?.byteLength ?? 0;
|
|
console.log(`${label}: ${elapsed.toFixed(2)} ms (${bytes} bytes result)`);
|
|
return result;
|
|
}
|
|
|
|
async function benchOnceAsync(label, fn) {
|
|
const start = performance.now();
|
|
const result = await fn();
|
|
const elapsed = performance.now() - start;
|
|
const bytes = result?.byteLength ?? result?.rgba?.byteLength ?? 0;
|
|
console.log(`${label}: ${elapsed.toFixed(2)} ms (${bytes} bytes result)`);
|
|
return result;
|
|
}
|
|
|
|
function assertExpectedAssetSize(asset, bytes) {
|
|
if (asset.expectedBytes != null && bytes.byteLength !== asset.expectedBytes) {
|
|
throw new Error(`${asset.id} expected ${asset.expectedBytes} bytes, got ${bytes.byteLength}`);
|
|
}
|
|
}
|
|
|
|
async function readOrDownloadAsset(asset, options = {}) {
|
|
await mkdir(mediaCacheDir, {recursive: true});
|
|
const fileUrl = new URL(asset.fileName, mediaCacheDir);
|
|
try {
|
|
const data = await readFile(fileUrl);
|
|
assertExpectedAssetSize(asset, data);
|
|
console.log(`asset ${asset.id}: cache hit (${data.byteLength} bytes)`);
|
|
return data;
|
|
} catch (error) {
|
|
if (options.offline) {
|
|
throw new Error(
|
|
`missing or invalid local media asset ${asset.fileName}; run pnpm wasm:bench:download-realmedia`,
|
|
{cause: error},
|
|
);
|
|
}
|
|
}
|
|
const response = await fetch(asset.url, {headers: {'User-Agent': userAgent}});
|
|
if (!response.ok) throw new Error(`failed to download ${asset.id}: HTTP ${response.status}`);
|
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
assertExpectedAssetSize(asset, bytes);
|
|
await writeFile(fileUrl, bytes);
|
|
console.log(`asset ${asset.id}: downloaded ${bytes.byteLength} bytes from ${asset.source}`);
|
|
return bytes;
|
|
}
|
|
|
|
async function loadRealMediaAssets(options = {}) {
|
|
const entries = await Promise.all(
|
|
realMediaAssets.map(async (asset) => [asset.id, {...asset, bytes: await readOrDownloadAsset(asset, options)}]),
|
|
);
|
|
return new Map(entries);
|
|
}
|
|
|
|
async function runStandardProfile(wasmBytes) {
|
|
const cropSource = makeFrame(1024, 1024, 1);
|
|
benchSync('crop_rotate_rgba 1024x1024 -> 512x512 rotate90', 60, () =>
|
|
crop_rotate_rgba(cropSource.rgba, cropSource.width, cropSource.height, 64, 64, 768, 768, 90, 512, 512),
|
|
);
|
|
const apngFrames = Array.from({length: 24}, (_, index) => makeFrame(320, 320, index));
|
|
benchSync('encode_apng_frames serial 24x320', 4, () => encode_apng_frames(apngFrames));
|
|
await benchAsync('encode_apng_frames worker payloads 24x320', 4, () =>
|
|
encodeInWorkers('apng', apngFrames, wasmBytes),
|
|
);
|
|
const gifFrames = Array.from({length: 16}, (_, index) => makeFrame(192, 192, index));
|
|
benchSync('encode_gif_frames serial 16x192', 3, () => encode_gif_frames(gifFrames));
|
|
await benchAsync('encode_gif_frames worker chunks 16x192', 3, () => encodeInWorkers('gif', gifFrames, wasmBytes));
|
|
}
|
|
|
|
async function runHighResProfile(wasmBytes) {
|
|
const crop4k = makeFrame(3840, 2160, 11);
|
|
benchSync('crop_rotate_rgba 4K -> 1080p rotate90', 10, () =>
|
|
crop_rotate_rgba(crop4k.rgba, crop4k.width, crop4k.height, 420, 120, 3000, 1800, 90, 1080, 1920),
|
|
);
|
|
const staticFrame = makeFrame(1920, 1080, 17);
|
|
const pngSource = encodePng({
|
|
width: staticFrame.width,
|
|
height: staticFrame.height,
|
|
data: staticFrame.rgba,
|
|
depth: 8,
|
|
channels: 4,
|
|
});
|
|
const jpegSource = encodeJpeg(
|
|
{width: staticFrame.width, height: staticFrame.height, data: staticFrame.rgba},
|
|
88,
|
|
).data;
|
|
benchSync('crop_and_rotate_image PNG 1080p -> PNG 720p', 3, () =>
|
|
crop_and_rotate_image(pngSource, 'png', 160, 90, 1600, 900, 0, 1280, 720),
|
|
);
|
|
benchSync('crop_and_rotate_image JPEG 1080p -> JPEG 720p', 3, () =>
|
|
crop_and_rotate_image(jpegSource, 'jpeg', 160, 90, 1600, 900, 0, 1280, 720),
|
|
);
|
|
const apngFrames = Array.from({length: 6}, (_, index) => makeFrame(1920, 1080, index + 31));
|
|
benchSync('encode_apng_frames serial 6x1080p', 1, () => encode_apng_frames(apngFrames));
|
|
await benchAsync('encode_apng_frames worker payloads 6x1080p', 1, () =>
|
|
encodeInWorkers('apng', apngFrames, wasmBytes),
|
|
);
|
|
const gifFrames = Array.from({length: 6}, (_, index) => makeFrame(1280, 720, index + 51));
|
|
benchSync('encode_gif_frames serial 6x720p', 1, () => encode_gif_frames(gifFrames));
|
|
await benchAsync('encode_gif_frames worker chunks 6x720p', 1, () => encodeInWorkers('gif', gifFrames, wasmBytes));
|
|
}
|
|
|
|
async function runRealMediaProfile(wasmBytes) {
|
|
const assets = await loadRealMediaAssets({offline: offlineRealMedia});
|
|
const jpeg = assets.get('jpeg-fronalpstock').bytes;
|
|
const png = assets.get('png-snr-demo').bytes;
|
|
const gif = assets.get('gif-gerridae').bytes;
|
|
const apng = assets.get('apng-human-male').bytes;
|
|
const webp = assets.get('webp-samsung-note').bytes;
|
|
const avif = assets.get('avif-hato').bytes;
|
|
benchOnce('real JPEG crop Fronalpstock 10109x4542 -> JPEG 1920x1080', () =>
|
|
crop_and_rotate_image(jpeg, 'jpeg', 1000, 400, 8000, 3600, 0, 1920, 1080),
|
|
);
|
|
benchOnce('real PNG crop SNR demo 3840x2880 -> PNG 1280x720', () =>
|
|
crop_and_rotate_image(png, 'png', 320, 240, 3200, 1800, 0, 1280, 720),
|
|
);
|
|
benchOnce('real APNG serial crop 1920x1920x22 -> 720x720', () =>
|
|
crop_and_rotate_apng(apng, 240, 240, 1440, 1440, 0, 720, 720),
|
|
);
|
|
await benchOnceAsync('real APNG worker-style crop 1920x1920x22 -> 720x720', async () => {
|
|
const frames = decode_apng_frames(apng);
|
|
const transformed = await transformInWorkers(
|
|
frames,
|
|
{x: 240, y: 240, width: 1440, height: 1440, rotation: 0, resizeWidth: 720, resizeHeight: 720},
|
|
wasmBytes,
|
|
);
|
|
return encodeInWorkers('apng', transformed, wasmBytes, {transferFrames: true});
|
|
});
|
|
benchOnce('real GIF serial crop 1200x675x13 -> 854x480', () =>
|
|
crop_and_rotate_gif(gif, 80, 45, 1040, 585, 0, 854, 480),
|
|
);
|
|
await benchOnceAsync('real GIF worker-style crop 1200x675x13 -> 854x480', async () => {
|
|
const frames = decode_gif_frames(gif);
|
|
const transformed = await transformInWorkers(
|
|
frames,
|
|
{x: 80, y: 45, width: 1040, height: 585, rotation: 0, resizeWidth: 854, resizeHeight: 480},
|
|
wasmBytes,
|
|
);
|
|
return encodeInWorkers('gif', transformed, wasmBytes, {transferFrames: true});
|
|
});
|
|
benchSync('real WebP animated detection 4032x3024', 1000, () => (is_animated_image(webp) ? webp : webp));
|
|
benchSync('real AVIF animated detection 3082x2048', 1000, () => (is_animated_image(avif) ? avif : avif));
|
|
console.log(
|
|
'WebP and AVIF decode/crop are routed through browser ImageDecoder or the native media bridge, not this Node wasm loader.',
|
|
);
|
|
}
|
|
|
|
if (!isMainThread) {
|
|
initWasm(workerData.wasmBytes);
|
|
parentPort.on('message', (message) => {
|
|
try {
|
|
if (message.kind === 'apng') {
|
|
const frames = message.frames.map(({frame, index}) => ({
|
|
frame: encode_apng_frame_payload(frame),
|
|
index,
|
|
}));
|
|
parentPort.postMessage(
|
|
{ok: true, frames},
|
|
frames.map(({frame}) => frame.compressed.buffer),
|
|
);
|
|
return;
|
|
}
|
|
if (message.kind === 'transform') {
|
|
const frames = message.frames.map(({frame, index}) => {
|
|
const transformed = crop_rotate_rgba(
|
|
frame.rgba,
|
|
frame.width,
|
|
frame.height,
|
|
message.cropParams.x,
|
|
message.cropParams.y,
|
|
message.cropParams.width,
|
|
message.cropParams.height,
|
|
message.cropParams.rotation,
|
|
message.cropParams.resizeWidth,
|
|
message.cropParams.resizeHeight,
|
|
);
|
|
return {frame: {...transformed, delayMs: frame.delayMs}, index};
|
|
});
|
|
parentPort.postMessage(
|
|
{ok: true, frames},
|
|
frames.map(({frame}) => frame.rgba.buffer),
|
|
);
|
|
return;
|
|
}
|
|
const frames = message.frames.map(({frame, index}) => ({
|
|
frame: encode_gif_frame_chunk(frame, index === 0),
|
|
index,
|
|
}));
|
|
parentPort.postMessage(
|
|
{ok: true, frames},
|
|
frames.map(({frame}) => frame.data.buffer),
|
|
);
|
|
} catch (error) {
|
|
parentPort.postMessage({ok: false, error: error instanceof Error ? error.message : String(error)});
|
|
}
|
|
});
|
|
} else {
|
|
if (downloadRealMediaOnly) {
|
|
await loadRealMediaAssets({offline: false});
|
|
console.log(`real media assets are local in ${mediaCacheDir.pathname}`);
|
|
process.exit(0);
|
|
}
|
|
const wasmBytes = await readFile(wasmUrl);
|
|
initWasm(wasmBytes);
|
|
console.log(
|
|
`libfluxcore benchmark (${workerCount} encode workers, profile=${profile}, SIMD=${process.env.FLUXCORE_WASM_SIMD === '1' ? 'on' : 'off'})`,
|
|
);
|
|
if (profile === 'highres') {
|
|
await runHighResProfile(wasmBytes);
|
|
} else if (profile === 'realmedia') {
|
|
await runRealMediaProfile(wasmBytes);
|
|
} else {
|
|
await runStandardProfile(wasmBytes);
|
|
}
|
|
}
|