resolveNitroFrame now matches spritesheet frame keys that carry a .png suffix or namespaced naming, and isScale/scaleName preserve that suffix. Broken source sprites (missing frames or references to icon artwork) are skipped and reported instead of aborting the whole generation, and the studio UI surfaces the skipped count.
139 lines
3.6 KiB
TypeScript
139 lines
3.6 KiB
TypeScript
"use client";
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { adminFetch } from "@/lib/admin-fetch";
|
|
export function NitroScale32Generator({
|
|
classname,
|
|
disabled,
|
|
onChanged,
|
|
}: {
|
|
classname: string;
|
|
disabled: boolean;
|
|
onChanged: (metadata: Record<string, unknown>) => void;
|
|
}) {
|
|
const [preview, setPreview] = useState<{
|
|
hash: string;
|
|
before: string;
|
|
after: string;
|
|
count: number;
|
|
skipped?: number;
|
|
} | null>(null),
|
|
[undo, setUndo] = useState<{ hash: string; backupHash: string } | null>(
|
|
null,
|
|
),
|
|
[busy, setBusy] = useState(false),
|
|
[error, setError] = useState("");
|
|
async function run(action: "preview" | "apply" | "restore") {
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const response = await adminFetch("/api/admin/studio/nitro-scale32", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
classname,
|
|
action,
|
|
expectedHash: action === "restore" ? undo?.hash : preview?.hash,
|
|
backupHash: undo?.backupHash,
|
|
}),
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) throw Error(data.error || "Generation failed");
|
|
if (action === "preview") setPreview(data);
|
|
else {
|
|
setPreview(null);
|
|
setUndo(
|
|
action === "apply"
|
|
? { hash: data.hash, backupHash: data.backupHash }
|
|
: null,
|
|
);
|
|
onChanged(data.metadata);
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Generation failed");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
return (
|
|
<section className="space-y-3 rounded-lg border p-3">
|
|
<h3 className="font-semibold">Generate scale 32 from 64</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Adds derived artwork using nearest-neighbor reduction. Existing scale 32
|
|
is preserved. Preview uses the saved bundle and shows one sprite at its
|
|
original pixel size.
|
|
</p>
|
|
{disabled && (
|
|
<p className="text-sm">Save or reset your pending edits first.</p>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
disabled={busy || disabled}
|
|
onClick={() => run("preview")}
|
|
>
|
|
{busy ? "Working…" : "Preview generation"}
|
|
</Button>
|
|
{preview && (
|
|
<>
|
|
<p className="text-sm">
|
|
{preview.count} sprite entries will be added. Check positioning and
|
|
animations in the client after saving.
|
|
</p>
|
|
{preview.skipped ? (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
{preview.skipped} sprite(s) were skipped because the source bundle
|
|
has no usable artwork for them.
|
|
</p>
|
|
) : null}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{[
|
|
["Original 64", preview.before],
|
|
["Derived 32", preview.after],
|
|
].map(([label, src]) => (
|
|
<figure key={label} className="min-w-0">
|
|
<figcaption>{label}</figcaption>
|
|
<div className="overflow-auto p-3">
|
|
<img
|
|
src={src}
|
|
alt={label}
|
|
style={{ imageRendering: "pixelated", maxWidth: "none" }}
|
|
/>
|
|
</div>
|
|
</figure>
|
|
))}
|
|
</div>
|
|
<Button disabled={busy || disabled} onClick={() => run("apply")}>
|
|
Save with backup
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
disabled={busy}
|
|
onClick={() => setPreview(null)}
|
|
>
|
|
Discard preview
|
|
</Button>
|
|
</>
|
|
)}
|
|
{undo && (
|
|
<div>
|
|
<p className="text-sm">
|
|
Saved. A copy of the original bundle is preserved.
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
disabled={busy || disabled}
|
|
onClick={() => run("restore")}
|
|
>
|
|
Restore original bundle
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className="text-sm">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|