feat: add managed ISAC image library
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user