From 7af900e6cbe1409626f082845e534ac45a1b47d1 Mon Sep 17 00:00:00 2001 From: vesper-arch Date: Thu, 14 May 2026 19:49:49 -0400 Subject: [PATCH 1/2] Split render size into width and height for render.mjs and render_utils.mjs NOT FINISHED --- tools/spine-renderer/render.mjs | 12 ++++---- tools/spine-renderer/render_utils.mjs | 43 ++++++++++++++------------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/tools/spine-renderer/render.mjs b/tools/spine-renderer/render.mjs index e94aafc9..f1c6bf84 100644 --- a/tools/spine-renderer/render.mjs +++ b/tools/spine-renderer/render.mjs @@ -33,9 +33,11 @@ const OUTPUT_DIR = path.resolve( "../../backend/static/images/monsters" ); -const OUTPUT_SIZE = 512; // final output image size +const OUTPUT_WIDTH = 512; // final output image size +const OUTPUT_HEIGHT = 512; // final output image size const SUPERSAMPLE = 2; // render at Nx and downscale to hide triangle seams -const RENDER_SIZE = OUTPUT_SIZE * SUPERSAMPLE; +const RENDER_WIDTH = OUTPUT_WIDTH * SUPERSAMPLE; +const RENDER_HEIGHT = OUTPUT_HEIGHT * SUPERSAMPLE; const PADDING = 20 * SUPERSAMPLE; /** Minimal Texture wrapper for node-canvas Image */ @@ -190,12 +192,12 @@ async function renderMonster(monsterDir, monsterName) { const skelHeight = maxY - minY; // Calculate canvas size to fit with padding, maintaining aspect ratio - const availableSize = RENDER_SIZE - PADDING * 2; + const availableSize = RENDER_WIDTH - PADDING * 2; const scale = Math.min(availableSize / skelWidth, availableSize / skelHeight); // Render skeleton (with automatic slot-by-slot fallback for complex meshes) - const imgData = renderSkeleton(skeleton, RENDER_SIZE, scale, minX, minY, maxX, maxY); - const buffer = imageDataToPng(imgData, RENDER_SIZE, OUTPUT_SIZE); + const imgData = renderSkeleton(skeleton, RENDER_WIDTH, RENDER_HEIGHT, scale, minX, minY, maxX, maxY); + const buffer = imageDataToPng(imgData, RENDER_WIDTH, RENDER_HEIGHT, OUTPUT_WIDTH, OUTPUT_HEIGHT); // Save to PNG fs.mkdirSync(OUTPUT_DIR, { recursive: true }); diff --git a/tools/spine-renderer/render_utils.mjs b/tools/spine-renderer/render_utils.mjs index d818fa0f..6c73a2d3 100644 --- a/tools/spine-renderer/render_utils.mjs +++ b/tools/spine-renderer/render_utils.mjs @@ -21,16 +21,16 @@ const BLANK_PNG_THRESHOLD = 2000; // bytes — a blank 512x512 transparent PNG i * First tries normal all-at-once rendering. If the result is blank (clip path * corruption), falls back to slot-by-slot compositing. */ -export function renderSkeleton(skeleton, renderSize, scale, minX, minY, maxX, maxY) { +export function renderSkeleton(skeleton, renderWidth, renderHeight, scale, minX, minY, maxX, maxY) { const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; // Try normal render first - const canvas = createCanvas(renderSize, renderSize); + const canvas = createCanvas(renderWidth, renderHeight); const ctx = canvas.getContext("2d"); - ctx.clearRect(0, 0, renderSize, renderSize); + ctx.clearRect(0, 0, renderWidth, renderHeight); ctx.save(); - ctx.translate(renderSize / 2, renderSize / 2); + ctx.translate(renderWidth / 2, renderHeight / 2); ctx.scale(scale, -scale); ctx.translate(-cx, -cy); const renderer = new SkeletonRenderer(ctx); @@ -39,7 +39,7 @@ export function renderSkeleton(skeleton, renderSize, scale, minX, minY, maxX, ma ctx.restore(); // Check pixel count via getImageData (bypasses clip corruption) - const imgData = ctx.getImageData(0, 0, renderSize, renderSize); + const imgData = ctx.getImageData(0, 0, renderWidth, renderHeight); let nonTransparent = 0; for (let i = 3; i < imgData.data.length; i += 4) { if (imgData.data[i] > 0) nonTransparent++; @@ -54,18 +54,19 @@ export function renderSkeleton(skeleton, renderSize, scale, minX, minY, maxX, ma // OOM — canvas state corrupted } - if (bufferOk && nonTransparent > renderSize * renderSize * 0.01) { + if (bufferOk && nonTransparent > renderWidth * renderWidth * 0.01 && nonTransparent > renderHeight * renderHeight * 0.01) { // Normal render succeeded — copy pixels to fresh canvas to be safe return imgData; } // Fallback: slot-by-slot compositing console.log(" (using slot-by-slot fallback renderer)"); - return renderSlotBySlot(skeleton, renderSize, scale, cx, cy); + return renderSlotBySlot(skeleton, renderWidth, scale, cx, cy); } -function renderSlotBySlot(skeleton, renderSize, scale, cx, cy) { - const compPixels = new Uint8ClampedArray(renderSize * renderSize * 4); +function renderSlotBySlot(skeleton, renderWidth, renderHeight, scale, cx, cy) { + // TODO: Not sure how to generalize this, may use the max of the height and width. + const compPixels = new Uint8ClampedArray(renderWidth * renderWidth * 4); for (const slot of skeleton.drawOrder) { const att = slot.getAttachment(); @@ -82,10 +83,10 @@ function renderSlotBySlot(skeleton, renderSize, scale, cx, cy) { } // Render this single slot - const tempCanvas = createCanvas(renderSize, renderSize); + const tempCanvas = createCanvas(renderWidth, renderHeight); const tempCtx = tempCanvas.getContext("2d"); tempCtx.save(); - tempCtx.translate(renderSize / 2, renderSize / 2); + tempCtx.translate(renderWidth / 2, renderHeight / 2); tempCtx.scale(scale, -scale); tempCtx.translate(-cx, -cy); const renderer = new SkeletonRenderer(tempCtx); @@ -97,7 +98,7 @@ function renderSlotBySlot(skeleton, renderSize, scale, cx, cy) { for (const { slot: s, att: a } of saved) s.setAttachment(a); // Alpha-composite raw pixels (source-over blending) - const src = tempCtx.getImageData(0, 0, renderSize, renderSize).data; + const src = tempCtx.getImageData(0, 0, renderWidth, renderHeight).data; for (let i = 0; i < src.length; i += 4) { const sa = src[i + 3] / 255; if (sa === 0) continue; @@ -112,9 +113,9 @@ function renderSlotBySlot(skeleton, renderSize, scale, cx, cy) { } // Return as ImageData - const resultCanvas = createCanvas(renderSize, renderSize); + const resultCanvas = createCanvas(renderWidth, renderHeight); const resultCtx = resultCanvas.getContext("2d"); - const resultData = resultCtx.createImageData(renderSize, renderSize); + const resultData = resultCtx.createImageData(renderWidth, renderHeight); resultData.data.set(compPixels); return resultData; } @@ -257,21 +258,21 @@ function blurSeams(imgData, width) { /** * Convert ImageData to a downscaled PNG buffer. */ -export function imageDataToPng(imgData, renderSize, outputSize) { +export function imageDataToPng(imgData, renderWidth, renderHeight, outputWidth, outputHeight) { // Fix triangle seam artifacts then blur seam areas before downscaling - fillSeams(imgData, renderSize); - blurSeams(imgData, renderSize); + fillSeams(imgData, renderWidth); + blurSeams(imgData, renderWidth); - const fullCanvas = createCanvas(renderSize, renderSize); + const fullCanvas = createCanvas(renderWidth, renderHeight); const fullCtx = fullCanvas.getContext("2d"); fullCtx.putImageData(imgData, 0, 0); - if (renderSize === outputSize) { + if (renderWidth === outputWidth) { return fullCanvas.toBuffer("image/png"); } - const outCanvas = createCanvas(outputSize, outputSize); + const outCanvas = createCanvas(outputWidth, outputHeight); const outCtx = outCanvas.getContext("2d"); - outCtx.drawImage(fullCanvas, 0, 0, outputSize, outputSize); + outCtx.drawImage(fullCanvas, 0, 0, outputWidth, outputHeight); return outCanvas.toBuffer("image/png"); } From 05d3e2479470845581eb4f5218ed49594b815dbe Mon Sep 17 00:00:00 2001 From: vesper-arch Date: Thu, 14 May 2026 20:14:09 -0400 Subject: [PATCH 2/2] Generalize size to width and height for all of the render files, probably not done, still needs to be tested. --- tools/spine-renderer/render_all.mjs | 13 ++++--- tools/spine-renderer/render_all_webgl.mjs | 43 +++++++++++---------- tools/spine-renderer/render_extras.mjs | 21 ++++++---- tools/spine-renderer/render_gif.mjs | 35 +++++++++-------- tools/spine-renderer/render_hires.mjs | 14 ++++--- tools/spine-renderer/render_neow.mjs | 14 ++++--- tools/spine-renderer/render_utils.mjs | 3 +- tools/spine-renderer/render_webgl.mjs | 47 ++++++++++++----------- 8 files changed, 103 insertions(+), 87 deletions(-) diff --git a/tools/spine-renderer/render_all.mjs b/tools/spine-renderer/render_all.mjs index 5c94675b..972123e6 100644 --- a/tools/spine-renderer/render_all.mjs +++ b/tools/spine-renderer/render_all.mjs @@ -18,9 +18,11 @@ const BASE = path.resolve(import.meta.dirname, "../.."); const ANIM_ROOT = path.join(BASE, "extraction/raw/animations"); const OUTPUT_ROOT = path.join(BASE, "backend/static/images/renders"); -const OUTPUT_SIZE = 512; +const OUTPUT_WIDTH = 512; +const OUTPUT_HEIGHT = 512; const SUPERSAMPLE = 3; -const RENDER_SIZE = OUTPUT_SIZE * SUPERSAMPLE; +const RENDER_WIDTH = OUTPUT_WIDTH * SUPERSAMPLE; +const RENDER_HEIGHT = OUTPUT_HEIGHT * SUPERSAMPLE; const PADDING = 20 * SUPERSAMPLE; const SHADOW_NAMES = new Set(["shadow", "shadow2", "ground", "ground_shadow"]); const IDLE_NAMES = ["idle_loop", "idle", "Idle_loop", "Idle", "rest_idle", "rest_loop", "loop", "animation"]; @@ -125,12 +127,13 @@ async function renderSkel(skelPath, outPath) { } const sw = maxX - minX, sh = maxY - minY; - const avail = RENDER_SIZE - PADDING * 2; + // TODO: Generalize this. + const avail = RENDER_WIDTH - PADDING * 2; const scale = Math.min(avail / sw, avail / sh); // Render skeleton (with automatic slot-by-slot fallback for complex meshes) - const imgData = renderSkeleton(skeleton, RENDER_SIZE, scale, minX, minY, maxX, maxY); - const buffer = imageDataToPng(imgData, RENDER_SIZE, OUTPUT_SIZE); + const imgData = renderSkeleton(skeleton, RENDER_WIDTH, RENDER_HEIGHT, scale, minX, minY, maxX, maxY); + const buffer = imageDataToPng(imgData, RENDER_WIDTH, RENDER_HEIGHT, OUTPUT_WIDTH, OUTPUT_HEIGHT); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, buffer); diff --git a/tools/spine-renderer/render_all_webgl.mjs b/tools/spine-renderer/render_all_webgl.mjs index ce232d09..be171fa2 100644 --- a/tools/spine-renderer/render_all_webgl.mjs +++ b/tools/spine-renderer/render_all_webgl.mjs @@ -16,7 +16,8 @@ const BASE = path.resolve(__dirname, "../.."); const ANIM_ROOT = path.join(BASE, "extraction/raw/animations"); const OUTPUT_ROOT = path.join(BASE, "backend/static/images/renders"); -const OUTPUT_SIZE = 512; +const OUTPUT_WIDTH = 512; +const OUTPUT_HEIGHT = 512; const IDLE_NAMES = ["idle_loop", "idle", "Idle_loop", "Idle", "rest_idle", "rest_loop", "loop", "animation"]; const SHADOW_NAMES = ["shadow", "shadow2", "shadow_v2", "ground", "ground_shadow"]; const HIDDEN_SLOTS = ["smoketex", "smoke_tex", "smokeplacholder", "smoke_placeholder", "megatail", "megablade"]; @@ -36,7 +37,7 @@ function findAllSkels(dir) { const spineCorePath = path.join(__dirname, "node_modules/@esotericsoftware/spine-webgl/dist/iife/spine-webgl.js"); const spineCoreCode = fs.readFileSync(spineCorePath, "utf-8"); -async function renderSkel(page, skelPath, outPath, outputSize) { +async function renderSkel(page, skelPath, outPath, outputWidth, outputHeight) { const dir = path.dirname(skelPath); const skelName = path.basename(skelPath, ".skel"); const atlasPath = path.join(dir, skelName + ".atlas"); @@ -71,7 +72,7 @@ async function renderSkel(page, skelPath, outPath, outputSize) { }); const result = await page.evaluate(async (params) => { - const { skelB64, atlasB64, textureData, outputSize, idleNames, shadowNames, hiddenSlots, spineCoreCode } = params; + const { skelB64, atlasB64, textureData, outputWidth, outputHeight, idleNames, shadowNames, hiddenSlots, spineCoreCode } = params; if (!window.spine) { eval(spineCoreCode.replace(/^"use strict";\s*var spine\s*=/, "window.spine =")); @@ -79,8 +80,8 @@ async function renderSkel(page, skelPath, outPath, outputSize) { const spine = window.spine; const canvas = document.createElement("canvas"); - canvas.width = outputSize; - canvas.height = outputSize; + canvas.width = outputWidth; + canvas.height = outputHeight; document.body.appendChild(canvas); const gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true }) @@ -175,17 +176,17 @@ async function renderSkel(page, skelPath, outPath, outputSize) { if (!isFinite(minX)) return { error: "no bounds" }; const sw = maxX - minX, sh = maxY - minY; - const padding = outputSize * 0.04; - const avail = outputSize - padding * 2; + const padding = outputWidth * 0.04; + const avail = outputWidth - padding * 2; const scale = Math.min(avail / sw, avail / sh); const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; mvp.ortho2d( - cx - outputSize / (2 * scale), cy - outputSize / (2 * scale), - outputSize / scale, outputSize / scale + cx - outputWidth / (2 * scale), cy - outputHeight / (2 * scale), + outputWidth / scale, outputHeight / scale ); - gl.viewport(0, 0, outputSize, outputSize); + gl.viewport(0, 0, outputWidth, outputWidth); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.enable(gl.BLEND); @@ -210,14 +211,14 @@ async function renderSkel(page, skelPath, outPath, outputSize) { batcher.end(); shader.unbind(); - const pixels = new Uint8Array(outputSize * outputSize * 4); - gl.readPixels(0, 0, outputSize, outputSize, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + const pixels = new Uint8Array(outputWidth * outputHeight * 4); + gl.readPixels(0, 0, outputWidth, outputHeight, gl.RGBA, gl.UNSIGNED_BYTE, pixels); // Flip vertically const flipped = new Uint8Array(pixels.length); - const rowSize = outputSize * 4; - for (let y = 0; y < outputSize; y++) { - flipped.set(pixels.subarray((outputSize - 1 - y) * rowSize, (outputSize - y) * rowSize), y * rowSize); + const rowSize = outputWidth * 4; + for (let y = 0; y < outputWidth; y++) { + flipped.set(pixels.subarray((outputWidth - 1 - y) * rowSize, (outputWidth - y) * rowSize), y * rowSize); } // Check if anything was actually rendered @@ -225,7 +226,7 @@ async function renderSkel(page, skelPath, outPath, outputSize) { for (let i = 3; i < flipped.length; i += 4) { if (flipped[i] > 0) nonTransparent++; } - if (nonTransparent < outputSize * outputSize * 0.001) { + if (nonTransparent < outputWidth * outputWidth * 0.001) { return { error: "no bounds (blank render)" }; } @@ -235,7 +236,7 @@ async function renderSkel(page, skelPath, outPath, outputSize) { size: `${sw.toFixed(0)}x${sh.toFixed(0)}`, }; }, { - skelB64, atlasB64, textureData, outputSize, + skelB64, atlasB64, textureData, outputWidth, outputHeight, idleNames: IDLE_NAMES, shadowNames: SHADOW_NAMES, hiddenSlots: HIDDEN_SLOTS, spineCoreCode, }); @@ -245,9 +246,9 @@ async function renderSkel(page, skelPath, outPath, outputSize) { fs.mkdirSync(path.dirname(outPath), { recursive: true }); // Write PNG via node-canvas - const pngCanvas = createCanvas(outputSize, outputSize); + const pngCanvas = createCanvas(outputWidth, outputHeight); const pngCtx = pngCanvas.getContext("2d"); - const imgData = pngCtx.createImageData(outputSize, outputSize); + const imgData = pngCtx.createImageData(outputWidth, outputHeight); imgData.data.set(rawBuffer); pngCtx.putImageData(imgData, 0, 0); fs.writeFileSync(outPath, pngCanvas.toBuffer("image/png")); @@ -255,7 +256,7 @@ async function renderSkel(page, skelPath, outPath, outputSize) { // Write WebP via sharp const webpPath = outPath.replace(/\.png$/, ".webp"); const webpBuffer = await sharp(rawBuffer, { - raw: { width: outputSize, height: outputSize, channels: 4 }, + raw: { width: outputWidth, height: outputHeight, channels: 4 }, }).webp({ quality: 90 }).toBuffer(); fs.writeFileSync(webpPath, webpBuffer); @@ -291,7 +292,7 @@ async function main() { const outPath = path.join(OUTPUT_ROOT, relDir, skelName + ".png"); const label = path.join(relDir, skelName); - const result = await renderSkel(page, skelPath, outPath, OUTPUT_SIZE); + const result = await renderSkel(page, skelPath, outPath, OUTPUT_WIDTH, OUTPUT_HEIGHT); if (result.status === "ok") { console.log(` OK ${label} (${result.size})`); ok++; diff --git a/tools/spine-renderer/render_extras.mjs b/tools/spine-renderer/render_extras.mjs index 1bc13cd6..3abd7700 100644 --- a/tools/spine-renderer/render_extras.mjs +++ b/tools/spine-renderer/render_extras.mjs @@ -16,12 +16,17 @@ class NodeTexture extends Texture { const BASE = path.resolve("/Users/peterlord/Documents/Projects/spire-codex"); const ANIM = path.join(BASE, "extraction/raw/animations"); const IMG = path.join(BASE, "backend/static/images"); -const OUTPUT_SIZE = 512, SS = 2, RS = OUTPUT_SIZE * SS, PAD = 20 * SS; +const OUTPUT_WIDTH = 512; +const OUTPUT_HEIGHT = 512; +const SUPERSAMPLE = 2; +const RENDER_WIDTH = OUTPUT_WIDTH * SUPERSAMPLE; +const RENDER_HEIGHT = OUTPUT_HEIGHT * SUPERSAMPLE; +const PADDING = 20 * SUPERSAMPLE; async function render(dir, skelName, outPath) { const skelPath = path.join(dir, skelName + ".skel"); const atlasPath = path.join(dir, skelName + ".atlas"); - + if (!fs.existsSync(skelPath) || !fs.existsSync(atlasPath)) { console.log(" SKIP " + skelName + ": missing files"); return false; @@ -85,14 +90,14 @@ async function render(dir, skelName, outPath) { if (!isFinite(minX)) { console.log(" SKIP " + skelName + ": no bounds"); return false; } const sw = maxX - minX, sh = maxY - minY; - const avail = RS - PAD * 2; + const avail = RENDER_WIDTH - PADDING * 2; const scale = Math.min(avail / sw, avail / sh); - const canvas = createCanvas(RS, RS); + const canvas = createCanvas(RENDER_WIDTH, RENDER_HEIGHT); const ctx = canvas.getContext("2d"); - ctx.clearRect(0, 0, RS, RS); + ctx.clearRect(0, 0, RENDER_WIDTH, RENDER_HEIGHT); ctx.save(); - ctx.translate(RS/2, RS/2); + ctx.translate(RENDER_WIDTH/2, RENDER_HEIGHT/2); ctx.scale(scale, -scale); ctx.translate(-(minX+maxX)/2, -(minY+maxY)/2); const renderer = new SkeletonRenderer(ctx); @@ -100,9 +105,9 @@ async function render(dir, skelName, outPath) { renderer.draw(skeleton); ctx.restore(); - const out = createCanvas(OUTPUT_SIZE, OUTPUT_SIZE); + const out = createCanvas(OUTPUT_WIDTH, OUTPUT_HEIGHT); const oc = out.getContext("2d"); - oc.drawImage(canvas, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE); + oc.drawImage(canvas, 0, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, out.toBuffer("image/png")); diff --git a/tools/spine-renderer/render_gif.mjs b/tools/spine-renderer/render_gif.mjs index 7b91a5b5..72ba62a6 100644 --- a/tools/spine-renderer/render_gif.mjs +++ b/tools/spine-renderer/render_gif.mjs @@ -53,7 +53,8 @@ const HIDDEN_SLOTS = [ async function main() { const skelDir = path.resolve(process.argv[2] || ""); const outputPath = path.resolve(process.argv[3] || "output.gif"); - const outputSize = parseInt(process.argv[4] || "256"); + const outputWidth = parseInt(process.argv[4] || "256"); + const outputHeight = parseInt(process.argv[5] || "256"); const fpsArg = process.argv.find(a => a.startsWith("--fps=")); const fps = fpsArg ? parseInt(fpsArg.split("=")[1]) : 20; const whiteMode = process.argv.includes("--white"); @@ -83,7 +84,7 @@ async function main() { textureData[tf] = fs.readFileSync(path.join(skelDir, tf)).toString("base64"); } - console.log(`Rendering ${skelName} as GIF at ${outputSize}x${outputSize}, ${fps}fps...`); + console.log(`Rendering ${skelName} as GIF at ${outputWidth}x${outputHeight}, ${fps}fps...`); console.log(` Textures: ${textureFiles.join(", ")}`); const browser = await chromium.launch({ headless: true, channel: "chrome" }); @@ -95,9 +96,9 @@ async function main() { if (isStreamFormat) { fs.mkdirSync(framesDir, { recursive: true }); await page.exposeFunction("__saveFrame", (idx, pixels) => { - const pngCanvas = createCanvas(outputSize, outputSize); + const pngCanvas = createCanvas(outputWidth, outputHeight); const pCtx = pngCanvas.getContext("2d"); - const imgData = pCtx.createImageData(outputSize, outputSize); + const imgData = pCtx.createImageData(outputWidth, outputHeight); imgData.data.set(new Uint8ClampedArray(pixels)); pCtx.putImageData(imgData, 0, 0); fs.writeFileSync(path.join(framesDir, `frame_${String(idx).padStart(4, "0")}.png`), pngCanvas.toBuffer("image/png")); @@ -108,7 +109,7 @@ async function main() { const spineCoreCode = fs.readFileSync(spineCorePath, "utf-8"); const result = await page.evaluate(async (params) => { - const { skelB64, atlasB64, textureData, outputSize, fps, streamFrames, idleNames, shadowNames, hiddenSlots, whiteMode, skinName, animOverride, spineCoreCode } = params; + const { skelB64, atlasB64, textureData, outputWidth, outputHeight, fps, streamFrames, idleNames, shadowNames, hiddenSlots, whiteMode, skinName, animOverride, spineCoreCode } = params; eval(spineCoreCode.replace(/^"use strict";\s*var spine\s*=/, "window.spine =")); const spine = window.spine; @@ -249,14 +250,14 @@ async function main() { renderer.draw(batcher, skeleton); batcher.end(); - const pixels = new Uint8Array(outputSize * outputSize * 4); - gl.readPixels(0, 0, outputSize, outputSize, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + const pixels = new Uint8Array(outputWidth * outputHeight * 4); + gl.readPixels(0, 0, outputSize, outputHeight, gl.RGBA, gl.UNSIGNED_BYTE, pixels); // Flip vertically - const flipped = new Uint8Array(outputSize * outputSize * 4); - const rowSize = outputSize * 4; - for (let row = 0; row < outputSize; row++) { - flipped.set(pixels.subarray((outputSize - 1 - row) * rowSize, (outputSize - row) * rowSize), row * rowSize); + const flipped = new Uint8Array(outputWidth * outputHeight * 4); + const rowSize = outputWidth * 4; + for (let row = 0; row < outputWidth; row++) { + flipped.set(pixels.subarray((outputWidth - 1 - row) * rowSize, (outputWidth - row) * rowSize), row * rowSize); } // White mode @@ -280,7 +281,7 @@ async function main() { return { frames: streamFrames ? [] : frames, frameCount, duration }; }, { - skelB64, atlasB64, textureData, outputSize, fps, + skelB64, atlasB64, textureData, outputWidth, outputHeight, fps, streamFrames: outputPath.endsWith(".webp") || outputPath.endsWith(".apng"), idleNames: IDLE_NAMES, shadowNames: SHADOW_NAMES, hiddenSlots: HIDDEN_SLOTS, whiteMode, skinName, animOverride, spineCoreCode, @@ -298,10 +299,10 @@ async function main() { if (!isStreamFormat) { // Fallback: save frames from memory fs.mkdirSync(tmpDir, { recursive: true }); - const pngCanvas2 = createCanvas(outputSize, outputSize); + const pngCanvas2 = createCanvas(outputWidth, outputHeight); const pCtx2 = pngCanvas2.getContext("2d"); for (let f = 0; f < result.frameCount; f++) { - const imgData = pCtx2.createImageData(outputSize, outputSize); + const imgData = pCtx2.createImageData(outputWidth, outputHeight); imgData.data.set(new Uint8ClampedArray(result.frames[f])); pCtx2.putImageData(imgData, 0, 0); fs.writeFileSync(path.join(tmpDir, `frame_${String(f).padStart(4, "0")}.png`), pngCanvas2.toBuffer("image/png")); @@ -323,17 +324,17 @@ imgs[0].save('${outputPath}', save_all=True, append_images=imgs[1:], duration=${ fs.rmdirSync(tmpDir); } else { // Encode GIF - const encoder = new GIFEncoder(outputSize, outputSize, "neuquant", true); + const encoder = new GIFEncoder(outputWidth, outputHeight, "neuquant", true); encoder.setDelay(Math.round(1000 / fps)); encoder.setRepeat(0); encoder.setTransparent(0x000000); encoder.start(); - const gifCanvas = createCanvas(outputSize, outputSize); + const gifCanvas = createCanvas(outputWidth, outputHeight); const ctx = gifCanvas.getContext("2d"); for (let f = 0; f < result.frameCount; f++) { - const imgData = ctx.createImageData(outputSize, outputSize); + const imgData = ctx.createImageData(outputWidth, outputHeight); imgData.data.set(new Uint8ClampedArray(result.frames[f])); ctx.putImageData(imgData, 0, 0); encoder.addFrame(ctx); diff --git a/tools/spine-renderer/render_hires.mjs b/tools/spine-renderer/render_hires.mjs index 88d3836b..6ec2a547 100644 --- a/tools/spine-renderer/render_hires.mjs +++ b/tools/spine-renderer/render_hires.mjs @@ -13,9 +13,11 @@ import fs from "node:fs"; import path from "node:path"; import { renderSkeleton, imageDataToPng } from "./render_utils.mjs"; -const OUTPUT_SIZE = 2048; +const OUTPUT_WIDTH = 2048; +const OUTPUT_HEIGHT = 2048; const SUPERSAMPLE = 3; -const RENDER_SIZE = OUTPUT_SIZE * SUPERSAMPLE; +const RENDER_WIDTH = OUTPUT_WIDTH * SUPERSAMPLE; +const RENDER_HEIGHT = OUTPUT_HEIGHT * SUPERSAMPLE; const PADDING = 40 * SUPERSAMPLE; const SHADOW_NAMES = new Set(["shadow", "shadow2", "ground", "ground_shadow"]); const IDLE_NAMES = ["idle_loop", "idle", "Idle_loop", "Idle", "rest_idle", "rest_loop", "loop", "animation"]; @@ -50,7 +52,7 @@ async function main() { const skelName = path.basename(skelFile, ".skel"); const atlasPath = path.join(resolvedDir, skelName + ".atlas"); - console.log(`Rendering ${skelName} at ${OUTPUT_SIZE}x${OUTPUT_SIZE}...`); + console.log(`Rendering ${skelName} at ${OUTPUT_WIDTH}x${OUTPUT_HEIGHT}...`); const atlasText = fs.readFileSync(atlasPath, "utf-8"); const atlas = new TextureAtlas(atlasText); @@ -116,13 +118,13 @@ async function main() { } const sw = maxX - minX, sh = maxY - minY; - const avail = RENDER_SIZE - PADDING * 2; + const avail = RENDER_WIDTH - PADDING * 2; const scale = Math.min(avail / sw, avail / sh); console.log(` Bounds: ${sw.toFixed(0)}x${sh.toFixed(0)}, scale: ${scale.toFixed(2)}`); - const imgData = renderSkeleton(skeleton, RENDER_SIZE, scale, minX, minY, maxX, maxY); + const imgData = renderSkeleton(skeleton, RENDER_WIDTH, RENDER_HEIGHT, scale, minX, minY, maxX, maxY); const resolvedOutput = path.resolve(outputPath); - const buffer = imageDataToPng(imgData, RENDER_SIZE, OUTPUT_SIZE); + const buffer = imageDataToPng(imgData, RENDER_WIDTH, RENDER_HEIGHT, OUTPUT_WIDTH, OUTPUT_HEIGHT); fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true }); fs.writeFileSync(resolvedOutput, buffer); diff --git a/tools/spine-renderer/render_neow.mjs b/tools/spine-renderer/render_neow.mjs index f68dbd80..06072ad9 100644 --- a/tools/spine-renderer/render_neow.mjs +++ b/tools/spine-renderer/render_neow.mjs @@ -16,9 +16,11 @@ const BASE = path.resolve(import.meta.dirname, "../.."); const SKEL_DIR = path.join(BASE, "extraction/raw/animations/backgrounds/neow_room"); const OUTPUT = path.join(BASE, "backend/static/images/misc/neow.png"); -const OUTPUT_SIZE = 2048; +const OUTPUT_WIDTH = 2048; +const OUTPUT_HEIGHT = 2048; const SUPERSAMPLE = 2; -const RENDER_SIZE = OUTPUT_SIZE * SUPERSAMPLE; +const RENDER_WIDTH = OUTPUT_WIDTH * SUPERSAMPLE; +const RENDER_HEIGHT = OUTPUT_HEIGHT * SUPERSAMPLE; const PADDING = 40 * SUPERSAMPLE; const SHADOW_NAMES = new Set(["shadow", "shadow2", "ground", "ground_shadow"]); const IDLE_NAMES = ["idle_loop", "idle", "Idle_loop", "Idle", "rest_idle", "rest_loop", "loop", "animation"]; @@ -103,13 +105,13 @@ async function main() { } const sw = maxX - minX, sh = maxY - minY; - const avail = RENDER_SIZE - PADDING * 2; + const avail = RENDER_WIDTH - PADDING * 2; const scale = Math.min(avail / sw, avail / sh); console.log(` Bounds: ${sw.toFixed(0)}x${sh.toFixed(0)}, scale: ${scale.toFixed(2)}`); - console.log(` Rendering at ${RENDER_SIZE}x${RENDER_SIZE}, output ${OUTPUT_SIZE}x${OUTPUT_SIZE}...`); + console.log(` Rendering at ${RENDER_WIDTH}x${RENDER_HEIGHT}, output ${OUTPUT_WIDTH}x${OUTPUT_WIDTH}...`); - const imgData = renderSkeleton(skeleton, RENDER_SIZE, scale, minX, minY, maxX, maxY); - const buffer = imageDataToPng(imgData, RENDER_SIZE, OUTPUT_SIZE); + const imgData = renderSkeleton(skeleton, RENDER_WIDTH, RENDER_HEIGHT, scale, minX, minY, maxX, maxY); + const buffer = imageDataToPng(imgData, RENDER_WIDTH, RENDER_HEIGHT, OUTPUT_WIDTH, OUTPUT_HEIGHT); fs.mkdirSync(path.dirname(OUTPUT), { recursive: true }); fs.writeFileSync(OUTPUT, buffer); diff --git a/tools/spine-renderer/render_utils.mjs b/tools/spine-renderer/render_utils.mjs index 6c73a2d3..927fca26 100644 --- a/tools/spine-renderer/render_utils.mjs +++ b/tools/spine-renderer/render_utils.mjs @@ -65,8 +65,7 @@ export function renderSkeleton(skeleton, renderWidth, renderHeight, scale, minX, } function renderSlotBySlot(skeleton, renderWidth, renderHeight, scale, cx, cy) { - // TODO: Not sure how to generalize this, may use the max of the height and width. - const compPixels = new Uint8ClampedArray(renderWidth * renderWidth * 4); + const compPixels = new Uint8ClampedArray(renderWidth * renderHeight * 4); for (const slot of skeleton.drawOrder) { const att = slot.getAttachment(); diff --git a/tools/spine-renderer/render_webgl.mjs b/tools/spine-renderer/render_webgl.mjs index 1a0dcfce..2d03d512 100644 --- a/tools/spine-renderer/render_webgl.mjs +++ b/tools/spine-renderer/render_webgl.mjs @@ -75,7 +75,8 @@ const SMOKE_PALETTES = { async function main() { const skelDir = path.resolve(process.argv[2] || ""); const outputPath = path.resolve(process.argv[3] || "output.png"); - const outputSize = parseInt(process.argv[4] || "2048"); + const outputWidth = parseInt(process.argv[4] || "2048"); + const outputHeight = parseInt(process.argv[5] || "2048"); // Optional: --only-slots=stroke to only render slots matching a pattern const onlySlotsArg = process.argv.find(a => a.startsWith("--only-slots=")); const onlySlots = onlySlotsArg ? onlySlotsArg.split("=")[1] : null; @@ -125,7 +126,7 @@ async function main() { } } - console.log(`Rendering ${skelName} at ${outputSize}x${outputSize} via WebGL...`); + console.log(`Rendering ${skelName} at ${outputWidth}x${outputHeight} via WebGL...`); console.log(` Textures: ${pngFiles.join(", ")}`); const browser = await chromium.launch({ @@ -141,7 +142,7 @@ async function main() { const spineCoreCode = fs.readFileSync(spineCorePath, "utf-8"); const result = await page.evaluate(async (params) => { - const { skelB64, atlasB64, textureData, outputSize, idleNames, shadowNames, hiddenSlots, smokePlaceholderPages, smokePalettes, onlySlots, whiteMode, skinName, animOverride, animTime, spineCoreCode } = params; + const { skelB64, atlasB64, textureData, outputWidth, outputHeight, idleNames, shadowNames, hiddenSlots, smokePlaceholderPages, smokePalettes, onlySlots, whiteMode, skinName, animOverride, animTime, spineCoreCode } = params; // Load spine-webgl — IIFE uses `var spine = (...)()`, make it global eval(spineCoreCode.replace(/^"use strict";\s*var spine\s*=/, "window.spine =")); @@ -149,8 +150,8 @@ async function main() { // Create WebGL canvas const canvas = document.createElement("canvas"); - canvas.width = outputSize; - canvas.height = outputSize; + canvas.width = outputWidth; + canvas.height = outputHeight; document.body.appendChild(canvas); const gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true }) @@ -159,7 +160,7 @@ async function main() { // Create spine WebGL context const mvp = new spine.Matrix4(); - mvp.ortho2d(0, 0, outputSize, outputSize); + mvp.ortho2d(0, 0, outputWidth, outputHeight); const shader = spine.Shader.newTwoColoredTextured(gl); const batcher = new spine.PolygonBatcher(gl); @@ -343,8 +344,9 @@ async function main() { const sw = maxX - minX; const sh = maxY - minY; - const padding = outputSize * 0.05; - const avail = outputSize - padding * 2; + // TODO: Generalize this. + const padding = outputWidth * 0.05; + const avail = outputWidth - padding * 2; const scale = Math.min(avail / sw, avail / sh); const cx = (minX + maxX) / 2; @@ -352,14 +354,14 @@ async function main() { // Set up orthographic projection centered on skeleton mvp.ortho2d( - cx - outputSize / (2 * scale), - cy - outputSize / (2 * scale), - outputSize / scale, - outputSize / scale + cx - outputWidth / (2 * scale), + cy - outputHeight / (2 * scale), + outputWidth / scale, + outputWidth / scale ); // Clear and render - gl.viewport(0, 0, outputSize, outputSize); + gl.viewport(0, 0, outputWidth, outputHeight); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); @@ -408,14 +410,14 @@ async function main() { shader.unbind(); // Read pixels - const pixels = new Uint8Array(outputSize * outputSize * 4); - gl.readPixels(0, 0, outputSize, outputSize, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + const pixels = new Uint8Array(outputWidth * outputHeight * 4); + gl.readPixels(0, 0, outputWidth, outputWidth, gl.RGBA, gl.UNSIGNED_BYTE, pixels); // WebGL pixels are bottom-up, flip vertically const flipped = new Uint8Array(pixels.length); - const rowSize = outputSize * 4; - for (let y = 0; y < outputSize; y++) { - const srcRow = (outputSize - 1 - y) * rowSize; + const rowSize = outputWidth * 4; + for (let y = 0; y < outputWidth; y++) { + const srcRow = (outputWidth - 1 - y) * rowSize; const dstRow = y * rowSize; flipped.set(pixels.subarray(srcRow, srcRow + rowSize), dstRow); } @@ -431,7 +433,8 @@ async function main() { skelB64, atlasB64, textureData, - outputSize, + outputWidth: outputWidth, + outputHeight: outputHeight, idleNames: IDLE_NAMES, shadowNames: SHADOW_NAMES, hiddenSlots: HIDDEN_SLOTS, @@ -473,14 +476,14 @@ async function main() { if (isWebp) { const buffer = await sharp(rawBuffer, { - raw: { width: outputSize, height: outputSize, channels: 4 }, + raw: { width: outputWidth, height: outputHeight, channels: 4 }, }).webp({ quality: 90 }).toBuffer(); fs.writeFileSync(outputPath, buffer); console.log(` Saved: ${outputPath} (${(buffer.length / 1024).toFixed(0)} KB)`); } else { - const pngCanvas = createCanvas(outputSize, outputSize); + const pngCanvas = createCanvas(outputWidth, outputHeight); const pngCtx = pngCanvas.getContext("2d"); - const imgData = pngCtx.createImageData(outputSize, outputSize); + const imgData = pngCtx.createImageData(outputWidth, outputHeight); imgData.data.set(pixelData); pngCtx.putImageData(imgData, 0, 0); const buffer = pngCanvas.toBuffer("image/png");