feat: add gear and weapon renders
This commit is contained in:
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user