Step 10C.2: generate optimized map art with sharp
CI / check (push) Successful in 3m26s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 19:07:40 -04:00
parent 102c5bba91
commit f239f452e7
29 changed files with 562 additions and 4 deletions

View File

@ -686,3 +686,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Missing: `badge-cloud-platform`** (the CR marks it optional). It is in the manifest with `file: null`, Jupiter Cloud Platform keeps `badge: "badge-cloud-platform"` in content, and the map simply draws no badge (fallback, decision 0.5.41). - **Missing: `badge-cloud-platform`** (the CR marks it optional). It is in the manifest with `file: null`, Jupiter Cloud Platform keeps `badge: "badge-cloud-platform"` in content, and the map simply draws no badge (fallback, decision 0.5.41).
- **The manifest is JSON** (`apps/web/src/map/art/manifest.json`) with a `kind` per asset (`background`, `body`, `badge`, `ship`), so both the app (typed wrapper `manifest.ts`) and the Node optimization script read the same file. - **The manifest is JSON** (`apps/web/src/map/art/manifest.json`) with a `kind` per asset (`background`, `body`, `badge`, `ship`), so both the app (typed wrapper `manifest.ts`) and the Node optimization script read the same file.
- **The Sun is not a location**, so its sprite key lives only in the manifest; the map style will refer to `sun` directly. - **The Sun is not a location**, so its sprite key lives only in the manifest; the map style will refer to `sun` directly.
## Step 10C.2
- **Output naming:** `public/assets/map-gen/<id>@1x.png` and `<id>@2x.png` for sprites, `<id>.webp` for the background. Missing assets (`file: null`) produce nothing and a warning.
- **The build runs the script with `--if-available`:** it regenerates when sharp is installed (output is byte-identical, so git sees no change) and otherwise only checks that the committed output exists. Builds therefore never need sharp, as the CR requires.
- **The source art is removed from the build output** by a small Vite plugin. Everything in `public/` is copied into `dist` by default, and the sources (about 22 MB) are only script input. The sources stay where the user put them.
- **The committed-output test compares decoded pixels, not bytes**, because PNG compression can differ between Windows and Linux (CI). The "twice is identical" test compares bytes within one machine. The source-transparency test reads all four corners of each source sprite.
- **Sizes:** the 2x body sprites are about 0.5–0.6 MB each (lossless PNG of detailed art). That is acceptable for now. If the PWA precache (Step 11.1) is too heavy, palette quantization is the lever.
- **sharp's install script is not approved** in pnpm; its prebuilt platform binaries load without it.

View File

@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "node scripts/map-art.mjs --if-available && vite build",
"map-art": "node scripts/map-art.mjs",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
@ -30,6 +31,7 @@
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.1.1", "jsdom": "^30.1.1",
"sharp": "^0.35.5",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"vite": "^8.3.2" "vite": "^8.3.2"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

View File

@ -0,0 +1,96 @@
// Generates optimized map art from the user's source images (CR-003, decision 0.5.37).
//
// node scripts/map-art.mjs [outDir] regenerate (default public/assets/map-gen)
// node scripts/map-art.mjs --if-available as above, but if sharp is not installed,
// only check the committed output exists
//
// Sources in the manifest's `sourceDir` are only ever read.
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const manifest = JSON.parse(readFileSync(join(webRoot, "src/map/art/manifest.json"), "utf8"));
/** Long-side sizes in px for the 1x and 2x variants of each sprite kind. */
export const SPRITE_SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256] };
export const BACKGROUND_MAX = 2048;
export const DEFAULT_OUT_DIR = join(webRoot, "public/assets/map-gen");
/** Every file the script writes for the manifest, relative to the output folder. */
export function expectedOutputs() {
const outputs = [];
for (const [id, asset] of Object.entries(manifest.assets)) {
if (asset.file === null) continue;
if (asset.kind === "background") outputs.push({ id, file: `${id}.webp`, kind: asset.kind });
else {
const [one, two] = SPRITE_SIZES[asset.kind];
outputs.push({ id, file: `${id}@1x.png`, kind: asset.kind, longSide: one });
outputs.push({ id, file: `${id}@2x.png`, kind: asset.kind, longSide: two });
}
}
return outputs;
}
const png = { compressionLevel: 9, adaptiveFiltering: false, palette: false };
async function generate(sharp, outDir) {
mkdirSync(outDir, { recursive: true });
for (const [id, asset] of Object.entries(manifest.assets)) {
if (asset.file === null) {
console.warn(`map-art: ${id} has no source file; the map will use its placeholder`);
continue;
}
const source = join(webRoot, manifest.sourceDir, asset.file);
if (asset.kind === "background") {
await sharp(source)
.resize({
width: BACKGROUND_MAX,
height: BACKGROUND_MAX,
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: 80, effort: 6 })
.toFile(join(outDir, `${id}.webp`));
continue;
}
// Trim transparent padding once, then export each size from the trimmed image.
const trimmed = await sharp(source).trim().png().toBuffer();
const [one, two] = SPRITE_SIZES[asset.kind];
for (const [suffix, size] of [
["1x", one],
["2x", two],
]) {
await sharp(trimmed)
.resize({ width: size, height: size, fit: "inside" })
.png(png)
.toFile(join(outDir, `${id}@${suffix}.png`));
}
}
}
async function main(args) {
const ifAvailable = args.includes("--if-available");
const outDir = resolve(args.find((a) => !a.startsWith("--")) ?? DEFAULT_OUT_DIR);
let sharp;
try {
sharp = (await import("sharp")).default;
} catch (error) {
if (!ifAvailable) throw error;
const missing = expectedOutputs().filter((o) => !existsSync(join(outDir, o.file)));
if (missing.length > 0) {
throw new Error(
`map-art: sharp unavailable and output missing: ${missing.map((o) => o.file)}`,
{ cause: error },
);
}
console.warn("map-art: sharp unavailable; using the committed output");
return;
}
await generate(sharp, outDir);
console.log(`map-art: wrote ${expectedOutputs().length} files to ${outDir}`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main(process.argv.slice(2));
}

View File

@ -0,0 +1,102 @@
// @vitest-environment node
import { execFileSync } from "node:child_process";
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { artManifest } from "../src/map/art/manifest";
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const script = join(webRoot, "scripts/map-art.mjs");
sharp.cache(false);
/** Reads through a buffer so sharp never holds the temp files open (Windows). */
const image = (path: string) => sharp(readFileSync(path));
const SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256] } as const;
const run = (outDir: string) =>
execFileSync(process.execPath, [script, outDir], { cwd: webRoot, stdio: "pipe" });
describe("map art optimization script (CR-003)", () => {
let first: string;
let second: string;
beforeAll(() => {
first = mkdtempSync(join(tmpdir(), "map-art-a-"));
second = mkdtempSync(join(tmpdir(), "map-art-b-"));
run(first);
run(second);
}, 120_000);
afterAll(() => {
rmSync(first, { recursive: true, force: true });
rmSync(second, { recursive: true, force: true });
});
const present = Object.entries(artManifest.assets).filter(([, a]) => a.file !== null);
it("writes every expected file at the right size", async () => {
for (const [id, asset] of present) {
if (asset.kind === "background") {
const meta = await image(join(first, `${id}.webp`)).metadata();
expect(meta.format).toBe("webp");
expect(Math.max(meta.width!, meta.height!)).toBeLessThanOrEqual(2048);
continue;
}
const [one, two] = SIZES[asset.kind];
for (const [suffix, longSide] of [
["1x", one],
["2x", two],
] as const) {
const meta = await image(join(first, `${id}@${suffix}.png`)).metadata();
expect(Math.max(meta.width!, meta.height!), `${id}@${suffix}`).toBe(longSide);
}
}
});
it("produces identical files when run twice", () => {
const files = readdirSync(first).sort();
expect(readdirSync(second).sort()).toEqual(files);
for (const file of files) {
expect(readFileSync(join(second, file)).equals(readFileSync(join(first, file))), file).toBe(
true,
);
}
});
it("matches the committed output pixel for pixel", async () => {
// Pixels, not bytes: PNG compression may differ between platforms (CI runs Linux).
const committed = join(webRoot, "public/assets/map-gen");
for (const file of readdirSync(first).filter((f) => f.endsWith(".png"))) {
const [fresh, stored] = await Promise.all([
image(join(first, file)).raw().toBuffer(),
image(join(committed, file)).raw().toBuffer(),
]);
expect(stored.equals(fresh), file).toBe(true);
}
});
it("keeps the background under 1 MB", () => {
expect(statSync(join(first, "bg.webp")).size).toBeLessThan(1024 * 1024);
});
it("has source sprites with transparent backgrounds", async () => {
for (const [id, asset] of present) {
if (asset.kind === "background") continue;
const { data, info } = await image(join(webRoot, artManifest.sourceDir, asset.file!))
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const alpha = (x: number, y: number) => data[(y * info.width + x) * info.channels + 3];
const corners = [
alpha(0, 0),
alpha(info.width - 1, 0),
alpha(0, info.height - 1),
alpha(info.width - 1, info.height - 1),
];
expect(
corners.every((a) => a === 0),
`${asset.file} (${id}): background isn't transparent`,
).toBe(true);
}
});
});

View File

@ -1,9 +1,30 @@
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { rm } from "node:fs/promises";
import { join } from "node:path";
import { defineConfig, type Plugin } from "vite";
/**
* The user's source map art (public/assets/map) is large and only an input to
* scripts/map-art.mjs; the app loads public/assets/map-gen. Drop the sources
* from the build output (decision 0.5.37).
*/
function omitSourceArt(): Plugin {
let outDir = "dist";
return {
name: "omit-source-art",
apply: "build",
configResolved(config) {
outDir = config.build.outDir;
},
async closeBundle() {
await rm(join(outDir, "assets/map"), { recursive: true, force: true });
},
};
}
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss(), omitSourceArt()],
server: { server: {
// Same-domain API in development too (decision 0.5.20). The server listens on // Same-domain API in development too (decision 0.5.20). The server listens on
// IPv4 only, so target 127.0.0.1 rather than localhost. // IPv4 only, so target 127.0.0.1 rather than localhost.

View File

@ -451,6 +451,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar | | `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
| `src/routes.ts` | Screen list shared by router and navigation | | `src/routes.ts` | Screen list shared by router and navigation |
| `src/map/art/manifest.json` | Map art manifest (CR-003): asset id → `{ kind, file }` in `public/assets/map/` (never edited); `null` file = missing, placeholder used | | `src/map/art/manifest.json` | Map art manifest (CR-003): asset id → `{ kind, file }` in `public/assets/map/` (never edited); `null` file = missing, placeholder used |
| `scripts/map-art.mjs` | Generates `public/assets/map-gen/` (`<id>@1x.png`, `<id>@2x.png`, `bg.webp`) from the manifest with sharp; output committed |
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen | | `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` | | `src/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | | `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |

View File

@ -150,7 +150,7 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record
Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as plan §0.5.37–0.5.41. Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as plan §0.5.37–0.5.41.
- [x] **Step 10C.1 — Inventory and asset manifest.** `apps/web/src/map/art/manifest.json` maps 12 asset ids to the 11 files in `apps/web/public/assets/map/` (all 1254×1254; sprites RGBA). `badge-cloud-platform` is listed with `file: null` (not provided). Content: `sprite` on every location and ship model, `badge` on Jupiter Cloud Platform. Tests: `art-manifest.test.ts`, content sprite-key tests. - [x] **Step 10C.1 — Inventory and asset manifest.** `apps/web/src/map/art/manifest.json` maps 12 asset ids to the 11 files in `apps/web/public/assets/map/` (all 1254×1254; sprites RGBA). `badge-cloud-platform` is listed with `file: null` (not provided). Content: `sprite` on every location and ship model, `badge` on Jupiter Cloud Platform. Tests: `art-manifest.test.ts`, content sprite-key tests.
- [ ] Step 10C.2 — Asset optimization script - [x] **Step 10C.2 — Asset optimization script.** `apps/web/scripts/map-art.mjs` (sharp; `pnpm --filter @deadhead/web map-art`, and first in `build` with `--if-available`) writes 21 files to `public/assets/map-gen/` (committed): bodies and Sun 256/512 px, ships 128/256 px (trimmed), `bg.webp` 1254 px / 106 KB. The Vite build drops the source art from `dist`. Test: `map-art.test.ts` (sizes, byte-identical reruns, pixels match the committed output, bg < 1 MB, transparent source corners).
- [ ] Step 10C.3 — Map style config - [ ] Step 10C.3 — Map style config
- [ ] Step 10C.4 — Asset loading and fallback - [ ] Step 10C.4 — Asset loading and fallback
- [ ] Step 10C.5 — Background - [ ] Step 10C.5 — Background

View File

@ -136,6 +136,9 @@ importers:
jsdom: jsdom:
specifier: ^30.1.1 specifier: ^30.1.1
version: 30.1.1(@noble/hashes@2.4.0) version: 30.1.1(@noble/hashes@2.4.0)
sharp:
specifier: ^0.35.5
version: 0.35.5(@types/node@24.19.1)
tailwindcss: tailwindcss:
specifier: ^4.3.3 specifier: ^4.3.3
version: 4.3.3 version: 4.3.3
@ -337,6 +340,9 @@ packages:
'@drizzle-team/brocli@0.10.2': '@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.hirok.io' deprecated: 'Merged into tsx: https://tsx.hirok.io'
@ -875,6 +881,168 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.5':
resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.35.5':
resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.5':
resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.4':
resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.4':
resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.4':
resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.4':
resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.4':
resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.4':
resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.4':
resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.4':
resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.5':
resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.5':
resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.5':
resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.5':
resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.5':
resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.5':
resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.5':
resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.5':
resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.5':
resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.5':
resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.5':
resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.35.5':
resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.35.5':
resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -2332,6 +2500,15 @@ packages:
set-cookie-parser@3.1.2: set-cookie-parser@3.1.2:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
sharp@0.35.5:
resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2428,6 +2605,9 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.23.15: tsx@4.23.15:
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@ -2760,6 +2940,11 @@ snapshots:
'@drizzle-team/brocli@0.10.2': {} '@drizzle-team/brocli@0.10.2': {}
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
dependencies: dependencies:
esbuild: 0.18.20 esbuild: 0.18.20
@ -3069,6 +3254,112 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {} '@humanwhocodes/retry@0.4.3': {}
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.4
optional: true
'@img/sharp-darwin-x64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.4
optional: true
'@img/sharp-freebsd-wasm32@0.35.5':
dependencies:
'@img/sharp-wasm32': 0.35.5
optional: true
'@img/sharp-libvips-darwin-arm64@1.3.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.3.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.3.4':
optional: true
'@img/sharp-libvips-linux-arm@1.3.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.3.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.3.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.3.4':
optional: true
'@img/sharp-libvips-linux-x64@1.3.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
optional: true
'@img/sharp-linux-arm64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.4
optional: true
'@img/sharp-linux-arm@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.4
optional: true
'@img/sharp-linux-ppc64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.4
optional: true
'@img/sharp-linux-riscv64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.4
optional: true
'@img/sharp-linux-s390x@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.4
optional: true
'@img/sharp-linux-x64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.4
optional: true
'@img/sharp-linuxmusl-arm64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
optional: true
'@img/sharp-linuxmusl-x64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
optional: true
'@img/sharp-wasm32@0.35.5':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-webcontainers-wasm32@0.35.5':
dependencies:
'@img/sharp-wasm32': 0.35.5
optional: true
'@img/sharp-win32-arm64@0.35.5':
optional: true
'@img/sharp-win32-ia32@0.35.5':
optional: true
'@img/sharp-win32-x64@0.35.5':
optional: true
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
@ -4354,6 +4645,39 @@ snapshots:
set-cookie-parser@3.1.2: {} set-cookie-parser@3.1.2: {}
sharp@0.35.5(@types/node@24.19.1):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.5
'@img/sharp-darwin-x64': 0.35.5
'@img/sharp-freebsd-wasm32': 0.35.5
'@img/sharp-libvips-darwin-arm64': 1.3.4
'@img/sharp-libvips-darwin-x64': 1.3.4
'@img/sharp-libvips-linux-arm': 1.3.4
'@img/sharp-libvips-linux-arm64': 1.3.4
'@img/sharp-libvips-linux-ppc64': 1.3.4
'@img/sharp-libvips-linux-riscv64': 1.3.4
'@img/sharp-libvips-linux-s390x': 1.3.4
'@img/sharp-libvips-linux-x64': 1.3.4
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
'@img/sharp-linux-arm': 0.35.5
'@img/sharp-linux-arm64': 0.35.5
'@img/sharp-linux-ppc64': 0.35.5
'@img/sharp-linux-riscv64': 0.35.5
'@img/sharp-linux-s390x': 0.35.5
'@img/sharp-linux-x64': 0.35.5
'@img/sharp-linuxmusl-arm64': 0.35.5
'@img/sharp-linuxmusl-x64': 0.35.5
'@img/sharp-webcontainers-wasm32': 0.35.5
'@img/sharp-win32-arm64': 0.35.5
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
'@types/node': 24.19.1
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0
@ -4426,6 +4750,9 @@ snapshots:
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
tslib@2.8.1:
optional: true
tsx@4.23.15: tsx@4.23.15:
dependencies: dependencies:
esbuild: 0.28.2 esbuild: 0.28.2