feat(photos): добавить пресеты кривой и починить переключение фото
- PCHIP для предпросмотра и серверного рендера - пресеты всех каналов в профиле, импорт и экспорт - мерж настроек auth без потери разделов - сохранение вкладки и правок текущего фото при переключении
This commit is contained in:
@@ -22,10 +22,16 @@
|
||||
let picking = $state(false);
|
||||
|
||||
let photo = $state<Photo | null>(null);
|
||||
// Вкладка правой панели живёт здесь и переживает смену фото.
|
||||
let photoTab = $state<'info' | 'edit'>('info');
|
||||
|
||||
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
|
||||
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
|
||||
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
|
||||
// Не показываем в панели фото от предыдущего маршрута, пока не загрузилось
|
||||
// текущее: иначе правки могут уйти не тому фото.
|
||||
const panelPhoto = $derived(
|
||||
photoId ? (photo?.id === photoId ? photo : null) : selectedPhoto
|
||||
);
|
||||
|
||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||
|
||||
@@ -51,7 +57,9 @@
|
||||
}
|
||||
|
||||
function onRenamed(updated: Photo) {
|
||||
photo = updated;
|
||||
// Фоновое сохранение предыдущего фото может завершиться уже после
|
||||
// переключения: его ответ не должен подменять текущее фото в панели.
|
||||
if (updated.id === photoId) photo = updated;
|
||||
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
|
||||
photoUpdates.publish(updated);
|
||||
}
|
||||
@@ -90,9 +98,17 @@
|
||||
photo = null;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api<{ photo: Photo }>(`/photos/${id}`)
|
||||
.then((data) => (photo = data.photo))
|
||||
.catch((e) => (error = (e as Error).message));
|
||||
.then((data) => {
|
||||
if (!cancelled && data.photo.id === id) photo = data.photo;
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) error = (e as Error).message;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
async function resetCover() {
|
||||
@@ -146,6 +162,7 @@
|
||||
|
||||
{#if panelPhoto}
|
||||
<PhotoPanel
|
||||
bind:tab={photoTab}
|
||||
photo={panelPhoto}
|
||||
{role}
|
||||
viewer={!!photoId}
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
import { portal } from '$lib/actions/portal';
|
||||
import { CURVE_CHANNELS, type CurveChannel, type CurvePoint } from '$lib/edit';
|
||||
import {
|
||||
BUILTIN_CURVE_PRESETS,
|
||||
MAX_CURVE_PRESET_NAME,
|
||||
exportCurvePresetFile,
|
||||
normalizeCurvePresetName,
|
||||
parseCurvePresetFile
|
||||
} from '$lib/curve-presets';
|
||||
import type { CurvePreset } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
curves: Record<CurveChannel, CurvePoint[]>;
|
||||
presets: CurvePreset[];
|
||||
activeName: string;
|
||||
disabled?: boolean;
|
||||
onApply: (preset: CurvePreset) => void;
|
||||
onSave: (preset: CurvePreset) => Promise<void>;
|
||||
onRename: (oldName: string, newName: string) => Promise<void>;
|
||||
onDelete: (name: string) => Promise<void>;
|
||||
onImport: (presets: CurvePreset[]) => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
curves,
|
||||
presets,
|
||||
activeName,
|
||||
disabled = false,
|
||||
onApply,
|
||||
onSave,
|
||||
onRename,
|
||||
onDelete,
|
||||
onImport
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let dialog = $state<'save' | 'rename' | null>(null);
|
||||
let renameFrom = $state('');
|
||||
let nameDraft = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
let trigger = $state<HTMLButtonElement | undefined>();
|
||||
let menuElement = $state<HTMLDivElement | undefined>();
|
||||
let menuStyle = $state('');
|
||||
let fileInput = $state<HTMLInputElement | undefined>();
|
||||
let nameInput = $state<HTMLInputElement | undefined>();
|
||||
|
||||
const MENU_WIDTH = 288;
|
||||
const MENU_HEIGHT = 320;
|
||||
const MENU_GAP = 6;
|
||||
const VIEWPORT_PAD = 8;
|
||||
|
||||
const isCustomActive = $derived(presets.some((preset) => preset.name === activeName));
|
||||
|
||||
$effect(() => {
|
||||
if (dialog && nameInput) {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}
|
||||
});
|
||||
|
||||
// Панель редактирования — скроллящийся контейнер с transform, поэтому меню
|
||||
// рендерится в portal и позиционируется от кнопки в координатах viewport.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onScroll = (ev: Event) => {
|
||||
if (menuElement && ev.target instanceof Node && menuElement.contains(ev.target)) return;
|
||||
open = false;
|
||||
};
|
||||
const onResize = () => (open = false);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
});
|
||||
|
||||
function positionMenu() {
|
||||
if (!trigger || typeof window === 'undefined') return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const width = Math.min(MENU_WIDTH, window.innerWidth - VIEWPORT_PAD * 2);
|
||||
const left = Math.min(
|
||||
Math.max(VIEWPORT_PAD, rect.right - width),
|
||||
window.innerWidth - width - VIEWPORT_PAD
|
||||
);
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MENU_GAP - VIEWPORT_PAD;
|
||||
const spaceAbove = rect.top - MENU_GAP - VIEWPORT_PAD;
|
||||
const openBelow = spaceBelow >= Math.min(MENU_HEIGHT, 220) || spaceBelow >= spaceAbove;
|
||||
if (openBelow) {
|
||||
menuStyle = `left:${left}px; top:${rect.bottom + MENU_GAP}px; width:${width}px; max-height:${Math.max(140, spaceBelow)}px;`;
|
||||
} else {
|
||||
menuStyle = `left:${left}px; bottom:${window.innerHeight - rect.top + MENU_GAP}px; width:${width}px; max-height:${Math.max(140, spaceAbove)}px;`;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
if (!open) positionMenu();
|
||||
open = !open;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function apply(preset: CurvePreset) {
|
||||
onApply(preset);
|
||||
open = false;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function startSave() {
|
||||
nameDraft = isCustomActive ? activeName : '';
|
||||
open = false;
|
||||
error = '';
|
||||
dialog = 'save';
|
||||
}
|
||||
|
||||
function startRename(preset: CurvePreset) {
|
||||
renameFrom = preset.name;
|
||||
nameDraft = preset.name;
|
||||
open = false;
|
||||
error = '';
|
||||
dialog = 'rename';
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (busy) return;
|
||||
dialog = null;
|
||||
error = '';
|
||||
}
|
||||
|
||||
async function submit(ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (busy) return;
|
||||
const name = normalizeCurvePresetName(nameDraft);
|
||||
if (!name) {
|
||||
error = `Введите название до ${MAX_CURVE_PRESET_NAME} символов`;
|
||||
return;
|
||||
}
|
||||
if (dialog === 'save') {
|
||||
if (
|
||||
presets.some((preset) => preset.name === name) &&
|
||||
!window.confirm(`Перезаписать пресет «${name}»?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const channels = {} as CurvePreset['channels'];
|
||||
for (const ch of CURVE_CHANNELS) {
|
||||
channels[ch] = curves[ch].map((point) => ({ ...point }));
|
||||
}
|
||||
await onSave({ name, channels });
|
||||
dialog = null;
|
||||
} catch (e) {
|
||||
error = (e as Error).message || 'Не удалось сохранить пресет';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dialog === 'rename') {
|
||||
if (name === renameFrom) {
|
||||
dialog = null;
|
||||
return;
|
||||
}
|
||||
if (presets.some((preset) => preset.name === name)) {
|
||||
error = 'Пресет с таким названием уже есть';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
await onRename(renameFrom, name);
|
||||
dialog = null;
|
||||
} catch (e) {
|
||||
error = (e as Error).message || 'Не удалось переименовать пресет';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removePreset(preset: CurvePreset) {
|
||||
if (busy) return;
|
||||
if (!window.confirm(`Удалить пресет «${preset.name}»?`)) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
await onDelete(preset.name);
|
||||
} catch (e) {
|
||||
error = (e as Error).message || 'Не удалось удалить пресет';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickFile() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
async function onFile(ev: Event) {
|
||||
const input = ev.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const presetsToImport = parseCurvePresetFile(await file.text());
|
||||
await onImport(presetsToImport);
|
||||
open = false;
|
||||
} catch (e) {
|
||||
error = (e as Error).message || 'Не удалось импортировать пресеты';
|
||||
} finally {
|
||||
busy = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function exportPresets() {
|
||||
try {
|
||||
const blob = new Blob([exportCurvePresetFile(presets)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'galo-curve-presets.json';
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
open = false;
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = (e as Error).message || 'Не удалось экспортировать пресеты';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(ev) => {
|
||||
if (dialog && ev.key === 'Escape' && !busy) {
|
||||
ev.preventDefault();
|
||||
closeDialog();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
bind:this={trigger}
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text flex min-w-0 items-center gap-1 text-xs disabled:opacity-40"
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onclick={toggle}
|
||||
>
|
||||
<span class="max-w-[10rem] truncate">{activeName}</span>
|
||||
<Icon name="expand_more" class="size-3" />
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<button
|
||||
use:portal
|
||||
type="button"
|
||||
class="fixed inset-0 z-[75] cursor-default"
|
||||
aria-label="Закрыть меню пресетов"
|
||||
onclick={() => (open = false)}
|
||||
></button>
|
||||
<div
|
||||
use:portal
|
||||
bind:this={menuElement}
|
||||
class="border-border bg-bg-elevated fixed z-[80] overflow-y-auto rounded-md border p-1 shadow-lg"
|
||||
style={menuStyle}
|
||||
role="menu"
|
||||
>
|
||||
<p class="text-text-subtle px-2 py-1 text-[11px] font-semibold uppercase">Встроенные</p>
|
||||
{#each BUILTIN_CURVE_PRESETS as preset (preset.name)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-bg-muted flex w-full items-center gap-1 rounded px-2 py-1.5 text-left text-xs"
|
||||
role="menuitem"
|
||||
onclick={() => apply(preset)}
|
||||
>
|
||||
<span class="flex-1 truncate">{preset.name}</span>
|
||||
{#if preset.name === activeName}
|
||||
<Icon name="check" class="text-primary size-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if presets.length}
|
||||
<p class="text-text-subtle px-2 py-1 text-[11px] font-semibold uppercase">
|
||||
Пользовательские
|
||||
</p>
|
||||
{#each presets as preset (preset.name)}
|
||||
<div class="hover:bg-bg-muted flex items-center rounded">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5 text-left text-xs"
|
||||
role="menuitem"
|
||||
onclick={() => apply(preset)}
|
||||
>
|
||||
<span class="truncate">{preset.name}</span>
|
||||
{#if preset.name === activeName}
|
||||
<Icon name="check" class="text-primary ml-auto size-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text px-1.5 py-1.5"
|
||||
title="Переименовать пресет"
|
||||
aria-label="Переименовать пресет {preset.name}"
|
||||
onclick={() => startRename(preset)}
|
||||
>
|
||||
<Icon name="edit" class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-danger px-1.5 py-1.5"
|
||||
title="Удалить пресет"
|
||||
aria-label="Удалить пресет {preset.name}"
|
||||
onclick={() => void removePreset(preset)}
|
||||
>
|
||||
<Icon name="delete" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="border-border mt-1 space-y-0.5 border-t pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-bg-muted flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs"
|
||||
onclick={startSave}
|
||||
>
|
||||
<Icon name="save" class="size-3.5" />
|
||||
Сохранить…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-bg-muted flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs"
|
||||
onclick={pickFile}
|
||||
>
|
||||
<Icon name="cloud_upload" class="size-3.5" />
|
||||
Импортировать…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-bg-muted flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs"
|
||||
onclick={exportPresets}
|
||||
>
|
||||
<Icon name="download" class="size-3.5" />
|
||||
Экспортировать…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-danger px-2 py-1 text-[11px]">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
class="hidden"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
onchange={onFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if dialog}
|
||||
<div
|
||||
use:portal
|
||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={dialog === 'save' ? 'Сохранить пресет' : 'Переименовать пресет'}
|
||||
tabindex="-1"
|
||||
onclick={(ev) => {
|
||||
if (ev.target === ev.currentTarget) closeDialog();
|
||||
}}
|
||||
onkeydown={(ev) => {
|
||||
if (ev.key === 'Escape') closeDialog();
|
||||
}}
|
||||
>
|
||||
<form
|
||||
class="border-border bg-bg-elevated w-full max-w-sm rounded-lg border p-4 shadow-xl"
|
||||
onsubmit={submit}
|
||||
>
|
||||
<h3 class="text-text text-sm font-semibold">
|
||||
{dialog === 'save' ? 'Сохранить пресет' : 'Переименовать пресет'}
|
||||
</h3>
|
||||
<p class="text-text-muted mt-1 text-xs">
|
||||
{dialog === 'save' ? 'Кривые всех каналов: RGB, R, G, B' : `Было: ${renameFrom}`}
|
||||
</p>
|
||||
<input
|
||||
bind:this={nameInput}
|
||||
bind:value={nameDraft}
|
||||
maxlength={MAX_CURVE_PRESET_NAME}
|
||||
class="border-border bg-bg text-text focus:border-primary mt-3 w-full rounded border px-2 py-1.5 text-sm outline-none"
|
||||
placeholder="Название пресета"
|
||||
aria-label="Название пресета"
|
||||
/>
|
||||
{#if error}
|
||||
<p class="text-danger mt-1 text-xs">{error}</p>
|
||||
{/if}
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="border-border text-text-muted hover:text-text rounded-md border px-3 py-1.5 text-xs disabled:opacity-40"
|
||||
disabled={busy}
|
||||
onclick={closeDialog}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-primary text-bg rounded-md px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? 'Сохраняем…' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { photosApi } from '$lib/api';
|
||||
import { authApi, photosApi } from '$lib/api';
|
||||
import Icon from './Icon.svelte';
|
||||
import EditSlider from './EditSlider.svelte';
|
||||
import ColorWheel from './ColorWheel.svelte';
|
||||
import CurvePresetMenu from './CurvePresetMenu.svelte';
|
||||
import { edit } from '$lib/stores/edit.svelte';
|
||||
import { eyedropper } from '$lib/stores/eyedropper.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { applyColor, applyLight } from '$lib/light-wasm';
|
||||
import {
|
||||
CHANNEL_COLORS,
|
||||
@@ -14,7 +16,10 @@
|
||||
CURVE_CHANNELS,
|
||||
GRADING_WHEELS,
|
||||
LIGHT_SLIDERS,
|
||||
MAX_CURVE_POINTS,
|
||||
MIXER_BANDS,
|
||||
curveSvgPath,
|
||||
effectiveCurvePoints,
|
||||
emptyEditParams,
|
||||
isEmptyEditParams,
|
||||
normalizeEditParams,
|
||||
@@ -28,7 +33,12 @@
|
||||
type MixerBand,
|
||||
type MixerBandKey
|
||||
} from '$lib/edit';
|
||||
import type { Photo } from '$lib/types';
|
||||
import {
|
||||
findActiveCurvePreset,
|
||||
normalizeCurvePresets,
|
||||
upsertCurvePresets
|
||||
} from '$lib/curve-presets';
|
||||
import type { CurvePreset, Photo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
photo: Photo;
|
||||
@@ -40,8 +50,13 @@
|
||||
let { photo, canEdit, viewer = false, onUpdated }: Props = $props();
|
||||
|
||||
let params = $state<EditParams>(emptyEditParams());
|
||||
let busy = $state(false);
|
||||
let savingCount = $state(0);
|
||||
const busy = $derived(savingCount > 0);
|
||||
let error = $state('');
|
||||
// Номер последней операции на фото: ответы старых запросов не должны
|
||||
// перетирать более свежие правки (например, после переключения фото).
|
||||
let saveSequence = 0;
|
||||
const latestSaveByPhoto = new Map<string, number>();
|
||||
let collapsed = $state(false);
|
||||
let curveCollapsed = $state(false);
|
||||
let channel = $state<CurveChannel>('rgb');
|
||||
@@ -61,20 +76,34 @@
|
||||
// Правки сохранены, но отредактированная версия ещё рендерится.
|
||||
const rendering = $derived(hasEdits && !photo.edit_preview_url);
|
||||
|
||||
const customPresets = $derived(normalizeCurvePresets(auth.preferences.curve_presets));
|
||||
const effectiveCurves = $derived<Record<CurveChannel, CurvePoint[]>>({
|
||||
rgb: channelPoints('rgb'),
|
||||
red: channelPoints('red'),
|
||||
green: channelPoints('green'),
|
||||
blue: channelPoints('blue')
|
||||
});
|
||||
const activePreset = $derived(findActiveCurvePreset(effectiveCurves, customPresets));
|
||||
const activePresetName = $derived(activePreset?.name ?? 'Пользовательский');
|
||||
|
||||
// При смене фото параметры берутся из метаданных.
|
||||
let lastPhotoId = '';
|
||||
$effect(() => {
|
||||
const id = photo.id;
|
||||
if (id === lastPhotoId) return;
|
||||
lastPhotoId = id;
|
||||
eyedropper.stop();
|
||||
params = readEditParams(photo);
|
||||
edit.set(id, params);
|
||||
error = '';
|
||||
return () => {
|
||||
// Фото переключилось: отправляем накопленные правки.
|
||||
flushSave();
|
||||
};
|
||||
const id = photo?.id;
|
||||
if (!id) return;
|
||||
if (id !== lastPhotoId) {
|
||||
lastPhotoId = id;
|
||||
untrack(() => {
|
||||
eyedropper.stop();
|
||||
params = readEditParams(photo);
|
||||
edit.set(id, params);
|
||||
error = '';
|
||||
});
|
||||
}
|
||||
// Cleanup регистрируется на каждом запуске эффекта: иначе повторный
|
||||
// запуск из-за обновления объекта photo (тот же id) потерял бы
|
||||
// сохранение накопленных правок при переключении фото.
|
||||
return () => flushSave();
|
||||
});
|
||||
|
||||
// Пипетка баланса белого: просмотрщик присылает цвет нейтральной точки.
|
||||
@@ -217,16 +246,18 @@
|
||||
}
|
||||
|
||||
async function saveTo(id: string, next: EditParams) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
const seq = ++saveSequence;
|
||||
latestSaveByPhoto.set(id, seq);
|
||||
savingCount++;
|
||||
error = '';
|
||||
try {
|
||||
const res = await photosApi.edit(id, next);
|
||||
onUpdated(res.photo);
|
||||
if (latestSaveByPhoto.get(id) === seq) onUpdated(res.photo);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
if (latestSaveByPhoto.get(id) === seq) error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
savingCount--;
|
||||
if (latestSaveByPhoto.get(id) === seq) latestSaveByPhoto.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,14 +448,60 @@
|
||||
channel = next;
|
||||
}
|
||||
|
||||
/** Точки канала: у пустого канала — линейная кривая с крайними точками. */
|
||||
function channelPoints(ch: CurveChannel): CurvePoint[] {
|
||||
return params.light.curve?.[ch] ?? [];
|
||||
return effectiveCurvePoints(params.light.curve?.[ch]);
|
||||
}
|
||||
|
||||
/** Есть ли у канала сохранённая (не пустая) кривая. */
|
||||
function hasChannelCurve(ch: CurveChannel): boolean {
|
||||
return (params.light.curve?.[ch]?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function curvePath(ch: CurveChannel): string {
|
||||
const pts = channelPoints(ch);
|
||||
if (pts.length < 2) return 'M0,100 L100,0';
|
||||
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x * 100},${(1 - p.y) * 100}`).join(' ');
|
||||
return curveSvgPath(channelPoints(ch));
|
||||
}
|
||||
|
||||
function applyPreset(preset: CurvePreset) {
|
||||
if (!canEdit) return;
|
||||
const curve = { ...params.light.curve };
|
||||
for (const ch of CURVE_CHANNELS) {
|
||||
curve[ch] = preset.channels[ch].map((point) => ({ ...point }));
|
||||
}
|
||||
const next = normalizeEditParams({
|
||||
...params,
|
||||
light: { ...params.light, curve }
|
||||
});
|
||||
params = next;
|
||||
edit.set(photo.id, next);
|
||||
onSliderCommit();
|
||||
}
|
||||
|
||||
async function saveCurvePresets(next: CurvePreset[]) {
|
||||
const res = await authApi.savePreferences({ curve_presets: next });
|
||||
auth.setPreferences(res.preferences ?? {});
|
||||
}
|
||||
|
||||
async function onSavePreset(preset: CurvePreset) {
|
||||
await saveCurvePresets(upsertCurvePresets(customPresets, [preset]));
|
||||
}
|
||||
|
||||
async function onRenamePreset(oldName: string, newName: string) {
|
||||
if (newName === oldName) return;
|
||||
if (customPresets.some((item) => item.name === newName)) {
|
||||
throw new Error('Пресет с таким названием уже есть');
|
||||
}
|
||||
await saveCurvePresets(
|
||||
customPresets.map((item) => (item.name === oldName ? { ...item, name: newName } : item))
|
||||
);
|
||||
}
|
||||
|
||||
async function onDeletePreset(name: string) {
|
||||
await saveCurvePresets(customPresets.filter((item) => item.name !== name));
|
||||
}
|
||||
|
||||
async function onImportPresets(imported: CurvePreset[]) {
|
||||
await saveCurvePresets(upsertCurvePresets(customPresets, imported));
|
||||
}
|
||||
|
||||
let dragging = -1;
|
||||
@@ -452,7 +529,7 @@
|
||||
if (target && target.tagName.toLowerCase() === 'circle') return;
|
||||
const ch = channel;
|
||||
const pts = [...channelPoints(ch)];
|
||||
if (pts.length >= 16) return;
|
||||
if (pts.length >= MAX_CURVE_POINTS) return;
|
||||
const point = pointFromEvent(ev);
|
||||
let index = pts.findIndex((p) => p.x > point.x);
|
||||
if (index < 0) index = pts.length;
|
||||
@@ -470,7 +547,11 @@
|
||||
const ch = channel;
|
||||
const pts = [...channelPoints(ch)];
|
||||
if (dragging >= pts.length) return;
|
||||
pts[dragging] = pointFromEvent(ev);
|
||||
const point = pointFromEvent(ev);
|
||||
const minX = dragging > 0 ? pts[dragging - 1].x + 1e-4 : 0;
|
||||
const maxX = dragging < pts.length - 1 ? pts[dragging + 1].x - 1e-4 : 1;
|
||||
const x = maxX >= minX ? Math.min(maxX, Math.max(minX, point.x)) : minX;
|
||||
pts[dragging] = { x, y: point.y };
|
||||
params = {
|
||||
...params,
|
||||
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
|
||||
@@ -499,8 +580,11 @@
|
||||
const ch = channel;
|
||||
const pts = [...channelPoints(ch)];
|
||||
if (index >= pts.length) return;
|
||||
const minX = index > 0 ? pts[index - 1].x + 1e-4 : 0;
|
||||
const maxX = index < pts.length - 1 ? pts[index + 1].x - 1e-4 : 1;
|
||||
const nextX = pts[index].x + dx;
|
||||
pts[index] = {
|
||||
x: Math.min(1, Math.max(0, pts[index].x + dx)),
|
||||
x: maxX >= minX ? Math.min(maxX, Math.max(minX, nextX)) : minX,
|
||||
y: Math.min(1, Math.max(0, pts[index].y + dy))
|
||||
};
|
||||
params = {
|
||||
@@ -523,18 +607,26 @@
|
||||
}
|
||||
|
||||
async function resetEdit(id: string) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
const seq = ++saveSequence;
|
||||
latestSaveByPhoto.set(id, seq);
|
||||
savingCount++;
|
||||
error = '';
|
||||
try {
|
||||
const res = await photosApi.resetEdit(id);
|
||||
params = readEditParams(res.photo);
|
||||
edit.set(id, params);
|
||||
onUpdated(res.photo);
|
||||
if (latestSaveByPhoto.get(id) === seq) {
|
||||
// Сброс фонового (уже покинутого) фото не должен менять
|
||||
// параметры открытого сейчас фото.
|
||||
if (photo?.id === id) {
|
||||
params = readEditParams(res.photo);
|
||||
edit.set(id, params);
|
||||
}
|
||||
onUpdated(res.photo);
|
||||
}
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
if (latestSaveByPhoto.get(id) === seq) error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
savingCount--;
|
||||
if (latestSaveByPhoto.get(id) === seq) latestSaveByPhoto.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,8 +785,6 @@
|
||||
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}
|
||||
@@ -718,16 +808,24 @@
|
||||
</svg>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<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 class="flex min-w-0 items-center gap-2">
|
||||
<span class="text-text-muted shrink-0 text-xs">Точечная кривая</span>
|
||||
<CurvePresetMenu
|
||||
curves={effectiveCurves}
|
||||
presets={customPresets}
|
||||
activeName={activePresetName}
|
||||
disabled={!canEdit}
|
||||
onApply={applyPreset}
|
||||
onSave={onSavePreset}
|
||||
onRename={onRenamePreset}
|
||||
onDelete={onDeletePreset}
|
||||
onImport={onImportPresets}
|
||||
/>
|
||||
</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}
|
||||
disabled={!canEdit || !hasChannelCurve(channel)}
|
||||
onclick={resetCurve}
|
||||
>
|
||||
Сбросить
|
||||
|
||||
@@ -19,19 +19,28 @@
|
||||
photo: Photo;
|
||||
role: string;
|
||||
viewer?: boolean;
|
||||
tab?: 'info' | 'edit';
|
||||
onOpen?: () => void;
|
||||
onRenamed?: (photo: Photo) => void;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
let { photo, role, viewer = false, onOpen, onRenamed, onDeleted }: Props = $props();
|
||||
let {
|
||||
photo,
|
||||
role,
|
||||
viewer = false,
|
||||
tab = $bindable('info'),
|
||||
onOpen,
|
||||
onRenamed,
|
||||
onDeleted
|
||||
}: Props = $props();
|
||||
|
||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
|
||||
const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id);
|
||||
const canRename = $derived(canEdit || photo?.uploader_id === auth.user?.id);
|
||||
const canDelete = $derived(canEdit || photo?.uploader_id === auth.user?.id);
|
||||
// Правки может вносить и загрузивший фото, если у него есть право загрузки.
|
||||
const canEditPhoto = $derived(
|
||||
canEdit || (photo.uploader_id === auth.user?.id && role !== 'viewer')
|
||||
canEdit || (photo?.uploader_id === auth.user?.id && role !== 'viewer')
|
||||
);
|
||||
|
||||
let editingName = $state(false);
|
||||
@@ -39,8 +48,6 @@
|
||||
let savingName = $state(false);
|
||||
let nameError = $state('');
|
||||
|
||||
let tab = $state<'info' | 'edit'>('info');
|
||||
|
||||
let pendingDelete = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
@@ -68,15 +75,16 @@
|
||||
// фото (например, отрендеренные правки) не должны сбрасывать вкладку.
|
||||
let lastPhotoId = '';
|
||||
$effect(() => {
|
||||
const id = photo.id;
|
||||
if (id === lastPhotoId) return;
|
||||
const id = photo?.id;
|
||||
if (!id || id === lastPhotoId) return;
|
||||
lastPhotoId = id;
|
||||
editingName = false;
|
||||
nameError = '';
|
||||
showAll = false;
|
||||
tagQuery = '';
|
||||
tagOpen = false;
|
||||
tab = 'info';
|
||||
// Вкладку не сбрасываем: при переходе между фото пользователь остаётся
|
||||
// на том же разделе правой панели.
|
||||
});
|
||||
|
||||
let tagQuery = $state('');
|
||||
@@ -509,7 +517,8 @@ const tabClass =
|
||||
labels={prefs.photo_info?.labels ?? {}}
|
||||
busy={savingPrefs}
|
||||
onSave={savePreferences}
|
||||
onReset={() => savePreferences({})}
|
||||
onReset={() =>
|
||||
savePreferences({ photo_info: { visible: [...EXIF_BASE], labels: {} } })}
|
||||
onClose={() => (settingsOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BUILTIN_CURVE_PRESETS,
|
||||
MAX_CURVE_PRESETS,
|
||||
exportCurvePresetFile,
|
||||
findActiveCurvePreset,
|
||||
normalizeCurvePreset,
|
||||
normalizeCurvePresets,
|
||||
parseCurvePresetFile,
|
||||
presetPointsEqual,
|
||||
upsertCurvePresets
|
||||
} from './curve-presets';
|
||||
import type { CurvePreset } from './types';
|
||||
|
||||
const linear = () => [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 }
|
||||
];
|
||||
|
||||
const medium = BUILTIN_CURVE_PRESETS.find((preset) => preset.name === 'Средний контраст')!;
|
||||
const custom: CurvePreset = {
|
||||
name: 'Мой',
|
||||
channels: {
|
||||
rgb: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0.5, y: 0.6 },
|
||||
{ x: 1, y: 1 }
|
||||
],
|
||||
red: linear(),
|
||||
green: linear(),
|
||||
blue: linear()
|
||||
}
|
||||
};
|
||||
|
||||
describe('пресеты кривой', () => {
|
||||
it('содержит встроенные пресеты со всеми четырьмя каналами', () => {
|
||||
expect(BUILTIN_CURVE_PRESETS.map((preset) => preset.name)).toEqual([
|
||||
'Линейный',
|
||||
'Средний контраст',
|
||||
'Сильный контраст'
|
||||
]);
|
||||
for (const preset of BUILTIN_CURVE_PRESETS) {
|
||||
expect(Object.keys(preset.channels).sort()).toEqual(['blue', 'green', 'red', 'rgb']);
|
||||
for (const channel of ['rgb', 'red', 'green', 'blue'] as const) {
|
||||
expect(preset.channels[channel].length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
// Контраст задаётся только мастер-кривой, остальные каналы линейные.
|
||||
expect(medium.channels.rgb).not.toEqual(linear());
|
||||
expect(medium.channels.red).toEqual(linear());
|
||||
});
|
||||
|
||||
it('валидирует и нормализует пресет со всеми каналами', () => {
|
||||
expect(normalizeCurvePreset(custom)).toEqual(custom);
|
||||
expect(normalizeCurvePreset({ ...custom, name: ' Мой ' })).toEqual(custom);
|
||||
expect(normalizeCurvePreset({ name: 'x', channels: { rgb: linear() } })).toBeNull();
|
||||
expect(
|
||||
normalizeCurvePreset({
|
||||
...custom,
|
||||
channels: { ...custom.channels, green: [{ x: 0, y: 0 }] }
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeCurvePreset({
|
||||
...custom,
|
||||
channels: { ...custom.channels, blue: [{ x: 0, y: 0 }, { x: 1.2, y: 1 }] }
|
||||
})
|
||||
).toBeNull();
|
||||
expect(normalizeCurvePreset({ ...custom, name: 'a'.repeat(65) })).toBeNull();
|
||||
});
|
||||
|
||||
it('конвертирует старый формат одного канала', () => {
|
||||
const legacy = normalizeCurvePreset({
|
||||
name: 'Старый',
|
||||
channel: 'red',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0.4 }
|
||||
]
|
||||
});
|
||||
expect(legacy?.channels.rgb).toEqual(linear());
|
||||
expect(legacy?.channels.red).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0.4 }
|
||||
]);
|
||||
expect(legacy?.channels.green).toEqual(linear());
|
||||
expect(legacy?.channels.blue).toEqual(linear());
|
||||
});
|
||||
|
||||
it('сортирует точки и отбрасывает дубликаты', () => {
|
||||
const preset = normalizeCurvePreset({
|
||||
...custom,
|
||||
channels: {
|
||||
...custom.channels,
|
||||
rgb: [
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0.5, y: 0.6 }
|
||||
]
|
||||
}
|
||||
});
|
||||
expect(preset?.channels.rgb.map((point) => point.x)).toEqual([0, 0.5, 1]);
|
||||
});
|
||||
|
||||
it('нормализует список из профиля и убирает повторы имён', () => {
|
||||
expect(normalizeCurvePresets([custom, custom, { name: 'bad' }])).toEqual([custom]);
|
||||
expect(normalizeCurvePresets('nope')).toEqual([]);
|
||||
});
|
||||
|
||||
it('разбирает и сериализует файл экспорта', () => {
|
||||
const text = exportCurvePresetFile([custom]);
|
||||
expect(parseCurvePresetFile(text)).toEqual([custom]);
|
||||
expect(() => parseCurvePresetFile('not json')).toThrow();
|
||||
expect(() => parseCurvePresetFile(JSON.stringify({ version: 2, presets: [] }))).toThrow();
|
||||
expect(() => parseCurvePresetFile(JSON.stringify({ version: 1, presets: [{ name: 'x' }] }))).toThrow();
|
||||
expect(() =>
|
||||
parseCurvePresetFile(JSON.stringify({ version: 1, presets: [custom, custom] }))
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('добавляет и перезаписывает пресеты по имени', () => {
|
||||
const changed: CurvePreset = {
|
||||
name: 'Мой',
|
||||
channels: { ...custom.channels, blue: [{ x: 0, y: 0 }, { x: 1, y: 0.5 }] }
|
||||
};
|
||||
const next = upsertCurvePresets([custom], [changed]);
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0].channels.blue).toEqual(changed.channels.blue);
|
||||
// Копии не должны делить массивы с исходным пресетом.
|
||||
expect(next[0].channels.red).not.toBe(custom.channels.red);
|
||||
|
||||
const other: CurvePreset = { ...custom, name: 'Другой' };
|
||||
expect(upsertCurvePresets([custom], [other]).map((preset) => preset.name)).toEqual([
|
||||
'Мой',
|
||||
'Другой'
|
||||
]);
|
||||
expect(() =>
|
||||
upsertCurvePresets(
|
||||
Array.from({ length: MAX_CURVE_PRESETS }, (_, i) => ({ ...custom, name: `p${i}` })),
|
||||
[other]
|
||||
)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('находит активный пресет по кривым всех каналов', () => {
|
||||
expect(findActiveCurvePreset(medium.channels, [])?.name).toBe('Средний контраст');
|
||||
expect(findActiveCurvePreset(custom.channels, [custom])?.name).toBe('Мой');
|
||||
expect(
|
||||
findActiveCurvePreset(
|
||||
{ ...custom.channels, blue: [{ x: 0, y: 0 }, { x: 1, y: 0.5 }] },
|
||||
[custom]
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
findActiveCurvePreset(
|
||||
{ rgb: linear(), red: linear(), green: linear(), blue: linear() },
|
||||
[]
|
||||
)?.name
|
||||
).toBe('Линейный');
|
||||
});
|
||||
|
||||
it('сравнивает точки с допуском', () => {
|
||||
expect(presetPointsEqual([{ x: 0, y: 0 }], [{ x: 1e-9, y: -1e-9 }])).toBe(true);
|
||||
expect(presetPointsEqual([{ x: 0, y: 0 }], [{ x: 0.1, y: 0 }])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
CURVE_CHANNELS,
|
||||
MAX_CURVE_POINTS,
|
||||
normalizeCurve,
|
||||
type CurveChannel,
|
||||
type CurvePoint
|
||||
} from './edit';
|
||||
import type { CurvePreset, CurvePresetChannels } from './types';
|
||||
|
||||
export const MAX_CURVE_PRESETS = 100;
|
||||
export const MAX_CURVE_PRESET_NAME = 64;
|
||||
export const CURVE_PRESET_FILE_VERSION = 1;
|
||||
|
||||
function linearPoints(): CurvePoint[] {
|
||||
return [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 }
|
||||
];
|
||||
}
|
||||
|
||||
function rgbContrastPoints(strong: boolean): CurvePoint[] {
|
||||
return strong
|
||||
? [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0.2, y: 0.12 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.8, y: 0.88 },
|
||||
{ x: 1, y: 1 }
|
||||
]
|
||||
: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0.25, y: 0.2 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.75, y: 0.8 },
|
||||
{ x: 1, y: 1 }
|
||||
];
|
||||
}
|
||||
|
||||
function linearChannels(rgb: CurvePoint[]): CurvePresetChannels {
|
||||
return {
|
||||
rgb,
|
||||
red: linearPoints(),
|
||||
green: linearPoints(),
|
||||
blue: linearPoints()
|
||||
};
|
||||
}
|
||||
|
||||
/** Встроенные пресеты доступны всегда и не сохраняются в профиле. */
|
||||
export const BUILTIN_CURVE_PRESETS: CurvePreset[] = [
|
||||
{ name: 'Линейный', channels: linearChannels(linearPoints()) },
|
||||
{ name: 'Средний контраст', channels: linearChannels(rgbContrastPoints(false)) },
|
||||
{ name: 'Сильный контраст', channels: linearChannels(rgbContrastPoints(true)) }
|
||||
];
|
||||
|
||||
/** Обрезает имя пресета; null — имя пустое или слишком длинное. */
|
||||
export function normalizeCurvePresetName(name: unknown): string | null {
|
||||
if (typeof name !== 'string') return null;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
if ([...trimmed].length > MAX_CURVE_PRESET_NAME) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function presetPoints(raw: unknown): CurvePoint[] | null {
|
||||
if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_CURVE_POINTS) return null;
|
||||
const points: CurvePoint[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const point = item as { x?: unknown; y?: unknown };
|
||||
if (
|
||||
typeof point.x !== 'number' ||
|
||||
typeof point.y !== 'number' ||
|
||||
!Number.isFinite(point.x) ||
|
||||
!Number.isFinite(point.y) ||
|
||||
point.x < 0 ||
|
||||
point.x > 1 ||
|
||||
point.y < 0 ||
|
||||
point.y > 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
points.push({ x: point.x, y: point.y });
|
||||
}
|
||||
const normalized = normalizeCurve(points);
|
||||
if (!normalized || normalized.length < 2) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function presetChannels(raw: unknown): CurvePresetChannels | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const item = raw as Record<string, unknown>;
|
||||
const channels = {} as CurvePresetChannels;
|
||||
for (const channel of CURVE_CHANNELS) {
|
||||
const points = presetPoints(item[channel]);
|
||||
if (!points) return null;
|
||||
channels[channel] = points;
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
/** Старый формат `{name, channel, points}`: остальные каналы линейные. */
|
||||
function legacyChannels(item: Record<string, unknown>): CurvePresetChannels | null {
|
||||
if (typeof item.channel !== 'string') return null;
|
||||
if (!CURVE_CHANNELS.includes(item.channel as CurveChannel)) return null;
|
||||
const points = presetPoints(item.points);
|
||||
if (!points) return null;
|
||||
const channels = {
|
||||
rgb: linearPoints(),
|
||||
red: linearPoints(),
|
||||
green: linearPoints(),
|
||||
blue: linearPoints()
|
||||
};
|
||||
channels[item.channel as CurveChannel] = points;
|
||||
return channels;
|
||||
}
|
||||
|
||||
/** Проверяет и нормализует один пресет; null — пресет некорректен. */
|
||||
export function normalizeCurvePreset(raw: unknown): CurvePreset | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const item = raw as Record<string, unknown>;
|
||||
const name = normalizeCurvePresetName(item.name);
|
||||
if (!name) return null;
|
||||
const channels =
|
||||
item.channels !== undefined ? presetChannels(item.channels) : legacyChannels(item);
|
||||
if (!channels) return null;
|
||||
return { name, channels };
|
||||
}
|
||||
|
||||
/** Нормализует список из профиля: некорректные пресеты отбрасываются. */
|
||||
export function normalizeCurvePresets(raw: unknown): CurvePreset[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: CurvePreset[] = [];
|
||||
const names = new Set<string>();
|
||||
for (const item of raw) {
|
||||
const preset = normalizeCurvePreset(item);
|
||||
if (!preset || names.has(preset.name)) continue;
|
||||
names.add(preset.name);
|
||||
out.push(preset);
|
||||
if (out.length >= MAX_CURVE_PRESETS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Разбирает файл импорта. Любая ошибка отменяет импорт целиком. */
|
||||
export function parseCurvePresetFile(text: string): CurvePreset[] {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('Файл не является корректным JSON');
|
||||
}
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Файл должен содержать объект с пресетами');
|
||||
}
|
||||
const file = data as Record<string, unknown>;
|
||||
if (file.version !== CURVE_PRESET_FILE_VERSION) {
|
||||
throw new Error('Неподдерживаемая версия файла пресетов');
|
||||
}
|
||||
if (!Array.isArray(file.presets)) {
|
||||
throw new Error('В файле нет списка пресетов');
|
||||
}
|
||||
if (file.presets.length > MAX_CURVE_PRESETS) {
|
||||
throw new Error(`В файле больше ${MAX_CURVE_PRESETS} пресетов`);
|
||||
}
|
||||
const presets: CurvePreset[] = [];
|
||||
const names = new Set<string>();
|
||||
for (const item of file.presets) {
|
||||
const preset = normalizeCurvePreset(item);
|
||||
if (!preset) throw new Error('В файле есть некорректный пресет');
|
||||
if (names.has(preset.name)) throw new Error(`Пресет «${preset.name}» повторяется`);
|
||||
names.add(preset.name);
|
||||
presets.push(preset);
|
||||
}
|
||||
return presets;
|
||||
}
|
||||
|
||||
/** Сериализует пользовательские пресеты в формат экспорта. */
|
||||
export function exportCurvePresetFile(presets: CurvePreset[]): string {
|
||||
return JSON.stringify({ version: CURVE_PRESET_FILE_VERSION, presets }, null, 2);
|
||||
}
|
||||
|
||||
/** Добавляет или заменяет пресеты по имени. */
|
||||
export function upsertCurvePresets(
|
||||
current: CurvePreset[],
|
||||
incoming: CurvePreset[]
|
||||
): CurvePreset[] {
|
||||
const out = current.map((preset) => ({
|
||||
...preset,
|
||||
channels: copyChannels(preset.channels)
|
||||
}));
|
||||
for (const preset of incoming) {
|
||||
const index = out.findIndex((item) => item.name === preset.name);
|
||||
const copy: CurvePreset = {
|
||||
...preset,
|
||||
channels: copyChannels(preset.channels)
|
||||
};
|
||||
if (index >= 0) out[index] = copy;
|
||||
else out.push(copy);
|
||||
}
|
||||
if (out.length > MAX_CURVE_PRESETS) {
|
||||
throw new Error(`Не больше ${MAX_CURVE_PRESETS} пресетов`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function copyChannels(channels: CurvePresetChannels): CurvePresetChannels {
|
||||
return {
|
||||
rgb: channels.rgb.map((point) => ({ ...point })),
|
||||
red: channels.red.map((point) => ({ ...point })),
|
||||
green: channels.green.map((point) => ({ ...point })),
|
||||
blue: channels.blue.map((point) => ({ ...point }))
|
||||
};
|
||||
}
|
||||
|
||||
/** Совпадают ли точки кривых (с допуском на округление). */
|
||||
export function presetPointsEqual(
|
||||
a: CurvePoint[],
|
||||
b: CurvePoint[],
|
||||
epsilon = 1e-6
|
||||
): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every(
|
||||
(point, index) =>
|
||||
Math.abs(point.x - b[index].x) <= epsilon &&
|
||||
Math.abs(point.y - b[index].y) <= epsilon
|
||||
);
|
||||
}
|
||||
|
||||
/** Активный пресет: кривые всех четырёх каналов совпадают с пресетом. */
|
||||
export function findActiveCurvePreset(
|
||||
curves: Record<CurveChannel, CurvePoint[]>,
|
||||
custom: CurvePreset[]
|
||||
): CurvePreset | undefined {
|
||||
return [...custom, ...BUILTIN_CURVE_PRESETS].find((preset) =>
|
||||
CURVE_CHANNELS.every((channel) =>
|
||||
presetPointsEqual(curves[channel], preset.channels[channel])
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyColorToPixels,
|
||||
buildCurveLUT,
|
||||
curveSvgPath,
|
||||
effectiveCurvePoints,
|
||||
emptyColor,
|
||||
emptyEditParams,
|
||||
isEmptyColor,
|
||||
@@ -182,3 +185,73 @@ describe('цветовые параметры', () => {
|
||||
expect(b).toBeCloseTo(g, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('кривая', () => {
|
||||
const medium = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0.25, y: 0.2 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.75, y: 0.8 },
|
||||
{ x: 1, y: 1 }
|
||||
];
|
||||
const strong = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0.2, y: 0.12 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.8, y: 0.88 },
|
||||
{ x: 1, y: 1 }
|
||||
];
|
||||
const inner = [
|
||||
{ x: 0.1, y: 0.05 },
|
||||
{ x: 0.4, y: 0.3 },
|
||||
{ x: 0.9, y: 0.95 }
|
||||
];
|
||||
const samples = [0, 32, 64, 128, 192, 224, 255];
|
||||
|
||||
it('показывает крайние точки у пустого канала', () => {
|
||||
expect(effectiveCurvePoints(undefined)).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 }
|
||||
]);
|
||||
expect(effectiveCurvePoints([{ x: 0.5, y: 0.4 }])).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 }
|
||||
]);
|
||||
expect(effectiveCurvePoints(inner)).toEqual(inner);
|
||||
});
|
||||
|
||||
it('совпадает с серверной PCHIP-кривой', () => {
|
||||
const values = (points: { x: number; y: number }[]) => {
|
||||
const lut = buildCurveLUT(points);
|
||||
return samples.map((i) => lut[i]);
|
||||
};
|
||||
expect(values([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toEqual([
|
||||
0, 32, 64, 128, 192, 224, 255
|
||||
]);
|
||||
expect(values(medium)).toEqual([0, 23, 51, 128, 205, 233, 255]);
|
||||
expect(values(strong)).toEqual([0, 17, 43, 128, 213, 239, 255]);
|
||||
expect(values(inner)).toEqual([13, 17, 42, 104, 186, 234, 242]);
|
||||
});
|
||||
|
||||
it('остаётся монотонной между точками', () => {
|
||||
for (const points of [medium, strong]) {
|
||||
const lut = buildCurveLUT(points);
|
||||
for (let i = 1; i < lut.length; i++) {
|
||||
expect(lut[i]).toBeGreaterThanOrEqual(lut[i - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('делает плоские полки вне крайних точек', () => {
|
||||
const lut = buildCurveLUT(inner);
|
||||
for (let i = 0; i <= Math.floor(0.1 * 255); i++) expect(lut[i]).toBe(13);
|
||||
for (let i = Math.ceil(0.9 * 255); i < 256; i++) expect(lut[i]).toBe(242);
|
||||
});
|
||||
|
||||
it('рисует плавный SVG-путь с полками', () => {
|
||||
const path = curveSvgPath(inner);
|
||||
expect(path.startsWith('M0.0000,95.0000 L10.0000,95.0000')).toBe(true);
|
||||
expect(path).toContain('C');
|
||||
expect(path.endsWith('L100.0000,5.0000')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+106
-10
@@ -154,6 +154,12 @@ export const MAX_EXPOSURE_STOPS = 5;
|
||||
export const MAX_PERCENT = 100;
|
||||
export const MAX_CURVE_POINTS = 16;
|
||||
|
||||
/** Крайние точки кривой по умолчанию: линейная кривая. */
|
||||
export const DEFAULT_CURVE_POINTS: CurvePoint[] = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 }
|
||||
];
|
||||
|
||||
export function emptyLight(): LightParams {
|
||||
return {
|
||||
exposure: 0,
|
||||
@@ -411,6 +417,58 @@ export function photoFullUrl(photo: Photo): string | undefined {
|
||||
return photo.edit_url ?? photo.original_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Точки для отображения: у пустого или некорректного канала показываем
|
||||
* линейную кривую с крайними точками. Сохранённые точки остаются источником
|
||||
* истины, если их хотя бы две.
|
||||
*/
|
||||
export function effectiveCurvePoints(points?: CurvePoint[]): CurvePoint[] {
|
||||
const pts = normalizeCurve(points);
|
||||
if (!pts || pts.length < 2) return DEFAULT_CURVE_POINTS.map((p) => ({ ...p }));
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Наклоны в контрольных точках для монотонного кубического Эрмита
|
||||
* (Fritsch–Carlson). Формула совпадает с серверной реализацией.
|
||||
*/
|
||||
function curveTangents(points: CurvePoint[]): number[] {
|
||||
const n = points.length;
|
||||
if (n < 2) return [];
|
||||
const h = new Array<number>(n - 1);
|
||||
const delta = new Array<number>(n - 1);
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
h[i] = points[i + 1].x - points[i].x;
|
||||
delta[i] = (points[i + 1].y - points[i].y) / h[i];
|
||||
}
|
||||
const m = new Array<number>(n).fill(0);
|
||||
if (n === 2) {
|
||||
m[0] = delta[0];
|
||||
m[1] = delta[0];
|
||||
return m;
|
||||
}
|
||||
m[0] = endpointTangent(h[0], h[1], delta[0], delta[1]);
|
||||
m[n - 1] = endpointTangent(h[n - 2], h[n - 3], delta[n - 2], delta[n - 3]);
|
||||
for (let i = 1; i < n - 1; i++) {
|
||||
if (delta[i - 1] * delta[i] <= 0) {
|
||||
m[i] = 0;
|
||||
continue;
|
||||
}
|
||||
const w1 = 2 * h[i] + h[i - 1];
|
||||
const w2 = h[i] + 2 * h[i - 1];
|
||||
m[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Односторонняя касательная крайней точки с защитой от выбросов. */
|
||||
function endpointTangent(h0: number, h1: number, d0: number, d1: number): number {
|
||||
const m = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1);
|
||||
if (m * d0 <= 0) return 0;
|
||||
if (d0 * d1 < 0 && Math.abs(m) > Math.abs(3 * d0)) return 3 * d0;
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит 256-элементную таблицу соответствия по точкам кривой. Математика
|
||||
* повторяет серверную (backend/internal/photos/edit.go), поэтому предпросмотр
|
||||
@@ -423,25 +481,63 @@ export function buildCurveLUT(points?: CurvePoint[]): Uint8Array {
|
||||
for (let i = 0; i < 256; i++) lut[i] = i;
|
||||
return lut;
|
||||
}
|
||||
const tangents = curveTangents(pts);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
lut[i] = Math.round(clamp01(curveEval(pts, i / 255)) * 255);
|
||||
lut[i] = Math.round(clamp01(curveEval(pts, tangents, i / 255)) * 255);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function curveEval(points: CurvePoint[], x: number): number {
|
||||
/** Значение плавной кривой в точке x; вне крайних точек — плоская полка. */
|
||||
function curveEval(points: CurvePoint[], tangents: number[], x: number): number {
|
||||
if (x <= points[0].x) return points[0].y;
|
||||
const last = points[points.length - 1];
|
||||
if (x >= last.x) return last.y;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (x > points[i].x) continue;
|
||||
const prev = points[i - 1];
|
||||
const cur = points[i];
|
||||
if (cur.x - prev.x < 1e-9) return cur.y;
|
||||
const t = (x - prev.x) / (cur.x - prev.x);
|
||||
return prev.y + t * (cur.y - prev.y);
|
||||
let i = 1;
|
||||
while (i < points.length - 1 && x > points[i].x) i++;
|
||||
const p0 = points[i - 1];
|
||||
const p1 = points[i];
|
||||
const h = p1.x - p0.x;
|
||||
if (h < 1e-9) return p1.y;
|
||||
const t = (x - p0.x) / h;
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
const h00 = 2 * t3 - 3 * t2 + 1;
|
||||
const h10 = t3 - 2 * t2 + t;
|
||||
const h01 = -2 * t3 + 3 * t2;
|
||||
const h11 = t3 - t2;
|
||||
return h00 * p0.y + h10 * h * tangents[i - 1] + h01 * p1.y + h11 * h * tangents[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG-путь кривой в координатах 0..100 (ось Y перевёрнута). Между точками —
|
||||
* кубические Безье с теми же касательными, что и у LUT; вне крайних точек —
|
||||
* горизонтальные полки.
|
||||
*/
|
||||
export function curveSvgPath(points?: CurvePoint[]): string {
|
||||
const pts = effectiveCurvePoints(points);
|
||||
if (pts.length < 2) return 'M0,100 L100,0';
|
||||
const tangents = curveTangents(pts);
|
||||
const sx = (x: number) => (clamp01(x) * 100).toFixed(4);
|
||||
const sy = (y: number) => ((1 - clamp01(y)) * 100).toFixed(4);
|
||||
const parts: string[] = [`M${sx(0)},${sy(pts[0].y)}`];
|
||||
if (pts[0].x > 1e-9) parts.push(`L${sx(pts[0].x)},${sy(pts[0].y)}`);
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[i];
|
||||
const p1 = pts[i + 1];
|
||||
const h = p1.x - p0.x;
|
||||
if (h < 1e-9) continue;
|
||||
const c1x = p0.x + h / 3;
|
||||
const c1y = p0.y + (tangents[i] * h) / 3;
|
||||
const c2x = p1.x - h / 3;
|
||||
const c2y = p1.y - (tangents[i + 1] * h) / 3;
|
||||
parts.push(
|
||||
`C${sx(c1x)},${sy(c1y)} ${sx(c2x)},${sy(c2y)} ${sx(p1.x)},${sy(p1.y)}`
|
||||
);
|
||||
}
|
||||
return last.y;
|
||||
const last = pts[pts.length - 1];
|
||||
if (last.x < 1 - 1e-9) parts.push(`L${sx(1)},${sy(last.y)}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function adjustChannel(
|
||||
|
||||
@@ -3,8 +3,27 @@ export interface PhotoInfoPreferences {
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CurvePresetPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface CurvePresetChannels {
|
||||
rgb: CurvePresetPoint[];
|
||||
red: CurvePresetPoint[];
|
||||
green: CurvePresetPoint[];
|
||||
blue: CurvePresetPoint[];
|
||||
}
|
||||
|
||||
/** Пользовательский пресет тоновой кривой: кривые всех четырёх каналов. */
|
||||
export interface CurvePreset {
|
||||
name: string;
|
||||
channels: CurvePresetChannels;
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
photo_info?: PhotoInfoPreferences;
|
||||
curve_presets?: CurvePreset[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
Reference in New Issue
Block a user