feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations
This commit is contained in:
+285
@@ -0,0 +1,285 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import type { MediaItem, MediaKind } from '../types';
|
||||
import { classifyMediaSource } from './classify';
|
||||
|
||||
export type MediaInput = Partial<MediaItem> & { src: string };
|
||||
|
||||
/** Normalize loose input into a fully-formed MediaItem with sensible defaults. */
|
||||
export function createMediaItem(input: MediaInput): MediaItem {
|
||||
const now = Date.now();
|
||||
const kind: MediaKind = input.kind ?? (input.mime?.startsWith('video/') ? 'video' : 'image');
|
||||
const name = input.name ?? 'Untitled';
|
||||
const width = input.width ?? 1600;
|
||||
const height = input.height ?? 1067;
|
||||
const mime = input.mime ?? (kind === 'video' ? 'video/mp4' : 'image/jpeg');
|
||||
|
||||
const source =
|
||||
input.source ??
|
||||
classifyMediaSource({
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
mime,
|
||||
hasCameraExif: Boolean(input.exif && Object.keys(input.exif).length > 0),
|
||||
});
|
||||
|
||||
return {
|
||||
id: input.id ?? nanoid(10),
|
||||
kind,
|
||||
src: input.src,
|
||||
thumbnail: input.thumbnail,
|
||||
poster: input.poster,
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
mime,
|
||||
bytes: input.bytes,
|
||||
takenAt: input.takenAt ?? now,
|
||||
importedAt: input.importedAt ?? now,
|
||||
editedAt: input.editedAt,
|
||||
deletedAt: input.deletedAt,
|
||||
duration: input.duration,
|
||||
favorite: input.favorite ?? false,
|
||||
hidden: input.hidden ?? false,
|
||||
source,
|
||||
albumIds: input.albumIds ?? [],
|
||||
tags: input.tags ?? [],
|
||||
objectLabels: input.objectLabels ?? [],
|
||||
personIds: input.personIds ?? [],
|
||||
location: input.location,
|
||||
exif: input.exif,
|
||||
// Who captured/imported this (display identity) + user note — preserved through
|
||||
// the single normalization path so they survive a reload.
|
||||
uploadedBy: input.uploadedBy,
|
||||
note: input.note,
|
||||
caption: input.caption,
|
||||
objects: input.objects,
|
||||
faces: input.faces,
|
||||
// Preserve OCR text through the normalization path — without this it's dropped
|
||||
// on every reload, so the analyzer re-runs OCR on the whole library each time.
|
||||
ocrText: input.ocrText,
|
||||
analyzedAt: input.analyzedAt,
|
||||
colorPalette: input.colorPalette,
|
||||
blurScore: input.blurScore,
|
||||
qualityScore: input.qualityScore,
|
||||
embedding: input.embedding,
|
||||
isLivePhoto: input.isLivePhoto,
|
||||
isRaw: input.isRaw,
|
||||
isPanorama: input.isPanorama,
|
||||
edits: input.edits,
|
||||
// Preserve edit history + comments through the single normalization path so
|
||||
// versions/comments survive a reload (localStorage adapter re-normalizes on load).
|
||||
versions: input.versions,
|
||||
comments: input.comments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a media URL. Allows http(s), data:image/video, blob, and same-origin
|
||||
* relative paths; rejects dangerous schemes (javascript:, vbscript:, file:, …).
|
||||
* Security: untrusted `src`/`thumbnail`/`poster` must never carry an active scheme.
|
||||
*/
|
||||
export function safeMediaUrl(value: unknown): string | undefined {
|
||||
const s = typeof value === 'string' ? value.trim() : '';
|
||||
if (!s) return undefined;
|
||||
if (/^(https?:|blob:)/i.test(s)) return s;
|
||||
if (/^data:(image|video)\//i.test(s)) return s;
|
||||
// No scheme at all → treat as a relative same-origin path.
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) return s;
|
||||
return undefined; // unknown / disallowed scheme
|
||||
}
|
||||
|
||||
const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
|
||||
const asStringArray = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
const asFiniteNumber = (v: unknown): number | undefined =>
|
||||
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
||||
|
||||
/**
|
||||
* Coerce an untrusted object (from props or persisted storage) into a safe
|
||||
* MediaItem. Returns null if it has no usable source. This is the single
|
||||
* normalization path so persisted and prop-supplied data are validated alike.
|
||||
*/
|
||||
export function normalizeMediaItem(raw: unknown): MediaItem | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
const src = safeMediaUrl(r.src);
|
||||
if (!src) return null;
|
||||
|
||||
let location: MediaItem['location'];
|
||||
if (r.location && typeof r.location === 'object') {
|
||||
const loc = r.location as Record<string, unknown>;
|
||||
const lat = asFiniteNumber(loc.lat);
|
||||
const lng = asFiniteNumber(loc.lng);
|
||||
if (lat !== undefined && lng !== undefined) {
|
||||
// Preserve every reverse-geocoded address field so a persisted full address
|
||||
// survives a reload (the InfoPanel then won't re-fetch it).
|
||||
location = {
|
||||
lat,
|
||||
lng,
|
||||
place: asString(loc.place),
|
||||
road: asString(loc.road),
|
||||
neighbourhood: asString(loc.neighbourhood),
|
||||
suburb: asString(loc.suburb),
|
||||
city: asString(loc.city),
|
||||
county: asString(loc.county),
|
||||
state: asString(loc.state),
|
||||
postcode: asString(loc.postcode),
|
||||
country: asString(loc.country),
|
||||
countryCode: asString(loc.countryCode),
|
||||
formatted: asString(loc.formatted),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return createMediaItem({
|
||||
...(r as MediaInput),
|
||||
src,
|
||||
thumbnail: safeMediaUrl(r.thumbnail),
|
||||
poster: safeMediaUrl(r.poster),
|
||||
name: asString(r.name),
|
||||
caption: asString(r.caption),
|
||||
note: asString(r.note),
|
||||
tags: asStringArray(r.tags),
|
||||
objectLabels: asStringArray(r.objectLabels),
|
||||
albumIds: asStringArray(r.albumIds),
|
||||
personIds: asStringArray(r.personIds),
|
||||
location,
|
||||
});
|
||||
}
|
||||
|
||||
/** Accepted upload types — guards against importing arbitrary/executable files. */
|
||||
const ACCEPTED = /^(image\/(jpeg|png|gif|webp|heic|heif|avif|bmp|tiff)|video\/(mp4|quicktime|webm|x-matroska|3gpp))$/;
|
||||
|
||||
export function isAcceptedMediaFile(file: File): boolean {
|
||||
return ACCEPTED.test(file.type) || /\.(jpe?g|png|gif|webp|heic|heif|avif|bmp|tiff?|mp4|mov|webm|mkv|3gp)$/i.test(file.name);
|
||||
}
|
||||
|
||||
/** Read a Blob/File into a base64 data: URL. Durable across reloads (unlike blob: URLs). */
|
||||
export function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a MediaItem from a browser File (reads real dimensions / duration). */
|
||||
export async function mediaFromFile(file: File): Promise<MediaItem | null> {
|
||||
if (!isAcceptedMediaFile(file)) return null;
|
||||
const isVideo = file.type.startsWith('video/') || /\.(mp4|mov|webm|mkv|3gp)$/i.test(file.name);
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
try {
|
||||
if (isVideo) {
|
||||
const meta = await readVideoMeta(url);
|
||||
return createMediaItem({
|
||||
src: url,
|
||||
name: file.name,
|
||||
mime: file.type || 'video/mp4',
|
||||
bytes: file.size,
|
||||
kind: 'video',
|
||||
takenAt: file.lastModified || Date.now(),
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
duration: meta.duration,
|
||||
});
|
||||
}
|
||||
const dims = await readImageMeta(url);
|
||||
const meta = await readExif(file);
|
||||
return createMediaItem({
|
||||
src: url,
|
||||
name: file.name,
|
||||
mime: file.type || 'image/jpeg',
|
||||
bytes: file.size,
|
||||
kind: 'image',
|
||||
takenAt: meta.takenAt ?? file.lastModified ?? Date.now(),
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
isPanorama: dims.width / Math.max(1, dims.height) > 2.2,
|
||||
location: meta.location,
|
||||
exif: meta.exif,
|
||||
tags: meta.tags,
|
||||
});
|
||||
} catch {
|
||||
URL.revokeObjectURL(url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ExifMeta {
|
||||
takenAt?: number;
|
||||
location?: MediaItem['location'];
|
||||
exif?: Record<string, string | number>;
|
||||
/** Existing keywords/tags from IPTC/XMP. */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** Parse EXIF + IPTC/XMP (date, GPS, camera, keywords) from an image File. Lazy-loads `exifr`. */
|
||||
async function readExif(file: File): Promise<ExifMeta> {
|
||||
try {
|
||||
const exifr = (await import('exifr')).default;
|
||||
// Parse GPS + IPTC + XMP so existing keywords/tags and location are imported.
|
||||
const data = await exifr.parse(file, { gps: true, iptc: true, xmp: true });
|
||||
if (!data) return {};
|
||||
const out: ExifMeta = {};
|
||||
const dt = data.DateTimeOriginal ?? data.CreateDate;
|
||||
if (dt) {
|
||||
const t = new Date(dt).getTime();
|
||||
if (Number.isFinite(t)) out.takenAt = t;
|
||||
}
|
||||
if (typeof data.latitude === 'number' && typeof data.longitude === 'number') {
|
||||
out.location = { lat: data.latitude, lng: data.longitude };
|
||||
}
|
||||
const exif: Record<string, string | number> = {};
|
||||
for (const k of [
|
||||
'Make',
|
||||
'Model',
|
||||
'LensModel',
|
||||
'ISO',
|
||||
'FNumber',
|
||||
'FocalLength',
|
||||
'Orientation',
|
||||
] as const) {
|
||||
const v = data[k];
|
||||
if (typeof v === 'string' || typeof v === 'number') exif[k] = v;
|
||||
}
|
||||
if (Object.keys(exif).length) out.exif = exif;
|
||||
|
||||
// Import existing keywords/tags from IPTC (Keywords) and XMP (dc:subject).
|
||||
const raw: unknown[] = [];
|
||||
const push = (v: unknown) => {
|
||||
if (Array.isArray(v)) raw.push(...v);
|
||||
else if (typeof v === 'string') raw.push(...v.split(/[;,]/));
|
||||
};
|
||||
push(data.Keywords);
|
||||
push(data.subject);
|
||||
const tags = [...new Set(raw.map((s) => String(s).trim().toLowerCase()).filter(Boolean))];
|
||||
if (tags.length) out.tags = tags;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readImageMeta(url: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function readVideoMeta(url: string): Promise<{ width: number; height: number; duration: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const v = document.createElement('video');
|
||||
v.preload = 'metadata';
|
||||
v.onloadedmetadata = () =>
|
||||
resolve({ width: v.videoWidth, height: v.videoHeight, duration: v.duration });
|
||||
v.onerror = reject;
|
||||
v.src = url;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user