Initial commit: Astro 6 static blog site
All checks were successful
Deploy / deploy (push) Successful in 49s
All checks were successful
Deploy / deploy (push) Successful in 49s
- German (default) and English i18n support - Categories and tags - Blog posts with hero images - Dark/light theme switcher - View Transitions removed to fix reload ghost images - Webmentions integration - RSS feeds per locale Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
5bb63bacf5
95 changed files with 12199 additions and 0 deletions
820
scripts/vision.ts
Normal file
820
scripts/vision.ts
Normal file
|
|
@ -0,0 +1,820 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { consola } from "consola";
|
||||
import OpenAI from "openai";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
getImagesMissingMetadata,
|
||||
getImagesWithExistingMetadata,
|
||||
getMetadataPathForImage,
|
||||
getPhotoAbsolutePath,
|
||||
getPhotoDirectories,
|
||||
PHOTOS_DIRECTORY,
|
||||
} from "../src/lib/photo-albums.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Define the directory where the images are located.
|
||||
*/
|
||||
const PHOTOS_DIR = PHOTOS_DIRECTORY;
|
||||
|
||||
/**
|
||||
* Instantiate the Anthropic client.
|
||||
*/
|
||||
type VisionProvider = "anthropic" | "openai";
|
||||
|
||||
let anthropic: Anthropic | undefined;
|
||||
let openai: OpenAI | undefined;
|
||||
|
||||
function getAnthropicClient(): Anthropic {
|
||||
anthropic ??= new Anthropic({ maxRetries: 0 });
|
||||
return anthropic;
|
||||
}
|
||||
|
||||
function getOpenAIClient(): OpenAI {
|
||||
openai ??= new OpenAI({ maxRetries: 0 });
|
||||
return openai;
|
||||
}
|
||||
|
||||
function assertRequiredEnvironment(provider: VisionProvider): void {
|
||||
if (provider === "anthropic" && !process.env.ANTHROPIC_API_KEY) {
|
||||
throw new Error(
|
||||
"Missing ANTHROPIC_API_KEY. `pnpm run vision` loads `.env.local` automatically. If you run the script directly, use `node --env-file=.env.local --experimental-strip-types scripts/vision.ts`.",
|
||||
);
|
||||
}
|
||||
if (provider === "openai" && !process.env.OPENAI_API_KEY) {
|
||||
throw new Error("Missing OPENAI_API_KEY. Set it in `.env.local`.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the metadata of an image in the Exif format.
|
||||
*/
|
||||
export interface ExifMetadata {
|
||||
SourceFile: string;
|
||||
FileName: string;
|
||||
Model: string;
|
||||
FNumber: number;
|
||||
FocalLength: string;
|
||||
ExposureTime: string;
|
||||
ISO: number;
|
||||
DateTimeOriginal: string;
|
||||
LensModel: string;
|
||||
GPSPosition?: string;
|
||||
GPSLatitude?: string;
|
||||
GPSLongitude?: string;
|
||||
Keywords?: string | string[];
|
||||
Subject?: string | string[];
|
||||
Title?: string;
|
||||
"Caption-Abstract"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the result of the AI analysis.
|
||||
*/
|
||||
export interface VisionAIResult {
|
||||
title_ideas: string[];
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the final metadata suggestion for an image.
|
||||
*/
|
||||
export interface ImageMetadataSuggestion {
|
||||
id: string;
|
||||
title: string[];
|
||||
image: string;
|
||||
alt: string;
|
||||
location: string;
|
||||
locationName?: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
exif: {
|
||||
camera: string;
|
||||
lens: string;
|
||||
aperture: string;
|
||||
iso: string;
|
||||
focal_length: string;
|
||||
shutter_speed: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface VisionCliOptions {
|
||||
refresh: boolean;
|
||||
exifOnly: boolean;
|
||||
photosDirectory?: string;
|
||||
visionConcurrency: number;
|
||||
visionMaxRetries: number;
|
||||
visionBaseBackoffMs: number;
|
||||
provider: VisionProvider;
|
||||
}
|
||||
|
||||
function parseCliOptions(argv: string[]): VisionCliOptions {
|
||||
const getNumericOption = (name: string, fallback: number): number => {
|
||||
const prefix = `--${name}=`;
|
||||
const rawValue = argv
|
||||
.find((arg) => arg.startsWith(prefix))
|
||||
?.slice(prefix.length);
|
||||
const parsed = Number.parseInt(rawValue ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
const envConcurrency = Number.parseInt(
|
||||
process.env.VISION_CONCURRENCY ?? "",
|
||||
10,
|
||||
);
|
||||
const envMaxRetries = Number.parseInt(
|
||||
process.env.VISION_MAX_RETRIES ?? "",
|
||||
10,
|
||||
);
|
||||
const envBaseBackoffMs = Number.parseInt(
|
||||
process.env.VISION_BASE_BACKOFF_MS ?? "",
|
||||
10,
|
||||
);
|
||||
const nonFlagArgs = argv.filter((arg) => !arg.startsWith("--"));
|
||||
|
||||
const providerArg = argv
|
||||
.find((arg) => arg.startsWith("--provider="))
|
||||
?.slice("--provider=".length);
|
||||
const envProvider = process.env.VISION_PROVIDER;
|
||||
const rawProvider = providerArg ?? envProvider ?? "anthropic";
|
||||
const provider: VisionProvider =
|
||||
rawProvider === "openai" ? "openai" : "anthropic";
|
||||
|
||||
return {
|
||||
refresh: argv.includes("--refresh"),
|
||||
exifOnly: argv.includes("--exif-only"),
|
||||
provider,
|
||||
photosDirectory: resolve(nonFlagArgs[0] ?? PHOTOS_DIR),
|
||||
visionConcurrency: getNumericOption(
|
||||
"concurrency",
|
||||
Number.isFinite(envConcurrency) && envConcurrency > 0
|
||||
? envConcurrency
|
||||
: 2,
|
||||
),
|
||||
visionMaxRetries: getNumericOption(
|
||||
"retries",
|
||||
Number.isFinite(envMaxRetries) && envMaxRetries > 0 ? envMaxRetries : 8,
|
||||
),
|
||||
visionBaseBackoffMs: getNumericOption(
|
||||
"backoff-ms",
|
||||
Number.isFinite(envBaseBackoffMs) && envBaseBackoffMs > 0
|
||||
? envBaseBackoffMs
|
||||
: 1500,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRateLimitError(error: unknown): boolean {
|
||||
return error instanceof Anthropic.RateLimitError;
|
||||
}
|
||||
|
||||
function extractRetryAfterMs(error: unknown): number | null {
|
||||
if (!(error instanceof Anthropic.RateLimitError)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const retryAfter = error.headers?.get("retry-after");
|
||||
if (retryAfter) {
|
||||
const seconds = Number.parseFloat(retryAfter);
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
values: T[],
|
||||
concurrency: number,
|
||||
mapper: (value: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: R[] = new Array(values.length);
|
||||
const workerCount = Math.max(1, Math.min(concurrency, values.length));
|
||||
let cursor = 0;
|
||||
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const currentIndex = cursor;
|
||||
cursor += 1;
|
||||
|
||||
if (currentIndex >= values.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = values[currentIndex];
|
||||
if (typeof value === "undefined") {
|
||||
continue;
|
||||
}
|
||||
|
||||
results[currentIndex] = await mapper(value, currentIndex);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all images that don't have a JSON file and therefore need to be processed.
|
||||
*/
|
||||
export async function getImagesToProcess(
|
||||
photosDirectory = PHOTOS_DIR,
|
||||
options: Pick<VisionCliOptions, "refresh" | "exifOnly"> = {
|
||||
refresh: false,
|
||||
exifOnly: false,
|
||||
},
|
||||
): Promise<string[]> {
|
||||
let relativeImagePaths: string[];
|
||||
let label: string;
|
||||
|
||||
if (options.exifOnly) {
|
||||
relativeImagePaths = await getImagesWithExistingMetadata(photosDirectory);
|
||||
label = `Found ${relativeImagePaths.length} ${relativeImagePaths.length === 1 ? "image" : "images"} with existing metadata (EXIF-only update)`;
|
||||
} else if (options.refresh) {
|
||||
relativeImagePaths = (await getPhotoDirectories(photosDirectory)).flatMap(
|
||||
(directory) => directory.imagePaths,
|
||||
);
|
||||
label = `Refreshing ${relativeImagePaths.length} ${relativeImagePaths.length === 1 ? "image" : "images"} with metadata sidecars`;
|
||||
} else {
|
||||
relativeImagePaths = await getImagesMissingMetadata(photosDirectory);
|
||||
label = `Found ${relativeImagePaths.length} ${relativeImagePaths.length === 1 ? "image" : "images"} without metadata`;
|
||||
}
|
||||
|
||||
consola.info(label);
|
||||
|
||||
return relativeImagePaths.map((imagePath) =>
|
||||
getPhotoAbsolutePath(imagePath, photosDirectory),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an existing JSON sidecar for an image, preserving all fields.
|
||||
*/
|
||||
async function readExistingJsonSidecar(
|
||||
imagePath: string,
|
||||
photosDirectory: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const relativeImagePath = relative(photosDirectory, imagePath);
|
||||
const jsonPath = getMetadataPathForImage(relativeImagePath, photosDirectory);
|
||||
const content = await readFile(jsonPath, "utf-8");
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates only the EXIF-derived fields in an existing metadata object,
|
||||
* preserving all other fields (title, alt, tags, flickrId, etc.).
|
||||
*/
|
||||
export function mergeExifIntoExisting(
|
||||
exifData: ExifMetadata,
|
||||
existing: Record<string, unknown>,
|
||||
locationName?: string | null,
|
||||
): Record<string, unknown> {
|
||||
const [date] = exifData.DateTimeOriginal.split(" ");
|
||||
|
||||
if (!date) {
|
||||
throw new Error(`Missing original date for ${exifData.SourceFile}.`);
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
...existing,
|
||||
location: getLocationFromExif(exifData),
|
||||
date: date.replaceAll(":", "-"),
|
||||
exif: {
|
||||
camera: exifData.Model,
|
||||
lens: exifData.LensModel,
|
||||
aperture: exifData.FNumber.toString(),
|
||||
iso: exifData.ISO.toString(),
|
||||
focal_length: exifData.FocalLength.replace(" mm", ""),
|
||||
shutter_speed: exifData.ExposureTime,
|
||||
},
|
||||
};
|
||||
|
||||
if (locationName) {
|
||||
result.locationName = locationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the EXIF metadata from an image file.
|
||||
* @param imagePath - The path to the image file.
|
||||
*
|
||||
* @returns A promise that resolves to the extracted EXIF metadata.
|
||||
*/
|
||||
export async function extractExifMetadata(
|
||||
imagePath: string,
|
||||
): Promise<ExifMetadata> {
|
||||
/// Check if `exiftool` is installed.
|
||||
try {
|
||||
await execFileAsync("exiftool", ["--version"]);
|
||||
} catch (_error) {
|
||||
consola.error(
|
||||
"exiftool is not installed. Please run `brew install exiftool`.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/// Extract the metadata
|
||||
const { stdout } = await execFileAsync("exiftool", ["-j", imagePath]);
|
||||
const output = JSON.parse(stdout) as ExifMetadata[];
|
||||
|
||||
if (!output[0]) {
|
||||
throw new Error(`No EXIF metadata found for ${imagePath}.`);
|
||||
}
|
||||
|
||||
return output[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an image file to base64.
|
||||
* @param imagePath - The path to the image file.
|
||||
* @returns A Promise that resolves to the base64 encoded image.
|
||||
*/
|
||||
/**
|
||||
* The Vision API internally downscales to max 1568px on the longest side.
|
||||
* Anything larger wastes tokens without improving results.
|
||||
*/
|
||||
const VISION_MAX_DIMENSION = 1568;
|
||||
|
||||
async function base64EncodeImage(imagePath: string): Promise<string> {
|
||||
const resized = await sharp(imagePath)
|
||||
.resize({
|
||||
width: VISION_MAX_DIMENSION,
|
||||
height: VISION_MAX_DIMENSION,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
return resized.toString("base64");
|
||||
}
|
||||
|
||||
const VISION_TOOL = {
|
||||
name: "vision_response",
|
||||
description: "Return the vision analysis of the image.",
|
||||
input_schema: {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
title_ideas: { type: "array", items: { type: "string" } },
|
||||
description: { type: "string" },
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["title_ideas", "description", "tags"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates image description, title suggestions and tags using AI.
|
||||
*
|
||||
* @param metadata - The metadata of the image.
|
||||
* @returns A Promise that resolves to a VisionAIResult object containing the generated image description, title suggestions, and tags.
|
||||
*/
|
||||
function buildVisionPrompt(
|
||||
metadata: ExifMetadata,
|
||||
locationName: string | null,
|
||||
): string {
|
||||
const locationContext = locationName
|
||||
? ` Das Foto wurde aufgenommen in: ${locationName}. Verwende diesen Ort konkret in der Beschreibung.`
|
||||
: "";
|
||||
|
||||
const rawKeywords = metadata.Keywords ?? metadata.Subject ?? [];
|
||||
const keywords = Array.isArray(rawKeywords) ? rawKeywords : [rawKeywords];
|
||||
const keywordContext =
|
||||
keywords.length > 0
|
||||
? ` Folgende Tags sind vom Fotografen vergeben worden: ${keywords.join(", ")}. Beruecksichtige diese Informationen in der Beschreibung.`
|
||||
: "";
|
||||
|
||||
return `Erstelle eine präzise und detaillierte Beschreibung dieses Bildes, die auch als Alt-Text funktioniert. Der Alt-Text soll keine Wörter wie Bild, Foto, Fotografie, Illustration oder Ähnliches enthalten. Beschreibe die Szene so, wie sie ist.${locationContext}${keywordContext} Erstelle außerdem 5 Titelvorschläge für dieses Bild. Schlage zuletzt 5 Tags vor, die zur Bildbeschreibung passen. Diese Tags sollen einzelne Wörter sein. Identifiziere das Hauptmotiv oder Thema und stelle den entsprechenden Tag an die erste Stelle. Gib die Beschreibung, die Titelvorschläge und die Tags zurück.`;
|
||||
}
|
||||
|
||||
async function callAnthropicVision(
|
||||
encodedImage: string,
|
||||
prompt: string,
|
||||
sourceFile: string,
|
||||
): Promise<VisionAIResult> {
|
||||
const response = await getAnthropicClient().messages.create({
|
||||
model: "claude-opus-4-6",
|
||||
max_tokens: 2048,
|
||||
tools: [VISION_TOOL],
|
||||
tool_choice: { type: "tool", name: "vision_response" },
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/jpeg",
|
||||
data: encodedImage,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const toolUseBlock = response.content.find((b) => b.type === "tool_use");
|
||||
if (!toolUseBlock || toolUseBlock.type !== "tool_use") {
|
||||
throw new Error(`No tool use response from AI for ${sourceFile}.`);
|
||||
}
|
||||
|
||||
return toolUseBlock.input as VisionAIResult;
|
||||
}
|
||||
|
||||
async function callOpenAIVision(
|
||||
encodedImage: string,
|
||||
prompt: string,
|
||||
sourceFile: string,
|
||||
): Promise<VisionAIResult> {
|
||||
const jsonPrompt = `${prompt}\n\nWICHTIG: Antworte komplett auf Deutsch. Alle Titel, Beschreibungen und Tags muessen auf Deutsch sein.\n\nAntworte ausschliesslich mit einem JSON-Objekt im folgenden Format:\n{"title_ideas": ["...", "...", "...", "...", "..."], "description": "...", "tags": ["...", "...", "...", "...", "..."]}`;
|
||||
|
||||
const response = await getOpenAIClient().chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
max_tokens: 2048,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: `data:image/jpeg;base64,${encodedImage}` },
|
||||
},
|
||||
{ type: "text", text: jsonPrompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error(`No response from OpenAI for ${sourceFile}.`);
|
||||
}
|
||||
|
||||
return JSON.parse(content) as VisionAIResult;
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown, provider: VisionProvider): boolean {
|
||||
if (provider === "anthropic") {
|
||||
return isRateLimitError(error);
|
||||
}
|
||||
if (error instanceof OpenAI.RateLimitError) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function generateImageDescriptionTitleSuggestionsAndTags(
|
||||
metadata: ExifMetadata,
|
||||
locationName: string | null,
|
||||
options: Pick<
|
||||
VisionCliOptions,
|
||||
"visionMaxRetries" | "visionBaseBackoffMs" | "provider"
|
||||
>,
|
||||
): Promise<VisionAIResult> {
|
||||
const encodedImage = await base64EncodeImage(metadata.SourceFile);
|
||||
const prompt = buildVisionPrompt(metadata, locationName);
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= options.visionMaxRetries; attempt += 1) {
|
||||
try {
|
||||
const result =
|
||||
options.provider === "openai"
|
||||
? await callOpenAIVision(encodedImage, prompt, metadata.SourceFile)
|
||||
: await callAnthropicVision(
|
||||
encodedImage,
|
||||
prompt,
|
||||
metadata.SourceFile,
|
||||
);
|
||||
|
||||
if (
|
||||
result.title_ideas.length === 0 ||
|
||||
result.description.length === 0 ||
|
||||
result.tags.length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Incomplete vision response for ${metadata.SourceFile}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (
|
||||
!isRetryableError(error, options.provider) ||
|
||||
attempt >= options.visionMaxRetries
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
const retryAfterMs = extractRetryAfterMs(error);
|
||||
const exponentialBackoffMs = options.visionBaseBackoffMs * 2 ** attempt;
|
||||
const jitterMs = Math.floor(Math.random() * 350);
|
||||
const waitMs =
|
||||
Math.max(retryAfterMs ?? 0, exponentialBackoffMs) + jitterMs;
|
||||
const relativeSourcePath = relative(process.cwd(), metadata.SourceFile);
|
||||
const nextAttempt = attempt + 1;
|
||||
consola.warn(
|
||||
`Rate limit for ${relativeSourcePath}. Retry ${nextAttempt}/${options.visionMaxRetries} in ${Math.ceil(waitMs / 1000)}s...`,
|
||||
);
|
||||
await sleep(waitMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function ensureVisionCanRun(
|
||||
imagesToProcess: string[],
|
||||
provider: VisionProvider,
|
||||
): void {
|
||||
if (imagesToProcess.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertRequiredEnvironment(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an EXIF DMS string like `7 deg 49' 12.00" N` into a decimal number.
|
||||
*/
|
||||
function parseDms(dms: string): number {
|
||||
const match = dms.match(/(\d+)\s*deg\s*(\d+)'\s*([\d.]+)"\s*([NSEW])/i);
|
||||
if (!match) {
|
||||
return Number.NaN;
|
||||
}
|
||||
const [, deg, min, sec, dir] = match;
|
||||
let decimal = Number(deg) + Number(min) / 60 + Number(sec) / 3600;
|
||||
if (dir === "S" || dir === "W") {
|
||||
decimal *= -1;
|
||||
}
|
||||
return decimal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves GPS coordinates to a human-readable location via Nominatim.
|
||||
*/
|
||||
async function reverseGeocode(
|
||||
lat: number,
|
||||
lon: number,
|
||||
): Promise<string | null> {
|
||||
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&accept-language=de&zoom=14`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": "adrian-altner.de/vision-script" },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as { display_name?: string };
|
||||
return data.display_name ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves EXIF GPS data to a readable location name. Returns null if no GPS data.
|
||||
*/
|
||||
async function resolveLocationName(
|
||||
exifData: ExifMetadata,
|
||||
): Promise<string | null> {
|
||||
const latStr = exifData.GPSLatitude;
|
||||
const lonStr = exifData.GPSLongitude;
|
||||
if (!latStr || !lonStr) return null;
|
||||
|
||||
const lat = parseDms(latStr);
|
||||
const lon = parseDms(lonStr);
|
||||
if (Number.isNaN(lat) || Number.isNaN(lon)) return null;
|
||||
|
||||
return await reverseGeocode(lat, lon);
|
||||
}
|
||||
|
||||
function getLocationFromExif(exifData: ExifMetadata): string {
|
||||
if (exifData.GPSPosition) {
|
||||
return exifData.GPSPosition;
|
||||
}
|
||||
|
||||
if (exifData.GPSLatitude && exifData.GPSLongitude) {
|
||||
return `${exifData.GPSLatitude}, ${exifData.GPSLongitude}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the metadata from EXIF data and vision data to create an ImageMetadataSuggestion object.
|
||||
* @param exifData - The EXIF metadata of the image.
|
||||
* @param visionData - The vision AI result data of the image.
|
||||
* @returns The merged ImageMetadataSuggestion object.
|
||||
*/
|
||||
export function mergeMetaAndVisionData(
|
||||
exifData: ExifMetadata,
|
||||
visionData: VisionAIResult,
|
||||
locationName?: string | null,
|
||||
): ImageMetadataSuggestion {
|
||||
const [date] = exifData.DateTimeOriginal.split(" ");
|
||||
|
||||
if (!date) {
|
||||
throw new Error(`Missing original date for ${exifData.SourceFile}.`);
|
||||
}
|
||||
|
||||
const result: ImageMetadataSuggestion = {
|
||||
id: exifData.FileName.replace(".jpg", ""),
|
||||
title: visionData.title_ideas,
|
||||
image: `./${exifData.FileName}`,
|
||||
alt: visionData.description,
|
||||
location: getLocationFromExif(exifData),
|
||||
date: date.replaceAll(":", "-"),
|
||||
tags: visionData.tags,
|
||||
exif: {
|
||||
camera: exifData.Model,
|
||||
lens: exifData.LensModel,
|
||||
aperture: exifData.FNumber.toString(),
|
||||
iso: exifData.ISO.toString(),
|
||||
focal_length: exifData.FocalLength.replace(" mm", ""),
|
||||
shutter_speed: exifData.ExposureTime,
|
||||
},
|
||||
};
|
||||
|
||||
if (locationName) {
|
||||
result.locationName = locationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the given image metadata to a JSON file.
|
||||
* @param imageMetadata - The image metadata to be written.
|
||||
* @returns A Promise that resolves when the JSON file is written successfully.
|
||||
*/
|
||||
async function writeToJsonFile(
|
||||
imageMetadata: ImageMetadataSuggestion,
|
||||
imagePath: string,
|
||||
photosDirectory: string,
|
||||
): Promise<void> {
|
||||
const relativeImagePath = relative(photosDirectory, imagePath);
|
||||
const jsonPath = getMetadataPathForImage(relativeImagePath, photosDirectory);
|
||||
const json = JSON.stringify(imageMetadata, null, 2);
|
||||
await writeFile(jsonPath, json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main.
|
||||
*/
|
||||
async function main() {
|
||||
consola.start("Checking for images to process...");
|
||||
const cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
const photosDirectory = cliOptions.photosDirectory ?? PHOTOS_DIR;
|
||||
|
||||
/// Load all images that don't have a JSON file.
|
||||
const images = await getImagesToProcess(photosDirectory, cliOptions);
|
||||
|
||||
if (images.length === 0) {
|
||||
consola.success(
|
||||
cliOptions.exifOnly
|
||||
? "No images with existing metadata found."
|
||||
: cliOptions.refresh
|
||||
? "No images found to refresh."
|
||||
: "No images require metadata.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Extract EXIF metadata from these images.
|
||||
const exifData = await mapWithConcurrency(
|
||||
images,
|
||||
8,
|
||||
async (imagePath, index) => {
|
||||
consola.info(`Extracting EXIF ${index + 1}/${images.length}...`);
|
||||
return await extractExifMetadata(imagePath);
|
||||
},
|
||||
);
|
||||
|
||||
/// Resolve location names via Nominatim (sequential, 1 req/s limit).
|
||||
consola.info("Resolving location names via Nominatim...");
|
||||
const locationNames: (string | null)[] = [];
|
||||
for (const exifEntry of exifData) {
|
||||
const name = await resolveLocationName(exifEntry);
|
||||
locationNames.push(name);
|
||||
if (name) {
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
const resolvedCount = locationNames.filter(Boolean).length;
|
||||
consola.info(`Resolved ${resolvedCount}/${exifData.length} location names.`);
|
||||
|
||||
if (cliOptions.exifOnly) {
|
||||
/// EXIF-only mode: read existing JSON, merge EXIF fields, write back.
|
||||
const existingData = await mapWithConcurrency(
|
||||
images,
|
||||
8,
|
||||
async (imagePath) => readExistingJsonSidecar(imagePath, photosDirectory),
|
||||
);
|
||||
|
||||
await mapWithConcurrency(exifData, 8, async (exifEntry, index) => {
|
||||
const existing = existingData[index];
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(
|
||||
`Missing existing metadata for ${exifEntry.SourceFile}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = mergeExifIntoExisting(
|
||||
exifEntry,
|
||||
existing,
|
||||
locationNames[index],
|
||||
);
|
||||
const relativeImagePath = relative(photosDirectory, exifEntry.SourceFile);
|
||||
const jsonPath = getMetadataPathForImage(
|
||||
relativeImagePath,
|
||||
photosDirectory,
|
||||
);
|
||||
await writeFile(jsonPath, JSON.stringify(updated, null, 2));
|
||||
consola.info(
|
||||
`Updated EXIF ${index + 1}/${exifData.length}: ${relativeImagePath}`,
|
||||
);
|
||||
});
|
||||
|
||||
consola.success("All EXIF data updated successfully.");
|
||||
return;
|
||||
}
|
||||
|
||||
consola.info(
|
||||
`Vision settings: provider=${cliOptions.provider}, concurrency=${cliOptions.visionConcurrency}, retries=${cliOptions.visionMaxRetries}, backoff=${cliOptions.visionBaseBackoffMs}ms`,
|
||||
);
|
||||
|
||||
ensureVisionCanRun(images, cliOptions.provider);
|
||||
|
||||
/// Determine the image description, title suggestions and tags for each image with AI.
|
||||
const visionData = await mapWithConcurrency(
|
||||
exifData,
|
||||
cliOptions.visionConcurrency,
|
||||
async (exifEntry, index) => {
|
||||
consola.info(`Generating AI metadata ${index + 1}/${exifData.length}...`);
|
||||
return await generateImageDescriptionTitleSuggestionsAndTags(
|
||||
exifEntry,
|
||||
locationNames[index] ?? null,
|
||||
cliOptions,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// Merge the EXIF and Vision data to create the final metadata suggestion.
|
||||
const imageData = exifData.map((e, i) => {
|
||||
const currentVisionData = visionData[i];
|
||||
|
||||
if (!currentVisionData) {
|
||||
throw new Error(`Missing vision data for ${e.SourceFile}.`);
|
||||
}
|
||||
|
||||
return mergeMetaAndVisionData(e, currentVisionData, locationNames[i]);
|
||||
});
|
||||
|
||||
/// Write the metadata to JSON files.
|
||||
await mapWithConcurrency(imageData, 8, async (imageMetadata, index) => {
|
||||
const sourceFile = exifData[index]?.SourceFile;
|
||||
|
||||
if (!sourceFile) {
|
||||
throw new Error(`Missing source file for ${imageMetadata.id}.`);
|
||||
}
|
||||
|
||||
await writeToJsonFile(imageMetadata, sourceFile, photosDirectory);
|
||||
consola.info(`Wrote metadata ${index + 1}/${imageData.length}.`);
|
||||
});
|
||||
|
||||
consola.success("All images processed successfully.");
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
consola.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue