feat: add gear and weapon renders
Validate and deploy production / validate (push) Successful in 21s
Validate and deploy staging / validate (push) Successful in 25s
Validate and deploy production / deploy (push) Successful in 1m17s
Validate and deploy staging / deploy (push) Successful in 35s

This commit is contained in:
jb
2026-07-31 22:39:00 -04:00
parent 3acc08c81d
commit aaf9e0ce97
10 changed files with 171 additions and 25 deletions
@@ -0,0 +1,16 @@
<script lang="ts">
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
let {
currentUrl = '',
label = 'ITEM RENDER',
resetToken = 0
}: { currentUrl?: string | null; label?: string; resetToken?: string | number } = $props();
</script>
<fieldset class="game-data-render-upload">
<legend>{label}</legend>
<ImageUploadInput name="render" accept="image/png,image/webp,image/avif,image/jpeg" {currentUrl} {resetToken} emptyLabel="NO RENDER ASSIGNED" browseLabel={currentUrl ? 'REPLACE RENDER' : 'ADD RENDER'}/>
{#if currentUrl}<label class="render-remove"><input name="removeRender" type="checkbox" value="1"/><span>REMOVE CURRENT RENDER</span></label>{/if}
<small>OPTIONAL · PNG, WEBP, AVIF, OR JPEG · CENTER-CROPPED TO 1000 × 1000 WEBP · NO BACKGROUND REMOVAL</small>
</fieldset>
+16 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import sharp from 'sharp';
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
@@ -8,7 +9,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({
}));
import { GAME_ICON_TYPES } from './game-data-icons';
import { ManagedImageDeletionError, deleteManagedImage } from './managed-images';
import { ManagedImageDeletionError, deleteManagedImage, processManagedImage } from './managed-images';
type Row = Record<string, unknown>;
@@ -137,3 +138,17 @@ describe('deleteManagedImage', () => {
expect(rmMock).not.toHaveBeenCalled();
});
});
describe('processManagedImage', () => {
it('center-crops game-data renders to 1000 pixel WebP squares without removing the background', async () => {
const input = await sharp({ create: { width: 1296, height: 1000, channels: 3, background: '#263238' } })
.png()
.toBuffer();
const result = await processManagedImage(new File([input], 'mask.png', { type: 'image/png' }), false, false, true);
const metadata = await sharp(result.output).metadata();
expect(metadata.format).toBe('webp');
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(1000);
});
});
+70 -7
View File
@@ -1,4 +1,5 @@
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
@@ -10,6 +11,8 @@ export const MANAGED_IMAGE_CATEGORIES = [
'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'Specializations', 'System'
] as const;
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
export const GAME_DATA_RENDER_TYPES = ['weapon', 'gear'] as const;
export type GameDataRenderType = (typeof GAME_DATA_RENDER_TYPES)[number];
const MAX_PIXELS = 24_000_000;
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
@@ -43,7 +46,7 @@ function validateSvg(input: Buffer) {
}
}
export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean) {
export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean, square = false) {
if (!file.size || file.size > MAX_MANAGED_IMAGE_BYTES) {
throw new ManagedImageValidationError('Choose an image no larger than 5 MiB.');
}
@@ -67,10 +70,12 @@ export async function processManagedImage(file: File, icon: boolean, forceWhite:
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
});
pipeline = square
? pipeline.resize(1000, 1000, { fit: 'cover', position: 'centre' })
: 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 } })
@@ -104,6 +109,63 @@ export async function saveManagedImageFile(id: string, output: Buffer) {
return key;
}
export async function readGameDataRenderMutation(form: FormData): Promise<{
file: File | null;
remove: boolean;
processed?: { output: Buffer; width: number; height: number };
}> {
const file = form.get('render');
return {
file: file instanceof File && file.size > 0 ? file : null,
remove: form.get('removeRender') === '1'
};
}
export async function applyGameDataRender(
database: Database,
type: GameDataRenderType,
entityId: string,
entityName: string,
mutation: { file: File | null; remove: boolean; processed?: { output: Buffer; width: number; height: number } },
actorId: string
) {
const sourceType = `${type}_render`;
const existing = await (await database.prepare(
'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?'
)).get(sourceType, entityId);
if (!mutation.file && !mutation.remove) return existing ?? null;
if (mutation.remove) {
if (!existing) return null;
await (await database.prepare('DELETE FROM managed_images WHERE id=?')).run(existing.id);
if (typeof existing.storage_key === 'string') await rm(storagePath(existing.storage_key), { force: true });
return null;
}
const processed = mutation.processed ?? await processManagedImage(mutation.file!, false, false, true);
const now = new Date().toISOString();
const category = type === 'weapon' ? 'Weapons' : 'Gear';
const tags = JSON.stringify(['isac', 'game data', type, 'render']);
if (existing) {
await writeFile(storagePath(String(existing.storage_key)), processed.output, { mode: 0o600 });
await (await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
original_filename=?,byte_size=?,width=?,height=?,created_by=?,updated_at=? WHERE id=?`)).run(
category, tags, mutation.file!.name, processed.output.length, processed.width, processed.height,
actorId, now, existing.id
);
return { ...existing, category, tags_json: tags, byte_size: processed.output.length, width: processed.width, height: processed.height, updated_at: now };
}
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`;
await (await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
id, name, category, tags, 0, 0, storageKey, mutation.file!.name, processed.output.length,
processed.width, processed.height, sourceType, entityId, actorId, now, now
);
return { id, name, category, tags_json: tags, is_icon: 0, storage_key: storageKey, byte_size: processed.output.length, width: processed.width, height: processed.height, source_entity_type: sourceType, source_entity_id: entityId, updated_at: now };
}
export async function deleteManagedImage(database: Database, id: string) {
const image = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
if (!image) return null;
@@ -114,11 +176,12 @@ export async function deleteManagedImage(database: Database, id: string) {
if (Boolean(sourceType) !== Boolean(sourceId)) {
throw new ManagedImageDeletionError('This image has an incomplete source assignment and cannot be deleted safely.');
}
if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType)) {
if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
&& !GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`)) {
throw new ManagedImageDeletionError('This image has an unsupported source assignment and cannot be deleted safely.');
}
const sourceIcon = sourceType && sourceId
const sourceIcon = sourceType && sourceId && GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
? await (await database.prepare('SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(sourceType, sourceId) ?? null
: null;
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {