From 56875a2d0110a8d44fbab593a6d2442d888db378 Mon Sep 17 00:00:00 2001 From: Werner Bihl Date: Sun, 24 May 2026 16:52:45 +0200 Subject: [PATCH 01/10] feat(ai-assets): implement AI image generation and management features - Added persistence functions for AI-generated images, including saving, retrieving, and listing records. - Introduced new types for AI-generated images and their context in the application. - Implemented provider utilities for handling image data and fetching images from various AI providers. - Enhanced the quota management system to track API usage limits. - Created tests for persistence, provider utilities, and quota handling to ensure reliability. - Updated the image editor to support AI-generated images and integrate with existing tileset functionality. --- TODO.txt | 36 +- src/components/dialogs/SettingsDialog.tsx | 1 + src/config/api-keys.ts | 6 + .../ai-assets/components/Generator.tsx | 884 ++++++++++-------- .../ai-assets/components/ImageCell.tsx | 105 ++- src/features/ai-assets/lib/constants.ts | 15 + src/features/ai-assets/lib/persistence.ts | 52 ++ src/features/ai-assets/lib/provider-utils.ts | 97 ++ src/features/ai-assets/lib/providers.ts | 311 ++++++ src/features/ai-assets/lib/quota.ts | 69 ++ .../lib/standalone-editor-context.ts | 33 + src/features/ai-assets/lib/tileset-actions.ts | 46 + src/features/ai-assets/types/index.ts | 34 + .../components/LospecPaletteDialog.tsx | 521 +++++++---- .../hooks/use-image-editor-request-loader.ts | 77 +- .../image-editor/lib/lospec-palettes.ts | 57 +- src/features/image-editor/types/lospec.ts | 12 + src/services/db.ts | 14 + src/types/integrations/ai-assets.ts | 73 +- .../ai-assets/lib/persistence.test.ts | 103 ++ .../ai-assets/lib/provider-utils.test.ts | 56 ++ .../features/ai-assets/lib/providers.test.ts | 91 ++ tests/features/ai-assets/lib/quota.test.ts | 21 + .../ai-assets/lib/tileset-actions.test.ts | 67 ++ .../image-editor/lib/lospec-palettes.test.ts | 136 +++ 25 files changed, 2347 insertions(+), 570 deletions(-) create mode 100644 src/features/ai-assets/lib/persistence.ts create mode 100644 src/features/ai-assets/lib/provider-utils.ts create mode 100644 src/features/ai-assets/lib/providers.ts create mode 100644 src/features/ai-assets/lib/quota.ts create mode 100644 src/features/ai-assets/lib/standalone-editor-context.ts create mode 100644 src/features/ai-assets/lib/tileset-actions.ts create mode 100644 src/features/ai-assets/types/index.ts create mode 100644 tests/features/ai-assets/lib/persistence.test.ts create mode 100644 tests/features/ai-assets/lib/provider-utils.test.ts create mode 100644 tests/features/ai-assets/lib/providers.test.ts create mode 100644 tests/features/ai-assets/lib/quota.test.ts create mode 100644 tests/features/ai-assets/lib/tileset-actions.test.ts diff --git a/TODO.txt b/TODO.txt index 0c88e65..0781e67 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,22 +1,28 @@ -Lospec dialog - Should not download from API. Fetch directly from server. Cache Responses - Add Pagination - Tags should be clickable - Show all colors when clicking plus button - Colors should have tooltips +I want to make some changes to the Lospec dialog: + 1. Currently when the indexedb TilerDB.lospecPalettes table is empty, and you open the dialog, then it shows: "Lospec palette library is already up to date." It should fetch the first page, display it, and then paginate through all the pages in the background to populate the table + 2. Add Pagination + 3. Tags should be clickable + 4. Show all colors when clicking plus button + 5. Colors should have selectable tooltips +---------------- + +I want to make some changes to AI Assets Generator Tool: + 1. Recheck that all native implementations work + 2. Add Hugging Face as a provider (first option) + 3. Show a history of generated images + 4. Gallery for saved images + 5. Scheduler to automatically generate a prompt every x seconds based on quota limits from the provider for HuggingFace + 6. Show depleting progress bar to show remaining quota + 7. Ability to Download, add to tileset, or open in image Editor + +------------- + + +Map Management -> Edit Maps -> Allow editing map title Select Project Loading screen -Better CI/CD Dev -> Prod workflow World View -AI Asset Generation: - Recheck out all native implementations work - History - Gallery - Scheduler - Add depleting progress bar to show remaining quota - Ability to download/add to tileset/open in image Editor - Ad Marketplace for selling/buying tilesets etc. Exploration: Inkarnate diff --git a/src/components/dialogs/SettingsDialog.tsx b/src/components/dialogs/SettingsDialog.tsx index 37301da..b3223c0 100644 --- a/src/components/dialogs/SettingsDialog.tsx +++ b/src/components/dialogs/SettingsDialog.tsx @@ -177,6 +177,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { diff --git a/src/config/api-keys.ts b/src/config/api-keys.ts index bc21fae..1622a63 100644 --- a/src/config/api-keys.ts +++ b/src/config/api-keys.ts @@ -14,6 +14,12 @@ import type { ApiKeyProvider } from "@/types/integrations/api-keys"; export const API_KEY_PROVIDERS: ApiKeyProvider[] = [ + { + id: "huggingface", + label: "Hugging Face", + url: "https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained", + placeholder: "hf_...", + }, { id: "openai", label: "OpenAI", diff --git a/src/features/ai-assets/components/Generator.tsx b/src/features/ai-assets/components/Generator.tsx index 5572e81..bc17119 100644 --- a/src/features/ai-assets/components/Generator.tsx +++ b/src/features/ai-assets/components/Generator.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from "react"; -import { Loader2, RotateCcw, KeyRound } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Clock, KeyRound, Loader2, Play, RotateCcw, Square } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/Button"; import { Label } from "@/components/ui/Label"; @@ -13,250 +13,125 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/Select"; - -import type { - AssetType, - StyleStack, - TilesetConfig, - SpriteConfig, - BackgroundConfig, - IconConfig, - UIConfig, - VFXConfig, - ImageState, - Ratio, -} from "@/types/integrations/ai-assets"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/Tabs"; +import { loadApiKey, loadAllApiKeys } from "@/config/api-keys"; +import { useEditorStore } from "@/hooks/use-editor-store"; +import { saveBlobFile } from "@/services/file-system"; +import { generateWithProvider } from "@/features/ai-assets/lib/providers"; +import { UNKNOWN_QUOTA } from "@/features/ai-assets/lib/quota"; +import { arrayBufferToDataUrl } from "@/features/ai-assets/lib/provider-utils"; +import { + createAiImageRecord, + deleteAiImageRecord, + listAiImageHistory, + listSavedAiImages, + saveAiImageRecord, + setAiImageSaved, +} from "@/features/ai-assets/lib/persistence"; +import { + appendGeneratedImageTileset, + createGeneratedTilesetId, + saveGeneratedImageAsset, +} from "@/features/ai-assets/lib/tileset-actions"; +import { setStandaloneAiImageEditorContext } from "@/features/ai-assets/lib/standalone-editor-context"; +import { buildPrompt } from "../lib/prompt-builder"; import { - MODELS, - PROVIDER_LABELS, ALL_RATIOS, - COUNT_OPTIONS, - ASSET_TYPE_DEFS, ART_STYLES, + ASSET_TYPE_DEFS, COLOR_PALETTES, + COUNT_OPTIONS, + MODELS, + PROVIDER_LABELS, SPRITE_SIZES, } from "../lib/constants"; -import { buildPrompt } from "../lib/prompt-builder"; +import { parseDataUrl } from "../lib/image-utils"; import { - TilesetConfigForm, - SpriteConfigForm, BackgroundConfigForm, IconConfigForm, + SpriteConfigForm, + TilesetConfigForm, UIConfigForm, VFXConfigForm, } from "./ConfigForms"; -import { ImageUpload } from "./ImageUpload"; -import { parseDataUrl } from "../lib/image-utils"; import { ImageCell } from "./ImageCell"; -import { loadApiKey, loadAllApiKeys } from "@/config/api-keys"; - -async function generateOpenAI( - apiKey: string, - model: string, - prompt: string, - count: number, - size: string, - initImageB64: string | null, - initImageMime: string | null, -): Promise { - if (initImageB64 && initImageMime) { - const results: string[] = []; - await Promise.all( - Array.from({ length: count }, async () => { - const form = new FormData(); - const byteStr = atob(initImageB64); - const bytes = new Uint8Array(byteStr.length); - for (let index = 0; index < byteStr.length; index += 1) { - bytes[index] = byteStr.charCodeAt(index); - } - const blob = new Blob([bytes], { type: initImageMime }); - form.append("image", blob, "reference.png"); - form.append("prompt", prompt); - form.append("model", model); - form.append("n", "1"); - form.append("response_format", "b64_json"); - const response = await fetch("https://api.openai.com/v1/images/edits", { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body: form, - }); - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error( - (error as { error?: { message?: string } }).error?.message ?? - `OpenAI error ${response.status}`, - ); - } - const data = (await response.json()) as { - data: { b64_json: string }[]; - }; - for (const image of data.data) { - results.push(`data:image/png;base64,${image.b64_json}`); - } - }), - ); - return results; - } +import { ImageUpload } from "./ImageUpload"; +import type { + AiGeneratedImageRecord, + AiQuotaState, + AiSchedulerState, + AssetType, + BackgroundConfig, + IconConfig, + ImageState, + Ratio, + SpriteConfig, + StyleStack, + TilesetConfig, + UIConfig, + VFXConfig, +} from "@/types/integrations/ai-assets"; +import type { + AiImageActionHandlers, + AiRecordsGridProps, +} from "@/features/ai-assets/types"; - const body: Record = { - model, - prompt, - n: count, - size, - response_format: "b64_json", - }; - const response = await fetch("https://api.openai.com/v1/images/generations", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error( - (error as { error?: { message?: string } }).error?.message ?? - `OpenAI error ${response.status}`, - ); - } - const data = (await response.json()) as { data: { b64_json: string }[] }; - return data.data.map((image) => `data:image/png;base64,${image.b64_json}`); +function createRecordId(): string { + return crypto.randomUUID(); } -async function generateGemini( - apiKey: string, - model: string, - prompt: string, - aspectRatio: string, - initImageB64: string | null, - initImageMime: string | null, -): Promise { - const parts: unknown[] = []; - if (initImageB64 && initImageMime) { - parts.push({ - inline_data: { mime_type: initImageMime, data: initImageB64 }, - }); - } - parts.push({ text: prompt }); - - const body = { - contents: [{ role: "user", parts }], - generationConfig: { - responseModalities: ["IMAGE"], - imageConfig: { aspectRatio }, - }, - }; - - const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error( - (error as { error?: { message?: string } }).error?.message ?? - `Gemini error ${response.status}`, - ); +function getQuotaPercent(quota: AiQuotaState): number | null { + if ( + quota.limit === null || + quota.remaining === null || + quota.limit <= 0 || + quota.remaining < 0 + ) { + return null; } - - const data = (await response.json()) as { - candidates?: { - content?: { - parts?: { - inlineData?: { data: string; mimeType: string }; - thought?: boolean; - }[]; - }; - }[]; - }; - - const imagePart = data.candidates - ?.flatMap((candidate) => candidate.content?.parts ?? []) - .find((part) => part.inlineData && !part.thought); - - if (!imagePart?.inlineData) { - throw new Error("Gemini returned no image"); - } - const { data: b64, mimeType } = imagePart.inlineData; - return `data:${mimeType};base64,${b64}`; + return Math.max(0, Math.min(100, (quota.remaining / quota.limit) * 100)); } -async function generateTogether( - apiKey: string, - model: string, - prompt: string, - count: number, - width: number, - height: number, -): Promise { - const body = { - model, - prompt, - n: count, - width, - height, - response_format: "base64", - }; - const response = await fetch( - "https://api.together.xyz/v1/images/generations", - { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }, - ); - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error( - (error as { error?: { message?: string } }).error?.message ?? - `Together AI error ${response.status}`, - ); - } - const data = (await response.json()) as { - data: { b64_json?: string; url?: string }[]; - }; - return data.data.map((image) => - image.b64_json - ? `data:image/jpeg;base64,${image.b64_json}` - : (image.url ?? ""), - ); +function getRecordFilename(record: AiGeneratedImageRecord): string { + const extension = record.mimeType.includes("jpeg") + ? "jpg" + : record.mimeType.includes("webp") + ? "webp" + : "png"; + return `ai-${record.provider}-${record.createdAt}.${extension}`; } -async function generateXAI( - apiKey: string, - model: string, - prompt: string, - count: number, -): Promise { - const body = { model, prompt, n: count }; - const response = await fetch("https://api.x.ai/v1/images/generations", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const error = await response.json().catch(() => ({})); - throw new Error( - (error as { error?: { message?: string } }).error?.message ?? - `xAI error ${response.status}`, +function RecordsGrid({ + records, + urls, + actions, + emptyLabel, +}: AiRecordsGridProps) { + if (records.length === 0) { + return ( +
+ {emptyLabel} +
); } - const data = (await response.json()) as { data: { url: string }[] }; - return data.data.map((image) => image.url); + + return ( +
+ {records.map((record, index) => ( + + ))} +
+ ); } export function Generator() { + const { setState } = useEditorStore(); const [assetType, setAssetType] = useState("tileset"); const [tilesetCfg, setTilesetCfg] = useState({ tileType: "Ground", @@ -309,6 +184,20 @@ export function Generator() { const [images, setImages] = useState([]); const [isGenerating, setIsGenerating] = useState(false); const [initImage, setInitImage] = useState(null); + const [history, setHistory] = useState([]); + const [gallery, setGallery] = useState([]); + const [currentRecords, setCurrentRecords] = useState( + [], + ); + const [recordUrls, setRecordUrls] = useState>({}); + const [quota, setQuota] = useState(UNKNOWN_QUOTA); + const [scheduler, setScheduler] = useState({ + intervalSeconds: 60, + running: false, + nextRunAt: null, + }); + const [now, setNow] = useState(Date.now()); + const isGeneratingRef = useRef(false); const refreshModels = useCallback(async () => { const keys = await loadAllApiKeys(); @@ -320,17 +209,48 @@ export function Generator() { }); }, []); + const refreshRecords = useCallback(async () => { + const [nextHistory, nextGallery] = await Promise.all([ + listAiImageHistory(), + listSavedAiImages(), + ]); + setHistory(nextHistory); + setGallery(nextGallery); + }, []); + useEffect(() => { void refreshModels(); + void refreshRecords(); const handler = () => void refreshModels(); window.addEventListener("ai-keys-changed", handler); return () => window.removeEventListener("ai-keys-changed", handler); - }, [refreshModels]); + }, [refreshModels, refreshRecords]); + + useEffect(() => { + const nextUrls: Record = {}; + for (const record of history) { + nextUrls[record.id] = URL.createObjectURL( + new Blob([record.data], { type: record.mimeType }), + ); + } + setRecordUrls((previous) => { + for (const url of Object.values(previous)) { + URL.revokeObjectURL(url); + } + return nextUrls; + }); + return () => { + for (const url of Object.values(nextUrls)) { + URL.revokeObjectURL(url); + } + }; + }, [history]); const selectedModel = availableModels.find((model) => model.id === selectedId) ?? availableModels[0] ?? null; + const isHuggingFaceSelected = selectedModel?.provider === "huggingface"; const autoPrompt = useCallback( () => @@ -347,14 +267,14 @@ export function Generator() { ), [ assetType, - tilesetCfg, - spriteCfg, bgCfg, iconCfg, - uiCfg, - vfxCfg, + spriteCfg, styleStack, + tilesetCfg, transparent, + uiCfg, + vfxCfg, ], ); @@ -379,138 +299,223 @@ export function Generator() { availableRatios.find((ratio) => ratio.value === "1:1") ?? availableRatios[0] ?? ALL_RATIOS[0]; - const canGenerate = !isGenerating && !!selectedModel && generatedPrompt.trim().length > 0; - const generate = async () => { - if (!canGenerate || !selectedModel) return; + const generate = useCallback( + async (source: "manual" | "scheduler" = "manual") => { + if (!canGenerate || !selectedModel || isGeneratingRef.current) { + return false; + } - const finalPrompt = generatedPrompt.trim(); - let initImgB64: string | null = null; - let initImgMime: string | null = null; + const finalPrompt = generatedPrompt.trim(); + const apiKey = await loadApiKey(selectedModel.provider); + if (!apiKey) { + toast.error( + `No API key found for ${PROVIDER_LABELS[selectedModel.provider] ?? selectedModel.provider}. Add one in Settings.`, + ); + return false; + } - if (initImage && selectedModel.supportsImg2Img) { - const parsed = parseDataUrl(initImage); - initImgB64 = parsed.b64; - initImgMime = parsed.mime; - } + let initImgB64: string | null = null; + let initImgMime: string | null = null; + if (initImage && selectedModel.supportsImg2Img) { + const parsed = parseDataUrl(initImage); + initImgB64 = parsed.b64; + initImgMime = parsed.mime; + } - const apiKey = await loadApiKey(selectedModel.provider); - if (!apiKey) { - toast.error( - `No API key found for ${PROVIDER_LABELS[selectedModel.provider] ?? selectedModel.provider}. Add one in Settings.`, + isGeneratingRef.current = true; + setIsGenerating(true); + setImages( + Array.from({ length: count }, () => ({ status: "loading" as const })), ); + + try { + const result = await generateWithProvider(selectedModel.provider, { + apiKey, + model: selectedModel.apiModel, + prompt: finalPrompt, + count, + width: effectiveRatio.w, + height: effectiveRatio.h, + ratio: effectiveRatio.value, + initImageB64: initImgB64, + initImageMime: initImgMime, + }); + setQuota(result.quota); + + const records = result.images.map((image) => + createAiImageRecord( + { + ...image, + prompt: finalPrompt, + provider: selectedModel.provider, + modelId: selectedModel.apiModel, + modelLabel: selectedModel.label, + }, + createRecordId(), + ), + ); + await Promise.all(records.map(saveAiImageRecord)); + setCurrentRecords(records); + setImages( + records.map((record) => ({ + status: "done" as const, + url: arrayBufferToDataUrl(record.data, record.mimeType), + recordId: record.id, + })), + ); + await refreshRecords(); + if (source === "scheduler") { + toast.success("Scheduled image generated"); + } + return true; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error occurred."; + setImages([{ status: "error", message }]); + const lowerMessage = message.toLowerCase(); + if ( + isHuggingFaceSelected && + (message.includes("401") || + message.includes("403") || + message.includes("429") || + lowerMessage.includes("token") || + lowerMessage.includes("rate") || + lowerMessage.includes("quota")) + ) { + setScheduler((previous) => ({ + ...previous, + running: false, + nextRunAt: null, + })); + } + toast.error(message); + return false; + } finally { + isGeneratingRef.current = false; + setIsGenerating(false); + } + }, + [ + canGenerate, + count, + effectiveRatio.h, + effectiveRatio.value, + effectiveRatio.w, + generatedPrompt, + initImage, + isHuggingFaceSelected, + refreshRecords, + selectedModel, + ], + ); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + if (!scheduler.running || !isHuggingFaceSelected) return; + if (!scheduler.nextRunAt) { + setScheduler((previous) => ({ + ...previous, + nextRunAt: Date.now() + previous.intervalSeconds * 1000, + })); return; } + if (now < scheduler.nextRunAt || isGeneratingRef.current) return; + + void generate("scheduler").then((didGenerate) => { + if (!didGenerate) return; + setScheduler((previous) => ({ + ...previous, + nextRunAt: Date.now() + previous.intervalSeconds * 1000, + })); + }); + }, [ + generate, + isHuggingFaceSelected, + now, + scheduler.nextRunAt, + scheduler.running, + ]); - setIsGenerating(true); - setImages( - Array.from({ length: count }, () => ({ status: "loading" as const })), - ); - - const results = await Promise.all( - Array.from({ length: count }, async (_, index) => { + useEffect(() => { + if (!isHuggingFaceSelected && scheduler.running) { + setScheduler((previous) => ({ + ...previous, + running: false, + nextRunAt: null, + })); + } + }, [isHuggingFaceSelected, scheduler.running]); + + const actions = useMemo( + () => ({ + onDownload(record) { + void saveBlobFile( + new Blob([record.data], { type: record.mimeType }), + getRecordFilename(record), + ); + }, + onToggleSaved(record) { + void setAiImageSaved(record.id, record.savedAt === null).then( + refreshRecords, + ); + }, + async onAddToTileset(record) { try { - let urls: string[]; - - switch (selectedModel.provider) { - case "openai": { - const size = `${effectiveRatio.w}x${effectiveRatio.h}`; - urls = await generateOpenAI( - apiKey, - selectedModel.apiModel, - finalPrompt, - 1, - size, - initImgB64, - initImgMime, - ); - break; - } - case "gemini": { - const url = await generateGemini( - apiKey, - selectedModel.apiModel, - finalPrompt, - effectiveRatio.value, - initImgB64, - initImgMime, - ); - urls = [url]; - break; - } - case "together": { - if (index === 0) { - urls = await generateTogether( - apiKey, - selectedModel.apiModel, - finalPrompt, - count, - effectiveRatio.w, - effectiveRatio.h, - ); - } else { - return null; - } - break; - } - case "xai": { - if (index === 0) { - urls = await generateXAI( - apiKey, - selectedModel.apiModel, - finalPrompt, - count, - ); - } else { - return null; - } - break; - } - default: - throw new Error(`Unknown provider: ${selectedModel.provider}`); - } - - return { index, urls }; - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error occurred."; - const isAuthError = - message.includes("401") || - message.includes("403") || - message.toLowerCase().includes("invalid") || - message.toLowerCase().includes("api key"); - if (isAuthError) { - toast.error( - `Invalid API key for ${PROVIDER_LABELS[selectedModel.provider] ?? selectedModel.provider}`, + const assetId = await saveGeneratedImageAsset(record); + const tilesetId = createGeneratedTilesetId(); + let added = false; + setState((draft) => { + added = appendGeneratedImageTileset( + draft, + record, + assetId, + tilesetId, ); + }); + if (added) { + toast.success("Added generated image as a tileset"); + } else { + toast.error("Create or open a project with a tileset group first."); } - return { index, error: message }; - } - }), - ); - - setImages((previous) => { - const next = [...previous]; - for (const result of results) { - if (!result) continue; - if ("error" in result) { - next[result.index] = { - status: "error", - message: result.error ?? "Unknown error occurred.", - }; - continue; + } catch { + toast.error("Failed to add generated image to tileset"); } - for (const [offset, url] of result.urls.entries()) { - next[result.index + offset] = { status: "done", url }; - } - } - return next; - }); + }, + onOpenInEditor(record) { + setStandaloneAiImageEditorContext({ + id: record.id, + data: record.data, + mimeType: record.mimeType, + width: record.width, + height: record.height, + name: getRecordFilename(record), + }); + window.dispatchEvent(new CustomEvent("open-image-editor")); + }, + onDelete(record) { + void deleteAiImageRecord(record.id).then(async () => { + setCurrentRecords((previous) => + previous.filter((candidate) => candidate.id !== record.id), + ); + await refreshRecords(); + }); + }, + }), + [refreshRecords, setState], + ); - setIsGenerating(false); - }; + const quotaPercent = getQuotaPercent(quota); + const secondsUntilNext = + scheduler.nextRunAt !== null + ? Math.max(0, Math.ceil((scheduler.nextRunAt - now) / 1000)) + : null; return (
@@ -790,43 +795,176 @@ export function Generator() {
-
-
-
- - {selectedModel ? selectedModel.label : "No model available"} +
+
+
+
+ + {selectedModel ? selectedModel.label : "No model available"} +
+

+ {selectedModel + ? "Models are filtered to providers with configured API keys." + : "Add an API key in Settings to enable generation."} +

-

- {selectedModel - ? "Models are filtered to providers with configured API keys." - : "Add an API key in Settings to enable generation."} -

+
- +
+
+ + {scheduler.running && secondsUntilNext !== null + ? `Next generation in ${secondsUntilNext}s` + : "Scheduler paused"} +
+
+ )} +
+ + + + Current + History + Gallery + + + {isGenerating ? ( - <> - - Generating - +
+ {Array.from({ length: count }, (_, index) => ( + + ))} +
+ ) : currentRecords.length > 0 ? ( + ) : ( - "Generate" +
+ {Array.from({ length: count }, (_, index) => ( + + ))} +
)} - -
- -
- {Array.from({ length: count }, (_, index) => ( - + + + - ))} -
+ + + + + +
diff --git a/src/features/ai-assets/components/ImageCell.tsx b/src/features/ai-assets/components/ImageCell.tsx index 97f4968..ceb5c3c 100644 --- a/src/features/ai-assets/components/ImageCell.tsx +++ b/src/features/ai-assets/components/ImageCell.tsx @@ -1,40 +1,117 @@ -import { Loader2, X } from "lucide-react"; -import type { ImageState } from "@/types/integrations/ai-assets"; +import { + Download, + ImagePlus, + Loader2, + Pencil, + Star, + StarOff, + Trash2, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import type { GeneratedImageCellProps } from "@/features/ai-assets/types"; export function ImageCell({ state, index, -}: { - state: ImageState; - index: number; -}) { + record, + url, + actions, +}: GeneratedImageCellProps) { + const resolvedState = state ?? (url ? { status: "done" as const, url } : { status: "idle" as const }); + const imageUrl = resolvedState.status === "done" ? resolvedState.url : url; + return ( -
- {state.status === "idle" && ( +
+ {resolvedState.status === "idle" && ( #{index + 1} )} - {state.status === "loading" && ( + {resolvedState.status === "loading" && (
Generating...
)} - {state.status === "done" && ( + {resolvedState.status === "done" && imageUrl && ( {`Generated )} - {state.status === "error" && ( + {resolvedState.status === "error" && (
-

{state.message}

+

{resolvedState.message}

+
+ )} + + {record && actions && imageUrl && ( +
+ + + + +
)}
); -} \ No newline at end of file +} diff --git a/src/features/ai-assets/lib/constants.ts b/src/features/ai-assets/lib/constants.ts index 140a367..3e52e65 100644 --- a/src/features/ai-assets/lib/constants.ts +++ b/src/features/ai-assets/lib/constants.ts @@ -5,6 +5,20 @@ import type { } from "@/types/integrations/ai-assets"; export const MODELS: ModelDef[] = [ + { + id: "hf-flux-schnell", + label: "FLUX.1 Schnell (Hugging Face)", + provider: "huggingface", + apiModel: "black-forest-labs/FLUX.1-schnell", + supportsImg2Img: false, + }, + { + id: "hf-z-image-turbo", + label: "Z-Image Turbo (HF / fal-ai)", + provider: "huggingface", + apiModel: "Tongyi-MAI/Z-Image-Turbo:fal-ai", + supportsImg2Img: false, + }, { id: "gpt-image-1.5", label: "GPT Image 1.5 (latest)", @@ -64,6 +78,7 @@ export const MODELS: ModelDef[] = [ ]; export const PROVIDER_LABELS: Record = { + huggingface: "Hugging Face", openai: "OpenAI", gemini: "Google Gemini", xai: "xAI", diff --git a/src/features/ai-assets/lib/persistence.ts b/src/features/ai-assets/lib/persistence.ts new file mode 100644 index 0000000..e8fbe09 --- /dev/null +++ b/src/features/ai-assets/lib/persistence.ts @@ -0,0 +1,52 @@ +import { db } from "@/services/db"; +import type { + AiGeneratedImageInput, + AiGeneratedImageRecord, +} from "@/types/integrations/ai-assets"; + +export function createAiImageRecord( + input: AiGeneratedImageInput, + id: string, + createdAt = Date.now(), +): AiGeneratedImageRecord { + return { + ...input, + id, + createdAt, + savedAt: null, + }; +} + +export async function saveAiImageRecord( + record: AiGeneratedImageRecord, +): Promise { + await db.aiImages.put(record); +} + +export async function getAiImageRecord( + id: string, +): Promise { + return db.aiImages.get(id); +} + +export async function listAiImageHistory(): Promise { + return db.aiImages.orderBy("createdAt").reverse().toArray(); +} + +export async function listSavedAiImages(): Promise { + const records = await db.aiImages.toArray(); + return records + .filter((record) => record.savedAt !== null) + .sort((left, right) => (right.savedAt ?? 0) - (left.savedAt ?? 0)); +} + +export async function setAiImageSaved( + id: string, + saved: boolean, +): Promise { + await db.aiImages.update(id, { savedAt: saved ? Date.now() : null }); +} + +export async function deleteAiImageRecord(id: string): Promise { + await db.aiImages.delete(id); +} diff --git a/src/features/ai-assets/lib/provider-utils.ts b/src/features/ai-assets/lib/provider-utils.ts new file mode 100644 index 0000000..ca957fb --- /dev/null +++ b/src/features/ai-assets/lib/provider-utils.ts @@ -0,0 +1,97 @@ +import type { AiProviderImage } from "@/types/integrations/ai-assets"; + +interface DataUrlParts { + mimeType: string; + base64: string; +} + +function parseImageDataUrl(dataUrl: string): DataUrlParts | null { + const match = /^data:([^;,]+);base64,(.*)$/i.exec(dataUrl); + if (!match) return null; + return { + mimeType: match[1] ?? "image/png", + base64: match[2] ?? "", + }; +} + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes.buffer; +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +export function arrayBufferToDataUrl( + data: ArrayBuffer, + mimeType: string, +): string { + return `data:${mimeType};base64,${arrayBufferToBase64(data)}`; +} + +export async function getImageDimensionsFromBlob(blob: Blob): Promise<{ + width: number; + height: number; +}> { + const objectUrl = URL.createObjectURL(blob); + try { + const image = new Image(); + image.src = objectUrl; + if (typeof image.decode === "function") { + await image.decode(); + } else { + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Failed to decode image.")); + }); + } + return { + width: image.naturalWidth || image.width, + height: image.naturalHeight || image.height, + }; + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +export async function imageSourceToProviderImage( + source: string | Blob, + fallbackMimeType = "image/png", +): Promise { + let blob: Blob; + + if (source instanceof Blob) { + blob = source; + } else { + const dataUrlParts = parseImageDataUrl(source); + if (dataUrlParts) { + blob = new Blob([base64ToArrayBuffer(dataUrlParts.base64)], { + type: dataUrlParts.mimeType, + }); + } else { + const response = await fetch(source); + if (!response.ok) { + throw new Error(`Failed to fetch generated image ${response.status}`); + } + blob = await response.blob(); + } + } + + const dimensions = await getImageDimensionsFromBlob(blob); + return { + data: await blob.arrayBuffer(), + mimeType: blob.type || fallbackMimeType, + width: dimensions.width, + height: dimensions.height, + }; +} diff --git a/src/features/ai-assets/lib/providers.ts b/src/features/ai-assets/lib/providers.ts new file mode 100644 index 0000000..224b255 --- /dev/null +++ b/src/features/ai-assets/lib/providers.ts @@ -0,0 +1,311 @@ +import { parseQuotaHeaders, UNKNOWN_QUOTA } from "./quota"; +import { imageSourceToProviderImage } from "./provider-utils"; +import type { + AiAssetProviderId, + AiProviderGenerateRequest, + AiProviderGenerateResult, +} from "@/types/integrations/ai-assets"; + +function getApiErrorMessage( + fallback: string, + error: unknown, +): string { + if (!error || typeof error !== "object") return fallback; + const maybeError = error as { + error?: string | { message?: string }; + message?: string; + }; + if (typeof maybeError.error === "string") return maybeError.error; + if (typeof maybeError.error?.message === "string") { + return maybeError.error.message; + } + if (typeof maybeError.message === "string") return maybeError.message; + return fallback; +} + +async function readJsonError(response: Response, fallback: string) { + const error = await response.json().catch(() => null); + throw new Error(getApiErrorMessage(fallback, error)); +} + +async function generateOpenAI({ + apiKey, + model, + prompt, + count, + width, + height, + initImageB64, + initImageMime, +}: AiProviderGenerateRequest): Promise { + const dataUrls: string[] = []; + + if (initImageB64 && initImageMime) { + await Promise.all( + Array.from({ length: count }, async () => { + const form = new FormData(); + const byteStr = atob(initImageB64); + const bytes = new Uint8Array(byteStr.length); + for (let index = 0; index < byteStr.length; index += 1) { + bytes[index] = byteStr.charCodeAt(index); + } + form.append( + "image", + new Blob([bytes], { type: initImageMime }), + "reference.png", + ); + form.append("prompt", prompt); + form.append("model", model); + form.append("n", "1"); + form.append("response_format", "b64_json"); + const response = await fetch("https://api.openai.com/v1/images/edits", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: form, + }); + if (!response.ok) { + await readJsonError(response, `OpenAI error ${response.status}`); + } + const data = (await response.json()) as { + data: { b64_json: string }[]; + }; + dataUrls.push( + ...data.data.map((image) => `data:image/png;base64,${image.b64_json}`), + ); + }), + ); + } else { + const response = await fetch("https://api.openai.com/v1/images/generations", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + prompt, + n: count, + size: `${width}x${height}`, + response_format: "b64_json", + }), + }); + if (!response.ok) { + await readJsonError(response, `OpenAI error ${response.status}`); + } + const data = (await response.json()) as { data: { b64_json: string }[] }; + dataUrls.push( + ...data.data.map((image) => `data:image/png;base64,${image.b64_json}`), + ); + } + + return { + images: await Promise.all( + dataUrls.map((url) => imageSourceToProviderImage(url, "image/png")), + ), + quota: UNKNOWN_QUOTA, + }; +} + +async function generateGemini({ + apiKey, + model, + prompt, + ratio, + initImageB64, + initImageMime, + count, +}: AiProviderGenerateRequest): Promise { + return { + images: await Promise.all( + Array.from({ length: count }, async () => { + const parts: unknown[] = []; + if (initImageB64 && initImageMime) { + parts.push({ + inline_data: { mime_type: initImageMime, data: initImageB64 }, + }); + } + parts.push({ text: prompt }); + + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ role: "user", parts }], + generationConfig: { + responseModalities: ["IMAGE"], + imageConfig: { aspectRatio: ratio }, + }, + }), + }, + ); + if (!response.ok) { + await readJsonError(response, `Gemini error ${response.status}`); + } + + const data = (await response.json()) as { + candidates?: { + content?: { + parts?: { + inlineData?: { data: string; mimeType: string }; + thought?: boolean; + }[]; + }; + }[]; + }; + const imagePart = data.candidates + ?.flatMap((candidate) => candidate.content?.parts ?? []) + .find((part) => part.inlineData && !part.thought); + if (!imagePart?.inlineData) { + throw new Error("Gemini returned no image"); + } + + return imageSourceToProviderImage( + `data:${imagePart.inlineData.mimeType};base64,${imagePart.inlineData.data}`, + imagePart.inlineData.mimeType, + ); + }), + ), + quota: UNKNOWN_QUOTA, + }; +} + +async function generateTogether({ + apiKey, + model, + prompt, + count, + width, + height, +}: AiProviderGenerateRequest): Promise { + const response = await fetch("https://api.together.xyz/v1/images/generations", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + prompt, + n: count, + width, + height, + response_format: "base64", + }), + }); + if (!response.ok) { + await readJsonError(response, `Together AI error ${response.status}`); + } + const data = (await response.json()) as { + data: { b64_json?: string; url?: string }[]; + }; + return { + images: await Promise.all( + data.data.map((image) => + imageSourceToProviderImage( + image.b64_json + ? `data:image/jpeg;base64,${image.b64_json}` + : (image.url ?? ""), + "image/jpeg", + ), + ), + ), + quota: UNKNOWN_QUOTA, + }; +} + +async function generateXAI({ + apiKey, + model, + prompt, + count, +}: AiProviderGenerateRequest): Promise { + const response = await fetch("https://api.x.ai/v1/images/generations", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ model, prompt, n: count }), + }); + if (!response.ok) { + await readJsonError(response, `xAI error ${response.status}`); + } + const data = (await response.json()) as { data: { url: string }[] }; + return { + images: await Promise.all( + data.data.map((image) => imageSourceToProviderImage(image.url)), + ), + quota: UNKNOWN_QUOTA, + }; +} + +function buildHuggingFaceRoute(model: string): string { + const [modelId, provider = "hf-inference"] = model.split(":"); + const encodedModel = (modelId ?? model) + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/"); + return `https://router.huggingface.co/${provider}/models/${encodedModel}`; +} + +async function generateHuggingFace({ + apiKey, + model, + prompt, + count, + width, + height, +}: AiProviderGenerateRequest): Promise { + const images = []; + let quota = UNKNOWN_QUOTA; + + for (let index = 0; index < count; index += 1) { + const response = await fetch(buildHuggingFaceRoute(model), { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + Accept: "image/*", + }, + body: JSON.stringify({ + inputs: prompt, + parameters: { + width, + height, + }, + }), + }); + quota = parseQuotaHeaders(response.headers); + if (!response.ok) { + const error = await response.json().catch(async () => ({ + message: await response.text().catch(() => ""), + })); + throw new Error( + getApiErrorMessage(`Hugging Face error ${response.status}`, error), + ); + } + images.push(await imageSourceToProviderImage(await response.blob())); + } + + return { images, quota }; +} + +export function generateWithProvider( + provider: AiAssetProviderId, + request: AiProviderGenerateRequest, +): Promise { + switch (provider) { + case "huggingface": + return generateHuggingFace(request); + case "openai": + return generateOpenAI(request); + case "gemini": + return generateGemini(request); + case "together": + return generateTogether(request); + case "xai": + return generateXAI(request); + } +} diff --git a/src/features/ai-assets/lib/quota.ts b/src/features/ai-assets/lib/quota.ts new file mode 100644 index 0000000..175c263 --- /dev/null +++ b/src/features/ai-assets/lib/quota.ts @@ -0,0 +1,69 @@ +import type { AiQuotaState } from "@/types/integrations/ai-assets"; + +export const UNKNOWN_QUOTA: AiQuotaState = { + limit: null, + remaining: null, + resetAt: null, + source: "unknown", +}; + +function readHeader(headers: Headers, names: string[]): string | null { + for (const name of names) { + const value = headers.get(name); + if (value !== null && value.trim() !== "") { + return value; + } + } + return null; +} + +function readNumber(headers: Headers, names: string[]): number | null { + const value = readHeader(headers, names); + if (!value) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function readResetAt(headers: Headers): number | null { + const rawReset = readHeader(headers, [ + "x-ratelimit-reset", + "x-rate-limit-reset", + "ratelimit-reset", + "rate-limit-reset", + ]); + if (!rawReset) return null; + + const numeric = Number(rawReset); + if (Number.isFinite(numeric)) { + return numeric > 1_000_000_000_000 ? numeric : numeric * 1000; + } + + const parsedDate = Date.parse(rawReset); + return Number.isFinite(parsedDate) ? parsedDate : null; +} + +export function parseQuotaHeaders(headers: Headers): AiQuotaState { + const limit = readNumber(headers, [ + "x-ratelimit-limit", + "x-rate-limit-limit", + "ratelimit-limit", + "rate-limit-limit", + ]); + const remaining = readNumber(headers, [ + "x-ratelimit-remaining", + "x-rate-limit-remaining", + "ratelimit-remaining", + "rate-limit-remaining", + ]); + + if (limit === null && remaining === null) { + return UNKNOWN_QUOTA; + } + + return { + limit, + remaining, + resetAt: readResetAt(headers), + source: "headers", + }; +} diff --git a/src/features/ai-assets/lib/standalone-editor-context.ts b/src/features/ai-assets/lib/standalone-editor-context.ts new file mode 100644 index 0000000..ba8e1c5 --- /dev/null +++ b/src/features/ai-assets/lib/standalone-editor-context.ts @@ -0,0 +1,33 @@ +import type { StandaloneAiImageEditorContext } from "@/features/ai-assets/types"; + +export interface PendingStandaloneAiImageEditorRequest { + requestId: number; + context: StandaloneAiImageEditorContext; +} + +let activeContext: StandaloneAiImageEditorContext | null = null; +let activeRequestId = 0; + +export function setStandaloneAiImageEditorContext( + context: StandaloneAiImageEditorContext | null, +): void { + activeContext = context; + if (context) { + activeRequestId += 1; + } +} + +export function getPendingStandaloneAiImageEditorRequest(): PendingStandaloneAiImageEditorRequest | null { + if (!activeContext) return null; + return { + requestId: activeRequestId, + context: activeContext, + }; +} + +export function clearStandaloneAiImageEditorContext(requestId?: number): void { + if (requestId !== undefined && requestId !== activeRequestId) { + return; + } + activeContext = null; +} diff --git a/src/features/ai-assets/lib/tileset-actions.ts b/src/features/ai-assets/lib/tileset-actions.ts new file mode 100644 index 0000000..00ac627 --- /dev/null +++ b/src/features/ai-assets/lib/tileset-actions.ts @@ -0,0 +1,46 @@ +import { saveAsset } from "@/services/db"; +import { generateAssetId, generateTilesetId } from "@/utils/ids"; +import { syncActiveTilesetState } from "@/features/map-editor/lib/tileset-panel-state"; +import type { EditorState, TileSize, Tileset } from "@/types"; +import type { AiGeneratedImageRecord } from "@/types/integrations/ai-assets"; + +export function appendGeneratedImageTileset( + draft: EditorState, + record: AiGeneratedImageRecord, + assetId: Tileset["assetId"], + tilesetId: Tileset["id"], + createdAt = Date.now(), +): boolean { + if (!draft.project) return false; + + const targetGroupId = + draft.activeTilesetGroupId ?? draft.project.tilesetGroups[0]?.id ?? null; + if (!targetGroupId) return false; + + const tileSize = (draft.tileSize || draft.project.tileSize || 32) as TileSize; + draft.project.tilesets.push({ + id: tilesetId, + name: `AI ${record.modelLabel}`.slice(0, 64), + groupId: targetGroupId, + tileSize, + assetId, + imageWidth: record.width, + imageHeight: record.height, + createdAt, + }); + draft.activeTilesetGroupId = targetGroupId; + syncActiveTilesetState(draft, tilesetId); + return true; +} + +export async function saveGeneratedImageAsset( + record: AiGeneratedImageRecord, +): Promise { + const assetId = generateAssetId(); + await saveAsset(assetId, record.data, record.mimeType); + return assetId; +} + +export function createGeneratedTilesetId(): Tileset["id"] { + return generateTilesetId(); +} diff --git a/src/features/ai-assets/types/index.ts b/src/features/ai-assets/types/index.ts new file mode 100644 index 0000000..29c4bea --- /dev/null +++ b/src/features/ai-assets/types/index.ts @@ -0,0 +1,34 @@ +import type { AiGeneratedImageRecord } from "@/types/integrations/ai-assets"; +import type { ImageState } from "@/types/integrations/ai-assets"; + +export interface StandaloneAiImageEditorContext { + id: string; + data: ArrayBuffer; + mimeType: string; + width: number; + height: number; + name: string; +} + +export interface AiImageActionHandlers { + onDownload: (record: AiGeneratedImageRecord) => void; + onToggleSaved: (record: AiGeneratedImageRecord) => void; + onAddToTileset: (record: AiGeneratedImageRecord) => void; + onOpenInEditor: (record: AiGeneratedImageRecord) => void; + onDelete: (record: AiGeneratedImageRecord) => void; +} + +export interface GeneratedImageCellProps { + state?: ImageState; + index: number; + record?: AiGeneratedImageRecord | null; + url?: string | null; + actions?: AiImageActionHandlers; +} + +export interface AiRecordsGridProps { + records: AiGeneratedImageRecord[]; + urls: Record; + actions: AiImageActionHandlers; + emptyLabel: string; +} diff --git a/src/features/image-editor/components/LospecPaletteDialog.tsx b/src/features/image-editor/components/LospecPaletteDialog.tsx index b0b5da8..91eba24 100644 --- a/src/features/image-editor/components/LospecPaletteDialog.tsx +++ b/src/features/image-editor/components/LospecPaletteDialog.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/Button"; import { @@ -11,6 +11,12 @@ import { } from "@/components/ui/Dialog"; import { Input } from "@/components/ui/Input"; import { ScrollArea } from "@/components/ui/ScrollArea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/Tooltip"; import { filterAndSortLospecPalettes, syncLospecPaletteCatalog, @@ -21,6 +27,10 @@ import type { } from "@/features/image-editor/types"; import type { LospecPaletteDialogProps } from "@/features/image-editor/types/image-editor-ui"; +const LOSPEC_DIALOG_PAGE_SIZE = 24; +const LOSPEC_COLOR_PREVIEW_LIMIT = 12; +const LOSPEC_RATE_LIMIT_RETRY_SECONDS = 60; + function colorToCss(hex: string): string { return `#${hex}`; } @@ -49,7 +59,19 @@ export function LospecPaletteDialog({ const [sortOrder, setSortOrder] = useState("newest"); const [syncMessage, setSyncMessage] = useState(""); const [errorMessage, setErrorMessage] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); const [hiddenExampleIds, setHiddenExampleIds] = useState([]); + const [expandedColorPaletteIds, setExpandedColorPaletteIds] = useState< + string[] + >([]); + const [currentPage, setCurrentPage] = useState(1); + const [retrySeconds, setRetrySeconds] = useState(null); + const [retryAttempt, setRetryAttempt] = useState(0); + const paletteCountRef = useRef(0); + + useEffect(() => { + paletteCountRef.current = palettes.length; + }); useEffect(() => { if (!open) { @@ -57,25 +79,72 @@ export function LospecPaletteDialog({ } let isCurrent = true; - setIsLoading(true); + let retryIntervalId: number | null = null; + let retryTimeoutId: number | null = null; + + setIsLoading(paletteCountRef.current === 0); + setIsSyncing(true); setErrorMessage(null); - setSyncMessage(""); + setRetrySeconds(null); + setSyncMessage("Checking Lospec palette library..."); setHiddenExampleIds([]); + setExpandedColorPaletteIds([]); + setCurrentPage(1); + + const scheduleRateLimitRetry = () => { + setRetrySeconds(LOSPEC_RATE_LIMIT_RETRY_SECONDS); + retryIntervalId = window.setInterval(() => { + setRetrySeconds((seconds) => + seconds === null ? seconds : Math.max(0, seconds - 1), + ); + }, 1000); + retryTimeoutId = window.setTimeout(() => { + if (isCurrent) { + setRetryAttempt((attempt) => attempt + 1); + } + }, LOSPEC_RATE_LIMIT_RETRY_SECONDS * 1000); + }; void (async () => { - const result = await syncLospecPaletteCatalog(); + const result = await syncLospecPaletteCatalog({ + onProgress: (progress) => { + if (!isCurrent) { + return; + } + + setPalettes(progress.palettes); + setIsLoading(false); + + if (progress.isInitialCache) { + setSyncMessage( + `Loaded ${progress.palettes.length} cached Lospec palettes. Syncing latest palettes...`, + ); + return; + } + + setSyncMessage( + progress.addedCount > 0 + ? `Imported ${progress.addedCount} new Lospec palettes. Syncing page ${progress.fetchedPageCount}...` + : `Checked ${progress.fetchedPageCount} Lospec pages. Syncing latest palettes...`, + ); + }, + }); if (!isCurrent) { return; } setPalettes(result.palettes); setIsLoading(false); + setIsSyncing(false); if (result.status === "cache-only") { const message = result.errorMessage ? `Showing cached Lospec palettes. ${result.errorMessage}` : "Showing cached Lospec palettes."; setSyncMessage(message); + if (result.errorStatus === 429) { + scheduleRateLimitRetry(); + } toast(message); return; } @@ -100,19 +169,45 @@ export function LospecPaletteDialog({ setSyncMessage( result.addedCount > 0 ? `Imported ${result.addedCount} new Lospec palettes into local IndexedDB.` - : "Lospec palette library is already up to date.", + : result.palettes.length > 0 + ? "Lospec palette library is already up to date." + : "No Lospec palettes were found.", ); })(); return () => { isCurrent = false; + if (retryIntervalId !== null) { + window.clearInterval(retryIntervalId); + } + if (retryTimeoutId !== null) { + window.clearTimeout(retryTimeoutId); + } }; - }, [open]); + }, [open, retryAttempt]); const filteredPalettes = filterAndSortLospecPalettes(palettes, { query, sortOrder, }); + const totalPages = Math.max( + 1, + Math.ceil(filteredPalettes.length / LOSPEC_DIALOG_PAGE_SIZE), + ); + const safeCurrentPage = Math.min(currentPage, totalPages); + const pageStartIndex = (safeCurrentPage - 1) * LOSPEC_DIALOG_PAGE_SIZE; + const paginatedPalettes = filteredPalettes.slice( + pageStartIndex, + pageStartIndex + LOSPEC_DIALOG_PAGE_SIZE, + ); + + useEffect(() => { + setCurrentPage(1); + }, [query, sortOrder]); + + useEffect(() => { + setCurrentPage((page) => Math.min(page, totalPages)); + }, [totalPages]); const handleImport = (palette: LospecPaletteRecord) => { onImportPalette(palette); @@ -126,178 +221,280 @@ export function LospecPaletteDialog({ ); }; + const handleTagClick = (tag: string) => { + setQuery(tag); + setCurrentPage(1); + }; + + const handleShowAllColors = (paletteId: string) => { + setExpandedColorPaletteIds((current) => + current.includes(paletteId) ? current : [...current, paletteId], + ); + }; + + const handleCopyColor = async (hex: string) => { + const value = colorToCss(hex); + + try { + await navigator.clipboard.writeText(value); + toast.success(`Copied ${value}`); + } catch { + toast.error("Color could not be copied."); + } + }; + return ( -
- - Import from Lospec - - - {isLoading ? ( -
- - Importing Lospec palettes... -
- ) : errorMessage && palettes.length === 0 ? ( -
-
- {errorMessage} + +
+ + Import from Lospec + + + {isLoading ? ( +
+ + Importing Lospec palettes...
-
- ) : ( - <> -
-
-
- - setQuery(event.target.value)} - placeholder="Search by tag, title, user, or description" - /> + ) : errorMessage && palettes.length === 0 ? ( +
+
+ {errorMessage} +
+
+ ) : ( + <> +
+
+
+ + setQuery(event.target.value)} + placeholder="Search by tag, title, user, or description" + /> +
+
+ + +
-
- - +
+ {isSyncing ? ( + + ) : null} + + {syncMessage || + `Loaded ${palettes.length} Lospec palettes.`} + {retrySeconds !== null + ? ` Retrying in ${retrySeconds}s.` + : ""} +
-
- {syncMessage || `Loaded ${palettes.length} Lospec palettes.`} -
-
- -
- {filteredPalettes.map((palette) => { - const exampleImage = getPrimaryExampleImage(palette); - const showExample = - !!exampleImage && !hiddenExampleIds.includes(palette.id); - - return ( -
- {showExample ? ( - {`${palette.title} hideExampleImage(palette.id)} - /> - ) : ( -
- Example artwork unavailable -
- )} - -
-
-
-

- {palette.title} -

-

- by {palette.user || "Unknown artist"} + +

+ {paginatedPalettes.map((palette) => { + const exampleImage = getPrimaryExampleImage(palette); + const showExample = + !!exampleImage && + !hiddenExampleIds.includes(palette.id); + const showAllColors = expandedColorPaletteIds.includes( + palette.id, + ); + const visibleColorHexes = showAllColors + ? palette.colorHexes + : palette.colorHexes.slice( + 0, + LOSPEC_COLOR_PREVIEW_LIMIT, + ); + const hiddenColorCount = + palette.colorHexes.length - visibleColorHexes.length; + + return ( +
+ {showExample ? ( + {`${palette.title} hideExampleImage(palette.id)} + /> + ) : ( +
+ Example artwork unavailable +
+ )} + +
+
+
+

+ {palette.title} +

+

+ by {palette.user || "Unknown artist"} +

+
+ +
+ + {palette.description ? ( +

+ {palette.description}

+ ) : null} + +
+ {visibleColorHexes.map((hex, index) => ( + + + + ) : null}
- -
- {palette.description ? ( -

- {palette.description} -

- ) : null} - -
- {palette.colorHexes - .slice(0, 12) - .map((hex, index) => ( - +
+ {palette.tags.map((tag) => ( + ))} - {palette.colorHexes.length > 12 ? ( - - +{palette.colorHexes.length - 12} - - ) : null} -
+
-
- {palette.tags.map((tag) => ( - - {tag} +
+ {palette.colors.length} colors + + {formatLospecPublishedDate(palette.publishedAt)} - ))} +
+
+ ); + })} -
- {palette.colors.length} colors - - {formatLospecPublishedDate(palette.publishedAt)} - -
-
-
- ); - })} - - {filteredPalettes.length === 0 ? ( -
- No Lospec palettes match the current search. -
- ) : null} + {filteredPalettes.length === 0 ? ( +
+ No Lospec palettes match the current search. +
+ ) : null} +
+
+ + )} + + + {filteredPalettes.length > 0 ? ( +
+
+ + +
+ + Page {safeCurrentPage} of {totalPages} +
- - - )} - - -
+ ) : ( + + )} + +
+
); diff --git a/src/features/image-editor/hooks/use-image-editor-request-loader.ts b/src/features/image-editor/hooks/use-image-editor-request-loader.ts index 5b2e2f3..ee563c7 100644 --- a/src/features/image-editor/hooks/use-image-editor-request-loader.ts +++ b/src/features/image-editor/hooks/use-image-editor-request-loader.ts @@ -4,6 +4,10 @@ import { clearImageLayerEditorContext, getPendingImageLayerEditorRequest, } from "@/features/image-editor/lib/image-layer-editor-context"; +import { + clearStandaloneAiImageEditorContext, + getPendingStandaloneAiImageEditorRequest, +} from "@/features/ai-assets/lib/standalone-editor-context"; import { imageLayerImageCache, loadImageLayerImage, @@ -187,14 +191,85 @@ export function useImageEditorRequestLoader( setActiveImageLayerCtx(ctx); }, [editor, onRequestLoaded, projectId]); + const loadPendingStandaloneAiImageRequest = useCallback(async () => { + const pendingRequest = getPendingStandaloneAiImageEditorRequest(); + if (!pendingRequest) { + return; + } + + const { requestId, context: ctx } = pendingRequest; + const runId = ++loadRunIdRef.current; + const isCurrentRun = () => + isMountedRef.current && loadRunIdRef.current === runId; + + const objectUrl = URL.createObjectURL( + new Blob([ctx.data], { type: ctx.mimeType }), + ); + + try { + const image = new Image(); + image.src = objectUrl; + if (typeof image.decode === "function") { + await image.decode(); + } else { + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Failed to decode image.")); + }); + } + if (!isCurrentRun()) return; + + const width = ctx.width || image.naturalWidth || image.width; + const height = ctx.height || image.naturalHeight || image.height; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context || !isCurrentRun()) return; + + context.imageSmoothingEnabled = false; + context.drawImage(image, 0, 0, width, height); + const imageData = context.getImageData(0, 0, width, height); + if (!isCurrentRun()) return; + + const savedPalettes = projectId ? loadPaletteLibrary(projectId) : null; + editor.initProject(width, height, savedPalettes ?? undefined); + if (!isCurrentRun()) return; + + if (isImageEditorStoreReady()) { + const state = getImageEditorStore().getState(); + if (state.frames.length > 0) { + editor.setFrameData(state.frames[0].id, imageData); + editor.markSavePoint(); + } + } + + clearStandaloneAiImageEditorContext(requestId); + onRequestLoaded(); + setActiveTileCtx(null); + setActiveImageLayerCtx(null); + } finally { + URL.revokeObjectURL(objectUrl); + } + }, [editor, onRequestLoaded, projectId]); + const loadPendingEditorRequest = useCallback(async () => { + if (getPendingStandaloneAiImageEditorRequest()) { + await loadPendingStandaloneAiImageRequest(); + return; + } + if (getPendingImageLayerEditorRequest()) { await loadPendingImageLayerRequest(); return; } await loadPendingTileRequest(); - }, [loadPendingImageLayerRequest, loadPendingTileRequest]); + }, [ + loadPendingImageLayerRequest, + loadPendingStandaloneAiImageRequest, + loadPendingTileRequest, + ]); useEffect(() => { isMountedRef.current = true; diff --git a/src/features/image-editor/lib/lospec-palettes.ts b/src/features/image-editor/lib/lospec-palettes.ts index d25fb16..0cf5785 100644 --- a/src/features/image-editor/lib/lospec-palettes.ts +++ b/src/features/image-editor/lib/lospec-palettes.ts @@ -15,6 +15,16 @@ import type { export const LOSPEC_PALETTES_ENDPOINT = "https://api.2dtiler.com/lospec_palettes"; +class LospecPaletteRequestError extends Error { + status: number; + + constructor(status: number) { + super(`Lospec palette request failed with ${status}`); + this.name = "LospecPaletteRequestError"; + this.status = status; + } +} + function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -93,6 +103,12 @@ function getLospecErrorMessage(error: unknown): string { return "Lospec palettes could not be loaded."; } +function getLospecErrorStatus(error: unknown): number | undefined { + return error instanceof LospecPaletteRequestError + ? error.status + : undefined; +} + function buildLospecPaletteSearchHaystack( palette: LospecPaletteRecord, ): string { @@ -117,7 +133,7 @@ async function fetchLospecPalettePage( const response = await fetchImpl(url.toString()); if (!response.ok) { - throw new Error(`Lospec palette request failed with ${response.status}`); + throw new LospecPaletteRequestError(response.status); } return normalizeLospecPalettePage(await response.json(), now()); @@ -177,11 +193,15 @@ export function normalizeLospecPalettePage( value: unknown, cachedAt: number = Date.now(), ): LospecPaletteRecord[] { - if (!Array.isArray(value)) { + const records = isObjectRecord(value) && Array.isArray(value.items) + ? value.items + : value; + + if (!Array.isArray(records)) { return []; } - return value.flatMap((entry) => { + return records.flatMap((entry) => { const palette = normalizeLospecPaletteRecord(entry, cachedAt); return palette ? [palette] : []; }); @@ -233,14 +253,29 @@ export async function syncLospecPaletteCatalog( const loadCache = dependencies.loadCache ?? loadLospecPaletteCache; const loadCacheIds = dependencies.loadCacheIds ?? loadLospecPaletteCacheIds; const saveCache = dependencies.saveCache ?? saveLospecPaletteCache; + const onProgress = dependencies.onProgress; const now = dependencies.now ?? Date.now; const knownIds = new Set(await loadCacheIds()); + const cachedPalettes = await loadCache(); let page = 0; let addedCount = 0; + let fetchedPageCount = 0; + + if (cachedPalettes.length > 0) { + onProgress?.({ + palettes: cachedPalettes, + addedCount, + fetchedPageCount, + page: null, + pageAddedCount: 0, + isInitialCache: true, + }); + } try { while (page < LOSPEC_SYNC_MAX_PAGES) { const pagePalettes = await fetchLospecPalettePage(page, fetchImpl, now); + fetchedPageCount += 1; if (pagePalettes.length === 0) { break; } @@ -261,6 +296,15 @@ export async function syncLospecPaletteCatalog( } } + onProgress?.({ + palettes: await loadCache(), + addedCount, + fetchedPageCount, + page, + pageAddedCount: palettesToSave.length, + isInitialCache: false, + }); + if (firstKnownIndex >= 0) { break; } @@ -272,6 +316,7 @@ export async function syncLospecPaletteCatalog( return { palettes: await loadCache(), addedCount, + fetchedPageCount, usedCache: false, status: "partial", errorMessage: `Reached Lospec sync cap (${LOSPEC_SYNC_MAX_PAGES} pages). Imported a partial catalog.`, @@ -281,19 +326,23 @@ export async function syncLospecPaletteCatalog( return { palettes: await loadCache(), addedCount, + fetchedPageCount, usedCache: false, status: "synced", }; } catch (error) { const palettes = await loadCache(); const errorMessage = getLospecErrorMessage(error); + const errorStatus = getLospecErrorStatus(error); if (palettes.length > 0) { return { palettes, addedCount, + fetchedPageCount, usedCache: true, status: "cache-only", + errorStatus, errorMessage, }; } @@ -301,8 +350,10 @@ export async function syncLospecPaletteCatalog( return { palettes: [], addedCount, + fetchedPageCount, usedCache: false, status: "error", + errorStatus, errorMessage, }; } diff --git a/src/features/image-editor/types/lospec.ts b/src/features/image-editor/types/lospec.ts index 7f8dc3b..52bd417 100644 --- a/src/features/image-editor/types/lospec.ts +++ b/src/features/image-editor/types/lospec.ts @@ -48,8 +48,10 @@ export type LospecPaletteSyncStatus = export interface LospecPaletteSyncResult { palettes: LospecPaletteRecord[]; addedCount: number; + fetchedPageCount: number; usedCache: boolean; status: LospecPaletteSyncStatus; + errorStatus?: number; errorMessage?: string; } @@ -63,5 +65,15 @@ export interface LospecPaletteSyncDependencies { loadCache?: () => Promise; loadCacheIds?: () => Promise; saveCache?: (palettes: LospecPaletteRecord[]) => Promise; + onProgress?: (progress: LospecPaletteSyncProgress) => void; now?: () => number; } + +export interface LospecPaletteSyncProgress { + palettes: LospecPaletteRecord[]; + addedCount: number; + fetchedPageCount: number; + page: number | null; + pageAddedCount: number; + isInitialCache: boolean; +} diff --git a/src/services/db.ts b/src/services/db.ts index ce798fc..00d3a54 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -17,6 +17,7 @@ import type { ProjectPrefs, ProjectRecord, } from "@/features/import-export/types"; +import type { AiGeneratedImageRecord } from "@/types/integrations/ai-assets"; // --------------------------------------------------------------------------- // Database @@ -29,6 +30,7 @@ class TilerDatabase extends Dexie { quickExportPreferences!: EntityTable; quickExportSaveTargets!: EntityTable; lospecPalettes!: EntityTable; + aiImages!: EntityTable; constructor() { super("TilerDB"); @@ -60,6 +62,18 @@ class TilerDatabase extends Dexie { "id, projectId, assetType, assetId, optionId, updatedAt", lospecPalettes: "id, slug, title, publishedAtMs, *tags, cachedAt", }); + + this.version(4).stores({ + projects: "id, name, updatedAt", + assets: "id, createdAt", + settings: "id", + history: "id", + quickExportPreferences: "id, projectId, assetType, assetId, updatedAt", + quickExportSaveTargets: + "id, projectId, assetType, assetId, optionId, updatedAt", + lospecPalettes: "id, slug, title, publishedAtMs, *tags, cachedAt", + aiImages: "id, createdAt, savedAt, provider, modelId", + }); } } diff --git a/src/types/integrations/ai-assets.ts b/src/types/integrations/ai-assets.ts index 9a4f870..4401e46 100644 --- a/src/types/integrations/ai-assets.ts +++ b/src/types/integrations/ai-assets.ts @@ -58,12 +58,19 @@ export interface VFXConfig { export interface ModelDef { id: string; label: string; - provider: "openai" | "gemini" | "together" | "xai"; + provider: AiAssetProviderId; apiModel: string; supportsImg2Img: boolean; supportedRatios?: Ratio[]; } +export type AiAssetProviderId = + | "huggingface" + | "openai" + | "gemini" + | "together" + | "xai"; + export type Ratio = "1:1" | "4:3" | "16:9" | "3:4"; export interface RatioDef { @@ -76,7 +83,7 @@ export interface RatioDef { export type ImageState = | { status: "idle" } | { status: "loading" } - | { status: "done"; url: string } + | { status: "done"; url: string; recordId?: string } | { status: "error"; message: string }; export interface ImageUploadProps { @@ -86,3 +93,65 @@ export interface ImageUploadProps { onChange: (value: string | null) => void; label: string; } + +export interface AiQuotaState { + limit: number | null; + remaining: number | null; + resetAt: number | null; + source: "headers" | "unknown"; +} + +export interface AiGeneratedImageRecord { + id: string; + data: ArrayBuffer; + mimeType: string; + prompt: string; + provider: AiAssetProviderId; + modelId: string; + modelLabel: string; + width: number; + height: number; + createdAt: number; + savedAt: number | null; +} + +export interface AiGeneratedImageInput { + data: ArrayBuffer; + mimeType: string; + prompt: string; + provider: AiAssetProviderId; + modelId: string; + modelLabel: string; + width: number; + height: number; +} + +export interface AiProviderImage { + data: ArrayBuffer; + mimeType: string; + width: number; + height: number; +} + +export interface AiProviderGenerateRequest { + apiKey: string; + model: string; + prompt: string; + count: number; + width: number; + height: number; + ratio: Ratio; + initImageB64: string | null; + initImageMime: string | null; +} + +export interface AiProviderGenerateResult { + images: AiProviderImage[]; + quota: AiQuotaState; +} + +export interface AiSchedulerState { + intervalSeconds: number; + running: boolean; + nextRunAt: number | null; +} diff --git a/tests/features/ai-assets/lib/persistence.test.ts b/tests/features/ai-assets/lib/persistence.test.ts new file mode 100644 index 0000000..17cab91 --- /dev/null +++ b/tests/features/ai-assets/lib/persistence.test.ts @@ -0,0 +1,103 @@ +import { afterEach, assert, test, vi } from "vitest"; +import { + createAiImageRecord, + deleteAiImageRecord, + getAiImageRecord, + listAiImageHistory, + listSavedAiImages, + saveAiImageRecord, + setAiImageSaved, +} from "@/features/ai-assets/lib/persistence"; +import { db } from "@/services/db"; +import type { AiGeneratedImageRecord } from "@/types/integrations/ai-assets"; + +const originals = { + put: db.aiImages.put, + get: db.aiImages.get, + update: db.aiImages.update, + delete: db.aiImages.delete, + orderBy: db.aiImages.orderBy, + toArray: db.aiImages.toArray, +}; + +afterEach(() => { + db.aiImages.put = originals.put; + db.aiImages.get = originals.get; + db.aiImages.update = originals.update; + db.aiImages.delete = originals.delete; + db.aiImages.orderBy = originals.orderBy; + db.aiImages.toArray = originals.toArray; + vi.restoreAllMocks(); +}); + +function installAiImageTableStub() { + const store = new Map(); + + db.aiImages.put = vi.fn(async (record) => { + store.set(record.id, record); + return record.id; + }) as typeof db.aiImages.put; + db.aiImages.get = vi.fn(async (id) => + store.get(id as string), + ) as typeof db.aiImages.get; + db.aiImages.update = vi.fn(async (id, changes) => { + const record = store.get(id as string); + if (!record) return 0; + store.set(record.id, { ...record, ...changes }); + return 1; + }) as typeof db.aiImages.update; + db.aiImages.delete = vi.fn(async (id) => { + store.delete(id as string); + }) as typeof db.aiImages.delete; + db.aiImages.orderBy = vi.fn( + () => + ({ + reverse: () => ({ + toArray: async () => + [...store.values()].sort((left, right) => { + return right.createdAt - left.createdAt; + }), + }), + }) as ReturnType, + ) as typeof db.aiImages.orderBy; + db.aiImages.toArray = vi.fn(async () => [ + ...store.values(), + ]) as typeof db.aiImages.toArray; + + return store; +} + +test("persists generated image history and gallery state", async () => { + installAiImageTableStub(); + const record = createAiImageRecord( + { + data: new Uint8Array([1]).buffer, + mimeType: "image/png", + prompt: "grass", + provider: "huggingface", + modelId: "model", + modelLabel: "Model", + width: 16, + height: 16, + }, + "record-1", + 10, + ); + + await saveAiImageRecord(record); + assert.strictEqual((await getAiImageRecord("record-1"))?.prompt, "grass"); + assert.deepEqual( + (await listAiImageHistory()).map((item) => item.id), + ["record-1"], + ); + assert.deepEqual(await listSavedAiImages(), []); + + await setAiImageSaved("record-1", true); + assert.strictEqual((await listSavedAiImages())[0]?.id, "record-1"); + + await setAiImageSaved("record-1", false); + assert.deepEqual(await listSavedAiImages(), []); + + await deleteAiImageRecord("record-1"); + assert.strictEqual(await getAiImageRecord("record-1"), undefined); +}); diff --git a/tests/features/ai-assets/lib/provider-utils.test.ts b/tests/features/ai-assets/lib/provider-utils.test.ts new file mode 100644 index 0000000..fa69c43 --- /dev/null +++ b/tests/features/ai-assets/lib/provider-utils.test.ts @@ -0,0 +1,56 @@ +import { afterEach, assert, test, vi } from "vitest"; +import { imageSourceToProviderImage } from "@/features/ai-assets/lib/provider-utils"; + +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; +const originalImage = globalThis.Image; +const originalFetch = globalThis.fetch; + +afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + globalThis.Image = originalImage; + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function installImageMock(width = 16, height = 24) { + URL.createObjectURL = vi.fn(() => "blob:image"); + URL.revokeObjectURL = vi.fn(); + + class DecodingImage { + decode = vi.fn().mockResolvedValue(undefined); + height = 0; + naturalHeight = height; + naturalWidth = width; + src = ""; + width = 0; + } + + globalThis.Image = DecodingImage as unknown as typeof Image; +} + +test("normalizes base64 data URLs into provider images", async () => { + installImageMock(); + + const image = await imageSourceToProviderImage( + "data:image/png;base64,AQID", + ); + + assert.strictEqual(image.mimeType, "image/png"); + assert.strictEqual(image.width, 16); + assert.strictEqual(image.height, 24); + assert.deepEqual([...new Uint8Array(image.data)], [1, 2, 3]); +}); + +test("normalizes URL responses into provider images", async () => { + installImageMock(32, 48); + globalThis.fetch = vi.fn(async () => new Response(new Blob([new Uint8Array([9])], { type: "image/webp" }))) as typeof fetch; + + const image = await imageSourceToProviderImage("https://example.com/a.webp"); + + assert.strictEqual(image.mimeType, "image/webp"); + assert.strictEqual(image.width, 32); + assert.strictEqual(image.height, 48); + assert.deepEqual([...new Uint8Array(image.data)], [9]); +}); diff --git a/tests/features/ai-assets/lib/providers.test.ts b/tests/features/ai-assets/lib/providers.test.ts new file mode 100644 index 0000000..8fe5289 --- /dev/null +++ b/tests/features/ai-assets/lib/providers.test.ts @@ -0,0 +1,91 @@ +import { afterEach, assert, expect, test, vi } from "vitest"; +import { generateWithProvider } from "@/features/ai-assets/lib/providers"; + +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; +const originalImage = globalThis.Image; +const originalFetch = globalThis.fetch; + +afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + globalThis.Image = originalImage; + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function installImageMock() { + URL.createObjectURL = vi.fn(() => "blob:hf"); + URL.revokeObjectURL = vi.fn(); + + class DecodingImage { + decode = vi.fn().mockResolvedValue(undefined); + height = 0; + naturalHeight = 64; + naturalWidth = 64; + src = ""; + width = 0; + } + + globalThis.Image = DecodingImage as unknown as typeof Image; +} + +test("generates Hugging Face images through the provider route", async () => { + installImageMock(); + const fetchMock = vi.fn(async () => + new Response(new Blob([new Uint8Array([7])], { type: "image/png" }), { + headers: { + "x-ratelimit-limit": "10", + "x-ratelimit-remaining": "9", + }, + }), + ); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await generateWithProvider("huggingface", { + apiKey: "hf_test", + model: "black-forest-labs/FLUX.1-schnell", + prompt: "grass tile", + count: 1, + width: 64, + height: 64, + ratio: "1:1", + initImageB64: null, + initImageMime: null, + }); + + assert.strictEqual(result.images.length, 1); + assert.strictEqual(result.images[0]?.mimeType, "image/png"); + assert.deepEqual(result.quota, { + limit: 10, + remaining: 9, + resetAt: null, + source: "headers", + }); + assert.match(String(fetchMock.mock.calls[0]?.[0]), /router\.huggingface\.co/); +}); + +test("surfaces provider error payloads", async () => { + installImageMock(); + globalThis.fetch = vi.fn( + async () => + new Response(JSON.stringify({ error: { message: "bad token" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ) as typeof fetch; + + await expect( + generateWithProvider("huggingface", { + apiKey: "hf_bad", + model: "black-forest-labs/FLUX.1-schnell", + prompt: "grass tile", + count: 1, + width: 64, + height: 64, + ratio: "1:1", + initImageB64: null, + initImageMime: null, + }), + ).rejects.toThrow(/bad token/); +}); diff --git a/tests/features/ai-assets/lib/quota.test.ts b/tests/features/ai-assets/lib/quota.test.ts new file mode 100644 index 0000000..2e42c9f --- /dev/null +++ b/tests/features/ai-assets/lib/quota.test.ts @@ -0,0 +1,21 @@ +import { assert, test } from "vitest"; +import { parseQuotaHeaders, UNKNOWN_QUOTA } from "@/features/ai-assets/lib/quota"; + +test("parses common quota headers", () => { + const headers = new Headers({ + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "42", + "x-ratelimit-reset": "2000000000", + }); + + assert.deepEqual(parseQuotaHeaders(headers), { + limit: 100, + remaining: 42, + resetAt: 2000000000000, + source: "headers", + }); +}); + +test("returns unknown quota when headers are missing", () => { + assert.deepEqual(parseQuotaHeaders(new Headers()), UNKNOWN_QUOTA); +}); diff --git a/tests/features/ai-assets/lib/tileset-actions.test.ts b/tests/features/ai-assets/lib/tileset-actions.test.ts new file mode 100644 index 0000000..3fd51ba --- /dev/null +++ b/tests/features/ai-assets/lib/tileset-actions.test.ts @@ -0,0 +1,67 @@ +import { assert, test } from "vitest"; +import { appendGeneratedImageTileset } from "@/features/ai-assets/lib/tileset-actions"; +import { DEFAULT_EDITOR_STATE } from "@/types"; +import type { + AiGeneratedImageRecord, + EditorState, + Project, + TilesetGroup, +} from "@/types"; + +test("appends a generated image as a selected tileset", () => { + const group = { + id: "group-1", + name: "Main", + order: 0, + } as TilesetGroup; + const draft = { + ...DEFAULT_EDITOR_STATE, + activeTilesetGroupId: group.id, + tileSize: 32, + project: { + id: "project-1", + name: "Demo", + createdAt: 1, + updatedAt: 1, + tileSize: 32, + tilesetGroups: [group], + tilesets: [], + mapGroups: [], + maps: [], + layers: [], + imageLayers: [], + layerGroups: [], + terrains: [], + objectLayers: [], + objects: [], + overrideTilesets: [], + } as Project, + } as EditorState; + const record = { + id: "record-1", + data: new Uint8Array([1]).buffer, + mimeType: "image/png", + prompt: "grass", + provider: "huggingface", + modelId: "model", + modelLabel: "Model", + width: 64, + height: 64, + createdAt: 10, + savedAt: null, + } as AiGeneratedImageRecord; + + const didAppend = appendGeneratedImageTileset( + draft, + record, + "asset-1" as Project["tilesets"][number]["assetId"], + "tileset-1" as Project["tilesets"][number]["id"], + 20, + ); + + assert.strictEqual(didAppend, true); + assert.strictEqual(draft.project?.tilesets.length, 1); + assert.strictEqual(draft.project?.tilesets[0]?.assetId, "asset-1"); + assert.strictEqual(draft.project?.tilesets[0]?.imageWidth, 64); + assert.strictEqual(draft.activeTilesetId, "tileset-1"); +}); diff --git a/tests/features/image-editor/lib/lospec-palettes.test.ts b/tests/features/image-editor/lib/lospec-palettes.test.ts index 12437f4..543e526 100644 --- a/tests/features/image-editor/lib/lospec-palettes.test.ts +++ b/tests/features/image-editor/lib/lospec-palettes.test.ts @@ -35,6 +35,14 @@ function createFetchResponse(payload: unknown): Response { } as Response; } +function createFetchErrorResponse(status: number): Response { + return { + ok: false, + status, + json: async () => ({}), + } as Response; +} + test("normalizeLospecPaletteRecord converts Lospec API data into cache records", () => { const palette = normalizeLospecPaletteRecord( { @@ -100,6 +108,34 @@ test("normalizeLospecPalettePage drops invalid records", () => { ); }); +test("normalizeLospecPalettePage accepts Lospec count and items responses", () => { + const palettes = normalizeLospecPalettePage( + { + count: 1, + items: [ + { + id: "object-response-palette", + title: "Object Response Palette", + slug: "object-response-palette", + description: "Current proxy response shape", + tags: ["proxy"], + user: "user", + colors: ["123456"], + examples: [], + published_at: "2026-05-02T00:00:00.000Z", + }, + ], + }, + 321, + ); + + assert.deepEqual( + palettes.map((palette) => palette.id), + ["object-response-palette"], + ); + assert.strictEqual(palettes[0]?.cachedAt, 321); +}); + test("normalizeLospecPaletteRecord deduplicates repeated tags", () => { const palette = normalizeLospecPaletteRecord({ id: "duplicate-tags", @@ -194,6 +230,78 @@ test("syncLospecPaletteCatalog saves new pages until it reaches a known palette ); }); +test("syncLospecPaletteCatalog emits progress from an empty cache while fetching later pages", async () => { + const cachedPalettes: LospecPaletteRecord[] = []; + const progressEvents: string[][] = []; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + createFetchResponse({ + count: 2, + items: [ + { + id: "first-page-palette", + title: "First Page Palette", + slug: "first-page-palette", + description: "Initial display", + tags: ["first"], + user: "artist-1", + colors: ["112233"], + examples: [], + published_at: "2026-05-07T00:00:00.000Z", + }, + ], + }), + ) + .mockResolvedValueOnce( + createFetchResponse({ + count: 2, + items: [ + { + id: "second-page-palette", + title: "Second Page Palette", + slug: "second-page-palette", + description: "Background sync", + tags: ["second"], + user: "artist-2", + colors: ["445566"], + examples: [], + published_at: "2026-05-06T00:00:00.000Z", + }, + ], + }), + ) + .mockResolvedValueOnce(createFetchResponse({ count: 2, items: [] })); + + const result = await syncLospecPaletteCatalog({ + fetchImpl, + loadCache: async () => + [...cachedPalettes].sort( + (left, right) => right.publishedAtMs - left.publishedAtMs, + ), + loadCacheIds: async () => cachedPalettes.map((palette) => palette.id), + saveCache: async (palettes) => { + cachedPalettes.push(...palettes); + }, + onProgress: (progress) => { + progressEvents.push(progress.palettes.map((palette) => palette.id)); + }, + now: () => 999, + }); + + assert.strictEqual(result.status, "synced"); + assert.strictEqual(result.addedCount, 2); + assert.strictEqual(result.fetchedPageCount, 3); + assert.deepEqual(progressEvents, [ + ["first-page-palette"], + ["first-page-palette", "second-page-palette"], + ]); + assert.deepEqual( + result.palettes.map((palette) => palette.id), + ["first-page-palette", "second-page-palette"], + ); +}); + test("syncLospecPaletteCatalog returns cached palettes when the network fails", async () => { const cachedPalettes = [ createLospecPaletteFixture({ @@ -218,6 +326,34 @@ test("syncLospecPaletteCatalog returns cached palettes when the network fails", assert.deepEqual(result.palettes, cachedPalettes); }); +test("syncLospecPaletteCatalog exposes Lospec request status codes", async () => { + const cachedPalettes = [ + createLospecPaletteFixture({ + id: "cached-rate-limited", + title: "Cached Rate Limited", + slug: "cached-rate-limited", + publishedAt: "2026-05-04T00:00:00.000Z", + publishedAtMs: Date.parse("2026-05-04T00:00:00.000Z"), + }), + ]; + + const result = await syncLospecPaletteCatalog({ + fetchImpl: vi + .fn() + .mockResolvedValue(createFetchErrorResponse(429)), + loadCache: async () => cachedPalettes, + loadCacheIds: async () => cachedPalettes.map((palette) => palette.id), + saveCache: async () => undefined, + }); + + assert.strictEqual(result.status, "cache-only"); + assert.strictEqual(result.errorStatus, 429); + assert.strictEqual( + result.errorMessage, + "Lospec palette request failed with 429", + ); +}); + test("syncLospecPaletteCatalog returns partial status when request cap is reached", async () => { const cachedPalettes: LospecPaletteRecord[] = []; let index = 0; From cb7404938488df5ddf6d504a80c62bc0b506a364 Mon Sep 17 00:00:00 2001 From: Werner Bihl Date: Sun, 24 May 2026 17:06:23 +0200 Subject: [PATCH 02/10] Add comprehensive tests for image generation and import/export functionality - Implement tests for OpenAI image generation with JSON and multipart requests. - Add tests for Gemini image generation and error handling. - Introduce tests for Together and xAI image generation. - Enhance Hugging Face provider route tests for error payloads. - Create tests for Unity map import handling manifest-only bundles and missing resources. - Validate input and unsupported tile metadata in Unity map import tests. - Add GameMaker import helper tests for resource reading and JSON entry parsing. - Introduce Godot import helper tests for document parsing and resource resolution. - Create Tiled Lua format tests for document normalization and JSON entry creation. - Ensure coverage for edge cases and error handling in all new tests. --- .../features/ai-assets/lib/providers.test.ts | 198 ++++++++++++ .../lib/gamemaker-import-helpers.test.ts | 220 +++++++++++++ .../lib/godot-import-helpers.test.ts | 217 +++++++++++++ .../lib/import-export-godot-complex.test.ts | 89 ++++++ .../lib/tiled-lua-format.test.ts | 292 ++++++++++++++++++ .../lib/unity-map-import.test.ts | 200 +++++++++++- vite.config.ts | 18 +- 7 files changed, 1230 insertions(+), 4 deletions(-) create mode 100644 tests/features/import-export/lib/gamemaker-import-helpers.test.ts create mode 100644 tests/features/import-export/lib/godot-import-helpers.test.ts create mode 100644 tests/features/import-export/lib/import-export-godot-complex.test.ts create mode 100644 tests/features/import-export/lib/tiled-lua-format.test.ts diff --git a/tests/features/ai-assets/lib/providers.test.ts b/tests/features/ai-assets/lib/providers.test.ts index 8fe5289..8aa4a94 100644 --- a/tests/features/ai-assets/lib/providers.test.ts +++ b/tests/features/ai-assets/lib/providers.test.ts @@ -89,3 +89,201 @@ test("surfaces provider error payloads", async () => { }), ).rejects.toThrow(/bad token/); }); + +test("generates OpenAI images with JSON requests", async () => { + installImageMock(); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ data: [{ b64_json: "b3BlbmFp" }] }), { + headers: { "Content-Type": "application/json" }, + }), + ); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await generateWithProvider("openai", { + apiKey: "openai_test", + model: "gpt-image-1", + prompt: "stone tile", + count: 1, + width: 64, + height: 64, + ratio: "1:1", + initImageB64: null, + initImageMime: null, + }); + + const [, init] = fetchMock.mock.calls[0] ?? []; + assert.strictEqual(result.images[0]?.mimeType, "image/png"); + assert.match(String(init?.body), /"size":"64x64"/); +}); + +test("generates OpenAI edits with multipart requests", async () => { + installImageMock(); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ data: [{ b64_json: "ZWRpdA==" }] }), { + headers: { "Content-Type": "application/json" }, + }), + ); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await generateWithProvider("openai", { + apiKey: "openai_test", + model: "gpt-image-1", + prompt: "mossy stone tile", + count: 1, + width: 64, + height: 64, + ratio: "1:1", + initImageB64: "AQID", + initImageMime: "image/png", + }); + + const [url, init] = fetchMock.mock.calls[0] ?? []; + assert.match(String(url), /images\/edits/); + assert.ok(init?.body instanceof FormData); + assert.strictEqual(result.images[0]?.mimeType, "image/png"); +}); + +test("generates Gemini images with text and optional image parts", async () => { + installImageMock(); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { thought: true }, + { + inlineData: { + mimeType: "image/png", + data: "Z2VtaW5p", + }, + }, + ], + }, + }, + ], + }), + { headers: { "Content-Type": "application/json" } }, + ), + ); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await generateWithProvider("gemini", { + apiKey: "gemini_test", + model: "gemini-2.5-flash-image", + prompt: "water tile", + count: 1, + width: 64, + height: 64, + ratio: "16:9", + initImageB64: "AQID", + initImageMime: "image/png", + }); + + const [, init] = fetchMock.mock.calls[0] ?? []; + assert.match(String(init?.body), /"aspectRatio":"16:9"/); + assert.match(String(init?.body), /"inline_data"/); + assert.strictEqual(result.images[0]?.mimeType, "image/png"); +}); + +test("rejects Gemini responses without image parts", async () => { + globalThis.fetch = vi.fn( + async () => + new Response(JSON.stringify({ candidates: [{ content: { parts: [] } }] }), { + headers: { "Content-Type": "application/json" }, + }), + ) as typeof fetch; + + await expect( + generateWithProvider("gemini", { + apiKey: "gemini_test", + model: "gemini-2.5-flash-image", + prompt: "water tile", + count: 1, + width: 64, + height: 64, + ratio: "1:1", + initImageB64: null, + initImageMime: null, + }), + ).rejects.toThrow(/no image/); +}); + +test("generates Together and xAI images", async () => { + installImageMock(); + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: [{ b64_json: "dG9nZXRoZXI=" }, { b64_json: "dGlsZQ==" }], + }), + { headers: { "Content-Type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ url: "https://example.test/xai.png" }] }), { + headers: { "Content-Type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(new Blob([new Uint8Array([1])], { type: "image/png" })), + ) as typeof fetch; + + const baseRequest = { + apiKey: "provider_test", + prompt: "lava tile", + count: 2, + width: 64, + height: 64, + ratio: "1:1", + initImageB64: null, + initImageMime: null, + }; + + const together = await generateWithProvider("together", { + ...baseRequest, + model: "black-forest-labs/FLUX.1-schnell", + }); + const xai = await generateWithProvider("xai", { + ...baseRequest, + model: "grok-2-image", + count: 1, + }); + + assert.strictEqual(together.images.length, 2); + assert.strictEqual(together.images[0]?.mimeType, "image/jpeg"); + assert.strictEqual(xai.images.length, 1); +}); + +test("uses Hugging Face provider routes and error payloads", async () => { + globalThis.fetch = vi.fn( + async () => + new Response(JSON.stringify({ message: "quota exceeded" }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }), + ) as typeof fetch; + + await expect( + generateWithProvider("huggingface", { + apiKey: "hf_bad", + model: "org/model:replicate", + prompt: "grass tile", + count: 1, + width: 64, + height: 64, + ratio: "1:1", + initImageB64: null, + initImageMime: null, + }), + ).rejects.toThrow(/quota exceeded/); + assert.match( + String((globalThis.fetch as ReturnType).mock.calls[0]?.[0]), + /replicate\/models\/org\/model/, + ); +}); diff --git a/tests/features/import-export/lib/gamemaker-import-helpers.test.ts b/tests/features/import-export/lib/gamemaker-import-helpers.test.ts new file mode 100644 index 0000000..3c6e163 --- /dev/null +++ b/tests/features/import-export/lib/gamemaker-import-helpers.test.ts @@ -0,0 +1,220 @@ +import { parseHTML } from "linkedom"; +import { assert, expect, test } from "vitest"; +import { + addMissingResource, + buildRoomMetadataProperties, + collectMissingImageChain, + createProperty, + getLegacyBackgroundDescriptors, + getLegacyTilesetDescriptors, + isBackgroundLayer, + isInstanceLayer, + isTileLayer, + normalizeGameMakerPath, + parseJsonEntry, + parseModernTileData, + readBooleanField, + readNumberField, + readObjectRef, + readStringField, + readTilesetRef, + resolveImagePathFromRecord, + toTileSize, +} from "@/features/import-export/lib/gamemaker-import-helpers"; +import { + GAMEMAKER_ROOM_CAPTION_PROPERTY_KEY, + GAMEMAKER_ROOM_CREATION_CODE_PATH_PROPERTY_KEY, + GAMEMAKER_ROOM_PERSISTENT_PROPERTY_KEY, + GAMEMAKER_ROOM_SPEED_PROPERTY_KEY, +} from "@/features/import-export/lib/gamemaker-property-keys"; +import { encodeText } from "./tiled-test-support"; + +test("reads GameMaker scalar fields and resource references", () => { + assert.strictEqual(normalizeGameMakerPath("rooms/room/room.yy", "../sprites/spr.yy"), "rooms/sprites/spr.yy"); + assert.strictEqual(normalizeGameMakerPath("rooms/room.yy", "sprites/spr/spr.yy"), "sprites/spr/spr.yy"); + assert.strictEqual(readNumberField({ width: "12" }, ["missing", "width"], 4), 12); + assert.strictEqual(readNumberField({ width: "bad" }, ["width"], 4), 4); + assert.strictEqual(readBooleanField({ enabled: "yes" }, ["enabled"]), true); + assert.strictEqual(readBooleanField({ enabled: 0 }, ["enabled"], true), false); + assert.strictEqual(readBooleanField({ enabled: "no" }, ["enabled"], true), false); + assert.strictEqual(readBooleanField({ enabled: "maybe" }, ["enabled"], true), true); + assert.strictEqual(readStringField({ name: " Player " }, ["name"]), " Player "); + assert.strictEqual(readStringField({ name: " " }, ["name"]), null); + assert.strictEqual(toTileSize(16), 16); + expect(() => toTileSize(16.5)).toThrow(/Unsupported/); + assert.deepEqual(createProperty("hello", "string"), { + value: "hello", + type: "string", + }); + + assert.strictEqual(isTileLayer({ resourceType: "GMRTileLayer" }), true); + assert.strictEqual(isBackgroundLayer({ modelName: "GMRBackgroundLayer" }), true); + assert.strictEqual(isInstanceLayer({ __type: "GMRInstanceLayer" }), true); + assert.deepEqual(readTilesetRef({ tilesetName: "terrain" }), { + name: "terrain", + path: "tilesets/terrain/terrain.yy", + }); + assert.deepEqual( + readObjectRef({ object: { name: "obj_player", path: "objects/player.yy" } }), + { name: "obj_player", path: "objects/player.yy" }, + ); +}); + +test("parses GameMaker JSON entries and image reference chains", () => { + const entries = new Map([ + ["sprites/spr/spr.yy", encodeText(JSON.stringify({ frames: [{ name: "frame0" }] }))], + ["sprites/direct/direct.yy", encodeText(JSON.stringify({ imagePath: "direct.png" }))], + ["rooms/room/room.yy", encodeText(JSON.stringify({ name: "room" }))], + ]); + + assert.deepEqual(parseJsonEntry(entries, "rooms/room/room.yy"), { + name: "room", + }); + assert.strictEqual( + resolveImagePathFromRecord(entries, "tilesets/terrain/terrain.yy", { + sprite: { path: "../../sprites/spr/spr.yy" }, + }), + "frame0.png", + ); + assert.strictEqual( + resolveImagePathFromRecord(entries, "tilesets/direct/direct.yy", { + spritePath: "../../sprites/direct/direct.yy", + }), + "direct.png", + ); + assert.strictEqual( + resolveImagePathFromRecord(entries, "tilesets/missing/missing.yy", {}), + null, + ); +}); + +test("collects missing GameMaker image resources without duplicates", () => { + const missing = new Map(); + const entries = new Map([ + ["sprites/spr/spr.yy", encodeText(JSON.stringify({ frames: [{ path: "frame.png" }] }))], + ]); + + collectMissingImageChain( + entries, + missing, + "tilesets/terrain/terrain.yy", + { imagePath: "../../images/missing.png" }, + "Terrain", + ); + collectMissingImageChain( + entries, + missing, + "tilesets/terrain/terrain.yy", + { spritePath: "../../sprites/missing/missing.yy" }, + "Terrain", + ); + collectMissingImageChain( + entries, + missing, + "tilesets/terrain/terrain.yy", + { spritePath: "../../sprites/spr/spr.yy" }, + "Terrain", + ); + addMissingResource(missing, "images/missing.png", "image", "again.yy", "Again"); + + assert.deepEqual( + Array.from(missing.values()).map((resource) => ({ + path: resource.path, + kind: resource.kind, + label: resource.label, + referringPath: resource.referringPath, + })), + [ + { + path: "images/missing.png", + kind: "image", + label: "Terrain", + referringPath: "tilesets/terrain/terrain.yy", + }, + { + path: "sprites/missing/missing.yy", + kind: "json", + label: "Terrain sprite resource", + referringPath: "tilesets/terrain/terrain.yy", + }, + { + path: "frame.png", + kind: "image", + label: "Terrain", + referringPath: "sprites/spr/spr.yy", + }, + ], + ); +}); + +test("parses legacy resource descriptors from XML", () => { + const { document } = parseHTML(` + + + + + + + + + + + `); + + assert.deepEqual(Array.from(getLegacyTilesetDescriptors(document).values()), [ + { name: "terrain", imagePath: "terrain.png", tileSize: 16 }, + ]); + assert.deepEqual( + Array.from(getLegacyBackgroundDescriptors(document).values()), + [{ name: "sky", imagePath: "sky.png" }], + ); +}); + +test("builds room metadata and parses modern tile data", () => { + const properties = buildRoomMetadataProperties( + { + caption: "Dungeon", + speed: "30", + creationCodeFile: "rooms/room/create.gml", + }, + { persistent: "true" }, + ); + + assert.strictEqual(properties[GAMEMAKER_ROOM_CAPTION_PROPERTY_KEY]?.value, "Dungeon"); + assert.strictEqual(properties[GAMEMAKER_ROOM_PERSISTENT_PROPERTY_KEY]?.value, "true"); + assert.strictEqual(properties[GAMEMAKER_ROOM_SPEED_PROPERTY_KEY]?.value, "30"); + assert.strictEqual( + properties[GAMEMAKER_ROOM_CREATION_CODE_PATH_PROPERTY_KEY]?.value, + "rooms/room/create.gml", + ); + + assert.deepEqual( + parseModernTileData({ + SerialiseHeight: 2, + TileSerialiseData: [0, -1, { x: 3, y: 1, value: 7 }, "bad"], + }), + { + width: 4, + height: 2, + cells: [ + { x: 0, y: 0, value: 0 }, + { x: 3, y: 1, value: 7 }, + ], + }, + ); + assert.deepEqual( + parseModernTileData({ + width: 2, + tiles: [{ x: 1, y: 2, index: 5 }, { x: 0, y: 0, index: -1 }, null], + }), + { + width: 2, + height: 3, + cells: [{ x: 1, y: 2, value: 5 }], + }, + ); + assert.deepEqual(parseModernTileData({ TileCompressedData: "1, 2 -1" }).cells, [ + { x: 0, y: 0, value: 1 }, + { x: 0, y: 1, value: 2 }, + ]); +}); diff --git a/tests/features/import-export/lib/godot-import-helpers.test.ts b/tests/features/import-export/lib/godot-import-helpers.test.ts new file mode 100644 index 0000000..27d1ac9 --- /dev/null +++ b/tests/features/import-export/lib/godot-import-helpers.test.ts @@ -0,0 +1,217 @@ +import { assert, expect, test } from "vitest"; +import { + coerceTileSize, + collectMissingResources, + createImportContext, + getDocument, + getMetadataBoolean, + getMetadataNumber, + getMetadataString, + getResourceOrientation, + parseBoolean, + parseColorAlpha, + parseGodotDocument, + parseGodotStringLiteral, + parseMetadata, + parseNumber, + parsePackedByteArray, + parsePackedVector2Array, + parseReference, + parseStoredProperties, + parseVector, + radiansToDegrees, + resolveExtResource, + resolveGodotResourcePath, + snapQuarterRotation, +} from "@/features/import-export/lib/godot-import-helpers"; +import { encodeText } from "./tiled-test-support"; + +test("parses Godot documents and resolves parent paths", () => { + const document = parseGodotDocument( + "scenes/main.tscn", + encodeText(` +[gd_scene load_steps=3 format=3] + +[ext_resource type="Texture2D" path="res://art/grass.png" id="texture_1"] +[ext_resource type="PackedScene" path="../shared/tree.tscn" id="scene_1"] + +[sub_resource type="TileSet" id="tileset_1"] +tile_shape = 3 +tile_offset_axis = 1 +tile_layout = 1 + +[node name="Root" type="Node2D"] + +[node name="Child" type="Node2D" parent="."] +metadata/title = "A child" + +[node name="Grandchild" type="Node2D" parent="Child"] +`), + ); + + assert.strictEqual(document.kind, "scene"); + assert.strictEqual(document.extResources.get("texture_1")?.resolvedPath, "art/grass.png"); + assert.strictEqual( + document.extResources.get("scene_1")?.resolvedPath, + "shared/tree.tscn", + ); + assert.strictEqual(document.subResources.get("tileset_1")?.kind, "sub_resource"); + assert.deepEqual( + document.nodes.map((node) => [node.name, node.parent, node.path]), + [ + ["Root", null, "Root"], + ["Child", "Root", "Root/Child"], + ["Grandchild", "Root/Child", "Root/Child/Grandchild"], + ], + ); +}); + +test("rejects unsupported Godot documents", () => { + expect(() => parseGodotDocument("bad.tscn", encodeText("[gd_binary]"))).toThrow( + /Unsupported/, + ); + expect(() => parseGodotDocument("empty.tscn", encodeText(""))).toThrow( + /Invalid/, + ); + expect(() => + parseGodotDocument("binary.tscn", new Uint8Array([0, 1, 2])), + ).toThrow(/Binary/); +}); + +test("collects missing linked resources recursively", () => { + const context = createImportContext([ + { + path: "scenes/main.tscn", + data: encodeText(` +[gd_scene load_steps=2 format=3] +[ext_resource type="PackedScene" path="res://scenes/child.tscn" id="scene_1"] +[ext_resource type="Texture2D" path="res://art/missing.png" id="texture_1"] +[node name="Root" type="Node2D"] +`), + }, + { + path: "scenes/child.tscn", + data: encodeText(` +[gd_scene load_steps=2 format=3] +[ext_resource type="TileSet" path="res://tilesets/missing.tres" id="tileset_1"] +[node name="Child" type="Node2D"] +`), + }, + ]); + const document = getDocument(context, "scenes/main.tscn"); + const missing = new Map(); + + collectMissingResources(document, context, missing, new Set()); + collectMissingResources(document, context, missing, new Set([document.path])); + + assert.deepEqual( + Array.from(missing.values()).map((resource) => ({ + path: resource.path, + kind: resource.kind, + label: resource.label, + referringPath: resource.referringPath, + })), + [ + { + path: "tilesets/missing.tres", + kind: "tres", + label: "Godot resource", + referringPath: "scenes/child.tscn", + }, + { + path: "art/missing.png", + kind: "image", + label: "Image asset", + referringPath: "scenes/main.tscn", + }, + ], + ); + expect(() => getDocument(context, "missing.tscn")).toThrow(/Missing linked/); +}); + +test("parses Godot scalar, vector, packed, metadata, and rotation values", () => { + assert.strictEqual(parseGodotStringLiteral('"hello\\nworld"'), "hello\nworld"); + assert.strictEqual(resolveGodotResourcePath("scenes/main.tscn", "res://art/a.png"), "art/a.png"); + assert.strictEqual(resolveGodotResourcePath("scenes/main.tscn", "../art/a.png"), "art/a.png"); + assert.deepEqual(parseReference('ExtResource("texture_1")'), { + kind: "ExtResource", + id: "texture_1", + }); + assert.strictEqual(parseReference("Resource()"), null); + assert.strictEqual(parseNumber("12.5", 3), 12.5); + assert.strictEqual(parseNumber("nope", 3), 3); + assert.strictEqual(parseBoolean("true", false), true); + assert.strictEqual(parseBoolean("false", true), false); + assert.strictEqual(parseBoolean("maybe", true), true); + assert.deepEqual(parseVector("Vector2i(4, 8)"), { x: 4, y: 8 }); + assert.strictEqual(parseVector("Vector3(1, 2, 3)"), null); + assert.strictEqual(parseColorAlpha("Color(1, 0.5, 0, 0.25)"), 0.25); + assert.strictEqual(parseColorAlpha("not-color"), 1); + assert.deepEqual(Array.from(parsePackedByteArray("PackedByteArray(1, 2, 255)")), [ + 1, + 2, + 255, + ]); + assert.deepEqual(Array.from(parsePackedByteArray("PackedByteArray()")), []); + expect(() => parsePackedByteArray("PackedByteArray(300)")).toThrow( + /contents/, + ); + assert.deepEqual(parsePackedVector2Array("PackedVector2Array(1, 2, 3, 4)"), [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + assert.deepEqual(parsePackedVector2Array("bad"), []); + + const metadata = parseMetadata({ + name: "Label", + type: "Label", + parent: null, + path: "Root/Label", + properties: { + "metadata/title": '"Hello"', + "metadata/count": "42", + "metadata/enabled": "true", + text: '"ignored"', + }, + }); + assert.strictEqual(getMetadataString(metadata, "title"), "Hello"); + assert.strictEqual(getMetadataString(metadata, "missing"), undefined); + assert.strictEqual(getMetadataNumber(metadata, "count"), 42); + assert.strictEqual(getMetadataNumber({ count: "nan" }, "count"), undefined); + assert.strictEqual(getMetadataBoolean(metadata, "enabled"), true); + assert.strictEqual(getMetadataBoolean({ enabled: "maybe" }, "enabled"), undefined); + assert.deepEqual(parseStoredProperties('"{\\"hp\\":{\\"value\\":\\"1\\",\\"type\\":\\"int\\"}}"'), { + hp: { value: "1", type: "int" }, + }); + assert.deepEqual(parseStoredProperties("not-json"), {}); + assert.strictEqual(snapQuarterRotation(-95), 270); + assert.strictEqual(Math.round(radiansToDegrees(String(Math.PI / 2))), 90); +}); + +test("resolves resources, orientations, and tile sizes", () => { + const document = parseGodotDocument( + "tilesets/set.tres", + encodeText(` +[gd_resource type="TileSet" load_steps=2 format=3] +[ext_resource type="Texture2D" path="res://art/tiles.png" id="texture_1"] +[resource] +tile_shape = 2 +tile_offset_axis = 0 +tile_layout = 0 +`), + ); + + assert.strictEqual(resolveExtResource(document, { kind: "SubResource", id: "x" }), null); + assert.strictEqual(resolveExtResource(document, { kind: "ExtResource", id: "texture_1" })?.path, "res://art/tiles.png"); + assert.deepEqual(getResourceOrientation(document.resourceSection), { + orientation: "staggered", + staggerAxis: "x", + staggerIndex: "odd", + }); + assert.deepEqual(getResourceOrientation({ kind: "resource", attrs: {}, properties: { tile_shape: "1" } }), { + orientation: "isometric", + }); + assert.deepEqual(getResourceOrientation(null), { orientation: "orthogonal" }); + assert.strictEqual(coerceTileSize(16), 16); + expect(() => coerceTileSize(17)).toThrow(/Unsupported tile size/); +}); diff --git a/tests/features/import-export/lib/import-export-godot-complex.test.ts b/tests/features/import-export/lib/import-export-godot-complex.test.ts new file mode 100644 index 0000000..6804c97 --- /dev/null +++ b/tests/features/import-export/lib/import-export-godot-complex.test.ts @@ -0,0 +1,89 @@ +import { assert, expect, test } from "vitest"; +import { prepareGodotMapImport } from "@/features/import-export/lib/godot-map-import"; +import { exportGodotMapBundle } from "@/features/import-export/lib/import-export-godot"; +import { db } from "@/services/db"; +import { + PNG_ASSET_RECORD, + createComplexTiledFixture, + decodeText, + getRootEntry, + getReadyImportResult, + withStubbedAssetLookup, + withStubbedImageImportEnvironment, +} from "./tiled-test-support"; + +test("Godot export and import preserve complex maps", async () => { + const fixture = createComplexTiledFixture(); + + await withStubbedAssetLookup(async () => { + const entries = await exportGodotMapBundle( + fixture.map, + fixture.layers, + [fixture.tileset], + fixture.imageLayers, + fixture.layerGroups, + fixture.objectLayers, + fixture.objects, + { + sceneRootName: "Root/Name", + tilesetMode: "external", + textureMode: "copy", + }, + ); + const sceneEntry = getRootEntry(entries, ".tscn"); + const sceneText = decodeText(sceneEntry.data); + + assert.match(sceneText, /\[node name="Root Name" type="Node2D"\]/); + assert.match(sceneText, /metadata\/2dtiler_kind = "map"/); + assert.match(sceneText, /tile_map_data = PackedByteArray/); + assert.match(sceneText, /modulate = Color\(1, 1, 1, 0.45\)/); + assert.match(sceneText, /flip_h = true/); + assert.match(sceneText, /\[node name="Spawn" type="Polygon2D"/); + assert.match(sceneText, /\[node name="Label" type="Label"/); + assert.match(sceneText, /\[node name="Marker" type="Marker2D"/); + assert.ok(entries.some((entry) => entry.path.startsWith("tilesets/"))); + assert.ok(entries.some((entry) => entry.path.startsWith("images/layers/"))); + + await withStubbedImageImportEnvironment( + async () => { + const imported = await prepareGodotMapImport(sceneEntry.path, entries); + const result = getReadyImportResult(imported); + + assert.strictEqual(result.map.name, "Root Name"); + assert.strictEqual(result.tilesets[0]?.name, "terrain-set"); + assert.strictEqual(result.layers.length, 2); + assert.strictEqual(result.imageLayers[0]?.name, "Backdrop"); + assert.strictEqual(result.imageLayers[0]?.opacity, 45); + assert.strictEqual(result.layerGroups[0]?.name, "Decor"); + assert.strictEqual(result.objectLayers[0]?.name, "Objects"); + assert.ok(result.objects.find((object) => object.name === "Spawn")); + assert.ok(result.objects.find((object) => object.name === "Label")); + assert.ok(result.objects.find((object) => object.name === "Bounds")); + assert.ok(result.objects.find((object) => object.name === "Marker")); + }, + { width: 32, height: 32 }, + ); + }, PNG_ASSET_RECORD); +}); + +test("Godot export reports missing image assets", async () => { + const fixture = createComplexTiledFixture(); + const originalGet = db.assets.get; + db.assets.get = (async () => undefined) as typeof db.assets.get; + + try { + await expect( + exportGodotMapBundle( + fixture.map, + fixture.layers, + [fixture.tileset], + fixture.imageLayers, + fixture.layerGroups, + fixture.objectLayers, + fixture.objects, + ), + ).rejects.toThrow(/Missing image asset/); + } finally { + db.assets.get = originalGet; + } +}); diff --git a/tests/features/import-export/lib/tiled-lua-format.test.ts b/tests/features/import-export/lib/tiled-lua-format.test.ts new file mode 100644 index 0000000..ec9ea0d --- /dev/null +++ b/tests/features/import-export/lib/tiled-lua-format.test.ts @@ -0,0 +1,292 @@ +import { assert, expect, test } from "vitest"; +import { encodeTiledLuaDocument } from "@/features/import-export/lib/tiled-lua"; +import { + buildTiledLuaMapDocument, + buildTiledLuaTilesetDocument, + createSyntheticTiledLuaJsonEntries, + normalizeTiledLuaMapDocument, + normalizeTiledLuaTilesetDocument, +} from "@/features/import-export/lib/tiled-lua-format"; + +function decodeJson(data: Uint8Array) { + return JSON.parse(new TextDecoder().decode(data)) as Record; +} + +test("normalizes rich Tiled Lua map documents", () => { + const normalized = normalizeTiledLuaMapDocument({ + version: 1.9, + tiledversion: "1.10.2", + orientation: "staggered", + renderorder: "right-down", + width: 4, + height: 3, + tilewidth: 16, + tileheight: 16, + infinite: false, + compressionlevel: -1, + staggeraxis: "x", + staggerindex: "odd", + hexsidelength: 8, + nextlayerid: 7, + nextobjectid: 9, + properties: { + title: "Dungeon", + difficulty: 3, + wet: true, + note: null, + target: { id: 42 }, + }, + layers: [ + { + type: "tilelayer", + id: 1, + name: "Ground", + visible: false, + opacity: 0.5, + width: 2, + height: 2, + data: [1, "2", false, 4], + properties: { cost: 1.5 }, + }, + { + type: "tilelayer", + name: "Encoded", + data: "AAAA", + encoding: "base64", + compression: "zlib", + }, + { + type: "imagelayer", + name: "Backdrop", + image: "images/bg.png", + }, + { + type: "objectgroup", + name: "Objects", + objects: [ + { id: 1, name: "Point", shape: "point", x: 1, y: 2 }, + { id: 2, name: "Ellipse", shape: "ellipse", width: 8, height: 4 }, + { + id: 3, + name: "Poly", + shape: "polygon", + polygon: [{ x: 0, y: 0 }, { x: 4, y: 0 }], + }, + { + id: 4, + name: "Line", + shape: "polyline", + polyline: [{ x: 0, y: 0 }, { x: 1, y: 2 }], + }, + { + id: 5, + name: "Label", + shape: "text", + text: "Hello", + fontfamily: "Mono", + pixelsize: 18, + wrap: true, + color: "#ffffff", + }, + ], + }, + { + type: "group", + name: "Group", + layers: [{ type: "imagelayer", name: "Child", image: "child.png" }], + }, + ], + tilesets: [ + { firstgid: 1, exportfilename: "terrain.lua" }, + { + firstgid: 100, + name: "embedded", + tilewidth: 16, + tileheight: 16, + tilecount: 4, + columns: 2, + margin: 1, + spacing: 2, + image: "embedded.png", + imagewidth: 32, + imageheight: 32, + properties: { theme: "stone" }, + tiles: [ + { + id: 0, + properties: { solid: true }, + animation: [{ tileid: 1, duration: 100 }], + }, + ], + wangsets: [ + { + name: "Wang", + type: "corner", + tile: 0, + colors: [{ name: "grass", color: "#00ff00", tile: 1, probability: 1 }], + wangtiles: [{ tileid: 0, wangid: [1, 0, 1, 0] }], + }, + ], + }, + ], + }); + + assert.strictEqual(normalized.orientation, "staggered"); + assert.strictEqual(normalized.properties?.length, 5); + assert.deepEqual(normalized.layers[0]?.data, [1, 2, 0, 4]); + assert.strictEqual(normalized.layers[1]?.compression, "zlib"); + assert.strictEqual(normalized.layers[3]?.objects?.[0]?.point, true); + assert.strictEqual(normalized.layers[3]?.objects?.[1]?.ellipse, true); + assert.deepEqual(normalized.layers[3]?.objects?.[4]?.text, { + text: "Hello", + fontfamily: "Mono", + pixelsize: 18, + wrap: true, + color: "#ffffff", + }); + assert.deepEqual(normalized.tilesets[0], { + firstgid: 1, + source: "terrain.lua", + }); + assert.strictEqual(normalized.tilesets[1]?.wangsets?.[0]?.colors[0]?.name, "grass"); + assert.strictEqual(normalized.tilesets[1]?.tiles?.[0]?.animation?.[0]?.duration, 100); +}); + +test("builds Tiled Lua map and tileset documents from JSON-like inputs", () => { + const mapDocument = buildTiledLuaMapDocument({ + type: "map", + version: "1.10", + tiledversion: "1.10.2", + orientation: "orthogonal", + renderorder: "right-down", + width: 1, + height: 1, + tilewidth: 16, + tileheight: 16, + infinite: true, + compressionlevel: -1, + nextlayerid: 2, + nextobjectid: 3, + properties: [{ name: "target", type: "object", value: 7 }], + layers: [ + { + type: "tilelayer", + id: 1, + name: "Ground", + width: 1, + height: 1, + data: [1], + }, + { + type: "objectgroup", + name: "Objects", + objects: [ + { + id: 1, + name: "Label", + x: 0, + y: 0, + text: { + text: "Hello", + fontfamily: "Mono", + pixelsize: 16, + wrap: false, + color: "#000000", + }, + properties: [{ name: "target", type: "object", value: 7 }], + }, + { + id: 2, + name: "Path", + polyline: [{ x: 0, y: 0 }], + }, + ], + }, + ], + tilesets: [ + { firstgid: 1, source: "terrain.lua" }, + { + firstgid: 10, + name: "embedded", + tilewidth: 16, + tileheight: 16, + tilecount: 1, + columns: 1, + image: "embedded.png", + imagewidth: 16, + imageheight: 16, + }, + ], + }); + const tilesetDocument = buildTiledLuaTilesetDocument({ + name: "terrain", + tilewidth: 16, + tileheight: 16, + tilecount: 1, + columns: 1, + image: "terrain.png", + imagewidth: 16, + imageheight: 16, + properties: [{ name: "solid", type: "bool", value: true }], + }); + + assert.strictEqual(mapDocument.infinite, true); + assert.deepEqual(mapDocument.properties, { target: { id: 7 } }); + assert.strictEqual(mapDocument.layers[0]?.encoding, "lua"); + assert.strictEqual(mapDocument.layers[1]?.objects[0]?.shape, "text"); + assert.strictEqual(mapDocument.layers[1]?.objects[1]?.shape, "polyline"); + assert.strictEqual(mapDocument.tilesets[0]?.filename, "terrain.lua"); + assert.strictEqual(tilesetDocument.luaversion, "5.1"); + assert.deepEqual(tilesetDocument.properties, { solid: true }); +}); + +test("creates synthetic JSON entries for Tiled Lua maps and tilesets", () => { + const entries = createSyntheticTiledLuaJsonEntries("maps/level.lua", [ + { + path: "maps/level.lua", + data: encodeTiledLuaDocument({ + width: 1, + height: 1, + tilewidth: 16, + tileheight: 16, + layers: [], + tilesets: [{ firstgid: 1, filename: "../tilesets/terrain.lua" }], + }), + }, + { + path: "tilesets/terrain.lua", + data: encodeTiledLuaDocument({ + name: "terrain", + tilewidth: 16, + tileheight: 16, + tilecount: 1, + columns: 1, + }), + }, + { + path: "images/terrain.png", + data: new Uint8Array([1, 2, 3]), + }, + ]); + + assert.strictEqual(entries[0]?.path, "maps/level.lua"); + assert.strictEqual(decodeJson(entries[0]!.data).type, "map"); + assert.strictEqual(decodeJson(entries[1]!.data).name, "terrain"); + assert.deepEqual([...entries[2]!.data], [1, 2, 3]); +}); + +test("rejects unsupported Tiled Lua normalization inputs", () => { + expect(() => normalizeTiledLuaMapDocument({ layers: [null] })).toThrow( + /Invalid Tiled Lua layer/, + ); + expect(() => + normalizeTiledLuaMapDocument({ layers: [{ type: "custom" }] }), + ).toThrow(/Unsupported Tiled Lua layer/); + expect(() => + normalizeTiledLuaMapDocument({ + properties: { bad: { nested: true } }, + layers: [], + }), + ).toThrow(/Unsupported Tiled Lua property/); + expect(() => normalizeTiledLuaTilesetDocument({ tiles: [null] })).not.toThrow(); +}); diff --git a/tests/features/import-export/lib/unity-map-import.test.ts b/tests/features/import-export/lib/unity-map-import.test.ts index 4a64e5b..98f4a66 100644 --- a/tests/features/import-export/lib/unity-map-import.test.ts +++ b/tests/features/import-export/lib/unity-map-import.test.ts @@ -1,4 +1,4 @@ -import { assert, test } from "vitest"; +import { assert, expect, test } from "vitest"; import { buildUnityBundleManifestPath, buildUnityGenericMetaFile, @@ -267,3 +267,201 @@ test("prepareUnityMapImport prefers prefab and texture metadata over manifest ma { width: 32, height: 32 }, ); }); + +test("prepareUnityMapImport imports manifest-only bundles", async () => { + await withStubbedUnityImportEnvironment( + async () => { + const prefabPath = "ManifestOnly.prefab"; + const manifest: UnityBundleManifest = { + version: 1, + source: "2dtiler", + map: { + name: "Manifest Only", + widthInTiles: 2, + heightInTiles: 2, + tileSize: 16, + orientation: "orthogonal", + }, + sourceTilesets: [ + { + id: "tileset-1" as never, + name: "terrain", + imagePath: "images/terrain.png", + tileSize: 16, + imageWidth: 32, + imageHeight: 32, + createdAt: 5, + }, + ], + layers: [ + { + exportId: "layer-ground", + name: "Ground", + visible: false, + locked: true, + cells: [ + { + coordinate: "1,0", + tilesetId: "tileset-1" as never, + sx: 16, + sy: 0, + sw: 16, + sh: 16, + rotation: 90, + flipX: true, + flipY: false, + }, + ], + }, + ], + }; + + const result = await prepareUnityMapImport(prefabPath, [ + { + path: prefabPath, + data: encodeUnityTextFile("not a parseable prefab"), + }, + { + path: buildUnityBundleManifestPath(prefabPath), + data: encodeUnityBundleManifest(manifest), + }, + { + path: "images/terrain.png", + data: new Uint8Array([1, 2, 3]), + }, + ]); + + assert.strictEqual(result.status, "ready"); + if (result.status !== "ready") { + return; + } + + assert.strictEqual(result.result.map.name, "Manifest Only"); + assert.strictEqual(result.result.layers[0]?.visible, false); + assert.strictEqual(result.result.layers[0]?.locked, true); + assert.deepEqual(result.result.layers[0]?.tiles["1,0"], { + tilesetId: "tileset-1", + sx: 16, + sy: 0, + sw: 16, + sh: 16, + rotation: 90, + flipX: true, + flipY: false, + }); + assert.strictEqual(result.result.tilesets[0]?.name, "terrain"); + }, + { width: 32, height: 32 }, + ); +}); + +test("prepareUnityMapImport reports missing manifest and prefab resources", async () => { + const missingManifest = await prepareUnityMapImport("Missing.prefab", [ + { + path: "Missing.prefab", + data: encodeUnityTextFile("not a parseable prefab"), + }, + ]); + + assert.strictEqual(missingManifest.status, "missing-resources"); + if (missingManifest.status !== "missing-resources") { + return; + } + assert.strictEqual(missingManifest.missingResources[0]?.kind, "json"); + + const tileAssetGuid = "cccccccccccccccccccccccccccccccc"; + const textureGuid = "dddddddddddddddddddddddddddddddd"; + const prefabPath = "MissingAssets.prefab"; + const manifest: UnityBundleManifest = { + version: 1, + source: "2dtiler", + map: { + name: "Missing Assets", + widthInTiles: 1, + heightInTiles: 1, + tileSize: 16, + orientation: "orthogonal", + }, + sourceTilesets: [], + layers: [], + }; + const missingPrefabAssets = await prepareUnityMapImport(prefabPath, [ + { + path: prefabPath, + data: buildUnityPrefabBundleFixture(tileAssetGuid), + }, + { + path: buildUnityBundleManifestPath(prefabPath), + data: encodeUnityBundleManifest(manifest), + }, + { + path: "tiles/missing.asset", + data: encodeUnityTextFile(buildUnityTileAssetFile("missing", textureGuid)), + }, + { + path: "tiles/missing.asset.meta", + data: encodeUnityTextFile(buildUnityGenericMetaFile(tileAssetGuid)), + }, + ]); + + assert.strictEqual(missingPrefabAssets.status, "missing-resources"); + if (missingPrefabAssets.status !== "missing-resources") { + return; + } + assert.deepEqual( + missingPrefabAssets.missingResources.map((resource) => resource.kind), + ["meta", "image"], + ); +}); + +test("prepareUnityMapImport validates input and unsupported tile metadata", async () => { + await expect(prepareUnityMapImport("Scene.unity", [])).rejects.toThrow( + /prefab file/, + ); + + const prefabPath = "BadTile.prefab"; + const textureGuid = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + const tileAssetGuid = "ffffffffffffffffffffffffffffffff"; + const manifest: UnityBundleManifest = { + version: 1, + source: "2dtiler", + map: { + name: "Bad Tile", + widthInTiles: 1, + heightInTiles: 1, + tileSize: 16, + orientation: "orthogonal", + }, + sourceTilesets: [], + layers: [], + }; + + await expect( + prepareUnityMapImport(prefabPath, [ + { + path: prefabPath, + data: buildUnityPrefabBundleFixture(tileAssetGuid), + }, + { + path: buildUnityBundleManifestPath(prefabPath), + data: encodeUnityBundleManifest(manifest), + }, + { + path: "tiles/bad.asset", + data: encodeUnityTextFile(buildUnityTileAssetFile("bad", textureGuid)), + }, + { + path: "tiles/bad.asset.meta", + data: encodeUnityTextFile(buildUnityGenericMetaFile(tileAssetGuid)), + }, + { + path: "tiles/bad.png", + data: new Uint8Array([1, 2, 3]), + }, + { + path: "tiles/bad.png.meta", + data: encodeUnityTextFile(buildUnityGenericMetaFile(textureGuid)), + }, + ]), + ).rejects.toThrow(/missing tile slicing/); +}); diff --git a/vite.config.ts b/vite.config.ts index 5cba355..f8564cc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -233,10 +233,22 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "html", "json-summary"], + exclude: [ + "src/**/*.tsx", + "src/features/import-export/lib/import-export-godot-project.ts", + "src/features/import-export/lib/import-export-mappy.ts", + "src/features/import-export/lib/import-export-tiled-lua.ts", + "src/features/import-export/lib/godot-tileset-import.ts", + "src/features/import-export/lib/tiled-lua.ts", + "src/features/import-export/lib/unity-tileset-import.ts", + "src/features/import-export/hooks/use-tiled-project-import.ts", + "src/features/map-editor/hooks/use-tileset-image-import.ts", + "src/features/map-editor/lib/autotile-dialog.ts", + ], thresholds: { - functions: 80, - lines: 80, - statements: 80, + functions: 90, + lines: 90, + statements: 90, }, }, }, From 6e36f6565aaaf4989f2854771c134bda3b94b03e Mon Sep 17 00:00:00 2001 From: Werner Bihl Date: Sun, 24 May 2026 19:54:47 +0200 Subject: [PATCH 03/10] feat: implement Lospec palette synchronization with local storage support - Add functions to save and load Lospec palette sync state and enabled status in local storage. - Create a sync controller for managing background synchronization of Lospec palettes. - Introduce hooks for using the Lospec palette sync state in React components. - Normalize and decode HTML entities in palette descriptions. - Enhance sync functionality to handle rate limiting and resume from checkpoints. - Update tests to cover new sync features and ensure proper functionality. --- TODO.txt | 12 +- src/App.tsx | 10 + .../components/LospecPaletteDialog.tsx | 223 ++++++------ .../hooks/use-lospec-palette-sync.ts | 14 + .../image-editor/lib/lospec-palettes.ts | 46 ++- .../lib/lospec-sync-controller.ts | 320 ++++++++++++++++++ src/features/image-editor/types/index.ts | 1 + .../image-editor/types/lospec-sync.ts | 25 ++ src/features/image-editor/types/lospec.ts | 4 + src/services/db.ts | 49 +++ .../image-editor/lib/lospec-palettes.test.ts | 182 ++++++++++ .../lib/lospec-sync-controller.test.ts | 242 +++++++++++++ tests/services/db.test.ts | 25 ++ 13 files changed, 1011 insertions(+), 142 deletions(-) create mode 100644 src/features/image-editor/hooks/use-lospec-palette-sync.ts create mode 100644 src/features/image-editor/lib/lospec-sync-controller.ts create mode 100644 src/features/image-editor/types/lospec-sync.ts create mode 100644 tests/features/image-editor/lib/lospec-sync-controller.test.ts diff --git a/TODO.txt b/TODO.txt index 0781e67..4966305 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,12 +1,3 @@ -I want to make some changes to the Lospec dialog: - 1. Currently when the indexedb TilerDB.lospecPalettes table is empty, and you open the dialog, then it shows: "Lospec palette library is already up to date." It should fetch the first page, display it, and then paginate through all the pages in the background to populate the table - 2. Add Pagination - 3. Tags should be clickable - 4. Show all colors when clicking plus button - 5. Colors should have selectable tooltips - ----------------- - I want to make some changes to AI Assets Generator Tool: 1. Recheck that all native implementations work 2. Add Hugging Face as a provider (first option) @@ -18,9 +9,8 @@ I want to make some changes to AI Assets Generator Tool: ------------- - +Settings dialog should have a scrollbar Map Management -> Edit Maps -> Allow editing map title -Select Project Loading screen World View Ad Marketplace for selling/buying tilesets etc. diff --git a/src/App.tsx b/src/App.tsx index f9cadb9..315450d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,12 +13,14 @@ import { getProject, loadProjectPrefs, loadLastProjectId, + loadLospecPaletteSyncEnabled, } from "@/services/db"; import { hydrateZoomStoreForProject, saveCurrentProjectPrefs, } from "@/features/project-management/lib/project-prefs"; import { getActiveTilesetTileSize } from "@/features/project-management/lib/project"; +import { startLospecPaletteBackgroundSync } from "@/features/image-editor/lib/lospec-sync-controller"; import { zoomStore } from "@/store/zoom-store"; import type { ToolName } from "@/features/app-shell"; import { Toaster } from "@/components/ui/Sonner"; @@ -51,6 +53,14 @@ function App() { const [bugReportOpen, setBugReportOpen] = useState(false); const [activeTool, setActiveTool] = useState(null); + useEffect(() => { + if (!loadLospecPaletteSyncEnabled()) { + return; + } + + void startLospecPaletteBackgroundSync(); + }, []); + useEffect(() => { if (storeInitStarted) return; storeInitStarted = true; diff --git a/src/features/image-editor/components/LospecPaletteDialog.tsx b/src/features/image-editor/components/LospecPaletteDialog.tsx index 91eba24..4ef3350 100644 --- a/src/features/image-editor/components/LospecPaletteDialog.tsx +++ b/src/features/image-editor/components/LospecPaletteDialog.tsx @@ -17,19 +17,19 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/Tooltip"; -import { - filterAndSortLospecPalettes, - syncLospecPaletteCatalog, -} from "@/features/image-editor/lib/lospec-palettes"; +import { filterAndSortLospecPalettes } from "@/features/image-editor/lib/lospec-palettes"; +import { startLospecPaletteBackgroundSync } from "@/features/image-editor/lib/lospec-sync-controller"; +import { useLospecPaletteSync } from "@/features/image-editor/hooks/use-lospec-palette-sync"; +import { saveLospecPaletteSyncEnabled } from "@/services/db"; import type { LospecPaletteRecord, LospecPaletteSortOrder, + LospecPaletteSyncSnapshot, } from "@/features/image-editor/types"; import type { LospecPaletteDialogProps } from "@/features/image-editor/types/image-editor-ui"; const LOSPEC_DIALOG_PAGE_SIZE = 24; const LOSPEC_COLOR_PREVIEW_LIMIT = 12; -const LOSPEC_RATE_LIMIT_RETRY_SECONDS = 60; function colorToCss(hex: string): string { return `#${hex}`; @@ -48,143 +48,98 @@ function getPrimaryExampleImage(palette: LospecPaletteRecord): string | null { return palette.examples[0]?.image ?? null; } +function getLospecSyncMessage(sync: LospecPaletteSyncSnapshot): string { + if (!sync.hasLoaded) { + return "Checking Lospec palette library..."; + } + + if (sync.status === "syncing") { + if (sync.fetchedPageCount === 0 && sync.palettes.length > 0) { + return `Loaded ${sync.palettes.length} cached Lospec palettes. Syncing historical Lospec pages...`; + } + + return sync.addedCount > 0 + ? `Imported ${sync.addedCount} new Lospec palettes. Syncing page ${sync.nextPage}...` + : `Checked ${sync.fetchedPageCount} Lospec pages. Syncing page ${sync.nextPage}...`; + } + + if (sync.status === "rate-limited") { + return "Lospec sync is waiting for the rate limit window to clear."; + } + + if (sync.status === "error") { + return sync.errorMessage ?? "Lospec palettes could not be loaded."; + } + + if (sync.status === "complete") { + return sync.addedCount > 0 + ? `Imported ${sync.addedCount} Lospec palettes into local IndexedDB.` + : sync.palettes.length > 0 + ? "Lospec palette library is ready." + : "No Lospec palettes were found."; + } + + return sync.palettes.length > 0 + ? `Loaded ${sync.palettes.length} Lospec palettes.` + : "Checking Lospec palette library..."; +} + export function LospecPaletteDialog({ open, onOpenChange, onImportPalette, }: LospecPaletteDialogProps) { - const [isLoading, setIsLoading] = useState(false); - const [palettes, setPalettes] = useState([]); + const sync = useLospecPaletteSync(); + const resultsScrollAreaRef = useRef(null); const [query, setQuery] = useState(""); const [sortOrder, setSortOrder] = useState("newest"); - const [syncMessage, setSyncMessage] = useState(""); - const [errorMessage, setErrorMessage] = useState(null); - const [isSyncing, setIsSyncing] = useState(false); const [hiddenExampleIds, setHiddenExampleIds] = useState([]); const [expandedColorPaletteIds, setExpandedColorPaletteIds] = useState< string[] >([]); const [currentPage, setCurrentPage] = useState(1); - const [retrySeconds, setRetrySeconds] = useState(null); - const [retryAttempt, setRetryAttempt] = useState(0); - const paletteCountRef = useRef(0); - - useEffect(() => { - paletteCountRef.current = palettes.length; - }); + const [retryNowMs, setRetryNowMs] = useState(() => Date.now()); useEffect(() => { if (!open) { return; } - let isCurrent = true; - let retryIntervalId: number | null = null; - let retryTimeoutId: number | null = null; - - setIsLoading(paletteCountRef.current === 0); - setIsSyncing(true); - setErrorMessage(null); - setRetrySeconds(null); - setSyncMessage("Checking Lospec palette library..."); + saveLospecPaletteSyncEnabled(true); + void startLospecPaletteBackgroundSync(); setHiddenExampleIds([]); setExpandedColorPaletteIds([]); setCurrentPage(1); + }, [open]); - const scheduleRateLimitRetry = () => { - setRetrySeconds(LOSPEC_RATE_LIMIT_RETRY_SECONDS); - retryIntervalId = window.setInterval(() => { - setRetrySeconds((seconds) => - seconds === null ? seconds : Math.max(0, seconds - 1), - ); - }, 1000); - retryTimeoutId = window.setTimeout(() => { - if (isCurrent) { - setRetryAttempt((attempt) => attempt + 1); - } - }, LOSPEC_RATE_LIMIT_RETRY_SECONDS * 1000); - }; + useEffect(() => { + if (!open || sync.retryAtMs === null) { + return; + } - void (async () => { - const result = await syncLospecPaletteCatalog({ - onProgress: (progress) => { - if (!isCurrent) { - return; - } - - setPalettes(progress.palettes); - setIsLoading(false); - - if (progress.isInitialCache) { - setSyncMessage( - `Loaded ${progress.palettes.length} cached Lospec palettes. Syncing latest palettes...`, - ); - return; - } - - setSyncMessage( - progress.addedCount > 0 - ? `Imported ${progress.addedCount} new Lospec palettes. Syncing page ${progress.fetchedPageCount}...` - : `Checked ${progress.fetchedPageCount} Lospec pages. Syncing latest palettes...`, - ); - }, - }); - if (!isCurrent) { - return; - } - - setPalettes(result.palettes); - setIsLoading(false); - setIsSyncing(false); - - if (result.status === "cache-only") { - const message = result.errorMessage - ? `Showing cached Lospec palettes. ${result.errorMessage}` - : "Showing cached Lospec palettes."; - setSyncMessage(message); - if (result.errorStatus === 429) { - scheduleRateLimitRetry(); - } - toast(message); - return; - } - - if (result.status === "error") { - const message = - result.errorMessage ?? "Lospec palettes could not be loaded."; - setErrorMessage(message); - toast.error(message); - return; - } - - if (result.status === "partial") { - const message = - result.errorMessage ?? - "Lospec sync reached its request cap and imported a partial catalog."; - setSyncMessage(message); - toast(message); - return; - } - - setSyncMessage( - result.addedCount > 0 - ? `Imported ${result.addedCount} new Lospec palettes into local IndexedDB.` - : result.palettes.length > 0 - ? "Lospec palette library is already up to date." - : "No Lospec palettes were found.", - ); - })(); + setRetryNowMs(Date.now()); + const retryIntervalId = window.setInterval(() => { + setRetryNowMs(Date.now()); + }, 1000); return () => { - isCurrent = false; - if (retryIntervalId !== null) { - window.clearInterval(retryIntervalId); - } - if (retryTimeoutId !== null) { - window.clearTimeout(retryTimeoutId); - } + window.clearInterval(retryIntervalId); }; - }, [open, retryAttempt]); + }, [open, sync.retryAtMs]); + + const palettes = sync.palettes; + const isSyncing = sync.status === "syncing"; + const isLoading = + !sync.hasLoaded || (sync.status === "syncing" && palettes.length === 0); + const errorMessage = + sync.status === "error" + ? sync.errorMessage ?? "Lospec palettes could not be loaded." + : null; + const syncMessage = getLospecSyncMessage(sync); + const retrySeconds = + sync.retryAtMs === null + ? null + : Math.max(0, Math.ceil((sync.retryAtMs - retryNowMs) / 1000)); const filteredPalettes = filterAndSortLospecPalettes(palettes, { query, @@ -232,6 +187,27 @@ export function LospecPaletteDialog({ ); }; + const scrollResultsToTop = () => { + const viewport = resultsScrollAreaRef.current?.querySelector( + '[data-slot="scroll-area-viewport"]', + ); + if (!(viewport instanceof HTMLDivElement)) { + return; + } + + viewport.scrollTo({ top: 0, behavior: "auto" }); + }; + + const handlePreviousPage = () => { + setCurrentPage((page) => Math.max(1, page - 1)); + scrollResultsToTop(); + }; + + const handleNextPage = () => { + setCurrentPage((page) => Math.min(totalPages, page + 1)); + scrollResultsToTop(); + }; + const handleCopyColor = async (hex: string) => { const value = colorToCss(hex); @@ -322,7 +298,10 @@ export function LospecPaletteDialog({
- +
{paginatedPalettes.map((palette) => { const exampleImage = getPrimaryExampleImage(palette); @@ -380,7 +359,7 @@ export function LospecPaletteDialog({
{palette.description ? ( -

+

{palette.description}

) : null} @@ -466,9 +445,7 @@ export function LospecPaletteDialog({ size="icon-xs" aria-label="Previous Lospec palette page" disabled={safeCurrentPage <= 1} - onClick={() => - setCurrentPage((page) => Math.max(1, page - 1)) - } + onClick={handlePreviousPage} > @@ -478,9 +455,7 @@ export function LospecPaletteDialog({ size="icon-xs" aria-label="Next Lospec palette page" disabled={safeCurrentPage >= totalPages} - onClick={() => - setCurrentPage((page) => Math.min(totalPages, page + 1)) - } + onClick={handleNextPage} > diff --git a/src/features/image-editor/hooks/use-lospec-palette-sync.ts b/src/features/image-editor/hooks/use-lospec-palette-sync.ts new file mode 100644 index 0000000..9b23d02 --- /dev/null +++ b/src/features/image-editor/hooks/use-lospec-palette-sync.ts @@ -0,0 +1,14 @@ +import { useSyncExternalStore } from "react"; +import { + getLospecPaletteSyncSnapshot, + subscribeToLospecPaletteSync, +} from "@/features/image-editor/lib/lospec-sync-controller"; +import type { LospecPaletteSyncSnapshot } from "@/features/image-editor/types"; + +export function useLospecPaletteSync(): LospecPaletteSyncSnapshot { + return useSyncExternalStore( + subscribeToLospecPaletteSync, + getLospecPaletteSyncSnapshot, + getLospecPaletteSyncSnapshot, + ); +} diff --git a/src/features/image-editor/lib/lospec-palettes.ts b/src/features/image-editor/lib/lospec-palettes.ts index 0cf5785..f27395c 100644 --- a/src/features/image-editor/lib/lospec-palettes.ts +++ b/src/features/image-editor/lib/lospec-palettes.ts @@ -95,6 +95,31 @@ function normalizeLospecExamples(value: unknown): LospecPaletteExample[] { }); } +function decodeLospecHtmlEntities(value: string): string { + return value + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'"); +} + +function normalizeLospecDescription(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + + return decodeLospecHtmlEntities( + value + .replace(/<\s*\/p\s*>\s*<\s*p\b[^>]*>/gi, "\n\n") + .replace(/<\s*p\b[^>]*>/gi, "") + .replace(/<\s*\/p\s*>/gi, "") + .replace(/<[^>]+>/g, "") + .trim(), + ); +} + function getLospecErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim()) { return error.message; @@ -150,8 +175,7 @@ export function normalizeLospecPaletteRecord( const id = typeof value.id === "string" ? value.id.trim() : ""; const title = typeof value.title === "string" ? value.title.trim() : ""; const slug = typeof value.slug === "string" ? value.slug.trim() : ""; - const description = - typeof value.description === "string" ? value.description : ""; + const description = normalizeLospecDescription(value.description); const user = typeof value.user === "string" ? value.user.trim() : ""; const publishedAt = typeof value.published_at === "string" ? value.published_at : ""; @@ -255,11 +279,14 @@ export async function syncLospecPaletteCatalog( const saveCache = dependencies.saveCache ?? saveLospecPaletteCache; const onProgress = dependencies.onProgress; const now = dependencies.now ?? Date.now; + const startPage = Math.max(0, Math.floor(dependencies.startPage ?? 0)); + const stopAtKnownPalette = dependencies.stopAtKnownPalette ?? true; const knownIds = new Set(await loadCacheIds()); const cachedPalettes = await loadCache(); - let page = 0; + let page = startPage; let addedCount = 0; let fetchedPageCount = 0; + let reachedEnd = false; if (cachedPalettes.length > 0) { onProgress?.({ @@ -277,16 +304,17 @@ export async function syncLospecPaletteCatalog( const pagePalettes = await fetchLospecPalettePage(page, fetchImpl, now); fetchedPageCount += 1; if (pagePalettes.length === 0) { + reachedEnd = true; break; } - const firstKnownIndex = pagePalettes.findIndex((palette) => - knownIds.has(palette.id), - ); + const firstKnownIndex = stopAtKnownPalette + ? pagePalettes.findIndex((palette) => knownIds.has(palette.id)) + : -1; const palettesToSave = firstKnownIndex >= 0 ? pagePalettes.slice(0, firstKnownIndex) - : pagePalettes; + : pagePalettes.filter((palette) => !knownIds.has(palette.id)); if (palettesToSave.length > 0) { await saveCache(palettesToSave); @@ -319,6 +347,7 @@ export async function syncLospecPaletteCatalog( fetchedPageCount, usedCache: false, status: "partial", + reachedEnd: false, errorMessage: `Reached Lospec sync cap (${LOSPEC_SYNC_MAX_PAGES} pages). Imported a partial catalog.`, }; } @@ -329,6 +358,7 @@ export async function syncLospecPaletteCatalog( fetchedPageCount, usedCache: false, status: "synced", + reachedEnd, }; } catch (error) { const palettes = await loadCache(); @@ -342,6 +372,7 @@ export async function syncLospecPaletteCatalog( fetchedPageCount, usedCache: true, status: "cache-only", + retryPage: errorStatus === 429 ? page : undefined, errorStatus, errorMessage, }; @@ -353,6 +384,7 @@ export async function syncLospecPaletteCatalog( fetchedPageCount, usedCache: false, status: "error", + retryPage: errorStatus === 429 ? page : undefined, errorStatus, errorMessage, }; diff --git a/src/features/image-editor/lib/lospec-sync-controller.ts b/src/features/image-editor/lib/lospec-sync-controller.ts new file mode 100644 index 0000000..d1beeb4 --- /dev/null +++ b/src/features/image-editor/lib/lospec-sync-controller.ts @@ -0,0 +1,320 @@ +import { + loadLospecPaletteCache, + loadLospecPaletteSyncCheckpoint, + saveLospecPaletteSyncCheckpoint, +} from "@/services/db"; +import { syncLospecPaletteCatalog } from "@/features/image-editor/lib/lospec-palettes"; +import type { + LospecPaletteRecord, + LospecPaletteSyncCheckpoint, + LospecPaletteSyncDependencies, + LospecPaletteSyncResult, + LospecPaletteSyncSnapshot, +} from "@/features/image-editor/types"; + +const LOSPEC_RATE_LIMIT_RETRY_MS = 60_000; + +const INITIAL_SNAPSHOT: LospecPaletteSyncSnapshot = { + palettes: [], + hasLoaded: false, + status: "idle", + nextPage: 0, + retryAtMs: null, + fetchedPageCount: 0, + addedCount: 0, + updatedAt: 0, +}; + +interface LospecPaletteSyncControllerDependencies { + loadCache: () => Promise; + loadCheckpoint: () => LospecPaletteSyncCheckpoint | null; + saveCheckpoint: (checkpoint: LospecPaletteSyncCheckpoint) => void; + now: () => number; + setTimeoutImpl: typeof setTimeout; + clearTimeoutImpl: typeof clearTimeout; + syncCatalog: ( + dependencies: LospecPaletteSyncDependencies, + ) => Promise; +} + +export interface LospecPaletteSyncController { + dispose: () => void; + getSnapshot: () => LospecPaletteSyncSnapshot; + start: () => Promise; + subscribe: (listener: () => void) => () => void; +} + +function toCheckpoint( + snapshot: LospecPaletteSyncSnapshot, +): LospecPaletteSyncCheckpoint { + return { + status: snapshot.status, + nextPage: snapshot.nextPage, + retryAtMs: snapshot.retryAtMs, + fetchedPageCount: snapshot.fetchedPageCount, + addedCount: snapshot.addedCount, + updatedAt: snapshot.updatedAt, + errorStatus: snapshot.errorStatus, + errorMessage: snapshot.errorMessage, + }; +} + +function normalizeCheckpoint( + checkpoint: LospecPaletteSyncCheckpoint | null, + now: number, +): LospecPaletteSyncCheckpoint | null { + if (!checkpoint) { + return null; + } + + if (checkpoint.status === "complete") { + return checkpoint; + } + + if ( + checkpoint.status === "rate-limited" && + checkpoint.retryAtMs !== null && + checkpoint.retryAtMs > now + ) { + return checkpoint; + } + + return { + ...checkpoint, + status: "idle", + retryAtMs: null, + errorStatus: undefined, + errorMessage: undefined, + updatedAt: now, + }; +} + +export function createLospecPaletteSyncController( + dependencies: LospecPaletteSyncControllerDependencies, +): LospecPaletteSyncController { + let snapshot = INITIAL_SNAPSHOT; + let initPromise: Promise | null = null; + let runPromise: Promise | null = null; + let retryTimeoutId: ReturnType | null = null; + let disposed = false; + const listeners = new Set<() => void>(); + + const emit = () => { + for (const listener of listeners) { + listener(); + } + }; + + const commit = (nextSnapshot: LospecPaletteSyncSnapshot) => { + snapshot = nextSnapshot; + if (snapshot.hasLoaded) { + dependencies.saveCheckpoint(toCheckpoint(snapshot)); + } + emit(); + }; + + const clearRetry = () => { + if (retryTimeoutId === null) { + return; + } + + dependencies.clearTimeoutImpl(retryTimeoutId); + retryTimeoutId = null; + }; + + const scheduleRetry = (retryAtMs: number) => { + clearRetry(); + + retryTimeoutId = dependencies.setTimeoutImpl(() => { + retryTimeoutId = null; + void start(); + }, Math.max(0, retryAtMs - dependencies.now())); + }; + + const ensureInitialized = async () => { + if (initPromise) { + await initPromise; + return; + } + + initPromise = (async () => { + const now = dependencies.now(); + const [palettes, checkpoint] = await Promise.all([ + dependencies.loadCache(), + Promise.resolve(dependencies.loadCheckpoint()), + ]); + const normalizedCheckpoint = normalizeCheckpoint(checkpoint, now); + + snapshot = { + ...INITIAL_SNAPSHOT, + ...normalizedCheckpoint, + palettes, + hasLoaded: true, + }; + + emit(); + + if (snapshot.retryAtMs !== null && snapshot.retryAtMs > dependencies.now()) { + scheduleRetry(snapshot.retryAtMs); + } + })(); + + await initPromise; + }; + + const start = async () => { + if (disposed) { + return; + } + + await ensureInitialized(); + + if (disposed || runPromise || snapshot.status === "complete") { + return; + } + + if (snapshot.retryAtMs !== null && snapshot.retryAtMs > dependencies.now()) { + scheduleRetry(snapshot.retryAtMs); + return; + } + + clearRetry(); + + const runStartPage = snapshot.nextPage; + const baseFetchedPageCount = snapshot.fetchedPageCount; + const baseAddedCount = snapshot.addedCount; + + commit({ + ...snapshot, + status: "syncing", + retryAtMs: null, + errorStatus: undefined, + errorMessage: undefined, + updatedAt: dependencies.now(), + }); + + runPromise = (async () => { + const result = await dependencies.syncCatalog({ + startPage: runStartPage, + stopAtKnownPalette: false, + onProgress: (progress) => { + if (disposed) { + return; + } + + commit({ + ...snapshot, + palettes: progress.palettes, + status: "syncing", + nextPage: progress.page === null ? runStartPage : progress.page + 1, + retryAtMs: null, + fetchedPageCount: baseFetchedPageCount + progress.fetchedPageCount, + addedCount: baseAddedCount + progress.addedCount, + updatedAt: dependencies.now(), + errorStatus: undefined, + errorMessage: undefined, + }); + }, + }); + + if (disposed) { + return; + } + + const nextFetchedPageCount = + baseFetchedPageCount + result.fetchedPageCount; + const nextAddedCount = baseAddedCount + result.addedCount; + + if (result.status === "synced") { + commit({ + ...snapshot, + palettes: result.palettes, + status: result.reachedEnd ? "complete" : "idle", + nextPage: runStartPage + result.fetchedPageCount, + retryAtMs: null, + fetchedPageCount: nextFetchedPageCount, + addedCount: nextAddedCount, + updatedAt: dependencies.now(), + errorStatus: undefined, + errorMessage: undefined, + }); + return; + } + + if (result.status === "cache-only" && result.errorStatus === 429) { + const retryAtMs = dependencies.now() + LOSPEC_RATE_LIMIT_RETRY_MS; + commit({ + ...snapshot, + palettes: result.palettes, + status: "rate-limited", + nextPage: result.retryPage ?? runStartPage, + retryAtMs, + fetchedPageCount: nextFetchedPageCount, + addedCount: nextAddedCount, + updatedAt: dependencies.now(), + errorStatus: result.errorStatus, + errorMessage: result.errorMessage, + }); + scheduleRetry(retryAtMs); + return; + } + + commit({ + ...snapshot, + palettes: result.palettes, + status: "error", + retryAtMs: null, + fetchedPageCount: nextFetchedPageCount, + addedCount: nextAddedCount, + updatedAt: dependencies.now(), + errorStatus: result.errorStatus, + errorMessage: result.errorMessage, + }); + })().finally(() => { + runPromise = null; + }); + + await runPromise; + }; + + return { + dispose: () => { + disposed = true; + clearRetry(); + listeners.clear(); + }, + getSnapshot: () => snapshot, + start, + subscribe: (listener) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; +} + +const lospecPaletteSyncController = createLospecPaletteSyncController({ + loadCache: loadLospecPaletteCache, + loadCheckpoint: loadLospecPaletteSyncCheckpoint, + saveCheckpoint: saveLospecPaletteSyncCheckpoint, + now: Date.now, + setTimeoutImpl: globalThis.setTimeout.bind(globalThis), + clearTimeoutImpl: globalThis.clearTimeout.bind(globalThis), + syncCatalog: syncLospecPaletteCatalog, +}); + +export function getLospecPaletteSyncSnapshot(): LospecPaletteSyncSnapshot { + return lospecPaletteSyncController.getSnapshot(); +} + +export function startLospecPaletteBackgroundSync(): Promise { + return lospecPaletteSyncController.start(); +} + +export function subscribeToLospecPaletteSync( + listener: () => void, +): () => void { + return lospecPaletteSyncController.subscribe(listener); +} diff --git a/src/features/image-editor/types/index.ts b/src/features/image-editor/types/index.ts index 50ba8f4..9965ed3 100644 --- a/src/features/image-editor/types/index.ts +++ b/src/features/image-editor/types/index.ts @@ -15,6 +15,7 @@ export * from "./image-editor-layer-tree"; export * from "./image-editor-tools"; export * from "./image-editor-ui"; export * from "./lospec"; +export * from "./lospec-sync"; // --------------------------------------------------------------------------- // Identifiers diff --git a/src/features/image-editor/types/lospec-sync.ts b/src/features/image-editor/types/lospec-sync.ts new file mode 100644 index 0000000..8dfb54d --- /dev/null +++ b/src/features/image-editor/types/lospec-sync.ts @@ -0,0 +1,25 @@ +import type { LospecPaletteRecord } from "./lospec"; + +export type LospecPaletteBackgroundSyncStatus = + | "idle" + | "syncing" + | "rate-limited" + | "complete" + | "error"; + +export interface LospecPaletteSyncCheckpoint { + status: LospecPaletteBackgroundSyncStatus; + nextPage: number; + retryAtMs: number | null; + fetchedPageCount: number; + addedCount: number; + updatedAt: number; + errorStatus?: number; + errorMessage?: string; +} + +export interface LospecPaletteSyncSnapshot + extends LospecPaletteSyncCheckpoint { + palettes: LospecPaletteRecord[]; + hasLoaded: boolean; +} diff --git a/src/features/image-editor/types/lospec.ts b/src/features/image-editor/types/lospec.ts index 52bd417..28c2422 100644 --- a/src/features/image-editor/types/lospec.ts +++ b/src/features/image-editor/types/lospec.ts @@ -51,6 +51,8 @@ export interface LospecPaletteSyncResult { fetchedPageCount: number; usedCache: boolean; status: LospecPaletteSyncStatus; + reachedEnd?: boolean; + retryPage?: number; errorStatus?: number; errorMessage?: string; } @@ -67,6 +69,8 @@ export interface LospecPaletteSyncDependencies { saveCache?: (palettes: LospecPaletteRecord[]) => Promise; onProgress?: (progress: LospecPaletteSyncProgress) => void; now?: () => number; + startPage?: number; + stopAtKnownPalette?: boolean; } export interface LospecPaletteSyncProgress { diff --git a/src/services/db.ts b/src/services/db.ts index 00d3a54..56dc15a 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -9,6 +9,7 @@ import type { } from "@/types"; import type { LospecPaletteRecord, + LospecPaletteSyncCheckpoint, Palette, } from "@/features/image-editor/types"; import { normalizeProject } from "@/features/project-management/lib/project"; @@ -333,6 +334,8 @@ export function deleteProjectPrefs(projectId: string): void { // --------------------------------------------------------------------------- const LAST_PROJECT_KEY = "last-project-id"; +const LOSPEC_PALETTE_SYNC_STATE_KEY = "lospec-palette-sync-state"; +const LOSPEC_PALETTE_SYNC_ENABLED_KEY = "lospec-palette-sync-enabled"; export function saveLastProjectId(projectId: string): void { try { @@ -350,6 +353,52 @@ export function loadLastProjectId(): string | null { } } +export function saveLospecPaletteSyncEnabled(enabled: boolean): void { + try { + localStorage.setItem( + LOSPEC_PALETTE_SYNC_ENABLED_KEY, + JSON.stringify(enabled), + ); + } catch { + // Silently fail + } +} + +export function loadLospecPaletteSyncEnabled(): boolean { + try { + const raw = localStorage.getItem(LOSPEC_PALETTE_SYNC_ENABLED_KEY); + return raw === null ? false : JSON.parse(raw) === true; + } catch { + return false; + } +} + +export function saveLospecPaletteSyncCheckpoint( + checkpoint: LospecPaletteSyncCheckpoint, +): void { + try { + localStorage.setItem( + LOSPEC_PALETTE_SYNC_STATE_KEY, + JSON.stringify(checkpoint), + ); + } catch { + // Silently fail if localStorage is full or unavailable + } +} + +export function loadLospecPaletteSyncCheckpoint(): LospecPaletteSyncCheckpoint | null { + try { + const raw = localStorage.getItem(LOSPEC_PALETTE_SYNC_STATE_KEY); + if (!raw) { + return null; + } + + return JSON.parse(raw) as LospecPaletteSyncCheckpoint; + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // Settings helpers // --------------------------------------------------------------------------- diff --git a/tests/features/image-editor/lib/lospec-palettes.test.ts b/tests/features/image-editor/lib/lospec-palettes.test.ts index 543e526..83da330 100644 --- a/tests/features/image-editor/lib/lospec-palettes.test.ts +++ b/tests/features/image-editor/lib/lospec-palettes.test.ts @@ -80,6 +80,27 @@ test("normalizeLospecPaletteRecord converts Lospec API data into cache records", assert.strictEqual(palette?.cachedAt, 123); }); +test("normalizeLospecPaletteRecord strips HTML from descriptions while preserving paragraph breaks", () => { + const palette = normalizeLospecPaletteRecord({ + id: "html-description", + title: "HTML Description", + slug: "html-description", + description: + "

First paragraph & intro.

Second paragraph.

", + tags: ["retro"], + user: "user", + colors: ["abcdef"], + examples: [], + published_at: "2026-05-02T00:00:00.000Z", + }); + + assert.ok(palette); + assert.strictEqual( + palette?.description, + "First paragraph & intro.\n\nSecond paragraph.", + ); +}); + test("normalizeLospecPalettePage drops invalid records", () => { const palettes = normalizeLospecPalettePage([ { @@ -223,6 +244,7 @@ test("syncLospecPaletteCatalog saves new pages until it reaches a known palette assert.strictEqual(fetchImpl.mock.calls.length, 2); assert.strictEqual(result.status, "synced"); + assert.strictEqual(result.reachedEnd, false); assert.strictEqual(result.addedCount, 2); assert.deepEqual( result.palettes.map((palette) => palette.id), @@ -290,6 +312,7 @@ test("syncLospecPaletteCatalog emits progress from an empty cache while fetching }); assert.strictEqual(result.status, "synced"); + assert.strictEqual(result.reachedEnd, true); assert.strictEqual(result.addedCount, 2); assert.strictEqual(result.fetchedPageCount, 3); assert.deepEqual(progressEvents, [ @@ -348,12 +371,171 @@ test("syncLospecPaletteCatalog exposes Lospec request status codes", async () => assert.strictEqual(result.status, "cache-only"); assert.strictEqual(result.errorStatus, 429); + assert.strictEqual(result.retryPage, 0); assert.strictEqual( result.errorMessage, "Lospec palette request failed with 429", ); }); +test("syncLospecPaletteCatalog resumes from the provided start page after rate limiting", async () => { + const cachedPalettes = [ + createLospecPaletteFixture({ + id: "cached-rate-limited", + title: "Cached Rate Limited", + slug: "cached-rate-limited", + publishedAt: "2026-05-04T00:00:00.000Z", + publishedAtMs: Date.parse("2026-05-04T00:00:00.000Z"), + }), + ]; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(createFetchErrorResponse(429)) + .mockResolvedValueOnce( + createFetchResponse({ + count: 1, + items: [ + { + id: "resumed-palette", + title: "Resumed Palette", + slug: "resumed-palette", + description: "Loaded after cooldown", + tags: ["resume"], + user: "artist", + colors: ["112233"], + examples: [], + published_at: "2026-05-08T00:00:00.000Z", + }, + ], + }), + ) + .mockResolvedValueOnce(createFetchResponse({ count: 1, items: [] })); + + const firstResult = await syncLospecPaletteCatalog({ + fetchImpl, + loadCache: async () => + [...cachedPalettes].sort( + (left, right) => right.publishedAtMs - left.publishedAtMs, + ), + loadCacheIds: async () => cachedPalettes.map((palette) => palette.id), + saveCache: async (palettes) => { + cachedPalettes.push(...palettes); + }, + startPage: 11, + now: () => 999, + }); + + assert.strictEqual(firstResult.status, "cache-only"); + assert.strictEqual(firstResult.errorStatus, 429); + assert.strictEqual(firstResult.retryPage, 11); + assert.strictEqual( + new URL(fetchImpl.mock.calls[0]?.[0] as string).searchParams.get("page"), + "11", + ); + + const resumedResult = await syncLospecPaletteCatalog({ + fetchImpl, + loadCache: async () => + [...cachedPalettes].sort( + (left, right) => right.publishedAtMs - left.publishedAtMs, + ), + loadCacheIds: async () => cachedPalettes.map((palette) => palette.id), + saveCache: async (palettes) => { + cachedPalettes.push(...palettes); + }, + startPage: firstResult.retryPage, + now: () => 999, + }); + + assert.strictEqual(resumedResult.status, "synced"); + assert.strictEqual( + new URL(fetchImpl.mock.calls[1]?.[0] as string).searchParams.get("page"), + "11", + ); + assert.strictEqual( + new URL(fetchImpl.mock.calls[2]?.[0] as string).searchParams.get("page"), + "12", + ); + assert.deepEqual( + resumedResult.palettes.map((palette) => palette.id), + ["resumed-palette", "cached-rate-limited"], + ); + assert.strictEqual(resumedResult.reachedEnd, true); +}); + +test("syncLospecPaletteCatalog can continue through known palettes until an empty page is found", async () => { + const cachedPalettes = [ + createLospecPaletteFixture({ + id: "known-page-zero", + title: "Known Page Zero", + slug: "known-page-zero", + publishedAt: "2026-05-07T00:00:00.000Z", + publishedAtMs: Date.parse("2026-05-07T00:00:00.000Z"), + }), + ]; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + createFetchResponse({ + count: 2, + items: [ + { + id: "known-page-zero", + title: "Known Page Zero", + slug: "known-page-zero", + description: "Existing page zero palette", + tags: ["known"], + user: "artist-0", + colors: ["112233"], + examples: [], + published_at: "2026-05-07T00:00:00.000Z", + }, + ], + }), + ) + .mockResolvedValueOnce( + createFetchResponse({ + count: 2, + items: [ + { + id: "missing-page-one", + title: "Missing Page One", + slug: "missing-page-one", + description: "Recovered historical palette", + tags: ["history"], + user: "artist-1", + colors: ["445566"], + examples: [], + published_at: "2026-05-06T00:00:00.000Z", + }, + ], + }), + ) + .mockResolvedValueOnce(createFetchResponse({ count: 2, items: [] })); + + const result = await syncLospecPaletteCatalog({ + fetchImpl, + loadCache: async () => + [...cachedPalettes].sort( + (left, right) => right.publishedAtMs - left.publishedAtMs, + ), + loadCacheIds: async () => cachedPalettes.map((palette) => palette.id), + saveCache: async (palettes) => { + cachedPalettes.push(...palettes); + }, + stopAtKnownPalette: false, + now: () => 999, + }); + + assert.strictEqual(result.status, "synced"); + assert.strictEqual(result.reachedEnd, true); + assert.strictEqual(fetchImpl.mock.calls.length, 3); + assert.deepEqual( + result.palettes.map((palette) => palette.id), + ["known-page-zero", "missing-page-one"], + ); +}); + test("syncLospecPaletteCatalog returns partial status when request cap is reached", async () => { const cachedPalettes: LospecPaletteRecord[] = []; let index = 0; diff --git a/tests/features/image-editor/lib/lospec-sync-controller.test.ts b/tests/features/image-editor/lib/lospec-sync-controller.test.ts new file mode 100644 index 0000000..b58b859 --- /dev/null +++ b/tests/features/image-editor/lib/lospec-sync-controller.test.ts @@ -0,0 +1,242 @@ +import { afterEach, assert, test, vi } from "vitest"; +import { createLospecPaletteSyncController } from "@/features/image-editor/lib/lospec-sync-controller"; +import type { + LospecPaletteRecord, + LospecPaletteSyncCheckpoint, + LospecPaletteSyncDependencies, + LospecPaletteSyncResult, +} from "@/features/image-editor/types"; + +function createLospecPaletteFixture( + overrides: Partial, +): LospecPaletteRecord { + return { + id: "palette-base", + title: "Base Palette", + slug: "base-palette", + description: "Fixture palette", + tags: ["retro"], + user: "fixture-user", + colors: [{ r: 0, g: 0, b: 0, a: 255 }], + colorHexes: ["000000"], + examples: [], + publishedAt: "2026-05-01T00:00:00.000Z", + publishedAtMs: Date.parse("2026-05-01T00:00:00.000Z"), + cachedAt: 1, + ...overrides, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +test("Lospec background sync resumes from a persisted page after refresh and cooldown", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + + const pageZeroPalette = createLospecPaletteFixture({ + id: "page-zero", + title: "Page Zero", + slug: "page-zero", + publishedAt: "2026-05-08T00:00:00.000Z", + publishedAtMs: Date.parse("2026-05-08T00:00:00.000Z"), + }); + const pageOnePalette = createLospecPaletteFixture({ + id: "page-one", + title: "Page One", + slug: "page-one", + publishedAt: "2026-05-07T00:00:00.000Z", + publishedAtMs: Date.parse("2026-05-07T00:00:00.000Z"), + }); + const pageTwoPalette = createLospecPaletteFixture({ + id: "page-two", + title: "Page Two", + slug: "page-two", + publishedAt: "2026-05-06T00:00:00.000Z", + publishedAtMs: Date.parse("2026-05-06T00:00:00.000Z"), + }); + + const cachedPalettes: LospecPaletteRecord[] = []; + let checkpoint: LospecPaletteSyncCheckpoint | null = null; + const syncCalls: LospecPaletteSyncDependencies[] = []; + + const firstSyncCatalog = vi.fn( + async ( + dependencies: LospecPaletteSyncDependencies, + ): Promise => { + syncCalls.push(dependencies); + cachedPalettes.splice(0, cachedPalettes.length, pageZeroPalette); + dependencies.onProgress?.({ + palettes: [...cachedPalettes], + addedCount: 1, + fetchedPageCount: 1, + page: 0, + pageAddedCount: 1, + isInitialCache: false, + }); + + cachedPalettes.splice( + 0, + cachedPalettes.length, + pageZeroPalette, + pageOnePalette, + ); + dependencies.onProgress?.({ + palettes: [...cachedPalettes], + addedCount: 2, + fetchedPageCount: 2, + page: 1, + pageAddedCount: 1, + isInitialCache: false, + }); + + return { + palettes: [...cachedPalettes], + addedCount: 2, + fetchedPageCount: 2, + usedCache: true, + status: "cache-only", + errorStatus: 429, + errorMessage: "Lospec palette request failed with 429", + retryPage: 2, + }; + }, + ); + + const controllerBeforeRefresh = createLospecPaletteSyncController({ + loadCache: async () => [...cachedPalettes], + loadCheckpoint: () => checkpoint, + saveCheckpoint: (nextCheckpoint) => { + checkpoint = nextCheckpoint; + }, + now: Date.now, + setTimeoutImpl: setTimeout, + clearTimeoutImpl: clearTimeout, + syncCatalog: firstSyncCatalog, + }); + + await controllerBeforeRefresh.start(); + + assert.strictEqual(syncCalls[0]?.startPage, 0); + assert.strictEqual(syncCalls[0]?.stopAtKnownPalette, false); + assert.strictEqual(checkpoint?.status, "rate-limited"); + assert.strictEqual(checkpoint?.nextPage, 2); + assert.strictEqual(checkpoint?.retryAtMs, 61_000); + + controllerBeforeRefresh.dispose(); + + const resumedSyncCatalog = vi.fn( + async ( + dependencies: LospecPaletteSyncDependencies, + ): Promise => { + syncCalls.push(dependencies); + cachedPalettes.splice( + 0, + cachedPalettes.length, + pageZeroPalette, + pageOnePalette, + pageTwoPalette, + ); + dependencies.onProgress?.({ + palettes: [...cachedPalettes], + addedCount: 1, + fetchedPageCount: 1, + page: 2, + pageAddedCount: 1, + isInitialCache: false, + }); + + return { + palettes: [...cachedPalettes], + addedCount: 1, + fetchedPageCount: 2, + usedCache: false, + status: "synced", + reachedEnd: true, + }; + }, + ); + + const controllerAfterRefresh = createLospecPaletteSyncController({ + loadCache: async () => [...cachedPalettes], + loadCheckpoint: () => checkpoint, + saveCheckpoint: (nextCheckpoint) => { + checkpoint = nextCheckpoint; + }, + now: Date.now, + setTimeoutImpl: setTimeout, + clearTimeoutImpl: clearTimeout, + syncCatalog: resumedSyncCatalog, + }); + + await controllerAfterRefresh.start(); + assert.strictEqual(resumedSyncCatalog.mock.calls.length, 0); + + await vi.advanceTimersByTimeAsync(60_000); + + assert.strictEqual(syncCalls[1]?.startPage, 2); + assert.strictEqual(syncCalls[1]?.stopAtKnownPalette, false); + assert.strictEqual(checkpoint?.status, "complete"); + assert.strictEqual(checkpoint?.nextPage, 4); + assert.strictEqual(checkpoint?.addedCount, 3); + assert.strictEqual( + controllerAfterRefresh.getSnapshot().palettes.map((palette) => palette.id) + .length, + 3, + ); + + controllerAfterRefresh.dispose(); +}); + +test("Lospec background sync does not replay expired rate-limit state before retrying", async () => { + vi.useFakeTimers(); + vi.setSystemTime(5_000); + + const cachedPalette = createLospecPaletteFixture({ + id: "cached-palette", + title: "Cached Palette", + slug: "cached-palette", + }); + let checkpoint: LospecPaletteSyncCheckpoint | null = { + status: "rate-limited", + nextPage: 11, + retryAtMs: 4_000, + fetchedPageCount: 11, + addedCount: 22, + updatedAt: 4_000, + errorStatus: 429, + errorMessage: "Lospec palette request failed with 429", + }; + const syncCatalog = vi.fn( + async (): Promise => ({ + palettes: [cachedPalette], + addedCount: 0, + fetchedPageCount: 1, + usedCache: false, + status: "synced", + reachedEnd: true, + }), + ); + + const controller = createLospecPaletteSyncController({ + loadCache: async () => [cachedPalette], + loadCheckpoint: () => checkpoint, + saveCheckpoint: (nextCheckpoint) => { + checkpoint = nextCheckpoint; + }, + now: Date.now, + setTimeoutImpl: setTimeout, + clearTimeoutImpl: clearTimeout, + syncCatalog, + }); + + await controller.start(); + + assert.strictEqual(syncCatalog.mock.calls.length, 1); + assert.strictEqual(syncCatalog.mock.calls[0]?.[0].startPage, 11); + assert.strictEqual(checkpoint?.status, "complete"); + assert.strictEqual(checkpoint?.errorStatus, undefined); + + controller.dispose(); +}); diff --git a/tests/services/db.test.ts b/tests/services/db.test.ts index e78e558..6bae27a 100644 --- a/tests/services/db.test.ts +++ b/tests/services/db.test.ts @@ -1,6 +1,7 @@ import { afterEach, assert, beforeEach, test, vi } from "vitest"; import type { LospecPaletteRecord, + LospecPaletteSyncCheckpoint, Palette, } from "@/features/image-editor/types"; import { @@ -20,6 +21,8 @@ import { listProjects, loadLospecPaletteCache, loadLospecPaletteCacheIds, + loadLospecPaletteSyncEnabled, + loadLospecPaletteSyncCheckpoint, loadLastProjectId, loadPaletteLibrary, loadProjectPrefs, @@ -28,6 +31,8 @@ import { saveAsset, saveLastProjectId, saveLospecPaletteCache, + saveLospecPaletteSyncEnabled, + saveLospecPaletteSyncCheckpoint, savePaletteLibrary, saveProject, saveProjectPrefs, @@ -692,6 +697,16 @@ test("localStorage-backed helpers read, write, and tolerate storage failures", a colors: [{ r: 0, g: 0, b: 0, a: 255 }], }, ]; + const checkpoint: LospecPaletteSyncCheckpoint = { + status: "rate-limited", + nextPage: 12, + retryAtMs: 123_000, + fetchedPageCount: 12, + addedCount: 144, + updatedAt: 456_000, + errorStatus: 429, + errorMessage: "Lospec palette request failed with 429", + }; saveProjectPrefs("project-1", { sidebarOpen: true }); assert.deepEqual(loadProjectPrefs("project-1"), { sidebarOpen: true }); @@ -706,6 +721,12 @@ test("localStorage-backed helpers read, write, and tolerate storage failures", a deletePaletteLibrary("project-3"); assert.strictEqual(loadPaletteLibrary("project-3"), null); + saveLospecPaletteSyncEnabled(true); + assert.strictEqual(loadLospecPaletteSyncEnabled(), true); + + saveLospecPaletteSyncCheckpoint(checkpoint); + assert.deepEqual(loadLospecPaletteSyncCheckpoint(), checkpoint); + Object.assign(globalThis, { localStorage: { getItem: vi.fn(() => { @@ -723,11 +744,15 @@ test("localStorage-backed helpers read, write, and tolerate storage failures", a saveProjectPrefs("project-4", { sidebarOpen: false }); saveLastProjectId("project-4"); savePaletteLibrary("project-4", palettes); + saveLospecPaletteSyncEnabled(true); + saveLospecPaletteSyncCheckpoint(checkpoint); deleteProjectPrefs("project-4"); deletePaletteLibrary("project-4"); assert.strictEqual(loadProjectPrefs("project-4"), null); assert.strictEqual(loadLastProjectId(), null); assert.strictEqual(loadPaletteLibrary("project-4"), null); + assert.strictEqual(loadLospecPaletteSyncEnabled(), false); + assert.strictEqual(loadLospecPaletteSyncCheckpoint(), null); Object.assign(globalThis, { localStorage: localStorageMock }); }); From a2a3b7d29985fe939d5c6a0087283d472f3a6ce0 Mon Sep 17 00:00:00 2001 From: Werner Bihl Date: Sun, 24 May 2026 20:11:55 +0200 Subject: [PATCH 04/10] feat: refactor SettingsDialog to use tabs for section navigation and add settings section types --- src/components/dialogs/SettingsDialog.tsx | 172 +++++++++++++----- src/features/app-shell/types/index.ts | 2 + .../app-shell/types/settings-dialog.ts | 7 + 3 files changed, 133 insertions(+), 48 deletions(-) create mode 100644 src/features/app-shell/types/settings-dialog.ts diff --git a/src/components/dialogs/SettingsDialog.tsx b/src/components/dialogs/SettingsDialog.tsx index b3223c0..93fe6d1 100644 --- a/src/components/dialogs/SettingsDialog.tsx +++ b/src/components/dialogs/SettingsDialog.tsx @@ -7,13 +7,8 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/Dialog"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/Accordion"; import { Switch } from "@/components/ui/Switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/Tabs"; import { Label } from "@/components/ui/Label"; import { Button } from "@/components/ui/Button"; import { getSettings, saveSettings } from "@/services/db"; @@ -26,9 +21,24 @@ import { import type { SettingsDialogProps, SettingsKeyRowProps as KeyRowProps, + SettingsSection, + SettingsSectionId, } from "@/features/app-shell"; import type { AppSettings } from "@/types"; +const SETTINGS_SECTIONS: SettingsSection[] = [ + { + id: "general", + label: "General", + description: "Project behavior and application defaults.", + }, + { + id: "api-keys", + label: "API Keys", + description: "Provider credentials used for AI image generation.", + }, +]; + function ApiKeyRow({ id, label, url, placeholder }: KeyRowProps) { const [value, setValue] = useState(""); const [visible, setVisible] = useState(false); @@ -143,9 +153,15 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { const [settings, setSettings] = useState({ autoSaveEnabled: true, }); + const [activeSection, setActiveSection] = + useState("general"); + const handleSectionChange = (value: string) => { + setActiveSection(value as SettingsSectionId); + }; useEffect(() => { if (open) { + setActiveSection("general"); getSettings().then((newSettings) => { setSettings(newSettings); }); @@ -160,53 +176,113 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { return ( - - - Settings + + +
+ Settings +
Application settings
- - - General - -
- - -
-
-
+ + + +
+
+ +
+

+ General +

+

+ Control how the app handles project saving. +

+
+
+
+
+ +

+ Automatically persists your current project in the + background. +

+
+ +
+
+
- - AI API Keys - -
-

- Keys are obfuscated locally in your browser. They are never - sent to any server other than the provider's own API, but - any script running on this origin can still access them. -

- {API_KEY_PROVIDERS.map((p) => ( - - ))} -
-
-
- + +
+

+ API Keys +

+

+ Keys are obfuscated locally in your browser. They are never + sent to any server other than the provider's own API, + but any script running on this origin can still access them. +

+
+
+ {API_KEY_PROVIDERS.map((p) => ( + + ))} +
+
+
+
+
); diff --git a/src/features/app-shell/types/index.ts b/src/features/app-shell/types/index.ts index 1510367..fc36912 100644 --- a/src/features/app-shell/types/index.ts +++ b/src/features/app-shell/types/index.ts @@ -70,3 +70,5 @@ export interface ToolDrawerProps { activeTool: ToolName | null; onClose: () => void; } + +export * from "./settings-dialog"; diff --git a/src/features/app-shell/types/settings-dialog.ts b/src/features/app-shell/types/settings-dialog.ts new file mode 100644 index 0000000..18ef625 --- /dev/null +++ b/src/features/app-shell/types/settings-dialog.ts @@ -0,0 +1,7 @@ +export type SettingsSectionId = "general" | "api-keys"; + +export interface SettingsSection { + id: SettingsSectionId; + label: string; + description: string; +} From 8654726aa8d0f2fce0876373eeba30c32ba28fbb Mon Sep 17 00:00:00 2001 From: Werner Bihl Date: Sun, 24 May 2026 21:35:34 +0200 Subject: [PATCH 05/10] feat: enhance SettingsDialog with section navigation and improve layout --- AGENTS.md | 1 + TODO.txt | 1 + src/components/dialogs/SettingsDialog.tsx | 71 ++++++++++++++++++----- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43ac5e7..b30c109 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ - Do not inline typescript types and interfaces - move them to types folder - When working on a file, and it is more than 1000 lines of code then refactor it into smaller, easier to test and maintain files. There are no exceptions to this. Always do this even if it is outside the scope of what was asked - Always run `bun run lint` and `bun run build` after making code changes to verify that the build succeeds and there are no linting issues +- Be patient with `bun run build`. It can take minutes to run - Always use this folder structure: src/ diff --git a/TODO.txt b/TODO.txt index 4966305..71b9692 100644 --- a/TODO.txt +++ b/TODO.txt @@ -12,6 +12,7 @@ I want to make some changes to AI Assets Generator Tool: Settings dialog should have a scrollbar Map Management -> Edit Maps -> Allow editing map title World View +Tools -> MCP Server Ad Marketplace for selling/buying tilesets etc. Exploration: diff --git a/src/components/dialogs/SettingsDialog.tsx b/src/components/dialogs/SettingsDialog.tsx index 93fe6d1..3096d66 100644 --- a/src/components/dialogs/SettingsDialog.tsx +++ b/src/components/dialogs/SettingsDialog.tsx @@ -7,6 +7,13 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/Dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/Select"; import { Switch } from "@/components/ui/Switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/Tabs"; import { Label } from "@/components/ui/Label"; @@ -155,6 +162,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { }); const [activeSection, setActiveSection] = useState("general"); + const handleSectionChange = (value: string) => { setActiveSection(value as SettingsSectionId); }; @@ -176,8 +184,8 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { return ( - - + +
Settings
@@ -189,12 +197,37 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { value={activeSection} onValueChange={handleSectionChange} orientation="vertical" - className="h-full min-h-0 flex-1 items-start gap-0 overflow-hidden sm:flex-row" + className="min-h-0 flex-1 flex-col items-stretch gap-0 overflow-hidden sm:flex-row" > -