feat: add managed ISAC image library

This commit is contained in:
JB
2026-07-26 13:46:44 -04:00
parent e8ed35e35f
commit 63b04d26a9
25 changed files with 705 additions and 57 deletions
+2 -2
View File
@@ -9,7 +9,7 @@
<fieldset class="icon-upload-field">
<legend>{label}</legend>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
<small>OPTIONAL · MAY BE CONTRIBUTED LATER · TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
<small>OPTIONAL · SVG, PNG, WEBP, AVIF, OR JPEG · OPAQUE BACKGROUNDS REMOVED WHEN NEEDED · 2 MiB MAX</small>
</fieldset>
+21 -8
View File
@@ -2,6 +2,7 @@ import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol', 'skill_chip'] as const;
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
@@ -42,7 +43,7 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
if (!file.size || file.size > MAX_GAME_ICON_BYTES) {
throw new GameDataIconValidationError('Choose a transparent icon no larger than 2 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
let input = Buffer.from(await file.arrayBuffer());
try {
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
if (looksLikeSvg) {
@@ -55,17 +56,28 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
throw new GameDataIconValidationError('SVG icons cannot contain scripts, embedded images, or external resources.');
}
}
const image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
const metadata = await image.metadata();
if (!metadata.format || !['png', 'webp', 'avif', 'svg'].includes(metadata.format) || !metadata.hasAlpha) {
throw new GameDataIconValidationError('Use an SVG, PNG, WebP, or AVIF icon with a transparent background.');
let image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
let metadata = await image.metadata();
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
throw new GameDataIconValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF icon.');
}
const stats = await image.stats();
if (stats.isOpaque) {
throw new GameDataIconValidationError('The icon must contain transparent background pixels around the artwork.');
let stats = await image.stats();
if (!metadata.hasAlpha || stats.isOpaque) {
input = await removeBackgroundPreview(input);
image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
metadata = await image.metadata();
stats = await image.stats();
}
if (!metadata.hasAlpha || stats.isOpaque) {
throw new GameDataIconValidationError('The processed icon must contain transparent background pixels.');
}
return await image
.rotate()
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 })
.extend({
top: 2, right: 2, bottom: 2, left: 2,
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.resize(GAME_ICON_SIZE, GAME_ICON_SIZE, {
fit: 'contain',
position: 'centre',
@@ -75,6 +87,7 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
.toBuffer();
} catch (error) {
if (error instanceof GameDataIconValidationError) throw error;
if (error instanceof BackgroundRemovalError) throw new GameDataIconValidationError(error.message);
throw new GameDataIconValidationError('That icon could not be processed. Use a transparent SVG, PNG, WebP, or AVIF file.');
}
}
+115
View File
@@ -0,0 +1,115 @@
import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
export const MANAGED_IMAGE_CATEGORIES = [
'General', 'Interface', 'Gear', 'Weapons', 'Weapon Mods', 'Gear Mods',
'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'System'
] as const;
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN = /^library-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}
export function managedImageStorageDirectory() {
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
}
const storagePath = (key: string) => {
if (!KEY_PATTERN.test(key)) throw new Error('Invalid managed image storage key');
return path.join(managedImageStorageDirectory(), key);
};
export function managedImageUrl(id: string, updatedAt?: unknown) {
if (!ID_PATTERN.test(id)) return '';
const version = typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : '';
return `/images/library/${id}${version}`;
}
function validateSvg(input: Buffer) {
const source = input.toString('utf8');
if (/<(?:script|foreignObject|image|iframe|object|embed)\b/i.test(source)
|| /<!ENTITY\b/i.test(source)
|| /\b(?:href|xlink:href)\s*=\s*["'](?!#)/i.test(source)
|| /@import\b/i.test(source)
|| /url\(\s*["']?(?!#)/i.test(source)) {
throw new ManagedImageValidationError('SVG files cannot contain scripts, embedded images, or external resources.');
}
}
export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean) {
if (!file.size || file.size > MAX_MANAGED_IMAGE_BYTES) {
throw new ManagedImageValidationError('Choose an image no larger than 5 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
try {
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
if (looksLikeSvg) validateSvg(input);
let processedInput = input;
let source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
let metadata = await source.metadata();
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
throw new ManagedImageValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF image.');
}
const sourceStats = await source.stats();
if (icon && (!metadata.hasAlpha || sourceStats.isOpaque)) {
processedInput = await removeBackgroundPreview(processedInput);
source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
metadata = await source.metadata();
}
let pipeline = source.rotate();
if (metadata.hasAlpha) {
pipeline = pipeline.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 });
}
pipeline = pipeline.resize(icon ? 256 : 1600, icon ? 256 : 1600, {
fit: 'inside',
withoutEnlargement: !icon
});
if (icon && forceWhite) {
const normalized = await pipeline
.resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
for (let offset = 0; offset < normalized.data.length; offset += normalized.info.channels) {
normalized.data[offset] = 255;
normalized.data[offset + 1] = 255;
normalized.data[offset + 2] = 255;
}
pipeline = sharp(normalized.data, {
raw: { width: normalized.info.width, height: normalized.info.height, channels: normalized.info.channels }
});
}
const output = await pipeline.webp({ quality: icon ? 90 : 84, alphaQuality: 100, effort: 5 }).toBuffer();
const result = await sharp(output).metadata();
return { output, width: result.width ?? 0, height: result.height ?? 0 };
} catch (error) {
if (error instanceof ManagedImageValidationError) throw error;
if (error instanceof BackgroundRemovalError) throw new ManagedImageValidationError(error.message);
throw new ManagedImageValidationError('That image could not be processed.');
}
}
export async function saveManagedImageFile(id: string, output: Buffer) {
if (!ID_PATTERN.test(id)) throw new Error('Invalid managed image identifier');
const key = `library-${id}.webp`;
await mkdir(managedImageStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'wx', mode: 0o600 });
return key;
}
export async function readManagedImage(database: Database, id: string) {
if (!ID_PATTERN.test(id)) return null;
const row = await (await database.prepare('SELECT storage_key FROM managed_images WHERE id=?')).get(id);
if (typeof row?.storage_key !== 'string') return null;
try {
return await readFile(storagePath(row.storage_key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
}
+48
View File
@@ -0,0 +1,48 @@
import sharp from 'sharp';
const REMOVE_BG_ENDPOINT = 'https://api.remove.bg/v1.0/removebg';
const REMOVE_BG_PREVIEW_EDGE = 1500;
export class BackgroundRemovalError extends Error {}
function apiMessage(status: number) {
if (status === 400) return 'remove.bg could not identify a removable foreground in that image.';
if (status === 402) return 'remove.bg preview credits are currently unavailable.';
if (status === 403) return 'The remove.bg API key was rejected. Update the server configuration.';
if (status === 429) return 'remove.bg is temporarily rate limited. Wait briefly and try again.';
return 'Background removal is temporarily unavailable. Try again or upload an image with transparency.';
}
export async function removeBackgroundPreview(input: Buffer): Promise<Buffer<ArrayBuffer>> {
const apiKey = process.env.REMOVE_BG_API_KEY?.trim();
if (!apiKey) {
throw new BackgroundRemovalError(
'This icon needs background removal, but remove.bg is not configured. Add REMOVE_BG_API_KEY or upload a transparent image.'
);
}
const prepared = await sharp(input, { failOn: 'warning' })
.rotate()
.resize(REMOVE_BG_PREVIEW_EDGE, REMOVE_BG_PREVIEW_EDGE, { fit: 'inside', withoutEnlargement: true })
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer();
const form = new FormData();
form.set('size', 'preview');
form.set('format', 'png');
form.set('image_file', new File([new Uint8Array(prepared)], 'icon-source.png', { type: 'image/png' }));
let response: Response;
try {
response = await fetch(REMOVE_BG_ENDPOINT, {
method: 'POST',
headers: { 'X-Api-Key': apiKey },
body: form,
signal: AbortSignal.timeout(45_000)
});
} catch {
throw new BackgroundRemovalError('remove.bg could not be reached. Try again or upload a transparent image.');
}
if (!response.ok) throw new BackgroundRemovalError(apiMessage(response.status));
const output = Buffer.from(await response.arrayBuffer());
const metadata = await sharp(output, { failOn: 'warning' }).metadata();
if (!metadata.hasAlpha) throw new BackgroundRemovalError('remove.bg returned an image without transparency.');
return output;
}