fix(web): искажение цветов в предпросмотре правок
Таблицы тональных кривых в WASM-модуле лежали в статическом массиве, который линковщик помещал в сегмент .data — такой сегмент в wasm доступен только для чтения, поэтому запись в него не срабатывала, и модуль считал по мусорным данным (отсюда «каша» вместо плавного градиента). Теперь таблицы считает и передаёт JS: модуль не хранит состояния и только применяет правки к пикселям. Дополнительно клиент сверяет модуль с эталонной JS-реализацией на маленьком образце и, если они расходятся, доверяет JS. Service worker больше не кэширует .wasm: старый модуль в кэше ломал предпросмотр даже после обновления сборки.
This commit is contained in:
@@ -231,9 +231,11 @@
|
|||||||
const dh = Math.round(visibleH);
|
const dh = Math.round(visibleH);
|
||||||
|
|
||||||
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
|
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() {
|
function scheduleViewport() {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { applyLightToPixels, emptyEditParams, type LightParams } from './edit';
|
import { applyLightToPixels, buildCurveLUT, emptyEditParams, type LightParams } from './edit';
|
||||||
|
|
||||||
interface LightExports {
|
interface LightExports {
|
||||||
memory: WebAssembly.Memory;
|
memory: WebAssembly.Memory;
|
||||||
build_lut(points: number, count: number, channel: number): void;
|
|
||||||
apply(
|
apply(
|
||||||
data: number,
|
data: number,
|
||||||
len: number,
|
len: number,
|
||||||
|
luts: number,
|
||||||
exposureMul: number,
|
exposureMul: number,
|
||||||
contrastK: number,
|
contrastK: number,
|
||||||
highlights: number,
|
highlights: number,
|
||||||
@@ -19,6 +19,9 @@ interface LightExports {
|
|||||||
|
|
||||||
let wasm: LightExports;
|
let wasm: LightExports;
|
||||||
|
|
||||||
|
const LUTS_OFFSET = 0;
|
||||||
|
const PIXELS_OFFSET = 4096;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
|
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
|
||||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||||
@@ -27,24 +30,27 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
/** Применяет правки модулем WASM к копии пикселей. */
|
/** Применяет правки модулем WASM к копии пикселей. */
|
||||||
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedArray {
|
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 mem = wasm.memory;
|
||||||
const need = Math.ceil((4096 + pixels.length) / 65536);
|
const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
|
||||||
if (need > mem.buffer.byteLength / 65536) mem.grow(need - mem.buffer.byteLength / 65536);
|
const have = mem.buffer.byteLength / 65536;
|
||||||
new Float32Array(mem.buffer, 0, floats.length).set(floats);
|
if (need > have) mem.grow(need - have);
|
||||||
wasm.build_lut(0, points.length, 0);
|
|
||||||
wasm.build_lut(0, 0, 1);
|
const curve = light.curve ?? {};
|
||||||
wasm.build_lut(0, 0, 2);
|
const tables = [
|
||||||
wasm.build_lut(0, 0, 3);
|
buildCurveLUT(curve.rgb),
|
||||||
new Uint8ClampedArray(mem.buffer, 4096, pixels.length).set(pixels);
|
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(
|
wasm.apply(
|
||||||
4096,
|
PIXELS_OFFSET,
|
||||||
pixels.length,
|
pixels.length,
|
||||||
|
LUTS_OFFSET,
|
||||||
Math.pow(2, light.exposure),
|
Math.pow(2, light.exposure),
|
||||||
1 + light.contrast / 100,
|
1 + light.contrast / 100,
|
||||||
(light.highlights / 100) * 80,
|
(light.highlights / 100) * 80,
|
||||||
@@ -52,7 +58,7 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA
|
|||||||
(light.whites / 100) * 80,
|
(light.whites / 100) * 80,
|
||||||
(light.blacks / 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-модуль фильтров', () => {
|
describe('WASM-модуль фильтров', () => {
|
||||||
@@ -86,4 +92,10 @@ describe('WASM-модуль фильтров', () => {
|
|||||||
const out = applyWasm(pixels, light);
|
const out = applyWasm(pixels, light);
|
||||||
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { applyLightToPixels, type CurvePoint, type LightParams } from './edit';
|
import { applyLightToPixels, buildCurveLUT, type LightParams } from './edit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WASM-модуль фильтров «Свет».
|
* WASM-модуль фильтров «Свет».
|
||||||
*
|
*
|
||||||
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.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 PIXELS_OFFSET = 4096;
|
||||||
const PAGE = 65536;
|
const PAGE = 65536;
|
||||||
|
const LUT_LEN = 256;
|
||||||
|
|
||||||
interface LightExports {
|
interface LightExports {
|
||||||
memory: WebAssembly.Memory;
|
memory: WebAssembly.Memory;
|
||||||
build_lut(points: number, count: number, channel: number): void;
|
|
||||||
apply(
|
apply(
|
||||||
data: number,
|
data: number,
|
||||||
len: number,
|
len: number,
|
||||||
|
luts: number,
|
||||||
exposureMul: number,
|
exposureMul: number,
|
||||||
contrastK: number,
|
contrastK: number,
|
||||||
highlights: number,
|
highlights: number,
|
||||||
@@ -36,7 +38,7 @@ export function initLightWasm(url = '/light.wasm'): Promise<boolean> {
|
|||||||
if (!loading) {
|
if (!loading) {
|
||||||
loading = (async () => {
|
loading = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url, { cache: 'no-store' });
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
const bytes = await res.arrayBuffer();
|
const bytes = await res.arrayBuffer();
|
||||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||||
@@ -67,17 +69,18 @@ function ensureMemory(bytes: number): WebAssembly.Memory | null {
|
|||||||
return mem;
|
return mem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Записывает точки кривой в память модуля и строит таблицу соответствия. */
|
/** Готовит четыре таблицы соответствия: общая, красный, зелёный, синий. */
|
||||||
function buildLut(mem: WebAssembly.Memory, points: CurvePoint[], channel: number): void {
|
function writeLuts(mem: WebAssembly.Memory, light: LightParams): void {
|
||||||
const ex = exportsRef;
|
const curve = light.curve ?? {};
|
||||||
if (!ex) return;
|
const tables = [
|
||||||
const floats = new Float32Array(points.length * 2);
|
buildCurveLUT(curve.rgb),
|
||||||
for (let i = 0; i < points.length; i++) {
|
buildCurveLUT(curve.red),
|
||||||
floats[i * 2] = points[i].x;
|
buildCurveLUT(curve.green),
|
||||||
floats[i * 2 + 1] = points[i].y;
|
buildCurveLUT(curve.blue)
|
||||||
}
|
];
|
||||||
new Float32Array(mem.buffer, POINTS_OFFSET, floats.length).set(floats);
|
const bytes = new Uint8Array(LUT_LEN * tables.length);
|
||||||
ex.build_lut(POINTS_OFFSET, points.length, channel);
|
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);
|
const mem = ensureMemory(bytes);
|
||||||
if (!mem) return false;
|
if (!mem) return false;
|
||||||
|
|
||||||
const curve = light.curve ?? {};
|
writeLuts(mem, light);
|
||||||
buildLut(mem, curve.rgb ?? [], 0);
|
|
||||||
buildLut(mem, curve.red ?? [], 1);
|
|
||||||
buildLut(mem, curve.green ?? [], 2);
|
|
||||||
buildLut(mem, curve.blue ?? [], 3);
|
|
||||||
|
|
||||||
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
|
||||||
ex.apply(
|
ex.apply(
|
||||||
PIXELS_OFFSET,
|
PIXELS_OFFSET,
|
||||||
data.length,
|
data.length,
|
||||||
|
LUTS_OFFSET,
|
||||||
Math.pow(2, light.exposure),
|
Math.pow(2, light.exposure),
|
||||||
1 + light.contrast / 100,
|
1 + light.contrast / 100,
|
||||||
(light.highlights / 100) * 80,
|
(light.highlights / 100) * 80,
|
||||||
@@ -112,8 +111,33 @@ export function applyLightWasm(data: Uint8ClampedArray, light: LightParams): boo
|
|||||||
return true;
|
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. */
|
/** Применяет правки: сначала WASM, при его недоступности — JS. */
|
||||||
export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
|
export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
|
||||||
if (applyLightWasm(data, light)) return;
|
if (wasmMatchesReference(light) && applyLightWasm(data, light)) return;
|
||||||
applyLightToPixels(data, light);
|
applyLightToPixels(data, light);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,18 @@ async function handleNavigation(request: Request): Promise<Response> {
|
|||||||
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */
|
/** Статика приложения: сначала кеш, затем сеть с дозаписью. */
|
||||||
async function handleAsset(request: Request): Promise<Response> {
|
async function handleAsset(request: Request): Promise<Response> {
|
||||||
const cache = await caches.open(SHELL_CACHE);
|
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);
|
const cached = await cache.match(request);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
|
|||||||
Binary file not shown.
@@ -1,8 +1,12 @@
|
|||||||
//! Фильтры «Свет» для веб-интерфейса: сборка кривых и попиксельная обработка.
|
//! Фильтры «Свет» для веб-интерфейса: попиксельная обработка кадра.
|
||||||
//!
|
//!
|
||||||
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
|
//! Модуль собирается в WebAssembly (`wasm32-unknown-unknown`) и загружается
|
||||||
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
|
//! браузером, чтобы предпросмотр правок в просмотрщике не ждал серверного
|
||||||
//! рендера. Математика совпадает с серверной (backend/internal/photos/edit.go).
|
//! рендера.
|
||||||
|
//!
|
||||||
|
//! Таблицы тональных кривых считает вызывающий код (JavaScript) и передаёт
|
||||||
|
//! сюда готовыми: модуль не хранит состояние и не использует transcendental
|
||||||
|
//! функции, поэтому работает без математической библиотеки.
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
use core::panic::PanicInfo;
|
use core::panic::PanicInfo;
|
||||||
@@ -12,10 +16,11 @@ fn panic(_info: &PanicInfo) -> ! {
|
|||||||
loop {}
|
loop {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Каналы: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
|
/// Количество каналов: 0 — общая кривая, 1 — красный, 2 — зелёный, 3 — синий.
|
||||||
const CHANNELS: usize = 4;
|
const CHANNELS: usize = 4;
|
||||||
|
|
||||||
static mut LUTS: [[u8; 256]; CHANNELS] = [[0; 256]; CHANNELS];
|
/// Длина таблицы соответствия одного канала.
|
||||||
|
const LUT_LEN: usize = 256;
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
fn clamp01(v: f32) -> f32 {
|
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 в памяти.
|
/// Применяет правки «Свет» к пикселям RGBA в памяти.
|
||||||
///
|
///
|
||||||
/// Коэффициенты вычисляет вызывающий код: модуль не использует transcendental
|
/// `luts` — четыре таблицы соответствия по 256 байт (общая, красный, зелёный,
|
||||||
/// функции, поэтому работает без математической библиотеки.
|
/// синий), подготовленные вызывающим кодом.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub extern "C" fn apply(
|
pub extern "C" fn apply(
|
||||||
data: *mut u8,
|
data: *mut u8,
|
||||||
len: u32,
|
len: u32,
|
||||||
|
luts: *const u8,
|
||||||
exposure_mul: f32,
|
exposure_mul: f32,
|
||||||
contrast_k: f32,
|
contrast_k: f32,
|
||||||
highlights: f32,
|
highlights: f32,
|
||||||
@@ -97,53 +61,21 @@ pub extern "C" fn apply(
|
|||||||
whites: f32,
|
whites: f32,
|
||||||
blacks: f32,
|
blacks: f32,
|
||||||
) {
|
) {
|
||||||
unsafe {
|
let tables = unsafe { core::slice::from_raw_parts(luts, CHANNELS * LUT_LEN) };
|
||||||
let luts = &*core::ptr::addr_of!(LUTS);
|
let master = &tables[0..LUT_LEN];
|
||||||
let master = &luts[0];
|
let red = &tables[LUT_LEN..2 * LUT_LEN];
|
||||||
let red = &luts[1];
|
let green = &tables[2 * LUT_LEN..3 * LUT_LEN];
|
||||||
let green = &luts[2];
|
let blue = &tables[3 * LUT_LEN..4 * LUT_LEN];
|
||||||
let blue = &luts[3];
|
let buf = unsafe { core::slice::from_raw_parts_mut(data, len as usize) };
|
||||||
let buf = core::slice::from_raw_parts_mut(data, len as usize);
|
|
||||||
let mut i = 0usize;
|
let mut i = 0usize;
|
||||||
while i + 3 < buf.len() {
|
while i + 3 < buf.len() {
|
||||||
if buf[i + 3] != 0 {
|
if buf[i + 3] != 0 {
|
||||||
buf[i] = adjust(
|
buf[i] = adjust(buf[i] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, red);
|
||||||
buf[i] as f32,
|
buf[i + 1] = adjust(buf[i + 1] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, green);
|
||||||
exposure_mul,
|
buf[i + 2] = adjust(buf[i + 2] as f32, exposure_mul, contrast_k, highlights, shadows, whites, blacks, master, blue);
|
||||||
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;
|
i += 4;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обрабатывает один канал: экспозиция, контраст, света, тени, белые, чёрные
|
/// Обрабатывает один канал: экспозиция, контраст, света, тени, белые, чёрные
|
||||||
@@ -158,8 +90,8 @@ fn adjust(
|
|||||||
shadows: f32,
|
shadows: f32,
|
||||||
whites: f32,
|
whites: f32,
|
||||||
blacks: f32,
|
blacks: f32,
|
||||||
master: &[u8; 256],
|
master: &[u8],
|
||||||
channel: &[u8; 256],
|
channel: &[u8],
|
||||||
) -> u8 {
|
) -> u8 {
|
||||||
let mut v = value * exposure_mul;
|
let mut v = value * exposure_mul;
|
||||||
v = 128.0 + (v - 128.0) * contrast_k;
|
v = 128.0 + (v - 128.0) * contrast_k;
|
||||||
|
|||||||
@@ -802,7 +802,7 @@ SHALL очищаться. При отсутствии сети приложен
|
|||||||
|
|
||||||
#### Scenario: Фильтры считаются в браузере
|
#### Scenario: Фильтры считаются в браузере
|
||||||
- **WHEN** браузер рисует предпросмотр правок
|
- **WHEN** браузер рисует предпросмотр правок
|
||||||
- **THEN** фильтры применяются WASM-модулем, а при его недоступности — JavaScript-реализацией с тем же результатом
|
- **THEN** фильтры применяются WASM-модулем, а если модул недоступен или считает иначе, чем эталонная JS-реализация, — JS-реализацией с тем же результатом
|
||||||
|
|
||||||
#### Scenario: Сброс правок
|
#### Scenario: Сброс правок
|
||||||
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
- **WHEN** пользователь нажимает «Сброс» на вкладке «Редактирование»
|
||||||
|
|||||||
Reference in New Issue
Block a user