Files
shpm/worker/ingest.js
T
nmemmertandClaude Sonnet 4.6 3c0cb2b68d fix: GPS extraction broken by exifr pick filter — use exifr.gps() separately
The pick option creates an allowlist for the merged EXIF output, which
drops latitude/longitude even when gps:true is set. Fixed by calling
exifr.gps() as a separate step (bypasses the pick filter entirely).

Also adds a startup backfill that re-extracts GPS for already-indexed
photos where gps_lat IS NULL, including reverse geocoding for place names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-18 11:48:11 -04:00

245 lines
8.4 KiB
JavaScript

import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { execFileSync } from 'child_process';
import sharp from 'sharp';
import exifr from 'exifr';
import { computeDHash } from './hash.js';
import { reverseGeocode } from './geocode.js';
const PHOTO_EXTS = new Set([
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
'.tiff', '.tif', '.gif', '.avif',
]);
const VIDEO_EXTS = new Set([
'.mp4', '.mov', '.m4v', '.avi', '.mkv', '.webm', '.3gp',
]);
export function isSupported(filepath) {
const ext = path.extname(filepath).toLowerCase();
return PHOTO_EXTS.has(ext) || VIDEO_EXTS.has(ext);
}
export async function ingestFile(filepath, db, posterDir, settings = {}) {
if (VIDEO_EXTS.has(path.extname(filepath).toLowerCase())) {
return ingestVideo(filepath, db, posterDir, settings);
}
return ingestPhoto(filepath, db, posterDir, settings);
}
// ── Photo ingest ───────────────────────────────────────────────────────────────
async function ingestPhoto(filepath, db, posterDir, settings) {
if (db.hasPhoto(filepath)) return;
const thumbSize = settings.thumb_size ?? 400;
const thumbQuality = settings.thumb_quality ?? 80;
const previewSize = settings.preview_size ?? 1600;
const previewQuality = settings.preview_quality ?? 85;
const stat = fs.statSync(filepath);
let exif = {};
try {
exif = await exifr.parse(filepath, {
pick: ['DateTimeOriginal', 'Make', 'Model', 'Orientation'],
}) ?? {};
} catch {}
const dateTaken = exif.DateTimeOriginal ?? null;
const cameraModel = [exif.Make, exif.Model].filter(Boolean).join(' ') || null;
let gpsLat = null, gpsLon = null;
try {
const gps = await exifr.gps(filepath);
if (gps?.latitude != null) { gpsLat = gps.latitude; gpsLon = gps.longitude; }
} catch {}
let placeCity = null, placeCountry = null;
if (gpsLat !== null && gpsLon !== null) {
const place = await reverseGeocode(gpsLat, gpsLon);
placeCity = place?.city ?? null;
placeCountry = place?.country ?? null;
}
const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime);
fs.mkdirSync(dir, { recursive: true });
let width = null, height = null;
try {
const meta = await sharp(filepath, { failOn: 'none' }).metadata();
width = meta.width;
height = meta.height;
await sharp(filepath, { failOn: 'none' })
.rotate()
.resize(thumbSize, thumbSize, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: thumbQuality })
.toFile(thumb);
await sharp(filepath, { failOn: 'none' })
.rotate()
.resize(previewSize, previewSize, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: previewQuality })
.toFile(preview);
} catch (err) {
console.error(`[ingest] poster failed ${path.basename(filepath)}: ${err.message}`);
return;
}
let phash = null;
try { phash = await computeDHash(filepath); } catch {}
db.insertPhoto({
filepath,
thumb_path: thumb,
preview_path: preview,
date_taken: dateTaken ? new Date(dateTaken).toISOString() : null,
date_imported: new Date().toISOString(),
camera_model: cameraModel,
gps_lat: gpsLat,
gps_lon: gpsLon,
phash,
width,
height,
filesize: stat.size,
place_city: placeCity,
place_country: placeCountry,
media_type: 'photo',
duration: null,
});
console.log(`[ingest] ${path.basename(filepath)}`);
}
// ── Video ingest ───────────────────────────────────────────────────────────────
async function ingestVideo(filepath, db, posterDir, settings) {
if (db.hasPhoto(filepath)) return;
const thumbSize = settings.thumb_size ?? 400;
const thumbQuality = settings.thumb_quality ?? 80;
const previewSize = settings.preview_size ?? 1600;
const previewQuality = settings.preview_quality ?? 85;
const stat = fs.statSync(filepath);
// 1. ffprobe — dimensions, duration, creation time
let width = null, height = null, duration = null, dateTaken = null;
try {
const out = execFileSync('ffprobe', [
'-v', 'quiet', '-print_format', 'json',
'-show_streams', '-show_format', filepath,
], { timeout: 15000, encoding: 'utf8' });
const probe = JSON.parse(out);
const vs = probe.streams?.find(s => s.codec_type === 'video');
width = vs?.width ?? null;
height = vs?.height ?? null;
duration = parseFloat(probe.format?.duration ?? '0') || null;
const ct = probe.format?.tags?.creation_time ?? probe.format?.tags?.['com.apple.quicktime.creationdate'];
if (ct) dateTaken = new Date(ct).toISOString();
} catch (e) {
console.error(`[ingest] ffprobe failed ${path.basename(filepath)}: ${e.message}`);
return;
}
// 2. exifr — more accurate date + GPS (best-effort, parsed separately to avoid pick filtering GPS)
let gpsLat = null, gpsLon = null;
try {
const exif = await exifr.parse(filepath, { pick: ['DateTimeOriginal'] }) ?? {};
if (exif.DateTimeOriginal) dateTaken = new Date(exif.DateTimeOriginal).toISOString();
} catch {}
try {
const gps = await exifr.gps(filepath);
if (gps?.latitude != null) { gpsLat = gps.latitude; gpsLon = gps.longitude; }
} catch {}
let placeCity = null, placeCountry = null;
if (gpsLat !== null) {
const place = await reverseGeocode(gpsLat, gpsLon);
placeCity = place?.city ?? null;
placeCountry = place?.country ?? null;
}
const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime);
fs.mkdirSync(dir, { recursive: true });
// 3. Extract a frame with ffmpeg at 10% of duration (capped at 3 s)
const seekSec = duration ? Math.min(duration * 0.1, 3) : 0;
const tmpFrame = path.join(dir, `.vtmp_${crypto.randomBytes(4).toString('hex')}.jpg`);
try {
execFileSync('ffmpeg', [
'-ss', String(seekSec.toFixed(3)),
'-i', filepath,
'-vframes', '1', '-q:v', '2', '-y', tmpFrame,
], { timeout: 30000, stdio: 'pipe' });
} catch {
try {
// Fallback: first frame
execFileSync('ffmpeg', [
'-i', filepath, '-vframes', '1', '-q:v', '2', '-y', tmpFrame,
], { timeout: 30000, stdio: 'pipe' });
} catch (e2) {
console.error(`[ingest] video thumb failed ${path.basename(filepath)}: ${e2.message}`);
return;
}
}
// 4. Resize extracted frame with sharp (same pipeline as photos)
try {
await sharp(tmpFrame, { failOn: 'none' })
.resize(thumbSize, thumbSize, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: thumbQuality })
.toFile(thumb);
await sharp(tmpFrame, { failOn: 'none' })
.resize(previewSize, previewSize, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: previewQuality })
.toFile(preview);
} catch (err) {
console.error(`[ingest] video poster failed ${path.basename(filepath)}: ${err.message}`);
try { fs.unlinkSync(tmpFrame); } catch {}
return;
}
try { fs.unlinkSync(tmpFrame); } catch {}
db.insertPhoto({
filepath,
thumb_path: thumb,
preview_path: preview,
date_taken: dateTaken,
date_imported: new Date().toISOString(),
camera_model: null,
gps_lat: gpsLat,
gps_lon: gpsLon,
phash: null,
width,
height,
filesize: stat.size,
place_city: placeCity,
place_country: placeCountry,
media_type: 'video',
duration,
});
console.log(`[ingest] video ${path.basename(filepath)}`);
}
// ── Shared helpers ─────────────────────────────────────────────────────────────
function posterPaths(filepath, posterDir, date) {
const id = crypto.createHash('sha1').update(filepath).digest('hex').slice(0, 10);
const stem = path.basename(filepath, path.extname(filepath));
const name = `${id}_${stem}`;
const d = new Date(date);
const folder = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}`;
const dir = path.join(posterDir, folder);
return {
dir,
thumb: path.join(dir, `${name}_thumb.jpg`),
preview: path.join(dir, `${name}_preview.jpg`),
};
}