Add HEIF decode fallback for uploads
This commit is contained in:
parent
d90f8bd645
commit
d51eef4d42
@ -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
38
services/api/src/heif.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
40
services/worker/src/heif.ts
Normal file
40
services/worker/src/heif.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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 () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user