Files
lynkeduppro-crm/vendor/photo-gallery-sdk/src/lib/media.ts
T

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;
});
}