import { createClient } from "@supabase/supabase-js"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { gunzipSync } from "node:zlib"; import { renderSnapshot } from "../renderer/render.mjs"; if (existsSync(".env.local")) process.loadEnvFile(".env.local"); const id = process.argv[2]; if ( !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test( id || "", ) ) throw new Error( "Usage: npm run repair:panoramas -- ", ); const client = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SECRET_KEY, { auth: { persistSession: false, autoRefreshToken: false } }, ); function check(r) { if (r.error) throw new Error(r.error.message); return r.data; } const dataset = check( await client.from("datasets").select("*").eq("id", id).single(), ); if ( dataset.source !== "plugin" || dataset.revision_id || dataset.parent_dataset_id || !["draft", "rejected"].includes(dataset.state) ) throw new Error( "Only a private, unpublished plugin draft can be repaired. Withdraw review or recover failed processing first.", ); const job = check( await client .from("upload_jobs") .select("state") .eq("dataset_id", id) .single(), ); if (job.state !== "complete") throw new Error("Wait for processing to finish first."); const input = check( await client .from("dataset_inputs") .select("manifest,validated") .eq("dataset_id", id) .single(), ); if (!input.validated) throw new Error("The export must be validated first."); if ( (input.manifest.availableViewpoints ?? input.manifest.viewpoints).some( (p) => p.assetRoot, ) ) throw new Error( "Repair mixed-media drafts by uploading a new capture; accepted contribution files cannot be overwritten.", ); const asset = input.manifest.assets.find((a) => a.path === "terrain.json.gz"); if (!asset) throw new Error("Missing original terrain export"); const raw = check( await client.storage .from("minegeo-raw") .download(`${dataset.owner_id}/${id}/${asset.path}`), ); const bytes = Buffer.from(await raw.arrayBuffer()); if ( bytes.length !== asset.bytes || createHash("sha256").update(bytes).digest("hex") !== asset.sha256 ) throw new Error("Original terrain checksum mismatch"); const output = `.cache/panorama-repair/${id}`; await mkdir(output, { recursive: true }); const terrain = JSON.parse( gunzipSync(bytes, { maxOutputLength: 256 * 1024 * 1024 }).toString(), ); await renderSnapshot( terrain, input.manifest.availableViewpoints ?? input.manifest.viewpoints, output, { size: Number(process.env.MINEGEO_FACE_SIZE || 1024), onProgress: async ({ percent, message }) => console.log(`${Math.floor(percent)}% ${message}`), }, ); // Render first, then briefly lock the draft while replacing its private images. // Its complete job stays complete, so another worker cannot pick it up. const locked = check( await client .from("datasets") .update({ state: "processing", updated_at: new Date().toISOString() }) .eq("id", id) .eq("state", dataset.state) .eq("updated_at", dataset.updated_at) .is("revision_id", null) .select("id"), ); if (locked.length !== 1) throw new Error( "Draft changed during rendering; images were retained locally but nothing was replaced.", ); try { for (const entry of (await readdir(output, { recursive: true })).filter((p) => p.endsWith(".webp"), )) { check( await client.storage .from("minegeo-media") .upload( `${id}/${entry.replaceAll("\\", "/")}`, await readFile(`${output}/${entry}`), { upsert: true, contentType: "image/webp", cacheControl: "60" }, ), ); } check( await client .from("datasets") .update({ state: dataset.state, updated_at: new Date().toISOString() }) .eq("id", id) .eq("state", "processing") .is("revision_id", null) .select("id") .single(), ); await writeFile( `${output}/repair.json`, JSON.stringify({ datasetId: id, repaired: true, at: new Date().toISOString(), }), ); console.log( `Repaired draft ${id}. Reload its dashboard preview; no Minecraft export or new upload is needed.`, ); } catch (error) { await client .from("datasets") .update({ state: "failed", rejection_reason: "Image repair interrupted. Reprocess the retained raw upload.", updated_at: new Date().toISOString(), }) .eq("id", id) .eq("state", "processing") .is("revision_id", null); throw error; }