feat(photos): full EXIF metadata extraction, backfill and info panel

- backend: switch EXIF parser to dsoprea/go-exif/v3 (all recognised tags, numeric normalization); store in photos.exif jsonb (migration 000003); add storage.Get and cmd/backfill-exif to backfill existing photos from S3 originals
- frontend: info panel renders all EXIF attributes with RU labels, enum translation and units (1/N, f/N, mm, EV); service tags hidden
- viewer: remove make-cover action (cover managed from panel only), icon-only download
- openspec: sync lightroom-workspace-ui artifacts (EXIF, cover-from-panel) and add plan for configurable info attributes/labels stored in user profile
This commit is contained in:
iu
2026-10-10 20:31:34 +03:00
parent d8da885020
commit e1592549b2
20 changed files with 744 additions and 108 deletions
@@ -26,6 +26,206 @@
const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(!!photo && (canEdit || photo.uploader_id === auth.user?.id));
const exif = $derived((photo?.exif ?? {}) as Record<string, unknown>);
const EXIF_LABELS: Record<string, string> = {
Make: 'Производитель',
Model: 'Модель',
LensMake: 'Производитель объектива',
LensModel: 'Объектив',
ExposureTime: 'Выдержка',
ShutterSpeedValue: 'Выдержка (APEX)',
FNumber: 'Диафрагма',
ApertureValue: 'Диафрагма (APEX)',
MaxApertureValue: 'Макс. диафрагма',
FocalLength: 'Фокусное расстояние',
FocalLengthIn35mmFilm: 'Фокусное (35 мм)',
ISOSpeedRatings: 'ISO',
PhotographicSensitivity: 'ISO',
ExposureProgram: 'Программа съёмки',
ExposureMode: 'Режим экспозиции',
ExposureBiasValue: 'Экспокоррекция',
BrightnessValue: 'Яркость (APEX)',
MeteringMode: 'Режим замера',
LightSource: 'Источник света',
Flash: 'Вспышка',
WhiteBalance: 'Баланс белого',
Orientation: 'Ориентация',
Software: 'ПО',
ColorSpace: 'Цветовое пространство',
SceneCaptureType: 'Тип сцены',
Contrast: 'Контраст',
Saturation: 'Насыщенность',
Sharpness: 'Резкость',
CustomRendered: 'Обработка',
GainControl: 'Усиление',
SensingMethod: 'Метод съёмки',
SubjectDistanceRange: 'Дистанция',
DigitalZoomRatio: 'Цифровой зум',
CompressedBitsPerPixel: 'Сжатие (бит/пиксель)',
ImageDescription: 'Описание'
};
const EXIF_SKIP = new Set([
'GPSLatitude',
'GPSLongitude',
'GPSLatitudeRef',
'GPSLongitudeRef',
'GPSAltitude',
'GPSAltitudeRef',
'GPSVersionID',
'GPSDateStamp',
'GPSTimeStamp',
'GPSProcessingMethod',
'GPSAreaInformation',
'GPSImgDirection',
'GPSImgDirectionRef',
'GPSDestBearing',
'GPSDestBearingRef',
'GPSSpeed',
'GPSSpeedRef',
'GPSTrack',
'GPSTrackRef',
'GPSMapDatum',
'GPSDifferential',
'DateTime',
'DateTimeDigitized',
'DateTimeOriginal',
'SubSecTime',
'SubSecTimeDigitized',
'SubSecTimeOriginal',
'ExifVersion',
'FlashpixVersion',
'InteroperabilityVersion',
'InteroperabilityIndex',
'ExifTag',
'GPSTag',
'InteroperabilityTag',
'ExifIFDPointer',
'GPSInfoIFDPointer',
'ComponentsConfiguration',
'MakerNote',
'UserComment',
'FileSource',
'SceneType',
'PhotometricInterpretation',
'YCbCrPositioning',
'ResolutionUnit',
'BitsPerSample',
'XResolution',
'YResolution',
'ImageWidth',
'ImageLength',
'PixelXDimension',
'PixelYDimension'
]);
const EXIF_ENUMS: Record<string, Record<number, string>> = {
Orientation: {
1: 'Горизонтальная',
2: 'Зеркальная (гориз.)',
3: 'Повёрнута на 180°',
4: 'Зеркальная (верт.)',
5: 'Зеркальная + 90° CW',
6: 'Повёрнута на 90° CW',
7: 'Зеркальная + 90° CCW',
8: 'Повёрнута на 90° CCW'
},
ExposureProgram: {
0: 'Не задано',
1: 'Ручная',
2: 'Программная',
3: 'Приоритет диафрагмы',
4: 'Приоритет выдержки',
5: 'Творческая',
6: 'Спортивная',
7: 'Портрет',
8: 'Пейзаж'
},
ExposureMode: { 0: 'Авто', 1: 'Ручной', 2: 'Авто-брекетинг' },
MeteringMode: {
0: 'Неизвестно',
1: 'Средневзвешенный',
2: 'Центрально-взвешенный',
3: 'Точечный',
4: 'Многозонный',
5: 'Матричный',
6: 'Частичный',
255: 'Другое'
},
LightSource: {
0: 'Неизвестно',
1: 'Дневной свет',
2: 'Люминесцентный',
3: 'Лампа накаливания',
4: 'Вспышка',
9: 'Хорошая погода',
10: 'Облачно',
11: 'Тень'
},
Flash: {
0: 'Не сработала',
1: 'Сработала',
9: 'Сработала (принуд.)',
16: 'Отключена',
24: 'Не сработала (авто)',
25: 'Сработала (авто)'
},
WhiteBalance: { 0: 'Авто', 1: 'Ручной' },
SceneCaptureType: { 0: 'Стандартный', 1: 'Пейзаж', 2: 'Портрет', 3: 'Ночной' },
Contrast: { 0: 'Обычный', 1: 'Мягкий', 2: 'Жёсткий' },
Saturation: { 0: 'Обычная', 1: 'Низкая', 2: 'Высокая' },
Sharpness: { 0: 'Обычная', 1: 'Мягкая', 2: 'Жёсткая' },
CustomRendered: { 0: 'Обычная', 1: 'Особая' },
GainControl: {
0: 'Нет',
1: 'Малое усиление',
2: 'Большое усиление',
3: 'Низкое усиление',
4: 'Высокое усиление'
},
SensingMethod: {
1: 'Не задано',
2: 'Однокристальный',
3: 'Двухчиповый',
4: 'Трёхчиповый',
5: 'Цветной последовательный',
7: 'Трёхлинейный',
8: 'Цветной последовательный'
},
SubjectDistanceRange: { 0: 'Неизвестно', 1: 'Макро', 2: 'Близко', 3: 'Далеко' },
ColorSpace: { 1: 'sRGB', 65535: 'Некалиброванное' }
};
function fmtExif(key: string, value: unknown): string {
if (value == null) return '';
const n = Number(value);
if (key === 'ExposureTime' && !Number.isNaN(n) && n > 0) {
return n >= 1 ? `${n} с` : `1/${Math.round(1 / n)}`;
}
if (
(key === 'FNumber' || key === 'ApertureValue' || key === 'MaxApertureValue') &&
!Number.isNaN(n)
) {
return `f/${n}`;
}
if ((key === 'FocalLength' || key === 'FocalLengthIn35mmFilm') && !Number.isNaN(n)) {
return `${n} мм`;
}
if (key === 'ExposureBiasValue' && !Number.isNaN(n)) {
return `${n > 0 ? '+' : ''}${n} EV`;
}
const enums = EXIF_ENUMS[key];
if (enums && !Number.isNaN(n) && enums[n] != null) return enums[n];
return String(value);
}
const exifRows = $derived(
Object.entries(exif)
.filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '')
.map(([k, v]) => ({ key: k, label: EXIF_LABELS[k] ?? k, value: fmtExif(k, v) }))
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
);
$effect(() => {
const id = albumId;
error = '';
@@ -170,6 +370,12 @@
<span>Разрешение</span>
<span class="text-text">{photo.width}×{photo.height}</span>
</div>
{#each exifRows as row (row.key)}
<div class={metaRow}>
<span>{row.label}</span>
<span class="text-text">{row.value}</span>
</div>
{/each}
{#if photo.taken_at}
<div class={metaRow}>
<span>Снято</span>
+9 -25
View File
@@ -7,24 +7,13 @@
interface Props {
photos: Photo[];
index: number;
canEdit?: boolean;
canDelete?: boolean;
onClose: () => void;
onIndexChange: (index: number) => void;
onMakeCover?: (photo: Photo) => void;
onDelete?: (photo: Photo) => void;
}
let {
photos,
index,
canEdit = false,
canDelete = false,
onClose,
onIndexChange,
onMakeCover,
onDelete
}: Props = $props();
let { photos, index, canDelete = false, onClose, onIndexChange, onDelete }: Props = $props();
let fresh = $state<Photo | null>(null);
let touchX = 0;
@@ -398,21 +387,16 @@
>
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} />
</button>
{#if canEdit}
<button
type="button"
class={btn}
title="Сделать обложкой"
onclick={() => current && onMakeCover?.(current)}
>
<Icon name="wallpaper" class="size-4" />
<span class="hidden sm:inline">Обложка</span>
</button>
{/if}
{#if fresh?.download_url}
<a href={fresh.download_url} class={btn} target="_blank" rel="noreferrer">
<a
href={fresh.download_url}
class={btn}
target="_blank"
rel="noreferrer"
aria-label="Скачать"
title="Скачать"
>
<Icon name="download" class="size-4" />
<span class="hidden sm:inline">Скачать</span>
</a>
{/if}
{#if canDelete}