286 lines
9.7 KiB
TypeScript
286 lines
9.7 KiB
TypeScript
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;
|
|
});
|
|
}
|