99 lines
3.2 KiB
TypeScript
99 lines
3.2 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|