Spaces:
Running
Running
| 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); | |
| }); |