diff --git a/packages/webamp/js/aniParser.test.ts b/packages/webamp/js/aniParser.test.ts new file mode 100644 index 00000000..0e285d43 --- /dev/null +++ b/packages/webamp/js/aniParser.test.ts @@ -0,0 +1,106 @@ +import fs from "fs"; +import path from "path"; +import { parseAni, ParsedAni } from "./aniParser"; + +function parsePath(filePath: string): ParsedAni { + const buffer = fs.readFileSync( + path.join(__dirname, "./__tests__/fixtures/ani/", filePath) + ); + + return parseAni(buffer); +} + +test("Super_Mario_Amp_2.wsz eqslid.cur", async () => { + const ani = parsePath("Super_Mario_Amp_2/eqslid.cur"); + // @ts-ignore + ani.images = ani.images.map((image) => image.slice(0, 60).join("")); + expect(ani).toMatchInlineSnapshot(` + Object { + "images": Array [ + "0020103232000000168120022000400003200064000102400000128120000000000000000", + "0020103232000000168120022000400003200064000102400000128120000000000000000", + "0020103232000000168120022000400003200064000102400000128120000000000000000", + "0020103232000000168120022000400003200064000102400000128120000000000000000", + "0020103232000000168120022000400003200064000102400000128120000000000000000", + "0020103232000000168120022000400003200064000102400000128120000000000000000", + "0020103232000000168120022000400003200064000102400000128120000000000000000", + "0020103232000000168120022000400003200064000102400000128120000000000000000", + ], + "metadata": Object { + "bfAttributes": 1, + "cbSize": 36, + "iBitCount": 4, + "iDispRate": 10, + "iHeight": 0, + "iWidth": 0, + "nFrames": 8, + "nPlanes": 1, + "nSteps": 8, + }, + "rate": null, + "seq": null, + } + `); +}); + +test("Green Dimension v2.wsz eqslid.cur", async () => { + const ani = parsePath("Green_Dimension_v2/Eqslid.cur"); + // @ts-ignore + ani.images = ani.images.map((image) => image.slice(0, 60).join("")); + expect(ani).toMatchInlineSnapshot(` + Object { + "images": Array [ + "00201032320000001681600220004000032000640001032000000160000000000000000", + "00201032320000001681600220004000032000640001032000000160000000000000000", + ], + "metadata": Object { + "bfAttributes": 1, + "cbSize": 36, + "iBitCount": 0, + "iDispRate": 10, + "iHeight": 0, + "iWidth": 0, + "nFrames": 2, + "nPlanes": 0, + "nSteps": 2, + }, + "rate": Array [ + 8, + 8, + ], + "seq": null, + } + `); +}); + +test("rainbow", async () => { + const ani = parsePath("Rainbow classic.ani"); + // @ts-ignore + ani.images = ani.images.map((image) => image.slice(0, 60).join("")); + expect(ani).toMatchInlineSnapshot(` + Object { + "images": Array [ + "0020103232000000168160022000400003200064000103200000128160000000000000000", + "0020103232000000168160022000400003200064000103200000128160000000000000000", + "0020103232000000168160022000400003200064000103200000128160000000000000000", + "0020103232000000168160022000400003200064000103200000128160000000000000000", + "0020103232000000168160022000400003200064000103200000128160000000000000000", + "0020103232000000168160022000400003200064000103200000128160000000000000000", + "0020103232000000168160022000400003200064000103200000128160000000000000000", + ], + "metadata": Object { + "bfAttributes": 1, + "cbSize": 36, + "iBitCount": 0, + "iDispRate": 7, + "iHeight": 0, + "iWidth": 0, + "nFrames": 7, + "nPlanes": 0, + "nSteps": 7, + }, + "rate": null, + "seq": null, + } + `); +}); diff --git a/packages/webamp/js/aniParser.ts b/packages/webamp/js/aniParser.ts index dd08c153..f213f526 100644 --- a/packages/webamp/js/aniParser.ts +++ b/packages/webamp/js/aniParser.ts @@ -1,5 +1,4 @@ import { RIFFFile } from "riff-file"; -import * as Utils from "./utils"; import { unpackArray } from "byte-data"; type Chunk = { @@ -12,75 +11,93 @@ type Chunk = { subChunks: Chunk[]; }; -export function parseAni(arr: Uint8Array) { +// https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3 +export 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 +}; + +export type ParsedAni = { + rate: number[] | null; + seq: number[] | null; + images: Uint8Array[]; + metadata: AniMetadata; +}; + +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(`Invalid format. Expected "ACON", got ${signature.format}`); + throw new Error(`Expected fromat "ACON", got "${signature.format}"`); } - const metaChunk = signature.subChunks[0]; - if (metaChunk.chunkId !== "anih") { - throw new Error( - `Invalid ANIHEADER chunkId. Expected "anih", got ${metaChunk.chunkId}` - ); - } + let metadata: null | AniMetadata = null; + let rate: number[] | null = null; + let seq: number[] | null = null; + let images: Uint8Array[] | null = null; - const metaData = parseMetadata(getChunkData(arr, metaChunk)); - - // TODO: Read metadata - // TODO: Handle the optional "rate" chunk; - - const list = signature.subChunks[signature.subChunks.length - 1]; - if (list.chunkId !== "LIST") { - throw new Error( - `Invalid LIST chunkId. Expected "LIST", got ${list.chunkId}` - ); - } - - const urls = list.subChunks.map((chunk) => { - if (chunk.chunkId !== "icon") { - throw new Error( - `Invalid icon chunkId. Expected "icon", got ${chunk.chunkId}` - ); + signature.subChunks.forEach(({ chunkId, chunkData, subChunks }) => { + // TODO: Why do we need to trim here? + switch (chunkId.trim()) { + case "anih": // TODO: assert(i === 0) + metadata = parseMetadata(arr, chunkData.start, chunkData.end); + break; + case "rate": // TODO: assert(i === 1) + rate = unpackArray(arr, DWORD, chunkData.start, chunkData.end); + break; + case "seq": // TODO: assert(i === 2) (or 1??) + seq = unpackArray(arr, DWORD, chunkData.start, chunkData.end); + break; + case "LIST": // TODO: assert(i === subChunks.length) + images = subChunks.map((c) => + // TODO: We could assert that each have a chunkId of "icon" + arr.slice(c.chunkData.start, c.chunkData.end) + ); + break; + default: + // TODO: We could assert that this never happens } - const iconArr = getChunkData(arr, chunk); - return urlFromData(iconArr); }); - return { urls, ...metaData }; + if (metadata == null) { + throw new Error("Did not find anih"); + } + + if (images == null) { + throw new Error("Did not find LIST"); + } + + return { images, rate, seq, metadata }; } -const DWORD = { bits: 32, be: false, signed: false, fp: false }; - -function parseMetadata(chunkData: Uint8Array) { - const words = unpackArray(chunkData, DWORD); - if (words.length !== 9) { - throw new Error( - `Expected nine DWORDs of metadata in the "anih" section, but got ${words.length}` - ); - } +function parseMetadata( + arr: Uint8Array, + start: number, + end: number +): AniMetadata { + // TODO: We could assert that we have 9 items here. + const words = unpackArray(arr, DWORD, start, end); return { - cbSize: words[0], // Data structure size (in bytes) - nFrames: words[1], // Number of images (also known as frames) stored in the file - nSteps: words[2], // Number of frames to be displayed before the animation repeats - iWidth: words[3], // Width of frame (in pixels) - iHeight: words[4], // Height of frame (in pixels) - iBitCount: words[5], // Number of bits per pixel - nPlanes: words[6], // Number of color planes - iDispRate: words[7], // Default frame display rate (measured in 1/60th-of-a-second units) - bfAttributes: words[9], // ANI attribute bit flags + 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], }; } - -function urlFromData(arr: Uint8Array): string { - const arrBase64 = Utils.base64FromDataArray(arr); - return `data:image/x-win-bitmap;base64,${arrBase64}`; -} - -function getChunkData(arr: Uint8Array, chunk: Chunk): Uint8Array { - return arr.slice(chunk.chunkData.start, chunk.chunkData.end); -} diff --git a/packages/webamp/js/components/Skin.tsx b/packages/webamp/js/components/Skin.tsx index 2c81c394..1754dc25 100644 --- a/packages/webamp/js/components/Skin.tsx +++ b/packages/webamp/js/components/Skin.tsx @@ -3,7 +3,7 @@ import { LETTERS } from "../constants"; import { imageSelectors, cursorSelectors } from "../skinSelectors"; import { useTypedSelector } from "../hooks"; import * as Selectors from "../selectors"; -import { SkinImages } from "../types"; +import { AniFrame, SkinImages } from "../types"; import { createSelector } from "reselect"; import * as Utils from "../utils"; import Css from "./Css"; @@ -33,6 +33,48 @@ const FALLBACKS: { [key: string]: string } = { MAIN_BALANCE_THUMB_ACTIVE: "MAIN_VOLUME_THUMB_SELECTED", }; +// Generate CSS for an animated cursor. +// +// Based on https://css-tricks.com/forums/topic/animated-cursor/ +// +// Browsers won't render animated cursor images specified via CSS. For `.ani` +// images, we already have the frames as indiviual images, so we create a CSS +// animation. +// +// This function returns CSS containing a set of keyframes with embedded Data +// URIs as well as a CSS rule to the given selector. +function aniCss(selector: string, frames: AniFrame[]): string { + const animationName = `webamp-ani-cursor-${Utils.uniqueId()}`; + const totalDuration = Utils.sum(frames.map(({ rate }) => rate)); + + let elapsed = 0; + const keyframes = frames.map(({ url, rate }) => { + const percent = (elapsed / totalDuration) * 100; + // Since our animation loops, we need to tell CSS that 0% === 100% + const percentStr = percent === 0 ? `0%, 100%` : `${percent}%`; + + elapsed += rate; + return `${percentStr} { cursor: url(${url}), auto; }`; + }); + const framesCss = keyframes.join("\n"); + const keyframesCss = `@keyframes ${animationName} { ${framesCss} }`; + + const durationMs = totalDuration * 16; // Convert jiffies to ms + const rule = `${selector} { animation: ${animationName} ${durationMs}ms infinite; }`; + return [keyframesCss, rule].join("\n"); +} + +// Cursors might appear in context menus which are not nested inside the window layout div. +function normalizeCursorSelector(selector: string): string { + return `${ + // TODO: Fix this hack + // Maybe our CSS name spacing should be based on some other class/id + // than the one we use for defining the main div. + // That way it could be shared by both the player and the context menu. + selector.startsWith("#webamp-context-menu") ? "" : CSS_PREFIX + } ${selector}`; +} + const getCssRules = createSelector( Selectors.getSkinImages, Selectors.getSkinCursors, @@ -69,42 +111,23 @@ const getCssRules = createSelector( }); } - Object.keys(cursorSelectors).forEach((cursorName) => { + Object.entries(cursorSelectors).forEach(([cursorName, cursorSelector]) => { const cursor = skinCursors[cursorName]; - if (cursor) { - cursorSelectors[cursorName].forEach((selector) => { - const normalizedSelector = `${ - // TODO: Fix this hack - // Maybe our CSS name spacing should be based on some other class/id - // than the one we use for defining the main div. - // That way it could be shared by both the player and the context menu. - selector.startsWith("#webamp-context-menu") ? "" : CSS_PREFIX - } ${selector}`; - + if (cursor == null) { + return; + } + const cursorRules = cursorSelector + .map(normalizeCursorSelector) + .map((selector) => { switch (cursor.type) { case "cur": - cssRules.push( - `${normalizedSelector} {cursor: url(${cursor.url}), auto}` - ); - break; + return `${selector} {cursor: url(${cursor.url}), auto}`; case "ani": { - const animationName = `ani-cursor-${Utils.uniqueId()}`; - const frames = cursor.urls.map((url, i) => { - const percent = (i / (cursor.urls.length - 1)) * 100; - return `${percent}% { cursor: url(${url}), auto; }`; - }); - const durationMs = cursor.iDispRate * 16 * frames.length; - const framesCss = frames.join("\n"); - const keyframes = `@keyframes ${animationName} { ${framesCss} }`; - cssRules.push(keyframes); - cssRules.push( - `${normalizedSelector} { animation: ${animationName} ${durationMs}ms infinite; }` - ); - break; + return aniCss(selector, cursor.frames); } } }); - } + cssRules.push(...cursorRules); }); if (numExIsUsed(skinImages)) { diff --git a/packages/webamp/js/skinParserUtils.ts b/packages/webamp/js/skinParserUtils.ts index 54123210..52251e74 100644 --- a/packages/webamp/js/skinParserUtils.ts +++ b/packages/webamp/js/skinParserUtils.ts @@ -3,7 +3,7 @@ import { PlaylistStyle, SkinGenExColors, CursorImage } from "./types"; import SKIN_SPRITES, { Sprite } from "./skinSprites"; import { DEFAULT_SKIN } from "./constants"; import * as Utils from "./utils"; -import { parseAni } from "./aniParser"; +import { parseAni, ParsedAni } from "./aniParser"; export const getFileExtension = (fileName: string): string | null => { const matches = /\.([a-z]{3,4})$/i.exec(fileName); @@ -131,6 +131,20 @@ function arrayStartsWith(arr: Uint8Array, matcher: number[]): boolean { return matcher.every((item, i) => arr[i] === item); } +function curUrlFromByteArray(arr: Uint8Array) { + const base64 = Utils.base64FromDataArray(arr); + return `data:image/x-win-bitmap;base64,${base64}`; +} + +function framesFromAni(ani: ParsedAni) { + const rawUrls = ani.images.map(curUrlFromByteArray); + const urls = ani.seq == null ? rawUrls : ani.seq.map((i) => rawUrls[i]); + return urls.map((url, i) => { + const rate = ani.rate == null ? ani.metadata.iDispRate : ani.rate[i]; + return { url, rate }; + }); +} + export async function getCursorFromFilename( zip: JSZip, fileName: string @@ -142,14 +156,10 @@ export async function getCursorFromFilename( const contents = file.contents as Uint8Array; if (arrayStartsWith(contents, RIFF_MAGIC)) { const ani = parseAni(contents); - return { type: "ani", urls: ani.urls, iDispRate: ani.iDispRate }; + return { type: "ani", frames: framesFromAni(ani) }; } - const base64 = Utils.base64FromDataArray(contents); - return { - type: "cur", - url: `data:image/x-win-bitmap;base64,${base64}`, - }; + return { type: "cur", url: curUrlFromByteArray(contents) }; } export async function getPlaylistStyle(zip: JSZip): Promise { diff --git a/packages/webamp/js/types.ts b/packages/webamp/js/types.ts index 42225e0e..03c30fbf 100644 --- a/packages/webamp/js/types.ts +++ b/packages/webamp/js/types.ts @@ -82,14 +82,21 @@ export type CursorImage = } | { type: "ani"; - iDispRate: number; - urls: string[]; + frames: { + url: string; + rate: number; + }[]; }; // TODO: Use a type to ensure these keys mirror the CURSORS constant in // skinParser.js export type Cursors = { [cursor: string]: CursorImage }; +export type AniFrame = { + url: string; + rate: number; +}; + export type GenLetterWidths = { [letter: string]: number }; export interface PlaylistStyle { diff --git a/packages/webamp/js/utils.ts b/packages/webamp/js/utils.ts index 61a1d370..9027640c 100644 --- a/packages/webamp/js/utils.ts +++ b/packages/webamp/js/utils.ts @@ -129,6 +129,9 @@ export const parseIni = (text: string): IniData => { export const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); +export const sum = (values: number[]): number => + values.reduce((total, value) => total + value, 0); + export const base64FromDataArray = (dataArray: Uint8Array): string => { return window.btoa(String.fromCharCode(...dataArray)); };