//============================================================================= // GifRecorder.js //============================================================================= /*: * @target MZ * @plugindesc v1.0.0 Records the next seconds of gameplay as an animated GIF at the press of a key. * @author cdqgrayde * * @param KeyCode * @text Record Key (key code) * @desc JavaScript key code of the record key. 118 = F7, 119 = F8, 120 = F9, 82 = R. * @type number * @default 118 * * @param Duration * @text Duration (seconds) * @type number * @decimals 1 * @min 0.5 * @default 5 * * @param Fps * @text Frames per Second * @desc GIF frame rate. Higher = smoother but bigger file. 15-25 is a good range. * @type number * @min 1 * @max 50 * @default 20 * * @param Scale * @text Scale (%) * @desc Size of the GIF relative to the game screen. 100 = full size (big files). * @type number * @min 10 * @max 100 * @default 50 * * @param Folder * @text Output Folder * @desc Folder (inside the game folder) the GIFs are saved to. In a browser build the GIF is downloaded instead. * @default gifs * * @param ShowIndicator * @text Show REC Indicator * @desc Shows a small overlay while recording. It is NOT part of the GIF. * @type boolean * @default true * * @help * ============================================================================ * GifRecorder (RPG Maker MZ / MV) * ============================================================================ * Press the record key (default F7) and the next seconds of gameplay * (default 5) are recorded and saved as an animated GIF: * * /gifs/gameplay_YYYY-MM-DD_HH-MM-SS.gif * * When playtesting from the editor this is your project folder. * In a web/browser deployment the GIF is downloaded instead. * * The GIF is encoded in a background thread while recording, so the game * keeps running smoothly. No external libraries are needed. * * Pressing the key again while recording stops the recording early. * * No plugin commands. */ (() => { "use strict"; const PLUGIN_NAME = (() => { const script = document.currentScript; const match = script && script.src.match(/([^/]+)\.js$/); return match ? decodeURIComponent(match[1]) : "GifRecorder"; })(); const params = PluginManager.parameters(PLUGIN_NAME); const num = (name, def) => { const n = Number(params[name]); return params[name] === undefined || params[name] === "" || isNaN(n) ? def : n; }; const P = { keyCode: num("KeyCode", 118), duration: num("Duration", 5) * 1000, fps: num("Fps", 20).clamp(1, 50), scale: num("Scale", 50).clamp(10, 100) / 100, folder: params["Folder"] || "gifs", indicator: params["ShowIndicator"] !== "false" }; const INPUT_NAME = "gifRecord"; Input.keyMapper[P.keyCode] = INPUT_NAME; //------------------------------------------------------------------------- // GIF encoder (runs inside a Web Worker) //------------------------------------------------------------------------- function workerMain() { let width = 0; let height = 0; const chunks = []; let frameCount = 0; function ByteWriter() { this.buf = new Uint8Array(1 << 16); this.len = 0; } ByteWriter.prototype.byte = function(b) { if (this.len >= this.buf.length) { const n = new Uint8Array(this.buf.length * 2); n.set(this.buf); this.buf = n; } this.buf[this.len++] = b; }; ByteWriter.prototype.word = function(w) { this.byte(w & 0xff); this.byte((w >> 8) & 0xff); }; ByteWriter.prototype.str = function(s) { for (let i = 0; i < s.length; i++) this.byte(s.charCodeAt(i)); }; ByteWriter.prototype.data = function() { return this.buf.subarray(0, this.len); }; // Median cut on a 15-bit color histogram. Returns palette + lookup. function quantize(rgba) { const hist = new Uint32Array(32768); const sumR = new Uint32Array(32768); const sumG = new Uint32Array(32768); const sumB = new Uint32Array(32768); for (let i = 0; i < rgba.length; i += 4) { const r = rgba[i]; const g = rgba[i + 1]; const b = rgba[i + 2]; const k = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3); hist[k]++; sumR[k] += r; sumG[k] += g; sumB[k] += b; } const keys = []; for (let k = 0; k < 32768; k++) if (hist[k]) keys.push(k); const makeBox = list => { let count = 0; const min = [31, 31, 31]; const max = [0, 0, 0]; for (const k of list) { const c = [(k >> 10) & 31, (k >> 5) & 31, k & 31]; for (let j = 0; j < 3; j++) { if (c[j] < min[j]) min[j] = c[j]; if (c[j] > max[j]) max[j] = c[j]; } count += hist[k]; } let axis = 0; let range = -1; for (let j = 0; j < 3; j++) { if (max[j] - min[j] > range) { range = max[j] - min[j]; axis = j; } } return { list, count, axis, range }; }; const boxes = [makeBox(keys)]; while (boxes.length < 256) { let best = -1; let bestScore = 0; for (let i = 0; i < boxes.length; i++) { const b = boxes[i]; const score = b.list.length > 1 ? b.range * b.count : 0; if (score > bestScore) { bestScore = score; best = i; } } if (best < 0) break; const box = boxes[best]; const shift = 10 - box.axis * 5; box.list.sort((a, b) => ((a >> shift) & 31) - ((b >> shift) & 31)); let acc = 0; let cut = 1; for (let i = 0; i < box.list.length - 1; i++) { acc += hist[box.list[i]]; cut = i + 1; if (acc >= box.count / 2) break; } boxes.splice(best, 1, makeBox(box.list.slice(0, cut)), makeBox(box.list.slice(cut))); } const palette = new Uint8Array(256 * 3); const lut = new Uint8Array(32768); boxes.forEach((box, i) => { let r = 0; let g = 0; let b = 0; for (const k of box.list) { r += sumR[k]; g += sumG[k]; b += sumB[k]; lut[k] = i; } palette[i * 3] = Math.round(r / box.count); palette[i * 3 + 1] = Math.round(g / box.count); palette[i * 3 + 2] = Math.round(b / box.count); }); const indices = new Uint8Array(rgba.length / 4); for (let i = 0, p = 0; i < rgba.length; i += 4, p++) { indices[p] = lut[((rgba[i] >> 3) << 10) | ((rgba[i + 1] >> 3) << 5) | (rgba[i + 2] >> 3)]; } return { palette, indices }; } // GIF LZW compression, written as data sub-blocks. function writeLzw(out, indices, minCodeSize) { const clearCode = 1 << minCodeSize; const eoiCode = clearCode + 1; let codeSize = minCodeSize + 1; let nextCode = eoiCode + 1; let table = new Map(); const bytes = new ByteWriter(); let cur = 0; let curShift = 0; const emit = code => { cur |= code << curShift; curShift += codeSize; while (curShift >= 8) { bytes.byte(cur & 0xff); cur >>>= 8; curShift -= 8; } }; emit(clearCode); let prefix = indices[0]; for (let i = 1; i < indices.length; i++) { const k = indices[i]; const key = (prefix << 8) | k; const found = table.get(key); if (found !== undefined) { prefix = found; continue; } emit(prefix); if (nextCode === 4096) { emit(clearCode); nextCode = eoiCode + 1; codeSize = minCodeSize + 1; table = new Map(); } else { if (nextCode >= 1 << codeSize) codeSize++; table.set(key, nextCode++); } prefix = k; } emit(prefix); emit(eoiCode); if (curShift > 0) bytes.byte(cur & 0xff); out.byte(minCodeSize); const data = bytes.data(); for (let i = 0; i < data.length; i += 255) { const n = Math.min(255, data.length - i); out.byte(n); for (let j = 0; j < n; j++) out.byte(data[i + j]); } out.byte(0); } function header() { const out = new ByteWriter(); out.str("GIF89a"); out.word(width); out.word(height); out.byte(0x70); // no global color table, 8 bit color resolution out.byte(0); out.byte(0); // Loop forever out.byte(0x21); out.byte(0xff); out.byte(11); out.str("NETSCAPE2.0"); out.byte(3); out.byte(1); out.word(0); out.byte(0); return out.data(); } function frame(rgba, delayCs) { const { palette, indices } = quantize(rgba); const out = new ByteWriter(); // Graphic control extension out.byte(0x21); out.byte(0xf9); out.byte(4); out.byte(0x04); // disposal: do not dispose out.word(Math.max(2, delayCs)); out.byte(0); out.byte(0); // Image descriptor with 256 color local table out.byte(0x2c); out.word(0); out.word(0); out.word(width); out.word(height); out.byte(0x87); for (let i = 0; i < palette.length; i++) out.byte(palette[i]); writeLzw(out, indices, 8); return out.data(); } self.onmessage = e => { const msg = e.data; if (msg.type === "start") { width = msg.width; height = msg.height; chunks.length = 0; frameCount = 0; chunks.push(header()); } else if (msg.type === "frame") { chunks.push(frame(new Uint8Array(msg.buffer), msg.delay)); frameCount++; } else if (msg.type === "finish") { chunks.push(new Uint8Array([0x3b])); let total = 0; for (const c of chunks) total += c.length; const result = new Uint8Array(total); let offset = 0; for (const c of chunks) { result.set(c, offset); offset += c.length; } chunks.length = 0; self.postMessage({ type: "done", buffer: result.buffer, frames: frameCount }, [result.buffer]); } }; } //------------------------------------------------------------------------- // GifRecorder //------------------------------------------------------------------------- const GifRecorder = { _worker: null, _recording: false, _encoding: false, _canvas: null, _context: null, _startTime: 0, _pending: null, _indicator: null, _hideTimer: null }; window.GifRecorder = GifRecorder; GifRecorder.worker = function() { if (!this._worker) { const code = "(" + workerMain.toString() + ")();"; const url = URL.createObjectURL(new Blob([code], { type: "text/javascript" })); this._worker = new Worker(url); this._worker.onmessage = e => this.onWorkerMessage(e.data); this._worker.onerror = e => { console.error("GifRecorder:", e.message); this._encoding = false; this.showIndicator("GIF-Fehler (siehe Konsole)", "#c33", 4000); }; } return this._worker; }; GifRecorder.toggle = function() { if (this._recording) { this.stop(); } else if (!this._encoding) { this.start(); } }; GifRecorder.start = function() { const source = Graphics._canvas; if (!source) return; const w = Math.max(1, Math.round(source.width * P.scale)); const h = Math.max(1, Math.round(source.height * P.scale)); this._canvas = document.createElement("canvas"); this._canvas.width = w; this._canvas.height = h; this._context = this._canvas.getContext("2d", { willReadFrequently: true }); this._context.imageSmoothingEnabled = true; this.worker().postMessage({ type: "start", width: w, height: h }); this._recording = true; this._startTime = performance.now(); this._pending = null; }; GifRecorder.stop = function() { if (!this._recording) return; this._recording = false; this.flushPending(1000 / P.fps); this._encoding = true; this.showIndicator("GIF wird erstellt …", "#d80", 0); this.worker().postMessage({ type: "finish" }); }; // Called right after the game rendered a frame. GifRecorder.capture = function() { if (!this._recording) return; const now = performance.now(); const elapsed = now - this._startTime; if (elapsed >= P.duration) { this.stop(); return; } if (this._pending && now - this._pending.time < 1000 / P.fps - 1) { this.updateRecIndicator(elapsed); return; } const w = this._canvas.width; const h = this._canvas.height; this._context.drawImage(Graphics._canvas, 0, 0, w, h); const data = this._context.getImageData(0, 0, w, h).data; this.flushPending(now - (this._pending ? this._pending.time : now)); this._pending = { data: data, time: now }; this.updateRecIndicator(elapsed); }; // A frame is sent once we know how long it stays on screen. GifRecorder.flushPending = function(durationMs) { if (!this._pending) return; const buffer = this._pending.data.buffer; const delay = Math.round(durationMs / 10); this.worker().postMessage({ type: "frame", buffer: buffer, delay: delay }, [buffer]); this._pending = null; }; GifRecorder.onWorkerMessage = function(msg) { if (msg.type !== "done") return; this._encoding = false; const bytes = new Uint8Array(msg.buffer); const name = "gameplay_" + this.timestamp() + ".gif"; try { const file = this.save(name, bytes); this.showIndicator("GIF gespeichert: " + file, "#2a2", 4000); } catch (e) { console.error("GifRecorder:", e); this.showIndicator("GIF konnte nicht gespeichert werden", "#c33", 4000); } }; GifRecorder.save = function(name, bytes) { if (Utils.isNwjs()) { const fs = require("fs"); const path = require("path"); const base = path.dirname(process.mainModule.filename); const dir = path.join(base, P.folder); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(path.join(dir, name), Buffer.from(bytes)); return P.folder + "/" + name; } const url = URL.createObjectURL(new Blob([bytes], { type: "image/gif" })); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000); return name; }; GifRecorder.timestamp = function() { const d = new Date(); const p = n => String(n).padStart(2, "0"); return ( d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + "_" + p(d.getHours()) + "-" + p(d.getMinutes()) + "-" + p(d.getSeconds()) ); }; //------------------------------------------------------------------------- // Overlay (DOM element above the canvas, so it is never recorded) //------------------------------------------------------------------------- GifRecorder.showIndicator = function(text, color, hideAfterMs) { if (!P.indicator) return; if (!this._indicator) { const el = document.createElement("div"); el.style.cssText = "position:fixed;top:12px;left:12px;z-index:1000;padding:4px 10px;" + "border-radius:4px;font:bold 14px sans-serif;color:#fff;" + "pointer-events:none;background:rgba(0,0,0,0.6);"; document.body.appendChild(el); this._indicator = el; } clearTimeout(this._hideTimer); this._indicator.style.display = "block"; this._indicator.style.borderLeft = "6px solid " + color; this._indicator.textContent = text; if (hideAfterMs > 0) { this._hideTimer = setTimeout(() => { this._indicator.style.display = "none"; }, hideAfterMs); } }; GifRecorder.updateRecIndicator = function(elapsed) { const left = Math.max(0, (P.duration - elapsed) / 1000); this.showIndicator("● REC " + left.toFixed(1) + "s", "#e22", 0); }; //------------------------------------------------------------------------- // Hooks //------------------------------------------------------------------------- const _SceneManager_updateMain = SceneManager.updateMain; SceneManager.updateMain = function() { _SceneManager_updateMain.apply(this, arguments); if (Input.isTriggered(INPUT_NAME)) { GifRecorder.toggle(); } }; if (Graphics._onTick) { // MZ: render happens inside _onTick const _Graphics_onTick = Graphics._onTick; Graphics._onTick = function(deltaTime) { _Graphics_onTick.call(this, deltaTime); GifRecorder.capture(); }; } else if (Graphics.render) { // MV const _Graphics_render = Graphics.render; Graphics.render = function(stage) { _Graphics_render.call(this, stage); GifRecorder.capture(); }; } })();