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