Add map import and export with durable refresh progress

This commit is contained in:
2026-10-07 19:44:49 +05:00
parent dac8650ac1
commit 1307436e65
37 changed files with 2207 additions and 85 deletions

98
lib/fabric-images.ts Normal file
View File

@@ -0,0 +1,98 @@
import { createHash } from "node:crypto";
import sharp from "sharp";
import { faces, type DatasetManifest } from "../shared/protocol";
type ImageIO = {
download: (path: string) => Promise<Buffer>;
save: (path: string, bytes: Buffer) => Promise<void>;
progress: (percent: number) => Promise<void>;
signal: AbortSignal;
mapOnly?: boolean;
};
// Process one image at a time. Vercel needs neither a local capture directory
// nor a Minecraft/Chromium process for images already captured by the mod.
export async function processFabricImages(
manifest: DatasetManifest,
io: ImageIO,
) {
if (manifest.source !== "fabric" && !manifest.mediaFormat)
throw new Error("A Fabric capture is required");
const format = manifest.mediaFormat ?? "png";
const assets = new Map(manifest.assets.map((asset) => [asset.path, asset]));
const points = io.mapOnly
? []
: (manifest.availableViewpoints ?? manifest.viewpoints);
const total = 1 + points.length * faces.length;
let completed = 0;
async function read(path: string) {
io.signal.throwIfAborted();
const asset = assets.get(path);
if (!asset) throw new Error(`Missing export asset: ${path}`);
const bytes = await io.download(path);
io.signal.throwIfAborted();
if (
bytes.length !== asset.bytes ||
createHash("sha256").update(bytes).digest("hex") !== asset.sha256
)
throw new Error(`Asset checksum/size mismatch: ${path}`);
return bytes;
}
async function save(path: string, bytes: Buffer) {
io.signal.throwIfAborted();
await io.save(path, bytes);
io.signal.throwIfAborted();
}
const map = await read(`map.${format}`);
const mapMeta = await sharp(map, { limitInputPixels: 16777216 }).metadata();
if (
mapMeta.format !== format ||
!mapMeta.width ||
!mapMeta.height ||
(mapMeta.pages ?? 1) !== 1
)
throw new Error("The captured map has an invalid image format");
await save(
"map.webp",
await sharp(map, { limitInputPixels: 16777216 })
.webp({ lossless: true })
.toBuffer(),
);
await io.progress((95 * ++completed) / total);
for (const point of points) {
for (const face of faces) {
const bytes = await read(`${point.id}/${face}.${format}`);
const meta = await sharp(bytes, {
limitInputPixels: 16777216,
}).metadata();
if (
meta.format !== format ||
(meta.pages ?? 1) !== 1 ||
!meta.width ||
meta.width !== meta.height ||
meta.width < 256 ||
meta.width > 2048
)
throw new Error(
format === "png"
? "Panorama faces must be square PNGs, 256–2048 pixels"
: "Panorama faces must be square WebPs, 256–2048 pixels",
);
await save(
`${point.id}/${face}.webp`,
await sharp(bytes, { limitInputPixels: 16777216 })
.webp({ quality: 85 })
.toBuffer(),
);
if (face === "nz")
await save(
`${point.id}/preview.webp`,
await sharp(bytes, { limitInputPixels: 16777216 })
.resize(512, 512)
.webp({ quality: 76 })
.toBuffer(),
);
await io.progress((95 * ++completed) / total);
}
}
}