diff --git a/bun.lockb b/bun.lockb
index 4508ecc..0b1fbf6 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 7d6c1d4..d201f8e 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,8 @@
"type": "module",
"dependencies": {
"@zip.js/zip.js": "^2.7.52",
+ "ani-cursor": "^0.0.5",
+ "riff-file": "^1.0.3",
"sssg": "git+https://git.sad.ovh/sophie/sssg#e68ad369e9399d33f58678d2a3271cd631c2fe6a"
}
}
\ No newline at end of file
diff --git a/website/assets/cursor.ani b/website/assets/cursor.ani
new file mode 100644
index 0000000..ceb9fb4
Binary files /dev/null and b/website/assets/cursor.ani differ
diff --git a/website/index.html b/website/index.html
index 79bb2c0..2103876 100644
--- a/website/index.html
+++ b/website/index.html
@@ -7,6 +7,7 @@ __TEMPLATE_HEAD__
+
diff --git a/website/scripts/ani_parser.ts b/website/scripts/ani_parser.ts
new file mode 100644
index 0000000..7fc7baf
--- /dev/null
+++ b/website/scripts/ani_parser.ts
@@ -0,0 +1,225 @@
+// Code by
+// https://www.npmjs.com/package/ani-cursor
+
+// When importing the NPM package it just doesn't work.
+// The babel version is so old it doesn't work on my browser.
+
+import { RIFFFile } from "riff-file";
+import { unpackArray, unpackString } from "byte-data";
+
+type Chunk = {
+ format: string;
+ chunkId: string;
+ chunkData: {
+ start: number;
+ end: number;
+ };
+ subChunks: Chunk[];
+};
+
+// https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3
+type AniMetadata = {
+ cbSize: number; // Data structure size (in bytes)
+ nFrames: number; // Number of images (also known as frames) stored in the file
+ nSteps: number; // Number of frames to be displayed before the animation repeats
+ iWidth: number; // Width of frame (in pixels)
+ iHeight: number; // Height of frame (in pixels)
+ iBitCount: number; // Number of bits per pixel
+ nPlanes: number; // Number of color planes
+ iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units)
+ bfAttributes: number; // ANI attribute bit flags
+};
+
+type ParsedAni = {
+ rate: number[] | null;
+ seq: number[] | null;
+ images: Uint8Array[];
+ metadata: AniMetadata;
+ artist: string | null;
+ title: string | null;
+};
+
+const DWORD = { bits: 32, be: false, signed: false, fp: false };
+
+export function parseAni(arr: Uint8Array): ParsedAni {
+ const riff = new RIFFFile();
+
+ riff.setSignature(arr);
+
+ const signature = riff.signature as Chunk;
+ if (signature.format !== "ACON") {
+ throw new Error(
+ `Expected format. Expected "ACON", got "${signature.format}"`
+ );
+ }
+
+ // Helper function to get a chunk by chunkId and transform it if it's non-null.
+ function mapChunk(chunkId: string, mapper: (chunk: Chunk) => T): T | null {
+ const chunk = riff.findChunk(chunkId) as Chunk | null;
+ return chunk == null ? null : mapper(chunk);
+ }
+
+ function readImages(chunk: Chunk, frameCount: number): Uint8Array[] {
+ return chunk.subChunks.slice(0, frameCount).map((c) => {
+ if (c.chunkId !== "icon") {
+ throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`);
+ }
+ return arr.slice(c.chunkData.start, c.chunkData.end);
+ });
+ }
+
+ const metadata = mapChunk("anih", (c) => {
+ const words = unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
+ return {
+ cbSize: words[0],
+ nFrames: words[1],
+ nSteps: words[2],
+ iWidth: words[3],
+ iHeight: words[4],
+ iBitCount: words[5],
+ nPlanes: words[6],
+ iDispRate: words[7],
+ bfAttributes: words[8],
+ };
+ });
+
+ if (metadata == null) {
+ throw new Error("Did not find anih");
+ }
+
+ const rate = mapChunk("rate", (c) => {
+ return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
+ });
+ // chunkIds are always four chars, hence the trailing space.
+ const seq = mapChunk("seq ", (c) => {
+ return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
+ });
+
+ const lists = riff.findChunk("LIST", true) as Chunk[] | null;
+ const imageChunk = lists?.find((c) => c.format === "fram");
+ if (imageChunk == null) {
+ throw new Error("Did not find fram LIST");
+ }
+
+ let images = readImages(imageChunk, metadata.nFrames);
+
+ let title: string | null = null;
+ let artist: string | null = null;
+
+ const infoChunk = lists?.find((c) => c.format === "INFO");
+ if (infoChunk != null) {
+ infoChunk.subChunks.forEach((c) => {
+ switch (c.chunkId) {
+ case "INAM":
+ title = unpackString(arr, c.chunkData.start, c.chunkData.end);
+ break;
+ case "IART":
+ artist = unpackString(arr, c.chunkData.start, c.chunkData.end);
+ break;
+ case "LIST":
+ // Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason?
+ if (c.format === "fram") {
+ images = readImages(c, metadata.nFrames);
+ }
+ break;
+
+ default:
+ // Unexpected subchunk
+ }
+ });
+ }
+
+ return { images, rate, seq, metadata, artist, title };
+}
+
+type AniCursorImage = {
+ frames: {
+ url: string;
+ percents: number[];
+ }[];
+ duration: number;
+};
+
+const JIFFIES_PER_MS = 1000 / 60;
+
+// Generate CSS for an animated cursor.
+//
+// This function returns CSS containing a set of keyframes with embedded Data
+// URIs as well as a CSS rule to the given selector.
+export function convertAniBinaryToCSS(
+ selector: string,
+ aniBinary: Uint8Array
+): string {
+ const ani = readAni(aniBinary);
+
+ const animationName = `ani-cursor-${uniqueId()}`;
+
+ const keyframes = ani.frames.map(({ url, percents }) => {
+ const percent = percents.map((num) => `${num}%`).join(", ");
+ return `${percent} { cursor: url(${url}), auto; }`;
+ });
+
+ // CSS properties with a animation type of "discrete", like `cursor`, actually
+ // switch half-way _between_ each keyframe percentage. Luckily this half-way
+ // measurement is applied _after_ the easing function is applied. So, we can
+ // force the frames to appear at exactly the % that we specify by using
+ // `timing-function` of `step-end`.
+ //
+ // https://drafts.csswg.org/web-animations-1/#discrete
+ const timingFunction = "step-end";
+
+ // Winamp (re)starts the animation cycle when your mouse enters an element. By
+ // default this approach would cause the animation to run continuously, even
+ // when the cursor is not visible. To match Winamp's behavior we add a
+ // `:hover` pseudo selector so that the animation only runs when the cursor is
+ // visible.
+ const pseudoSelector = ":hover";
+
+ // prettier-ignore
+ return `
+ @keyframes ${animationName} {
+ ${keyframes.join("\n")}
+ }
+ ${selector}${pseudoSelector} {
+ animation: ${animationName} ${ani.duration}ms ${timingFunction} infinite;
+ }
+ `;
+}
+
+function readAni(contents: Uint8Array): AniCursorImage {
+ const ani = parseAni(contents);
+ const rate = ani.rate ?? ani.images.map(() => ani.metadata.iDispRate);
+ const duration = sum(rate);
+
+ const frames = ani.images.map((image) => ({
+ url: curUrlFromByteArray(image),
+ percents: [] as number[],
+ }));
+
+ let elapsed = 0;
+ rate.forEach((r, i) => {
+ const frameIdx = ani.seq ? ani.seq[i] : i;
+ frames[frameIdx].percents.push((elapsed / duration) * 100);
+ elapsed += r;
+ });
+
+ return { duration: duration * JIFFIES_PER_MS, frames };
+}
+
+/* Utility Functions */
+
+let i = 0;
+const uniqueId = () => i++;
+
+function base64FromDataArray(dataArray: Uint8Array): string {
+ return globalThis.window ? window.btoa(String.fromCharCode(...dataArray)) : Buffer.from(dataArray).toString("base64");
+}
+
+function curUrlFromByteArray(arr: Uint8Array) {
+ const base64 = base64FromDataArray(arr);
+ return `data:image/x-win-bitmap;base64,${base64}`;
+}
+
+function sum(values: number[]): number {
+ return values.reduce((total, value) => total + value, 0);
+}
\ No newline at end of file
diff --git a/website/scripts/cursor.ts b/website/scripts/cursor.ts
new file mode 100644
index 0000000..2e14733
--- /dev/null
+++ b/website/scripts/cursor.ts
@@ -0,0 +1,8 @@
+import { convertAniBinaryToCSS } from "./ani_parser";
+(async () => {
+ const req = await fetch("/assets/cursor.ani");
+ const buf = new Uint8Array(await req.arrayBuffer());
+ const style = document.createElement("style");
+ style.innerHTML = convertAniBinaryToCSS("body", buf);
+ document.body.appendChild(style);
+})();
diff --git a/website/scripts/util.ts b/website/scripts/util.ts
index 9b541ae..28a7c55 100644
--- a/website/scripts/util.ts
+++ b/website/scripts/util.ts
@@ -18,4 +18,4 @@ export function timeAgo(input: number | Date) {
return formatter.format(Math.round(delta), rangeType);
}
}
-}
+}
\ No newline at end of file