quad-image 0.2.0

An image host
import { JSX } from 'preact';
import LoadingIcon from 'mdi-preact/LoadingIcon';

import { Printer, Upload } from './components/upload';
import { useEffect, useState } from 'preact/hooks';
import { serializeError } from 'serialize-error';
import { encodeWebP, readMagic } from './locket/resize';
import * as dssim from './locket/dssim';

interface Picked {
  blob: Blob;
  alt: string[];
  ctx: string;

  magic?: string;
  dims?: [number, number];
  images: Record<
    string,
    {
      enc?: Blob;
      comp?: number;
      elapsed?: number;
    }
  >;
}

async function loadImage(
  file: Blob,
  d: dssim.Dssim,
  cleanup: (f: () => void) => void,
) {
  const image = await createImageBitmap(file);
  cleanup(() => image.close());
  const cvs = new OffscreenCanvas(image.width, image.height);
  const ct = cvs.getContext('2d');
  ct!.drawImage(image, 0, 0);
  const arr = ct!.getImageData(0, 0, image.width, image.height);
  cvs.width = 0;
  cvs.height = 0;
  let di: dssim.DssimImage | undefined = undefined;
  try {
    di = dssim.createImageRgba(d, arr.data, image.width, image.height);
    cleanup(() => dssim.freeImage(di!));
  } catch (err) {
    console.error('loading original into dssim', err);
  }
  return { image, di };
}

export const ImageDebug = () => {
  const [picked, setPicked] = useState<Picked | undefined>(undefined);
  const [messages, setMessages] = useState<JSX.Element[]>([]);

  const addMessage = (msg: JSX.Element) =>
    setMessages((messages) => [...messages, msg]);
  const printer: Printer = {
    error: (err) =>
      addMessage(<>crash: {JSON.stringify(serializeError(err))}</>),
    warn: (msg) => addMessage(<>warn: {msg}</>),
  };

  useEffect(() => {
    (async () => {
      const cleanup: (() => void)[] = [];

      try {
        if (!picked) return;
        const file = picked.blob;

        try {
          const magic = await readMagic(file);
          setPicked((picked) => picked && { ...picked, magic });
        } catch (err) {
          console.error('reading magic', err);
        }

        const toProduce = [0.9, 0.8, 0.7, 0.5, 0.3, 0.1].map(
          (v) => [v, 'browser webp at ' + (v ?? 'default')] as const,
        );

        setPicked(
          (picked) =>
            picked && {
              ...picked,
              images: Object.fromEntries(
                toProduce.map(([, msg]) => [msg, {}] as const),
              ),
            },
        );

        const d = dssim.create();
        cleanup.push(() => dssim.free(d));

        const { image: iOriginal, di: dOriginal } = await loadImage(
          file,
          d,
          (c) => cleanup.push(c),
        );

        setPicked(
          (picked) =>
            picked && { ...picked, dims: [iOriginal.width, iOriginal.height] },
        );
        for (const [quality, msg] of toProduce) {
          const start = performance.now();
          const enc = await encodeWebP(iOriginal, quality);
          const elapsed = performance.now() - start;
          await sleep(15);

          let comp: number | undefined;
          try {
            const cleanupsHere: (() => void)[] = [];
            const { image: iThis, di: dThis } = await loadImage(enc, d, (c) =>
              cleanupsHere.push(c),
            );
            iThis.close();

            try {
              comp = dOriginal && dThis && dssim.compare(d, dOriginal, dThis);
            } finally {
              for (const c of cleanupsHere) {
                c();
              }
            }
          } catch (err) {
            console.error(err);
            // TODO: printer
          }

          setPicked(
            (picked) =>
              picked && {
                ...picked,
                images: {
                  ...picked.images,
                  [msg]: {
                    enc,
                    comp,
                    elapsed,
                  },
                },
              },
          );
        }
      } catch (err) {
        console.error(err);
        // TODO: printer
      } finally {
        for (const c of cleanup) {
          try {
            c();
          } catch (err) {
            console.error('cleanup', err);
            // TODO: printer
          }
        }
      }
    })();
  }, [picked?.blob, picked?.ctx, picked?.alt]);

  const rows = [
    <div class={'row'}>
      <div className={'col'}>
        <Upload
          printer={printer}
          triggerUploads={(files: Blob[], ctx: string) => {
            addMessage(
              <>
                {files.length} items received from {ctx}, using first
              </>,
            );
            setPicked({
              blob: files[0],
              alt: [],
              ctx,
              images: {},
            });
          }}
        />
      </div>
      <div className={'col'}>
        <p>
          <button
            class={'btn btn-danger'}
            onClick={() => {
              setPicked(undefined);
              setMessages([]);
            }}
          >
            Clear
          </button>
        </p>
      </div>
    </div>,
    <div class={'row'}>
      <div className={'col'}>
        {messages.map((msg) => (
          <p>{msg}</p>
        ))}
      </div>
    </div>,
  ];

  if (picked) {
    rows.push(
      <div class={'row'}>
        <div class={'col'}>
          <Stretchy image={{ enc: picked.blob }} alt={'original'} />
        </div>
        <div class={'col'}>
          <p>Declared: {picked.blob.type}</p>
          <p>Magic: {picked.magic}</p>
          <p>
            Dims:{' '}
            {picked.dims ? (
              <>
                {picked.dims?.[0]}x{picked.dims?.[1]}
              </>
            ) : (
              '???'
            )}
          </p>
        </div>
      </div>,
    );

    rows.push(
      <div class={'row'}>
        <div class={'col'}>
          <h2>WebP</h2>
          {Object.entries(picked.images ?? {}).map(([msg, result]) =>
            result.enc ? (
              <>
                <Stretchy image={result} alt={msg} />
              </>
            ) : (
              <div style={{ float: 'left' }}>
                <LoadingIcon /> {msg}
              </div>
            ),
          )}
        </div>
      </div>,
    );
  }

  return <div class={'container-fluid'}>{rows}</div>;
};

const Size = (props: { size: number }) => {
  return (
    <abbr title={props.size.toLocaleString() + ' bytes'}>
      {(props.size / 1024 / 1024).toFixed(2)} MiB
    </abbr>
  );
};

const Stretchy = (props: { image: Picked['images'][string]; alt: string }) => {
  const res = props.image;
  const [big, setBig] = useState(false);
  return (
    <div>
      <img
        src={URL.createObjectURL(res.enc!)}
        style={{ maxWidth: big ? undefined : '500px', cursor: 'zoom-in' }}
        onClick={() => setBig((big) => !big)}
      />
      <p>
        {props.alt}: <Size size={res.enc!.size} />{' '}
        {res.comp && (
          <>
            <a href={'https://github.com/kornelski/dssim'} target={'_blank'}>
              dssim
            </a>{' '}
            error: {(100 * res.comp).toFixed(6)}%
          </>
        )}
        {res.elapsed && <>, encoded in {res.elapsed.toFixed(0)}ms</>}
      </p>
    </div>
  );
};

const sleep = async (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));