- backend: users.preferences jsonb (migration 000004), Me returns preferences, PATCH /auth/me/preferences with validation (visible list + labels) - frontend: gear in info section opens PhotoInfoSettingsDialog; base attribute set shown by default, remaining under ellipsis; per-attribute visibility and custom labels persisted to the profile; reset clears settings from the profile - frontend: dialog has select-all/deselect-all icon buttons (no 'show all'); EXIF constants extracted to lib/exif.ts - fix: render modal overlays via portal to document.body so they center in the viewport despite transformed panel/rail ancestors - openspec: sync lightroom-workspace-ui artifacts (spec/design/proposal/tasks 9.8)
225 lines
6.4 KiB
TypeScript
225 lines
6.4 KiB
TypeScript
export 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: 'Описание'
|
||
};
|
||
|
||
export 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'
|
||
]);
|
||
|
||
export 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: 'Некалиброванное' }
|
||
};
|
||
|
||
export function formatExifValue(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);
|
||
}
|
||
|
||
export interface ExifRow {
|
||
key: string;
|
||
label: string;
|
||
value: string;
|
||
}
|
||
|
||
export const EXIF_BASE: string[] = [
|
||
'Make',
|
||
'Model',
|
||
'LensModel',
|
||
'ExposureTime',
|
||
'FNumber',
|
||
'ISOSpeedRatings',
|
||
'FocalLength',
|
||
'FocalLengthIn35mmFilm'
|
||
];
|
||
|
||
export function buildExifRows(
|
||
exif: Record<string, unknown>,
|
||
labels: Record<string, string> = {}
|
||
): ExifRow[] {
|
||
return Object.entries(exif)
|
||
.filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '')
|
||
.map(([k, v]) => ({
|
||
key: k,
|
||
label: labels[k] ?? EXIF_LABELS[k] ?? k,
|
||
value: formatExifValue(k, v)
|
||
}))
|
||
.sort((a, b) => a.label.localeCompare(b.label, 'ru'));
|
||
}
|
||
|
||
export const EXIF_CATALOG: string[] = Object.keys(EXIF_LABELS);
|