import { readFile } from 'node:fs/promises'; import { beforeAll, describe, expect, it } from 'vitest'; import { applyColorToPixels, applyLightToPixels, buildCurveLUT, emptyColor, emptyEditParams, GRADING_WHEELS, MIXER_BANDS, type ColorParams, type LightParams } from './edit'; interface LightExports { memory: WebAssembly.Memory; apply( data: number, len: number, luts: number, exposureMul: number, contrastK: number, highlights: number, shadows: number, whites: number, blacks: number ): void; apply_color(data: number, len: number, params: number): void; } let wasm: LightExports; const LUTS_OFFSET = 0; const COLOR_PARAMS_OFFSET = 2048; const COLOR_PARAM_COUNT = 42; const PIXELS_OFFSET = 4096; beforeAll(async () => { const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url)); const { instance } = await WebAssembly.instantiate(bytes, {}); wasm = instance.exports as unknown as LightExports; }); /** Применяет правки модулем WASM к копии пикселей. */ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray { const mem = wasm.memory; const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536); const have = mem.buffer.byteLength / 65536; if (need > have) mem.grow(need - have); const curve = light.curve ?? {}; const tables = [ buildCurveLUT(curve.rgb), buildCurveLUT(curve.red), buildCurveLUT(curve.green), buildCurveLUT(curve.blue) ]; const luts = new Uint8Array(256 * tables.length); tables.forEach((table, i) => luts.set(table, i * 256)); new Uint8Array(mem.buffer, LUTS_OFFSET, luts.length).set(luts); new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels); wasm.apply( PIXELS_OFFSET, pixels.length, LUTS_OFFSET, Math.pow(2, light.exposure), 1 + light.contrast / 100, (light.highlights / 100) * 80, (light.shadows / 100) * 80, (light.whites / 100) * 80, (light.blacks / 100) * 80 ); return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice(); } /** Применяет цветовые правки модулем WASM к копии пикселей. */ function applyColorWasm(pixels: Uint8ClampedArray, color: ColorParams): Uint8ClampedArray { const mem = wasm.memory; const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536); const have = mem.buffer.byteLength / 65536; if (need > have) mem.grow(need - have); const params = new Float64Array(COLOR_PARAM_COUNT); params[0] = color.temperature / 100; params[1] = color.tint / 100; params[2] = color.vibrance / 100; params[3] = color.saturation / 100; MIXER_BANDS.forEach((band, index) => { const value = color.mixer[band.key]; const base = 4 + index * 3; params[base] = value.hue / 100; params[base + 1] = value.saturation / 100; params[base + 2] = value.luminance / 100; }); GRADING_WHEELS.forEach((wheel, index) => { const value = color.grading[wheel.key]; const base = 28 + index * 2; params[base] = value.hue; params[base + 1] = value.saturation / 100; params[34 + index] = (value.luminance ?? 0) / 100; }); params[37] = color.grading.blending / 100; params[38] = color.grading.balance / 100; params[39] = color.correction.red / 100; params[40] = color.correction.green / 100; params[41] = color.correction.blue / 100; new Float64Array(mem.buffer, COLOR_PARAMS_OFFSET, COLOR_PARAM_COUNT).set(params); new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels); wasm.apply_color(PIXELS_OFFSET, pixels.length, COLOR_PARAMS_OFFSET); return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice(); } describe('WASM-модуль фильтров', () => { it('совпадает с JS-реализацией', () => { const light: LightParams = { ...emptyEditParams().light, exposure: 0.8, contrast: 25, highlights: -30, shadows: 40, whites: 10, blacks: -15, curve: { rgb: [{ x: 0, y: 0 }, { x: 0.5, y: 0.4 }, { x: 1, y: 1 }] } }; const source = new Uint8ClampedArray(4 * 64); for (let i = 0; i < source.length; i += 4) { source[i] = (i * 7) % 256; source[i + 1] = (i * 13) % 256; source[i + 2] = (i * 29) % 256; source[i + 3] = 255; } const expected = source.slice(); applyLightToPixels(expected, light); const actual = applyWasm(source.slice(), light); expect(Array.from(actual)).toEqual(Array.from(expected)); }); it('не меняет прозрачные пиксели', () => { const light: LightParams = { ...emptyEditParams().light, exposure: 2 }; const pixels = new Uint8ClampedArray([10, 20, 30, 0]); const out = applyWasm(pixels, light); expect(Array.from(out)).toEqual([10, 20, 30, 0]); }); it('осветляет при положительной экспозиции', () => { const light: LightParams = { ...emptyEditParams().light, exposure: 1 }; const out = applyWasm(new Uint8ClampedArray([100, 100, 100, 255]), light); expect(out[0]).toBeGreaterThan(100); }); it('совпадает с JS на большом градиенте (свет)', () => { const light: LightParams = { ...emptyEditParams().light, exposure: 0.4, contrast: 20, highlights: -20, shadows: 30 }; const width = 256; const height = 256; const source = new Uint8ClampedArray(width * height * 4); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; source[i] = 20 + (y % 200); source[i + 1] = 30 + (x % 150); source[i + 2] = 40 + ((x + y) % 120); source[i + 3] = 255; } } const expected = source.slice(); applyLightToPixels(expected, light); const actual = applyWasm(source.slice(), light); expect(Array.from(actual)).toEqual(Array.from(expected)); }); it('совпадает с JS-реализацией цвета', () => { const color = emptyColor(); color.temperature = 30; color.tint = -10; color.vibrance = 20; color.saturation = -15; color.mixer.red = { hue: 20, saturation: 30, luminance: -10 }; color.mixer.blue = { hue: -15, saturation: -20, luminance: 10 }; color.grading.shadows = { hue: 240, saturation: 40, luminance: 10 }; color.grading.highlights = { hue: 40, saturation: 20, luminance: -10 }; color.grading.blending = 30; color.grading.balance = -20; color.correction = { red: 10, green: -5, blue: 8 }; const source = new Uint8ClampedArray(4 * 64); for (let i = 0; i < source.length; i += 4) { source[i] = (i * 7) % 256; source[i + 1] = (i * 13) % 256; source[i + 2] = (i * 29) % 256; source[i + 3] = 255; } const expected = source.slice(); applyColorToPixels(expected, color); const actual = applyColorWasm(source.slice(), color); expect(Array.from(actual)).toEqual(Array.from(expected)); }); it('не меняет прозрачные пиксели при цветовых правках', () => { const color = emptyColor(); color.saturation = 100; color.temperature = 100; const out = applyColorWasm(new Uint8ClampedArray([10, 20, 30, 0]), color); expect(Array.from(out)).toEqual([10, 20, 30, 0]); }); it('совпадает с JS на большом градиенте', () => { const color = emptyColor(); color.temperature = 25; color.vibrance = 15; color.mixer.blue = { hue: -10, saturation: 20, luminance: 10 }; color.grading.shadows = { hue: 240, saturation: 80, luminance: 10 }; color.grading.highlights = { hue: 40, saturation: 30, luminance: -10 }; const width = 256; const height = 256; const source = new Uint8ClampedArray(width * height * 4); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; source[i] = 20 + (y % 200); source[i + 1] = 30 + (x % 150); source[i + 2] = 40 + ((x + y) % 120); source[i + 3] = 255; } } const expected = source.slice(); applyColorToPixels(expected, color); const actual = applyColorWasm(source.slice(), color); expect(Array.from(actual)).toEqual(Array.from(expected)); }); it('совпадает с JS на быстром пути (только колёса)', () => { const color = emptyColor(); color.temperature = 20; color.tint = -10; color.grading.blending = 50; color.grading.balance = -20; color.grading.shadows = { hue: 240, saturation: 70, luminance: 25 }; color.grading.midtones = { hue: 40, saturation: 20, luminance: -15 }; color.grading.highlights = { hue: 200, saturation: 30, luminance: 10 }; const width = 128; const height = 128; const source = new Uint8ClampedArray(width * height * 4); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; source[i] = 10 + (y % 220); source[i + 1] = 20 + (x % 180); source[i + 2] = 30 + ((x + y) % 160); source[i + 3] = 255; } } const expected = source.slice(); applyColorToPixels(expected, color); const actual = applyColorWasm(source.slice(), color); expect(Array.from(actual)).toEqual(Array.from(expected)); }); it('совпадает с JS на случайных цветовых параметрах', () => { for (let n = 0; n < 100; n++) { const color = emptyColor(); color.temperature = Math.random() * 200 - 100; color.tint = Math.random() * 200 - 100; color.vibrance = Math.random() * 200 - 100; color.saturation = Math.random() * 200 - 100; color.grading.blending = Math.random() * 100; color.grading.balance = Math.random() * 200 - 100; color.grading.shadows = { hue: Math.random() * 360, saturation: Math.random() * 200 - 100, luminance: Math.random() * 200 - 100 }; color.grading.midtones = { hue: Math.random() * 360, saturation: Math.random() * 200 - 100, luminance: Math.random() * 200 - 100 }; color.grading.highlights = { hue: Math.random() * 360, saturation: Math.random() * 200 - 100, luminance: Math.random() * 200 - 100 }; const source = new Uint8ClampedArray(64); for (let i = 0; i < source.length; i += 4) { source[i] = (i * 7) % 256; source[i + 1] = (i * 13) % 256; source[i + 2] = (i * 29) % 256; source[i + 3] = 255; } const expected = source.slice(); applyColorToPixels(expected, color); const actual = applyColorWasm(source.slice(), color); for (let i = 0; i < expected.length; i++) { if (expected[i] !== actual[i]) { throw new Error( `mismatch n=${n} i=${i} expected=${expected[i]} actual=${actual[i]} color=${JSON.stringify(color)}` ); } } } }); });