ani-cursor 0.0.0-next-c2c0675

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # ani-cursor
2
+
3
+ A library for rendering Windows Animated Cursor files (`.ani`) in the browser by parsing out the individual frames and constructing a CSS animation.
4
+
5
+ Built to support `.ani` files in Winamp skins for https://webamp.org.
6
+
7
+ I wrote a blog post about this library which you can find [here](https://jordaneldredge.com/blog/rendering-animated-ani-cursors-in-the-browser/).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install ani-cursor
13
+ ```
14
+
15
+ ## Usage Example
16
+
17
+ ```JavaScript
18
+ import {convertAniBinaryToCSS} from 'ani-cursor';
19
+
20
+ async function applyCursor(selector, aniUrl) {
21
+ const response = await fetch(aniUrl);
22
+ const data = new Uint8Array(await response.arrayBuffer());
23
+
24
+ const style = document.createElement('style');
25
+ style.innerText = convertAniBinaryToCSS(selector, data);
26
+
27
+ document.head.appendChild(style);
28
+ }
29
+
30
+ const h1 = document.createElement('h1');
31
+ h1.id = 'pizza';
32
+ h1.innerText = 'Pizza Time!';
33
+ document.body.appendChild(h1);
34
+
35
+ applyCursor("#pizza", "https://archive.org/cors/tucows_169906_Pizza_cursor/pizza.ani");
36
+ ```
37
+
38
+ Try the [Live Demo on CodeSandbox](https://codesandbox.io/s/jolly-thunder-9jkio?file=/src/index.js).
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { convertAniBinaryToCSS } from "../index.js";
4
+ const LONG_BASE_64 = /([A-Za-z0-9+/=]{50})[A-Za-z0-9+/=]+/g;
5
+ // Parse a `.ani` in our fixture directory and trim down the image data for use
6
+ // in snapshot tests.
7
+ function readPathCss(filePath) {
8
+ const buffer = fs.readFileSync(path.join(__dirname, "./fixtures/", filePath));
9
+ return convertAniBinaryToCSS("#example", buffer).replace(LONG_BASE_64, "$1...");
10
+ }
11
+ // https://skins.webamp.org/skin/6e30f9e9b8f5719469809785ae5e4a1f/Super_Mario_Amp_2.wsz/
12
+ describe("Super_Mario_Amp_2.wsz", () => {
13
+ test("eqslid.cur", async () => {
14
+ expect(readPathCss("Super_Mario_Amp_2/eqslid.cur")).toMatchSnapshot();
15
+ });
16
+ test("close.cur", async () => {
17
+ expect(readPathCss("Super_Mario_Amp_2/close.cur")).toMatchSnapshot();
18
+ });
19
+ });
20
+ // https://skins.webamp.org/skin/4308a2fc648033bf5fe7c4d56a5c8823/Green-Dimension-V2.wsz/
21
+ describe("Green Dimension v2.wsz", () => {
22
+ test("eqslid.cur", async () => {
23
+ expect(readPathCss("Green Dimension v2/eqslid.cur")).toMatchSnapshot();
24
+ });
25
+ });
26
+ describe("AfterShock_Digital_2003.wsz", () => {
27
+ test("close.cur", async () => {
28
+ expect(readPathCss("AfterShock_Digital_2003/close.cur")).toMatchSnapshot();
29
+ });
30
+ });
31
+ describe("Edge cases", () => {
32
+ test("piano.ani", async () => {
33
+ expect(readPathCss("piano.ani")).toMatchSnapshot();
34
+ });
35
+ });
36
+ //# sourceMappingURL=parser.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parser.test.js","sourceRoot":"","sources":["../../src/__tests__/parser.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEpD,MAAM,YAAY,GAAG,sCAAsC,CAAC;AAE5D,+EAA+E;AAC/E,qBAAqB;AACrB,SAAS,WAAW,CAAC,QAAgB;IACnC,MAAM,MAAM,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,aAAa,EAAE,QAAQ,CAAC,CAAC,CAAC;IAE9E,OAAO,qBAAqB,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,OAAO,CACtD,YAAY,EACZ,OAAO,CACR,CAAC;AACJ,CAAC;AAED,wFAAwF;AACxF,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,IAAI,CAAC,YAAY,EAAE,KAAK,IAAI,EAAE;QAC5B,MAAM,CAAC,WAAW,CAAC,8BAA8B,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC;IACxE,CAAC,CAAC,CAAC;IACH,IAAI,CAAC,WAAW,EAAE,KAAK,IAAI,EAAE;QAC3B,MAAM,CAAC,WAAW,CAAC,6BAA6B,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC;IACvE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,yFAAyF;AACzF,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,IAAI,CAAC,YAAY,EAAE,KAAK,IAAI,EAAE;QAC5B,MAAM,CAAC,WAAW,CAAC,+BAA+B,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC;IACzE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,6BAA6B,EAAE,GAAG,EAAE;IAC3C,IAAI,CAAC,WAAW,EAAE,KAAK,IAAI,EAAE;QAC3B,MAAM,CAAC,WAAW,CAAC,mCAAmC,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC;IAC7E,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,YAAY,EAAE,GAAG,EAAE;IAC1B,IAAI,CAAC,WAAW,EAAE,KAAK,IAAI,EAAE;QAC3B,MAAM,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC;IACrD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare function convertAniBinaryToCSS(selector: string, aniBinary: Uint8Array): string;
2
+ export declare function base64FromDataArray(dataArray: Uint8Array): string;
package/dist/index.js ADDED
@@ -0,0 +1,70 @@
1
+ import { parseAni } from "./parser.js";
2
+ const JIFFIES_PER_MS = 1000 / 60;
3
+ // Generate CSS for an animated cursor.
4
+ //
5
+ // This function returns CSS containing a set of keyframes with embedded Data
6
+ // URIs as well as a CSS rule to the given selector.
7
+ export function convertAniBinaryToCSS(selector, aniBinary) {
8
+ const ani = readAni(aniBinary);
9
+ const animationName = `ani-cursor-${uniqueId()}`;
10
+ const keyframes = ani.frames.map(({ url, percents }) => {
11
+ const percent = percents.map((num) => `${num}%`).join(", ");
12
+ return `${percent} { cursor: url(${url}), auto; }`;
13
+ });
14
+ // CSS properties with a animation type of "discrete", like `cursor`, actually
15
+ // switch half-way _between_ each keyframe percentage. Luckily this half-way
16
+ // measurement is applied _after_ the easing function is applied. So, we can
17
+ // force the frames to appear at exactly the % that we specify by using
18
+ // `timing-function` of `step-end`.
19
+ //
20
+ // https://drafts.csswg.org/web-animations-1/#discrete
21
+ const timingFunction = "step-end";
22
+ // Winamp (re)starts the animation cycle when your mouse enters an element. By
23
+ // default this approach would cause the animation to run continuously, even
24
+ // when the cursor is not visible. To match Winamp's behavior we add a
25
+ // `:hover` pseudo selector so that the animation only runs when the cursor is
26
+ // visible.
27
+ const pseudoSelector = ":hover";
28
+ // prettier-ignore
29
+ return `
30
+ @keyframes ${animationName} {
31
+ ${keyframes.join("\n")}
32
+ }
33
+ ${selector}${pseudoSelector} {
34
+ animation: ${animationName} ${ani.duration}ms ${timingFunction} infinite;
35
+ }
36
+ `;
37
+ }
38
+ function readAni(contents) {
39
+ var _a;
40
+ const ani = parseAni(contents);
41
+ const rate = (_a = ani.rate) !== null && _a !== void 0 ? _a : ani.images.map(() => ani.metadata.iDispRate);
42
+ const duration = sum(rate);
43
+ const frames = ani.images.map((image) => ({
44
+ url: curUrlFromByteArray(image),
45
+ percents: [],
46
+ }));
47
+ let elapsed = 0;
48
+ rate.forEach((r, i) => {
49
+ const frameIdx = ani.seq ? ani.seq[i] : i;
50
+ frames[frameIdx].percents.push((elapsed / duration) * 100);
51
+ elapsed += r;
52
+ });
53
+ return { duration: duration * JIFFIES_PER_MS, frames };
54
+ }
55
+ /* Utility Functions */
56
+ let i = 0;
57
+ const uniqueId = () => i++;
58
+ export function base64FromDataArray(dataArray) {
59
+ return window.btoa(Array.from(dataArray)
60
+ .map((byte) => String.fromCharCode(byte))
61
+ .join(""));
62
+ }
63
+ function curUrlFromByteArray(arr) {
64
+ const base64 = base64FromDataArray(arr);
65
+ return `data:image/x-win-bitmap;base64,${base64}`;
66
+ }
67
+ function sum(values) {
68
+ return values.reduce((total, value) => total + value, 0);
69
+ }
70
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ type AniMetadata = {
2
+ cbSize: number;
3
+ nFrames: number;
4
+ nSteps: number;
5
+ iWidth: number;
6
+ iHeight: number;
7
+ iBitCount: number;
8
+ nPlanes: number;
9
+ iDispRate: number;
10
+ bfAttributes: number;
11
+ };
12
+ type ParsedAni = {
13
+ rate: number[] | null;
14
+ seq: number[] | null;
15
+ images: Uint8Array[];
16
+ metadata: AniMetadata;
17
+ artist: string | null;
18
+ title: string | null;
19
+ };
20
+ export declare function parseAni(arr: Uint8Array): ParsedAni;
21
+ export {};
package/dist/parser.js ADDED
@@ -0,0 +1,79 @@
1
+ import { RIFFFile } from "riff-file";
2
+ import { unpackArray, unpackString } from "byte-data";
3
+ const DWORD = { bits: 32, be: false, signed: false, fp: false };
4
+ export function parseAni(arr) {
5
+ const riff = new RIFFFile();
6
+ riff.setSignature(arr);
7
+ const signature = riff.signature;
8
+ if (signature.format !== "ACON") {
9
+ throw new Error(`Expected format. Expected "ACON", got "${signature.format}"`);
10
+ }
11
+ // Helper function to get a chunk by chunkId and transform it if it's non-null.
12
+ function mapChunk(chunkId, mapper) {
13
+ const chunk = riff.findChunk(chunkId);
14
+ return chunk == null ? null : mapper(chunk);
15
+ }
16
+ function readImages(chunk, frameCount) {
17
+ return chunk.subChunks.slice(0, frameCount).map((c) => {
18
+ if (c.chunkId !== "icon") {
19
+ throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`);
20
+ }
21
+ return arr.slice(c.chunkData.start, c.chunkData.end);
22
+ });
23
+ }
24
+ const metadata = mapChunk("anih", (c) => {
25
+ const words = unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
26
+ return {
27
+ cbSize: words[0],
28
+ nFrames: words[1],
29
+ nSteps: words[2],
30
+ iWidth: words[3],
31
+ iHeight: words[4],
32
+ iBitCount: words[5],
33
+ nPlanes: words[6],
34
+ iDispRate: words[7],
35
+ bfAttributes: words[8],
36
+ };
37
+ });
38
+ if (metadata == null) {
39
+ throw new Error("Did not find anih");
40
+ }
41
+ const rate = mapChunk("rate", (c) => {
42
+ return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
43
+ });
44
+ // chunkIds are always four chars, hence the trailing space.
45
+ const seq = mapChunk("seq ", (c) => {
46
+ return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
47
+ });
48
+ const lists = riff.findChunk("LIST", true);
49
+ const imageChunk = lists === null || lists === void 0 ? void 0 : lists.find((c) => c.format === "fram");
50
+ if (imageChunk == null) {
51
+ throw new Error("Did not find fram LIST");
52
+ }
53
+ let images = readImages(imageChunk, metadata.nFrames);
54
+ let title = null;
55
+ let artist = null;
56
+ const infoChunk = lists === null || lists === void 0 ? void 0 : lists.find((c) => c.format === "INFO");
57
+ if (infoChunk != null) {
58
+ infoChunk.subChunks.forEach((c) => {
59
+ switch (c.chunkId) {
60
+ case "INAM":
61
+ title = unpackString(arr, c.chunkData.start, c.chunkData.end);
62
+ break;
63
+ case "IART":
64
+ artist = unpackString(arr, c.chunkData.start, c.chunkData.end);
65
+ break;
66
+ case "LIST":
67
+ // Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason?
68
+ if (c.format === "fram") {
69
+ images = readImages(c, metadata.nFrames);
70
+ }
71
+ break;
72
+ default:
73
+ // Unexpected subchunk
74
+ }
75
+ });
76
+ }
77
+ return { images, rate, seq, metadata, artist, title };
78
+ }
79
+ //# sourceMappingURL=parser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parser.js","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAkCtD,MAAM,KAAK,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC;AAEhE,MAAM,UAAU,QAAQ,CAAC,GAAe;IACtC,MAAM,IAAI,GAAG,IAAI,QAAQ,EAAE,CAAC;IAE5B,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;IAEvB,MAAM,SAAS,GAAG,IAAI,CAAC,SAAkB,CAAC;IAC1C,IAAI,SAAS,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CACb,0CAA0C,SAAS,CAAC,MAAM,GAAG,CAC9D,CAAC;IACJ,CAAC;IAED,+EAA+E;IAC/E,SAAS,QAAQ,CAAI,OAAe,EAAE,MAA2B;QAC/D,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAiB,CAAC;QACtD,OAAO,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9C,CAAC;IAED,SAAS,UAAU,CAAC,KAAY,EAAE,UAAkB;QAClD,OAAO,KAAK,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;YACpD,IAAI,CAAC,CAAC,OAAO,KAAK,MAAM,EAAE,CAAC;gBACzB,MAAM,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;YACjE,CAAC;YACD,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QACvD,CAAC,CAAC,CAAC;IACL,CAAC;IAED,MAAM,QAAQ,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE;QACtC,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAC1E,OAAO;YACL,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;YAChB,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;YACjB,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;YAChB,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;YAChB,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;YACjB,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC;YACnB,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;YACjB,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC;YACnB,YAAY,EAAE,KAAK,CAAC,CAAC,CAAC;SACvB,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,IAAI,QAAQ,IAAI,IAAI,EAAE,CAAC;QACrB,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE;QAClC,OAAO,WAAW,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IACrE,CAAC,CAAC,CAAC;IACH,4DAA4D;IAC5D,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,EAAE,EAAE;QACjC,OAAO,WAAW,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IACrE,CAAC,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,CAAmB,CAAC;IAC7D,MAAM,UAAU,GAAG,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC;IAC3D,IAAI,UAAU,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC;IAC5C,CAAC;IAED,IAAI,MAAM,GAAG,UAAU,CAAC,UAAU,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC;IAEtD,IAAI,KAAK,GAAG,IAAI,CAAC;IACjB,IAAI,MAAM,GAAG,IAAI,CAAC;IAElB,MAAM,SAAS,GAAG,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC;IAC1D,IAAI,SAAS,IAAI,IAAI,EAAE,CAAC;QACtB,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;YAChC,QAAQ,CAAC,CAAC,OAAO,EAAE,CAAC;gBAClB,KAAK,MAAM;oBACT,KAAK,GAAG,YAAY,CAAC,GAAG,EAAE,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;oBAC9D,MAAM;gBACR,KAAK,MAAM;oBACT,MAAM,GAAG,YAAY,CAAC,GAAG,EAAE,CAAC,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;oBAC/D,MAAM;gBACR,KAAK,MAAM;oBACT,qIAAqI;oBACrI,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;wBACxB,MAAM,GAAG,UAAU,CAAC,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC;oBAC3C,CAAC;oBACD,MAAM;gBAER,QAAQ;gBACR,sBAAsB;YACxB,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;AACxD,CAAC"}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "ani-cursor",
3
+ "version": "0.0.0-next-c2c0675",
4
+ "description": "Render .ani cursors as CSS animations in the browser",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist/",
16
+ "src/**/*.ts"
17
+ ],
18
+ "author": "Jordan Eldredge <jordan@jordaneldredge.com>",
19
+ "license": "MIT",
20
+ "engines": {
21
+ "node": ">=14.0.0"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/captbaritone/webamp.git",
26
+ "directory": "packages/ani-cursor"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/captbaritone/webamp/issues"
30
+ },
31
+ "homepage": "https://github.com/captbaritone/webamp/tree/master/packages/ani-cursor",
32
+ "scripts": {
33
+ "build": "tsc",
34
+ "type-check": "tsc --noEmit",
35
+ "test": "jest",
36
+ "prepublish": "tsc"
37
+ },
38
+ "devDependencies": {
39
+ "@babel/core": "^7.20.0",
40
+ "@babel/preset-env": "^7.20.2",
41
+ "@babel/preset-typescript": "^7.20.0",
42
+ "@swc/jest": "^0.2.24",
43
+ "@types/jest": "^30.0.0",
44
+ "@types/node": "^24.0.10",
45
+ "typescript": "^5.3.3"
46
+ },
47
+ "dependencies": {
48
+ "byte-data": "18.1.1",
49
+ "riff-file": "^1.0.3"
50
+ },
51
+ "jest": {
52
+ "modulePathIgnorePatterns": [
53
+ "dist"
54
+ ],
55
+ "testEnvironment": "jsdom",
56
+ "extensionsToTreatAsEsm": [
57
+ ".ts"
58
+ ],
59
+ "moduleNameMapper": {
60
+ "^(\\.{1,2}/.*)\\.js$": "$1"
61
+ },
62
+ "transform": {
63
+ "^.+\\.(t|j)sx?$": [
64
+ "@swc/jest"
65
+ ]
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,45 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { convertAniBinaryToCSS } from "../index.js";
4
+
5
+ const LONG_BASE_64 = /([A-Za-z0-9+/=]{50})[A-Za-z0-9+/=]+/g;
6
+
7
+ // Parse a `.ani` in our fixture directory and trim down the image data for use
8
+ // in snapshot tests.
9
+ function readPathCss(filePath: string): string {
10
+ const buffer = fs.readFileSync(path.join(__dirname, "./fixtures/", filePath));
11
+
12
+ return convertAniBinaryToCSS("#example", buffer).replace(
13
+ LONG_BASE_64,
14
+ "$1..."
15
+ );
16
+ }
17
+
18
+ // https://skins.webamp.org/skin/6e30f9e9b8f5719469809785ae5e4a1f/Super_Mario_Amp_2.wsz/
19
+ describe("Super_Mario_Amp_2.wsz", () => {
20
+ test("eqslid.cur", async () => {
21
+ expect(readPathCss("Super_Mario_Amp_2/eqslid.cur")).toMatchSnapshot();
22
+ });
23
+ test("close.cur", async () => {
24
+ expect(readPathCss("Super_Mario_Amp_2/close.cur")).toMatchSnapshot();
25
+ });
26
+ });
27
+
28
+ // https://skins.webamp.org/skin/4308a2fc648033bf5fe7c4d56a5c8823/Green-Dimension-V2.wsz/
29
+ describe("Green Dimension v2.wsz", () => {
30
+ test("eqslid.cur", async () => {
31
+ expect(readPathCss("Green Dimension v2/eqslid.cur")).toMatchSnapshot();
32
+ });
33
+ });
34
+
35
+ describe("AfterShock_Digital_2003.wsz", () => {
36
+ test("close.cur", async () => {
37
+ expect(readPathCss("AfterShock_Digital_2003/close.cur")).toMatchSnapshot();
38
+ });
39
+ });
40
+
41
+ describe("Edge cases", () => {
42
+ test("piano.ani", async () => {
43
+ expect(readPathCss("piano.ani")).toMatchSnapshot();
44
+ });
45
+ });
package/src/index.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { parseAni } from "./parser.js";
2
+
3
+ type AniCursorImage = {
4
+ frames: {
5
+ url: string;
6
+ percents: number[];
7
+ }[];
8
+ duration: number;
9
+ };
10
+
11
+ const JIFFIES_PER_MS = 1000 / 60;
12
+
13
+ // Generate CSS for an animated cursor.
14
+ //
15
+ // This function returns CSS containing a set of keyframes with embedded Data
16
+ // URIs as well as a CSS rule to the given selector.
17
+ export function convertAniBinaryToCSS(
18
+ selector: string,
19
+ aniBinary: Uint8Array
20
+ ): string {
21
+ const ani = readAni(aniBinary);
22
+
23
+ const animationName = `ani-cursor-${uniqueId()}`;
24
+
25
+ const keyframes = ani.frames.map(({ url, percents }) => {
26
+ const percent = percents.map((num) => `${num}%`).join(", ");
27
+ return `${percent} { cursor: url(${url}), auto; }`;
28
+ });
29
+
30
+ // CSS properties with a animation type of "discrete", like `cursor`, actually
31
+ // switch half-way _between_ each keyframe percentage. Luckily this half-way
32
+ // measurement is applied _after_ the easing function is applied. So, we can
33
+ // force the frames to appear at exactly the % that we specify by using
34
+ // `timing-function` of `step-end`.
35
+ //
36
+ // https://drafts.csswg.org/web-animations-1/#discrete
37
+ const timingFunction = "step-end";
38
+
39
+ // Winamp (re)starts the animation cycle when your mouse enters an element. By
40
+ // default this approach would cause the animation to run continuously, even
41
+ // when the cursor is not visible. To match Winamp's behavior we add a
42
+ // `:hover` pseudo selector so that the animation only runs when the cursor is
43
+ // visible.
44
+ const pseudoSelector = ":hover";
45
+
46
+ // prettier-ignore
47
+ return `
48
+ @keyframes ${animationName} {
49
+ ${keyframes.join("\n")}
50
+ }
51
+ ${selector}${pseudoSelector} {
52
+ animation: ${animationName} ${ani.duration}ms ${timingFunction} infinite;
53
+ }
54
+ `;
55
+ }
56
+
57
+ function readAni(contents: Uint8Array): AniCursorImage {
58
+ const ani = parseAni(contents);
59
+ const rate = ani.rate ?? ani.images.map(() => ani.metadata.iDispRate);
60
+ const duration = sum(rate);
61
+
62
+ const frames = ani.images.map((image) => ({
63
+ url: curUrlFromByteArray(image),
64
+ percents: [] as number[],
65
+ }));
66
+
67
+ let elapsed = 0;
68
+ rate.forEach((r, i) => {
69
+ const frameIdx = ani.seq ? ani.seq[i] : i;
70
+ frames[frameIdx].percents.push((elapsed / duration) * 100);
71
+ elapsed += r;
72
+ });
73
+
74
+ return { duration: duration * JIFFIES_PER_MS, frames };
75
+ }
76
+
77
+ /* Utility Functions */
78
+
79
+ let i = 0;
80
+ const uniqueId = () => i++;
81
+
82
+ export function base64FromDataArray(dataArray: Uint8Array): string {
83
+ return window.btoa(
84
+ Array.from(dataArray)
85
+ .map((byte) => String.fromCharCode(byte))
86
+ .join("")
87
+ );
88
+ }
89
+
90
+ function curUrlFromByteArray(arr: Uint8Array) {
91
+ const base64 = base64FromDataArray(arr);
92
+ return `data:image/x-win-bitmap;base64,${base64}`;
93
+ }
94
+
95
+ function sum(values: number[]): number {
96
+ return values.reduce((total, value) => total + value, 0);
97
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { RIFFFile } from "riff-file";
2
+ import { unpackArray, unpackString } from "byte-data";
3
+
4
+ type Chunk = {
5
+ format: string;
6
+ chunkId: string;
7
+ chunkData: {
8
+ start: number;
9
+ end: number;
10
+ };
11
+ subChunks: Chunk[];
12
+ };
13
+
14
+ // https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3
15
+ type AniMetadata = {
16
+ cbSize: number; // Data structure size (in bytes)
17
+ nFrames: number; // Number of images (also known as frames) stored in the file
18
+ nSteps: number; // Number of frames to be displayed before the animation repeats
19
+ iWidth: number; // Width of frame (in pixels)
20
+ iHeight: number; // Height of frame (in pixels)
21
+ iBitCount: number; // Number of bits per pixel
22
+ nPlanes: number; // Number of color planes
23
+ iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units)
24
+ bfAttributes: number; // ANI attribute bit flags
25
+ };
26
+
27
+ type ParsedAni = {
28
+ rate: number[] | null;
29
+ seq: number[] | null;
30
+ images: Uint8Array[];
31
+ metadata: AniMetadata;
32
+ artist: string | null;
33
+ title: string | null;
34
+ };
35
+
36
+ const DWORD = { bits: 32, be: false, signed: false, fp: false };
37
+
38
+ export function parseAni(arr: Uint8Array): ParsedAni {
39
+ const riff = new RIFFFile();
40
+
41
+ riff.setSignature(arr);
42
+
43
+ const signature = riff.signature as Chunk;
44
+ if (signature.format !== "ACON") {
45
+ throw new Error(
46
+ `Expected format. Expected "ACON", got "${signature.format}"`
47
+ );
48
+ }
49
+
50
+ // Helper function to get a chunk by chunkId and transform it if it's non-null.
51
+ function mapChunk<T>(chunkId: string, mapper: (chunk: Chunk) => T): T | null {
52
+ const chunk = riff.findChunk(chunkId) as Chunk | null;
53
+ return chunk == null ? null : mapper(chunk);
54
+ }
55
+
56
+ function readImages(chunk: Chunk, frameCount: number): Uint8Array[] {
57
+ return chunk.subChunks.slice(0, frameCount).map((c) => {
58
+ if (c.chunkId !== "icon") {
59
+ throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`);
60
+ }
61
+ return arr.slice(c.chunkData.start, c.chunkData.end);
62
+ });
63
+ }
64
+
65
+ const metadata = mapChunk("anih", (c) => {
66
+ const words = unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
67
+ return {
68
+ cbSize: words[0],
69
+ nFrames: words[1],
70
+ nSteps: words[2],
71
+ iWidth: words[3],
72
+ iHeight: words[4],
73
+ iBitCount: words[5],
74
+ nPlanes: words[6],
75
+ iDispRate: words[7],
76
+ bfAttributes: words[8],
77
+ };
78
+ });
79
+
80
+ if (metadata == null) {
81
+ throw new Error("Did not find anih");
82
+ }
83
+
84
+ const rate = mapChunk("rate", (c) => {
85
+ return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
86
+ });
87
+ // chunkIds are always four chars, hence the trailing space.
88
+ const seq = mapChunk("seq ", (c) => {
89
+ return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end);
90
+ });
91
+
92
+ const lists = riff.findChunk("LIST", true) as Chunk[] | null;
93
+ const imageChunk = lists?.find((c) => c.format === "fram");
94
+ if (imageChunk == null) {
95
+ throw new Error("Did not find fram LIST");
96
+ }
97
+
98
+ let images = readImages(imageChunk, metadata.nFrames);
99
+
100
+ let title = null;
101
+ let artist = null;
102
+
103
+ const infoChunk = lists?.find((c) => c.format === "INFO");
104
+ if (infoChunk != null) {
105
+ infoChunk.subChunks.forEach((c) => {
106
+ switch (c.chunkId) {
107
+ case "INAM":
108
+ title = unpackString(arr, c.chunkData.start, c.chunkData.end);
109
+ break;
110
+ case "IART":
111
+ artist = unpackString(arr, c.chunkData.start, c.chunkData.end);
112
+ break;
113
+ case "LIST":
114
+ // Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason?
115
+ if (c.format === "fram") {
116
+ images = readImages(c, metadata.nFrames);
117
+ }
118
+ break;
119
+
120
+ default:
121
+ // Unexpected subchunk
122
+ }
123
+ });
124
+ }
125
+
126
+ return { images, rate, seq, metadata, artist, title };
127
+ }