Correctly handle keyframe transitions (#1037)

* Correctly handle keyframe transitions

CSS properties with a animation type of "discrete" like `cursor`actually
switch half-way _between_ each keyframe. To avoid ambiguityabout how
long we want each frame to show, we explicitly give both thestart _and_
end timing of each frame.

https://drafts.csswg.org/web-animations-1/#discrete

Thanks to https://github.com/matthewwithanm/ for catching this bug.

We also add a `:hover` pseudo selector to avoid running the animation
when the cursor is not visible.

* Refactor to dedupe URIs in CSS

* Make ani parsing more robust by handling artist/title

Also, ignore icons that crash the parser

* Clean up comments
This commit is contained in:
Jordan Eldredge 2020-12-07 13:07:13 -08:00 committed by GitHub
parent 680b863b90
commit 0bb694d9d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 194 additions and 74 deletions

View file

@ -2,6 +2,7 @@
exports[`Green Dimension v2.wsz eqslid.cur 1`] = `
Object {
"artist": null,
"images": "00201032320000001681600220004000032000640001032000000160000000000000000,00201032320000001681600220004000032000640001032000000160000000000000000...",
"metadata": Object {
"bfAttributes": 1,
@ -19,11 +20,13 @@ Object {
8,
],
"seq": null,
"title": null,
}
`;
exports[`Super_Mario_Amp_2.wsz close.cur 1`] = `
Object {
"artist": null,
"images": "0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000...",
"metadata": Object {
"bfAttributes": 3,
@ -74,11 +77,33 @@ Object {
9,
8,
],
"title": null,
}
`;
exports[`Super_Mario_Amp_2.wsz close.cur 2`] = `
"
@keyframes webamp-ani-cursor-0 {
0% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
7.246376811594203% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
14.492753623188406% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
21.73913043478261% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
28.985507246376812%, 38.405797101449274%, 44.927536231884055% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
34.05797101449276%, 42.028985507246375%, 47.10144927536232% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
48.55072463768116% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
49.275362318840585% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
50.72463768115942%, 53.62318840579711%, 56.52173913043478% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
52.17391304347826%, 55.072463768115945% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
}
#example:hover {
animation: webamp-ani-cursor-0 2300ms step-end infinite;
}
"
`;
exports[`Super_Mario_Amp_2.wsz eqslid.cur 1`] = `
Object {
"artist": null,
"images": "0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000...",
"metadata": Object {
"bfAttributes": 1,
@ -93,5 +118,6 @@ Object {
},
"rate": null,
"seq": null,
"title": null,
}
`;

View file

@ -1,6 +1,7 @@
import fs from "fs";
import path from "path";
import { parseAni } from "./aniParser";
import { readAni, aniCss } from "./aniUtils";
// Parse a `.ani` in our fixture directory and trim down the image data for use
// in snapshot tests.
@ -16,6 +17,19 @@ function parsePath(filePath: string) {
return ani;
}
function readPathCss(filePath: string) {
const buffer = fs.readFileSync(
path.join(__dirname, "./__tests__/fixtures/ani/", filePath)
);
const ani = readAni(buffer);
ani.frames = ani.frames.map(({ url, percents }) => ({
url: url.slice(0, 60),
percents,
}));
return aniCss("#example", ani);
}
// https://skins.webamp.org/skin/6e30f9e9b8f5719469809785ae5e4a1f/Super_Mario_Amp_2.wsz/
describe("Super_Mario_Amp_2.wsz", () => {
test("eqslid.cur", async () => {
@ -23,6 +37,7 @@ describe("Super_Mario_Amp_2.wsz", () => {
});
test("close.cur", async () => {
expect(parsePath("Super_Mario_Amp_2/close.cur")).toMatchSnapshot();
expect(readPathCss("Super_Mario_Amp_2/close.cur")).toMatchSnapshot();
});
});

View file

@ -1,5 +1,5 @@
import { RIFFFile } from "riff-file";
import { unpackArray } from "byte-data";
import { unpackArray, unpackString } from "byte-data";
type Chunk = {
format: string;
@ -24,11 +24,15 @@ export type AniMetadata = {
bfAttributes: number; // ANI attribute bit flags
};
type AniInfo = { title: string | null; artist: string | null };
export 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 };
@ -47,24 +51,32 @@ export function parseAni(arr: Uint8Array): ParsedAni {
let rate: number[] | null = null;
let seq: number[] | null = null;
let images: Uint8Array[] | null = null;
let info: AniInfo = { artist: null, title: null };
signature.subChunks.forEach(({ chunkId, chunkData, subChunks }) => {
// TODO: Why do we need to trim here?
switch (chunkId.trim()) {
case "anih": // TODO: assert(i === 0)
signature.subChunks.forEach(({ chunkId, chunkData, subChunks, format }) => {
switch (trimNullTerminated(chunkId)) {
case "anih":
metadata = parseMetadata(arr, chunkData.start, chunkData.end);
break;
case "rate": // TODO: assert(i === 1)
case "rate":
rate = unpackArray(arr, DWORD, chunkData.start, chunkData.end);
break;
case "seq": // TODO: assert(i === 2) (or 1??)
case "seq":
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)
);
switch (format) {
case "INFO":
info = parseInfo(arr, subChunks);
break;
case "fram":
images = subChunks.map((c) => {
if (c.chunkId !== "icon") {
throw new Error(`Unexpected chunk type in fram: ${chunkId}`);
}
return arr.slice(c.chunkData.start, c.chunkData.end);
});
}
break;
default:
// TODO: We could assert that this never happens
@ -79,7 +91,28 @@ export function parseAni(arr: Uint8Array): ParsedAni {
throw new Error("Did not find LIST");
}
return { images, rate, seq, metadata };
return { ...info, images, rate, seq, metadata };
}
function parseInfo(arr: Uint8Array, chunks: Chunk[]): AniInfo {
const info: AniInfo = { title: null, artist: null };
chunks.forEach((chunk) => {
switch (chunk.chunkId) {
case "INAM":
info.title = trimNullTerminated(
unpackString(arr, chunk.chunkData.start, chunk.chunkData.end)
);
break;
case "IART":
info.artist = trimNullTerminated(
unpackString(arr, chunk.chunkData.start, chunk.chunkData.end)
);
break;
default:
// Unexpected subchunk
}
});
return info;
}
function parseMetadata(
@ -101,3 +134,9 @@ function parseMetadata(
bfAttributes: words[8],
};
}
// I suspect that RIFF points to byte ranges, but that includes the byte(s?)
// used for null termination.
function trimNullTerminated(str: string): string {
return str.trim();
}

View file

@ -0,0 +1,75 @@
import { parseAni } from "./aniParser";
import { AniCursorImage } from "./types";
import * as Utils from "./utils";
import * as FileUtils from "./fileUtils";
const JIFFIES_PER_MS = 1000 / 60;
export function readAni(contents: Uint8Array): AniCursorImage {
const ani = parseAni(contents);
const rate = ani.rate ?? ani.images.map(() => ani.metadata.iDispRate);
const duration = Utils.sum(rate);
const frames = ani.images.map((image) => ({
url: FileUtils.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 };
}
// 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.
//
// **Note:** This does not seem to work on Safari. I've filed an issue here:
// https://bugs.webkit.org/show_bug.cgi?id=219564
export function aniCss(selector: string, ani: AniCursorImage): string {
const animationName = `webamp-ani-cursor-${Utils.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";
return `
@keyframes ${animationName} {
${keyframes.join("\n")}
}
${selector}${pseudoSelector} {
animation: ${animationName} ${
ani.duration
}ms ${timingFunction} infinite;
}
`;
}

View file

@ -3,14 +3,13 @@ import { LETTERS } from "../constants";
import { imageSelectors, cursorSelectors } from "../skinSelectors";
import { useTypedSelector } from "../hooks";
import * as Selectors from "../selectors";
import { AniFrame, SkinImages } from "../types";
import { SkinImages } from "../types";
import { createSelector } from "reselect";
import * as Utils from "../utils";
import Css from "./Css";
import ClipPaths from "./ClipPaths";
import { aniCss } from "../aniUtils";
const CSS_PREFIX = "#webamp";
const JIFFIES_PER_MS = 1000 / 60;
const mapRegionNamesToIds: { [key: string]: string } = {
normal: "mainWindowClipPath",
@ -34,37 +33,6 @@ 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 * JIFFIES_PER_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 `${
@ -124,7 +92,7 @@ const getCssRules = createSelector(
case "cur":
return `${selector} {cursor: url(${cursor.url}), auto}`;
case "ani": {
return aniCss(selector, cursor.frames);
return aniCss(selector, cursor.ani);
}
}
});

View file

@ -1,6 +1,7 @@
import invariant from "invariant";
import { IMusicMetadataBrowserApi } from "./types";
import { IAudioMetadata } from "music-metadata-browser"; // Import music-metadata type definitions
import * as Utils from "./utils";
type MediaDataType = string | ArrayBuffer | Blob;
@ -123,6 +124,11 @@ function urlIsBlobUrl(url: string): boolean {
return /^blob:/.test(url);
}
export function curUrlFromByteArray(arr: Uint8Array) {
const base64 = Utils.base64FromDataArray(arr);
return `data:image/x-win-bitmap;base64,${base64}`;
}
// This is not perfect, but... meh: https://stackoverflow.com/a/36756650/1263117
export function filenameFromUrl(url: string): string | null {
if (urlIsBlobUrl(url)) {

View file

@ -3,7 +3,8 @@ import { PlaylistStyle, SkinGenExColors, CursorImage } from "./types";
import SKIN_SPRITES, { Sprite } from "./skinSprites";
import { DEFAULT_SKIN } from "./constants";
import * as Utils from "./utils";
import { parseAni, ParsedAni } from "./aniParser";
import * as FileUtils from "./fileUtils";
import * as AniUtils from "./aniUtils";
export const getFileExtension = (fileName: string): string | null => {
const matches = /\.([a-z]{3,4})$/i.exec(fileName);
@ -131,20 +132,6 @@ 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
@ -155,11 +142,15 @@ export async function getCursorFromFilename(
}
const contents = file.contents as Uint8Array;
if (arrayStartsWith(contents, RIFF_MAGIC)) {
const ani = parseAni(contents);
return { type: "ani", frames: framesFromAni(ani) };
try {
return { type: "ani", ani: AniUtils.readAni(contents) };
} catch (e) {
console.error(e);
return null;
}
}
return { type: "cur", url: curUrlFromByteArray(contents) };
return { type: "cur", url: FileUtils.curUrlFromByteArray(contents) };
}
export async function getPlaylistStyle(zip: JSZip): Promise<PlaylistStyle> {

View file

@ -75,6 +75,14 @@ export type Band =
export type Slider = Band | "preamp";
export type AniCursorImage = {
frames: {
url: string;
percents: number[];
}[];
duration: number;
};
export type CursorImage =
| {
type: "cur";
@ -82,21 +90,13 @@ export type CursorImage =
}
| {
type: "ani";
frames: {
url: string;
rate: number;
}[];
ani: AniCursorImage;
};
// 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 {