feat(web): вкладка «Редактирование» с инструментом «Свет»

В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.

Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
This commit is contained in:
iu
2026-10-20 23:06:24 +03:00
parent afaaaab5f0
commit 0548b1ab5e
32 changed files with 1954 additions and 24 deletions
+4
View File
@@ -1,5 +1,6 @@
import { goto } from '$app/navigation';
import { auth, type Tokens, type User } from './stores/auth.svelte';
import type { EditParams } from './edit';
import type { Photo, Preferences, Tag } from './types';
export class ApiError extends Error {
@@ -142,6 +143,9 @@ export const photosApi = {
body: albumId ? { album_id: albumId } : {}
}),
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }),
edit: (id: string, params: EditParams) =>
api<{ photo: Photo }>(`/photos/${id}/edit`, { method: 'PATCH', body: params }),
resetEdit: (id: string) => api<{ photo: Photo }>(`/photos/${id}/edit`, { method: 'DELETE' }),
addTag: (id: string, name: string) =>
api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }),
removeTag: (id: string, tagId: string) =>
@@ -0,0 +1,345 @@
<script lang="ts">
import { photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import {
CHANNEL_COLORS,
CHANNEL_LABELS,
CURVE_CHANNELS,
LIGHT_SLIDERS,
emptyEditParams,
isEmptyEditParams,
normalizeEditParams,
readEditParams,
type CurveChannel,
type CurvePoint,
type EditParams
} from '$lib/edit';
import type { Photo } from '$lib/types';
interface Props {
photo: Photo;
canEdit: boolean;
onUpdated: (photo: Photo) => void;
}
let { photo, canEdit, onUpdated }: Props = $props();
let params = $state<EditParams>(emptyEditParams());
let busy = $state(false);
let error = $state('');
let collapsed = $state(false);
let channel = $state<CurveChannel>('rgb');
const saved = $derived(readEditParams(photo));
const hasEdits = $derived(!isEmptyEditParams(saved));
const dirty = $derived(JSON.stringify(params) !== JSON.stringify(saved));
// При смене фото параметры берутся из метаданных.
let lastPhotoId = '';
$effect(() => {
const id = photo.id;
if (id === lastPhotoId) return;
lastPhotoId = id;
params = readEditParams(photo);
error = '';
});
async function save(next: EditParams) {
if (busy) return;
busy = true;
error = '';
try {
const res = await photosApi.edit(photo.id, next);
onUpdated(res.photo);
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
function onSliderInput(key: (typeof LIGHT_SLIDERS)[number]['key'], value: number) {
params = { ...params, light: { ...params.light, [key]: value } };
}
function onSliderCommit() {
if (!canEdit) return;
const next = normalizeEditParams(params);
params = next;
if (isEmptyEditParams(next)) {
reset();
return;
}
void save(next);
}
function setChannel(next: CurveChannel) {
channel = next;
}
function channelPoints(ch: CurveChannel): CurvePoint[] {
return params.light.curve?.[ch] ?? [];
}
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(' ');
}
let dragging = -1;
function pointFromEvent(ev: PointerEvent): CurvePoint {
const svg = (ev.currentTarget as SVGElement).ownerSVGElement ?? (ev.currentTarget as SVGElement);
const rect = svg.getBoundingClientRect();
const x = (ev.clientX - rect.left) / rect.width;
const y = 1 - (ev.clientY - rect.top) / rect.height;
return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) };
}
function onPointDown(ev: PointerEvent, index: number) {
if (!canEdit) return;
ev.preventDefault();
ev.stopPropagation();
dragging = index;
(ev.currentTarget as Element).setPointerCapture?.(ev.pointerId);
}
/** Клик по полю кривой добавляет точку. */
function onCurveDown(ev: PointerEvent) {
if (!canEdit) return;
const target = ev.target as Element | null;
if (target && target.tagName.toLowerCase() === 'circle') return;
const ch = channel;
const pts = [...channelPoints(ch)];
if (pts.length >= 16) return;
const point = pointFromEvent(ev);
let index = pts.findIndex((p) => p.x > point.x);
if (index < 0) index = pts.length;
pts.splice(index, 0, point);
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
dragging = index;
}
function onPointMove(ev: PointerEvent) {
if (dragging < 0 || !canEdit) return;
const ch = channel;
const pts = [...channelPoints(ch)];
if (dragging >= pts.length) return;
pts[dragging] = pointFromEvent(ev);
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
}
function onPointUp() {
if (dragging < 0) return;
dragging = -1;
onSliderCommit();
}
/** Точку кривой можно двигать и с клавиатуры. */
function onPointKey(ev: KeyboardEvent, index: number) {
if (!canEdit) return;
const step = ev.shiftKey ? 0.1 : 0.02;
let dx = 0;
let dy = 0;
if (ev.key === 'ArrowLeft') dx = -step;
else if (ev.key === 'ArrowRight') dx = step;
else if (ev.key === 'ArrowUp') dy = step;
else if (ev.key === 'ArrowDown') dy = -step;
else return;
ev.preventDefault();
const ch = channel;
const pts = [...channelPoints(ch)];
if (index >= pts.length) return;
pts[index] = {
x: Math.min(1, Math.max(0, pts[index].x + dx)),
y: Math.min(1, Math.max(0, pts[index].y + dy))
};
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: pts } }
};
onSliderCommit();
}
function resetCurve() {
if (!canEdit) return;
const ch = channel;
params = {
...params,
light: { ...params.light, curve: { ...params.light.curve, [ch]: undefined } }
};
onSliderCommit();
}
async function reset() {
if (busy) return;
busy = true;
error = '';
try {
const res = await photosApi.resetEdit(photo.id);
params = readEditParams(res.photo);
onUpdated(res.photo);
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
// Сброс доступен, когда у фото есть правки.
async function toggleReset() {
if (!hasEdits) return;
await reset();
}
const sliderClass = 'accent-primary h-1 w-full cursor-pointer';
const valueClass = 'text-text-muted text-xs tabular-nums';
function formatValue(value: number): string {
const rounded = Math.round(value * 10) / 10;
if (rounded === 0) return '0';
return rounded > 0 ? `+${rounded}` : `${rounded}`;
}
</script>
<div class="p-4">
<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}
onclick={() => (collapsed = !collapsed)}
>
<Icon
name="expand_more"
class="size-4 transition-transform {collapsed ? '-rotate-90' : ''}"
/>
Свет
</button>
<button
type="button"
class="border-border text-text-muted hover:text-text inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs disabled:opacity-40"
aria-pressed={hasEdits}
disabled={!canEdit || !hasEdits || busy}
title="Сбросить правки: удалить параметры и отредактированную версию"
onclick={toggleReset}
>
Сброс
</button>
</div>
{#if !collapsed}
<div class="mt-3 space-y-4">
<div class="space-y-3">
{#each LIGHT_SLIDERS as slider (slider.key)}
<div>
<div class="flex items-baseline justify-between gap-2">
<label class="text-text text-xs" for="light-{slider.key}">{slider.label}</label>
<span class={valueClass}>{formatValue(params.light[slider.key])}</span>
</div>
<input
id="light-{slider.key}"
type="range"
class={sliderClass}
min={slider.min}
max={slider.max}
step={slider.step}
value={params.light[slider.key]}
disabled={!canEdit}
oninput={(e) => onSliderInput(slider.key, Number(e.currentTarget.value))}
onchange={onSliderCommit}
/>
</div>
{/each}
</div>
<div class="border-border border-t pt-3">
<div class="flex items-center justify-between gap-2">
<span class="text-text text-xs font-semibold">Кривая</span>
<div class="flex items-center gap-1.5">
{#each CURVE_CHANNELS as ch (ch)}
<button
type="button"
class="size-4 rounded-full border-2 disabled:opacity-40"
style="border-color: {CHANNEL_COLORS[ch]}; background: {ch === channel
? CHANNEL_COLORS[ch]
: 'transparent'}"
aria-label="Канал {CHANNEL_LABELS[ch]}"
title="Канал {CHANNEL_LABELS[ch]}"
aria-pressed={ch === channel}
disabled={!canEdit}
onclick={() => setChannel(ch)}
></button>
{/each}
</div>
</div>
<svg
class="border-border mt-2 block aspect-square w-full touch-none rounded border"
viewBox="0 0 100 100"
role="application"
aria-label="Тоновая кривая"
onpointerdown={onCurveDown}
onpointermove={onPointMove}
onpointerup={onPointUp}
onpointerleave={onPointUp}
>
<rect x="0" y="0" width="100" height="100" class="bg-bg" />
{#each [25, 50, 75] as step (step)}
<line x1={step} y1="0" x2={step} y2="100" class="stroke-border" stroke-width="0.4" />
<line x1="0" y1={step} x2="100" y2={step} class="stroke-border" stroke-width="0.4" />
{/each}
<path d={curvePath(channel)} fill="none" stroke={CHANNEL_COLORS[channel]} stroke-width="1.2" />
{#each channelPoints(channel) as point, index (index)}
<circle
cx={point.x * 100}
cy={(1 - point.y) * 100}
r="2.4"
class="cursor-grab"
fill="var(--bg)"
stroke={CHANNEL_COLORS[channel]}
stroke-width="1"
role="slider"
tabindex="0"
aria-label="Точка кривой {index + 1}"
aria-valuemin="0"
aria-valuemax="1"
aria-valuenow={point.x}
onpointerdown={(e) => onPointDown(e, index)}
onkeydown={(e) => onPointKey(e, index)}
></circle>
{/each}
</svg>
<div class="mt-2 flex items-center justify-between gap-2">
<span class="text-text-muted text-xs">Точечная кривая · линейная</span>
<button
type="button"
class="border-border text-text-muted hover:text-text rounded-md border px-2 py-1 text-xs disabled:opacity-40"
disabled={!canEdit || !channelPoints(channel).length}
onclick={resetCurve}
>
Сбросить кривую
</button>
</div>
</div>
{#if busy}
<p class="text-text-subtle text-xs">Сохраняем и рендерим…</p>
{/if}
{#if error}
<p class="text-danger text-xs">{error}</p>
{/if}
{#if dirty && !busy}
<p class="text-text-subtle text-xs">Есть несохранённые изменения</p>
{/if}
</div>
{/if}
</div>
+10 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { grid } from '$lib/stores/grid.svelte';
import { photoMediumUrl, photoPreviewUrl, photoThumbUrl } from '$lib/edit';
import { isPanoTag } from '$lib/pano';
import type { Photo } from '$lib/types';
@@ -38,11 +39,15 @@
// Offer every variant the photo actually has; the backend generates the
// medium variant asynchronously, so an incomplete set must still render.
// When the photo is edited, the edited variants are shown.
function srcsetFor(photo: Photo): string {
const parts: string[] = [];
if (photo.thumb_url) parts.push(`${photo.thumb_url} 200w`);
if (photo.medium_url) parts.push(`${photo.medium_url} 800w`);
if (photo.preview_url) parts.push(`${photo.preview_url} 1200w`);
const thumb = photoThumbUrl(photo);
const medium = photoMediumUrl(photo);
const preview = photoPreviewUrl(photo);
if (thumb) parts.push(`${thumb} 200w`);
if (medium) parts.push(`${medium} 800w`);
if (preview) parts.push(`${preview} 1200w`);
return parts.join(', ');
}
@@ -106,9 +111,9 @@
ondragstart={(e) => onDragStart?.(i, e)}
oncontextmenu={(e) => onContextMenu?.(i, e)}
>
{#if photo.thumb_url}
{#if photoThumbUrl(photo)}
<img
src={photo.thumb_url}
src={photoThumbUrl(photo)}
srcset={srcsetFor(photo)}
{sizes}
alt={photo.original_filename}
+48 -1
View File
@@ -5,6 +5,7 @@
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoEditPanel from './PhotoEditPanel.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
@@ -28,12 +29,18 @@
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 canEditPhoto = $derived(
canEdit || (photo.uploader_id === auth.user?.id && role !== 'viewer')
);
let editingName = $state(false);
let nameDraft = $state('');
let savingName = $state(false);
let nameError = $state('');
let tab = $state<'info' | 'edit'>('info');
let pendingDelete = $state(false);
let deleting = $state(false);
@@ -65,6 +72,7 @@
showAll = false;
tagQuery = '';
tagOpen = false;
tab = 'info';
});
let tagQuery = $state('');
@@ -207,10 +215,47 @@
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
const tabClass =
'text-text-muted hover:text-text flex-1 border-b-2 border-transparent px-3 py-2 text-sm font-medium transition-colors aria-selected:border-primary aria-selected:text-text';
</script>
<div>
<div class="p-4">
<div
class="border-border bg-chrome sticky top-0 z-10 flex border-b"
role="tablist"
aria-label="Разделы фото"
>
<button
type="button"
role="tab"
id="photo-tab-info"
aria-controls="photo-panel-info"
aria-selected={tab === '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>
</div>
{#if tab === 'edit'}
<div id="photo-panel-edit" role="tabpanel" aria-labelledby="photo-tab-edit">
<PhotoEditPanel {photo} canEdit={canEditPhoto} onUpdated={(updated) => onRenamed?.(updated)} />
</div>
{:else}
<div id="photo-panel-info" role="tabpanel" aria-labelledby="photo-tab-info">
<div class="p-4">
{#if editingName}
<form class="relative" onsubmit={saveName}>
<input
@@ -443,6 +488,8 @@
<PanelSection id="photo-comments" title="Комментарии">
<Comments photoId={photo.id} />
</PanelSection>
</div>
{/if}
</div>
{#if settingsOpen}
+15 -2
View File
@@ -9,6 +9,7 @@
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { viewer } from '$lib/stores/viewer.svelte';
import { photoFullUrl, photoPreviewUrl } from '$lib/edit';
import type { Photo } from '$lib/types';
interface Props {
@@ -55,10 +56,19 @@
let startL = 0;
let startT = 0;
// Отредактированная версия фото показывается вместо исходной.
const previewSrc = $derived(
fresh?.preview_url ?? fresh?.original_url ?? current?.preview_url ?? current?.original_url ?? ''
(fresh ? photoPreviewUrl(fresh) : undefined) ??
(current ? photoPreviewUrl(current) : undefined) ??
fresh?.original_url ??
current?.original_url ??
''
);
const fullSrc = $derived(
(fresh ? photoFullUrl(fresh) : undefined) ??
(current ? photoFullUrl(current) : undefined) ??
''
);
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
const pano = $derived(panoTag && !panoFailed);
@@ -79,6 +89,9 @@
$effect(() => {
const id = current?.id;
// Версия фото входит в зависимость: после рендера правок превью
// меняется, и данные нужно перечитать.
current?.edit_preview_url ?? current?.preview_url ?? '';
fresh = null;
zoomed = false;
scale = 1;
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
emptyEditParams,
isEmptyEditParams,
normalizeEditParams,
readEditParams
} from './edit';
import type { Photo } from './types';
describe('параметры правок', () => {
it('зажимает значения в допустимые диапазоны', () => {
const params = normalizeEditParams({
light: { ...emptyEditParams().light, exposure: 99, contrast: -500 }
});
expect(params.light.exposure).toBe(5);
expect(params.light.contrast).toBe(-100);
});
it('считает пустыми нулевые параметры и линейную кривую', () => {
expect(isEmptyEditParams(emptyEditParams())).toBe(true);
const linear = normalizeEditParams({
light: {
...emptyEditParams().light,
curve: { rgb: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }
}
});
expect(isEmptyEditParams(linear)).toBe(true);
const bent = normalizeEditParams({
light: {
...emptyEditParams().light,
curve: { rgb: [{ x: 0, y: 0 }, { x: 1, y: 0.5 }] }
}
});
expect(isEmptyEditParams(bent)).toBe(false);
});
it('читает параметры из метаданных фото', () => {
const photo = {
edit_params: { light: { exposure: 0.5, curve: { rgb: [{ x: 0, y: 0 }] } } }
} as unknown as Photo;
const params = readEditParams(photo);
expect(params.light.exposure).toBe(0.5);
expect(params.light.contrast).toBe(0);
});
it('сортирует точки кривой и убирает дубликаты', () => {
const params = normalizeEditParams({
light: {
...emptyEditParams().light,
curve: { rgb: [{ x: 0.8, y: 0.9 }, { x: 0.2, y: 0.1 }, { x: 0.8, y: 0.7 }] }
}
});
const points = params.light.curve.rgb ?? [];
expect(points.length).toBe(2);
expect(points[0].x).toBeCloseTo(0.2);
expect(points[1].x).toBeCloseTo(0.8);
});
});
+185
View File
@@ -0,0 +1,185 @@
import type { Photo } from './types';
/**
* Неразрушающее редактирование фотографии.
*
* Параметры правок хранятся в метаданных фото, а отрендеренная версия
* приходит с сервера отдельным объектом. Расчёт кадров и рендер выполняет
* сервер (backend/internal/photos/edit.go).
*/
export interface CurvePoint {
x: number;
y: number;
}
export interface CurveParams {
rgb?: CurvePoint[];
red?: CurvePoint[];
green?: CurvePoint[];
blue?: CurvePoint[];
}
export interface LightParams {
exposure: number;
contrast: number;
highlights: number;
shadows: number;
whites: number;
blacks: number;
curve: CurveParams;
}
export interface EditParams {
light: LightParams;
}
export const CURVE_CHANNELS = ['rgb', 'red', 'green', 'blue'] as const;
export type CurveChannel = (typeof CURVE_CHANNELS)[number];
export const CHANNEL_LABELS: Record<CurveChannel, string> = {
rgb: 'RGB',
red: 'R',
green: 'G',
blue: 'B'
};
export const CHANNEL_COLORS: Record<CurveChannel, string> = {
rgb: 'var(--text)',
red: '#ef4444',
green: '#22c55e',
blue: '#3b82f6'
};
export interface LightSlider {
key: Exclude<keyof LightParams, 'curve'>;
label: string;
min: number;
max: number;
step: number;
}
export const LIGHT_SLIDERS: LightSlider[] = [
{ key: 'exposure', label: 'Экспозиция', min: -5, max: 5, step: 0.1 },
{ key: 'contrast', label: 'Контраст', min: -100, max: 100, step: 1 },
{ key: 'highlights', label: 'Света', min: -100, max: 100, step: 1 },
{ key: 'shadows', label: 'Тени', min: -100, max: 100, step: 1 },
{ key: 'whites', label: 'Белые', min: -100, max: 100, step: 1 },
{ key: 'blacks', label: 'Чёрные', min: -100, max: 100, step: 1 }
];
export const MAX_EXPOSURE_STOPS = 5;
export const MAX_PERCENT = 100;
export const MAX_CURVE_POINTS = 16;
export function emptyLight(): LightParams {
return {
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
curve: {}
};
}
export function emptyEditParams(): EditParams {
return { light: emptyLight() };
}
function clamp(v: number, min: number, max: number): number {
if (!Number.isFinite(v)) return 0;
return Math.min(max, Math.max(min, v));
}
function clamp01(v: number): number {
return clamp(v, 0, 1);
}
/** Нормализует кривую: точки зажимаются в 0..1, сортируются по X, дубликаты убираются. */
export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined {
if (!points?.length) return undefined;
const sorted = points
.map((p) => ({ x: clamp(p.x, 0, 1), y: clamp(p.y, 0, 1) }))
.sort((a, b) => a.x - b.x);
const out: CurvePoint[] = [];
for (const p of sorted) {
if (out.length && Math.abs(p.x - out[out.length - 1].x) < 1e-6) continue;
out.push(p);
}
return out.length ? out.slice(0, MAX_CURVE_POINTS) : undefined;
}
/** Приводит параметры к допустимым диапазонам. */
export function normalizeEditParams(params: EditParams): EditParams {
const light = params?.light ?? emptyLight();
return {
light: {
exposure: clamp(light.exposure, -MAX_EXPOSURE_STOPS, MAX_EXPOSURE_STOPS),
contrast: clamp(light.contrast, -MAX_PERCENT, MAX_PERCENT),
highlights: clamp(light.highlights, -MAX_PERCENT, MAX_PERCENT),
shadows: clamp(light.shadows, -MAX_PERCENT, MAX_PERCENT),
whites: clamp(light.whites, -MAX_PERCENT, MAX_PERCENT),
blacks: clamp(light.blacks, -MAX_PERCENT, MAX_PERCENT),
curve: {
rgb: normalizeCurve(light.curve?.rgb),
red: normalizeCurve(light.curve?.red),
green: normalizeCurve(light.curve?.green),
blue: normalizeCurve(light.curve?.blue)
}
}
};
}
/** Пустые ли параметры: фотография не тронута. */
export function isEmptyEditParams(params: EditParams): boolean {
const light = params.light;
if (
light.exposure !== 0 ||
light.contrast !== 0 ||
light.highlights !== 0 ||
light.shadows !== 0 ||
light.whites !== 0 ||
light.blacks !== 0
) {
return false;
}
for (const channel of CURVE_CHANNELS) {
for (const point of light.curve?.[channel] ?? []) {
if (Math.abs(point.x - point.y) > 1e-6) return false;
}
}
return true;
}
/** Читает параметры правок из метаданных фото. */
export function readEditParams(photo: Photo | null | undefined): EditParams {
const raw = photo?.edit_params as { light?: Partial<LightParams> } | undefined;
if (!raw?.light) return emptyEditParams();
return normalizeEditParams({
light: {
...emptyLight(),
...raw.light,
curve: raw.light.curve ?? {}
}
});
}
/** URL отредактированной версии фото, если она есть. */
export function photoThumbUrl(photo: Photo): string | undefined {
return photo.edit_thumb_url ?? photo.thumb_url;
}
export function photoMediumUrl(photo: Photo): string | undefined {
return photo.edit_medium_url ?? photo.medium_url;
}
export function photoPreviewUrl(photo: Photo): string | undefined {
return photo.edit_preview_url ?? photo.preview_url;
}
/** Полноразмерная версия: отредактированная, если она отрендерена. */
export function photoFullUrl(photo: Photo): string | undefined {
return photo.edit_url ?? photo.original_url;
}
+5
View File
@@ -59,6 +59,7 @@ export interface Photo {
longitude?: number | null;
caption: string;
exif?: Record<string, unknown>;
edit_params?: Record<string, unknown>;
tags?: Tag[];
deleted_at?: string | null;
album_title?: string;
@@ -73,6 +74,10 @@ export interface Photo {
playback_url?: string;
original_url?: string;
download_url?: string;
edit_url?: string;
edit_thumb_url?: string;
edit_medium_url?: string;
edit_preview_url?: string;
}
export interface Comment {