This commit is contained in:
parent
3a430ab596
commit
789f4638dc
|
@ -14,6 +14,8 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "^2.7.52",
|
"@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"
|
"sssg": "git+https://git.sad.ovh/sophie/sssg#e68ad369e9399d33f58678d2a3271cd631c2fe6a"
|
||||||
}
|
}
|
||||||
}
|
}
|
BIN
website/assets/cursor.ani
Normal file
BIN
website/assets/cursor.ani
Normal file
Binary file not shown.
|
@ -7,6 +7,7 @@ __TEMPLATE_HEAD__
|
||||||
<script type="module" src="scripts/lastfm.js"></script>
|
<script type="module" src="scripts/lastfm.js"></script>
|
||||||
<script type="module" src="scripts/binkies.js"></script>
|
<script type="module" src="scripts/binkies.js"></script>
|
||||||
<script type="module" src="scripts/commit.js"></script>
|
<script type="module" src="scripts/commit.js"></script>
|
||||||
|
<script type="module" src="scripts/cursor.js"></script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="assets/style.css">
|
<link rel="stylesheet" href="assets/style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
225
website/scripts/ani_parser.ts
Normal file
225
website/scripts/ani_parser.ts
Normal file
|
@ -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<T>(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);
|
||||||
|
}
|
8
website/scripts/cursor.ts
Normal file
8
website/scripts/cursor.ts
Normal file
|
@ -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);
|
||||||
|
})();
|
|
@ -18,4 +18,4 @@ export function timeAgo(input: number | Date) {
|
||||||
return formatter.format(Math.round(delta), rangeType);
|
return formatter.format(Math.round(delta), rangeType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in a new issue