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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user