const Packager = require("@turbowarp/packager"); const fetch = require("cross-fetch").default; const fs = require("fs"); // Scratch プロジェクトデータを取得 async function fetchProjectData(projectId) { const metaRes = await fetch( `https://api.scratch.mit.edu/projects/${encodeURIComponent(projectId)}` ); if (!metaRes.ok) throw new Error(`project metadata fetch failed: ${metaRes.status}`); const meta = await metaRes.json(); const token = meta.project_token; if (!token) throw new Error("project_token が取得できませんでした"); const projectRes = await fetch( `https://projects.scratch.mit.edu/${encodeURIComponent(projectId)}?token=${encodeURIComponent(token)}` ); if (!projectRes.ok) throw new Error(`project data fetch failed: ${projectRes.status}`); return Buffer.from(await projectRes.arrayBuffer()); } // options 引数を「ファイルパス」または「JSON文字列」として読み込む function readOptionsArg(arg) { if (!arg) return {}; try { if (fs.existsSync(arg) && fs.statSync(arg).isFile()) { return JSON.parse(fs.readFileSync(arg, "utf8")); } } catch { // ファイルとして読めなければ JSON 文字列として解釈する } return JSON.parse(arg); } function isPlainObject(v) { return v !== null && typeof v === "object" && !Array.isArray(v); } function asBool(v, fallback = false) { if (v === undefined || v === null || v === "") return fallback; if (typeof v === "boolean") return v; if (typeof v === "number") return v !== 0; const s = String(v).toLowerCase(); return s === "true" || s === "1" || s === "yes" || s === "on"; } function asNumber(v, fallback) { if (v === undefined || v === null || v === "") return fallback; const n = Number(v); return Number.isFinite(n) ? n : fallback; } function asInt(v, fallback) { const n = asNumber(v, NaN); return Number.isFinite(n) ? Math.trunc(n) : fallback; } function asString(v, fallback) { if (v === undefined || v === null) return fallback; if (typeof v === "string") return v; return String(v); } function asNonEmptyString(v, fallback) { if (typeof v === "string" && v.trim() !== "") return v; return fallback; } function asStringArray(v, fallback = []) { if (Array.isArray(v)) return v.filter((x) => typeof x === "string"); if (typeof v === "string" && v.trim() !== "") return [v]; return fallback; } // URL から画像を Packager.Image に変換 async function imageFromUrl(url) { if (!url) return undefined; const res = await fetch(url); if (!res.ok) throw new Error(`image fetch failed: ${res.status}`); const contentType = (res.headers.get("content-type") || "image/png").split(";")[0].trim(); const buffer = Buffer.from(await res.arrayBuffer()); return new Packager.Image(contentType, buffer); } // 画像として受け取る値を正規化する async function imageFromValue(value) { if (value === undefined) return undefined; if (value === null || value === "") return null; // JSON 側では通常ここには来ないが、コード直書きの場合も吸収する if (typeof value === "string") { return await imageFromUrl(value); } if (typeof Packager.Image === "function" && value instanceof Packager.Image) { return value; } // 互換用: { contentType, buffer } っぽい形を受けた場合 if ( isPlainObject(value) && typeof value.contentType === "string" && value.buffer !== undefined ) { const buf = Buffer.isBuffer(value.buffer) ? value.buffer : Buffer.from(value.buffer); return new Packager.Image(value.contentType, buf); } throw new Error("画像は URL 文字列、null、または Packager.Image で指定してください"); } async function applyOptions(packager, options, fallbackProjectId) { if (!isPlainObject(options)) options = {}; // projectId は cloudVariables などでも使うので、CLI引数を既定値にする packager.options.projectId = asNonEmptyString(options.projectId, fallbackProjectId); // 基本オプション if ("turbo" in options) packager.options.turbo = asBool(options.turbo, packager.options.turbo); if ("interpolation" in options) { packager.options.interpolation = asBool(options.interpolation, packager.options.interpolation); } if ("framerate" in options) { packager.options.framerate = asNumber(options.framerate, packager.options.framerate); } if ("highQualityPen" in options) { packager.options.highQualityPen = asBool(options.highQualityPen, packager.options.highQualityPen); } if ("maxClones" in options) { packager.options.maxClones = asInt(options.maxClones, packager.options.maxClones); } if ("fencing" in options) { packager.options.fencing = asBool(options.fencing, packager.options.fencing); } if ("miscLimits" in options) { packager.options.miscLimits = asBool(options.miscLimits, packager.options.miscLimits); } if ("stageWidth" in options) { packager.options.stageWidth = asInt(options.stageWidth, packager.options.stageWidth); } if ("stageHeight" in options) { packager.options.stageHeight = asInt(options.stageHeight, packager.options.stageHeight); } if ("resizeMode" in options) { packager.options.resizeMode = asNonEmptyString(options.resizeMode, packager.options.resizeMode); } if ("autoplay" in options) { packager.options.autoplay = asBool(options.autoplay, packager.options.autoplay); } if ("username" in options) { packager.options.username = asNonEmptyString(options.username, packager.options.username); } if ("closeWhenStopped" in options) { packager.options.closeWhenStopped = asBool( options.closeWhenStopped, packager.options.closeWhenStopped ); } if ("packagedRuntime" in options) { packager.options.packagedRuntime = asBool( options.packagedRuntime, packager.options.packagedRuntime ); } if ("target" in options) { packager.options.target = asNonEmptyString(options.target, packager.options.target); } if ("maxTextureDimension" in options) { packager.options.maxTextureDimension = asInt( options.maxTextureDimension, packager.options.maxTextureDimension ); } // custom if (isPlainObject(options.custom)) { if ("css" in options.custom) { packager.options.custom.css = asString(options.custom.css, packager.options.custom.css); } if ("js" in options.custom) { packager.options.custom.js = asString(options.custom.js, packager.options.custom.js); } } // appearance if (isPlainObject(options.appearance)) { if ("background" in options.appearance) { packager.options.appearance.background = asNonEmptyString( options.appearance.background, packager.options.appearance.background ); } if ("foreground" in options.appearance) { packager.options.appearance.foreground = asNonEmptyString( options.appearance.foreground, packager.options.appearance.foreground ); } if ("accent" in options.appearance) { packager.options.appearance.accent = asNonEmptyString( options.appearance.accent, packager.options.appearance.accent ); } } // loadingScreen if (isPlainObject(options.loadingScreen)) { if ("progressBar" in options.loadingScreen) { packager.options.loadingScreen.progressBar = asBool( options.loadingScreen.progressBar, packager.options.loadingScreen.progressBar ); } if ("text" in options.loadingScreen) { packager.options.loadingScreen.text = asString( options.loadingScreen.text, packager.options.loadingScreen.text ); } if ("imageMode" in options.loadingScreen) { packager.options.loadingScreen.imageMode = asNonEmptyString( options.loadingScreen.imageMode, packager.options.loadingScreen.imageMode ); } if ("image" in options.loadingScreen) { const loadingImage = await imageFromValue(options.loadingScreen.image); packager.options.loadingScreen.image = loadingImage ?? null; } } // controls if (isPlainObject(options.controls)) { if (isPlainObject(options.controls.greenFlag) && "enabled" in options.controls.greenFlag) { packager.options.controls.greenFlag.enabled = asBool( options.controls.greenFlag.enabled, packager.options.controls.greenFlag.enabled ); } if (isPlainObject(options.controls.stopAll) && "enabled" in options.controls.stopAll) { packager.options.controls.stopAll.enabled = asBool( options.controls.stopAll.enabled, packager.options.controls.stopAll.enabled ); } if (isPlainObject(options.controls.fullscreen) && "enabled" in options.controls.fullscreen) { packager.options.controls.fullscreen.enabled = asBool( options.controls.fullscreen.enabled, packager.options.controls.fullscreen.enabled ); } if (isPlainObject(options.controls.pause) && "enabled" in options.controls.pause) { packager.options.controls.pause.enabled = asBool( options.controls.pause.enabled, packager.options.controls.pause.enabled ); } } // monitors if (isPlainObject(options.monitors)) { if ("editableLists" in options.monitors) { packager.options.monitors.editableLists = asBool( options.monitors.editableLists, packager.options.monitors.editableLists ); } if ("variableColor" in options.monitors) { packager.options.monitors.variableColor = asNonEmptyString( options.monitors.variableColor, packager.options.monitors.variableColor ); } if ("listColor" in options.monitors) { packager.options.monitors.listColor = asNonEmptyString( options.monitors.listColor, packager.options.monitors.listColor ); } } // compiler if (isPlainObject(options.compiler)) { if ("enabled" in options.compiler) { packager.options.compiler.enabled = asBool( options.compiler.enabled, packager.options.compiler.enabled ); } if ("warpTimer" in options.compiler) { packager.options.compiler.warpTimer = asBool( options.compiler.warpTimer, packager.options.compiler.warpTimer ); } } // app if (isPlainObject(options.app)) { if ("icon" in options.app) { const icon = await imageFromValue(options.app.icon); packager.options.app.icon = icon ?? null; } if ("packageName" in options.app) { packager.options.app.packageName = asNonEmptyString( options.app.packageName, packager.options.app.packageName ); } if ("windowTitle" in options.app) { packager.options.app.windowTitle = asNonEmptyString( options.app.windowTitle, packager.options.app.windowTitle ); } if ("windowMode" in options.app) { packager.options.app.windowMode = asNonEmptyString( options.app.windowMode, packager.options.app.windowMode ); } if ("version" in options.app) { packager.options.app.version = asNonEmptyString(options.app.version, packager.options.app.version); } if ("escapeBehavior" in options.app) { packager.options.app.escapeBehavior = asNonEmptyString( options.app.escapeBehavior, packager.options.app.escapeBehavior ); } if ("windowControls" in options.app) { packager.options.app.windowControls = asNonEmptyString( options.app.windowControls, packager.options.app.windowControls ); } if ("backgroundThrottling" in options.app) { packager.options.app.backgroundThrottling = asBool( options.app.backgroundThrottling, packager.options.app.backgroundThrottling ); } } // chunks if (isPlainObject(options.chunks)) { if ("gamepad" in options.chunks) { packager.options.chunks.gamepad = asBool(options.chunks.gamepad, packager.options.chunks.gamepad); } if ("pointerlock" in options.chunks) { packager.options.chunks.pointerlock = asBool( options.chunks.pointerlock, packager.options.chunks.pointerlock ); } } // cloudVariables if (isPlainObject(options.cloudVariables)) { if ("mode" in options.cloudVariables) { packager.options.cloudVariables.mode = asNonEmptyString( options.cloudVariables.mode, packager.options.cloudVariables.mode ); } if ("cloudHost" in options.cloudVariables) { packager.options.cloudVariables.cloudHost = asNonEmptyString( options.cloudVariables.cloudHost, packager.options.cloudVariables.cloudHost ); } if ("custom" in options.cloudVariables && isPlainObject(options.cloudVariables.custom)) { packager.options.cloudVariables.custom = { ...options.cloudVariables.custom }; } if ("specialCloudBehaviors" in options.cloudVariables) { packager.options.cloudVariables.specialCloudBehaviors = asBool( options.cloudVariables.specialCloudBehaviors, packager.options.cloudVariables.specialCloudBehaviors ); } if ("unsafeCloudBehaviors" in options.cloudVariables) { packager.options.cloudVariables.unsafeCloudBehaviors = asBool( options.cloudVariables.unsafeCloudBehaviors, packager.options.cloudVariables.unsafeCloudBehaviors ); } } // cursor if (isPlainObject(options.cursor)) { if ("type" in options.cursor) { packager.options.cursor.type = asNonEmptyString(options.cursor.type, packager.options.cursor.type); } if ("custom" in options.cursor) { const cursorImage = await imageFromValue(options.cursor.custom); packager.options.cursor.custom = cursorImage ?? null; } if (isPlainObject(options.cursor.center)) { if ("x" in options.cursor.center) { packager.options.cursor.center.x = asNumber( options.cursor.center.x, packager.options.cursor.center.x ); } if ("y" in options.cursor.center) { packager.options.cursor.center.y = asNumber( options.cursor.center.y, packager.options.cursor.center.y ); } } } // steamworks if (isPlainObject(options.steamworks)) { if ("appId" in options.steamworks) { packager.options.steamworks.appId = asNonEmptyString( options.steamworks.appId, packager.options.steamworks.appId ); } if ("onError" in options.steamworks) { packager.options.steamworks.onError = asNonEmptyString( options.steamworks.onError, packager.options.steamworks.onError ); } } // extensions if ("extensions" in options) { packager.options.extensions = asStringArray(options.extensions, packager.options.extensions); } // bakeExtensions if ("bakeExtensions" in options) { packager.options.bakeExtensions = asBool( options.bakeExtensions, packager.options.bakeExtensions ); } } async function main() { const projectId = process.argv[2]; const optionsArg = process.argv[3]; if (!projectId) throw new Error("project id required"); const options = readOptionsArg(optionsArg); const projectData = await fetchProjectData(projectId); const loadedProject = await Packager.loadProject(projectData); const packager = new Packager.Packager(); packager.project = loadedProject; await applyOptions(packager, options, projectId); // パッケージ作成 const result = await packager.package(); // 出力 process.stdout.write(Buffer.from(result.data)); } main().catch((err) => { console.error(err); process.exit(1); });