feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой

- неразрушающие правки цвета: баланс белого, вибрация/насыщенность,
  цветовой микшер, колористика и поканальная коррекция
- серверный рендер цвета в Go и паритетная реализация в WASM
- живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк
- пипетка баланса белого по нейтральной точке
- новые контролы: слайдеры с градиентами, цветовые колёса,
  двойной клик по контролу сбрасывает значение
- тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
This commit is contained in:
iu
2026-10-23 21:57:48 +03:00
parent c886c6f22c
commit d791b9a572
19 changed files with 3375 additions and 144 deletions
@@ -0,0 +1,87 @@
<script lang="ts">
interface Props {
hue: number;
saturation: number;
label?: string;
disabled?: boolean;
onchange?: (hue: number, saturation: number) => void;
oncommit?: () => void;
}
let { hue, saturation, label, disabled = false, onchange, oncommit }: Props = $props();
let dragging = $state(false);
let el: HTMLDivElement | undefined = $state();
function pick(ev: PointerEvent) {
if (!el || disabled) return;
const rect = el.getBoundingClientRect();
const x = (ev.clientX - rect.left) / rect.width - 0.5;
const y = (ev.clientY - rect.top) / rect.height - 0.5;
const radius = Math.sqrt(x * x + y * y);
const angle = Math.atan2(x, -y);
const nextHue = (((angle * 180) / Math.PI) + 360) % 360;
const nextSaturation = Math.min(1, radius * 2) * 100;
onchange?.(nextHue, nextSaturation);
}
function onDown(ev: PointerEvent) {
if (disabled) return;
ev.preventDefault();
dragging = true;
pick(ev);
(ev.currentTarget as Element).setPointerCapture?.(ev.pointerId);
}
function onMove(ev: PointerEvent) {
if (dragging) pick(ev);
}
function onUp() {
if (!dragging) return;
dragging = false;
oncommit?.();
}
/** Двойной клик сбрасывает колесо в нейтраль. */
function onDoubleClick(ev: MouseEvent) {
ev.preventDefault();
if (disabled) return;
onchange?.(0, 0);
oncommit?.();
}
const knob = $derived({
left:
50 +
Math.sin((hue * Math.PI) / 180) * (Math.min(100, Math.max(0, saturation)) / 100) * 50,
top:
50 -
Math.cos((hue * Math.PI) / 180) * (Math.min(100, Math.max(0, saturation)) / 100) * 50
});
</script>
<div class="flex flex-col items-center gap-1">
<div
bind:this={el}
class="relative aspect-square w-full max-w-[88px] touch-none rounded-full"
style="background: radial-gradient(circle, #d9d9d9 0%, rgba(217,217,217,0) 55%), conic-gradient(from 0deg, red, yellow, lime, cyan, blue, magenta, red);"
role="slider"
tabindex="0"
aria-label={label ?? 'Цвет'}
aria-valuemin="0"
aria-valuemax="360"
aria-valuenow={Math.round(hue)}
onpointerdown={onDown}
onpointermove={onMove}
onpointerup={onUp}
onpointerleave={onUp}
ondblclick={onDoubleClick}
>
<span
class="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow"
style="left: {knob.left}%; top: {knob.top}%;"
></span>
</div>
{#if label}<span class="text-text-muted text-[10px]">{label}</span>{/if}
</div>
@@ -0,0 +1,109 @@
<script lang="ts">
interface Props {
id?: string;
label: string;
value: number;
min: number;
max: number;
step: number;
disabled?: boolean;
/** Полный градиент дорожки (например, для цветовых ползунков). */
gradient?: string;
/** Значение при двойном клике. */
resetValue?: number;
format?: (value: number) => string;
oninput?: (value: number) => void;
onchange?: () => void;
}
let {
id,
label,
value,
min,
max,
step,
disabled = false,
gradient,
resetValue = 0,
format,
oninput,
onchange
}: Props = $props();
const percent = $derived(((value - min) / (max - min)) * 100);
const trackStyle = $derived(
gradient
? `background: ${gradient};`
: `background: linear-gradient(to right, #cfcfcf 0%, #cfcfcf ${percent}%, #4a4a4a ${percent}%, #4a4a4a 100%);`
);
const display = $derived(format ? format(value) : String(value));
function handleInput(event: Event) {
oninput?.(Number((event.currentTarget as HTMLInputElement).value));
}
function handleDoubleClick(event: MouseEvent) {
event.preventDefault();
if (disabled) return;
oninput?.(resetValue);
onchange?.();
}
</script>
<div class="space-y-1">
<div class="flex items-baseline justify-between gap-2">
<label class="text-text text-xs" for={id}>{label}</label>
<span class="text-text-muted text-xs tabular-nums">{display}</span>
</div>
<input
{id}
type="range"
class="edit-slider"
style={trackStyle}
{min}
{max}
{step}
{disabled}
{value}
oninput={handleInput}
onchange={onchange}
ondblclick={handleDoubleClick}
/>
</div>
<style>
.edit-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 2px;
border-radius: 999px;
outline: none;
cursor: pointer;
}
.edit-slider:disabled {
cursor: default;
opacity: 0.4;
}
.edit-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: #f3f3f3;
border: 1px solid #9a9a9a;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25);
cursor: pointer;
}
.edit-slider::-moz-range-thumb {
width: 15px;
height: 15px;
border-radius: 50%;
background: #f3f3f3;
border: 1px solid #9a9a9a;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25);
cursor: pointer;
}
</style>
+646 -78
View File
@@ -1,37 +1,58 @@
<script lang="ts">
import { untrack } from 'svelte';
import { photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import EditSlider from './EditSlider.svelte';
import ColorWheel from './ColorWheel.svelte';
import { edit } from '$lib/stores/edit.svelte';
import { applyLight } from '$lib/light-wasm';
import { eyedropper } from '$lib/stores/eyedropper.svelte';
import { applyColor, applyLight } from '$lib/light-wasm';
import {
CHANNEL_COLORS,
CHANNEL_LABELS,
COLOR_SLIDERS,
CURVE_CHANNELS,
GRADING_WHEELS,
LIGHT_SLIDERS,
MIXER_BANDS,
emptyEditParams,
isEmptyEditParams,
normalizeEditParams,
readEditParams,
whiteBalanceFromRGB,
type ColorSliderKey,
type CurveChannel,
type CurvePoint,
type EditParams
type EditParams,
type GradingWheelKey,
type MixerBand,
type MixerBandKey
} from '$lib/edit';
import type { Photo } from '$lib/types';
interface Props {
photo: Photo;
canEdit: boolean;
viewer?: boolean;
onUpdated: (photo: Photo) => void;
}
let { photo, canEdit, onUpdated }: Props = $props();
let { photo, canEdit, viewer = false, onUpdated }: Props = $props();
let params = $state<EditParams>(emptyEditParams());
let busy = $state(false);
let error = $state('');
let collapsed = $state(false);
let curveCollapsed = $state(true);
let curveCollapsed = $state(false);
let channel = $state<CurveChannel>('rgb');
let colorCollapsed = $state(false);
let mixerCollapsed = $state(false);
let mixerTab = $state<keyof MixerBand>('hue');
let mixerBand = $state<MixerBandKey>('red');
let pointColorCollapsed = $state(false);
let correctionCollapsed = $state(false);
let correctionView = $state<'all' | 'midtones' | 'split' | 'highlights'>('all');
let wbPreset = $state('as-shot');
const saved = $derived(readEditParams(photo));
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
@@ -46,6 +67,7 @@
const id = photo.id;
if (id === lastPhotoId) return;
lastPhotoId = id;
eyedropper.stop();
params = readEditParams(photo);
edit.set(id, params);
error = '';
@@ -55,6 +77,26 @@
};
});
// Пипетка баланса белого: просмотрщик присылает цвет нейтральной точки.
let lastSampleSeq = 0;
$effect(() => {
const sample = eyedropper.sample;
if (!sample || sample.photoId !== photo.id || sample.seq === lastSampleSeq) return;
lastSampleSeq = sample.seq;
const wb = whiteBalanceFromRGB(sample.r, sample.g, sample.b);
const base = untrack(() => params);
const next = normalizeEditParams({
light: base.light,
color: { ...base.color, temperature: wb.temperature, tint: wb.tint }
});
untrack(() => {
params = next;
edit.set(photo.id, next);
wbPreset = 'custom';
scheduleSave();
});
});
// Гистограмма каналов считается по базовому превью фото и пересчитывается
// с учётом текущих правок, поэтому реагирует на движение ползунков и кривой.
type Histogram = { red: number[]; green: number[]; blue: number[] };
@@ -97,6 +139,7 @@
if (!pixels) return null;
const data = new Uint8ClampedArray(pixels);
applyLight(data, params.light);
applyColor(data, params.color);
const r = new Array<number>(256).fill(0);
const g = new Array<number>(256).fill(0);
const b = new Array<number>(256).fill(0);
@@ -202,6 +245,174 @@
scheduleSave();
}
const MIXER_COMPONENTS: { key: keyof MixerBand; label: string }[] = [
{ key: 'hue', label: 'Тон' },
{ key: 'saturation', label: 'Насыщенность' },
{ key: 'luminance', label: 'Яркость' }
];
const MIXER_GENITIVE: Record<MixerBandKey, string> = {
red: 'красного',
orange: 'оранжевого',
yellow: 'жёлтого',
green: 'зелёного',
aqua: 'бирюзового',
blue: 'синего',
purple: 'фиолетового',
magenta: 'пурпурного'
};
const CORRECTION_VIEWS = [
{ key: 'all' as const, icon: 'blur_on' as const, label: 'Все зоны' },
{ key: 'midtones' as const, icon: 'circle' as const, label: 'Средние тона' },
{ key: 'split' as const, icon: 'contrast' as const, label: 'Тени и света' },
{ key: 'highlights' as const, icon: 'light_mode' as const, label: 'Света' }
];
const WB_PRESETS = [
{ value: 'as-shot', label: 'Как снято', temperature: 0, tint: 0 },
{ value: 'auto', label: 'Авто', temperature: 0, tint: 0 },
{ value: 'daylight', label: 'Дневной свет', temperature: 5, tint: -5 },
{ value: 'cloudy', label: 'Облачно', temperature: 20, tint: 0 },
{ value: 'shade', label: 'Тень', temperature: 40, tint: 5 },
{ value: 'tungsten', label: 'Лампа накаливания', temperature: -40, tint: 10 },
{ value: 'fluorescent', label: 'Флуоресцентная', temperature: -20, tint: 15 }
];
const correctionWheels = $derived<GradingWheelKey[]>(
correctionView === 'all'
? ['midtones', 'shadows', 'highlights']
: correctionView === 'midtones'
? ['midtones']
: correctionView === 'split'
? ['shadows', 'highlights']
: ['highlights']
);
const selectedBand = $derived(
MIXER_BANDS.find((band) => band.key === mixerBand) ?? MIXER_BANDS[0]
);
function onColorSliderInput(key: ColorSliderKey, value: number) {
if (key === 'temperature' || key === 'tint') wbPreset = 'custom';
params = { ...params, color: { ...params.color, [key]: value } };
edit.set(photo.id, params);
}
function onColorSliderCommit() {
if (!canEdit) return;
params = normalizeEditParams(params);
edit.set(photo.id, params);
scheduleSave();
}
function onMixerInput(band: MixerBandKey, component: keyof MixerBand, value: number) {
const current = params.color.mixer[band];
params = {
...params,
color: {
...params.color,
mixer: {
...params.color.mixer,
[band]: { ...current, [component]: value }
}
}
};
edit.set(photo.id, params);
}
function onWheelInput(wheel: GradingWheelKey, hue: number, saturation: number) {
const nextHue = Number.isFinite(hue) ? Math.min(360, Math.max(0, hue)) : 0;
const nextSaturation = Number.isFinite(saturation)
? Math.min(100, Math.max(0, saturation))
: 0;
params = {
...params,
color: {
...params.color,
grading: {
...params.color.grading,
[wheel]: {
...params.color.grading[wheel],
hue: nextHue,
saturation: nextSaturation
}
}
}
};
edit.set(photo.id, params);
}
function onGradingInput(key: 'blending' | 'balance', value: number) {
params = {
...params,
color: {
...params.color,
grading: { ...params.color.grading, [key]: value }
}
};
edit.set(photo.id, params);
}
function onWheelLuminanceInput(wheel: GradingWheelKey, value: number) {
params = {
...params,
color: {
...params.color,
grading: {
...params.color.grading,
[wheel]: { ...params.color.grading[wheel], luminance: value }
}
}
};
edit.set(photo.id, params);
}
function applyWbPreset(value: string) {
wbPreset = value;
if (value === 'custom') return;
const preset = WB_PRESETS.find((item) => item.value === value) ?? WB_PRESETS[0];
params = {
...params,
color: { ...params.color, temperature: preset.temperature, tint: preset.tint }
};
edit.set(photo.id, params);
scheduleSave();
}
function bandColor(center: number): string {
return `hsl(${center}, 85%, 50%)`;
}
function hueGradient(center: number): string {
return `linear-gradient(90deg, hsl(${center - 60}, 85%, 50%), hsl(${center}, 85%, 50%), hsl(${center + 60}, 85%, 50%))`;
}
function saturationGradient(center: number): string {
return `linear-gradient(90deg, hsl(${center}, 0%, 50%), hsl(${center}, 100%, 50%))`;
}
function luminanceGradient(center: number): string {
return `linear-gradient(90deg, hsl(${center}, 85%, 8%), hsl(${center}, 85%, 65%))`;
}
function colorGradient(key: ColorSliderKey): string {
switch (key) {
case 'temperature':
return 'linear-gradient(90deg, #3b6fd4, #a3b83a)';
case 'tint':
return 'linear-gradient(90deg, #3fa34d, #b23bb0)';
case 'vibrance':
return 'linear-gradient(90deg, #6b7280, #e0526b)';
case 'saturation':
return 'linear-gradient(90deg, #6b7280, #e03131)';
}
}
function formatExposure(value: number): string {
return value.toFixed(2);
}
function setChannel(next: CurveChannel) {
channel = next;
}
@@ -234,7 +445,7 @@
(ev.currentTarget as Element).setPointerCapture?.(ev.pointerId);
}
/** Клик по полю кривой добавляет точку. */
/** Клик по полю кривой добавляет точку, двойной клик — сбрасывает кривую. */
function onCurveDown(ev: PointerEvent) {
if (!canEdit) return;
const target = ev.target as Element | null;
@@ -339,9 +550,6 @@
await resetEdit(photo.id);
}
const sliderClass = 'accent-primary h-1 w-full cursor-pointer';
const valueClass = 'text-text-muted text-xs tabular-nums';
function formatValue(value: number): string {
const rounded = Math.round(value * 10) / 10;
if (rounded === 0) return '0';
@@ -377,66 +585,78 @@
{#if !collapsed}
<div class="mt-3 space-y-4">
<div class="space-y-3">
<div class="space-y-2.5">
{#each LIGHT_SLIDERS as slider (slider.key)}
<div>
<div class="flex items-baseline justify-between gap-2">
<label class="text-text text-xs" for="light-{slider.key}">{slider.label}</label>
<span class={valueClass}>{formatValue(params.light[slider.key])}</span>
</div>
<input
id="light-{slider.key}"
type="range"
class={sliderClass}
min={slider.min}
max={slider.max}
step={slider.step}
value={params.light[slider.key]}
disabled={!canEdit}
oninput={(e) => onSliderInput(slider.key, Number(e.currentTarget.value))}
onchange={onSliderCommit}
/>
</div>
<EditSlider
id="light-{slider.key}"
label={slider.key === 'contrast' ? 'Контрастность' : slider.label}
value={params.light[slider.key]}
min={slider.min}
max={slider.max}
step={slider.step}
disabled={!canEdit}
format={slider.key === 'exposure' ? formatExposure : formatValue}
oninput={(value) => onSliderInput(slider.key, value)}
onchange={onSliderCommit}
/>
{/each}
</div>
<div class="border-border border-t pt-3">
<div class="flex items-center justify-between gap-2">
<button
type="button"
class="text-text hover:text-primary flex items-center gap-1 text-xs font-semibold"
aria-expanded={!curveCollapsed}
aria-controls="light-curve"
onclick={() => (curveCollapsed = !curveCollapsed)}
>
<Icon
name="expand_more"
class="size-4 transition-transform {curveCollapsed ? '-rotate-90' : ''}"
/>
<button
type="button"
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
aria-expanded={!curveCollapsed}
aria-controls="light-curve"
onclick={() => (curveCollapsed = !curveCollapsed)}
>
<span class="flex items-center gap-1.5">
<Icon name="show_chart" class="size-4" />
Кривая
</button>
<div class="flex items-center gap-1.5">
{#each CURVE_CHANNELS as ch (ch)}
<button
type="button"
class="size-4 rounded-full border-2 disabled:opacity-40"
style="border-color: {CHANNEL_COLORS[ch]}; background: {ch === channel
? CHANNEL_COLORS[ch]
: 'transparent'}"
aria-label="Канал {CHANNEL_LABELS[ch]}"
title="Канал {CHANNEL_LABELS[ch]}"
aria-pressed={ch === channel}
disabled={!canEdit}
onclick={() => setChannel(ch)}
></button>
{/each}
</div>
</div>
</span>
<Icon
name="expand_more"
class="size-4 transition-transform {curveCollapsed ? '-rotate-90' : ''}"
/>
</button>
{#if !curveCollapsed}
<div id="light-curve">
<div id="light-curve" class="mt-2">
<div class="flex items-center gap-2">
{#each CURVE_CHANNELS as ch (ch)}
{#if ch === 'rgb'}
<button
type="button"
class="size-4 border-2 disabled:opacity-40"
style="border-color: #d4d4d4; background: {channel === ch
? '#d4d4d4'
: 'transparent'}"
aria-label="Канал RGB"
title="Канал RGB"
aria-pressed={channel === ch}
disabled={!canEdit}
onclick={() => setChannel(ch)}
></button>
{:else}
<button
type="button"
class="size-4 rounded-full border-2 disabled:opacity-40"
style="border-color: {CHANNEL_COLORS[ch]}; background: {channel === ch
? CHANNEL_COLORS[ch]
: 'transparent'}"
aria-label="Канал {CHANNEL_LABELS[ch]}"
title="Канал {CHANNEL_LABELS[ch]}"
aria-pressed={channel === ch}
disabled={!canEdit}
onclick={() => setChannel(ch)}
></button>
{/if}
{/each}
<Icon name="colorize" class="text-text-muted ml-auto size-4" />
</div>
<svg
class="border-border mt-2 block aspect-square w-full touch-none rounded border"
class="border-border bg-bg mt-2 block aspect-square w-full touch-none rounded border"
viewBox="0 0 100 100"
role="application"
aria-label="Тоновая кривая"
@@ -444,6 +664,10 @@
onpointermove={onPointMove}
onpointerup={onPointUp}
onpointerleave={onPointUp}
ondblclick={(e) => {
e.preventDefault();
resetCurve();
}}
>
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
{#if histogram}
@@ -452,7 +676,10 @@
d={histogramPath(histogramValues(ch))}
fill="none"
stroke-width="0.4"
style="stroke: {CHANNEL_COLORS[ch]}; opacity: {ch === 'rgb' ? 0.45 : 0.7}"
style="stroke: {channel === 'rgb' ? '#6b7280' : CHANNEL_COLORS[ch]}; opacity: {channel ===
'rgb'
? 0.45
: 0.7}"
/>
{/each}
{/if}
@@ -463,9 +690,11 @@
<path
d={curvePath(channel)}
fill="none"
stroke-width="1.4"
style="stroke: {CHANNEL_COLORS[channel]}"
stroke-width="1.2"
style="stroke: {channel === 'rgb' ? '#e5e5e5' : CHANNEL_COLORS[channel]}"
/>
<circle cx="0" cy="100" r="1.8" fill="#e5e5e5" />
<circle cx="100" cy="0" r="1.8" fill="#e5e5e5" />
{#each channelPoints(channel) as point, index (index)}
<circle
cx={point.x * 100}
@@ -473,7 +702,9 @@
r="2.4"
class="cursor-grab"
stroke-width="1.2"
style="fill: hsl(var(--bg)); stroke: {CHANNEL_COLORS[channel]}"
style="fill: hsl(var(--bg)); stroke: {channel === 'rgb'
? '#e5e5e5'
: CHANNEL_COLORS[channel]}"
role="slider"
tabindex="0"
aria-label="Точка кривой {index + 1}"
@@ -487,32 +718,369 @@
</svg>
<div class="mt-2 flex items-center justify-between gap-2">
<span class="text-text-muted text-xs">Точечная кривая · линейная</span>
<div class="flex items-center gap-2">
<span class="text-text-muted text-xs">Точечная кривая</span>
<span class="text-text-muted flex items-center gap-0.5 text-xs"
>Линейный<Icon name="expand_more" class="size-3" /></span
>
</div>
<button
type="button"
class="border-border text-text-muted hover:text-text rounded-md border px-2 py-1 text-xs disabled:opacity-40"
disabled={!canEdit || !channelPoints(channel).length}
onclick={resetCurve}
>
Сбросить кривую
Сбросить
</button>
</div>
</div>
{/if}
</div>
{#if busy}
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
{:else if dirty && !busy}
<p class="text-text-subtle text-xs">
Правки видны сразу, на сервер уйдут через 10 секунд без изменений
</p>
{:else if rendering}
<p class="text-text-subtle text-xs">Рендерим правки на сервере…</p>
{/if}
{#if error}
<p class="text-danger text-xs">{error}</p>
{/if}
</div>
{/if}
<div class="border-border border-t pt-3">
<div class="flex items-center justify-between gap-2">
<button
type="button"
class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold"
aria-expanded={!colorCollapsed}
aria-controls="color-panel"
onclick={() => (colorCollapsed = !colorCollapsed)}
>
<Icon
name="expand_more"
class="size-4 transition-transform {colorCollapsed ? '-rotate-90' : ''}"
/>
Цвет
</button>
<span class="text-text-muted" title="Показать цвет">
<Icon name="visibility" class="size-4" />
</span>
</div>
{#if !colorCollapsed}
<div id="color-panel" class="mt-3 space-y-4">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span class="text-text-muted text-xs">Баланс белого</span>
<select
class="text-text-muted bg-transparent text-xs outline-none"
value={wbPreset}
disabled={!canEdit}
onchange={(e) => applyWbPreset(e.currentTarget.value)}
>
{#each WB_PRESETS as preset (preset.value)}
<option value={preset.value}>{preset.label}</option>
{/each}
<option value="custom">Пользовательский</option>
</select>
</div>
<button
type="button"
class="hover:text-text disabled:opacity-40 {eyedropper.mode === 'wb' &&
eyedropper.photoId === photo.id
? 'text-primary'
: 'text-text-muted'}"
title={viewer
? 'Баланс белого по нейтральной точке'
: 'Откройте фото в просмотрщике'}
aria-pressed={eyedropper.mode === 'wb' && eyedropper.photoId === photo.id}
disabled={!canEdit || !viewer}
onclick={() => eyedropper.start('wb', photo.id)}
>
<Icon name="colorize" class="size-4" />
</button>
</div>
{#if eyedropper.mode === 'wb' && eyedropper.photoId === photo.id}
<p class="text-text-subtle text-xs">
Кликните по нейтральному участку на фото.
</p>
{/if}
<div class="space-y-2.5">
{#each COLOR_SLIDERS as slider (slider.key)}
<EditSlider
id="color-{slider.key}"
label={slider.label}
value={params.color[slider.key]}
min={slider.min}
max={slider.max}
step={slider.step}
disabled={!canEdit}
gradient={colorGradient(slider.key)}
format={formatValue}
oninput={(value) => onColorSliderInput(slider.key, value)}
onchange={onColorSliderCommit}
/>
{/each}
</div>
<div class="border-border border-t pt-3">
<button
type="button"
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
aria-expanded={!mixerCollapsed}
aria-controls="color-mixer"
onclick={() => (mixerCollapsed = !mixerCollapsed)}
>
<span class="flex items-center gap-1.5">
<Icon name="tune" class="size-4" />
Цветовой микшер
</span>
<Icon
name="expand_more"
class="size-4 transition-transform {mixerCollapsed ? '-rotate-90' : ''}"
/>
</button>
{#if !mixerCollapsed}
<div id="color-mixer" class="mt-2">
<div class="flex items-center justify-between gap-1">
{#each MIXER_BANDS as band (band.key)}
<button
type="button"
class="size-6 rounded-full border-2 disabled:opacity-40"
style="background: {bandColor(band.center)}; border-color: {mixerBand ===
band.key
? '#ffffff'
: 'transparent'}"
aria-label={band.label}
title={band.label}
aria-pressed={mixerBand === band.key}
disabled={!canEdit}
onclick={() => (mixerBand = band.key)}
></button>
{/each}
</div>
<div class="mt-3 flex items-center gap-1">
<Icon name="tune" class="text-text-muted size-4" />
{#each MIXER_COMPONENTS as component (component.key)}
<button
type="button"
class="rounded-md px-2 py-1 text-xs {mixerTab === component.key
? 'bg-bg-muted text-text'
: 'text-text-muted hover:text-text'}"
aria-pressed={mixerTab === component.key}
disabled={!canEdit}
onclick={() => (mixerTab = component.key)}
>
{component.label}
</button>
{/each}
</div>
<div class="mt-3 space-y-2.5">
<EditSlider
id="mixer-hue"
label="Тон {MIXER_GENITIVE[mixerBand]}"
value={params.color.mixer[mixerBand].hue}
min={-100}
max={100}
step={1}
disabled={!canEdit}
gradient={hueGradient(selectedBand.center)}
format={formatValue}
oninput={(value) => onMixerInput(mixerBand, 'hue', value)}
onchange={onColorSliderCommit}
/>
<EditSlider
id="mixer-saturation"
label="Насыщенность {MIXER_GENITIVE[mixerBand]}"
value={params.color.mixer[mixerBand].saturation}
min={-100}
max={100}
step={1}
disabled={!canEdit}
gradient={saturationGradient(selectedBand.center)}
format={formatValue}
oninput={(value) => onMixerInput(mixerBand, 'saturation', value)}
onchange={onColorSliderCommit}
/>
<EditSlider
id="mixer-luminance"
label="Яркость {MIXER_GENITIVE[mixerBand]}"
value={params.color.mixer[mixerBand].luminance}
min={-100}
max={100}
step={1}
disabled={!canEdit}
gradient={luminanceGradient(selectedBand.center)}
format={formatValue}
oninput={(value) => onMixerInput(mixerBand, 'luminance', value)}
onchange={onColorSliderCommit}
/>
</div>
</div>
{/if}
</div>
<div class="border-border border-t pt-3">
<button
type="button"
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
aria-expanded={!pointColorCollapsed}
aria-controls="color-point"
onclick={() => (pointColorCollapsed = !pointColorCollapsed)}
>
<span class="flex items-center gap-1.5">
<Icon name="colorize" class="size-4" />
Точечный цвет
</span>
<Icon
name="expand_more"
class="size-4 transition-transform {pointColorCollapsed ? '-rotate-90' : ''}"
/>
</button>
{#if !pointColorCollapsed}
<div id="color-point" class="text-text-muted mt-2 flex items-start gap-2 text-xs">
<Icon name="colorize" class="mt-0.5 size-4 shrink-0" />
<span>Выберите цвет на фото пипеткой.</span>
</div>
{/if}
</div>
<div class="border-border border-t pt-3">
<button
type="button"
class="text-text hover:text-primary flex w-full items-center justify-between gap-1 text-xs font-semibold"
aria-expanded={!correctionCollapsed}
aria-controls="color-correction"
onclick={() => (correctionCollapsed = !correctionCollapsed)}
>
<span class="flex items-center gap-1.5">
<Icon name="blur_on" class="size-4" />
Цветокоррекция
</span>
<Icon
name="expand_more"
class="size-4 transition-transform {correctionCollapsed ? '-rotate-90' : ''}"
/>
</button>
{#if !correctionCollapsed}
<div id="color-correction" class="mt-2 space-y-4">
<div class="flex items-center gap-1">
{#each CORRECTION_VIEWS as view (view.key)}
<button
type="button"
class="rounded-md p-1 disabled:opacity-40 {correctionView === view.key
? 'bg-bg-muted text-text'
: 'text-text-muted hover:text-text'}"
aria-label={view.label}
title={view.label}
aria-pressed={correctionView === view.key}
disabled={!canEdit}
onclick={() => (correctionView = view.key)}
>
<Icon name={view.icon} class="size-4" />
</button>
{/each}
</div>
<div
class="grid gap-3 {correctionWheels.length === 1
? 'grid-cols-1'
: 'grid-cols-2'}"
>
{#each correctionWheels as wheel (wheel)}
<div
class="flex justify-center {wheel === 'midtones' &&
correctionView === 'all'
? 'col-span-2'
: ''}"
>
<div class="w-full max-w-[88px]">
<ColorWheel
hue={params.color.grading[wheel].hue}
saturation={params.color.grading[wheel].saturation}
label={GRADING_WHEELS.find((item) => item.key === wheel)?.label}
disabled={!canEdit}
onchange={(hue, saturation) => onWheelInput(wheel, hue, saturation)}
oncommit={onColorSliderCommit}
/>
</div>
</div>
{/each}
</div>
<div class="space-y-2.5">
<EditSlider
id="correction-shadows"
label="Яркость теней"
value={params.color.grading.shadows.luminance}
min={-100}
max={100}
step={1}
disabled={!canEdit}
format={formatValue}
oninput={(value) => onWheelLuminanceInput('shadows', value)}
onchange={onColorSliderCommit}
/>
<EditSlider
id="correction-midtones"
label="Яркость средних тонов"
value={params.color.grading.midtones.luminance}
min={-100}
max={100}
step={1}
disabled={!canEdit}
format={formatValue}
oninput={(value) => onWheelLuminanceInput('midtones', value)}
onchange={onColorSliderCommit}
/>
<EditSlider
id="correction-highlights"
label="Яркость светов"
value={params.color.grading.highlights.luminance}
min={-100}
max={100}
step={1}
disabled={!canEdit}
format={formatValue}
oninput={(value) => onWheelLuminanceInput('highlights', value)}
onchange={onColorSliderCommit}
/>
<EditSlider
id="grading-blending"
label="Наложение"
value={params.color.grading.blending}
min={0}
max={100}
step={1}
resetValue={50}
disabled={!canEdit}
format={formatValue}
oninput={(value) => onGradingInput('blending', value)}
onchange={onColorSliderCommit}
/>
<EditSlider
id="grading-balance"
label="Баланс"
value={params.color.grading.balance}
min={-100}
max={100}
step={1}
disabled={!canEdit}
format={formatValue}
oninput={(value) => onGradingInput('balance', value)}
onchange={onColorSliderCommit}
/>
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
{#if busy}
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
{:else if dirty && !busy}
<p class="text-text-subtle text-xs">
Правки видны сразу, на сервер уйдут через 10 секунд без изменений
</p>
{:else if rendering}
<p class="text-text-subtle text-xs">Рендерим правки на сервере…</p>
{/if}
{#if error}
<p class="text-danger text-xs">{error}</p>
{/if}
</div>
@@ -255,7 +255,12 @@ const tabClass =
{#if tab === 'edit'}
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
<PhotoEditPanel {photo} canEdit={canEditPhoto} onUpdated={(updated) => onRenamed?.(updated)} />
<PhotoEditPanel
{photo}
canEdit={canEditPhoto}
viewer={viewer}
onUpdated={(updated) => onRenamed?.(updated)}
/>
</div>
{:else}
<div id="photo-panel-info" role="tabpanel" aria-labelledby="photo-tab-info">
+203 -42
View File
@@ -10,9 +10,13 @@
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { viewer } from '$lib/stores/viewer.svelte';
import { edit } from '$lib/stores/edit.svelte';
import { applyLight, initLightWasm } from '$lib/light-wasm';
import { eyedropper } from '$lib/stores/eyedropper.svelte';
import { applyColor, applyLight, initLightWasm } from '$lib/light-wasm';
import { WebGLPreview } from '$lib/webgl-preview';
import {
isEmptyColor,
isEmptyEditParams,
isEmptyLight,
photoFullUrl,
photoPreviewUrl,
readEditParams
@@ -30,10 +34,15 @@
let fresh = $state<Photo | null>(null);
let touchX = 0;
let previewLow = $state(false);
let useGL = $state(false);
const current = $derived(photos[index]);
const MAX_ZOOM = 2;
// В зуме фильтруем уменьшенную копию вьюпорта: предпросмотр остаётся
// отзывчивым, а финальное качество даёт серверный рендер.
const PREVIEW_MAX = 560;
// Шаг зума клавишами `+`/`-`.
const ZOOM_STEP = 1.25;
@@ -97,7 +106,15 @@
// Пока браузер рисует правки сам, под ним лежит исходная версия фото.
const clientSource = $derived(
zoomed ? originalSrc || basePreviewSrc : basePreviewSrc
useGL
? zoomed
? originalSrc || basePreviewSrc
: basePreviewSrc
: previewLow
? basePreviewSrc
: zoomed
? originalSrc || basePreviewSrc
: basePreviewSrc
);
const displaySrc = $derived(
clientPreview
@@ -152,8 +169,9 @@
});
// In zoom mode, preload the full-resolution original and swap it in once loaded.
// Во время правок full-res не грузим: превью рисуется из base preview.
$effect(() => {
if (!zoomed || hiResLoaded) return;
if (!zoomed || hiResLoaded || clientPreview) return;
const url = fullSrc;
if (!url) return;
const im = new Image();
@@ -179,62 +197,134 @@
// Пока сервер не отрендерил отредактированную версию, видимую область
// фото рисует сам браузер: правки видны сразу, без ожидания рендера.
let canvas: HTMLCanvasElement | undefined = $state();
let glCanvas: HTMLCanvasElement | undefined = $state();
let glPreview: WebGLPreview | null = null;
let viewportFrame: number | null = null;
$effect(() => {
const el = glCanvas;
if (!el) return;
const preview = WebGLPreview.create(el);
glPreview = preview;
useGL = !!preview;
return () => {
preview?.dispose();
glPreview = null;
useGL = false;
};
});
// Пока пользователь активно крутит контролы, фильтруем уменьшенный буфер,
// а после короткой паузы — полный кадр. Иначе тяжёлый фильтр блокирует UI.
let settleTimer: ReturnType<typeof setTimeout> | null = null;
function markInteracting() {
previewLow = true;
if (settleTimer) clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
settleTimer = null;
previewLow = false;
}, 180);
}
$effect(() => () => {
if (settleTimer) {
clearTimeout(settleTimer);
settleTimer = null;
}
});
/**
* Рисует видимую область фото с применёнными правками. Работает и в зуме,
* и в обычном виде: берём ту часть картинки, что попала во вьюпорт.
* Рисует видимую область фото с применёнными правками. Сначала пробует
* WebGL (кадр считает GPU), затем CPU-фолбэк.
*/
function renderViewport() {
const c = canvas;
const img = imgEl;
const el = scroller;
if (!c || !img || !el) return;
if (!img || !el) return;
const natural = img.naturalWidth;
if (!natural) return;
const imgRect = img.getBoundingClientRect();
const viewRect = el.getBoundingClientRect();
if (!imgRect.width || !viewRect.width) return;
const ratio = imgRect.width / natural;
// Видимая часть картинки.
const left = Math.max(imgRect.left, viewRect.left);
const top = Math.max(imgRect.top, viewRect.top);
const right = Math.min(imgRect.right, viewRect.right);
const bottom = Math.min(imgRect.bottom, viewRect.bottom);
const visibleW = right - left;
const visibleH = bottom - top;
if (visibleW <= 0 || visibleH <= 0) return;
const width = el.clientWidth;
const height = el.clientHeight;
if (!width || !height) return;
if (c.width !== width || c.height !== height) {
c.width = width;
c.height = height;
}
c.style.width = `${width}px`;
c.style.height = `${height}px`;
// Кусок изображения и его место на холсте.
let crop: { x: number; y: number; width: number; height: number };
let dest: { x: number; y: number; width: number; height: number };
if (!zoomed) {
crop = { x: 0, y: 0, width: natural, height: img.naturalHeight };
dest = {
x: imgRect.left - viewRect.left,
y: imgRect.top - viewRect.top,
width: imgRect.width,
height: imgRect.height
};
} else {
const ratio = imgRect.width / natural;
const left = Math.max(imgRect.left, viewRect.left);
const top = Math.max(imgRect.top, viewRect.top);
const right = Math.min(imgRect.right, viewRect.right);
const bottom = Math.min(imgRect.bottom, viewRect.bottom);
const visibleW = right - left;
const visibleH = bottom - top;
if (visibleW <= 0 || visibleH <= 0) return;
crop = {
x: (left - imgRect.left) / ratio,
y: (top - imgRect.top) / ratio,
width: visibleW / ratio,
height: visibleH / ratio
};
dest = {
x: left - viewRect.left,
y: top - viewRect.top,
width: visibleW,
height: visibleH
};
}
// GPU-путь: фильтр целиком на видеокарте, main thread свободен.
if (useGL && glPreview) {
const ok = glPreview.render({
image: img,
imageVersion: `${img.currentSrc || img.src}|${natural}|${img.naturalHeight}`,
imageWidth: natural,
imageHeight: img.naturalHeight,
crop,
viewport: { width, height },
dest,
light: liveParams.light,
color: liveParams.color
});
if (ok) return;
useGL = false;
}
// CPU-фолбэк (если WebGL недоступен).
const c = canvas;
if (!c) return;
const ctx = c.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
// Исходный кусок в пикселях картинки и его место на холсте.
const sx = (left - imgRect.left) / ratio;
const sy = (top - imgRect.top) / ratio;
const sw = visibleW / ratio;
const sh = visibleH / ratio;
const dx = Math.round(left - viewRect.left);
const dy = Math.round(top - viewRect.top);
const dw = Math.round(visibleW);
const dh = Math.round(visibleH);
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
// Фильтруем весь холст: так прошлый кадр не смешивается с новым.
const frame = ctx.getImageData(0, 0, width, height);
applyLight(frame.data, liveParams.light);
const lowPx = isEmptyLight(liveParams.light) ? 900 : PREVIEW_MAX;
const maxPx = previewLow ? lowPx : 4096;
const k = Math.min(1, maxPx / Math.max(dest.width, dest.height));
const bw = Math.max(1, Math.round(dest.width * k));
const bh = Math.max(1, Math.round(dest.height * k));
if (c.width !== bw || c.height !== bh) {
c.width = bw;
c.height = bh;
}
c.style.left = `${Math.round(dest.x)}px`;
c.style.top = `${Math.round(dest.y)}px`;
c.style.width = `${Math.round(dest.width)}px`;
c.style.height = `${Math.round(dest.height)}px`;
ctx.clearRect(0, 0, bw, bh);
ctx.drawImage(img, crop.x, crop.y, crop.width, crop.height, 0, 0, bw, bh);
const frame = ctx.getImageData(0, 0, bw, bh);
if (!isEmptyLight(liveParams.light)) applyLight(frame.data, liveParams.light);
if (!isEmptyColor(liveParams.color)) applyColor(frame.data, liveParams.color);
ctx.putImageData(frame, 0, 0);
}
@@ -252,10 +342,22 @@
zoomed;
scale;
liveParams.light;
liveParams.color;
displaySrc;
previewLow;
useGL;
scheduleViewport();
});
// Любое изменение параметров — это активное взаимодействие.
let lastLiveParams: typeof liveParams | null = null;
$effect(() => {
const p = liveParams;
if (p === lastLiveParams) return;
lastLiveParams = p;
markInteracting();
});
$effect(() => {
const onFs = () => {
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
@@ -441,6 +543,7 @@
if (!current || !scroller) return;
ev.preventDefault();
ev.stopPropagation();
markInteracting();
const natW = baseWidth();
const natH = baseHeight();
@@ -456,6 +559,7 @@
}
function onPointerDown(ev: PointerEvent) {
if (eyedropper.mode !== 'none') return;
if (!zoomed || !scroller || ev.pointerType !== 'mouse') return;
dragging = true;
startX = ev.clientX;
@@ -485,6 +589,46 @@
scheduleViewport();
}
/** Пипетка: сэмплирует пиксель базового изображения под курсором. */
async function onImageClick(ev: MouseEvent) {
const photo = current;
if (!photo || !imgEl || eyedropper.mode === 'none') return;
const rect = imgEl.getBoundingClientRect();
const x = (ev.clientX - rect.left) / rect.width;
const y = (ev.clientY - rect.top) / rect.height;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
ev.preventDefault();
ev.stopPropagation();
const src = (zoomed ? originalSrc : basePreviewSrc) || basePreviewSrc;
if (!src) return;
try {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('image load failed'));
img.src = src;
});
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
ctx.drawImage(img, 0, 0);
const px = Math.min(
img.naturalWidth - 1,
Math.max(0, Math.round(x * (img.naturalWidth - 1)))
);
const py = Math.min(
img.naturalHeight - 1,
Math.max(0, Math.round(y * (img.naturalHeight - 1)))
);
const data = ctx.getImageData(px, py, 1, 1).data;
eyedropper.report(photo.id, data[0], data[1], data[2]);
} catch {
/* изображение не загрузилось — тихо выходим */
}
}
async function toggleViewerFullscreen() {
if (!viewerEl) return;
try {
@@ -732,7 +876,10 @@
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
onscroll={scheduleViewport}
onscroll={() => {
markInteracting();
scheduleViewport();
}}
>
{#if current}
<img
@@ -748,12 +895,26 @@
/>
{/if}
</div>
{#if eyedropper.mode !== 'none' && eyedropper.photoId === current?.id}
<button
type="button"
class="absolute inset-0 z-20 cursor-crosshair"
aria-label="Выберите нейтральную точку на фото"
onclick={onImageClick}
></button>
{/if}
{#if clientPreview}
<!-- Правки видны сразу: браузер рисует видимую область, пока
сервер готовит отредактированную версию фото. -->
<canvas
bind:this={canvas}
class="pointer-events-none absolute inset-0 z-10 select-none"
class:hidden={useGL}
></canvas>
<canvas
bind:this={glCanvas}
class="pointer-events-none absolute inset-0 z-10 select-none"
class:hidden={!useGL}
></canvas>
{/if}
</div>
+127 -1
View File
@@ -1,9 +1,13 @@
import { describe, expect, it } from 'vitest';
import {
applyColorToPixels,
emptyColor,
emptyEditParams,
isEmptyColor,
isEmptyEditParams,
normalizeEditParams,
readEditParams
readEditParams,
whiteBalanceFromRGB
} from './edit';
import type { Photo } from './types';
@@ -56,3 +60,125 @@ describe('параметры правок', () => {
expect(points[1].x).toBeCloseTo(0.8);
});
});
describe('цветовые параметры', () => {
it('зажимает значения и оттенки колёс', () => {
const params = normalizeEditParams({
light: emptyEditParams().light,
color: {
...emptyColor(),
temperature: 200,
tint: -300,
saturation: -500,
grading: {
...emptyColor().grading,
shadows: { hue: 400, saturation: 150, luminance: 0 },
balance: -200
}
}
});
expect(params.color.temperature).toBe(100);
expect(params.color.tint).toBe(-100);
expect(params.color.saturation).toBe(-100);
expect(params.color.grading.shadows.hue).toBe(360);
expect(params.color.grading.shadows.saturation).toBe(100);
expect(params.color.grading.balance).toBe(-100);
});
it('считает пустыми нулевые цветовые параметры', () => {
expect(isEmptyColor(emptyColor())).toBe(true);
expect(isEmptyColor({ ...emptyColor(), vibrance: 1 })).toBe(false);
const hueOnly = emptyColor();
hueOnly.grading.shadows.hue = 120;
expect(isEmptyColor(hueOnly)).toBe(true);
const grade = emptyColor();
grade.grading.highlights.saturation = 10;
expect(isEmptyColor(grade)).toBe(false);
});
it('читает цвет из метаданных и не теряет свет', () => {
const photo = {
edit_params: {
light: { exposure: 0.5 },
color: { temperature: 20, vibrance: 10 }
}
} as unknown as Photo;
const params = readEditParams(photo);
expect(params.light.exposure).toBe(0.5);
expect(params.color.temperature).toBe(20);
expect(params.color.vibrance).toBe(10);
});
it('осветляет тёплым балансом белого', () => {
const pixels = new Uint8ClampedArray([100, 100, 100, 255]);
applyColorToPixels(pixels, { ...emptyColor(), temperature: 100 });
expect(pixels[0]).toBeGreaterThan(pixels[1]);
expect(pixels[1]).toBeGreaterThan(pixels[2]);
});
it('обеспечивает серый цвет при насыщенности -100', () => {
const pixels = new Uint8ClampedArray([200, 50, 50, 255]);
applyColorToPixels(pixels, { ...emptyColor(), saturation: -100 });
expect(pixels[0]).toBe(pixels[1]);
expect(pixels[1]).toBe(pixels[2]);
});
it('тонирует тени синим колесом', () => {
const color = emptyColor();
color.grading.shadows = { hue: 240, saturation: 100, luminance: 0 };
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
applyColorToPixels(pixels, color);
expect(pixels[2]).toBeGreaterThan(pixels[0]);
});
it('повышает яркость теней', () => {
const color = emptyColor();
color.grading.shadows = { hue: 0, saturation: 0, luminance: 100 };
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
applyColorToPixels(pixels, color);
expect(pixels[0]).toBeGreaterThan(40);
});
it('одинаково обрабатывает все строки однородного изображения', () => {
const color = emptyColor();
color.grading.shadows = { hue: 240, saturation: 100, luminance: 20 };
const pixels = new Uint8ClampedArray(64 * 64 * 4);
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 40;
pixels[i + 1] = 40;
pixels[i + 2] = 40;
pixels[i + 3] = 255;
}
applyColorToPixels(pixels, color);
for (let i = 4; i < pixels.length; i += 4) {
expect(pixels[i]).toBe(pixels[0]);
expect(pixels[i + 1]).toBe(pixels[1]);
expect(pixels[i + 2]).toBe(pixels[2]);
}
});
it('не трогает прозрачные пиксели', () => {
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
applyColorToPixels(pixels, {
...emptyColor(),
temperature: 100,
saturation: 100
});
expect(Array.from(pixels)).toEqual([10, 20, 30, 0]);
});
it('подбирает баланс белого по нейтральной точке', () => {
const neutral = whiteBalanceFromRGB(128, 128, 128);
expect(neutral.temperature).toBeCloseTo(0, 5);
expect(neutral.tint).toBeCloseTo(0, 5);
const wb = whiteBalanceFromRGB(150, 140, 120);
const temperature = wb.temperature / 100;
const tint = wb.tint / 100;
const r = 150 * (1 + 0.2 * temperature + 0.1 * tint);
const g = 140 * (1 - 0.2 * tint);
const b = 120 * (1 - 0.2 * temperature + 0.1 * tint);
expect(r).toBeCloseTo(g, 5);
expect(b).toBeCloseTo(g, 5);
});
});
+413 -12
View File
@@ -30,10 +30,91 @@ export interface LightParams {
curve: CurveParams;
}
export interface MixerBand {
hue: number;
saturation: number;
luminance: number;
}
export interface ColorMixer {
red: MixerBand;
orange: MixerBand;
yellow: MixerBand;
green: MixerBand;
aqua: MixerBand;
blue: MixerBand;
purple: MixerBand;
magenta: MixerBand;
}
export interface ColorWheel {
hue: number;
saturation: number;
luminance: number;
}
export interface ColorGrading {
shadows: ColorWheel;
midtones: ColorWheel;
highlights: ColorWheel;
blending: number;
balance: number;
}
export interface ColorCorrection {
red: number;
green: number;
blue: number;
}
export interface ColorParams {
temperature: number;
tint: number;
vibrance: number;
saturation: number;
mixer: ColorMixer;
grading: ColorGrading;
correction: ColorCorrection;
}
export interface EditParams {
light: LightParams;
color: ColorParams;
}
/** Полосы цветового микшера: ключ, подпись и центр оттенка в градусах. */
export const MIXER_BANDS = [
{ key: 'red', label: 'Красное', center: 0 },
{ key: 'orange', label: 'Оранжевое', center: 30 },
{ key: 'yellow', label: 'Жёлтое', center: 60 },
{ key: 'green', label: 'Зелёное', center: 120 },
{ key: 'aqua', label: 'Бирюзовое', center: 180 },
{ key: 'blue', label: 'Синее', center: 240 },
{ key: 'purple', label: 'Фиолетовое', center: 280 },
{ key: 'magenta', label: 'Пурпурное', center: 320 }
] as const;
export type MixerBandKey = (typeof MIXER_BANDS)[number]['key'];
/** Ползунки базового блока «Цвет». */
export const COLOR_SLIDERS = [
{ key: 'temperature', label: 'Темп.', min: -100, max: 100, step: 1 },
{ key: 'tint', label: 'Оттенок', min: -100, max: 100, step: 1 },
{ key: 'vibrance', label: 'Сочность', min: -100, max: 100, step: 1 },
{ key: 'saturation', label: 'Насыщенность', min: -100, max: 100, step: 1 }
] as const;
export type ColorSliderKey = (typeof COLOR_SLIDERS)[number]['key'];
/** Колёса точечного цвета. */
export const GRADING_WHEELS = [
{ key: 'shadows', label: 'Тени' },
{ key: 'midtones', label: 'Средние тона' },
{ key: 'highlights', label: 'Света' }
] as const;
export type GradingWheelKey = (typeof GRADING_WHEELS)[number]['key'];
export const CURVE_CHANNELS = ['rgb', 'red', 'green', 'blue'] as const;
export type CurveChannel = (typeof CURVE_CHANNELS)[number];
@@ -86,11 +167,42 @@ export function emptyLight(): LightParams {
}
export function emptyEditParams(): EditParams {
return { light: emptyLight() };
return { light: emptyLight(), color: emptyColor() };
}
function clamp(v: number, min: number, max: number): number {
if (!Number.isFinite(v)) return 0;
function emptyMixerBand(): MixerBand {
return { hue: 0, saturation: 0, luminance: 0 };
}
export function emptyColor(): ColorParams {
return {
temperature: 0,
tint: 0,
vibrance: 0,
saturation: 0,
mixer: {
red: emptyMixerBand(),
orange: emptyMixerBand(),
yellow: emptyMixerBand(),
green: emptyMixerBand(),
aqua: emptyMixerBand(),
blue: emptyMixerBand(),
purple: emptyMixerBand(),
magenta: emptyMixerBand()
},
grading: {
shadows: { hue: 0, saturation: 0, luminance: 0 },
midtones: { hue: 0, saturation: 0, luminance: 0 },
highlights: { hue: 0, saturation: 0, luminance: 0 },
blending: 50,
balance: 0
},
correction: { red: 0, green: 0, blue: 0 }
};
}
function clamp(v: number | undefined, min: number, max: number): number {
if (typeof v !== 'number' || !Number.isFinite(v)) return 0;
return Math.min(max, Math.max(min, v));
}
@@ -113,7 +225,10 @@ export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined
}
/** Приводит параметры к допустимым диапазонам. */
export function normalizeEditParams(params: EditParams): EditParams {
export function normalizeEditParams(params: {
light?: Partial<LightParams>;
color?: Partial<ColorParams>;
}): EditParams {
const light = params?.light ?? emptyLight();
return {
light: {
@@ -129,13 +244,64 @@ export function normalizeEditParams(params: EditParams): EditParams {
green: normalizeCurve(light.curve?.green),
blue: normalizeCurve(light.curve?.blue)
}
},
color: normalizeColor(params?.color)
};
}
function normalizeBand(band?: Partial<MixerBand>): MixerBand {
return {
hue: clamp(band?.hue ?? 0, -MAX_PERCENT, MAX_PERCENT),
saturation: clamp(band?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
luminance: clamp(band?.luminance ?? 0, -MAX_PERCENT, MAX_PERCENT)
};
}
function normalizeWheel(wheel?: Partial<ColorWheel>): ColorWheel {
return {
hue: clamp(wheel?.hue ?? 0, 0, 360),
saturation: clamp(wheel?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
luminance: clamp(wheel?.luminance ?? 0, -MAX_PERCENT, MAX_PERCENT)
};
}
/** Приводит цветовые параметры к допустимым диапазонам. */
export function normalizeColor(color?: Partial<ColorParams>): ColorParams {
const mixer: Partial<ColorMixer> = color?.mixer ?? {};
const grading: Partial<ColorGrading> = color?.grading ?? {};
const correction: Partial<ColorCorrection> = color?.correction ?? {};
return {
temperature: clamp(color?.temperature ?? 0, -MAX_PERCENT, MAX_PERCENT),
tint: clamp(color?.tint ?? 0, -MAX_PERCENT, MAX_PERCENT),
vibrance: clamp(color?.vibrance ?? 0, -MAX_PERCENT, MAX_PERCENT),
saturation: clamp(color?.saturation ?? 0, -MAX_PERCENT, MAX_PERCENT),
mixer: {
red: normalizeBand(mixer.red),
orange: normalizeBand(mixer.orange),
yellow: normalizeBand(mixer.yellow),
green: normalizeBand(mixer.green),
aqua: normalizeBand(mixer.aqua),
blue: normalizeBand(mixer.blue),
purple: normalizeBand(mixer.purple),
magenta: normalizeBand(mixer.magenta)
},
grading: {
shadows: normalizeWheel(grading.shadows),
midtones: normalizeWheel(grading.midtones),
highlights: normalizeWheel(grading.highlights),
blending: clamp(grading.blending ?? 50, 0, MAX_PERCENT),
balance: clamp(grading.balance ?? 0, -MAX_PERCENT, MAX_PERCENT)
},
correction: {
red: clamp(correction.red ?? 0, -MAX_PERCENT, MAX_PERCENT),
green: clamp(correction.green ?? 0, -MAX_PERCENT, MAX_PERCENT),
blue: clamp(correction.blue ?? 0, -MAX_PERCENT, MAX_PERCENT)
}
};
}
/** Пустые ли параметры: фотография не тронута. */
export function isEmptyEditParams(params: EditParams): boolean {
const light = params.light;
/** Пустой ли блок «Свет». */
export function isEmptyLight(light: LightParams): boolean {
if (
light.exposure !== 0 ||
light.contrast !== 0 ||
@@ -154,16 +320,76 @@ export function isEmptyEditParams(params: EditParams): boolean {
return true;
}
/**
* Цвет можно посчитать быстрым путём (без HSL): не задействованы микшер и
* насыщенность/вибрация — только колёса, яркость зон и температура/оттенок.
*/
export function isColorFast(color: ColorParams): boolean {
if (color.vibrance !== 0 || color.saturation !== 0) return false;
for (const band of MIXER_BANDS) {
const value = color.mixer[band.key];
if (value.hue !== 0 || value.saturation !== 0 || value.luminance !== 0) return false;
}
return true;
}
/** Пустые ли параметры: фотография не тронута. */
export function isEmptyEditParams(params: EditParams): boolean {
return isEmptyLight(params.light) && isEmptyColor(params.color);
}
/** Пусты ли цветовые параметры: цвет не тронут. */
export function isEmptyColor(color?: ColorParams): boolean {
if (!color) return true;
if (color.temperature || color.tint || color.vibrance || color.saturation) return false;
for (const band of MIXER_BANDS) {
const value = color.mixer[band.key];
if (value?.hue || value?.saturation || value?.luminance) return false;
}
for (const wheel of GRADING_WHEELS) {
const value = color.grading[wheel.key];
// Оттенок колеса без силы или яркости не влияет на картинку.
if (value?.saturation || value?.luminance) return false;
}
const correction = color.correction;
return !correction?.red && !correction?.green && !correction?.blue;
}
/**
* Подбирает баланс белого, при котором нейтральный пиксель (r, g, b) станет
* серым. Возвращает температуру и оттенок в диапазоне -100..100.
*/
export function whiteBalanceFromRGB(
r: number,
g: number,
b: number
): { temperature: number; tint: number } {
const rr = Math.max(1, r);
const gg = Math.max(1, g);
const bb = Math.max(1, b);
const sum = gg * (1 / rr + 1 / bb);
const tint = (sum - 2) / (0.2 * (1 + sum));
const factor = 1 - 0.2 * tint;
const temperature = (gg * (1 / rr - 1 / bb) * factor) / 0.4;
return {
temperature: clamp(temperature * 100, -MAX_PERCENT, MAX_PERCENT),
tint: clamp(tint * 100, -MAX_PERCENT, MAX_PERCENT)
};
}
/** Читает параметры правок из метаданных фото. */
export function readEditParams(photo: Photo | null | undefined): EditParams {
const raw = photo?.edit_params as { light?: Partial<LightParams> } | undefined;
if (!raw?.light) return emptyEditParams();
const raw = photo?.edit_params as
| { light?: Partial<LightParams>; color?: Partial<ColorParams> }
| undefined;
if (!raw?.light && !raw?.color) return emptyEditParams();
return normalizeEditParams({
light: {
...emptyLight(),
...raw.light,
curve: raw.light.curve ?? {}
}
...raw?.light,
curve: raw?.light?.curve ?? {}
},
color: raw?.color ?? emptyColor()
});
}
@@ -261,3 +487,178 @@ export function applyLightToPixels(data: Uint8ClampedArray, light: LightParams):
data[i + 2] = adjustChannel(data[i + 2], light, master, blue);
}
}
/** RGB (0..255) -> HSL: оттенок в градусах, насыщенность и яркость в 0..1. */
function rgbToHSL(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
if (max === r) {
h = (g - b) / d + (g < b ? 6 : 0);
} else if (max === g) {
h = (b - r) / d + 2;
} else {
h = (r - g) / d + 4;
}
return [h * 60, s, l];
}
function hueToRGB(p: number, q: number, t: number): number {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
/** HSL -> RGB (0..255). */
export function hslToRGB(h: number, s: number, l: number): [number, number, number] {
if (s === 0) {
const v = l * 255;
return [v, v, v];
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hk = h / 360;
return [
hueToRGB(p, q, hk + 1 / 3) * 255,
hueToRGB(p, q, hk) * 255,
hueToRGB(p, q, hk - 1 / 3) * 255
];
}
/** Треугольный вес оттенка вокруг центра полосы микшера. */
function bandWeight(h: number, center: number): number {
let d = Math.abs(h - center);
if (d > 180) d = 360 - d;
return clamp01(1 - d / 60);
}
/**
* Применяет правки «Цвет» к пикселям (в том же массиве). Порядок операций
* повторяет серверный рендер: баланс белого, HSL, насыщенность/вибрация,
* микшер, точечный цвет, цветокоррекция.
*/
export function applyColorToPixels(data: Uint8ClampedArray, color: ColorParams): void {
const temperature = color.temperature / 100;
const tint = color.tint / 100;
const vibrance = color.vibrance / 100;
const saturation = color.saturation / 100;
const blending = color.grading.blending / 100;
const balance = color.grading.balance / 100;
const correction = color.correction;
const tints = GRADING_WHEELS.map((wheel) =>
hslToRGB(color.grading[wheel.key].hue, 1, 0.5)
);
const blend = blending;
const hasMixer = MIXER_BANDS.some((band) => {
const value = color.mixer[band.key];
return value.hue !== 0 || value.saturation !== 0 || value.luminance !== 0;
});
for (let i = 0; i + 3 < data.length; i += 4) {
if (data[i + 3] === 0) continue;
let r = data[i];
let g = data[i + 1];
let b = data[i + 2];
// Баланс белого.
r *= 1 + 0.2 * temperature + 0.1 * tint;
g *= 1 - 0.2 * tint;
b *= 1 - 0.2 * temperature + 0.1 * tint;
r = clamp(r, 0, 255);
g = clamp(g, 0, 255);
b = clamp(b, 0, 255);
// Быстрый путь: только колёса/яркость, без микшера и насыщенности —
// не нужен HSL-переход, lightness считается напрямую.
const fast = !hasMixer && saturation === 0 && vibrance === 0;
let weights: number[];
let rr: number;
let gg: number;
let bb: number;
if (fast) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 510;
const lb = clamp01(l + balance * 0.5);
const shadowW = (1 - lb) * (1 - lb) * (1 - lb) * (1 - lb);
const highlightW = lb * lb * lb * lb;
weights = [shadowW, 1 - shadowW - highlightW, highlightW];
let targetL = l;
GRADING_WHEELS.forEach((wheel, index) => {
targetL +=
((color.grading[wheel.key].luminance ?? 0) / 100) * 0.2 * weights[index];
});
targetL = clamp01(targetL);
const delta = (targetL - l) * 255;
rr = r + delta;
gg = g + delta;
bb = b + delta;
} else {
// RGB -> HSL.
let [h, s, l] = rgbToHSL(r, g, b);
// Насыщенность и вибрация.
if (saturation !== 0 || vibrance !== 0) {
s = clamp01(s * (1 + saturation + vibrance * (1 - s)));
}
// Цветовой микшер.
if (hasMixer) {
for (const band of MIXER_BANDS) {
const value = color.mixer[band.key];
const w = bandWeight(h, band.center);
if (w === 0) continue;
h += (value.hue / 100) * 30 * w;
s = clamp01(s * (1 + (value.saturation / 100) * w));
l = clamp01(l + (value.luminance / 100) * 0.2 * w);
}
}
h = ((h % 360) + 360) % 360;
// Зоны яркости: веса в сумме дают 1, более узкие у теней и светов.
const lb = clamp01(l + balance * 0.5);
const shadowW = (1 - lb) * (1 - lb) * (1 - lb) * (1 - lb);
const highlightW = lb * lb * lb * lb;
weights = [shadowW, 1 - shadowW - highlightW, highlightW];
GRADING_WHEELS.forEach((wheel, index) => {
l = clamp01(
l + ((color.grading[wheel.key].luminance ?? 0) / 100) * 0.2 * weights[index]
);
});
// HSL -> RGB.
[rr, gg, bb] = hslToRGB(h, s, l);
}
// Точечный цвет: три колеса по зонам яркости.
GRADING_WHEELS.forEach((wheel, index) => {
const strength =
(color.grading[wheel.key].saturation / 100) * 0.5 * blend * weights[index];
if (strength === 0) return;
const tintRGB = tints[index];
rr = rr * (1 - strength) + tintRGB[0] * strength;
gg = gg * (1 - strength) + tintRGB[1] * strength;
bb = bb * (1 - strength) + tintRGB[2] * strength;
});
// Цветокоррекция.
rr *= 1 + (correction.red / 100) * 0.5;
gg *= 1 + (correction.green / 100) * 0.5;
bb *= 1 + (correction.blue / 100) * 0.5;
data[i] = Math.round(clamp(rr, 0, 255));
data[i + 1] = Math.round(clamp(gg, 0, 255));
data[i + 2] = Math.round(clamp(bb, 0, 255));
}
}
+7 -1
View File
@@ -55,7 +55,13 @@ export const icons = {
check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' },
uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' },
left_panel_close: { path: '<path fill="currentColor" d="M16.5 16V8l-4 4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
left_panel_open: { path: '<path fill="currentColor" d="M12.5 8v8l4-4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' }
left_panel_open: { path: '<path fill="currentColor" d="M12.5 8v8l4-4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm5-2h9V5h-9z"/>' },
visibility: { path: '<path fill="currentColor" d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5m0 12.5a5 5 0 1 1 0-10 5 5 0 0 1 0 10m0-2a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>' },
colorize: { path: '<path fill="currentColor" d="M17.66 5.41l.92.92-2.69 2.69-.92-.92zM17.67 3c-.26 0-.51.1-.71.29l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42l-2.34-2.34c-.2-.19-.45-.29-.7-.29M6.92 19L5 17.08l8.06-8.06 1.92 1.92z"/>' },
tune: { path: '<path fill="currentColor" d="M3 17v2h6v-2zm0-12v2h10V5zm10 16v-2h8v-2h-8v-2h-2v6zM7 9v2H3v2h4v2h2V9zm14 4v-2H11v2zm-6-4h2V7h4V5h-4V3h-2z"/>' },
blur_on: { path: '<path fill="currentColor" d="M12 3a5 5 0 0 1 0 10 5 5 0 0 1 0-10m-5 6a5 5 0 1 1 0 10 5 5 0 0 1 0-10m10 0a5 5 0 1 1 0 10 5 5 0 0 1 0-10"/>' },
circle: { path: '<path fill="currentColor" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16"/>' },
show_chart: { path: '<path fill="currentColor" d="M3 3v18h18v-2H5V3zm16.7 3.3l-1.4-1.4-6.3 6.3-3-3L5 15v-2.8l3.6-3.6 3 3z"/>' }
} satisfies Record<string, IconDef>;
export type IconName = keyof typeof icons;
+212 -1
View File
@@ -1,6 +1,16 @@
import { readFile } from 'node:fs/promises';
import { beforeAll, describe, expect, it } from 'vitest';
import { applyLightToPixels, buildCurveLUT, emptyEditParams, type LightParams } from './edit';
import {
applyColorToPixels,
applyLightToPixels,
buildCurveLUT,
emptyColor,
emptyEditParams,
GRADING_WHEELS,
MIXER_BANDS,
type ColorParams,
type LightParams
} from './edit';
interface LightExports {
memory: WebAssembly.Memory;
@@ -15,11 +25,14 @@ interface LightExports {
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 () => {
@@ -61,6 +74,44 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA
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 = {
@@ -98,4 +149,164 @@ describe('WASM-модуль фильтров', () => {
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)}`
);
}
}
}
});
});
+85 -2
View File
@@ -1,7 +1,15 @@
import { applyLightToPixels, buildCurveLUT, type LightParams } from './edit';
import {
applyColorToPixels,
applyLightToPixels,
buildCurveLUT,
GRADING_WHEELS,
MIXER_BANDS,
type ColorParams,
type LightParams
} from './edit';
/**
* WASM-модуль фильтров «Свет».
* WASM-модуль фильтров «Свет» и «Цвет».
*
* Модуль собран из wasm/light (Rust, no_std) и лежит в static/light.wasm.
* Он попиксельно применяет правки — это горячий путь предпросмотра в
@@ -10,6 +18,8 @@ import { applyLightToPixels, buildCurveLUT, type LightParams } from './edit';
*/
const LUTS_OFFSET = 0;
const COLOR_PARAMS_OFFSET = 2048;
const COLOR_PARAM_COUNT = 42;
const PIXELS_OFFSET = 4096;
const PAGE = 65536;
const LUT_LEN = 256;
@@ -27,6 +37,7 @@ interface LightExports {
whites: number,
blacks: number
): void;
apply_color(data: number, len: number, params: number): void;
}
let exportsRef: LightExports | null = null;
@@ -141,3 +152,75 @@ export function applyLight(data: Uint8ClampedArray, light: LightParams): void {
if (wasmMatchesReference(light) && applyLightWasm(data, light)) return;
applyLightToPixels(data, light);
}
/** Пишет f64-блок цветовых параметров в память модуля. */
function writeColorParams(mem: WebAssembly.Memory, color: ColorParams): void {
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);
}
/**
* Применяет цветовые правки к пикселям. Возвращает false, если модуль не
* готов и вызывающий код должен использовать JS-реализацию.
*/
export function applyColorWasm(data: Uint8ClampedArray, color: ColorParams): boolean {
const ex = exportsRef;
if (!ex) return false;
const bytes = PIXELS_OFFSET + data.length;
const mem = ensureMemory(bytes);
if (!mem) return false;
writeColorParams(mem, color);
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length).set(data);
ex.apply_color(PIXELS_OFFSET, data.length, COLOR_PARAMS_OFFSET);
data.set(new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, data.length));
return true;
}
/** Сверяет цветовой модуль с эталонной JS-реализацией на образце. */
function colorWasmMatchesReference(color: ColorParams): 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);
applyColorToPixels(expected, color);
const actual = new Uint8ClampedArray(probe);
if (!applyColorWasm(actual, color)) return false;
for (let i = 0; i < expected.length; i++) {
if (expected[i] !== actual[i]) return false;
}
return true;
}
/** Применяет цвет: сначала WASM, при его недоступности — JS. */
export function applyColor(data: Uint8ClampedArray, color: ColorParams): void {
if (colorWasmMatchesReference(color) && applyColorWasm(data, color)) return;
applyColorToPixels(data, color);
}
@@ -0,0 +1,39 @@
/**
* Режим пипетки: панель редактирования включает его, а просмотрщик сообщает
* цвет пикселя, по которому кликнули.
*/
export type EyedropperMode = 'none' | 'wb';
export interface EyedropperSample {
photoId: string;
r: number;
g: number;
b: number;
seq: number;
}
class EyedropperStore {
mode = $state<EyedropperMode>('none');
photoId = $state('');
sample = $state<EyedropperSample | null>(null);
private seq = 0;
start(mode: EyedropperMode, photoId: string) {
this.mode = mode;
this.photoId = photoId;
}
stop() {
this.mode = 'none';
this.photoId = '';
}
report(photoId: string, r: number, g: number, b: number) {
if (this.mode === 'none' || this.photoId !== photoId) return;
this.seq += 1;
this.sample = { photoId, r, g, b, seq: this.seq };
this.stop();
}
}
export const eyedropper = new EyedropperStore();
+470
View File
@@ -0,0 +1,470 @@
import {
buildCurveLUT,
GRADING_WHEELS,
hslToRGB,
MIXER_BANDS,
type ColorParams,
type LightParams
} from './edit';
/**
* GPU-превью правок на WebGL2.
*
* Шейдер повторяет математику серверного рендера: свет (экспозиция, контраст,
* света/тени, белые/чёрные, кривые через LUT) и цвет (баланс белого, HSL,
* микшер, колористика, коррекция). В отличие от попиксельного CPU-фильтра,
* кадр целиком считает GPU, поэтому ползунки не блокируют интерфейс.
*/
const VERT = `#version 300 es
in vec2 a_pos;
uniform vec2 u_uvOffset;
uniform vec2 u_uvScale;
out vec2 v_uv;
void main() {
vec2 uv = a_pos * 0.5 + 0.5;
v_uv = u_uvOffset + uv * u_uvScale;
gl_Position = vec4(a_pos, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
in vec2 v_uv;
out vec4 outColor;
uniform sampler2D u_image;
uniform sampler2D u_luts;
uniform float u_exposureMul;
uniform float u_contrastK;
uniform float u_highlights;
uniform float u_shadows;
uniform float u_whites;
uniform float u_blacks;
uniform float u_temperature;
uniform float u_tint;
uniform float u_vibrance;
uniform float u_saturation;
uniform float u_blending;
uniform float u_balance;
uniform vec3 u_correction;
uniform float u_mixerHue[8];
uniform float u_mixerSat[8];
uniform float u_mixerLum[8];
uniform float u_gradeSat[3];
uniform float u_gradeLum[3];
uniform vec3 u_gradeTint[3];
const float CENTERS[8] = float[8](0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 280.0, 320.0);
vec4 lutAt(float v) {
float idx = clamp(floor(v + 0.5), 0.0, 255.0);
return texture(u_luts, vec2((idx + 0.5) / 256.0, 0.5));
}
float lightChannel(float v, int ch) {
v *= u_exposureMul;
v = 128.0 + (v - 128.0) * u_contrastK;
float w = clamp((v - 128.0) / 127.0, 0.0, 1.0);
v += u_highlights * w * w;
w = clamp((128.0 - v) / 128.0, 0.0, 1.0);
v += u_shadows * w * w;
w = clamp((v - 192.0) / 63.0, 0.0, 1.0);
v += u_whites * w;
w = clamp((64.0 - v) / 64.0, 0.0, 1.0);
v += u_blacks * w;
vec4 l1 = lutAt(v);
float master = l1.r * 255.0;
vec4 l2 = lutAt(master);
float value = ch == 0 ? l2.g : (ch == 1 ? l2.b : l2.a);
return value * 255.0;
}
vec3 rgb2hsl(vec3 c) {
float maxc = max(c.r, max(c.g, c.b));
float minc = min(c.r, min(c.g, c.b));
float l = (maxc + minc) * 0.5;
if (maxc == minc) return vec3(0.0, 0.0, l);
float d = maxc - minc;
float s = l > 0.5 ? d / (2.0 - maxc - minc) : d / (maxc + minc);
float h;
if (maxc == c.r) {
h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
} else if (maxc == c.g) {
h = (c.b - c.r) / d + 2.0;
} else {
h = (c.r - c.g) / d + 4.0;
}
return vec3(h * 60.0, s, l);
}
float hue2rgb(float p, float q, float t) {
if (t < 0.0) t += 1.0;
if (t > 1.0) t -= 1.0;
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
if (t < 1.0 / 2.0) return q;
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
float h = hsl.x / 360.0;
float s = hsl.y;
float l = hsl.z;
if (s == 0.0) return vec3(l);
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
float p = 2.0 * l - q;
return vec3(
hue2rgb(p, q, h + 1.0 / 3.0),
hue2rgb(p, q, h),
hue2rgb(p, q, h - 1.0 / 3.0)
);
}
float bandWeight(float h, float center) {
float d = abs(h - center);
if (d > 180.0) d = 360.0 - d;
return clamp(1.0 - d / 60.0, 0.0, 1.0);
}
void main() {
vec4 tex = texture(u_image, v_uv);
if (tex.a == 0.0) {
outColor = vec4(0.0);
return;
}
vec3 c = tex.rgb * 255.0;
c.r = lightChannel(c.r, 0);
c.g = lightChannel(c.g, 1);
c.b = lightChannel(c.b, 2);
// Баланс белого.
c.r *= 1.0 + 0.2 * u_temperature + 0.1 * u_tint;
c.g *= 1.0 - 0.2 * u_tint;
c.b *= 1.0 - 0.2 * u_temperature + 0.1 * u_tint;
c = clamp(c, 0.0, 255.0);
vec3 hsl = rgb2hsl(c / 255.0);
float h = hsl.x;
float s = hsl.y;
float l = hsl.z;
if (u_saturation != 0.0 || u_vibrance != 0.0) {
s = clamp(s * (1.0 + u_saturation + u_vibrance * (1.0 - s)), 0.0, 1.0);
}
for (int i = 0; i < 8; i++) {
float w = bandWeight(h, CENTERS[i]);
if (w > 0.0) {
h += u_mixerHue[i] * 30.0 * w;
s = clamp(s * (1.0 + u_mixerSat[i] * w), 0.0, 1.0);
l = clamp(l + u_mixerLum[i] * 0.2 * w, 0.0, 1.0);
}
}
h = mod(h, 360.0);
if (h < 0.0) h += 360.0;
float lb = clamp(l + u_balance * 0.5, 0.0, 1.0);
float sw = (1.0 - lb) * (1.0 - lb);
sw *= sw;
float hw = lb * lb;
hw *= hw;
float weights[3] = float[3](sw, 1.0 - sw - hw, hw);
l = clamp(
l +
u_gradeLum[0] * 0.2 * weights[0] +
u_gradeLum[1] * 0.2 * weights[1] +
u_gradeLum[2] * 0.2 * weights[2],
0.0, 1.0);
vec3 outc = hsl2rgb(vec3(h, s, l)) * 255.0;
for (int i = 0; i < 3; i++) {
float strength = u_gradeSat[i] * 0.5 * u_blending * weights[i];
if (strength != 0.0) {
outc = outc * (1.0 - strength) + u_gradeTint[i] * strength;
}
}
outc *= 1.0 + u_correction * 0.5;
outColor = vec4(clamp(outc / 255.0, 0.0, 1.0), tex.a);
}`;
export interface PreviewRenderOptions {
image: TexImageSource;
imageVersion: string;
imageWidth: number;
imageHeight: number;
crop: { x: number; y: number; width: number; height: number };
viewport: { width: number; height: number };
dest: { x: number; y: number; width: number; height: number };
light: LightParams;
color: ColorParams;
}
function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('webgl preview shader:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function buildLuts(light: LightParams): Uint8Array {
const master = buildCurveLUT(light.curve?.rgb);
const red = buildCurveLUT(light.curve?.red);
const green = buildCurveLUT(light.curve?.green);
const blue = buildCurveLUT(light.curve?.blue);
const out = new Uint8Array(256 * 4);
for (let i = 0; i < 256; i++) {
out[i * 4] = master[i];
out[i * 4 + 1] = red[i];
out[i * 4 + 2] = green[i];
out[i * 4 + 3] = blue[i];
}
return out;
}
export class WebGLPreview {
private readonly gl: WebGL2RenderingContext;
private readonly canvas: HTMLCanvasElement;
private readonly program: WebGLProgram;
private readonly vao: WebGLVertexArrayObject;
private readonly buffer: WebGLBuffer;
private readonly imageTex: WebGLTexture;
private readonly lutTex: WebGLTexture;
private readonly u: Record<string, WebGLUniformLocation | null>;
private imageVersion = '';
private constructor(canvas: HTMLCanvasElement, gl: WebGL2RenderingContext) {
this.canvas = canvas;
this.gl = gl;
const vs = compile(gl, gl.VERTEX_SHADER, VERT);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
if (!vs || !fs) throw new Error('shader compile failed');
const program = gl.createProgram();
if (!program) throw new Error('program create failed');
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`program link failed: ${log ?? ''}`);
}
this.program = program;
const vao = gl.createVertexArray();
const buffer = gl.createBuffer();
if (!vao || !buffer) throw new Error('buffer create failed');
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW
);
const aPos = gl.getAttribLocation(program, 'a_pos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
this.vao = vao;
this.buffer = buffer;
const imageTex = gl.createTexture();
const lutTex = gl.createTexture();
if (!imageTex || !lutTex) throw new Error('texture create failed');
gl.bindTexture(gl.TEXTURE_2D, imageTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.bindTexture(gl.TEXTURE_2D, lutTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
this.imageTex = imageTex;
this.lutTex = lutTex;
const loc = (name: string) => gl.getUniformLocation(program, name);
this.u = {
u_image: loc('u_image'),
u_luts: loc('u_luts'),
u_uvOffset: loc('u_uvOffset'),
u_uvScale: loc('u_uvScale'),
u_exposureMul: loc('u_exposureMul'),
u_contrastK: loc('u_contrastK'),
u_highlights: loc('u_highlights'),
u_shadows: loc('u_shadows'),
u_whites: loc('u_whites'),
u_blacks: loc('u_blacks'),
u_temperature: loc('u_temperature'),
u_tint: loc('u_tint'),
u_vibrance: loc('u_vibrance'),
u_saturation: loc('u_saturation'),
u_blending: loc('u_blending'),
u_balance: loc('u_balance'),
u_correction: loc('u_correction'),
u_mixerHue: loc('u_mixerHue[0]'),
u_mixerSat: loc('u_mixerSat[0]'),
u_mixerLum: loc('u_mixerLum[0]'),
u_gradeSat: loc('u_gradeSat[0]'),
u_gradeLum: loc('u_gradeLum[0]'),
u_gradeTint: loc('u_gradeTint[0]')
};
}
static create(canvas: HTMLCanvasElement): WebGLPreview | null {
const gl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: false,
antialias: false,
depth: false,
stencil: false,
preserveDrawingBuffer: false
});
if (!gl) return null;
try {
return new WebGLPreview(canvas, gl);
} catch (err) {
console.error('webgl preview init:', err);
return null;
}
}
dispose() {
const gl = this.gl;
gl.deleteTexture(this.imageTex);
gl.deleteTexture(this.lutTex);
gl.deleteBuffer(this.buffer);
gl.deleteVertexArray(this.vao);
gl.deleteProgram(this.program);
}
render(opts: PreviewRenderOptions): boolean {
const gl = this.gl;
const { width, height } = opts.viewport;
if (!width || !height) return false;
if (this.canvas.width !== width || this.canvas.height !== height) {
this.canvas.width = width;
this.canvas.height = height;
}
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(this.program);
if (this.imageVersion !== opts.imageVersion) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, opts.image);
this.imageVersion = opts.imageVersion;
}
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
gl.uniform1i(this.u.u_image, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
256,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
buildLuts(opts.light)
);
gl.uniform1i(this.u.u_luts, 1);
const light = opts.light;
gl.uniform1f(this.u.u_exposureMul, Math.pow(2, light.exposure));
gl.uniform1f(this.u.u_contrastK, 1 + light.contrast / 100);
gl.uniform1f(this.u.u_highlights, (light.highlights / 100) * 80);
gl.uniform1f(this.u.u_shadows, (light.shadows / 100) * 80);
gl.uniform1f(this.u.u_whites, (light.whites / 100) * 80);
gl.uniform1f(this.u.u_blacks, (light.blacks / 100) * 80);
const color = opts.color;
gl.uniform1f(this.u.u_temperature, color.temperature / 100);
gl.uniform1f(this.u.u_tint, color.tint / 100);
gl.uniform1f(this.u.u_vibrance, color.vibrance / 100);
gl.uniform1f(this.u.u_saturation, color.saturation / 100);
gl.uniform1f(this.u.u_blending, color.grading.blending / 100);
gl.uniform1f(this.u.u_balance, color.grading.balance / 100);
gl.uniform3f(
this.u.u_correction,
color.correction.red / 100,
color.correction.green / 100,
color.correction.blue / 100
);
const hue = new Float32Array(8);
const sat = new Float32Array(8);
const lum = new Float32Array(8);
for (let i = 0; i < 8; i++) {
const band = color.mixer[MIXER_BANDS[i].key];
hue[i] = band.hue / 100;
sat[i] = band.saturation / 100;
lum[i] = band.luminance / 100;
}
gl.uniform1fv(this.u.u_mixerHue, hue);
gl.uniform1fv(this.u.u_mixerSat, sat);
gl.uniform1fv(this.u.u_mixerLum, lum);
const gradeSat = new Float32Array(3);
const gradeLum = new Float32Array(3);
const gradeTint = new Float32Array(9);
for (let i = 0; i < 3; i++) {
const wheel = color.grading[GRADING_WHEELS[i].key];
gradeSat[i] = wheel.saturation / 100;
gradeLum[i] = wheel.luminance / 100;
const [r, g, b] = hslToRGB(wheel.hue, 1, 0.5);
gradeTint[i * 3] = r;
gradeTint[i * 3 + 1] = g;
gradeTint[i * 3 + 2] = b;
}
gl.uniform1fv(this.u.u_gradeSat, gradeSat);
gl.uniform1fv(this.u.u_gradeLum, gradeLum);
gl.uniform3fv(this.u.u_gradeTint, gradeTint);
const u0 = opts.crop.x / opts.imageWidth;
const u1 = (opts.crop.x + opts.crop.width) / opts.imageWidth;
const vTop = opts.crop.y / opts.imageHeight;
const vBottom = (opts.crop.y + opts.crop.height) / opts.imageHeight;
gl.uniform2f(this.u.u_uvOffset, u0, 1 - vBottom);
gl.uniform2f(this.u.u_uvScale, u1 - u0, vBottom - vTop);
gl.viewport(
Math.round(opts.dest.x),
Math.round(height - opts.dest.y - opts.dest.height),
Math.max(1, Math.round(opts.dest.width)),
Math.max(1, Math.round(opts.dest.height))
);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.bindVertexArray(null);
return true;
}
}