Support rate and seq

This commit is contained in:
Jordan Eldredge 2020-12-03 20:07:32 -08:00
parent 757d630579
commit 303a83a8c2
6 changed files with 261 additions and 95 deletions

View file

@ -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,
}
`);
});

View file

@ -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);
}

View file

@ -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)) {

View file

@ -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<PlaylistStyle> {

View file

@ -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 {

View file

@ -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));
};