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 +38 -0
- package/dist/__tests__/parser.test.d.ts +1 -0
- package/dist/__tests__/parser.test.js +36 -0
- package/dist/__tests__/parser.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +70 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +21 -0
- package/dist/parser.js +79 -0
- package/dist/parser.js.map +1 -0
- package/package.json +68 -0
- package/src/__tests__/parser.test.ts +45 -0
- package/src/index.ts +97 -0
- package/src/parser.ts +127 -0
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"}
|
package/dist/index.d.ts
ADDED
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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"}
|
package/dist/parser.d.ts
ADDED
|
@@ -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":"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"}
|
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
|
+
}
|