Batch Exporter is a tool that will export every bbmodel file within a folder to an output folder using the selected format.
diff --git a/plugins/resource_pack_utilities/changelog.json b/plugins/resource_pack_utilities/changelog.json
index 5e46643e..18d7d309 100644
--- a/plugins/resource_pack_utilities/changelog.json
+++ b/plugins/resource_pack_utilities/changelog.json
@@ -51,5 +51,24 @@
]
}
]
+ },
+ "1.3.0": {
+ "title": "1.3.0",
+ "date": "2024-07-25",
+ "author": "Ewan Howell",
+ "categories": [
+ {
+ "title": "New Features",
+ "list": [
+ "Added Animation Combiner utility"
+ ]
+ },
+ {
+ "title": "Bug Fixes",
+ "list": [
+ "Fixed textures trying to export as \".image\" files"
+ ]
+ }
+ ]
}
}
\ No newline at end of file
diff --git a/plugins/resource_pack_utilities/resource_pack_utilities.js b/plugins/resource_pack_utilities/resource_pack_utilities.js
index 2fab0297..a7539d84 100644
--- a/plugins/resource_pack_utilities/resource_pack_utilities.js
+++ b/plugins/resource_pack_utilities/resource_pack_utilities.js
@@ -29,7 +29,7 @@
author: "Ewan Howell",
description,
tags: ["Minecraft: Java Edition", "Resource Packs", "Utilities"],
- version: "1.2.0",
+ version: "1.3.0",
min_version: "4.10.0",
variant: "desktop",
website: `https://ewanhowell.com/plugins/${id.replace(/_/g, "-")}/`,
@@ -343,6 +343,15 @@
justify-content: center;
}
+ .button-row {
+ display: flex;
+ gap: 8px;
+
+ button {
+ flex: 1 1 0px;
+ }
+ }
+
${Object.entries(components).filter((([k, v]) => v.styles)).map(([k, v]) => `.component-${k} { ${v.styles} }`).join("")}
${Object.entries(utilities).filter((([k, v]) => v.component.styles)).map(([k, v]) => `.utility-${k} { ${v.component.styles} }`).join("")}
}`],
@@ -579,7 +588,7 @@
MenuBar.addAction(action2, "tools")
document.addEventListener("keydown", copyText)
// dialog.show()
- // dialog.content_vue.utility = "clockGenerator"
+ // dialog.content_vue.utility = "animationCombiner"
},
onunload() {
document.removeEventListener("keydown", copyText)
@@ -998,6 +1007,16 @@
return canvas
}
+ function arrayBufferToBase64(buffer) {
+ let binary = ""
+ const bytes = new Uint8Array(buffer)
+ for (let i = 0; i < bytes.length; i += 8192) {
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192))
+ }
+ return btoa(binary)
+ }
+
+
// Constants
const header = `Generated by the Resource Pack Utilities plugin for Blockbench: https://ewanhowell.com/plugins/${id.replace(/_/g, "-")}/\n\n`
@@ -1323,7 +1342,12 @@
`
},
outputLog: {
- props: ["value"],
+ props: {
+ value: {},
+ small: {
+ type: Boolean
+ }
+ },
data() {
return {
logs: this.value,
@@ -1395,7 +1419,8 @@
}
code {
- background-color: var(--color-border);
+ background-color: var(--color-dark);
+ border-color: var(--color-dark);
}
}
@@ -1418,6 +1443,10 @@
}
}
+ .small {
+ height: 128px;
+ }
+
.buttons {
display: flex;
gap: 8px;
@@ -1429,7 +1458,7 @@
}
`,
template: `
-
+
{{ (logs.length - 1000).toLocaleString() }} log entries are not displayed. Save Log to see the full log
@@ -1600,7 +1629,7 @@
props: {
value: {},
type: {
- default: tl("data.image"),
+ default: "PNG",
},
extensions: {
default: ["png"]
@@ -1830,7 +1859,7 @@
default: "image",
},
type: {
- default: tl("data.image")
+ default: "PNG"
},
error: {},
height: {}
@@ -1852,7 +1881,7 @@
this.$refs.canvasContainer.textContent = ""
if (this.type === "GIF") {
const img = document.createElement("img")
- img.src = `data:image/gif;base64,${btoa(String.fromCharCode(...this.value))}`
+ img.src = `data:image/gif;base64,${arrayBufferToBase64(this.value)}`
this.$refs.canvasContainer.append(img)
this.$refs.canvasInfo.textContent = `${this.name}.gif\n${this.value[6] + (this.value[7] << 8)}x${this.value[8] + (this.value[9] << 8)} - ${formatBytes(this.value.length)}`
} else {
@@ -1892,7 +1921,7 @@
type: this.type,
name: this.name,
savetype: "image",
- content: this.value.toDataURL()
+ content: this.output.toDataURL()
}, () => Blockbench.showQuickMessage("Saved…"))
}
}
@@ -1932,15 +1961,6 @@
display: block;
}
- .button-row {
- display: flex;
- gap: 8px;
-
- button {
- flex: 1 1 0px;
- }
- }
-
.canvas-output-error {
color: var(--color-error);
}
@@ -4513,12 +4533,21 @@
canvas.ctx.globalCompositeOperation = "source-over"
canvas.ctx.drawImage(this.file.image, 0, 0, size, size, 0, 0, size, size)
this.frames.push(canvas)
- const data = canvas.ctx.getImageData(0, 0, size, size).data
+ const { data } = canvas.ctx.getImageData(0, 0, size, size)
const palette = GIFEnc.quantize(data, 256)
const index = GIFEnc.applyPalette(data, palette)
+ let transparent
+ for (let i = data.length - 1; i >= 0; i -= 4) {
+ if (data[i] < 128) {
+ transparent = true
+ break
+ }
+ }
gif.writeFrame(index, size, size, {
- transparent: true,
- palette
+ transparent,
+ palette,
+ delay: 50,
+ dispose: 2
})
}
gif.finish()
@@ -4558,6 +4587,223 @@
`
}
+ },
+ animationCombiner: {
+ name: "Animation Combiner",
+ icon: "theaters",
+ tagline: "Combine a folder of textures into an animated spritesheet texture.",
+ description: "Animation Combiner is a tool that combines the textures from a selected folder into an animated spritesheet texture.",
+ component: {
+ data: {
+ folder: "",
+ ignoreList: [],
+ outputLog,
+ done: 0,
+ total: null,
+ cancelled: false,
+ gif: null,
+ output: null,
+ type: "vertical",
+ types: {
+ vertical: "Vertical",
+ horizonal: "Horizontal"
+ },
+ delay: 1,
+ interpolation: false,
+ width: null,
+ height: null
+ },
+ methods: {
+ async execute() {
+ outputLog.length = 0
+ this.status.finished = false
+ this.status.processing = true
+ this.done = 0
+ this.total = null
+ this.cancelled = false
+ this.gif = null
+ this.width = null
+ this.height = null
+
+ if (!await exists(this.folder)) {
+ this.status.finished = true
+ this.status.processing = false
+ this.total = 0
+ output.error(`The folder \`${formatPath(this.folder)}\` was not found`)
+ return
+ }
+
+ const files = (await fs.promises.readdir(this.folder)).filter(e => e.endsWith(".png"))
+
+ this.total = files.length
+
+ const frames = []
+
+ for (const file of files) {
+ if (this.cancelled) break
+ let img
+ try {
+ img = await loadImage(path.join(this.folder, file))
+ } catch {
+ output.error(`Skipping \`${shortened}\` as it could not be read`)
+ this.done++
+ continue
+ }
+ if (!this.width) {
+ this.width = img.width
+ this.height = img.height
+ output.log(`Animation frame size set to \`${this.width}x${this.height}\`. Loaded from \`${file}\``)
+ } else if (img.width !== this.width || img.height !== this.height) {
+ output.warn(`Skipping \`${file}\` as its size \`${img.width}x${img.height}\` does not match the animation frame size \`${this.width}x${this.height}\``)
+ this.done++
+ continue
+ }
+ frames.push(img)
+ this.done++
+ }
+
+ if (!this.cancelled) {
+ const gif = GIFEnc.GIFEncoder()
+ if (this.type === "vertical") {
+ this.output = new Canvas(this.width, this.height * frames.length)
+ } else {
+ this.output = new Canvas(this.width * frames.length, this.height)
+ }
+ let delay, interpolationFrames, opacityMultiplier
+ if (this.interpolation) {
+ if (this.delay === 1) {
+ delay = 25
+ interpolationFrames = 1
+ opacityMultiplier = 0.5
+ } else if (this.delay === 2) {
+ delay = 17
+ interpolationFrames = 2
+ opacityMultiplier = 0.33
+ } else if (this.delay === 3) {
+ delay = 13
+ interpolationFrames = 3
+ opacityMultiplier = 0.25
+ } else {
+ delay = this.delay * 10
+ interpolationFrames = 4
+ opacityMultiplier = 0.2
+ }
+ }
+ for (const [i, frame] of frames.entries()) {
+ if (this.type === "vertical") {
+ this.output.ctx.drawImage(frame, 0, i * this.height)
+ } else {
+ this.output.ctx.drawImage(frame, i * this.width, 0)
+ }
+ const canvas = imageToCanvas(frame)
+ const { data } = canvas.ctx.getImageData(0, 0, this.width, this.height)
+ const palette = GIFEnc.quantize(data, 256)
+ const index = GIFEnc.applyPalette(data, palette)
+ let transparent
+ for (let i = data.length - 1; i >= 0; i -= 4) {
+ if (data[i] < 128) {
+ transparent = true
+ break
+ }
+ }
+ if (this.interpolation) {
+ gif.writeFrame(index, this.width, this.height, {
+ transparent,
+ palette,
+ delay: this.delay * 10
+ })
+ const nextFrame = frames[i + 1] ?? frames[0]
+ for (let j = 1; j <= interpolationFrames; j++) {
+ const interpolatedCanvas = imageToCanvas(frame)
+ const ctx = interpolatedCanvas.ctx
+ ctx.drawImage(frame, 0, 0)
+ ctx.globalAlpha = j * opacityMultiplier
+ ctx.drawImage(nextFrame, 0, 0)
+ ctx.globalAlpha = 1
+
+ const { data } = ctx.getImageData(0, 0, this.width, this.height)
+ const palette = GIFEnc.quantize(data, 256)
+ const index = GIFEnc.applyPalette(data, palette)
+ gif.writeFrame(index, this.width, this.height, {
+ transparent,
+ palette,
+ delay
+ })
+ }
+ } else {
+ gif.writeFrame(index, this.width, this.height, {
+ transparent,
+ palette,
+ delay: this.delay * 50,
+ dispose: 2
+ })
+ }
+ }
+ gif.finish()
+ this.gif = gif.bytes()
+ }
+
+ this.total = this.done
+ output.info("Finished")
+ this.status.processing = false
+ this.status.finished = true
+ },
+ async save() {
+ const save = await electron.dialog.showSaveDialog({
+ title: "Save animation",
+ defaultPath: "animation",
+ filters: [
+ { name: "PNG", extensions: ["png"] }
+ ]
+ })
+ if (save.cancelled) return
+ await fs.promises.writeFile(save.filePath, Buffer.from(await (await new Promise(fulfil => this.output.toBlob(fulfil))).arrayBuffer()))
+ const mcmeta = {
+ animation: {}
+ }
+ if (this.delay > 1) {
+ mcmeta.animation.frametime = this.delay
+ }
+ if (this.interpolation) {
+ mcmeta.animation.interpolate = true
+ }
+ if (this.type === "vertical" && this.width !== this.height) {
+ mcmeta.animation.height = this.height
+ } else if (this.type === "horizonal") {
+ mcmeta.animation.width = this.width
+ }
+ await fs.promises.writeFile(save.filePath + ".mcmeta", JSON.stringify(mcmeta, null, 2))
+ Blockbench.showQuickMessage("Exported animation")
+ }
+ },
+ styles: `
+ #template {
+ display: flex;
+ gap: 8px;
+ flex-direction: row;
+ }
+ `,
+ template: `
+
+
Input folder:
+ folder containing the animation frames
+ Format
+ Delay
+ Interpolation
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ }
}
}