Add HEIF decode fallback for uploads

This commit is contained in:
vance 2026-04-09 23:30:06 -07:00
parent d90f8bd645
commit d51eef4d42
5 changed files with 177 additions and 80 deletions

View File

@ -23,6 +23,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
libheif1 \ libheif1 \
libheif-plugin-libde265 \ libheif-plugin-libde265 \
libheif-examples \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
FROM workspace-source AS admin-build FROM workspace-source AS admin-build

38
services/api/src/heif.ts Normal file
View File

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

View File

@ -3,6 +3,7 @@ import path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import type { ContributorConsent, PhotoAsset, Submission } from "@goodgrief/shared-types"; import type { ContributorConsent, PhotoAsset, Submission } from "@goodgrief/shared-types";
import { config } from "./config.ts"; import { config } from "./config.ts";
import { decodeHeifToJpeg, isHeifSource } from "./heif.ts";
interface ImportedAssetRecord { interface ImportedAssetRecord {
asset: PhotoAsset; asset: PhotoAsset;
@ -142,57 +143,65 @@ export const createLibraryAssets = async () => {
const originalRelativePath = path.join("runtime", "library", `${baseId}-original.jpg`); const originalRelativePath = path.join("runtime", "library", `${baseId}-original.jpg`);
const originalAbsolutePath = path.join(config.storageDir, originalRelativePath); const originalAbsolutePath = path.join(config.storageDir, originalRelativePath);
const sourceImage = sharp(file.absolutePath).rotate(); const decoded = isHeifSource(file.absolutePath)
const metadata = await sourceImage.metadata(); ? await decodeHeifToJpeg(file.absolutePath, `goodgrief-library-${baseId}`)
const width = metadata.width ?? 1600; : null;
const height = metadata.height ?? 900;
await sourceImage.clone().jpeg({ quality: 92 }).toFile(originalAbsolutePath); try {
const dominantColor = await getDominantColor(sourceImage); const sourceImage = sharp(decoded?.outputPath ?? file.absolutePath).rotate();
const thumbRelativePath = path.join("runtime", "library", `${baseId}-thumb.jpg`); const metadata = await sourceImage.metadata();
const previewRelativePath = path.join("runtime", "library", `${baseId}-preview.jpg`); const width = metadata.width ?? 1600;
const renderRelativePath = path.join("runtime", "library", `${baseId}-render.jpg`); const height = metadata.height ?? 900;
await sourceImage await sourceImage.clone().jpeg({ quality: 92 }).toFile(originalAbsolutePath);
.clone() const dominantColor = await getDominantColor(sourceImage);
.resize({ width: 320, height: 320, fit: "cover", position: "attention" }) const thumbRelativePath = path.join("runtime", "library", `${baseId}-thumb.jpg`);
.jpeg({ quality: 84 }) const previewRelativePath = path.join("runtime", "library", `${baseId}-preview.jpg`);
.toFile(path.join(config.storageDir, thumbRelativePath)); const renderRelativePath = path.join("runtime", "library", `${baseId}-render.jpg`);
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( await sourceImage
toImportedRecord({ .clone()
id: baseId, .resize({ width: 320, height: 320, fit: "cover", position: "attention" })
title: displayTitle, .jpeg({ quality: 84 })
originalKey: `/uploads/${originalRelativePath}`, .toFile(path.join(config.storageDir, thumbRelativePath));
thumbKey: `/uploads/${thumbRelativePath}`, await sourceImage
previewKey: `/uploads/${previewRelativePath}`, .clone()
renderKey: `/uploads/${renderRelativePath}`, .resize({
mimeType: "image/jpeg", width: 1280,
width, height: 1280,
height, fit: "inside",
dominantColor 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) { } catch (error) {
console.warn(`[library-import] Skipping ${file.relativeName}:`, error); console.warn(`[library-import] Skipping ${file.relativeName}:`, error);
} }

View File

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

View File

@ -4,6 +4,7 @@ import path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import type { PhotoAsset, RepositoryState } from "@goodgrief/shared-types"; import type { PhotoAsset, RepositoryState } from "@goodgrief/shared-types";
import { config } from "./config.ts"; import { config } from "./config.ts";
import { decodeHeifToJpeg, isHeifSource } from "./heif.ts";
const toStoragePath = (publicUrl: string) => path.join(config.storageDir, publicUrl.replace(/^\/uploads\//, "")); 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", "previews"), { recursive: true });
await mkdir(path.join(config.storageDir, "runtime", "renders"), { recursive: true }); await mkdir(path.join(config.storageDir, "runtime", "renders"), { recursive: true });
const image = sharp(inputBuffer, { failOn: "none" }).rotate(); const decoded = isHeifSource(sourcePath, asset.mimeType)
const metadata = await image.metadata(); ? await decodeHeifToJpeg(sourcePath, `goodgrief-worker-${asset.id}`)
const stats = await image.stats(); : null;
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
await image.clone().resize({ width: 320, height: 320, fit: "inside" }).jpeg({ quality: 78 }).toFile( try {
createDerivativePath(asset.id, "thumbs") const image = sharp(decoded?.outputPath ?? sourcePath, { failOn: "none" }).rotate();
); const metadata = await image.metadata();
await image.clone().resize({ width: 960, height: 960, fit: "inside" }).jpeg({ quality: 84 }).toFile( const stats = await image.stats();
createDerivativePath(asset.id, "previews") const width = metadata.width ?? 0;
); const height = metadata.height ?? 0;
await image.clone().resize({ width: 1920, height: 1920, fit: "inside" }).jpeg({ quality: 88 }).toFile(
createDerivativePath(asset.id, "renders")
);
const dominant = stats.dominant; await image.clone().resize({ width: 320, height: 320, fit: "inside" }).jpeg({ quality: 78 }).toFile(
const dominantColor = `#${[dominant.r, dominant.g, dominant.b] createDerivativePath(asset.id, "thumbs")
.map((value) => value.toString(16).padStart(2, "0")) );
.join("")}`; 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 { const dominant = stats.dominant;
thumbKey: publicKeyFor(asset.id, "thumbs"), const dominantColor = `#${[dominant.r, dominant.g, dominant.b]
previewKey: publicKeyFor(asset.id, "previews"), .map((value) => value.toString(16).padStart(2, "0"))
renderKey: publicKeyFor(asset.id, "renders"), .join("")}`;
width,
height, return {
orientation: computeOrientation(width, height), thumbKey: publicKeyFor(asset.id, "thumbs"),
sha256, previewKey: publicKeyFor(asset.id, "previews"),
dominantColor, renderKey: publicKeyFor(asset.id, "renders"),
qualityFlags: { width,
tooSmall: width < 800 || height < 800, height,
lowContrast: stats.channels[0]?.stdev ? stats.channels[0].stdev < 12 : false 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 () => { export const runWorkerOnce = async () => {