import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate"; import { type Project, clone, validateProject } from "./schema.ts"; export const mimeFor = (n: string) => n.toLowerCase().endsWith(".gltf") ? "model/gltf+json" : "model/gltf-binary"; export function base64(a: Uint8Array) { let s = ""; for (let i = 0; i < a.length; i += 16384) s += String.fromCharCode(...a.subarray(i, i + 16384)); return btoa(s); } export function decodeData(uri: string) { const i = uri.indexOf(","); if (i < 0 || !uri.slice(0, i).endsWith(";base64")) throw Error("Ожидался data URI base64"); return Uint8Array.from(atob(uri.slice(i + 1)), (c) => c.charCodeAt(0)); } export async function readBytes(uri: string) { if (uri.startsWith("data:")) return decodeData(uri); const r = await fetch(uri); if (!r.ok) throw Error("Не удалось загрузить " + uri); return new Uint8Array(await r.arrayBuffer()); } async function pack( project: Project, read: (uri: string) => Promise, ) { validateProject(project); const p = clone(project), files: Record = {}; for (const a of p.assets) if (a.uri) { const file = "assets/" + a.id + (a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb"); files[file] = await read(a.uri); a.uri = file; } return { p, files }; } export async function projectArchive( project: Project, read: (uri: string) => Promise = readBytes, ) { const { p, files } = await pack(project, read); files["project.forma.json"] = strToU8(JSON.stringify(p, null, 2)); for (const s of p.scripts) files["scripts/" + s.id + ".js"] = strToU8(s.source); files["README.txt"] = strToU8( "Open this .forma archive in Forma. project.forma.json is authoritative. scripts/ contains readable copies.\n", ); return zipSync(files, { level: 6 }); } export async function gameArchive( project: Project, read: (uri: string) => Promise = readBytes, ) { const { p, files } = await pack(project, read); files["project.forma.json"] = strToU8(JSON.stringify(p)); files["player.js"] = await read("/engine/player.js"); files["player.css"] = await read("/engine/player.css"); files["index.html"] = strToU8( '' + p.name.replace(/[<>&"]/g, "") + '
', ); files["README.txt"] = strToU8( "Serve this directory with an HTTP server, e.g. python3 -m http.server 8080. Open http://localhost:8080. No editor or MCP required.\n", ); return zipSync(files, { level: 6 }); } export function unpackProject(bytes: Uint8Array): Project { let p: Project; if (bytes.length > 80 * 1024 * 1024) throw Error("Проект превышает 80 МБ"); if (strFromU8(bytes.subarray(0, 20)).trimStart().startsWith("{")) p = JSON.parse(strFromU8(bytes)); else { let total = 0; const files = unzipSync(bytes, { filter: (f) => { total += f.originalSize; if (total > 200 * 1024 * 1024 || f.originalSize > 60 * 1024 * 1024) throw Error("Распакованный проект превышает лимит"); if ( f.name.includes("..") || f.name.startsWith("/") || f.name.includes("\\") ) throw Error("Небезопасный путь в архиве"); return true; }, }); if (!files["project.forma.json"]) throw Error("Нет project.forma.json"); p = JSON.parse(strFromU8(files["project.forma.json"])); validateProject(p); for (const a of p.assets) if (a.uri && !a.uri.startsWith("data:")) { const uri = a.uri.replace(/^\.\//, "").replace(/^\//, ""); if (!files[uri]) throw Error("Ресурс отсутствует: " + uri); a.uri = "data:" + mimeFor(a.name) + ";base64," + base64(files[uri]); } } validateProject(p); return p; } export function download( bytes: Uint8Array, name: string, mime = "application/octet-stream", ) { const url = URL.createObjectURL( new Blob([bytes as BlobPart], { type: mime }), ); const a = document.createElement("a"); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 3000); }