mirror of
https://github.com/captbaritone/webamp.git
synced 2026-08-01 14:33:38 +00:00
Support rate and seq
This commit is contained in:
parent
757d630579
commit
303a83a8c2
6 changed files with 261 additions and 95 deletions
106
packages/webamp/js/aniParser.test.ts
Normal file
106
packages/webamp/js/aniParser.test.ts
Normal 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,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue