core/renderers/image.renderer.ts

core/renderers/image.renderer.ts is a file in Bane's Lab Social Share. 87 lines of code and 26 definitions.

import { ANIMATION_FAILED, SUBJECT_SEPARATOR } from "#configuration/strings/card.strings";
import type { AnimationPass } from "#types/stage.types";
import { MILLISECONDS } from "#configuration/constants/card.constants";
import { sequenceIndexAt } from "#core/timers/stage.timer";

interface DecodedFrame {
    readonly bitmap: ImageBitmap;
    readonly delay: number;
}

const CONTEXT_KIND = "2d";
const TYPE_HEADER = "content-type";
const MICROSECONDS_PER_MILLISECOND = 1000;

const failure = function failure(source: string, reason: string): Error {
    return new Error(ANIMATION_FAILED + SUBJECT_SEPARATOR + source + SUBJECT_SEPARATOR + reason);
};

const decoderOf = async function decoderOf(source: string): Promise<ImageDecoder> {
    const response = await fetch(source);
    const type = response.headers.get(TYPE_HEADER);
    if (!response.ok || response.body === null || type === null) {
        throw failure(source, String(response.status));
    }
    const decoder = new ImageDecoder({ data: response.body, type });
    await decoder.completed;
    return decoder;
};

const frameAt = async function frameAt(decoder: ImageDecoder, source: string, index: number): Promise<DecodedFrame> {
    const { image } = await decoder.decode({ frameIndex: index });
    const { duration } = image;
    if (duration === null) {
        image.close();
        throw failure(source, String(index));
    }
    const bitmap = await createImageBitmap(image);
    image.close();
    return { bitmap, delay: duration / MICROSECONDS_PER_MILLISECOND };
};

const framesOf = async function framesOf(decoder: ImageDecoder, source: string): Promise<readonly DecodedFrame[]> {
    const track = decoder.tracks.selectedTrack;
    if (track === null) {
        throw failure(source, String(decoder.tracks.length));
    }
    const indices = Array.from({ length: track.frameCount }, (_, index) => index);
    return indices.reduce<Promise<readonly DecodedFrame[]>>(
        async (previous, index) => [...(await previous), await frameAt(decoder, source, index)],
        Promise.resolve([]),
    );
};

const decodedFrames = new Map<string, Promise<readonly DecodedFrame[]>>();

const decodeOnce = async function decodeOnce(source: string): Promise<readonly DecodedFrame[]> {
    const decoder = await decoderOf(source);
    try {
        return await framesOf(decoder, source);
    } finally {
        decoder.close();
    }
};

const framesFor = async function framesFor(source: string): Promise<readonly DecodedFrame[]> {
    const held = decodedFrames.get(source);
    if (held !== undefined) {
        return held;
    }
    const decoding = decodeOnce(source);
    decodedFrames.set(source, decoding);
    return decoding;
};

export const createAnimationPass = async function createAnimationPass(
    canvas: HTMLCanvasElement,
    source: string,
): Promise<AnimationPass> {
    const context = canvas.getContext(CONTEXT_KIND);
    if (context === null) {
        throw failure(source, CONTEXT_KIND);
    }
    context.imageSmoothingQuality = "high";
    const frames = await framesFor(source);
    const delays = frames.map((frame) => frame.delay);
    return {
        delays,
        draw: (time: number): void => {
            const frame = frames[sequenceIndexAt(delays, time * MILLISECONDS)];
            if (frame === undefined) {
                throw failure(source, String(time));
            }
            context.clearRect(0, 0, canvas.width, canvas.height);
            context.drawImage(frame.bitmap, 0, 0, canvas.width, canvas.height);
        },
    };
};