diff --git a/Dockerfile b/Dockerfile index 68a1e91..6563e76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ libheif1 \ libheif-plugin-libde265 \ + libheif-examples \ && rm -rf /var/lib/apt/lists/* FROM workspace-source AS admin-build diff --git a/services/api/src/heif.ts b/services/api/src/heif.ts new file mode 100644 index 0000000..f6f047e --- /dev/null +++ b/services/api/src/heif.ts @@ -0,0 +1,38 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const heifExtensions = new Set([".heic", ".heif"]); +const decoderCommands = ["heif-dec", "heif-convert"]; + +export const isHeifSource = (sourcePath: string) => heifExtensions.has(path.extname(sourcePath).toLowerCase()); + +export const decodeHeifToJpeg = async (sourcePath: string, prefix: string) => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), `${prefix}-`)); + const outputPath = path.join(tempDir, "decoded.jpg"); + + try { + let lastError: unknown; + for (const command of decoderCommands) { + try { + await execFileAsync(command, ["-q", "92", sourcePath, outputPath]); + return { + outputPath, + cleanup: async () => rm(tempDir, { recursive: true, force: true }) + }; + } catch (error) { + lastError = error; + } + } + + throw lastError instanceof Error ? lastError : new Error("HEIF decoder command failed."); + } catch (error) { + await rm(tempDir, { recursive: true, force: true }); + const message = error instanceof Error ? error.message : "Unknown HEIF decode error."; + throw new Error(`HEIF decoding failed. ${message}`); + } +}; diff --git a/services/api/src/seed.ts b/services/api/src/seed.ts index 6046fc7..24ac938 100644 --- a/services/api/src/seed.ts +++ b/services/api/src/seed.ts @@ -3,6 +3,7 @@ import path from "node:path"; import sharp from "sharp"; import type { ContributorConsent, PhotoAsset, Submission } from "@goodgrief/shared-types"; import { config } from "./config.ts"; +import { decodeHeifToJpeg, isHeifSource } from "./heif.ts"; interface ImportedAssetRecord { asset: PhotoAsset; @@ -142,57 +143,65 @@ export const createLibraryAssets = async () => { const originalRelativePath = path.join("runtime", "library", `${baseId}-original.jpg`); const originalAbsolutePath = path.join(config.storageDir, originalRelativePath); - const sourceImage = sharp(file.absolutePath).rotate(); - const metadata = await sourceImage.metadata(); - const width = metadata.width ?? 1600; - const height = metadata.height ?? 900; + const decoded = isHeifSource(file.absolutePath) + ? await decodeHeifToJpeg(file.absolutePath, `goodgrief-library-${baseId}`) + : null; - await sourceImage.clone().jpeg({ quality: 92 }).toFile(originalAbsolutePath); - const dominantColor = await getDominantColor(sourceImage); - const thumbRelativePath = path.join("runtime", "library", `${baseId}-thumb.jpg`); - const previewRelativePath = path.join("runtime", "library", `${baseId}-preview.jpg`); - const renderRelativePath = path.join("runtime", "library", `${baseId}-render.jpg`); + try { + const sourceImage = sharp(decoded?.outputPath ?? file.absolutePath).rotate(); + const metadata = await sourceImage.metadata(); + const width = metadata.width ?? 1600; + const height = metadata.height ?? 900; - await sourceImage - .clone() - .resize({ width: 320, height: 320, fit: "cover", position: "attention" }) - .jpeg({ quality: 84 }) - .toFile(path.join(config.storageDir, thumbRelativePath)); - await sourceImage - .clone() - .resize({ - width: 1280, - height: 1280, - fit: "inside", - withoutEnlargement: true - }) - .jpeg({ quality: 86 }) - .toFile(path.join(config.storageDir, previewRelativePath)); - await sourceImage - .clone() - .resize({ - width: 1920, - height: 1920, - fit: "inside", - withoutEnlargement: true - }) - .jpeg({ quality: 88 }) - .toFile(path.join(config.storageDir, renderRelativePath)); + await sourceImage.clone().jpeg({ quality: 92 }).toFile(originalAbsolutePath); + const dominantColor = await getDominantColor(sourceImage); + const thumbRelativePath = path.join("runtime", "library", `${baseId}-thumb.jpg`); + const previewRelativePath = path.join("runtime", "library", `${baseId}-preview.jpg`); + const renderRelativePath = path.join("runtime", "library", `${baseId}-render.jpg`); - imported.push( - toImportedRecord({ - id: baseId, - title: displayTitle, - originalKey: `/uploads/${originalRelativePath}`, - thumbKey: `/uploads/${thumbRelativePath}`, - previewKey: `/uploads/${previewRelativePath}`, - renderKey: `/uploads/${renderRelativePath}`, - mimeType: "image/jpeg", - width, - height, - dominantColor - }) - ); + await sourceImage + .clone() + .resize({ width: 320, height: 320, fit: "cover", position: "attention" }) + .jpeg({ quality: 84 }) + .toFile(path.join(config.storageDir, thumbRelativePath)); + await sourceImage + .clone() + .resize({ + width: 1280, + height: 1280, + fit: "inside", + withoutEnlargement: true + }) + .jpeg({ quality: 86 }) + .toFile(path.join(config.storageDir, previewRelativePath)); + await sourceImage + .clone() + .resize({ + width: 1920, + height: 1920, + fit: "inside", + withoutEnlargement: true + }) + .jpeg({ quality: 88 }) + .toFile(path.join(config.storageDir, renderRelativePath)); + + imported.push( + toImportedRecord({ + id: baseId, + title: displayTitle, + originalKey: `/uploads/${originalRelativePath}`, + thumbKey: `/uploads/${thumbRelativePath}`, + previewKey: `/uploads/${previewRelativePath}`, + renderKey: `/uploads/${renderRelativePath}`, + mimeType: "image/jpeg", + width, + height, + dominantColor + }) + ); + } finally { + await decoded?.cleanup(); + } } catch (error) { console.warn(`[library-import] Skipping ${file.relativeName}:`, error); } diff --git a/services/worker/src/heif.ts b/services/worker/src/heif.ts new file mode 100644 index 0000000..d25eccc --- /dev/null +++ b/services/worker/src/heif.ts @@ -0,0 +1,40 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const heifExtensions = new Set([".heic", ".heif"]); +const heifMimeTypes = new Set(["image/heic", "image/heif", "image/heic-sequence", "image/heif-sequence"]); +const decoderCommands = ["heif-dec", "heif-convert"]; + +export const isHeifSource = (sourcePath: string, mimeType?: string) => + heifExtensions.has(path.extname(sourcePath).toLowerCase()) || (mimeType ? heifMimeTypes.has(mimeType) : false); + +export const decodeHeifToJpeg = async (sourcePath: string, prefix: string) => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), `${prefix}-`)); + const outputPath = path.join(tempDir, "decoded.jpg"); + + try { + let lastError: unknown; + for (const command of decoderCommands) { + try { + await execFileAsync(command, ["-q", "92", sourcePath, outputPath]); + return { + outputPath, + cleanup: async () => rm(tempDir, { recursive: true, force: true }) + }; + } catch (error) { + lastError = error; + } + } + + throw lastError instanceof Error ? lastError : new Error("HEIF decoder command failed."); + } catch (error) { + await rm(tempDir, { recursive: true, force: true }); + const message = error instanceof Error ? error.message : "Unknown HEIF decode error."; + throw new Error(`HEIF decoding failed. ${message}`); + } +}; diff --git a/services/worker/src/processor.ts b/services/worker/src/processor.ts index ddd75b6..642768b 100644 --- a/services/worker/src/processor.ts +++ b/services/worker/src/processor.ts @@ -4,6 +4,7 @@ import path from "node:path"; import sharp from "sharp"; import type { PhotoAsset, RepositoryState } from "@goodgrief/shared-types"; import { config } from "./config.ts"; +import { decodeHeifToJpeg, isHeifSource } from "./heif.ts"; const toStoragePath = (publicUrl: string) => path.join(config.storageDir, publicUrl.replace(/^\/uploads\//, "")); @@ -57,41 +58,49 @@ export const processAsset = async (asset: PhotoAsset) => { await mkdir(path.join(config.storageDir, "runtime", "previews"), { recursive: true }); await mkdir(path.join(config.storageDir, "runtime", "renders"), { recursive: true }); - const image = sharp(inputBuffer, { failOn: "none" }).rotate(); - const metadata = await image.metadata(); - const stats = await image.stats(); - const width = metadata.width ?? 0; - const height = metadata.height ?? 0; + const decoded = isHeifSource(sourcePath, asset.mimeType) + ? await decodeHeifToJpeg(sourcePath, `goodgrief-worker-${asset.id}`) + : null; - await image.clone().resize({ width: 320, height: 320, fit: "inside" }).jpeg({ quality: 78 }).toFile( - createDerivativePath(asset.id, "thumbs") - ); - await image.clone().resize({ width: 960, height: 960, fit: "inside" }).jpeg({ quality: 84 }).toFile( - createDerivativePath(asset.id, "previews") - ); - await image.clone().resize({ width: 1920, height: 1920, fit: "inside" }).jpeg({ quality: 88 }).toFile( - createDerivativePath(asset.id, "renders") - ); + try { + const image = sharp(decoded?.outputPath ?? sourcePath, { failOn: "none" }).rotate(); + const metadata = await image.metadata(); + const stats = await image.stats(); + const width = metadata.width ?? 0; + const height = metadata.height ?? 0; - const dominant = stats.dominant; - const dominantColor = `#${[dominant.r, dominant.g, dominant.b] - .map((value) => value.toString(16).padStart(2, "0")) - .join("")}`; + await image.clone().resize({ width: 320, height: 320, fit: "inside" }).jpeg({ quality: 78 }).toFile( + createDerivativePath(asset.id, "thumbs") + ); + await image.clone().resize({ width: 960, height: 960, fit: "inside" }).jpeg({ quality: 84 }).toFile( + createDerivativePath(asset.id, "previews") + ); + await image.clone().resize({ width: 1920, height: 1920, fit: "inside" }).jpeg({ quality: 88 }).toFile( + createDerivativePath(asset.id, "renders") + ); - return { - thumbKey: publicKeyFor(asset.id, "thumbs"), - previewKey: publicKeyFor(asset.id, "previews"), - renderKey: publicKeyFor(asset.id, "renders"), - width, - height, - orientation: computeOrientation(width, height), - sha256, - dominantColor, - qualityFlags: { - tooSmall: width < 800 || height < 800, - lowContrast: stats.channels[0]?.stdev ? stats.channels[0].stdev < 12 : false - } - }; + const dominant = stats.dominant; + const dominantColor = `#${[dominant.r, dominant.g, dominant.b] + .map((value) => value.toString(16).padStart(2, "0")) + .join("")}`; + + return { + thumbKey: publicKeyFor(asset.id, "thumbs"), + previewKey: publicKeyFor(asset.id, "previews"), + renderKey: publicKeyFor(asset.id, "renders"), + width, + height, + orientation: computeOrientation(width, height), + sha256, + dominantColor, + qualityFlags: { + tooSmall: width < 800 || height < 800, + lowContrast: stats.channels[0]?.stdev ? stats.channels[0].stdev < 12 : false + } + }; + } finally { + await decoded?.cleanup(); + } }; export const runWorkerOnce = async () => {