feat(photos): добавить эффекты и настройки панели редактора
- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе - живой WebGL2-предпросмотр эффектов и CPU-фолбэк - секции редактора свёрнуты по умолчанию, состояние в localStorage - вкладка «Редактирование» доступна только из просмотрщика
This commit is contained in:
@@ -8,30 +8,44 @@
|
||||
import { edit } from '$lib/stores/edit.svelte';
|
||||
import { eyedropper } from '$lib/stores/eyedropper.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import {
|
||||
loadSectionCollapse,
|
||||
saveSectionCollapse,
|
||||
type SectionCollapse
|
||||
} from '$lib/editor-sections';
|
||||
import { applyColor, applyLight } from '$lib/light-wasm';
|
||||
import {
|
||||
CHANNEL_COLORS,
|
||||
CHANNEL_LABELS,
|
||||
COLOR_SLIDERS,
|
||||
CURVE_CHANNELS,
|
||||
EFFECT_SLIDERS,
|
||||
GRAIN_SLIDERS,
|
||||
GRADING_WHEELS,
|
||||
LIGHT_SLIDERS,
|
||||
MAX_CURVE_POINTS,
|
||||
MIXER_BANDS,
|
||||
VIGNETTE_MODES,
|
||||
VIGNETTE_SLIDERS,
|
||||
curveSvgPath,
|
||||
effectiveCurvePoints,
|
||||
emptyEditParams,
|
||||
isEmptyEditParams,
|
||||
normalizeEditParams,
|
||||
normalizeVignetteColor,
|
||||
readEditParams,
|
||||
whiteBalanceFromRGB,
|
||||
type ColorSliderKey,
|
||||
type CurveChannel,
|
||||
type CurvePoint,
|
||||
type EditParams,
|
||||
type EffectSliderKey,
|
||||
type GrainSliderKey,
|
||||
type GradingWheelKey,
|
||||
type MixerBand,
|
||||
type MixerBandKey
|
||||
type MixerBandKey,
|
||||
type VignetteMode,
|
||||
type VignetteSliderKey
|
||||
} from '$lib/edit';
|
||||
import {
|
||||
findActiveCurvePreset,
|
||||
@@ -57,15 +71,15 @@
|
||||
// перетирать более свежие правки (например, после переключения фото).
|
||||
let saveSequence = 0;
|
||||
const latestSaveByPhoto = new Map<string, number>();
|
||||
let collapsed = $state(false);
|
||||
let curveCollapsed = $state(false);
|
||||
let collapsed = $state<SectionCollapse>(loadSectionCollapse());
|
||||
// Состояние секций хранится в браузере: панель открывается в том же виде
|
||||
// после перезагрузки и переключения фото.
|
||||
$effect(() => {
|
||||
saveSectionCollapse(collapsed);
|
||||
});
|
||||
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');
|
||||
|
||||
@@ -337,6 +351,57 @@
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function onEffectInput(key: EffectSliderKey, value: number) {
|
||||
params = { ...params, effects: { ...params.effects, [key]: value } };
|
||||
edit.set(photo.id, params);
|
||||
}
|
||||
|
||||
function onVignetteInput(key: VignetteSliderKey, value: number) {
|
||||
params = {
|
||||
...params,
|
||||
effects: {
|
||||
...params.effects,
|
||||
vignette: { ...params.effects.vignette, [key]: value }
|
||||
}
|
||||
};
|
||||
edit.set(photo.id, params);
|
||||
}
|
||||
|
||||
function onVignetteMode(mode: VignetteMode) {
|
||||
params = {
|
||||
...params,
|
||||
effects: { ...params.effects, vignette: { ...params.effects.vignette, mode } }
|
||||
};
|
||||
edit.set(photo.id, params);
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function onVignetteColor(value: string) {
|
||||
params = {
|
||||
...params,
|
||||
effects: {
|
||||
...params.effects,
|
||||
vignette: { ...params.effects.vignette, color: normalizeVignetteColor(value) }
|
||||
}
|
||||
};
|
||||
edit.set(photo.id, params);
|
||||
}
|
||||
|
||||
function onGrainInput(key: GrainSliderKey, value: number) {
|
||||
params = {
|
||||
...params,
|
||||
effects: { ...params.effects, grain: { ...params.effects.grain, [key]: value } }
|
||||
};
|
||||
edit.set(photo.id, params);
|
||||
}
|
||||
|
||||
function onEffectsCommit() {
|
||||
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 = {
|
||||
@@ -654,12 +719,12 @@
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold"
|
||||
aria-expanded={!collapsed}
|
||||
onclick={() => (collapsed = !collapsed)}
|
||||
aria-expanded={!collapsed.light}
|
||||
onclick={() => (collapsed.light = !collapsed.light)}
|
||||
>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {collapsed ? '-rotate-90' : ''}"
|
||||
class="size-4 transition-transform {collapsed.light ? '-rotate-90' : ''}"
|
||||
/>
|
||||
Свет
|
||||
</button>
|
||||
@@ -675,7 +740,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !collapsed}
|
||||
{#if !collapsed.light}
|
||||
<div class="mt-3 space-y-4">
|
||||
<div class="space-y-2.5">
|
||||
{#each LIGHT_SLIDERS as slider (slider.key)}
|
||||
@@ -698,9 +763,9 @@
|
||||
<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-expanded={!collapsed.curve}
|
||||
aria-controls="light-curve"
|
||||
onclick={() => (curveCollapsed = !curveCollapsed)}
|
||||
onclick={() => (collapsed.curve = !collapsed.curve)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="show_chart" class="size-4" />
|
||||
@@ -708,11 +773,11 @@
|
||||
</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {curveCollapsed ? '-rotate-90' : ''}"
|
||||
class="size-4 transition-transform {collapsed.curve ? '-rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if !curveCollapsed}
|
||||
{#if !collapsed.curve}
|
||||
<div id="light-curve" class="mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{#each CURVE_CHANNELS as ch (ch)}
|
||||
@@ -842,13 +907,13 @@
|
||||
<button
|
||||
type="button"
|
||||
class="text-text hover:text-primary flex items-center gap-1 text-sm font-semibold"
|
||||
aria-expanded={!colorCollapsed}
|
||||
aria-expanded={!collapsed.color}
|
||||
aria-controls="color-panel"
|
||||
onclick={() => (colorCollapsed = !colorCollapsed)}
|
||||
onclick={() => (collapsed.color = !collapsed.color)}
|
||||
>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {colorCollapsed ? '-rotate-90' : ''}"
|
||||
class="size-4 transition-transform {collapsed.color ? '-rotate-90' : ''}"
|
||||
/>
|
||||
Цвет
|
||||
</button>
|
||||
@@ -857,7 +922,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !colorCollapsed}
|
||||
{#if !collapsed.color}
|
||||
<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">
|
||||
@@ -918,9 +983,9 @@
|
||||
<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-expanded={!collapsed.mixer}
|
||||
aria-controls="color-mixer"
|
||||
onclick={() => (mixerCollapsed = !mixerCollapsed)}
|
||||
onclick={() => (collapsed.mixer = !collapsed.mixer)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="tune" class="size-4" />
|
||||
@@ -928,10 +993,10 @@
|
||||
</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {mixerCollapsed ? '-rotate-90' : ''}"
|
||||
class="size-4 transition-transform {collapsed.mixer ? '-rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if !mixerCollapsed}
|
||||
{#if !collapsed.mixer}
|
||||
<div id="color-mixer" class="mt-2">
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
{#each MIXER_BANDS as band (band.key)}
|
||||
@@ -1015,9 +1080,9 @@
|
||||
<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-expanded={!collapsed.pointColor}
|
||||
aria-controls="color-point"
|
||||
onclick={() => (pointColorCollapsed = !pointColorCollapsed)}
|
||||
onclick={() => (collapsed.pointColor = !collapsed.pointColor)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="colorize" class="size-4" />
|
||||
@@ -1025,10 +1090,10 @@
|
||||
</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {pointColorCollapsed ? '-rotate-90' : ''}"
|
||||
class="size-4 transition-transform {collapsed.pointColor ? '-rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if !pointColorCollapsed}
|
||||
{#if !collapsed.pointColor}
|
||||
<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>
|
||||
@@ -1040,9 +1105,9 @@
|
||||
<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-expanded={!collapsed.correction}
|
||||
aria-controls="color-correction"
|
||||
onclick={() => (correctionCollapsed = !correctionCollapsed)}
|
||||
onclick={() => (collapsed.correction = !collapsed.correction)}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="blur_on" class="size-4" />
|
||||
@@ -1050,10 +1115,10 @@
|
||||
</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {correctionCollapsed ? '-rotate-90' : ''}"
|
||||
class="size-4 transition-transform {collapsed.correction ? '-rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if !correctionCollapsed}
|
||||
{#if !collapsed.correction}
|
||||
<div id="color-correction" class="mt-2 space-y-4">
|
||||
<div class="flex items-center gap-1">
|
||||
{#each CORRECTION_VIEWS as view (view.key)}
|
||||
@@ -1169,6 +1234,116 @@
|
||||
{/if}
|
||||
</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-sm font-semibold"
|
||||
aria-expanded={!collapsed.effects}
|
||||
aria-controls="effects-panel"
|
||||
onclick={() => (collapsed.effects = !collapsed.effects)}
|
||||
>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 transition-transform {collapsed.effects ? '-rotate-90' : ''}"
|
||||
/>
|
||||
Эффекты
|
||||
</button>
|
||||
<span class="text-text-muted" title="Показать эффекты">
|
||||
<Icon name="visibility" class="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !collapsed.effects}
|
||||
<div id="effects-panel" class="mt-3 space-y-4">
|
||||
<div class="space-y-2.5">
|
||||
{#each EFFECT_SLIDERS as slider (slider.key)}
|
||||
<EditSlider
|
||||
id="effect-{slider.key}"
|
||||
label={slider.label}
|
||||
value={params.effects[slider.key]}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
disabled={!canEdit}
|
||||
format={formatValue}
|
||||
oninput={(value) => onEffectInput(slider.key, value)}
|
||||
onchange={onEffectsCommit}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="border-border space-y-2.5 border-t pt-3">
|
||||
<div class="flex items-center gap-1" role="group" aria-label="Режим виньетки">
|
||||
{#each VIGNETTE_MODES as mode (mode.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2 py-1 text-xs disabled:opacity-40 {params.effects.vignette
|
||||
.mode === mode.key
|
||||
? 'bg-bg-muted text-text'
|
||||
: 'text-text-muted hover:text-text'}"
|
||||
aria-pressed={params.effects.vignette.mode === mode.key}
|
||||
disabled={!canEdit}
|
||||
onclick={() => onVignetteMode(mode.key)}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each VIGNETTE_SLIDERS as slider (slider.key)}
|
||||
<EditSlider
|
||||
id="vignette-{slider.key}"
|
||||
label={slider.label}
|
||||
value={params.effects.vignette[slider.key]}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
resetValue={slider.key === 'midpoint' || slider.key === 'feather' ? 50 : 0}
|
||||
disabled={!canEdit}
|
||||
format={slider.min < 0 ? formatValue : undefined}
|
||||
oninput={(value) => onVignetteInput(slider.key, value)}
|
||||
onchange={onEffectsCommit}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if params.effects.vignette.mode !== 'light'}
|
||||
<label class="flex items-center justify-between gap-2 text-xs">
|
||||
<span class="text-text">Цвет виньетки</span>
|
||||
<input
|
||||
type="color"
|
||||
class="border-border bg-bg h-6 w-10 cursor-pointer rounded border disabled:opacity-40"
|
||||
value={params.effects.vignette.color}
|
||||
title="Цвет виньетки"
|
||||
disabled={!canEdit}
|
||||
oninput={(e) => onVignetteColor(e.currentTarget.value)}
|
||||
onchange={onEffectsCommit}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-border space-y-2.5 border-t pt-3">
|
||||
<div class="text-text text-xs font-semibold">Зерно</div>
|
||||
{#each GRAIN_SLIDERS as slider (slider.key)}
|
||||
<EditSlider
|
||||
id="grain-{slider.key}"
|
||||
label={slider.label}
|
||||
value={params.effects.grain[slider.key]}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
resetValue={slider.key === 'size' ? 25 : slider.key === 'roughness' ? 50 : 0}
|
||||
disabled={!canEdit}
|
||||
oninput={(value) => onGrainInput(slider.key, value)}
|
||||
onchange={onEffectsCommit}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if busy}
|
||||
<p class="text-text-subtle text-xs">Сохраняем на сервере…</p>
|
||||
{:else if dirty && !busy}
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
const canEditPhoto = $derived(
|
||||
canEdit || (photo?.uploader_id === auth.user?.id && role !== 'viewer')
|
||||
);
|
||||
// Редактирование доступно только из просмотрщика: в режиме сетки вкладка
|
||||
// «Редактирование» скрыта, панель показывает «Сведения». Сохранённая
|
||||
// вкладка при этом не теряется — она снова станет активной в просмотрщике.
|
||||
const activeTab = $derived(viewer ? tab : 'info');
|
||||
|
||||
let editingName = $state(false);
|
||||
let nameDraft = $state('');
|
||||
@@ -242,26 +246,28 @@ const tabClass =
|
||||
role="tab"
|
||||
id="photo-tab-info"
|
||||
aria-controls="photo-panel-info"
|
||||
aria-selected={tab === 'info'}
|
||||
aria-selected={activeTab === 'info'}
|
||||
class={tabClass}
|
||||
onclick={() => (tab = 'info')}
|
||||
>
|
||||
Сведения
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="photo-tab-edit"
|
||||
aria-controls="photo-panel-edit"
|
||||
aria-selected={tab === 'edit'}
|
||||
class={tabClass}
|
||||
onclick={() => (tab = 'edit')}
|
||||
>
|
||||
Редактирование
|
||||
</button>
|
||||
{#if viewer}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="photo-tab-edit"
|
||||
aria-controls="photo-panel-edit"
|
||||
aria-selected={activeTab === 'edit'}
|
||||
class={tabClass}
|
||||
onclick={() => (tab = 'edit')}
|
||||
>
|
||||
Редактирование
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if tab === 'edit'}
|
||||
{#if activeTab === 'edit'}
|
||||
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
|
||||
<PhotoEditPanel
|
||||
{photo}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import { applyColor, applyLight, initLightWasm } from '$lib/light-wasm';
|
||||
import { WebGLPreview } from '$lib/webgl-preview';
|
||||
import {
|
||||
applyEffectsToPixels,
|
||||
effectsSeed,
|
||||
isEmptyColor,
|
||||
isEmptyEditParams,
|
||||
isEmptyLight,
|
||||
@@ -296,7 +298,10 @@
|
||||
viewport: { width, height },
|
||||
dest,
|
||||
light: liveParams.light,
|
||||
color: liveParams.color
|
||||
color: liveParams.color,
|
||||
effects: liveParams.effects,
|
||||
seed: effectsSeed(current?.id ?? ''),
|
||||
lowQuality: previewLow
|
||||
});
|
||||
if (ok) return;
|
||||
useGL = false;
|
||||
@@ -325,6 +330,14 @@
|
||||
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);
|
||||
// CPU-фолбэк считает только попиксельные эффекты: виньетку и зерно.
|
||||
applyEffectsToPixels(
|
||||
frame.data,
|
||||
liveParams.effects,
|
||||
bw,
|
||||
bh,
|
||||
effectsSeed(current?.id ?? '')
|
||||
);
|
||||
ctx.putImageData(frame, 0, 0);
|
||||
}
|
||||
|
||||
@@ -343,6 +356,7 @@
|
||||
scale;
|
||||
liveParams.light;
|
||||
liveParams.color;
|
||||
liveParams.effects;
|
||||
displaySrc;
|
||||
previewLow;
|
||||
useGL;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyColorToPixels,
|
||||
applyGrainToPixels,
|
||||
applyVignetteToPixels,
|
||||
buildCurveLUT,
|
||||
curveSvgPath,
|
||||
effectiveCurvePoints,
|
||||
effectsSeed,
|
||||
emptyColor,
|
||||
emptyEditParams,
|
||||
emptyEffects,
|
||||
isEmptyColor,
|
||||
isEmptyEditParams,
|
||||
isEmptyEffects,
|
||||
normalizeEditParams,
|
||||
normalizeVignette,
|
||||
readEditParams,
|
||||
whiteBalanceFromRGB
|
||||
} from './edit';
|
||||
@@ -186,6 +192,187 @@ describe('цветовые параметры', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('эффекты', () => {
|
||||
function uniform(width: number, height: number, value = 128): Uint8ClampedArray {
|
||||
const pixels = new Uint8ClampedArray(width * height * 4);
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
pixels[i] = value;
|
||||
pixels[i + 1] = value;
|
||||
pixels[i + 2] = value;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
function channel(pixels: Uint8ClampedArray, width: number, x: number, y: number): [number, number, number] {
|
||||
const i = (y * width + x) * 4;
|
||||
return [pixels[i], pixels[i + 1], pixels[i + 2]];
|
||||
}
|
||||
|
||||
it('зажимает значения и заполняет дефолты', () => {
|
||||
const params = normalizeEditParams({
|
||||
light: emptyEditParams().light,
|
||||
color: emptyColor(),
|
||||
effects: {
|
||||
texture: 500,
|
||||
clarity: -500,
|
||||
dehaze: 50,
|
||||
vignette: {
|
||||
mode: 'weird' as never,
|
||||
amount: 200,
|
||||
midpoint: -10,
|
||||
feather: 300,
|
||||
roundness: 500,
|
||||
highlights: -5,
|
||||
color: 'red'
|
||||
},
|
||||
grain: { amount: -10, size: 500, roughness: -5 }
|
||||
}
|
||||
});
|
||||
expect(params.effects.texture).toBe(100);
|
||||
expect(params.effects.clarity).toBe(-100);
|
||||
expect(params.effects.dehaze).toBe(50);
|
||||
expect(params.effects.vignette.mode).toBe('light');
|
||||
expect(params.effects.vignette.amount).toBe(100);
|
||||
expect(params.effects.vignette.midpoint).toBe(0);
|
||||
expect(params.effects.vignette.feather).toBe(100);
|
||||
expect(params.effects.vignette.roundness).toBe(100);
|
||||
expect(params.effects.vignette.highlights).toBe(0);
|
||||
expect(params.effects.vignette.color).toBe('#000000');
|
||||
expect(params.effects.grain.amount).toBe(0);
|
||||
expect(params.effects.grain.size).toBe(100);
|
||||
expect(params.effects.grain.roughness).toBe(0);
|
||||
});
|
||||
|
||||
it('нормализует цвет виньетки', () => {
|
||||
expect(normalizeVignette({ color: '#AABBCC' }).color).toBe('#aabbcc');
|
||||
expect(normalizeVignette({ color: '#abc' }).color).toBe('#aabbcc');
|
||||
expect(normalizeVignette({ color: 'нет' }).color).toBe('#000000');
|
||||
expect(normalizeVignette({}).midpoint).toBe(50);
|
||||
expect(normalizeVignette({}).feather).toBe(50);
|
||||
});
|
||||
|
||||
it('считает пустыми эффекты без силы', () => {
|
||||
expect(isEmptyEffects(emptyEffects())).toBe(true);
|
||||
expect(isEmptyEffects({ ...emptyEffects(), texture: 1 })).toBe(false);
|
||||
const noVignettePower = emptyEffects();
|
||||
noVignettePower.vignette.amount = 0;
|
||||
noVignettePower.vignette.midpoint = 90;
|
||||
noVignettePower.vignette.color = '#ff0000';
|
||||
expect(isEmptyEffects(noVignettePower)).toBe(true);
|
||||
const grain = emptyEffects();
|
||||
grain.grain.amount = 1;
|
||||
expect(isEmptyEffects(grain)).toBe(false);
|
||||
});
|
||||
|
||||
it('читает эффекты из метаданных и не теряет свет и цвет', () => {
|
||||
const photo = {
|
||||
edit_params: {
|
||||
light: { exposure: 0.5 },
|
||||
color: { temperature: 20 },
|
||||
effects: { texture: 30, grain: { amount: 10 } }
|
||||
}
|
||||
} as unknown as Photo;
|
||||
const params = readEditParams(photo);
|
||||
expect(params.light.exposure).toBe(0.5);
|
||||
expect(params.color.temperature).toBe(20);
|
||||
expect(params.effects.texture).toBe(30);
|
||||
expect(params.effects.grain.amount).toBe(10);
|
||||
expect(params.effects.grain.size).toBe(25);
|
||||
expect(isEmptyEditParams(params)).toBe(false);
|
||||
});
|
||||
|
||||
it('затемняет края и не трогает центр', () => {
|
||||
const pixels = uniform(9, 9);
|
||||
applyVignetteToPixels(
|
||||
pixels,
|
||||
{ ...emptyEffects().vignette, amount: 100 },
|
||||
9,
|
||||
9
|
||||
);
|
||||
const center = channel(pixels, 9, 4, 4);
|
||||
const corner = channel(pixels, 9, 0, 0);
|
||||
expect(center).toEqual([128, 128, 128]);
|
||||
expect(corner[0]).toBeLessThan(center[0]);
|
||||
});
|
||||
|
||||
it('осветляет края при отрицательной силе', () => {
|
||||
const pixels = uniform(9, 9);
|
||||
applyVignetteToPixels(
|
||||
pixels,
|
||||
{ ...emptyEffects().vignette, amount: -100 },
|
||||
9,
|
||||
9
|
||||
);
|
||||
expect(channel(pixels, 9, 0, 0)[0]).toBeGreaterThan(128);
|
||||
});
|
||||
|
||||
it('тонирует и заливает края цветом', () => {
|
||||
const tinted = uniform(9, 9);
|
||||
applyVignetteToPixels(
|
||||
tinted,
|
||||
{
|
||||
...emptyEffects().vignette,
|
||||
mode: 'color',
|
||||
amount: 100,
|
||||
color: '#ff0000'
|
||||
},
|
||||
9,
|
||||
9
|
||||
);
|
||||
const colorCorner = channel(tinted, 9, 0, 0);
|
||||
expect(colorCorner[0]).toBeGreaterThan(colorCorner[1]);
|
||||
expect(colorCorner[0]).toBeGreaterThan(colorCorner[2]);
|
||||
expect(channel(tinted, 9, 4, 4)).toEqual([128, 128, 128]);
|
||||
|
||||
const painted = uniform(9, 9);
|
||||
applyVignetteToPixels(
|
||||
painted,
|
||||
{
|
||||
...emptyEffects().vignette,
|
||||
mode: 'colorize',
|
||||
amount: 100,
|
||||
color: '#ff0000'
|
||||
},
|
||||
9,
|
||||
9
|
||||
);
|
||||
expect(channel(painted, 9, 0, 0)).toEqual([255, 0, 0]);
|
||||
});
|
||||
|
||||
it('не трогает прозрачные пиксели', () => {
|
||||
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
|
||||
applyVignetteToPixels(
|
||||
pixels,
|
||||
{ ...emptyEffects().vignette, amount: 100 },
|
||||
1,
|
||||
1
|
||||
);
|
||||
expect(Array.from(pixels)).toEqual([10, 20, 30, 0]);
|
||||
});
|
||||
|
||||
it('делает зерно детерминированным', () => {
|
||||
const seed = effectsSeed('photo-1');
|
||||
const first = uniform(16, 16);
|
||||
const second = uniform(16, 16);
|
||||
const grain = { ...emptyEffects().grain, amount: 80 };
|
||||
applyGrainToPixels(first, grain, 16, 16, seed);
|
||||
applyGrainToPixels(second, grain, 16, 16, seed);
|
||||
expect(Array.from(first)).toEqual(Array.from(second));
|
||||
expect(first[0]).not.toBe(128);
|
||||
|
||||
const other = uniform(16, 16);
|
||||
applyGrainToPixels(other, grain, 16, 16, effectsSeed('photo-2'));
|
||||
expect(Array.from(other)).not.toEqual(Array.from(first));
|
||||
});
|
||||
|
||||
it('не меняет кадр при нулевой силе зерна', () => {
|
||||
const pixels = uniform(8, 8);
|
||||
applyGrainToPixels(pixels, emptyEffects().grain, 8, 8, effectsSeed('photo-1'));
|
||||
expect(Array.from(pixels)).toEqual(Array.from(uniform(8, 8)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('кривая', () => {
|
||||
const medium = [
|
||||
{ x: 0, y: 0 },
|
||||
|
||||
+340
-6
@@ -77,9 +77,49 @@ export interface ColorParams {
|
||||
correction: ColorCorrection;
|
||||
}
|
||||
|
||||
export type VignetteMode = 'light' | 'color' | 'colorize';
|
||||
|
||||
/** Параметры виньетки: геометрия общая для всех режимов. */
|
||||
export interface VignetteParams {
|
||||
/** Режим: «Свет», «Цвет» или «Закарасить». */
|
||||
mode: VignetteMode;
|
||||
/** Сила виньетки, -100..100. */
|
||||
amount: number;
|
||||
/** Средняя точка, 0..100. */
|
||||
midpoint: number;
|
||||
/** Растушёвка, 0..100. */
|
||||
feather: number;
|
||||
/** Округлость, -100..100. */
|
||||
roundness: number;
|
||||
/** Защита светов, 0..100. */
|
||||
highlights: number;
|
||||
/** Цвет виньетки для режимов color/colorize, "#rrggbb". */
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Параметры зерна. */
|
||||
export interface GrainParams {
|
||||
/** Сила зерна, 0..100. */
|
||||
amount: number;
|
||||
/** Размер зерна, 0..100. */
|
||||
size: number;
|
||||
/** Шероховатость, 0..100. */
|
||||
roughness: number;
|
||||
}
|
||||
|
||||
/** Блок «Эффекты»: пространственные эффекты, виньетка и зерно. */
|
||||
export interface EffectsParams {
|
||||
texture: number;
|
||||
clarity: number;
|
||||
dehaze: number;
|
||||
vignette: VignetteParams;
|
||||
grain: GrainParams;
|
||||
}
|
||||
|
||||
export interface EditParams {
|
||||
light: LightParams;
|
||||
color: ColorParams;
|
||||
effects: EffectsParams;
|
||||
}
|
||||
|
||||
/** Полосы цветового микшера: ключ, подпись и центр оттенка в градусах. */
|
||||
@@ -106,6 +146,44 @@ export const COLOR_SLIDERS = [
|
||||
|
||||
export type ColorSliderKey = (typeof COLOR_SLIDERS)[number]['key'];
|
||||
|
||||
/** Ползунки пространственных эффектов. */
|
||||
export const EFFECT_SLIDERS = [
|
||||
{ key: 'texture', label: 'Текстура', min: -100, max: 100, step: 1 },
|
||||
{ key: 'clarity', label: 'Чёткость', min: -100, max: 100, step: 1 },
|
||||
{ key: 'dehaze', label: 'Убрать дымку', min: -100, max: 100, step: 1 }
|
||||
] as const;
|
||||
|
||||
export type EffectSliderKey = (typeof EFFECT_SLIDERS)[number]['key'];
|
||||
|
||||
/** Ползунки виньетки: общие для всех режимов. */
|
||||
export const VIGNETTE_SLIDERS = [
|
||||
{ key: 'amount', label: 'Виньетка', min: -100, max: 100, step: 1 },
|
||||
{ key: 'midpoint', label: 'Средняя точка', min: 0, max: 100, step: 1 },
|
||||
{ key: 'feather', label: 'Растушёвка', min: 0, max: 100, step: 1 },
|
||||
{ key: 'roundness', label: 'Округлость', min: -100, max: 100, step: 1 },
|
||||
{ key: 'highlights', label: 'Света', min: 0, max: 100, step: 1 }
|
||||
] as const;
|
||||
|
||||
export type VignetteSliderKey = (typeof VIGNETTE_SLIDERS)[number]['key'];
|
||||
|
||||
/** Ползунки зерна. */
|
||||
export const GRAIN_SLIDERS = [
|
||||
{ key: 'amount', label: 'Зерно', min: 0, max: 100, step: 1 },
|
||||
{ key: 'size', label: 'Размер', min: 0, max: 100, step: 1 },
|
||||
{ key: 'roughness', label: 'Шероховатость', min: 0, max: 100, step: 1 }
|
||||
] as const;
|
||||
|
||||
export type GrainSliderKey = (typeof GRAIN_SLIDERS)[number]['key'];
|
||||
|
||||
/** Вкладки виньетки. */
|
||||
export const VIGNETTE_MODES = [
|
||||
{ key: 'light', label: 'Свет' },
|
||||
{ key: 'color', label: 'Цвет' },
|
||||
{ key: 'colorize', label: 'Закарасить' }
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_VIGNETTE_COLOR = '#000000';
|
||||
|
||||
/** Колёса точечного цвета. */
|
||||
export const GRADING_WHEELS = [
|
||||
{ key: 'shadows', label: 'Тени' },
|
||||
@@ -173,7 +251,7 @@ export function emptyLight(): LightParams {
|
||||
}
|
||||
|
||||
export function emptyEditParams(): EditParams {
|
||||
return { light: emptyLight(), color: emptyColor() };
|
||||
return { light: emptyLight(), color: emptyColor(), effects: emptyEffects() };
|
||||
}
|
||||
|
||||
function emptyMixerBand(): MixerBand {
|
||||
@@ -207,6 +285,25 @@ export function emptyColor(): ColorParams {
|
||||
};
|
||||
}
|
||||
|
||||
/** Пустые эффекты: все силы нулевые, геометрия — по умолчанию. */
|
||||
export function emptyEffects(): EffectsParams {
|
||||
return {
|
||||
texture: 0,
|
||||
clarity: 0,
|
||||
dehaze: 0,
|
||||
vignette: {
|
||||
mode: 'light',
|
||||
amount: 0,
|
||||
midpoint: 50,
|
||||
feather: 50,
|
||||
roundness: 0,
|
||||
highlights: 0,
|
||||
color: DEFAULT_VIGNETTE_COLOR
|
||||
},
|
||||
grain: { amount: 0, size: 25, roughness: 50 }
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -234,6 +331,7 @@ export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined
|
||||
export function normalizeEditParams(params: {
|
||||
light?: Partial<LightParams>;
|
||||
color?: Partial<ColorParams>;
|
||||
effects?: Partial<EffectsParams>;
|
||||
}): EditParams {
|
||||
const light = params?.light ?? emptyLight();
|
||||
return {
|
||||
@@ -251,7 +349,8 @@ export function normalizeEditParams(params: {
|
||||
blue: normalizeCurve(light.curve?.blue)
|
||||
}
|
||||
},
|
||||
color: normalizeColor(params?.color)
|
||||
color: normalizeColor(params?.color),
|
||||
effects: normalizeEffects(params?.effects)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,6 +405,53 @@ export function normalizeColor(color?: Partial<ColorParams>): ColorParams {
|
||||
};
|
||||
}
|
||||
|
||||
const VIGNETTE_MODE_KEYS: VignetteMode[] = ['light', 'color', 'colorize'];
|
||||
|
||||
/** Приводит hex-цвет виньетки к виду "#rrggbb"; невалидное значение — чёрный. */
|
||||
export function normalizeVignetteColor(value: unknown): string {
|
||||
if (typeof value !== 'string') return DEFAULT_VIGNETTE_COLOR;
|
||||
const raw = value.trim().replace(/^#/, '');
|
||||
const hex = raw.length === 3 ? raw.replace(/./g, (c) => c + c) : raw;
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return DEFAULT_VIGNETTE_COLOR;
|
||||
return `#${hex.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** Приводит параметры виньетки к допустимым диапазонам и дефолтам. */
|
||||
export function normalizeVignette(v?: Partial<VignetteParams>): VignetteParams {
|
||||
const mode = VIGNETTE_MODE_KEYS.includes(v?.mode as VignetteMode)
|
||||
? (v?.mode as VignetteMode)
|
||||
: 'light';
|
||||
return {
|
||||
mode,
|
||||
amount: clamp(v?.amount ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
midpoint: clamp(v?.midpoint ?? 50, 0, MAX_PERCENT),
|
||||
feather: clamp(v?.feather ?? 50, 0, MAX_PERCENT),
|
||||
roundness: clamp(v?.roundness ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
highlights: clamp(v?.highlights ?? 0, 0, MAX_PERCENT),
|
||||
color: normalizeVignetteColor(v?.color)
|
||||
};
|
||||
}
|
||||
|
||||
/** Приводит параметры зерна к допустимым диапазонам и дефолтам. */
|
||||
export function normalizeGrain(g?: Partial<GrainParams>): GrainParams {
|
||||
return {
|
||||
amount: clamp(g?.amount ?? 0, 0, MAX_PERCENT),
|
||||
size: clamp(g?.size ?? 25, 0, MAX_PERCENT),
|
||||
roughness: clamp(g?.roughness ?? 50, 0, MAX_PERCENT)
|
||||
};
|
||||
}
|
||||
|
||||
/** Приводит эффекты к допустимым диапазонам и дефолтам. */
|
||||
export function normalizeEffects(effects?: Partial<EffectsParams>): EffectsParams {
|
||||
return {
|
||||
texture: clamp(effects?.texture ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
clarity: clamp(effects?.clarity ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
dehaze: clamp(effects?.dehaze ?? 0, -MAX_PERCENT, MAX_PERCENT),
|
||||
vignette: normalizeVignette(effects?.vignette),
|
||||
grain: normalizeGrain(effects?.grain)
|
||||
};
|
||||
}
|
||||
|
||||
/** Пустой ли блок «Свет». */
|
||||
export function isEmptyLight(light: LightParams): boolean {
|
||||
if (
|
||||
@@ -341,7 +487,21 @@ export function isColorFast(color: ColorParams): boolean {
|
||||
|
||||
/** Пустые ли параметры: фотография не тронута. */
|
||||
export function isEmptyEditParams(params: EditParams): boolean {
|
||||
return isEmptyLight(params.light) && isEmptyColor(params.color);
|
||||
return (
|
||||
isEmptyLight(params.light) && isEmptyColor(params.color) && isEmptyEffects(params.effects)
|
||||
);
|
||||
}
|
||||
|
||||
/** Пусты ли эффекты: все силы нулевые (дефолты геометрии правкой не считаются). */
|
||||
export function isEmptyEffects(effects?: EffectsParams): boolean {
|
||||
if (!effects) return true;
|
||||
return (
|
||||
!effects.texture &&
|
||||
!effects.clarity &&
|
||||
!effects.dehaze &&
|
||||
!effects.vignette?.amount &&
|
||||
!effects.grain?.amount
|
||||
);
|
||||
}
|
||||
|
||||
/** Пусты ли цветовые параметры: цвет не тронут. */
|
||||
@@ -386,16 +546,17 @@ export function whiteBalanceFromRGB(
|
||||
/** Читает параметры правок из метаданных фото. */
|
||||
export function readEditParams(photo: Photo | null | undefined): EditParams {
|
||||
const raw = photo?.edit_params as
|
||||
| { light?: Partial<LightParams>; color?: Partial<ColorParams> }
|
||||
| { light?: Partial<LightParams>; color?: Partial<ColorParams>; effects?: Partial<EffectsParams> }
|
||||
| undefined;
|
||||
if (!raw?.light && !raw?.color) return emptyEditParams();
|
||||
if (!raw?.light && !raw?.color && !raw?.effects) return emptyEditParams();
|
||||
return normalizeEditParams({
|
||||
light: {
|
||||
...emptyLight(),
|
||||
...raw?.light,
|
||||
curve: raw?.light?.curve ?? {}
|
||||
},
|
||||
color: raw?.color ?? emptyColor()
|
||||
color: raw?.color ?? emptyColor(),
|
||||
effects: raw?.effects ?? emptyEffects()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -758,3 +919,176 @@ export function applyColorToPixels(data: Uint8ClampedArray, color: ColorParams):
|
||||
data[i + 2] = Math.round(clamp(bb, 0, 255));
|
||||
}
|
||||
}
|
||||
|
||||
/** Разбирает "#rrggbb" в RGB (0..255); на входе уже нормализованный цвет. */
|
||||
function parseHexColor(hex: string): [number, number, number] {
|
||||
const m = /^#([0-9a-f]{6})$/.exec(hex);
|
||||
if (!m) return [0, 0, 0];
|
||||
const value = parseInt(m[1], 16);
|
||||
return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
|
||||
}
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||
if (edge1 <= edge0) return x < edge0 ? 0 : 1;
|
||||
const t = clamp01((x - edge0) / (edge1 - edge0));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/** Яркость пикселя (та же формула, что в серверном рендере). */
|
||||
function luma(r: number, g: number, b: number): number {
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет виньетку к пикселям (в том же массиве). Математика повторяет
|
||||
* серверную: нормализованные координаты, суперэллипс по округлости,
|
||||
* smoothstep по средней точке и растушёвке, три режима.
|
||||
*/
|
||||
export function applyVignetteToPixels(
|
||||
data: Uint8ClampedArray,
|
||||
vignette: VignetteParams,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
if (!width || !height || !vignette?.amount) return;
|
||||
const p = 2 * Math.pow(6, vignette.roundness / 100);
|
||||
const start = 0.15 + 0.85 * (vignette.midpoint / 100);
|
||||
const feather = 0.02 + 0.48 * (vignette.feather / 100);
|
||||
const amount = vignette.amount / 100;
|
||||
const highlights = vignette.highlights / 100;
|
||||
const tint = parseHexColor(vignette.color);
|
||||
const tintLuma = luma(tint[0], tint[1], tint[2]);
|
||||
const colorize = vignette.mode === 'colorize';
|
||||
const colored = colorize || vignette.mode === 'color';
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const dy = ((y + 0.5) / height) * 2 - 1;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
if (data[i + 3] === 0) continue;
|
||||
const dx = ((x + 0.5) / width) * 2 - 1;
|
||||
const radius = Math.pow(
|
||||
Math.pow(Math.abs(dx), p) + Math.pow(Math.abs(dy), p),
|
||||
1 / p
|
||||
);
|
||||
const mask = smoothstep(start, start + feather, radius);
|
||||
if (mask <= 0) continue;
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const y0 = luma(r, g, b);
|
||||
const light = 1 - highlights * smoothstep(0.5, 1, y0 / 255);
|
||||
|
||||
if (!colored) {
|
||||
let factor = 1 - amount * 0.85 * mask;
|
||||
const protect = highlights * mask * smoothstep(0.5, 1, y0 / 255);
|
||||
factor += (1 - factor) * protect;
|
||||
data[i] = Math.round(clamp(r * factor, 0, 255));
|
||||
data[i + 1] = Math.round(clamp(g * factor, 0, 255));
|
||||
data[i + 2] = Math.round(clamp(b * factor, 0, 255));
|
||||
continue;
|
||||
}
|
||||
|
||||
const strength = Math.abs(amount) * mask * light;
|
||||
if (strength <= 0) continue;
|
||||
if (colorize) {
|
||||
data[i] = Math.round(clamp(r + (tint[0] - r) * strength, 0, 255));
|
||||
data[i + 1] = Math.round(clamp(g + (tint[1] - g) * strength, 0, 255));
|
||||
data[i + 2] = Math.round(clamp(b + (tint[2] - b) * strength, 0, 255));
|
||||
} else {
|
||||
const target = [tint[0] - tintLuma + y0, tint[1] - tintLuma + y0, tint[2] - tintLuma + y0];
|
||||
data[i] = Math.round(clamp(r + (target[0] - r) * strength, 0, 255));
|
||||
data[i + 1] = Math.round(clamp(g + (target[1] - g) * strength, 0, 255));
|
||||
data[i + 2] = Math.round(clamp(b + (target[2] - b) * strength, 0, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** FNV-1a 64-bit: seed зерна, одинаковый с серверным рендером (photo.id). */
|
||||
export function effectsSeed(photoId: string): bigint {
|
||||
let hash = 0xcbf29ce484222325n;
|
||||
const bytes = new TextEncoder().encode(photoId);
|
||||
for (const byte of bytes) {
|
||||
hash ^= BigInt(byte);
|
||||
hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function mix32(x: number): number {
|
||||
x = Math.imul(x ^ (x >>> 16), 0x7feb352d);
|
||||
x = Math.imul(x ^ (x >>> 15), 0x846ca68b);
|
||||
return (x ^ (x >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
/** Детерминированный шум в -1..1 по координатам пикселя/ячейки. */
|
||||
function hashNoise(seedLo: number, seedHi: number, x: number, y: number): number {
|
||||
let h = seedLo ^ mix32(seedHi ^ mix32(Math.imul(x, 0x9e3779b1) ^ Math.imul(y, 0x85ebca6b)));
|
||||
h = mix32(h);
|
||||
return (h / 0xffffffff) * 2 - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет зерно к пикселям (в том же массиве). Детерминировано от seed:
|
||||
* повторный вызов с теми же аргументами даёт тот же рисунок.
|
||||
*/
|
||||
export function applyGrainToPixels(
|
||||
data: Uint8ClampedArray,
|
||||
grain: GrainParams,
|
||||
width: number,
|
||||
height: number,
|
||||
seed: bigint
|
||||
): void {
|
||||
if (!width || !height || !grain?.amount) return;
|
||||
const minDim = Math.min(width, height);
|
||||
const cell = Math.max(1, Math.round((0.0005 + 0.004 * (grain.size / 100)) * minDim));
|
||||
const roughness = grain.roughness / 100;
|
||||
const strength = (grain.amount / 100) * 38;
|
||||
const seedLo = Number(seed & 0xffffffffn);
|
||||
const seedHi = Number((seed >> 32n) & 0xffffffffn);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const fy = y / cell;
|
||||
const iy = Math.floor(fy);
|
||||
const ty0 = fy - iy;
|
||||
const ty = ty0 * ty0 * (3 - 2 * ty0);
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
if (data[i + 3] === 0) continue;
|
||||
const fx = x / cell;
|
||||
const ix = Math.floor(fx);
|
||||
const tx0 = fx - ix;
|
||||
const tx = tx0 * tx0 * (3 - 2 * tx0);
|
||||
const v00 = hashNoise(seedLo, seedHi, ix, iy);
|
||||
const v10 = hashNoise(seedLo, seedHi, ix + 1, iy);
|
||||
const v01 = hashNoise(seedLo, seedHi, ix, iy + 1);
|
||||
const v11 = hashNoise(seedLo, seedHi, ix + 1, iy + 1);
|
||||
const value = v00 + (v10 - v00) * tx + (v01 - v00) * ty + (v00 - v10 - v01 + v11) * tx * ty;
|
||||
const white = hashNoise(seedLo, seedHi, x, y);
|
||||
const noise = value * (1 - roughness) + white * roughness;
|
||||
const y0 = luma(data[i], data[i + 1], data[i + 2]);
|
||||
const weight = 1 - Math.pow((2 * y0) / 255 - 1, 2);
|
||||
const delta = noise * strength * weight;
|
||||
data[i] = Math.round(clamp(data[i] + delta, 0, 255));
|
||||
data[i + 1] = Math.round(clamp(data[i + 1] + delta, 0, 255));
|
||||
data[i + 2] = Math.round(clamp(data[i + 2] + delta, 0, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет эффекты к пикселям кадра (в том же массиве). CPU-фолбэк считает
|
||||
* только попиксельные эффекты — виньетку и зерно; пространственные эффекты
|
||||
* появляются после серверного рендера.
|
||||
*/
|
||||
export function applyEffectsToPixels(
|
||||
data: Uint8ClampedArray,
|
||||
effects: EffectsParams,
|
||||
width: number,
|
||||
height: number,
|
||||
seed: bigint
|
||||
): void {
|
||||
applyVignetteToPixels(data, effects.vignette, width, height);
|
||||
applyGrainToPixels(data, effects.grain, width, height, seed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultSectionCollapse,
|
||||
EDITOR_SECTIONS_KEY,
|
||||
loadSectionCollapse,
|
||||
normalizeSectionCollapse,
|
||||
saveSectionCollapse
|
||||
} from './editor-sections';
|
||||
|
||||
function memoryStorage(initial: Record<string, string> = {}) {
|
||||
const data = new Map(Object.entries(initial));
|
||||
return {
|
||||
data,
|
||||
getItem(key: string) {
|
||||
return data.has(key) ? data.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
data.set(key, value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('секции редактора', () => {
|
||||
it('по умолчанию все секции свёрнуты', () => {
|
||||
const state = defaultSectionCollapse();
|
||||
for (const section of [
|
||||
'light',
|
||||
'curve',
|
||||
'color',
|
||||
'mixer',
|
||||
'pointColor',
|
||||
'correction',
|
||||
'effects'
|
||||
] as const) {
|
||||
expect(state[section]).toBe(true);
|
||||
}
|
||||
expect(loadSectionCollapse(null)).toEqual(defaultSectionCollapse());
|
||||
});
|
||||
|
||||
it('читает сохранённое состояние и восстанавливает недостающие ключи', () => {
|
||||
const storage = memoryStorage({
|
||||
[EDITOR_SECTIONS_KEY]: JSON.stringify({ effects: false, light: false, unknown: false })
|
||||
});
|
||||
const state = loadSectionCollapse(storage);
|
||||
expect(state.effects).toBe(false);
|
||||
expect(state.light).toBe(false);
|
||||
expect(state.color).toBe(true);
|
||||
expect('unknown' in state).toBe(false);
|
||||
});
|
||||
|
||||
it('игнорирует невалидный JSON и небулевы значения', () => {
|
||||
expect(
|
||||
loadSectionCollapse(memoryStorage({ [EDITOR_SECTIONS_KEY]: '{oops' }))
|
||||
).toEqual(defaultSectionCollapse());
|
||||
expect(normalizeSectionCollapse({ effects: 'no', color: 1 })).toEqual(
|
||||
defaultSectionCollapse()
|
||||
);
|
||||
expect(normalizeSectionCollapse(null)).toEqual(defaultSectionCollapse());
|
||||
});
|
||||
|
||||
it('сохраняет состояние в хранилище', () => {
|
||||
const storage = memoryStorage();
|
||||
const state = { ...defaultSectionCollapse(), effects: false, curve: false };
|
||||
saveSectionCollapse(state, storage);
|
||||
expect(storage.data.get(EDITOR_SECTIONS_KEY)).toBe(JSON.stringify(state));
|
||||
expect(loadSectionCollapse(storage)).toEqual(state);
|
||||
});
|
||||
|
||||
it('переживает недоступное хранилище', () => {
|
||||
const broken = {
|
||||
getItem() {
|
||||
throw new Error('denied');
|
||||
},
|
||||
setItem() {
|
||||
throw new Error('denied');
|
||||
}
|
||||
};
|
||||
expect(loadSectionCollapse(broken)).toEqual(defaultSectionCollapse());
|
||||
expect(() => saveSectionCollapse(defaultSectionCollapse(), broken)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Состояние сворачиваемых секций редактора фото.
|
||||
*
|
||||
* Все секции по умолчанию свёрнуты, а выбор пользователя хранится в браузере
|
||||
* (localStorage), поэтому панель открывается в том же виде после перезагрузки
|
||||
* и переключения фото. Серверные настройки для этого не нужны.
|
||||
*/
|
||||
|
||||
export const EDITOR_SECTIONS = [
|
||||
'light',
|
||||
'curve',
|
||||
'color',
|
||||
'mixer',
|
||||
'pointColor',
|
||||
'correction',
|
||||
'effects'
|
||||
] as const;
|
||||
|
||||
export type EditorSection = (typeof EDITOR_SECTIONS)[number];
|
||||
|
||||
/** true — секция свёрнута. */
|
||||
export type SectionCollapse = Record<EditorSection, boolean>;
|
||||
|
||||
export const EDITOR_SECTIONS_KEY = 'galo.edit.sections';
|
||||
|
||||
/** По умолчанию все секции свёрнуты. */
|
||||
export function defaultSectionCollapse(): SectionCollapse {
|
||||
return {
|
||||
light: true,
|
||||
curve: true,
|
||||
color: true,
|
||||
mixer: true,
|
||||
pointColor: true,
|
||||
correction: true,
|
||||
effects: true
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит сохранённое значение к полному набору секций: неизвестные ключи
|
||||
* отбрасываются, небулевы значения и отсутствующие ключи получают дефолт.
|
||||
*/
|
||||
export function normalizeSectionCollapse(raw: unknown): SectionCollapse {
|
||||
const defaults = defaultSectionCollapse();
|
||||
if (!isRecord(raw)) return defaults;
|
||||
const out = defaults;
|
||||
for (const section of EDITOR_SECTIONS) {
|
||||
const value = raw[section];
|
||||
if (typeof value === 'boolean') out[section] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type ReadableStorage = Pick<Storage, 'getItem'>;
|
||||
type WritableStorage = Pick<Storage, 'setItem'>;
|
||||
|
||||
/** Читает состояние секций из браузера; без хранилища — дефолт. */
|
||||
export function loadSectionCollapse(
|
||||
storage?: ReadableStorage | null
|
||||
): SectionCollapse {
|
||||
const target = storage === undefined ? browserStorage() : storage;
|
||||
if (!target) return defaultSectionCollapse();
|
||||
try {
|
||||
const raw = target.getItem(EDITOR_SECTIONS_KEY);
|
||||
if (!raw) return defaultSectionCollapse();
|
||||
return normalizeSectionCollapse(JSON.parse(raw));
|
||||
} catch {
|
||||
return defaultSectionCollapse();
|
||||
}
|
||||
}
|
||||
|
||||
/** Сохраняет состояние секций в браузере; ошибки хранилища игнорируются. */
|
||||
export function saveSectionCollapse(
|
||||
state: SectionCollapse,
|
||||
storage?: WritableStorage | null
|
||||
): void {
|
||||
const target = storage === undefined ? browserStorage() : storage;
|
||||
if (!target) return;
|
||||
try {
|
||||
target.setItem(EDITOR_SECTIONS_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Приватный режим или переполненное хранилище не должны ломать панель.
|
||||
}
|
||||
}
|
||||
|
||||
function browserStorage(): Storage | null {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||
}
|
||||
+673
-101
@@ -4,31 +4,37 @@ import {
|
||||
hslToRGB,
|
||||
MIXER_BANDS,
|
||||
type ColorParams,
|
||||
type EffectsParams,
|
||||
type LightParams
|
||||
} from './edit';
|
||||
|
||||
/**
|
||||
* GPU-превью правок на WebGL2.
|
||||
*
|
||||
* Шейдер повторяет математику серверного рендера: свет (экспозиция, контраст,
|
||||
* света/тени, белые/чёрные, кривые через LUT) и цвет (баланс белого, HSL,
|
||||
* микшер, колористика, коррекция). В отличие от попиксельного CPU-фильтра,
|
||||
* кадр целиком считает GPU, поэтому ползунки не блокируют интерфейс.
|
||||
* Проход 1 повторяет серверную математику света и цвета и рисует кадр в
|
||||
* FBO-текстуру. Затем считаются размытия яркости (текстура — мелкие детали,
|
||||
* чёткость — средние частоты), карта дымки готовится на CPU. Финальный проход
|
||||
* применяет пространственные эффекты, виньетку и зерно и выводит кадр на экран.
|
||||
*
|
||||
* Превью приблизительное: источник истины — серверный рендер.
|
||||
*/
|
||||
|
||||
const VERT = `#version 300 es
|
||||
in vec2 a_pos;
|
||||
layout(location = 0) in vec2 a_pos;
|
||||
uniform vec2 u_uvOffset;
|
||||
uniform vec2 u_uvScale;
|
||||
out vec2 v_uv;
|
||||
out vec2 v_tex;
|
||||
void main() {
|
||||
vec2 uv = a_pos * 0.5 + 0.5;
|
||||
v_tex = uv;
|
||||
v_uv = u_uvOffset + uv * u_uvScale;
|
||||
gl_Position = vec4(a_pos, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
const SCENE_FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 outColor;
|
||||
@@ -196,6 +202,181 @@ void main() {
|
||||
outColor = vec4(clamp(outc / 255.0, 0.0, 1.0), tex.a);
|
||||
}`;
|
||||
|
||||
const BLUR_FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
|
||||
in vec2 v_tex;
|
||||
out vec4 outColor;
|
||||
|
||||
uniform sampler2D u_src;
|
||||
uniform vec2 u_step;
|
||||
uniform int u_radius;
|
||||
|
||||
const int MAX_RADIUS = 32;
|
||||
|
||||
float luma(vec3 c) {
|
||||
return dot(c, vec3(0.299, 0.587, 0.114));
|
||||
}
|
||||
|
||||
void main() {
|
||||
float sum = 0.0;
|
||||
float count = 0.0;
|
||||
int radius = u_radius;
|
||||
if (radius > MAX_RADIUS) radius = MAX_RADIUS;
|
||||
for (int i = 0; i <= MAX_RADIUS * 2; i++) {
|
||||
int d = i - MAX_RADIUS;
|
||||
if (d < -radius || d > radius) continue;
|
||||
sum += luma(texture(u_src, v_tex + u_step * float(d)).rgb);
|
||||
count += 1.0;
|
||||
}
|
||||
outColor = vec4(vec3(sum / max(count, 1.0)), 1.0);
|
||||
}`;
|
||||
|
||||
const FINAL_FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
|
||||
in vec2 v_uv;
|
||||
in vec2 v_tex;
|
||||
out vec4 outColor;
|
||||
|
||||
uniform sampler2D u_scene;
|
||||
uniform sampler2D u_blurTex;
|
||||
uniform sampler2D u_blurClar;
|
||||
uniform sampler2D u_dehaze;
|
||||
|
||||
uniform float u_hasBlurTex;
|
||||
uniform float u_hasBlurClar;
|
||||
uniform float u_texture;
|
||||
uniform float u_clarity;
|
||||
uniform float u_dehazeAmount;
|
||||
uniform vec3 u_dehazeA;
|
||||
|
||||
uniform vec2 u_imageSize;
|
||||
uniform float u_grainCell;
|
||||
uniform uint u_seedLo;
|
||||
uniform uint u_seedHi;
|
||||
uniform float u_grainAmount;
|
||||
uniform float u_grainSize;
|
||||
uniform float u_grainRoughness;
|
||||
|
||||
uniform float u_vigAmount;
|
||||
uniform float u_vigMidpoint;
|
||||
uniform float u_vigFeather;
|
||||
uniform float u_vigRoundness;
|
||||
uniform float u_vigHighlights;
|
||||
uniform int u_vigMode;
|
||||
uniform vec3 u_vigColor;
|
||||
|
||||
float luma(vec3 c) {
|
||||
return dot(c, vec3(0.299, 0.587, 0.114));
|
||||
}
|
||||
|
||||
uint mix32(uint x) {
|
||||
x ^= x >> 16;
|
||||
x *= 0x7feb352du;
|
||||
x ^= x >> 15;
|
||||
x *= 0x846ca68bu;
|
||||
x ^= x >> 16;
|
||||
return x;
|
||||
}
|
||||
|
||||
float hashNoise(uint lo, uint hi, float x, float y) {
|
||||
uint ux = uint(int(x));
|
||||
uint uy = uint(int(y));
|
||||
uint h = mix32(lo ^ mix32(hi ^ mix32(ux * 0x9e3779b1u ^ uy * 0x85ebca6bu)));
|
||||
return float(h) / 4294967295.0 * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 scene = texture(u_scene, v_tex);
|
||||
if (scene.a == 0.0) {
|
||||
outColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
vec3 c = scene.rgb * 255.0;
|
||||
float y = luma(c);
|
||||
|
||||
// Текстура и чёткость: локальный контраст по яркости.
|
||||
if (u_texture != 0.0 && u_hasBlurTex > 0.5) {
|
||||
float b = texture(u_blurTex, v_tex).r * 255.0;
|
||||
c += (y - b) * u_texture;
|
||||
}
|
||||
if (u_clarity != 0.0 && u_hasBlurClar > 0.5) {
|
||||
float b = texture(u_blurClar, v_tex).r * 255.0;
|
||||
float d = clamp(y - b, -40.0, 40.0);
|
||||
float t = abs(2.0 * y / 255.0 - 1.0);
|
||||
float w = 1.0 - 0.6 * t * t;
|
||||
c += u_clarity * 1.25 * d * w;
|
||||
}
|
||||
|
||||
// Дымка: приближённо, по карте пропускания на уменьшенной копии.
|
||||
if (u_dehazeAmount != 0.0) {
|
||||
float transmission = max(texture(u_dehaze, v_uv).r, 0.1);
|
||||
if (u_dehazeAmount > 0.0) {
|
||||
vec3 cleaned = (c - u_dehazeA) / transmission + u_dehazeA;
|
||||
c = mix(c, cleaned, u_dehazeAmount);
|
||||
} else {
|
||||
c = c * (1.0 - 0.75 * -u_dehazeAmount) + u_dehazeA * (0.75 * -u_dehazeAmount);
|
||||
}
|
||||
}
|
||||
|
||||
c = clamp(c, 0.0, 255.0);
|
||||
float vy = luma(c);
|
||||
|
||||
// Виньетка.
|
||||
if (u_vigAmount != 0.0) {
|
||||
vec2 d = v_uv * 2.0 - 1.0;
|
||||
float p = 2.0 * pow(6.0, u_vigRoundness / 100.0);
|
||||
float radius = pow(pow(abs(d.x), p) + pow(abs(d.y), p), 1.0 / p);
|
||||
float start = 0.15 + 0.85 * u_vigMidpoint / 100.0;
|
||||
float feather = 0.02 + 0.48 * u_vigFeather / 100.0;
|
||||
float mask = clamp((radius - start) / feather, 0.0, 1.0);
|
||||
mask = mask * mask * (3.0 - 2.0 * mask);
|
||||
float hl = (u_vigHighlights / 100.0) * smoothstep(0.5, 1.0, vy / 255.0);
|
||||
float amount = u_vigAmount / 100.0;
|
||||
if (u_vigMode == 0) {
|
||||
float factor = 1.0 - amount * 0.85 * mask;
|
||||
float protect = hl * mask;
|
||||
factor += (1.0 - factor) * protect;
|
||||
c *= factor;
|
||||
} else {
|
||||
float strength = abs(amount) * mask * (1.0 - hl);
|
||||
vec3 tint = u_vigColor * 255.0;
|
||||
if (u_vigMode == 2) {
|
||||
c += (tint - c) * strength;
|
||||
} else {
|
||||
float tintY = luma(tint);
|
||||
vec3 target = tint - tintY + vy;
|
||||
c += (target - c) * strength;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Зерно: детерминированный шум в координатах изображения.
|
||||
if (u_grainAmount != 0.0) {
|
||||
vec2 pos = v_uv * u_imageSize;
|
||||
vec2 cell = vec2(max(1.0, u_grainCell));
|
||||
vec2 f = pos / cell;
|
||||
vec2 i = floor(f);
|
||||
vec2 fr = f - i;
|
||||
fr = fr * fr * (3.0 - 2.0 * fr);
|
||||
float v00 = hashNoise(u_seedLo, u_seedHi, i.x, i.y);
|
||||
float v10 = hashNoise(u_seedLo, u_seedHi, i.x + 1.0, i.y);
|
||||
float v01 = hashNoise(u_seedLo, u_seedHi, i.x, i.y + 1.0);
|
||||
float v11 = hashNoise(u_seedLo, u_seedHi, i.x + 1.0, i.y + 1.0);
|
||||
float value = mix(mix(v00, v10, fr.x), mix(v01, v11, fr.x), fr.y);
|
||||
float white = hashNoise(u_seedLo, u_seedHi, pos.x, pos.y);
|
||||
float roughness = u_grainRoughness / 100.0;
|
||||
float noise = value * (1.0 - roughness) + white * roughness;
|
||||
float weight = 1.0 - pow(2.0 * vy / 255.0 - 1.0, 2.0);
|
||||
c += noise * (u_grainAmount / 100.0) * 38.0 * weight;
|
||||
}
|
||||
|
||||
outColor = vec4(clamp(c / 255.0, 0.0, 1.0), scene.a);
|
||||
}`;
|
||||
|
||||
export interface PreviewRenderOptions {
|
||||
image: TexImageSource;
|
||||
imageVersion: string;
|
||||
@@ -206,6 +387,24 @@ export interface PreviewRenderOptions {
|
||||
dest: { x: number; y: number; width: number; height: number };
|
||||
light: LightParams;
|
||||
color: ColorParams;
|
||||
effects: EffectsParams;
|
||||
seed: bigint;
|
||||
lowQuality?: boolean;
|
||||
}
|
||||
|
||||
interface Target {
|
||||
texture: WebGLTexture;
|
||||
framebuffer: WebGLFramebuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface DehazeMap {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Карта пропускания, строки снизу вверх (как у FBO-текстур). */
|
||||
transmission: Uint8Array;
|
||||
atmosphere: [number, number, number];
|
||||
}
|
||||
|
||||
function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
|
||||
@@ -221,6 +420,36 @@ function compile(gl: WebGL2RenderingContext, type: number, source: string): WebG
|
||||
return shader;
|
||||
}
|
||||
|
||||
interface Program {
|
||||
program: WebGLProgram;
|
||||
uniforms: Record<string, WebGLUniformLocation | null>;
|
||||
}
|
||||
|
||||
function buildProgram(
|
||||
gl: WebGL2RenderingContext,
|
||||
fragment: string,
|
||||
uniformNames: string[]
|
||||
): Program {
|
||||
const vertex = compile(gl, gl.VERTEX_SHADER, VERT);
|
||||
const frag = compile(gl, gl.FRAGMENT_SHADER, fragment);
|
||||
if (!vertex || !frag) throw new Error('shader compile failed');
|
||||
const program = gl.createProgram();
|
||||
if (!program) throw new Error('program create failed');
|
||||
gl.attachShader(program, vertex);
|
||||
gl.attachShader(program, frag);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vertex);
|
||||
gl.deleteShader(frag);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
const log = gl.getProgramInfoLog(program);
|
||||
gl.deleteProgram(program);
|
||||
throw new Error(`program link failed: ${log ?? ''}`);
|
||||
}
|
||||
const uniforms: Record<string, WebGLUniformLocation | null> = {};
|
||||
for (const name of uniformNames) uniforms[name] = gl.getUniformLocation(program, name);
|
||||
return { program, uniforms };
|
||||
}
|
||||
|
||||
function buildLuts(light: LightParams): Uint8Array {
|
||||
const master = buildCurveLUT(light.curve?.rgb);
|
||||
const red = buildCurveLUT(light.curve?.red);
|
||||
@@ -236,37 +465,191 @@ function buildLuts(light: LightParams): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseHex(color: string): [number, number, number] {
|
||||
const m = /^#([0-9a-f]{6})$/.exec(color);
|
||||
if (!m) return [0, 0, 0];
|
||||
const value = parseInt(m[1], 16);
|
||||
return [((value >> 16) & 0xff) / 255, ((value >> 8) & 0xff) / 255, (value & 0xff) / 255];
|
||||
}
|
||||
|
||||
/** box-размытие карты пропускания (маленькая картинка, поэтому простое). */
|
||||
function blurTransmission(map: Float32Array, width: number, height: number, radius: number): void {
|
||||
if (radius <= 0) return;
|
||||
const temp = new Float32Array(map.length);
|
||||
const window = radius * 2 + 1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
let sum = 0;
|
||||
const row = y * width;
|
||||
for (let x = -radius; x <= radius; x++) sum += map[row + Math.min(width - 1, Math.max(0, x))];
|
||||
for (let x = 0; x < width; x++) {
|
||||
temp[row + x] = sum / window;
|
||||
sum -= map[row + Math.min(width - 1, Math.max(0, x - radius))];
|
||||
sum += map[row + Math.min(width - 1, Math.max(0, x + radius + 1))];
|
||||
}
|
||||
}
|
||||
for (let x = 0; x < width; x++) {
|
||||
let sum = 0;
|
||||
for (let y = -radius; y <= radius; y++) sum += temp[Math.min(height - 1, Math.max(0, y)) * width + x];
|
||||
for (let y = 0; y < height; y++) {
|
||||
map[y * width + x] = sum / window;
|
||||
sum -= temp[Math.min(height - 1, Math.max(0, y - radius)) * width + x];
|
||||
sum += temp[Math.min(height - 1, Math.max(0, y + radius + 1)) * width + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Готовит карту дымки по уменьшенной копии изображения: атмосферный свет и
|
||||
* пропускание (dark channel prior). Карта хранится в ориентации изображения
|
||||
* снизу вверх, чтобы совпадать с текстурными координатами сцены.
|
||||
*/
|
||||
function buildDehazeMap(
|
||||
image: CanvasImageSource,
|
||||
imageWidth: number,
|
||||
imageHeight: number
|
||||
): DehazeMap | null {
|
||||
const scale = Math.min(1, 256 / Math.max(1, imageWidth, imageHeight));
|
||||
const width = Math.max(1, Math.round(imageWidth * scale));
|
||||
const height = Math.max(1, Math.round(imageHeight * scale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(image, 0, 0, width, height);
|
||||
const src = ctx.getImageData(0, 0, width, height).data;
|
||||
|
||||
const dark = new Uint8Array(width * height);
|
||||
const histogram = new Uint32Array(256);
|
||||
for (let i = 0, p = 0; i < dark.length; i++, p += 4) {
|
||||
const d = Math.min(src[p], src[p + 1], src[p + 2]);
|
||||
dark[i] = d;
|
||||
histogram[d]++;
|
||||
}
|
||||
let needed = Math.max(1, Math.round(dark.length * 0.001));
|
||||
let threshold = 255;
|
||||
for (let value = 255; value >= 0; value--) {
|
||||
needed -= histogram[value];
|
||||
if (needed <= 0) {
|
||||
threshold = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let sumR = 0;
|
||||
let sumG = 0;
|
||||
let sumB = 0;
|
||||
let count = 0;
|
||||
for (let i = 0, p = 0; i < dark.length; i++, p += 4) {
|
||||
if (dark[i] < threshold) continue;
|
||||
sumR += src[p];
|
||||
sumG += src[p + 1];
|
||||
sumB += src[p + 2];
|
||||
count++;
|
||||
}
|
||||
const atmosphere: [number, number, number] = count
|
||||
? [sumR / count, sumG / count, sumB / count]
|
||||
: [255, 255, 255];
|
||||
const a = atmosphere.map((v) => Math.max(1, v)) as [number, number, number];
|
||||
|
||||
const transmission = new Float32Array(width * height);
|
||||
for (let i = 0, p = 0; i < dark.length; i++, p += 4) {
|
||||
const t =
|
||||
1 -
|
||||
0.95 *
|
||||
Math.min(src[p] / a[0], src[p + 1] / a[1], src[p + 2] / a[2]);
|
||||
transmission[i] = Math.min(1, Math.max(0.1, t));
|
||||
}
|
||||
blurTransmission(transmission, width, height, Math.max(1, Math.round(0.02 * Math.min(width, height))));
|
||||
|
||||
const bytes = new Uint8Array(width * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const dstRow = (height - 1 - y) * width;
|
||||
const srcRow = y * width;
|
||||
for (let x = 0; x < width; x++) {
|
||||
bytes[dstRow + x] = Math.round(transmission[srcRow + x] * 255);
|
||||
}
|
||||
}
|
||||
return { width, height, transmission: bytes, atmosphere: a };
|
||||
}
|
||||
|
||||
export class WebGLPreview {
|
||||
private readonly gl: WebGL2RenderingContext;
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
private readonly program: WebGLProgram;
|
||||
private readonly scene: Program;
|
||||
private readonly blur: Program;
|
||||
private readonly final: Program;
|
||||
private readonly vao: WebGLVertexArrayObject;
|
||||
private readonly buffer: WebGLBuffer;
|
||||
private readonly imageTex: WebGLTexture;
|
||||
private readonly lutTex: WebGLTexture;
|
||||
private readonly u: Record<string, WebGLUniformLocation | null>;
|
||||
private readonly dehazeTex: WebGLTexture;
|
||||
private imageVersion = '';
|
||||
private dehazeVersion = '';
|
||||
private dehazeMap: DehazeMap | null = null;
|
||||
private sceneTarget: Target | null = null;
|
||||
private textureH: Target | null = null;
|
||||
private textureV: Target | null = null;
|
||||
private clarityH: Target | null = null;
|
||||
private clarityV: Target | null = null;
|
||||
|
||||
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;
|
||||
this.scene = buildProgram(gl, SCENE_FRAG, [
|
||||
'u_image',
|
||||
'u_luts',
|
||||
'u_uvOffset',
|
||||
'u_uvScale',
|
||||
'u_exposureMul',
|
||||
'u_contrastK',
|
||||
'u_highlights',
|
||||
'u_shadows',
|
||||
'u_whites',
|
||||
'u_blacks',
|
||||
'u_temperature',
|
||||
'u_tint',
|
||||
'u_vibrance',
|
||||
'u_saturation',
|
||||
'u_blending',
|
||||
'u_balance',
|
||||
'u_correction',
|
||||
'u_mixerHue[0]',
|
||||
'u_mixerSat[0]',
|
||||
'u_mixerLum[0]',
|
||||
'u_gradeSat[0]',
|
||||
'u_gradeLum[0]',
|
||||
'u_gradeTint[0]'
|
||||
]);
|
||||
this.blur = buildProgram(gl, BLUR_FRAG, ['u_src', 'u_step', 'u_radius']);
|
||||
this.final = buildProgram(gl, FINAL_FRAG, [
|
||||
'u_scene',
|
||||
'u_blurTex',
|
||||
'u_blurClar',
|
||||
'u_dehaze',
|
||||
'u_hasBlurTex',
|
||||
'u_hasBlurClar',
|
||||
'u_texture',
|
||||
'u_clarity',
|
||||
'u_dehazeAmount',
|
||||
'u_dehazeA',
|
||||
'u_imageSize',
|
||||
'u_grainCell',
|
||||
'u_seedLo',
|
||||
'u_seedHi',
|
||||
'u_grainAmount',
|
||||
'u_grainSize',
|
||||
'u_grainRoughness',
|
||||
'u_vigAmount',
|
||||
'u_vigMidpoint',
|
||||
'u_vigFeather',
|
||||
'u_vigRoundness',
|
||||
'u_vigHighlights',
|
||||
'u_vigMode',
|
||||
'u_vigColor',
|
||||
'u_uvOffset',
|
||||
'u_uvScale'
|
||||
]);
|
||||
|
||||
const vao = gl.createVertexArray();
|
||||
const buffer = gl.createBuffer();
|
||||
@@ -278,55 +661,34 @@ export class WebGLPreview {
|
||||
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.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 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);
|
||||
const dehazeTex = gl.createTexture();
|
||||
if (!imageTex || !lutTex || !dehazeTex) throw new Error('texture create failed');
|
||||
for (const texture of [imageTex, lutTex, dehazeTex]) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
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,
|
||||
texture === lutTex ? gl.NEAREST : gl.LINEAR
|
||||
);
|
||||
gl.texParameteri(
|
||||
gl.TEXTURE_2D,
|
||||
gl.TEXTURE_MAG_FILTER,
|
||||
texture === lutTex ? gl.NEAREST : gl.LINEAR
|
||||
);
|
||||
}
|
||||
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]')
|
||||
};
|
||||
this.dehazeTex = dehazeTex;
|
||||
}
|
||||
|
||||
static create(canvas: HTMLCanvasElement): WebGLPreview | null {
|
||||
@@ -349,11 +711,120 @@ export class WebGLPreview {
|
||||
|
||||
dispose() {
|
||||
const gl = this.gl;
|
||||
for (const target of [
|
||||
this.sceneTarget,
|
||||
this.textureH,
|
||||
this.textureV,
|
||||
this.clarityH,
|
||||
this.clarityV
|
||||
]) {
|
||||
if (!target) continue;
|
||||
gl.deleteTexture(target.texture);
|
||||
gl.deleteFramebuffer(target.framebuffer);
|
||||
}
|
||||
gl.deleteTexture(this.imageTex);
|
||||
gl.deleteTexture(this.lutTex);
|
||||
gl.deleteTexture(this.dehazeTex);
|
||||
gl.deleteBuffer(this.buffer);
|
||||
gl.deleteVertexArray(this.vao);
|
||||
gl.deleteProgram(this.program);
|
||||
gl.deleteProgram(this.scene.program);
|
||||
gl.deleteProgram(this.blur.program);
|
||||
gl.deleteProgram(this.final.program);
|
||||
}
|
||||
|
||||
private ensureTarget(target: Target | null, width: number, height: number): Target {
|
||||
const gl = this.gl;
|
||||
if (target && target.width === width && target.height === height) return target;
|
||||
if (target) {
|
||||
gl.deleteTexture(target.texture);
|
||||
gl.deleteFramebuffer(target.framebuffer);
|
||||
}
|
||||
const texture = gl.createTexture();
|
||||
const framebuffer = gl.createFramebuffer();
|
||||
if (!texture || !framebuffer) throw new Error('target create failed');
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||||
gl.framebufferTexture2D(
|
||||
gl.FRAMEBUFFER,
|
||||
gl.COLOR_ATTACHMENT0,
|
||||
gl.TEXTURE_2D,
|
||||
texture,
|
||||
0
|
||||
);
|
||||
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
|
||||
console.error('webgl preview: incomplete framebuffer');
|
||||
}
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
return { texture, framebuffer, width, height };
|
||||
}
|
||||
|
||||
private drawQuad() {
|
||||
const gl = this.gl;
|
||||
gl.bindVertexArray(this.vao);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
gl.bindVertexArray(null);
|
||||
}
|
||||
|
||||
private uploadImage(opts: PreviewRenderOptions) {
|
||||
const gl = this.gl;
|
||||
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);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
this.imageVersion = opts.imageVersion;
|
||||
}
|
||||
|
||||
private ensureDehazeMap(opts: PreviewRenderOptions) {
|
||||
const gl = this.gl;
|
||||
if (this.dehazeVersion !== opts.imageVersion) {
|
||||
this.dehazeMap = buildDehazeMap(
|
||||
opts.image as CanvasImageSource,
|
||||
opts.imageWidth,
|
||||
opts.imageHeight
|
||||
);
|
||||
this.dehazeVersion = opts.imageVersion;
|
||||
if (this.dehazeMap) {
|
||||
gl.activeTexture(gl.TEXTURE3);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.dehazeTex);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.R8,
|
||||
this.dehazeMap.width,
|
||||
this.dehazeMap.height,
|
||||
0,
|
||||
gl.RED,
|
||||
gl.UNSIGNED_BYTE,
|
||||
this.dehazeMap.transmission
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.dehazeMap;
|
||||
}
|
||||
|
||||
private blurPass(
|
||||
source: WebGLTexture,
|
||||
target: Target,
|
||||
stepX: number,
|
||||
stepY: number,
|
||||
radius: number
|
||||
) {
|
||||
const gl = this.gl;
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
|
||||
gl.viewport(0, 0, target.width, target.height);
|
||||
gl.useProgram(this.blur.program);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, source);
|
||||
gl.uniform1i(this.blur.uniforms.u_src, 0);
|
||||
gl.uniform2f(this.blur.uniforms.u_step, stepX, stepY);
|
||||
gl.uniform1i(this.blur.uniforms.u_radius, Math.min(32, Math.max(1, radius)));
|
||||
this.drawQuad();
|
||||
}
|
||||
|
||||
render(opts: PreviewRenderOptions): boolean {
|
||||
@@ -370,19 +841,22 @@ export class WebGLPreview {
|
||||
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;
|
||||
}
|
||||
if (this.imageVersion !== opts.imageVersion) this.uploadImage(opts);
|
||||
|
||||
const effects = opts.effects;
|
||||
const sceneWidth = Math.max(1, Math.round(opts.dest.width));
|
||||
const sceneHeight = Math.max(1, Math.round(opts.dest.height));
|
||||
this.sceneTarget = this.ensureTarget(this.sceneTarget, sceneWidth, sceneHeight);
|
||||
const sceneTarget = this.sceneTarget;
|
||||
|
||||
// Проход 1: свет и цвет в FBO-текстуру.
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, sceneTarget.framebuffer);
|
||||
gl.viewport(0, 0, sceneWidth, sceneHeight);
|
||||
gl.useProgram(this.scene.program);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
|
||||
gl.uniform1i(this.u.u_image, 0);
|
||||
|
||||
gl.uniform1i(this.scene.uniforms.u_image, 0);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
|
||||
gl.texImage2D(
|
||||
@@ -396,25 +870,25 @@ export class WebGLPreview {
|
||||
gl.UNSIGNED_BYTE,
|
||||
buildLuts(opts.light)
|
||||
);
|
||||
gl.uniform1i(this.u.u_luts, 1);
|
||||
gl.uniform1i(this.scene.uniforms.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);
|
||||
gl.uniform1f(this.scene.uniforms.u_exposureMul, Math.pow(2, light.exposure));
|
||||
gl.uniform1f(this.scene.uniforms.u_contrastK, 1 + light.contrast / 100);
|
||||
gl.uniform1f(this.scene.uniforms.u_highlights, (light.highlights / 100) * 80);
|
||||
gl.uniform1f(this.scene.uniforms.u_shadows, (light.shadows / 100) * 80);
|
||||
gl.uniform1f(this.scene.uniforms.u_whites, (light.whites / 100) * 80);
|
||||
gl.uniform1f(this.scene.uniforms.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.uniform1f(this.scene.uniforms.u_temperature, color.temperature / 100);
|
||||
gl.uniform1f(this.scene.uniforms.u_tint, color.tint / 100);
|
||||
gl.uniform1f(this.scene.uniforms.u_vibrance, color.vibrance / 100);
|
||||
gl.uniform1f(this.scene.uniforms.u_saturation, color.saturation / 100);
|
||||
gl.uniform1f(this.scene.uniforms.u_blending, color.grading.blending / 100);
|
||||
gl.uniform1f(this.scene.uniforms.u_balance, color.grading.balance / 100);
|
||||
gl.uniform3f(
|
||||
this.u.u_correction,
|
||||
this.scene.uniforms.u_correction,
|
||||
color.correction.red / 100,
|
||||
color.correction.green / 100,
|
||||
color.correction.blue / 100
|
||||
@@ -429,9 +903,9 @@ export class WebGLPreview {
|
||||
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);
|
||||
gl.uniform1fv(this.scene.uniforms['u_mixerHue[0]'], hue);
|
||||
gl.uniform1fv(this.scene.uniforms['u_mixerSat[0]'], sat);
|
||||
gl.uniform1fv(this.scene.uniforms['u_mixerLum[0]'], lum);
|
||||
|
||||
const gradeSat = new Float32Array(3);
|
||||
const gradeLum = new Float32Array(3);
|
||||
@@ -445,26 +919,124 @@ export class WebGLPreview {
|
||||
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);
|
||||
gl.uniform1fv(this.scene.uniforms['u_gradeSat[0]'], gradeSat);
|
||||
gl.uniform1fv(this.scene.uniforms['u_gradeLum[0]'], gradeLum);
|
||||
gl.uniform3fv(this.scene.uniforms['u_gradeTint[0]'], 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);
|
||||
const uvOffsetX = u0;
|
||||
const uvOffsetY = 1 - vBottom;
|
||||
const uvScaleX = u1 - u0;
|
||||
const uvScaleY = vBottom - vTop;
|
||||
gl.uniform2f(this.scene.uniforms.u_uvOffset, uvOffsetX, uvOffsetY);
|
||||
gl.uniform2f(this.scene.uniforms.u_uvScale, uvScaleX, uvScaleY);
|
||||
this.drawQuad();
|
||||
|
||||
// Проходы размытия: текстура — полное (или половинное) разрешение,
|
||||
// чёткость — половинное (или четвертное).
|
||||
const scale = opts.dest.width / Math.max(1, opts.crop.width);
|
||||
const minCrop = Math.min(opts.crop.width, opts.crop.height);
|
||||
const textureRadius = Math.max(1, Math.round(0.0015 * minCrop * scale));
|
||||
const clarityRadius = Math.max(2, Math.round(0.008 * minCrop * scale));
|
||||
const texScale = opts.lowQuality ? 0.5 : 1;
|
||||
const clarScale = opts.lowQuality ? 0.25 : 0.5;
|
||||
let hasTexture = false;
|
||||
let hasClarity = false;
|
||||
|
||||
if (effects.texture !== 0) {
|
||||
const targetW = Math.max(1, Math.round(sceneWidth * texScale));
|
||||
const targetH = Math.max(1, Math.round(sceneHeight * texScale));
|
||||
this.textureH = this.ensureTarget(this.textureH, targetW, targetH);
|
||||
this.textureV = this.ensureTarget(this.textureV, targetW, targetH);
|
||||
const radius = Math.max(1, Math.round(textureRadius * texScale));
|
||||
this.blurPass(sceneTarget.texture, this.textureH, 1 / targetW, 0, radius);
|
||||
this.blurPass(this.textureH.texture, this.textureV, 0, 1 / targetH, radius);
|
||||
hasTexture = true;
|
||||
}
|
||||
if (effects.clarity !== 0) {
|
||||
const targetW = Math.max(1, Math.round(sceneWidth * clarScale));
|
||||
const targetH = Math.max(1, Math.round(sceneHeight * clarScale));
|
||||
this.clarityH = this.ensureTarget(this.clarityH, targetW, targetH);
|
||||
this.clarityV = this.ensureTarget(this.clarityV, targetW, targetH);
|
||||
const radius = Math.max(1, Math.round(clarityRadius * clarScale));
|
||||
this.blurPass(sceneTarget.texture, this.clarityH, 1 / targetW, 0, radius);
|
||||
this.blurPass(this.clarityH.texture, this.clarityV, 0, 1 / targetH, radius);
|
||||
hasClarity = true;
|
||||
}
|
||||
|
||||
const dehazeMap = effects.dehaze !== 0 ? this.ensureDehazeMap(opts) : null;
|
||||
|
||||
// Финальный проход: эффекты, виньетка, зерно.
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
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);
|
||||
gl.useProgram(this.final.program);
|
||||
const u = this.final.uniforms;
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, sceneTarget.texture);
|
||||
gl.uniform1i(u.u_scene, 0);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, hasTexture ? this.textureV!.texture : sceneTarget.texture);
|
||||
gl.uniform1i(u.u_blurTex, 1);
|
||||
gl.activeTexture(gl.TEXTURE2);
|
||||
gl.bindTexture(gl.TEXTURE_2D, hasClarity ? this.clarityV!.texture : sceneTarget.texture);
|
||||
gl.uniform1i(u.u_blurClar, 2);
|
||||
gl.activeTexture(gl.TEXTURE3);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.dehazeTex);
|
||||
gl.uniform1i(u.u_dehaze, 3);
|
||||
|
||||
gl.uniform1f(u.u_hasBlurTex, hasTexture ? 1 : 0);
|
||||
gl.uniform1f(u.u_hasBlurClar, hasClarity ? 1 : 0);
|
||||
gl.uniform1f(u.u_texture, effects.texture / 100);
|
||||
gl.uniform1f(u.u_clarity, effects.clarity / 100);
|
||||
gl.uniform1f(u.u_dehazeAmount, dehazeMap ? effects.dehaze / 100 : 0);
|
||||
if (dehazeMap) {
|
||||
gl.uniform3f(
|
||||
u.u_dehazeA,
|
||||
dehazeMap.atmosphere[0],
|
||||
dehazeMap.atmosphere[1],
|
||||
dehazeMap.atmosphere[2]
|
||||
);
|
||||
} else {
|
||||
gl.uniform3f(u.u_dehazeA, 255, 255, 255);
|
||||
}
|
||||
|
||||
gl.uniform2f(u.u_imageSize, opts.imageWidth, opts.imageHeight);
|
||||
const cell = Math.max(
|
||||
1,
|
||||
(0.0005 + 0.004 * (effects.grain.size / 100)) *
|
||||
Math.min(opts.imageWidth, opts.imageHeight)
|
||||
);
|
||||
gl.uniform1f(u.u_grainCell, cell);
|
||||
const seed = opts.seed;
|
||||
gl.uniform1ui(u.u_seedLo, Number(seed & 0xffffffffn));
|
||||
gl.uniform1ui(u.u_seedHi, Number((seed >> 32n) & 0xffffffffn));
|
||||
gl.uniform1f(u.u_grainAmount, effects.grain.amount);
|
||||
gl.uniform1f(u.u_grainSize, effects.grain.size);
|
||||
gl.uniform1f(u.u_grainRoughness, effects.grain.roughness);
|
||||
|
||||
const vignette = effects.vignette;
|
||||
gl.uniform1f(u.u_vigAmount, vignette.amount);
|
||||
gl.uniform1f(u.u_vigMidpoint, vignette.midpoint);
|
||||
gl.uniform1f(u.u_vigFeather, vignette.feather);
|
||||
gl.uniform1f(u.u_vigRoundness, vignette.roundness);
|
||||
gl.uniform1f(u.u_vigHighlights, vignette.highlights);
|
||||
gl.uniform1i(
|
||||
u.u_vigMode,
|
||||
vignette.mode === 'light' ? 0 : vignette.mode === 'color' ? 1 : 2
|
||||
);
|
||||
gl.uniform3f(u.u_vigColor, ...parseHex(vignette.color));
|
||||
gl.uniform2f(u.u_uvOffset, uvOffsetX, uvOffsetY);
|
||||
gl.uniform2f(u.u_uvScale, uvScaleX, uvScaleY);
|
||||
this.drawQuad();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user