diff --git a/AGENTS.md b/AGENTS.md index 43ac5e7..ec36be4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,8 @@ - Always comply with ARIA accessibility standards and make sure all inputs have an id and name field. If you are working on a file which doesn't comply with ARIA standards, add the necessary ARIA fields as part of user request, even if the changes does not fall within scope - 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 +- Always run `bun run lint` and `bun run build` after making code changes to verify there are no linting or build 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 0c88e65..ee7f11e 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,21 +1,9 @@ -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 +Check all AI Asset generation +------------- -Select Project Loading screen -Better CI/CD Dev -> Prod workflow +Map Management -> Edit Maps -> Allow editing map title 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 +Tools -> MCP Server Ad Marketplace for selling/buying tilesets etc. Exploration: diff --git a/public/_headers b/public/_headers index 3045459..376c6a6 100644 --- a/public/_headers +++ b/public/_headers @@ -23,11 +23,11 @@ # style-src: unsafe-inline required for Radix UI / Tailwind inline styles # img-src: data: / blob: for canvas exports; https: for remote images # font-src: self-hosted app fonts + data: URIs - # connect-src: Sentry ingest + Cloudflare Insights RUM endpoint + app APIs + # connect-src: Sentry ingest + analytics + app APIs + browser-side AI providers # worker-src: blob: for Vite-generated web workers # object-src: none (no Flash / plugins) # base-uri: self (prevent base-tag injection) # form-action: self (no external form submissions) # frame-ancestors: none (blocks clickjacking; supersedes X-Frame-Options) # upgrade-insecure-requests: force HTTP→HTTPS for sub-resources - Content-Security-Policy: default-src 'self'; script-src 'self' https://static.cloudflareinsights.com https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://o4510891797250048.ingest.us.sentry.io https://*.sentry.io https://cloudflareinsights.com https://www.google-analytics.com https://*.google-analytics.com https://www.google.com https://api.2dtiler.com https://api.openai.com https://api.together.xyz https://api.x.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests + Content-Security-Policy: default-src 'self'; script-src 'self' https://static.cloudflareinsights.com https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://o4510891797250048.ingest.us.sentry.io https://*.sentry.io https://cloudflareinsights.com https://www.google-analytics.com https://*.google-analytics.com https://www.google.com https://api.2dtiler.com https://api.openai.com https://generativelanguage.googleapis.com https://api.together.xyz https://api.x.ai https://router.huggingface.co; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests 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/components/dialogs/SettingsDialog.tsx b/src/components/dialogs/SettingsDialog.tsx index 37301da..1f1e609 100644 --- a/src/components/dialogs/SettingsDialog.tsx +++ b/src/components/dialogs/SettingsDialog.tsx @@ -8,12 +8,14 @@ import { DialogTitle, } from "@/components/ui/Dialog"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/Accordion"; + 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"; import { Button } from "@/components/ui/Button"; import { getSettings, saveSettings } from "@/services/db"; @@ -26,9 +28,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 +160,16 @@ 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,52 +184,149 @@ 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/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/config/content-security-policy.ts b/src/config/content-security-policy.ts new file mode 100644 index 0000000..df57fb6 --- /dev/null +++ b/src/config/content-security-policy.ts @@ -0,0 +1,23 @@ +export const APP_CONNECT_SOURCES = [ + "'self'", + "https://o4510891797250048.ingest.us.sentry.io", + "https://*.sentry.io", + "https://cloudflareinsights.com", + "https://www.google-analytics.com", + "https://*.google-analytics.com", + "https://www.google.com", + "https://api.2dtiler.com", +] as const; + +export const AI_PROVIDER_CONNECT_SOURCES = [ + "https://api.openai.com", + "https://generativelanguage.googleapis.com", + "https://api.together.xyz", + "https://api.x.ai", + "https://router.huggingface.co", +] as const; + +export const CONNECT_SOURCES = [ + ...APP_CONNECT_SOURCES, + ...AI_PROVIDER_CONNECT_SOURCES, +] as const; diff --git a/src/features/ai-assets/components/Generator.tsx b/src/features/ai-assets/components/Generator.tsx index 5572e81..3fbbd3a 100644 --- a/src/features/ai-assets/components/Generator.tsx +++ b/src/features/ai-assets/components/Generator.tsx @@ -1,5 +1,12 @@ -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 +20,129 @@ 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 { + getAiAssetTargetDimensions, + getClosestAiAssetRatio, +} from "../lib/dimensions"; 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 }, - }); +function getQuotaPercent(quota: AiQuotaState): number | null { + if ( + quota.limit === null || + quota.remaining === null || + quota.limit <= 0 || + quota.remaining < 0 + ) { + return null; } - 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}`, - ); - } - - 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 +195,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< + AiGeneratedImageRecord[] + >([]); + 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 +220,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 +278,14 @@ export function Generator() { ), [ assetType, - tilesetCfg, - spriteCfg, bgCfg, iconCfg, - uiCfg, - vfxCfg, + spriteCfg, styleStack, + tilesetCfg, transparent, + uiCfg, + vfxCfg, ], ); @@ -379,138 +310,240 @@ export function Generator() { availableRatios.find((ratio) => ratio.value === "1:1") ?? availableRatios[0] ?? ALL_RATIOS[0]; - + const targetDimensions = useMemo( + () => + getAiAssetTargetDimensions({ + assetType, + style: styleStack, + tileset: tilesetCfg, + sprite: spriteCfg, + vfx: vfxCfg, + }), + [assetType, spriteCfg, styleStack, tilesetCfg, vfxCfg], + ); + const generationRatio = + (targetDimensions + ? getClosestAiAssetRatio(targetDimensions, availableRatios) + : null) ?? effectiveRatio; + const generationWidth = targetDimensions?.width ?? effectiveRatio.w; + const generationHeight = targetDimensions?.height ?? effectiveRatio.h; 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: generationWidth, + height: generationHeight, + ratio: generationRatio.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, + generationHeight, + generationRatio.value, + generationWidth, + 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 }; + } catch { + toast.error("Failed to add generated image to tileset"); } - }), - ); - - 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; - } - 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 (
@@ -742,7 +775,7 @@ export function Generator() {
+ setScheduler((previous) => ({ + ...previous, + intervalSeconds: Math.max( + 10, + Number(event.target.value) || 10, + ), + })) + } + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm" + /> +
+ +
+
+ + {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/dimensions.ts b/src/features/ai-assets/lib/dimensions.ts new file mode 100644 index 0000000..c125625 --- /dev/null +++ b/src/features/ai-assets/lib/dimensions.ts @@ -0,0 +1,99 @@ +import type { + AiAssetTargetDimensionInput, + AiImageDimensions, + AiImageGridDimensions, + RatioDef, +} from "@/types/integrations/ai-assets"; + +const PIXEL_SIZE_PATTERN = /^(\d+)x(\d+)$/i; + +const DEFAULT_TILESET_GRID: AiImageGridDimensions = { + columns: 4, + rows: 4, +}; + +const TILESET_GRIDS: Record = { + "seamless 47-tile blob": { columns: 8, rows: 6 }, + "16-tile corner mask": { columns: 4, rows: 4 }, + "Wang tile": { columns: 4, rows: 4 }, + "dual grid": { columns: 4, rows: 4 }, +}; + +function parsePositiveInteger(value: string): number | null { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function multiplyDimensions( + dimensions: AiImageDimensions | null, + columns: number, + rows: number, +): AiImageDimensions | null { + if (!dimensions) return null; + return { + width: dimensions.width * columns, + height: dimensions.height * rows, + }; +} + +export function parseAiPixelSize(value: string): AiImageDimensions | null { + const match = PIXEL_SIZE_PATTERN.exec(value.trim()); + if (!match) return null; + + const width = parsePositiveInteger(match[1] ?? ""); + const height = parsePositiveInteger(match[2] ?? ""); + if (width === null || height === null) return null; + + return { width, height }; +} + +export function getAiAssetTargetDimensions({ + assetType, + style, + tileset, + sprite, + vfx, +}: AiAssetTargetDimensionInput): AiImageDimensions | null { + switch (assetType) { + case "tileset": { + const tileSize = parseAiPixelSize(style.spriteSize); + const grid = TILESET_GRIDS[tileset.maskMode] ?? DEFAULT_TILESET_GRID; + return multiplyDimensions(tileSize, grid.columns, grid.rows); + } + case "sprite": { + const frameSize = parseAiPixelSize(style.spriteSize); + const frameCount = parsePositiveInteger(sprite.frameCount); + return frameCount ? multiplyDimensions(frameSize, frameCount, 1) : null; + } + case "icon": + return parseAiPixelSize(style.spriteSize); + case "vfx": { + const frameSize = parseAiPixelSize(vfx.size); + const frameCount = parsePositiveInteger(vfx.frameCount); + return frameCount ? multiplyDimensions(frameSize, frameCount, 1) : null; + } + case "background": + case "ui": + return null; + } +} + +export function getClosestAiAssetRatio( + dimensions: AiImageDimensions, + ratios: readonly RatioDef[], +): RatioDef | null { + if (ratios.length === 0 || dimensions.width <= 0 || dimensions.height <= 0) { + return null; + } + + const targetRatio = dimensions.width / dimensions.height; + return ratios.reduce((closest, ratio) => { + const closestDistance = Math.abs( + Math.log(closest.w / closest.h / targetRatio), + ); + const candidateDistance = Math.abs( + Math.log(ratio.w / ratio.h / targetRatio), + ); + return candidateDistance < closestDistance ? ratio : closest; + }); +} 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..911b804 --- /dev/null +++ b/src/features/ai-assets/lib/provider-utils.ts @@ -0,0 +1,190 @@ +import type { + AiImageDataUrlParts, + AiImageDimensions, + AiProviderImage, + AiProviderImageSourceOptions, +} from "@/types/integrations/ai-assets"; + +function parseImageDataUrl(dataUrl: string): AiImageDataUrlParts | 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)}`; +} + +async function decodeImageBlob(blob: Blob): Promise { + 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 image; + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +function getDecodedImageDimensions(image: HTMLImageElement): AiImageDimensions { + return { + width: image.naturalWidth || image.width, + height: image.naturalHeight || image.height, + }; +} + +function getNormalizedTargetDimensions( + dimensions: AiImageDimensions | null | undefined, +): AiImageDimensions | null { + if (!dimensions) return null; + const width = Math.round(dimensions.width); + const height = Math.round(dimensions.height); + if (width <= 0 || height <= 0) return null; + return { width, height }; +} + +function getImageSourceOptions( + fallbackMimeTypeOrOptions: string | AiProviderImageSourceOptions, + targetDimensions: AiImageDimensions | null | undefined, +): AiProviderImageSourceOptions { + if (typeof fallbackMimeTypeOrOptions === "string") { + return { + fallbackMimeType: fallbackMimeTypeOrOptions, + targetDimensions, + }; + } + return fallbackMimeTypeOrOptions; +} + +async function canvasToBlob( + canvas: HTMLCanvasElement, + mimeType: string, +): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error("Failed to scale generated image.")); + return; + } + resolve(blob); + }, mimeType); + }); +} + +async function scaleDecodedImageToBlob( + image: HTMLImageElement, + targetDimensions: AiImageDimensions, + mimeType: string, +): Promise { + const canvas = document.createElement("canvas"); + canvas.width = targetDimensions.width; + canvas.height = targetDimensions.height; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("No canvas context available for generated image scaling."); + } + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.drawImage( + image, + 0, + 0, + targetDimensions.width, + targetDimensions.height, + ); + return canvasToBlob(canvas, mimeType); +} + +export async function getImageDimensionsFromBlob( + blob: Blob, +): Promise { + return getDecodedImageDimensions(await decodeImageBlob(blob)); +} + +export async function imageSourceToProviderImage( + source: string | Blob, + fallbackMimeTypeOrOptions: + | string + | AiProviderImageSourceOptions = "image/png", + targetDimensions?: AiImageDimensions | null, +): Promise { + const options = getImageSourceOptions( + fallbackMimeTypeOrOptions, + targetDimensions, + ); + const fallbackMimeType = options.fallbackMimeType ?? "image/png"; + const requestedDimensions = getNormalizedTargetDimensions( + options.targetDimensions, + ); + 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 image = await decodeImageBlob(blob); + const dimensions = getDecodedImageDimensions(image); + const outputBlob = + requestedDimensions && + (dimensions.width !== requestedDimensions.width || + dimensions.height !== requestedDimensions.height) + ? await scaleDecodedImageToBlob( + image, + requestedDimensions, + blob.type || fallbackMimeType, + ) + : blob; + const outputDimensions = requestedDimensions ?? dimensions; + + return { + data: await outputBlob.arrayBuffer(), + mimeType: outputBlob.type || blob.type || fallbackMimeType, + width: outputDimensions.width, + height: outputDimensions.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..9c57817 --- /dev/null +++ b/src/features/ai-assets/lib/providers.ts @@ -0,0 +1,350 @@ +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[] = []; + const targetDimensions = { width, height }; + + if (initImageB64 && initImageMime) { + const editDataUrls = 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("size", `${width}x${height}`); + 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 }[]; + }; + return data.data.map((image) => `data:image/png;base64,${image.b64_json}`); + }), + ); + dataUrls.push(...editDataUrls.flat()); + } 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, { + fallbackMimeType: "image/png", + targetDimensions, + }), + ), + ), + quota: UNKNOWN_QUOTA, + }; +} + +async function generateGemini({ + apiKey, + model, + prompt, + ratio, + initImageB64, + initImageMime, + count, + width, + height, +}: AiProviderGenerateRequest): Promise { + const targetDimensions = { width, height }; + + 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}`, + { + fallbackMimeType: imagePart.inlineData.mimeType, + targetDimensions, + }, + ); + }), + ), + quota: UNKNOWN_QUOTA, + }; +} + +async function generateTogether({ + apiKey, + model, + prompt, + count, + width, + height, +}: AiProviderGenerateRequest): Promise { + const targetDimensions = { width, height }; + 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 ?? ""), + { + fallbackMimeType: "image/jpeg", + targetDimensions, + }, + ), + ), + ), + quota: UNKNOWN_QUOTA, + }; +} + +async function generateXAI({ + apiKey, + model, + prompt, + count, + width, + height, +}: AiProviderGenerateRequest): Promise { + const targetDimensions = { width, height }; + 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, { targetDimensions }), + ), + ), + 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}`; +} + +const HUGGING_FACE_IMAGE_ACCEPT = "image/png"; + +async function generateHuggingFace({ + apiKey, + model, + prompt, + count, + width, + height, +}: AiProviderGenerateRequest): Promise { + const images = []; + let quota = UNKNOWN_QUOTA; + const targetDimensions = { width, height }; + + 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: HUGGING_FACE_IMAGE_ACCEPT, + }, + body: JSON.stringify({ + inputs: prompt, + parameters: { + width, + height, + }, + }), + }); + quota = parseQuotaHeaders(response.headers); + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + const error = (() => { + if (!errorText) return null; + try { + return JSON.parse(errorText) as unknown; + } catch { + return { message: errorText }; + } + })(); + throw new Error( + getApiErrorMessage(`Hugging Face error ${response.status}`, error), + ); + } + images.push( + await imageSourceToProviderImage(await response.blob(), { + fallbackMimeType: HUGGING_FACE_IMAGE_ACCEPT, + targetDimensions, + }), + ); + } + + 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/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; +} diff --git a/src/features/image-editor/components/LospecPaletteDialog.tsx b/src/features/image-editor/components/LospecPaletteDialog.tsx index b0b5da8..4ef3350 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 { @@ -12,15 +12,25 @@ import { import { Input } from "@/components/ui/Input"; import { ScrollArea } from "@/components/ui/ScrollArea"; import { - filterAndSortLospecPalettes, - syncLospecPaletteCatalog, -} from "@/features/image-editor/lib/lospec-palettes"; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/Tooltip"; +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; + function colorToCss(hex: string): string { return `#${hex}`; } @@ -38,81 +48,121 @@ 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 [hiddenExampleIds, setHiddenExampleIds] = useState([]); + const [expandedColorPaletteIds, setExpandedColorPaletteIds] = useState< + string[] + >([]); + const [currentPage, setCurrentPage] = useState(1); + const [retryNowMs, setRetryNowMs] = useState(() => Date.now()); useEffect(() => { if (!open) { return; } - let isCurrent = true; - setIsLoading(true); - setErrorMessage(null); - setSyncMessage(""); + saveLospecPaletteSyncEnabled(true); + void startLospecPaletteBackgroundSync(); setHiddenExampleIds([]); + setExpandedColorPaletteIds([]); + setCurrentPage(1); + }, [open]); - void (async () => { - const result = await syncLospecPaletteCatalog(); - if (!isCurrent) { - return; - } - - setPalettes(result.palettes); - setIsLoading(false); - - if (result.status === "cache-only") { - const message = result.errorMessage - ? `Showing cached Lospec palettes. ${result.errorMessage}` - : "Showing cached Lospec palettes."; - setSyncMessage(message); - 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.` - : "Lospec palette library is already up to date.", - ); - })(); + useEffect(() => { + if (!open || sync.retryAtMs === null) { + return; + } + + setRetryNowMs(Date.now()); + const retryIntervalId = window.setInterval(() => { + setRetryNowMs(Date.now()); + }, 1000); return () => { - isCurrent = false; + window.clearInterval(retryIntervalId); }; - }, [open]); + }, [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, 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 +176,300 @@ export function LospecPaletteDialog({ ); }; + const handleTagClick = (tag: string) => { + setQuery(tag); + setCurrentPage(1); + }; + + const handleShowAllColors = (paletteId: string) => { + setExpandedColorPaletteIds((current) => + current.includes(paletteId) ? current : [...current, paletteId], + ); + }; + + 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); + + 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..23451fb 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,87 @@ 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); + } catch { + clearStandaloneAiImageEditorContext(requestId); + } 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/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 d25fb16..b8327a8 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); } @@ -85,6 +95,54 @@ function normalizeLospecExamples(value: unknown): LospecPaletteExample[] { }); } +function decodeLospecHtmlEntities(value: string): string { + const entityMap: Record = { + nbsp: " ", + amp: "&", + lt: "<", + gt: ">", + quot: '"', + "#39": "'", + }; + return value.replace(/&(nbsp|amp|lt|gt|quot|#39);/gi, (_, entity: string) => { + return entityMap[entity.toLowerCase()] ?? _; + }); +} + +function stripLospecHtmlTags(value: string): string { + let text = ""; + let inTag = false; + + for (const char of value) { + if (char === "<") { + inTag = true; + continue; + } + if (char === ">" && inTag) { + inTag = false; + continue; + } + if (!inTag) { + text += char; + } + } + + return text; +} + +function normalizeLospecDescription(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + + const decoded = decodeLospecHtmlEntities(value); + const withParagraphBreaks = decoded.replace( + /<\s*\/p\s*>\s*<\s*p\b[^>]*>/gi, + "\n\n", + ); + return stripLospecHtmlTags(withParagraphBreaks).trim(); +} + function getLospecErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim()) { return error.message; @@ -93,6 +151,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 +181,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()); @@ -134,8 +198,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 : ""; @@ -177,11 +240,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,25 +300,44 @@ 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 startPage = Math.max(0, Math.floor(dependencies.startPage ?? 0)); + const stopAtKnownPalette = dependencies.stopAtKnownPalette ?? true; const knownIds = new Set(await loadCacheIds()); - let page = 0; + const cachedPalettes = await loadCache(); + let page = startPage; let addedCount = 0; + let fetchedPageCount = 0; + let reachedEnd = false; + + 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) { + 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); @@ -261,6 +347,15 @@ export async function syncLospecPaletteCatalog( } } + onProgress?.({ + palettes: await loadCache(), + addedCount, + fetchedPageCount, + page, + pageAddedCount: palettesToSave.length, + isInitialCache: false, + }); + if (firstKnownIndex >= 0) { break; } @@ -272,8 +367,10 @@ export async function syncLospecPaletteCatalog( return { palettes: await loadCache(), addedCount, + fetchedPageCount, usedCache: false, status: "partial", + reachedEnd: false, errorMessage: `Reached Lospec sync cap (${LOSPEC_SYNC_MAX_PAGES} pages). Imported a partial catalog.`, }; } @@ -281,19 +378,25 @@ export async function syncLospecPaletteCatalog( return { palettes: await loadCache(), addedCount, + fetchedPageCount, usedCache: false, status: "synced", + reachedEnd, }; } 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", + retryPage: errorStatus === 429 ? page : undefined, + errorStatus, errorMessage, }; } @@ -301,8 +404,11 @@ export async function syncLospecPaletteCatalog( return { palettes: [], addedCount, + 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..451064a --- /dev/null +++ b/src/features/image-editor/lib/lospec-sync-controller.ts @@ -0,0 +1,356 @@ +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 getUnexpectedErrorMessage = (error: unknown) => { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + return "Lospec palettes could not be loaded."; + }; + + 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; + } + + try { + await ensureInitialized(); + } catch (error) { + if (disposed) { + return; + } + commit({ + ...snapshot, + status: "error", + retryAtMs: null, + updatedAt: dependencies.now(), + errorStatus: undefined, + errorMessage: getUnexpectedErrorMessage(error), + }); + return; + } + + 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 () => { + try { + 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, + }); + } catch (error) { + if (disposed) { + return; + } + commit({ + ...snapshot, + status: "error", + retryAtMs: null, + updatedAt: dependencies.now(), + errorStatus: undefined, + errorMessage: getUnexpectedErrorMessage(error), + }); + } + })().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 7f8dc3b..28c2422 100644 --- a/src/features/image-editor/types/lospec.ts +++ b/src/features/image-editor/types/lospec.ts @@ -48,8 +48,12 @@ export type LospecPaletteSyncStatus = export interface LospecPaletteSyncResult { palettes: LospecPaletteRecord[]; addedCount: number; + fetchedPageCount: number; usedCache: boolean; status: LospecPaletteSyncStatus; + reachedEnd?: boolean; + retryPage?: number; + errorStatus?: number; errorMessage?: string; } @@ -63,5 +67,17 @@ export interface LospecPaletteSyncDependencies { loadCache?: () => Promise; loadCacheIds?: () => Promise; saveCache?: (palettes: LospecPaletteRecord[]) => Promise; + onProgress?: (progress: LospecPaletteSyncProgress) => void; now?: () => number; + startPage?: number; + stopAtKnownPalette?: boolean; +} + +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..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"; @@ -17,6 +18,7 @@ import type { ProjectPrefs, ProjectRecord, } from "@/features/import-export/types"; +import type { AiGeneratedImageRecord } from "@/types/integrations/ai-assets"; // --------------------------------------------------------------------------- // Database @@ -29,6 +31,7 @@ class TilerDatabase extends Dexie { quickExportPreferences!: EntityTable; quickExportSaveTargets!: EntityTable; lospecPalettes!: EntityTable; + aiImages!: EntityTable; constructor() { super("TilerDB"); @@ -60,6 +63,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", + }); } } @@ -319,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 { @@ -336,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/src/types/integrations/ai-assets.ts b/src/types/integrations/ai-assets.ts index 9a4f870..9d1d74c 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 { @@ -73,10 +80,38 @@ export interface RatioDef { h: number; } +export interface AiImageDimensions { + width: number; + height: number; +} + +export interface AiImageGridDimensions { + columns: number; + rows: number; +} + +export interface AiAssetTargetDimensionInput { + assetType: AssetType; + style: StyleStack; + tileset: TilesetConfig; + sprite: SpriteConfig; + vfx: VFXConfig; +} + +export interface AiImageDataUrlParts { + mimeType: string; + base64: string; +} + +export interface AiProviderImageSourceOptions { + fallbackMimeType?: string; + targetDimensions?: AiImageDimensions | null; +} + 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 +121,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/config/content-security-policy.test.ts b/tests/config/content-security-policy.test.ts new file mode 100644 index 0000000..0eb97b4 --- /dev/null +++ b/tests/config/content-security-policy.test.ts @@ -0,0 +1,168 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, assert, test, vi } from "vitest"; +import { AI_PROVIDER_CONNECT_SOURCES } from "@/config/content-security-policy"; +import { generateWithProvider } from "@/features/ai-assets/lib/providers"; + +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; +const originalFetch = globalThis.fetch; +const originalImage = globalThis.Image; + +afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + globalThis.Image = originalImage; + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function installImageMock() { + URL.createObjectURL = vi.fn(() => "blob:test-image"); + 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; +} + +function getFetchUrl(input: Parameters[0]): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +test("AI provider origins are covered by both CSP configurations", async () => { + installImageMock(); + const seenOrigins = new Set(); + + globalThis.fetch = vi.fn(async (input) => { + const url = getFetchUrl(input); + seenOrigins.add(new URL(url).origin); + + if (url.startsWith("https://router.huggingface.co/")) { + return new Response( + new Blob([new Uint8Array([7])], { type: "image/png" }), + { + headers: { + "x-ratelimit-limit": "10", + "x-ratelimit-remaining": "9", + }, + }, + ); + } + + if (url.startsWith("https://api.openai.com/")) { + return new Response( + JSON.stringify({ data: [{ b64_json: "b3BlbmFp" }] }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + if (url.startsWith("https://generativelanguage.googleapis.com/")) { + return new Response( + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: "image/png", + data: "Z2VtaW5p", + }, + }, + ], + }, + }, + ], + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + if (url.startsWith("https://api.together.xyz/")) { + return new Response( + JSON.stringify({ data: [{ b64_json: "dG9nZXRoZXI=" }] }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + if (url.startsWith("https://api.x.ai/")) { + return new Response( + JSON.stringify({ data: [{ url: "data:image/png;base64,eGFp" }] }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }) as typeof fetch; + + const baseRequest = { + apiKey: "provider_test", + prompt: "grass tile", + count: 1, + width: 64, + height: 64, + ratio: "1:1" as const, + initImageB64: null, + initImageMime: null, + }; + + await Promise.all([ + generateWithProvider("huggingface", { + ...baseRequest, + model: "black-forest-labs/FLUX.1-schnell", + }), + generateWithProvider("openai", { + ...baseRequest, + model: "gpt-image-1", + }), + generateWithProvider("gemini", { + ...baseRequest, + model: "gemini-2.5-flash-image", + ratio: "16:9", + }), + generateWithProvider("together", { + ...baseRequest, + model: "black-forest-labs/FLUX.1-schnell", + }), + generateWithProvider("xai", { + ...baseRequest, + model: "aurora", + }), + ]); + + assert.deepEqual( + [...seenOrigins].sort(), + [...AI_PROVIDER_CONNECT_SOURCES].sort(), + ); + + const headersPath = path.resolve( + import.meta.dirname, + "../../public/_headers", + ); + const headers = readFileSync(headersPath, "utf8"); + for (const origin of AI_PROVIDER_CONNECT_SOURCES) { + assert.match(headers, new RegExp(escapeForRegex(origin))); + } +}); diff --git a/tests/features/ai-assets/lib/dimensions.test.ts b/tests/features/ai-assets/lib/dimensions.test.ts new file mode 100644 index 0000000..7f6c03b --- /dev/null +++ b/tests/features/ai-assets/lib/dimensions.test.ts @@ -0,0 +1,85 @@ +import { assert, test } from "vitest"; +import { ALL_RATIOS } from "@/features/ai-assets/lib/constants"; +import { + getAiAssetTargetDimensions, + getClosestAiAssetRatio, + parseAiPixelSize, +} from "@/features/ai-assets/lib/dimensions"; +import type { AiAssetTargetDimensionInput } from "@/types/integrations/ai-assets"; + +const baseInput: AiAssetTargetDimensionInput = { + assetType: "tileset", + style: { + artStyle: "pixel art", + colorPalette: "vibrant", + spriteSize: "32x32", + }, + tileset: { + tileType: "Ground", + terrain: "Grass", + transition: "None", + maskMode: "seamless 47-tile blob", + perspective: "Top-down", + seamless: true, + }, + sprite: { + role: "Hero / Player", + animState: "idle", + perspective: "side-view", + direction: "South", + frameCount: "4", + proportion: "semi-realistic", + }, + vfx: { + action: "Explosion", + frameCount: "8", + size: "64x64", + }, +}; + +test("parses pixel size strings", () => { + assert.deepEqual(parseAiPixelSize("32x48"), { width: 32, height: 48 }); + assert.strictEqual(parseAiPixelSize("0x48"), null); + assert.strictEqual(parseAiPixelSize("large"), null); +}); + +test("calculates structured AI asset target dimensions", () => { + assert.deepEqual(getAiAssetTargetDimensions(baseInput), { + width: 256, + height: 192, + }); + assert.deepEqual( + getAiAssetTargetDimensions({ ...baseInput, assetType: "sprite" }), + { width: 128, height: 32 }, + ); + assert.deepEqual( + getAiAssetTargetDimensions({ ...baseInput, assetType: "icon" }), + { width: 32, height: 32 }, + ); + assert.deepEqual( + getAiAssetTargetDimensions({ ...baseInput, assetType: "vfx" }), + { width: 512, height: 64 }, + ); +}); + +test("preserves ratio-based behavior for assets without explicit pixel targets", () => { + assert.strictEqual( + getAiAssetTargetDimensions({ ...baseInput, assetType: "background" }), + null, + ); + assert.strictEqual( + getAiAssetTargetDimensions({ ...baseInput, assetType: "ui" }), + null, + ); +}); + +test("finds the closest supported provider aspect ratio", () => { + assert.strictEqual( + getClosestAiAssetRatio({ width: 128, height: 32 }, ALL_RATIOS)?.value, + "16:9", + ); + assert.strictEqual( + getClosestAiAssetRatio({ width: 256, height: 192 }, ALL_RATIOS)?.value, + "4:3", + ); +}); 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..4f2eac2 --- /dev/null +++ b/tests/features/ai-assets/lib/provider-utils.test.ts @@ -0,0 +1,105 @@ +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; +const originalDocument = globalThis.document; + +afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + globalThis.Image = originalImage; + globalThis.fetch = originalFetch; + if (originalDocument) { + Object.defineProperty(globalThis, "document", { + configurable: true, + value: originalDocument, + }); + } else { + Reflect.deleteProperty(globalThis, "document"); + } + 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; +} + +function installCanvasMock(outputBytes = [4, 5, 6]) { + const drawImage = vi.fn(); + const context = { drawImage } as unknown as CanvasRenderingContext2D; + const canvas = { + height: 0, + width: 0, + getContext: vi.fn(() => context), + toBlob: vi.fn((callback: BlobCallback, mimeType?: string) => { + callback(new Blob([new Uint8Array(outputBytes)], { type: mimeType })); + }), + } as unknown as HTMLCanvasElement; + const createElement = vi.fn(() => canvas); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { createElement }, + }); + + return { canvas, createElement, drawImage }; +} + +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]); +}); + +test("scales generated images to requested dimensions", async () => { + installImageMock(1024, 1024); + const canvasMock = installCanvasMock(); + + const image = await imageSourceToProviderImage("data:image/png;base64,AQID", { + targetDimensions: { width: 32, height: 48 }, + }); + + assert.strictEqual(image.mimeType, "image/png"); + assert.strictEqual(image.width, 32); + assert.strictEqual(image.height, 48); + assert.deepEqual([...new Uint8Array(image.data)], [4, 5, 6]); + assert.strictEqual(canvasMock.canvas.width, 32); + assert.strictEqual(canvasMock.canvas.height, 48); + assert.strictEqual(canvasMock.createElement.mock.calls[0]?.[0], "canvas"); + assert.strictEqual(canvasMock.drawImage.mock.calls[0]?.[3], 32); + assert.strictEqual(canvasMock.drawImage.mock.calls[0]?.[4], 48); +}); 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..9285591 --- /dev/null +++ b/tests/features/ai-assets/lib/providers.test.ts @@ -0,0 +1,459 @@ +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; +const originalDocument = globalThis.document; + +afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + globalThis.Image = originalImage; + globalThis.fetch = originalFetch; + if (originalDocument) { + Object.defineProperty(globalThis, "document", { + configurable: true, + value: originalDocument, + }); + } else { + Reflect.deleteProperty(globalThis, "document"); + } + vi.restoreAllMocks(); +}); + +function installImageMock(width = 64, height = 64) { + URL.createObjectURL = vi.fn(() => "blob:hf"); + 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; +} + +function installCanvasMock(outputBytes = [8]) { + const drawImage = vi.fn(); + const context = { drawImage } as unknown as CanvasRenderingContext2D; + const canvas = { + height: 0, + width: 0, + getContext: vi.fn(() => context), + toBlob: vi.fn((callback: BlobCallback, mimeType?: string) => { + callback(new Blob([new Uint8Array(outputBytes)], { type: mimeType })); + }), + } as unknown as HTMLCanvasElement; + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { createElement: vi.fn(() => canvas) }, + }); + + return { canvas, drawImage }; +} + +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/); + assert.strictEqual( + new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get("Accept"), + "image/png", + ); + assert.match(String(fetchMock.mock.calls[0]?.[1]?.body), /"width":64/); + assert.match(String(fetchMock.mock.calls[0]?.[1]?.body), /"height":64/); +}); + +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/); +}); + +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] ?? []; + const body = init?.body; + assert.match(String(url), /images\/edits/); + assert.ok(body instanceof FormData); + assert.strictEqual(body.get("size"), "64x64"); + assert.strictEqual(result.images[0]?.mimeType, "image/png"); +}); + +test("preserves OpenAI edit image order across concurrent requests", async () => { + installImageMock(); + const firstImageBytes = new TextEncoder().encode("first-image"); + const secondImageBytes = new TextEncoder().encode("second-image"); + let callIndex = 0; + globalThis.fetch = vi.fn(async () => { + const currentCallIndex = callIndex++; + await new Promise((resolve) => + setTimeout(resolve, currentCallIndex === 0 ? 10 : 0), + ); + return new Response( + JSON.stringify({ + data: [ + { + b64_json: + currentCallIndex === 0 + ? btoa(String.fromCharCode(...firstImageBytes)) + : btoa(String.fromCharCode(...secondImageBytes)), + }, + ], + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + }) as typeof fetch; + + const result = await generateWithProvider("openai", { + apiKey: "openai_test", + model: "gpt-image-1", + prompt: "ordered edits", + count: 2, + width: 64, + height: 64, + ratio: "1:1", + initImageB64: "AQID", + initImageMime: "image/png", + }); + + assert.strictEqual(result.images.length, 2); + assert.deepEqual(new Uint8Array(result.images[0]?.data ?? new ArrayBuffer(0)), firstImageBytes); + assert.deepEqual(new Uint8Array(result.images[1]?.data ?? new ArrayBuffer(0)), secondImageBytes); +}); + +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); + assert.match( + String( + (globalThis.fetch as ReturnType).mock.calls[0]?.[1]?.body, + ), + /"width":64/, + ); + assert.notMatch( + String( + (globalThis.fetch as ReturnType).mock.calls[1]?.[1]?.body, + ), + /"width"/, + ); +}); + +test("scales xAI output to requested dimensions after generation", async () => { + installImageMock(1024, 1024); + const canvasMock = installCanvasMock(); + globalThis.fetch = vi + .fn() + .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 result = await generateWithProvider("xai", { + apiKey: "xai_test", + model: "aurora", + prompt: "hero sprite", + count: 1, + width: 128, + height: 32, + ratio: "16:9", + initImageB64: null, + initImageMime: null, + }); + + assert.strictEqual(result.images[0]?.width, 128); + assert.strictEqual(result.images[0]?.height, 32); + assert.deepEqual( + [...new Uint8Array(result.images[0]?.data ?? new ArrayBuffer(0))], + [8], + ); + assert.strictEqual(canvasMock.canvas.width, 128); + assert.strictEqual(canvasMock.canvas.height, 32); + assert.strictEqual(canvasMock.drawImage.mock.calls[0]?.[3], 128); + assert.strictEqual(canvasMock.drawImage.mock.calls[0]?.[4], 32); + assert.notMatch( + String( + (globalThis.fetch as ReturnType).mock.calls[0]?.[1]?.body, + ), + /"width"/, + ); +}); + +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/, + ); +}); + +test("reads Hugging Face plain-text error bodies safely", async () => { + globalThis.fetch = vi.fn( + async () => + new Response("gateway timeout", { + status: 504, + headers: { "Content-Type": "text/plain" }, + }), + ) 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(/gateway timeout/); +}); 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..5af7531 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( { @@ -72,6 +80,65 @@ 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("normalizeLospecPaletteRecord strips decoded script tags from descriptions", () => { + const palette = normalizeLospecPaletteRecord({ + id: "decoded-script-description", + title: "Decoded Script Description", + slug: "decoded-script-description", + description: "Before <script>alert(1)</script> after", + tags: ["retro"], + user: "user", + colors: ["abcdef"], + examples: [], + published_at: "2026-05-02T00:00:00.000Z", + }); + + assert.ok(palette); + assert.strictEqual(palette?.description, "Before alert(1) after"); + assert.ok(!palette?.description.includes(" { + const palette = normalizeLospecPaletteRecord({ + id: "double-decode-description", + title: "Double Decode Description", + slug: "double-decode-description", + description: "Escaped &lt;script&gt;safe&lt;/script&gt;", + tags: ["retro"], + user: "user", + colors: ["abcdef"], + examples: [], + published_at: "2026-05-02T00:00:00.000Z", + }); + + assert.ok(palette); + assert.strictEqual( + palette?.description, + "Escaped <script>safe</script>", + ); +}); + test("normalizeLospecPalettePage drops invalid records", () => { const palettes = normalizeLospecPalettePage([ { @@ -100,6 +167,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", @@ -187,6 +282,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), @@ -194,6 +290,79 @@ 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.reachedEnd, true); + 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 +387,193 @@ 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.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..31d7488 --- /dev/null +++ b/tests/features/image-editor/lib/lospec-sync-controller.test.ts @@ -0,0 +1,268 @@ +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(); +}); + +test("Lospec background sync start resolves when sync throws unexpectedly", async () => { + let checkpoint: LospecPaletteSyncCheckpoint | null = null; + const controller = createLospecPaletteSyncController({ + loadCache: async () => [], + loadCheckpoint: () => checkpoint, + saveCheckpoint: (nextCheckpoint) => { + checkpoint = nextCheckpoint; + }, + now: () => 42, + setTimeoutImpl: setTimeout, + clearTimeoutImpl: clearTimeout, + syncCatalog: async () => { + throw new Error("sync exploded"); + }, + }); + + await controller.start(); + + assert.strictEqual(controller.getSnapshot().status, "error"); + assert.strictEqual(controller.getSnapshot().errorMessage, "sync exploded"); + assert.strictEqual(checkpoint?.status, "error"); + assert.strictEqual(checkpoint?.errorMessage, "sync exploded"); + + controller.dispose(); +}); 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/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 }); }); diff --git a/vite.config.ts b/vite.config.ts index 5cba355..0cece3b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { VitePWA } from "vite-plugin-pwa"; +import { CONNECT_SOURCES } from "./src/config/content-security-policy"; const manualChunkPackages = [ ["react-vendor", ["react", "react-dom"]], @@ -203,7 +204,7 @@ export default defineConfig({ "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob: https:; " + "font-src 'self' data:; " + - "connect-src 'self' https://o4510891797250048.ingest.us.sentry.io https://*.sentry.io https://cloudflareinsights.com https://www.google-analytics.com https://*.google-analytics.com https://www.google.com https://api.2dtiler.com; " + + `connect-src ${CONNECT_SOURCES.join(" ")}; ` + "worker-src 'self' blob:; " + "object-src 'none'; " + "base-uri 'self'; " + @@ -233,10 +234,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, }, }, },