Skip to content

Instantly share code, notes, and snippets.

@20kdc
Last active May 16, 2024 20:08
Show Gist options
  • Save 20kdc/5f3d67f980509be67b9136a8a9a5064a to your computer and use it in GitHub Desktop.
Save 20kdc/5f3d67f980509be67b9136a8a9a5064a to your computer and use it in GitHub Desktop.
isle-tooling, gist edition

This release mechanism for isle-tooling is now deprecated.

I've decided to put little projects like these into a dedicated repository, https://gitlab.com/20kdc/scrapheap .

The benefits of this change include actually having released JARs, so it's really for the best.

This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org>
/*
* isle-tooling - Solve weird problems that shouldn't exist
* Written starting in 2023 by contributors (see CREDITS.txt)
* To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide. This software is distributed without any warranty.
* A copy of the Unlicense should have been supplied as COPYING.txt in this repository. Alternatively, you can find it at <https://unlicense.org/>.
*/
package isle;
import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.LinkedList;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import javax.imageio.ImageIO;
import org.json.JSONObject;
import org.json.JSONTokener;
public class Main {
public static void main(String[] args) throws Exception {
if (args.length < 1) {
showHelp();
} else if (args[0].equals("splitafl") || args[0].equals("splitaflu")) {
if (args.length != 6) {
System.out.println("splitafl/splitaflu JSON PNG FPS OUTDIR OUTLIST");
System.exit(1);
}
boolean unix = args[0].equals("splitaflu");
System.out.println("Loading spritesheet...");
Anim frames = readLSSpritesheet(new File(args[1]), new File(args[2]));
SplitAFLResult frameFiles = splitAFL(frames, Integer.parseInt(args[3]), new File(args[4]), unix);
PrintStream fos = new PrintStream(new File(args[5]), "UTF-8");
for (File f : frameFiles.files)
fos.println(f.toString());
fos.close();
} else if (args[0].equals("aflgs") || args[0].equals("aflgsu")) {
if (args.length < 4) {
System.out.println("aflgs/aflgsl JSON PNG FPS GIFSKIARGS...");
System.exit(1);
}
boolean unix = args[0].equals("aflgsu");
File f = new File("__ISLE_TMP_WillBeDeleted__");
int fps = Integer.parseInt(args[3]);
System.out.println("Loading spritesheet...");
Anim frames = readLSSpritesheet(new File(args[1]), new File(args[2]));
if (fps == -1) {
fps = frames.guessFPS();
System.out.println("Detected FPS: " + fps);
}
if (fps == -1) {
System.out.println("Failed to find a valid FPS");
System.exit(1);
}
SplitAFLResult frameFiles = splitAFL(frames, fps, f, unix);
// prepare gifski command
LinkedList<String> cmd = new LinkedList<>();
cmd.add("gifski");
for (int i = 4; i < args.length; i++)
cmd.add(args[i]);
cmd.add("-r");
cmd.add(Integer.toString(fps));
// gifski platform-dependent issues
if (unix) {
cmd.add("--no-sort");
for (File ff : frameFiles.files)
cmd.add(ff.toString());
} else {
cmd.add(frameFiles.wildcardGifski);
}
// announce
System.out.println("Running gifski:");
for (String s : cmd)
System.out.print(" " + s);
System.out.println();
// run gifski
ProcessBuilder pb = new ProcessBuilder();
pb.command(cmd);
pb.start().waitFor();
System.out.println("Done");
// clean
frameFiles.delete();
} else if (args[0].equals("apng")) {
if (args.length != 4) {
System.out.println("apng JSON PNG OUTAPNG");
System.exit(1);
}
System.out.println("Loading spritesheet...");
Anim frames = readLSSpritesheet(new File(args[1]), new File(args[2]));
System.out.println("Writing...");
writeAPNG(frames, new File(args[3]));
} else if (args[0].equals("ffmpeg")) {
if (args.length < 3) {
System.out.println("ffmpeg JSON PNG FFMPEGARGS...");
System.exit(1);
}
File tmpF = new File("__ISLE_TMP_WillBeDeleted__.apng");
if (tmpF.exists()) {
System.out.println("__ISLE_TMP_WillBeDeleted__.apng already exists.");
System.exit(1);
}
System.out.println("Loading spritesheet...");
Anim frames = readLSSpritesheet(new File(args[1]), new File(args[2]));
System.out.println("Writing...");
writeAPNG(frames, tmpF);
System.out.println("Running ffmpeg...");
LinkedList<String> cmd = new LinkedList<>();
cmd.add("ffmpeg");
cmd.add("-i");
cmd.add(tmpF.toString());
for (int i = 3; i < args.length; i++)
cmd.add(args[i]);
ProcessBuilder pb = new ProcessBuilder();
pb.command(cmd);
pb.start().waitFor();
System.out.println("Done");
tmpF.delete();
} else if (args[0].equals("guessfps")) {
if (args.length != 2) {
System.out.println("guessfps JSON");
System.exit(1);
}
Anim frames = readLSSpritesheet(new File(args[1]), null);
int fps = frames.guessFPS();
System.out.println(fps);
} else {
showHelp();
System.exit(1);
}
}
private static void writeAPNG(Anim anim, File file) throws Exception {
try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(file))) {
// begin actual write
dos.write(new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A});
try (APNGChunk chk = new APNGChunk(dos, "IHDR")) {
chk.contents.writeInt(anim.width);
chk.contents.writeInt(anim.height);
chk.contents.write(8);
chk.contents.write(6);
chk.contents.write(0);
chk.contents.write(0);
chk.contents.write(0);
}
try (APNGChunk chk = new APNGChunk(dos, "acTL")) {
chk.contents.writeInt(anim.frames.size());
chk.contents.writeInt(0);
}
int fctlSeq = 0;
Anim.Frame lastFrame = null;
for (Anim.Frame frame : anim.frames) {
Anim.Diff diff = lastFrame != null ? genDiff(anim, lastFrame, frame) : genDiff(anim, frame);
System.out.println(frame.name + " (" + diff.x + "," + diff.y + "," + diff.width + "," + diff.height + "," + diff.replace + ")");
try (APNGChunk chk = new APNGChunk(dos, "fcTL")) {
chk.contents.writeInt(fctlSeq++);
chk.contents.writeInt(diff.width);
chk.contents.writeInt(diff.height);
chk.contents.writeInt(diff.x);
chk.contents.writeInt(diff.y);
chk.contents.writeShort(frame.durationMs);
chk.contents.writeShort(1000);
// we need this at 0 even though 1 may be more optimized for the renderer.
// this is because a *future* frame may require the contents of *this* frame.
chk.contents.write(0);
chk.contents.write(diff.replace ? 0 : 1);
}
try (APNGChunk chk = new APNGChunk(dos, (fctlSeq == 1) ? "IDAT" : "fdAT")) {
if (fctlSeq != 1)
chk.contents.writeInt(fctlSeq++);
int dPIdx = 0;
int w = diff.width;
int h = diff.height;
int oIdx = 0;
byte[] frameBuf = new byte[((w * 4) + 1) * h];
for (int i = 0; i < h; i++) {
// init to filter type 0
frameBuf[oIdx++] = 0;
for (int j = 0; j < w; j++) {
int col = diff.pixels[dPIdx++];
frameBuf[oIdx] = (byte) (col >> 16);
frameBuf[oIdx + 1] = (byte) (col >> 8);
frameBuf[oIdx + 2] = (byte) (col);
frameBuf[oIdx + 3] = (byte) (col >> 24);
oIdx += 4;
}
}
// OLD ALG.
// filtered: 1814369
// unfiltered: 1762173
// NEW ALG.
// unfiltered: 1599128
// target: 1595839
// done!
doDeflate(chk.contents, frameBuf);
}
lastFrame = frame;
}
try (APNGChunk chk = new APNGChunk(dos, "IEND")) {
// :D
}
}
}
private static Anim.Diff genDiff(Anim anim, Anim.Frame a) {
int[] data = new int[anim.width * anim.height];
for (int i = 0; i < anim.height; i++)
System.arraycopy(anim.pixels, a.rows[i], data, i * anim.width, anim.width);
return new Anim.Diff(data, 0, 0, anim.width, anim.height, true);
}
private static Anim.Diff genDiff(Anim anim, Anim.Frame a, Anim.Frame b) {
// test column/row differences
boolean[] colsDiffer = new boolean[anim.width];
boolean[] rowsDiffer = new boolean[anim.height];
for (int testY = 0; testY < anim.height; testY++) {
int aOfs = a.rows[testY];
int bOfs = b.rows[testY];
for (int testX = 0; testX < anim.width; testX++) {
if (anim.pixels[aOfs++] != anim.pixels[bOfs++]) {
colsDiffer[testX] = true;
rowsDiffer[testY] = true;
}
}
}
// constrain rectangle
int x = 0;
int y = 0;
int w = anim.width;
int h = anim.height;
// L
while (w > 1 && !colsDiffer[x]) {
x++;
w--;
}
// U
while (h > 1 && !rowsDiffer[y]) {
y++;
h--;
}
// R
while (w > 1 && !colsDiffer[x + w - 1])
w--;
// D
while (h > 1 && !rowsDiffer[y + h - 1])
h--;
// rectangle finalized, now we can finally begin actually generating the diff
int[] aBuffer = new int[w * h];
int[] finalDiff = new int[w * h];
// copy all pixels to diff-relative coordinates
for (int i = 0; i < h; i++) {
System.arraycopy(anim.pixels, a.rows[y + i] + x, aBuffer, w * i, w);
System.arraycopy(anim.pixels, b.rows[y + i] + x, finalDiff, w * i, w);
}
// if a pixel differs AND the new value has non-full alpha, we can't just blend in
// otherwise, we *can*
boolean replace = false;
for (int i = 0; i < finalDiff.length; i++) {
if (aBuffer[i] != finalDiff[i]) {
if ((finalDiff[i] & 0xFF000000) != 0xFF000000) {
replace = true;
break;
}
}
}
// if we're not replacing then do per-pixel blend-based opt.
if (!replace)
for (int i = 0; i < finalDiff.length; i++)
if (aBuffer[i] == finalDiff[i])
finalDiff[i] = 0;
// finally export this
return new Anim.Diff(finalDiff, x, y, w, h, replace);
}
private static void doDeflate(DataOutputStream contents, byte[] frameBuf) throws IOException {
DeflaterOutputStream dfos = new DeflaterOutputStream(contents, new Deflater(Deflater.BEST_COMPRESSION, false));
dfos.write(frameBuf);
dfos.close();
}
private static SplitAFLResult splitAFL(Anim anim, int fps, File baseFile, boolean unix) throws Exception {
// This is doubly important now that wildcards are in use.
if (baseFile.isDirectory())
throw new RuntimeException(baseFile + " already exists");
baseFile.mkdirs();
int idx = 0;
double gifskiTime = 0;
double gifskiAdvance = 1000.0d / fps;
int inputTime = 0;
SplitAFLResult frameFiles = new SplitAFLResult(baseFile);
BufferedImage tmpBI = new BufferedImage(anim.width, anim.height, BufferedImage.TYPE_INT_ARGB);
WritableRaster tmpWR = tmpBI.getRaster();
int[] rowBuffer = new int[anim.width];
for (Anim.Frame frame : anim.frames) {
int instances = 0;
inputTime += frame.durationMs;
while (gifskiTime < inputTime) {
gifskiTime += gifskiAdvance;
instances++;
}
if (instances > 0) {
File[] targets = new File[instances];
for (int i = 0; i < targets.length; i++) {
targets[i] = new File(baseFile, "f" + (idx++) + ".png");
}
// have to copy each individual row because of the memory problem
for (int i = 0; i < anim.height; i++) {
System.arraycopy(anim.pixels, frame.rows[i], rowBuffer, 0, anim.width);
tmpWR.setDataElements(0, i, anim.width, 1, rowBuffer);
}
ImageIO.write(tmpBI, "PNG", targets[0]);
System.out.println(frame.name + " -> " + targets[0]);
if (unix) {
// reuse this one file for all targets
for (int i = 0; i < instances; i++)
frameFiles.files.add(targets[0]);
} else {
// this sucks so much. blame Windows command line limits.
// I'm serious.
frameFiles.files.add(targets[0]);
for (int i = 1; i < targets.length; i++) {
Files.copy(targets[0].toPath(), targets[i].toPath(), StandardCopyOption.REPLACE_EXISTING);
frameFiles.files.add(targets[i]);
}
}
}
}
return frameFiles;
}
public static class SplitAFLResult {
public final File baseDir;
// Individual frames in-order
public final LinkedList<File> files = new LinkedList<>();
public final String wildcardGifski;
public SplitAFLResult(File dir) {
baseDir = dir;
wildcardGifski = new File(dir, "f").toString() + "*.png";
}
public void delete() {
for (File f : files)
f.delete();
baseDir.delete();
}
}
public static void showHelp() {
System.out.println("java -jar isle-tooling.jar CMD ARGS...");
System.out.println("Commands:");
System.out.println("splitafl[u] JSON PNG FPS OUTDIR OUTLIST");
System.out.println(" splits JSON/PNG into parts");
System.out.println(" adding 'u' enables unix mode (blame gifski)");
System.out.println("aflgs[u] JSON PNG FPS GIFSKIARGS...");
System.out.println(" splitafl to a temporary directory, then automatically runs gifski ; you're expected to pass -o, --fixed etc.");
System.out.println(" if FPS is -1, the FPS is guessed (THIS CAN FAIL)");
System.out.println(" adding 'u' enables unix mode (blame gifski)");
System.out.println("apng JSON PNG OUTAPNG");
System.out.println(" APNGifies the spritesheet");
System.out.println("ffmpeg JSON PNG FFMPEGARGS...");
System.out.println(" APNGifies the spritesheet and then passes to ffmpeg");
System.out.println(" input args are setup for you, output args aren't");
System.out.println(" example: ffmpeg bleh.json bleh.png out.mp4");
System.out.println(" this is good for using ffmpeg to optimize an APNG");
System.out.println("guessfps JSON");
System.out.println(" run FPS guesser and output to stdout, outputs -1 on failure");
}
/* -- APNG assistance -- */
public static class APNGChunk implements AutoCloseable {
private final DataOutputStream parent;
private final ByteArrayOutputStream buffer;
public final DataOutputStream contents;
private static final int[] crc32tab = new int[256];
static {
for (int i = 0; i < crc32tab.length; i++) {
int crc = i;
for (int j = 0; j < 8; j++) {
if ((crc & 1) != 0) {
crc = (crc >>> 1) ^ 0xEDB88320;
} else {
crc = crc >>> 1;
}
}
crc32tab[i] = crc;
}
}
public APNGChunk(DataOutputStream p, String ct) {
parent = p;
byte[] chunkType = ct.getBytes(StandardCharsets.UTF_8);
if (chunkType.length != 4)
throw new RuntimeException("bad chunk type");
contents = new DataOutputStream(buffer = new ByteArrayOutputStream());
// So it gets included in CRC. The 4 bytes are removed from the length manually later.
buffer.write(chunkType[0]);
buffer.write(chunkType[1]);
buffer.write(chunkType[2]);
buffer.write(chunkType[3]);
}
@Override
public void close() throws Exception {
contents.flush();
byte[] bufDat = buffer.toByteArray();
parent.writeInt(bufDat.length - 4);
parent.write(bufDat);
parent.writeInt(crc32(bufDat));
}
public int crc32(byte[] data) {
int v = -1;
for (byte b : data) {
int lb = (b & 0xFF) ^ (v & 0xFF);
v = crc32tab[lb] ^ (v >>> 8);
}
return v ^ -1;
}
}
/* -- LSSpritesheet -- */
public static Anim readLSSpritesheet(File json, File png) throws Exception {
JSONTokener jt = new JSONTokener(new InputStreamReader(new FileInputStream(json), StandardCharsets.UTF_8));
JSONObject res = (JSONObject) jt.nextValue();
JSONObject frames = res.getJSONObject("frames");
LinkedList<String> frameKeys = new LinkedList<String>(frames.keySet());
// listen to eurobeat while looking at this code {
int sheetW;
int sheetH;
int[] sheet;
if (png != null) {
BufferedImage biPre = ImageIO.read(png);
sheetW = biPre.getWidth();
sheetH = biPre.getHeight();
sheet = new int[sheetW * sheetH];
biPre.getRGB(0, 0, sheetW, sheetH, sheet, 0, sheetW);
biPre = null;
System.gc();
} else {
// luckily, this is fine, because we don't do anything with content here
sheetW = 0;
sheetH = 0;
sheet = new int[0];
}
// }
// example:
// move to inventory 0.ase
// move to inventory 1.ase
// ...
// move to inventory 10.ase
frameKeys.sort((a, b) -> {
if (a.length() > b.length())
return 1;
if (a.length() < b.length())
return -1;
return a.compareTo(b);
});
Anim anim = null;
for (String s : frameKeys) {
JSONObject frame = frames.getJSONObject(s);
JSONObject rect = frame.getJSONObject("frame");
int x = rect.getInt("x");
int y = rect.getInt("y");
int w = rect.getInt("w");
int h = rect.getInt("h");
int duration = frame.getInt("duration");
// and now actually instance
if (anim == null)
anim = new Anim(w, h, sheet);
// blit into subFrame
int[] rows = new int[h];
for (int i = 0; i < h; i++)
rows[i] = x + ((y + i) * sheetW);
// and make frame
anim.frames.add(new Anim.Frame(anim, s, rows, duration));
}
return anim;
}
public static class Anim {
public final int width, height;
public final LinkedList<Frame> frames = new LinkedList<>();
public final int[] pixels;
public Anim(int w, int h, int[] p) {
width = w;
height = h;
pixels = p;
}
public int guessFPS() {
for (int i = 1; i <= 1000; i++) {
// alright, here goes nothing.
// firstly, does this even divide evenly?
// if not, don't even try...
if ((1000 % i) != 0)
continue;
int ms = 1000 / i;
boolean isValid = true;
for (Frame f : frames) {
if ((f.durationMs % ms) != 0) {
isValid = false;
break;
}
}
if (isValid)
return i;
}
// :(
return -1;
}
public static class Frame {
public final String name;
// Row base locations
public final int[] rows;
public final int durationMs;
public Frame(Anim parent, String n, int[] r, int d) {
name = n;
rows = r;
durationMs = d;
}
}
public static class Diff {
public final int[] pixels;
public final int x, y, width, height;
public final boolean replace;
public Diff(int[] data, int x, int y, int w, int h, boolean r) {
this.pixels = data;
this.x = x;
this.y = y;
this.width = w;
this.height = h;
this.replace = r;
}
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>isle.tooling</groupId>
<artifactId>isle-tooling</artifactId>
<version>0.666-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Java 8 target is non-negotiable -->
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20230227</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.4.2</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>isle.Main</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@20kdc
Copy link
Author

20kdc commented May 16, 2024

This is a comment on my old Gist in order to test something, pay it no mind

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment