diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte index 42dac7a..dc383e0 100644 --- a/frontend/src/lib/components/Viewer.svelte +++ b/frontend/src/lib/components/Viewer.svelte @@ -231,9 +231,11 @@ const dh = Math.round(visibleH); ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh); - const data = ctx.getImageData(dx, dy, dw, dh); - applyLight(data.data, liveParams.light); - ctx.putImageData(data, dx, dy); + + // Фильтруем весь холст: так прошлый кадр не смешивается с новым. + const frame = ctx.getImageData(0, 0, width, height); + applyLight(frame.data, liveParams.light); + ctx.putImageData(frame, 0, 0); } function scheduleViewport() { diff --git a/frontend/src/lib/light-wasm.test.ts b/frontend/src/lib/light-wasm.test.ts index a87c11b..62c2be7 100644 --- a/frontend/src/lib/light-wasm.test.ts +++ b/frontend/src/lib/light-wasm.test.ts @@ -1,13 +1,13 @@ import { readFile } from 'node:fs/promises'; import { beforeAll, describe, expect, it } from 'vitest'; -import { applyLightToPixels, emptyEditParams, type LightParams } from './edit'; +import { applyLightToPixels, buildCurveLUT, emptyEditParams, type LightParams } from './edit'; interface LightExports { memory: WebAssembly.Memory; - build_lut(points: number, count: number, channel: number): void; apply( data: number, len: number, + luts: number, exposureMul: number, contrastK: number, highlights: number, @@ -19,6 +19,9 @@ interface LightExports { let wasm: LightExports; +const LUTS_OFFSET = 0; +const PIXELS_OFFSET = 4096; + beforeAll(async () => { const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url)); const { instance } = await WebAssembly.instantiate(bytes, {}); @@ -27,24 +30,27 @@ beforeAll(async () => { /** Применяет правки модулем WASM к копии пикселей. */ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray { - const points = light.curve?.rgb ?? []; - const floats = new Float32Array(points.length * 2); - points.forEach((p, i) => { - floats[i * 2] = p.x; - floats[i * 2 + 1] = p.y; - }); const mem = wasm.memory; - const need = Math.ceil((4096 + pixels.length) / 65536); - if (need > mem.buffer.byteLength / 65536) mem.grow(need - mem.buffer.byteLength / 65536); - new Float32Array(mem.buffer, 0, floats.length).set(floats); - wasm.build_lut(0, points.length, 0); - wasm.build_lut(0, 0, 1); - wasm.build_lut(0, 0, 2); - wasm.build_lut(0, 0, 3); - new Uint8ClampedArray(mem.buffer, 4096, pixels.length).set(pixels); + 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( - 4096, + PIXELS_OFFSET, pixels.length, + LUTS_OFFSET, Math.pow(2, light.exposure), 1 + light.contrast / 100, (light.highlights / 100) * 80, @@ -52,7 +58,7 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA (light.whites / 100) * 80, (light.blacks / 100) * 80 ); - return new Uint8ClampedArray(mem.buffer, 4096, pixels.length).slice(); + return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice(); } describe('WASM-модуль фильтров', () => { @@ -86,4 +92,10 @@ describe('WASM-модуль фильтров', () => { 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); + }); }); diff --git a/frontend/src/lib/light-wasm.ts b/frontend/src/lib/light-wasm.ts index 1abdc84..b455df1 100644 --- a/frontend/src/lib/light-wasm.ts +++ b/frontend/src/lib/light-wasm.ts @@ -1,23 +1,25 @@ -import { applyLightToPixels, type CurvePoint, type LightParams } from './edit'; +import { applyLightToPixels, buildCurveLUT, type LightParams } from './edit'; /** * WASM-модуль фильтров «Свет». * * Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm. - * Он считает правки по пикселям — это горячий путь предпросмотра в - * просмотрщике. Если модуль недоступен, используется JS-реализация. + * Он попиксельно применяет правки — это горячий путь предпросмотра в + * просмотрщике. Если модуль недоступен или считает иначе, чем эталонная + * JS-реализация, используется JS. */ -const POINTS_OFFSET = 0; +const LUTS_OFFSET = 0; const PIXELS_OFFSET = 4096; const PAGE = 65536; +const LUT_LEN = 256; interface LightExports { memory: WebAssembly.Memory; - build_lut(points: number, count: number, channel: number): void; apply( data: number, len: number, + luts: number, exposureMul: number, contrastK: number, highlights: number, @@ -36,7 +38,7 @@ export function initLightWasm(url = '/light.wasm'): Promise { if (!loading) { loading = (async () => { try { - const res = await fetch(url); + const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) return false; const bytes = await res.arrayBuffer(); const { instance } = await WebAssembly.instantiate(bytes, {}); @@ -67,17 +69,18 @@ function ensureMemory(bytes: number): WebAssembly.Memory | null { return mem; } -/** Записывает точки кривой в память модуля и строит таблицу соответствия. */ -function buildLut(mem: WebAssembly.Memory, points: CurvePoint[], channel: number): void { - const ex = exportsRef; - if (!ex) return; - const floats = new Float32Array(points.length * 2); - for (let i = 0; i < points.length; i++) { - floats[i * 2] = points[i].x; - floats[i * 2 + 1] = points[i].y; - } - new Float32Array(mem.buffer, POINTS_OFFSET, floats.length).set(floats); - ex.build_lut(POINTS_OFFSET, points.length, channel); +/** Готовит четыре таблицы соответствия: общая, красный, зелёный, синий. */ +function writeLuts(mem: WebAssembly.Memory, light: LightParams): void { + const curve = light.curve ?? {}; + const tables = [ + buildCurveLUT(curve.rgb), + buildCurveLUT(curve.red), + buildCurveLUT(curve.green), + buildCurveLUT(curve.blue) + ]; + const bytes = new Uint8Array(LUT_LEN * tables.length); + tables.forEach((table, i) => bytes.set(table, i * LUT_LEN)); + new Uint8Array(mem.buffer, LUTS_OFFSET, bytes.length).set(bytes); } /** @@ -91,16 +94,12 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo const mem = ensureMemory(bytes); if (!mem) return false; - const curve = light.curve ?? {}; - buildLut(mem, curve.rgb ?? [], 0); - buildLut(mem, curve.red ?? [], 1); - buildLut(mem, curve.green ?? [], 2); - buildLut(mem, curve.blue ?? [], 3); - + writeLuts(mem, light); new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data); ex.apply( PIXELS_OFFSET, data.length, + LUTS_OFFSET, Math.pow(2, light.exposure), 1 + light.contrast / 100, (light.highlights / 100) * 80, @@ -112,8 +111,33 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo return true; } +/** + * Сверяет модуль с эталонной JS-реализацией на маленьком образце. + * + * Модуль и JS должны считать одинаково; если модуль расходится (другая + * сборка, битая версия в кэше) — доверяем JS, чтобы предпросмотр не был + * испорчен. + */ +function wasmMatchesReference(light: LightParams): boolean { + const probe = new Uint8ClampedArray(16 * 4); + for (let i = 0; i < probe.length; i += 4) { + probe[i] = (i * 7) % 256; + probe[i + 1] = (i * 13) % 256; + probe[i + 2] = (i * 29) % 256; + probe[i + 3] = 255; + } + const expected = new Uint8ClampedArray(probe); + applyLightToPixels(expected, light); + const actual = new Uint8ClampedArray(probe); + if (!applyLightWasm(actual, light)) return false; + for (let i = 0; i < expected.length; i++) { + if (expected[i] !== actual[i]) return false; + } + return true; +} + /** Применяет правки: сначала WASM, при его недоступности — JS. */ export function applyLight(data: Uint8ClampedArray, light: LightParams): void { - if (applyLightWasm(data, light)) return; + if (wasmMatchesReference(light) && applyLightWasm(data, light)) return; applyLightToPixels(data, light); } diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts index 60d4ef7..2bcad42 100644 --- a/frontend/src/service-worker.ts +++ b/frontend/src/service-worker.ts @@ -156,6 +156,18 @@ async function handleNavigation(request: Request): Promise { /** Статика приложения: сначала кеш, затем сеть с дозаписью. */ async function handleAsset(request: Request): Promise { const cache = await caches.open(SHELL_CACHE); + // wasm-модуль фильтров меняется вместе с ними: старый кеш сломал бы + // предпросмотр правок, поэтому его всегда берём из сети. + if (new URL(request.url).pathname.endsWith('.wasm')) { + try { + const fresh = await fetch(request, { cache: 'no-store' }); + if (fresh.ok) await cache.put(request, fresh.clone()); + return fresh; + } catch { + const stale = await cache.match(request); + if (stale) return stale; + } + } const cached = await cache.match(request); if (cached) return cached; const response = await fetch(request); diff --git a/frontend/static/light.wasm b/frontend/static/light.wasm index 639a2c1..91923cd 100755 Binary files a/frontend/static/light.wasm and b/frontend/static/light.wasm differ diff --git a/frontend/wasm/light/src/lib.rs b/frontend/wasm/light/src/lib.rs index a1d286b..ec3f20b 100644 --- a/frontend/wasm/light/src/lib.rs +++ b/frontend/wasm/light/src/lib.rs @@ -1,8 +1,12 @@ -//! Фильтры «Свет» для веб-интерфейса: сборка кривых и попиксельная обработка. +//! Фильтры «Свет» для веб-интерфейса: попиксельная обработка кадра. //! //! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается //! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного -//! рендера. Математика совпадает с серверной (backend/internal/photos/edit.go). +//! рендера. +//! +//! Таблицы тональных кривых считает вызывающий код (JavaScript) и передаёт +//! сюда готовыми: модуль не хранит состояние и не использует transcendental +//! функции, поэтому работает без математической библиотеки. #![no_std] use core::panic::PanicInfo; @@ -12,10 +16,11 @@ fn panic(_info: &PanicInfo) -> ! { loop {} } -/// Каналы: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий. +/// Количество каналов: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий. const CHANNELS: usize = 4; -static mut LUTS: [[u8; 256]; CHANNELS] = [[0; 256]; CHANNELS]; +/// Длина таблицы соответствия одного канала. +const LUT_LEN: usize = 256; #[inline(always)] fn clamp01(v: f32) -> f32 { @@ -39,57 +44,16 @@ fn clampi(v: i32, lo: i32, hi: i32) -> i32 { } } -/// Строит таблицу соответствия 0..255 по точкам кривой. -/// -/// `points` — пары (x, y) в диапазоне 0..1, отсортированные по x. -/// Точки вне диапазона обрезаются, дубликаты по x пропускаются. -#[no_mangle] -pub extern "C" fn build_lut(points: *const f32, count: u32, channel: u32) { - unsafe { - let luts = &mut *core::ptr::addr_of_mut!(LUTS); - let ch = (channel as usize).min(CHANNELS - 1); - let lut = &mut luts[ch]; - if count < 2 { - for (i, slot) in lut.iter_mut().enumerate() { - *slot = i as u8; - } - return; - } - let n = count as usize; - let mut seg = 0usize; - for v in 0..256 { - let x = v as f32 / 255.0; - while seg + 1 < n && x > *points.add((seg + 1) * 2) { - seg += 1; - } - let x0 = *points.add(seg * 2); - let y0 = *points.add(seg * 2 + 1); - let x1 = *points.add((seg + 1) * 2); - let y1 = *points.add((seg + 1) * 2 + 1); - let y = if x <= x0 { - y0 - } else if x >= x1 { - y1 - } else if x1 - x0 < 1e-6 { - y1 - } else { - let t = (x - x0) / (x1 - x0); - y0 + t * (y1 - y0) - }; - lut[v] = (clamp01(y) * 255.0 + 0.5) as u8; - } - } -} - /// Применяет правки «Свет» к пикселям RGBA в памяти. /// -/// Коэффициенты вычисляет вызывающий код: модуль не использует transcendental -/// функции, поэтому работает без математической библиотеки. +/// `luts` — четыре таблицы соответствия по 256 байт (общая, красный, зелёный, +/// синий), подготовленные вызывающим кодом. #[no_mangle] #[allow(clippy::too_many_arguments)] pub extern "C" fn apply( data: *mut u8, len: u32, + luts: *const u8, exposure_mul: f32, contrast_k: f32, highlights: f32, @@ -97,52 +61,20 @@ pub extern "C" fn apply( whites: f32, blacks: f32, ) { - unsafe { - let luts = &*core::ptr::addr_of!(LUTS); - let master = &luts[0]; - let red = &luts[1]; - let green = &luts[2]; - let blue = &luts[3]; - let buf = core::slice::from_raw_parts_mut(data, len as usize); - let mut i = 0usize; - while i + 3 < buf.len() { - if buf[i + 3] != 0 { - buf[i] = adjust( - buf[i] as f32, - exposure_mul, - contrast_k, - highlights, - shadows, - whites, - blacks, - master, - red, - ); - buf[i + 1] = adjust( - buf[i + 1] as f32, - exposure_mul, - contrast_k, - highlights, - shadows, - whites, - blacks, - master, - green, - ); - buf[i + 2] = adjust( - buf[i + 2] as f32, - exposure_mul, - contrast_k, - highlights, - shadows, - whites, - blacks, - master, - blue, - ); - } - i += 4; + let tables = unsafe { core::slice::from_raw_parts(luts, CHANNELS * LUT_LEN) }; + let master = &tables[0..LUT_LEN]; + let red = &tables[LUT_LEN..2 * LUT_LEN]; + let green = &tables[2 * LUT_LEN..3 * LUT_LEN]; + let blue = &tables[3 * LUT_LEN..4 * LUT_LEN]; + let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) }; + let mut i = 0usize; + while i + 3 < buf.len() { + if buf[i + 3] != 0 { + buf[i] = adjust(buf[i] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, red); + buf[i + 1] = adjust(buf[i + 1] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, green); + buf[i + 2] = adjust(buf[i + 2] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, blue); } + i += 4; } } @@ -158,8 +90,8 @@ fn adjust( shadows: f32, whites: f32, blacks: f32, - master: &[u8; 256], - channel: &[u8; 256], + master: &[u8], + channel: &[u8], ) -> u8 { let mut v = value * exposure_mul; v = 128.0 + (v - 128.0) * contrast_k; diff --git a/openspec/specs/ui-workspace/spec.md b/openspec/specs/ui-workspace/spec.md index 378817c..7a71fda 100644 --- a/openspec/specs/ui-workspace/spec.md +++ b/openspec/specs/ui-workspace/spec.md @@ -802,7 +802,7 @@ SHALL очищаться. При отсутствии сети приложен #### Scenario: Фильтры считаются в браузере - **WHEN** браузер рисует предпросмотр правок -- **THEN** фильтры применяются WASM-модулем, а при его недоступности — JavaScript-реализацией с тем же результатом +- **THEN** фильтры применяются WASM-модулем, а если модул недоступен или считает иначе, чем эталонная JS-реализация, — JS-реализацией с тем же результатом #### Scenario: Сброс правок - **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»