import { nanoid } from 'nanoid'; import type { MediaItem, MediaKind } from '../types'; import { classifyMediaSource } from './classify'; export type MediaInput = Partial & { 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; 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; 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 { 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 { 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; /** 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 { 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 = {}; 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; }); }