forked from external-repos/squoosh
wip
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ build
|
|||||||
# Auto-generated by lib/image-worker-plugin.js
|
# Auto-generated by lib/image-worker-plugin.js
|
||||||
src/features/worker/index.ts
|
src/features/worker/index.ts
|
||||||
src/features/worker/tsconfig.json
|
src/features/worker/tsconfig.json
|
||||||
|
src/features/worker/bridge/meta.ts
|
||||||
|
src/features/worker/bridge/tsconfig.json
|
||||||
|
|||||||
@@ -62,22 +62,61 @@ export default function () {
|
|||||||
if (previousWorkerContent === workerFile) return;
|
if (previousWorkerContent === workerFile) return;
|
||||||
previousWorkerContent = workerFile;
|
previousWorkerContent = workerFile;
|
||||||
|
|
||||||
const tsConfig = {
|
const tsConfigReferences = tsImports.map((tsImport) => ({
|
||||||
|
path: path.dirname(tsImport),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const workerTsConfig = {
|
||||||
extends: '../../../generic-tsconfig.json',
|
extends: '../../../generic-tsconfig.json',
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
lib: ['webworker', 'esnext'],
|
lib: ['webworker', 'esnext'],
|
||||||
},
|
},
|
||||||
references: tsImports.map((tsImport) => ({
|
references: tsConfigReferences,
|
||||||
path: path.dirname(tsImport),
|
};
|
||||||
|
|
||||||
|
const bridgeTsConfig = {
|
||||||
|
extends: '../../../../generic-tsconfig.json',
|
||||||
|
compilerOptions: {
|
||||||
|
lib: ['esnext', 'dom', 'dom.iterable'],
|
||||||
|
types: [],
|
||||||
|
},
|
||||||
|
include: ['../../../client/lazy-app/util.ts', '**/*.ts'],
|
||||||
|
references: tsConfigReferences.map((ref) => ({
|
||||||
|
path: path.join('..', ref.path),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bridgeMeta = [
|
||||||
|
`// This file is autogenerated by lib/image-worker-plugin.js`,
|
||||||
|
tsNames.map(([path, name]) => `import type ${name} from '../${path}';`),
|
||||||
|
`export const methodNames = ${JSON.stringify(
|
||||||
|
tsNames.map(([_, name]) => name),
|
||||||
|
null,
|
||||||
|
' ',
|
||||||
|
)} as const;`,
|
||||||
|
`export interface BridgeMethods {`,
|
||||||
|
tsNames.map(([_, name]) => [
|
||||||
|
` ${name}(`,
|
||||||
|
` signal: AbortSignal,`,
|
||||||
|
` ...args: Parameters<typeof ${name}>`,
|
||||||
|
` ): Promise<ReturnType<typeof ${name}>>;`,
|
||||||
|
]),
|
||||||
|
`}`,
|
||||||
|
]
|
||||||
|
.flat(Infinity)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fsp.writeFile(
|
fsp.writeFile(
|
||||||
path.join(base, 'tsconfig.json'),
|
path.join(base, 'tsconfig.json'),
|
||||||
JSON.stringify(tsConfig, null, ' '),
|
JSON.stringify(workerTsConfig, null, ' '),
|
||||||
|
),
|
||||||
|
fsp.writeFile(
|
||||||
|
path.join(base, 'bridge', 'tsconfig.json'),
|
||||||
|
JSON.stringify(bridgeTsConfig, null, ' '),
|
||||||
),
|
),
|
||||||
fsp.writeFile(path.join(base, 'index.ts'), workerFile),
|
fsp.writeFile(path.join(base, 'index.ts'), workerFile),
|
||||||
|
fsp.writeFile(path.join(base, 'bridge', 'meta.ts'), bridgeMeta),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import 'shared/initial-app/custom-els/loading-spinner';
|
|||||||
|
|
||||||
const ROUTE_EDITOR = '/editor';
|
const ROUTE_EDITOR = '/editor';
|
||||||
|
|
||||||
//const compressPromise = import('../compress');
|
const compressPromise = import('client/lazy-app/Compress');
|
||||||
const swBridgePromise = import('client/lazy-app/sw-bridge');
|
const swBridgePromise = import('client/lazy-app/sw-bridge');
|
||||||
|
|
||||||
console.log(swBridgePromise);
|
console.log(compressPromise);
|
||||||
|
|
||||||
function back() {
|
function back() {
|
||||||
window.history.back();
|
window.history.back();
|
||||||
@@ -53,7 +53,7 @@ export default class App extends Component<Props, State> {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
this.showSnack('Failed to load app');
|
this.showSnack('Failed to load app');
|
||||||
});
|
});*/
|
||||||
|
|
||||||
swBridgePromise.then(async ({ offliner, getSharedImage }) => {
|
swBridgePromise.then(async ({ offliner, getSharedImage }) => {
|
||||||
offliner(this.showSnack);
|
offliner(this.showSnack);
|
||||||
@@ -63,7 +63,7 @@ export default class App extends Component<Props, State> {
|
|||||||
history.replaceState('', '', '/');
|
history.replaceState('', '', '/');
|
||||||
this.openEditor();
|
this.openEditor();
|
||||||
this.setState({ file, awaitingShareTarget: false });
|
this.setState({ file, awaitingShareTarget: false });
|
||||||
});*/
|
});
|
||||||
|
|
||||||
// Since iOS 10, Apple tries to prevent disabling pinch-zoom. This is great in theory, but
|
// Since iOS 10, Apple tries to prevent disabling pinch-zoom. This is great in theory, but
|
||||||
// really breaks things on Squoosh, as you can easily end up zooming the UI when you mean to
|
// really breaks things on Squoosh, as you can easily end up zooming the UI when you mean to
|
||||||
|
|||||||
321
src/client/lazy-app/Compress/custom-els/MultiPanel/index.ts
Normal file
321
src/client/lazy-app/Compress/custom-els/MultiPanel/index.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import * as style from './styles.css';
|
||||||
|
import 'add-css:./styles.css';
|
||||||
|
import { transitionHeight } from 'client/lazy-app/util';
|
||||||
|
|
||||||
|
interface CloseAllOptions {
|
||||||
|
exceptFirst?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openOneOnlyAttr = 'open-one-only';
|
||||||
|
|
||||||
|
function getClosestHeading(el: Element): HTMLElement | undefined {
|
||||||
|
// Look for the child of multi-panel, but stop at interactive elements like links & buttons
|
||||||
|
const closestEl = el.closest('multi-panel > *, a, button');
|
||||||
|
if (closestEl && closestEl.classList.contains(style.panelHeading)) {
|
||||||
|
return closestEl as HTMLElement;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close(heading: HTMLElement) {
|
||||||
|
const content = heading.nextElementSibling as HTMLElement;
|
||||||
|
|
||||||
|
// if there is no content, nothing to expand
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const from = content.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
heading.removeAttribute('content-expanded');
|
||||||
|
content.setAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
|
// Wait a microtask so other calls to open/close can get the final sizes.
|
||||||
|
await null;
|
||||||
|
|
||||||
|
await transitionHeight(content, {
|
||||||
|
from,
|
||||||
|
to: 0,
|
||||||
|
duration: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
content.style.height = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(heading: HTMLElement) {
|
||||||
|
const content = heading.nextElementSibling as HTMLElement;
|
||||||
|
|
||||||
|
// if there is no content, nothing to expand
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const from = content.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
heading.setAttribute('content-expanded', '');
|
||||||
|
content.setAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
const to = content.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
// Wait a microtask so other calls to open/close can get the final sizes.
|
||||||
|
await null;
|
||||||
|
|
||||||
|
await transitionHeight(content, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
duration: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
content.style.height = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A multi-panel view that the user can add any number of 'panels'.
|
||||||
|
* 'a panel' consists of two elements. Even index element becomes heading,
|
||||||
|
* and odd index element becomes the expandable content.
|
||||||
|
*/
|
||||||
|
export default class MultiPanel extends HTMLElement {
|
||||||
|
static get observedAttributes() {
|
||||||
|
return [openOneOnlyAttr];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// add EventListeners
|
||||||
|
this.addEventListener('click', this._onClick);
|
||||||
|
this.addEventListener('keydown', this._onKeyDown);
|
||||||
|
|
||||||
|
// Watch for children changes.
|
||||||
|
new MutationObserver(() => this._childrenChange()).observe(this, {
|
||||||
|
childList: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._childrenChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(
|
||||||
|
name: string,
|
||||||
|
oldValue: string | null,
|
||||||
|
newValue: string | null,
|
||||||
|
) {
|
||||||
|
if (name === openOneOnlyAttr && newValue === null) {
|
||||||
|
this._closeAll({ exceptFirst: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click event handler
|
||||||
|
private _onClick(event: MouseEvent) {
|
||||||
|
const el = event.target as HTMLElement;
|
||||||
|
const heading = getClosestHeading(el);
|
||||||
|
if (!heading) return;
|
||||||
|
this._toggle(heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyDown event handler
|
||||||
|
private _onKeyDown(event: KeyboardEvent) {
|
||||||
|
const selectedEl = document.activeElement!;
|
||||||
|
const heading = getClosestHeading(selectedEl);
|
||||||
|
|
||||||
|
// if keydown event is not on heading element, ignore
|
||||||
|
if (!heading) return;
|
||||||
|
|
||||||
|
// if something inside of heading has focus, ignore
|
||||||
|
if (selectedEl !== heading) return;
|
||||||
|
|
||||||
|
// don’t handle modifier shortcuts used by assistive technology.
|
||||||
|
if (event.altKey) return;
|
||||||
|
|
||||||
|
let newHeading: HTMLElement | undefined;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowUp':
|
||||||
|
newHeading = this._prevHeading();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
newHeading = this._nextHeading();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Home':
|
||||||
|
newHeading = this._firstHeading();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'End':
|
||||||
|
newHeading = this._lastHeading();
|
||||||
|
break;
|
||||||
|
|
||||||
|
// this has 3 cases listed to support IEs and FF before 37
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
case 'Spacebar':
|
||||||
|
this._toggle(heading);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Any other key press is ignored and passed back to the browser.
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
if (newHeading) {
|
||||||
|
selectedEl.setAttribute('tabindex', '-1');
|
||||||
|
newHeading.setAttribute('tabindex', '0');
|
||||||
|
newHeading.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toggle(heading: HTMLElement) {
|
||||||
|
if (!heading) return;
|
||||||
|
|
||||||
|
// toggle expanded and aria-expanded attributes
|
||||||
|
if (heading.hasAttribute('content-expanded')) {
|
||||||
|
close(heading);
|
||||||
|
} else {
|
||||||
|
if (this.openOneOnly) this._closeAll();
|
||||||
|
open(heading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _closeAll(options: CloseAllOptions = {}): void {
|
||||||
|
const { exceptFirst = false } = options;
|
||||||
|
let els = [...this.children].filter((el) =>
|
||||||
|
el.matches('[content-expanded]'),
|
||||||
|
) as HTMLElement[];
|
||||||
|
|
||||||
|
if (exceptFirst) {
|
||||||
|
els = els.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const el of els) close(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// children of multi-panel should always be even number (heading/content pair)
|
||||||
|
private _childrenChange() {
|
||||||
|
let preserveTabIndex = false;
|
||||||
|
let heading = this.firstElementChild;
|
||||||
|
|
||||||
|
while (heading) {
|
||||||
|
const content = heading.nextElementSibling;
|
||||||
|
const randomId = Math.random().toString(36).substr(2, 9);
|
||||||
|
|
||||||
|
// if at the end of this loop, runout of element for content,
|
||||||
|
// it means it has odd number of elements. log error and set heading to end the loop.
|
||||||
|
if (!content) {
|
||||||
|
console.error(
|
||||||
|
'<multi-panel> requires an even number of element children.',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When odd number of elements were inserted in the middle,
|
||||||
|
// what was heading before may become content after the insertion.
|
||||||
|
// Remove classes and attributes to prepare for this change.
|
||||||
|
heading.classList.remove(style.panelContent);
|
||||||
|
content.classList.remove(style.panelHeading);
|
||||||
|
heading.removeAttribute('aria-expanded');
|
||||||
|
heading.removeAttribute('content-expanded');
|
||||||
|
|
||||||
|
// If appreciable, remove tabindex from content which used to be header.
|
||||||
|
content.removeAttribute('tabindex');
|
||||||
|
|
||||||
|
// Assign heading and content classes
|
||||||
|
heading.classList.add(style.panelHeading);
|
||||||
|
content.classList.add(style.panelContent);
|
||||||
|
|
||||||
|
// Assign ids and aria-X for heading/content pair.
|
||||||
|
heading.id = `panel-heading-${randomId}`;
|
||||||
|
heading.setAttribute('aria-controls', `panel-content-${randomId}`);
|
||||||
|
content.id = `panel-content-${randomId}`;
|
||||||
|
content.setAttribute('aria-labelledby', `panel-heading-${randomId}`);
|
||||||
|
|
||||||
|
// If tabindex 0 is assigned to a heading, flag to preserve tab index position.
|
||||||
|
// Otherwise, make sure tabindex -1 is set to heading elements.
|
||||||
|
if (heading.getAttribute('tabindex') === '0') {
|
||||||
|
preserveTabIndex = true;
|
||||||
|
} else {
|
||||||
|
heading.setAttribute('tabindex', '-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's possible that the heading & content expanded attributes are now out of sync. Resync
|
||||||
|
// them using the heading as the source of truth.
|
||||||
|
content.setAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
heading.hasAttribute('content-expanded') ? 'true' : 'false',
|
||||||
|
);
|
||||||
|
|
||||||
|
// next sibling of content = next heading
|
||||||
|
heading = content.nextElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no flag, make 1st heading as tabindex 0 (otherwise keep previous tab index position).
|
||||||
|
if (!preserveTabIndex && this.firstElementChild) {
|
||||||
|
this.firstElementChild.setAttribute('tabindex', '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In case we're openOneOnly, and an additional open item has been added:
|
||||||
|
if (this.openOneOnly) this._closeAll({ exceptFirst: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns heading that is before currently selected one.
|
||||||
|
private _prevHeading() {
|
||||||
|
// activeElement would be the currently selected heading
|
||||||
|
// 2 elements before that would be the previous heading unless it is the first element.
|
||||||
|
if (this.firstElementChild === document.activeElement) {
|
||||||
|
return this.firstElementChild as HTMLElement;
|
||||||
|
}
|
||||||
|
// previous Element of active Element is previous Content,
|
||||||
|
// previous Element of previous Content is previousHeading
|
||||||
|
const previousContent = document.activeElement!.previousElementSibling;
|
||||||
|
if (previousContent) {
|
||||||
|
return previousContent.previousElementSibling as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns heading that is after currently selected one.
|
||||||
|
private _nextHeading() {
|
||||||
|
// activeElement would be the currently selected heading
|
||||||
|
// 2 elemements after that would be the next heading.
|
||||||
|
const nextContent = document.activeElement!.nextElementSibling;
|
||||||
|
if (nextContent) {
|
||||||
|
return nextContent.nextElementSibling as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns first heading in multi-panel.
|
||||||
|
private _firstHeading() {
|
||||||
|
// first element is always first heading
|
||||||
|
return this.firstElementChild as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns last heading in multi-panel.
|
||||||
|
private _lastHeading() {
|
||||||
|
// if the last element is heading, return last element
|
||||||
|
const lastEl = this.lastElementChild as HTMLElement;
|
||||||
|
if (lastEl && lastEl.classList.contains(style.panelHeading)) {
|
||||||
|
return lastEl;
|
||||||
|
}
|
||||||
|
// otherwise return 2nd from the last
|
||||||
|
const lastContent = this.lastElementChild;
|
||||||
|
if (lastContent) {
|
||||||
|
return lastContent.previousElementSibling as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, only one panel can be open at once. When one opens, others close.
|
||||||
|
*/
|
||||||
|
get openOneOnly() {
|
||||||
|
return this.hasAttribute(openOneOnlyAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
set openOneOnly(val: boolean) {
|
||||||
|
if (val) {
|
||||||
|
this.setAttribute(openOneOnlyAttr, '');
|
||||||
|
} else {
|
||||||
|
this.removeAttribute(openOneOnlyAttr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('multi-panel', MultiPanel);
|
||||||
13
src/client/lazy-app/Compress/custom-els/MultiPanel/missing-types.d.ts
vendored
Normal file
13
src/client/lazy-app/Compress/custom-els/MultiPanel/missing-types.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
interface MultiPanelAttributes extends preact.JSX.HTMLAttributes {
|
||||||
|
'open-one-only'?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'preact' {
|
||||||
|
namespace createElement.JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
'multi-panel': MultiPanelAttributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.panel-heading {
|
||||||
|
background: gray;
|
||||||
|
}
|
||||||
|
.panel-content {
|
||||||
|
height: 0px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.panel-content[aria-expanded='true'] {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
774
src/client/lazy-app/Compress/index.tsx
Normal file
774
src/client/lazy-app/Compress/index.tsx
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
import { h, Component } from 'preact';
|
||||||
|
|
||||||
|
import { bind, Fileish } from '../../lib/initial-util';
|
||||||
|
import { blobToImg, drawableToImageData, blobToText } from '../../lib/util';
|
||||||
|
import * as style from './style.scss';
|
||||||
|
import Output from '../Output';
|
||||||
|
import Options from '../Options';
|
||||||
|
import ResultCache from './result-cache';
|
||||||
|
import * as identity from '../../codecs/identity/encoder-meta';
|
||||||
|
import * as oxiPNG from '../../codecs/oxipng/encoder-meta';
|
||||||
|
import * as mozJPEG from '../../codecs/mozjpeg/encoder-meta';
|
||||||
|
import * as webP from '../../codecs/webp/encoder-meta';
|
||||||
|
import * as avif from '../../codecs/avif/encoder-meta';
|
||||||
|
import * as browserPNG from '../../codecs/browser-png/encoder-meta';
|
||||||
|
import * as browserJPEG from '../../codecs/browser-jpeg/encoder-meta';
|
||||||
|
import * as browserWebP from '../../codecs/browser-webp/encoder-meta';
|
||||||
|
import * as browserGIF from '../../codecs/browser-gif/encoder-meta';
|
||||||
|
import * as browserTIFF from '../../codecs/browser-tiff/encoder-meta';
|
||||||
|
import * as browserJP2 from '../../codecs/browser-jp2/encoder-meta';
|
||||||
|
import * as browserBMP from '../../codecs/browser-bmp/encoder-meta';
|
||||||
|
import * as browserPDF from '../../codecs/browser-pdf/encoder-meta';
|
||||||
|
import {
|
||||||
|
EncoderState,
|
||||||
|
EncoderType,
|
||||||
|
EncoderOptions,
|
||||||
|
encoderMap,
|
||||||
|
} from '../../codecs/encoders';
|
||||||
|
import {
|
||||||
|
PreprocessorState,
|
||||||
|
defaultPreprocessorState,
|
||||||
|
} from '../../codecs/preprocessors';
|
||||||
|
import { decodeImage } from '../../codecs/decoders';
|
||||||
|
import { cleanMerge, cleanSet } from '../../lib/clean-modify';
|
||||||
|
import Processor from '../../codecs/processor';
|
||||||
|
import {
|
||||||
|
BrowserResizeOptions,
|
||||||
|
isWorkerOptions as isWorkerResizeOptions,
|
||||||
|
isHqx,
|
||||||
|
WorkerResizeOptions,
|
||||||
|
} from '../../codecs/resize/processor-meta';
|
||||||
|
import './custom-els/MultiPanel';
|
||||||
|
import Results from '../results';
|
||||||
|
import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons';
|
||||||
|
import SnackBarElement from '../../lib/SnackBar';
|
||||||
|
import {
|
||||||
|
InputProcessorState,
|
||||||
|
defaultInputProcessorState,
|
||||||
|
} from '../../codecs/input-processors';
|
||||||
|
|
||||||
|
export interface SourceImage {
|
||||||
|
file: File | Fileish;
|
||||||
|
decoded: ImageData;
|
||||||
|
processed: ImageData;
|
||||||
|
vectorImage?: HTMLImageElement;
|
||||||
|
inputProcessorState: InputProcessorState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideSettings {
|
||||||
|
preprocessorState: PreprocessorState;
|
||||||
|
encoderState: EncoderState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Side {
|
||||||
|
preprocessed?: ImageData;
|
||||||
|
file?: Fileish;
|
||||||
|
downloadUrl?: string;
|
||||||
|
data?: ImageData;
|
||||||
|
latestSettings: SideSettings;
|
||||||
|
encodedSettings?: SideSettings;
|
||||||
|
loading: boolean;
|
||||||
|
/** Counter of the latest bmp currently encoding */
|
||||||
|
loadingCounter: number;
|
||||||
|
/** Counter of the latest bmp encoded */
|
||||||
|
loadedCounter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: File | Fileish;
|
||||||
|
showSnack: SnackBarElement['showSnackbar'];
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
source?: SourceImage;
|
||||||
|
sides: [Side, Side];
|
||||||
|
/** Source image load */
|
||||||
|
loading: boolean;
|
||||||
|
loadingCounter: number;
|
||||||
|
error?: string;
|
||||||
|
mobileView: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateImageOptions {
|
||||||
|
skipPreprocessing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processInput(
|
||||||
|
data: ImageData,
|
||||||
|
inputProcessData: InputProcessorState,
|
||||||
|
processor: Processor,
|
||||||
|
) {
|
||||||
|
let processedData = data;
|
||||||
|
|
||||||
|
if (inputProcessData.rotate.rotate !== 0) {
|
||||||
|
processedData = await processor.rotate(
|
||||||
|
processedData,
|
||||||
|
inputProcessData.rotate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preprocessImage(
|
||||||
|
source: SourceImage,
|
||||||
|
preprocessData: PreprocessorState,
|
||||||
|
processor: Processor,
|
||||||
|
): Promise<ImageData> {
|
||||||
|
let result = source.processed;
|
||||||
|
|
||||||
|
if (preprocessData.resize.enabled) {
|
||||||
|
if (preprocessData.resize.method === 'vector' && source.vectorImage) {
|
||||||
|
result = processor.vectorResize(
|
||||||
|
source.vectorImage,
|
||||||
|
preprocessData.resize,
|
||||||
|
);
|
||||||
|
} else if (isHqx(preprocessData.resize)) {
|
||||||
|
// Hqx can only do x2, x3 or x4.
|
||||||
|
result = await processor.workerResize(result, preprocessData.resize);
|
||||||
|
// If the target size is not a clean x2, x3 or x4, use Catmull-Rom
|
||||||
|
// for the remaining scaling.
|
||||||
|
const pixelOpts = { ...preprocessData.resize, method: 'catrom' };
|
||||||
|
result = await processor.workerResize(
|
||||||
|
result,
|
||||||
|
pixelOpts as WorkerResizeOptions,
|
||||||
|
);
|
||||||
|
} else if (isWorkerResizeOptions(preprocessData.resize)) {
|
||||||
|
result = await processor.workerResize(result, preprocessData.resize);
|
||||||
|
} else {
|
||||||
|
result = processor.resize(
|
||||||
|
result,
|
||||||
|
preprocessData.resize as BrowserResizeOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preprocessData.quantizer.enabled) {
|
||||||
|
result = await processor.imageQuant(result, preprocessData.quantizer);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compressImage(
|
||||||
|
image: ImageData,
|
||||||
|
encodeData: EncoderState,
|
||||||
|
sourceFilename: string,
|
||||||
|
processor: Processor,
|
||||||
|
): Promise<Fileish> {
|
||||||
|
const compressedData = await (() => {
|
||||||
|
switch (encodeData.type) {
|
||||||
|
case oxiPNG.type:
|
||||||
|
return processor.oxiPngEncode(image, encodeData.options);
|
||||||
|
case mozJPEG.type:
|
||||||
|
return processor.mozjpegEncode(image, encodeData.options);
|
||||||
|
case webP.type:
|
||||||
|
return processor.webpEncode(image, encodeData.options);
|
||||||
|
case avif.type:
|
||||||
|
return processor.avifEncode(image, encodeData.options);
|
||||||
|
case browserPNG.type:
|
||||||
|
return processor.browserPngEncode(image);
|
||||||
|
case browserJPEG.type:
|
||||||
|
return processor.browserJpegEncode(image, encodeData.options);
|
||||||
|
case browserWebP.type:
|
||||||
|
return processor.browserWebpEncode(image, encodeData.options);
|
||||||
|
case browserGIF.type:
|
||||||
|
return processor.browserGifEncode(image);
|
||||||
|
case browserTIFF.type:
|
||||||
|
return processor.browserTiffEncode(image);
|
||||||
|
case browserJP2.type:
|
||||||
|
return processor.browserJp2Encode(image);
|
||||||
|
case browserBMP.type:
|
||||||
|
return processor.browserBmpEncode(image);
|
||||||
|
case browserPDF.type:
|
||||||
|
return processor.browserPdfEncode(image);
|
||||||
|
default:
|
||||||
|
throw Error(`Unexpected encoder ${JSON.stringify(encodeData)}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const encoder = encoderMap[encodeData.type];
|
||||||
|
|
||||||
|
return new Fileish(
|
||||||
|
[compressedData],
|
||||||
|
sourceFilename.replace(/.[^.]*$/, `.${encoder.extension}`),
|
||||||
|
{ type: encoder.mimeType },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateForNewSourceData(state: State, newSource: SourceImage): State {
|
||||||
|
let newState = { ...state };
|
||||||
|
|
||||||
|
for (const i of [0, 1]) {
|
||||||
|
// Ditch previous encodings
|
||||||
|
const downloadUrl = state.sides[i].downloadUrl;
|
||||||
|
if (downloadUrl) URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
|
newState = cleanMerge(state, `sides.${i}`, {
|
||||||
|
preprocessed: undefined,
|
||||||
|
file: undefined,
|
||||||
|
downloadUrl: undefined,
|
||||||
|
data: undefined,
|
||||||
|
encodedSettings: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processSvg(blob: Blob): Promise<HTMLImageElement> {
|
||||||
|
// Firefox throws if you try to draw an SVG to canvas that doesn't have width/height.
|
||||||
|
// In Chrome it loads, but drawImage behaves weirdly.
|
||||||
|
// This function sets width/height if it isn't already set.
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const text = await blobToText(blob);
|
||||||
|
const document = parser.parseFromString(text, 'image/svg+xml');
|
||||||
|
const svg = document.documentElement!;
|
||||||
|
|
||||||
|
if (svg.hasAttribute('width') && svg.hasAttribute('height')) {
|
||||||
|
return blobToImg(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewBox = svg.getAttribute('viewBox');
|
||||||
|
if (viewBox === null) throw Error('SVG must have width/height or viewBox');
|
||||||
|
|
||||||
|
const viewboxParts = viewBox.split(/\s+/);
|
||||||
|
svg.setAttribute('width', viewboxParts[2]);
|
||||||
|
svg.setAttribute('height', viewboxParts[3]);
|
||||||
|
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
const newSource = serializer.serializeToString(document);
|
||||||
|
return blobToImg(new Blob([newSource], { type: 'image/svg+xml' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are only used in the mobile view
|
||||||
|
const resultTitles = ['Top', 'Bottom'];
|
||||||
|
// These are only used in the desktop view
|
||||||
|
const buttonPositions = ['download-left', 'download-right'] as (
|
||||||
|
| 'download-left'
|
||||||
|
| 'download-right'
|
||||||
|
)[];
|
||||||
|
|
||||||
|
const originalDocumentTitle = document.title;
|
||||||
|
|
||||||
|
export default class Compress extends Component<Props, State> {
|
||||||
|
widthQuery = window.matchMedia('(max-width: 599px)');
|
||||||
|
|
||||||
|
state: State = {
|
||||||
|
source: undefined,
|
||||||
|
loading: false,
|
||||||
|
loadingCounter: 0,
|
||||||
|
sides: [
|
||||||
|
{
|
||||||
|
latestSettings: {
|
||||||
|
preprocessorState: defaultPreprocessorState,
|
||||||
|
encoderState: {
|
||||||
|
type: identity.type,
|
||||||
|
options: identity.defaultOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loadingCounter: 0,
|
||||||
|
loadedCounter: 0,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
latestSettings: {
|
||||||
|
preprocessorState: defaultPreprocessorState,
|
||||||
|
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
|
||||||
|
},
|
||||||
|
loadingCounter: 0,
|
||||||
|
loadedCounter: 0,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mobileView: this.widthQuery.matches,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly encodeCache = new ResultCache();
|
||||||
|
private readonly leftProcessor = new Processor();
|
||||||
|
private readonly rightProcessor = new Processor();
|
||||||
|
// For debouncing calls to updateImage for each side.
|
||||||
|
private readonly updateImageTimeoutIds: [number?, number?] = [
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.widthQuery.addListener(this.onMobileWidthChange);
|
||||||
|
this.updateFile(props.file);
|
||||||
|
|
||||||
|
import('../../lib/sw-bridge').then(({ mainAppLoaded }) => mainAppLoaded());
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
private onMobileWidthChange() {
|
||||||
|
this.setState({ mobileView: this.widthQuery.matches });
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
|
||||||
|
this.setState({
|
||||||
|
sides: cleanSet(
|
||||||
|
this.state.sides,
|
||||||
|
`${index}.latestSettings.encoderState`,
|
||||||
|
{
|
||||||
|
type: newType,
|
||||||
|
options: encoderMap[newType].defaultOptions,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPreprocessorOptionsChange(
|
||||||
|
index: 0 | 1,
|
||||||
|
options: PreprocessorState,
|
||||||
|
): void {
|
||||||
|
this.setState({
|
||||||
|
sides: cleanSet(
|
||||||
|
this.state.sides,
|
||||||
|
`${index}.latestSettings.preprocessorState`,
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
|
||||||
|
this.setState({
|
||||||
|
sides: cleanSet(
|
||||||
|
this.state.sides,
|
||||||
|
`${index}.latestSettings.encoderState.options`,
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDocumentTitle(filename: string = ''): void {
|
||||||
|
document.title = filename
|
||||||
|
? `${filename} - ${originalDocumentTitle}`
|
||||||
|
: originalDocumentTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps: Props): void {
|
||||||
|
if (nextProps.file !== this.props.file) {
|
||||||
|
this.updateFile(nextProps.file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount(): void {
|
||||||
|
this.updateDocumentTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props, prevState: State): void {
|
||||||
|
const { source, sides } = this.state;
|
||||||
|
|
||||||
|
const sourceDataChanged =
|
||||||
|
// Has the source object become set/unset?
|
||||||
|
!!source !== !!prevState.source ||
|
||||||
|
// Or has the processed data changed?
|
||||||
|
(source &&
|
||||||
|
prevState.source &&
|
||||||
|
source.processed !== prevState.source.processed);
|
||||||
|
|
||||||
|
for (const [i, side] of sides.entries()) {
|
||||||
|
const prevSettings = prevState.sides[i].latestSettings;
|
||||||
|
const encoderChanged =
|
||||||
|
side.latestSettings.encoderState !== prevSettings.encoderState;
|
||||||
|
const preprocessorChanged =
|
||||||
|
side.latestSettings.preprocessorState !==
|
||||||
|
prevSettings.preprocessorState;
|
||||||
|
|
||||||
|
// The image only needs updated if the encoder/preprocessor settings have changed, or the
|
||||||
|
// source has changed.
|
||||||
|
if (sourceDataChanged || encoderChanged || preprocessorChanged) {
|
||||||
|
this.queueUpdateImage(i, {
|
||||||
|
skipPreprocessing: !sourceDataChanged && !preprocessorChanged,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onCopyToOtherClick(index: 0 | 1) {
|
||||||
|
const otherIndex = (index + 1) % 2;
|
||||||
|
const oldSettings = this.state.sides[otherIndex];
|
||||||
|
const newSettings = { ...this.state.sides[index] };
|
||||||
|
|
||||||
|
// Create a new object URL for the new settings. This avoids both sides sharing a URL, which
|
||||||
|
// means it can be safely revoked without impacting the other side.
|
||||||
|
if (newSettings.file)
|
||||||
|
newSettings.downloadUrl = URL.createObjectURL(newSettings.file);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
sides: cleanSet(this.state.sides, otherIndex, newSettings),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.props.showSnack('Settings copied across', {
|
||||||
|
timeout: 5000,
|
||||||
|
actions: ['undo', 'dismiss'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result !== 'undo') return;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
sides: cleanSet(this.state.sides, otherIndex, oldSettings),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
private async onInputProcessorChange(
|
||||||
|
options: InputProcessorState,
|
||||||
|
): Promise<void> {
|
||||||
|
const source = this.state.source;
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
const oldRotate = source.inputProcessorState.rotate.rotate;
|
||||||
|
const newRotate = options.rotate.rotate;
|
||||||
|
const orientationChanged = oldRotate % 180 !== newRotate % 180;
|
||||||
|
const loadingCounter = this.state.loadingCounter + 1;
|
||||||
|
// Either processor is good enough here.
|
||||||
|
const processor = this.leftProcessor;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
loadingCounter,
|
||||||
|
loading: true,
|
||||||
|
source: cleanSet(source, 'inputProcessorState', options),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abort any current encode jobs, as they're redundant now.
|
||||||
|
this.leftProcessor.abortCurrent();
|
||||||
|
this.rightProcessor.abortCurrent();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const processed = await processInput(source.decoded, options, processor);
|
||||||
|
|
||||||
|
// Another file has been opened/processed before this one processed.
|
||||||
|
if (this.state.loadingCounter !== loadingCounter) return;
|
||||||
|
|
||||||
|
let newState = { ...this.state, loading: false };
|
||||||
|
newState = cleanSet(newState, 'source.processed', processed);
|
||||||
|
newState = stateForNewSourceData(newState, newState.source!);
|
||||||
|
|
||||||
|
if (orientationChanged) {
|
||||||
|
// If orientation has changed, we should flip the resize values.
|
||||||
|
for (const i of [0, 1]) {
|
||||||
|
const resizeSettings =
|
||||||
|
newState.sides[i].latestSettings.preprocessorState.resize;
|
||||||
|
newState = cleanMerge(
|
||||||
|
newState,
|
||||||
|
`sides.${i}.latestSettings.preprocessorState.resize`,
|
||||||
|
{
|
||||||
|
width: resizeSettings.height,
|
||||||
|
height: resizeSettings.width,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setState(newState);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
console.error(err);
|
||||||
|
// Another file has been opened/processed before this one processed.
|
||||||
|
if (this.state.loadingCounter !== loadingCounter) return;
|
||||||
|
this.props.showSnack('Processing error');
|
||||||
|
this.setState({ loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
private async updateFile(file: File | Fileish) {
|
||||||
|
const loadingCounter = this.state.loadingCounter + 1;
|
||||||
|
// Either processor is good enough here.
|
||||||
|
const processor = this.leftProcessor;
|
||||||
|
|
||||||
|
this.setState({ loadingCounter, loading: true });
|
||||||
|
|
||||||
|
// Abort any current encode jobs, as they're redundant now.
|
||||||
|
this.leftProcessor.abortCurrent();
|
||||||
|
this.rightProcessor.abortCurrent();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let decoded: ImageData;
|
||||||
|
let vectorImage: HTMLImageElement | undefined;
|
||||||
|
|
||||||
|
// Special-case SVG. We need to avoid createImageBitmap because of
|
||||||
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=606319.
|
||||||
|
// Also, we cache the HTMLImageElement so we can perform vector resizing later.
|
||||||
|
if (file.type.startsWith('image/svg+xml')) {
|
||||||
|
vectorImage = await processSvg(file);
|
||||||
|
decoded = drawableToImageData(vectorImage);
|
||||||
|
} else {
|
||||||
|
// Either processor is good enough here.
|
||||||
|
decoded = await decodeImage(file, processor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const processed = await processInput(
|
||||||
|
decoded,
|
||||||
|
defaultInputProcessorState,
|
||||||
|
processor,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Another file has been opened/processed before this one processed.
|
||||||
|
if (this.state.loadingCounter !== loadingCounter) return;
|
||||||
|
|
||||||
|
let newState: State = {
|
||||||
|
...this.state,
|
||||||
|
source: {
|
||||||
|
decoded,
|
||||||
|
file,
|
||||||
|
vectorImage,
|
||||||
|
processed,
|
||||||
|
inputProcessorState: defaultInputProcessorState,
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
newState = stateForNewSourceData(newState, newState.source!);
|
||||||
|
|
||||||
|
for (const i of [0, 1]) {
|
||||||
|
// Default resize values come from the image:
|
||||||
|
newState = cleanMerge(
|
||||||
|
newState,
|
||||||
|
`sides.${i}.latestSettings.preprocessorState.resize`,
|
||||||
|
{
|
||||||
|
width: processed.width,
|
||||||
|
height: processed.height,
|
||||||
|
method: vectorImage ? 'vector' : 'lanczos3',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateDocumentTitle(file.name);
|
||||||
|
this.setState(newState);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
console.error(err);
|
||||||
|
// Another file has been opened/processed before this one processed.
|
||||||
|
if (this.state.loadingCounter !== loadingCounter) return;
|
||||||
|
this.props.showSnack('Invalid image');
|
||||||
|
this.setState({ loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce the heavy lifting of updateImage.
|
||||||
|
* Otherwise, the thrashing causes jank, and sometimes crashes iOS Safari.
|
||||||
|
*/
|
||||||
|
private queueUpdateImage(
|
||||||
|
index: number,
|
||||||
|
options: UpdateImageOptions = {},
|
||||||
|
): void {
|
||||||
|
// Call updateImage after this delay, unless queueUpdateImage is called again, in which case the
|
||||||
|
// timeout is reset.
|
||||||
|
const delay = 100;
|
||||||
|
|
||||||
|
clearTimeout(this.updateImageTimeoutIds[index]);
|
||||||
|
|
||||||
|
this.updateImageTimeoutIds[index] = self.setTimeout(() => {
|
||||||
|
this.updateImage(index, options).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateImage(
|
||||||
|
index: number,
|
||||||
|
options: UpdateImageOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const { skipPreprocessing = false } = options;
|
||||||
|
const { source } = this.state;
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
// Each time we trigger an async encode, the counter changes.
|
||||||
|
const loadingCounter = this.state.sides[index].loadingCounter + 1;
|
||||||
|
|
||||||
|
let sides = cleanMerge(this.state.sides, index, {
|
||||||
|
loadingCounter,
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({ sides });
|
||||||
|
|
||||||
|
const side = sides[index];
|
||||||
|
const settings = side.latestSettings;
|
||||||
|
|
||||||
|
let file: File | Fileish | undefined;
|
||||||
|
let preprocessed: ImageData | undefined;
|
||||||
|
let data: ImageData | undefined;
|
||||||
|
const cacheResult = this.encodeCache.match(
|
||||||
|
source.processed,
|
||||||
|
settings.preprocessorState,
|
||||||
|
settings.encoderState,
|
||||||
|
);
|
||||||
|
const processor = index === 0 ? this.leftProcessor : this.rightProcessor;
|
||||||
|
|
||||||
|
// Abort anything the processor is currently doing.
|
||||||
|
// Although the processor will abandon current tasks when a new one is called,
|
||||||
|
// we might not call another task here. Eg, we might get the result from the cache.
|
||||||
|
processor.abortCurrent();
|
||||||
|
|
||||||
|
if (cacheResult) {
|
||||||
|
({ file, preprocessed, data } = cacheResult);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Special case for identity
|
||||||
|
if (settings.encoderState.type === identity.type) {
|
||||||
|
file = source.file;
|
||||||
|
data = source.processed;
|
||||||
|
} else {
|
||||||
|
preprocessed =
|
||||||
|
skipPreprocessing && side.preprocessed
|
||||||
|
? side.preprocessed
|
||||||
|
: await preprocessImage(
|
||||||
|
source,
|
||||||
|
settings.preprocessorState,
|
||||||
|
processor,
|
||||||
|
);
|
||||||
|
|
||||||
|
file = await compressImage(
|
||||||
|
preprocessed,
|
||||||
|
settings.encoderState,
|
||||||
|
source.file.name,
|
||||||
|
processor,
|
||||||
|
);
|
||||||
|
data = await decodeImage(file, processor);
|
||||||
|
|
||||||
|
this.encodeCache.add({
|
||||||
|
data,
|
||||||
|
preprocessed,
|
||||||
|
file,
|
||||||
|
sourceData: source.processed,
|
||||||
|
encoderState: settings.encoderState,
|
||||||
|
preprocessorState: settings.preprocessorState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
this.props.showSnack(
|
||||||
|
`Processing error (type=${settings.encoderState.type}): ${err}`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestData = this.state.sides[index];
|
||||||
|
// If a later encode has landed before this one, return.
|
||||||
|
if (loadingCounter < latestData.loadedCounter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestData.downloadUrl) URL.revokeObjectURL(latestData.downloadUrl);
|
||||||
|
|
||||||
|
sides = cleanMerge(this.state.sides, index, {
|
||||||
|
file,
|
||||||
|
data,
|
||||||
|
preprocessed,
|
||||||
|
downloadUrl: URL.createObjectURL(file),
|
||||||
|
loading: sides[index].loadingCounter !== loadingCounter,
|
||||||
|
loadedCounter: loadingCounter,
|
||||||
|
encodedSettings: settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState({ sides });
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ onBack }: Props, { loading, sides, source, mobileView }: State) {
|
||||||
|
const [leftSide, rightSide] = sides;
|
||||||
|
const [leftImageData, rightImageData] = sides.map((i) => i.data);
|
||||||
|
|
||||||
|
const options = sides.map((side, index) => (
|
||||||
|
// tslint:disable-next-line:jsx-key
|
||||||
|
<Options
|
||||||
|
source={source}
|
||||||
|
mobileView={mobileView}
|
||||||
|
preprocessorState={side.latestSettings.preprocessorState}
|
||||||
|
encoderState={side.latestSettings.encoderState}
|
||||||
|
onEncoderTypeChange={this.onEncoderTypeChange.bind(
|
||||||
|
this,
|
||||||
|
index as 0 | 1,
|
||||||
|
)}
|
||||||
|
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(
|
||||||
|
this,
|
||||||
|
index as 0 | 1,
|
||||||
|
)}
|
||||||
|
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(
|
||||||
|
this,
|
||||||
|
index as 0 | 1,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
const copyDirections = (mobileView
|
||||||
|
? ['down', 'up']
|
||||||
|
: ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
|
||||||
|
|
||||||
|
const results = sides.map((side, index) => (
|
||||||
|
// tslint:disable-next-line:jsx-key
|
||||||
|
<Results
|
||||||
|
downloadUrl={side.downloadUrl}
|
||||||
|
imageFile={side.file}
|
||||||
|
source={source}
|
||||||
|
loading={loading || side.loading}
|
||||||
|
copyDirection={copyDirections[index]}
|
||||||
|
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index as 0 | 1)}
|
||||||
|
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
|
||||||
|
>
|
||||||
|
{!mobileView
|
||||||
|
? null
|
||||||
|
: [
|
||||||
|
<ExpandIcon class={style.expandIcon} key="expand-icon" />,
|
||||||
|
`${resultTitles[index]} (${
|
||||||
|
encoderMap[side.latestSettings.encoderState.type].label
|
||||||
|
})`,
|
||||||
|
]}
|
||||||
|
</Results>
|
||||||
|
));
|
||||||
|
|
||||||
|
// For rendering, we ideally want the settings that were used to create the data, not the latest
|
||||||
|
// settings.
|
||||||
|
const leftDisplaySettings =
|
||||||
|
leftSide.encodedSettings || leftSide.latestSettings;
|
||||||
|
const rightDisplaySettings =
|
||||||
|
rightSide.encodedSettings || rightSide.latestSettings;
|
||||||
|
const leftImgContain =
|
||||||
|
leftDisplaySettings.preprocessorState.resize.enabled &&
|
||||||
|
leftDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
|
||||||
|
const rightImgContain =
|
||||||
|
rightDisplaySettings.preprocessorState.resize.enabled &&
|
||||||
|
rightDisplaySettings.preprocessorState.resize.fitMethod === 'contain';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={style.compress}>
|
||||||
|
<Output
|
||||||
|
source={source}
|
||||||
|
mobileView={mobileView}
|
||||||
|
leftCompressed={leftImageData}
|
||||||
|
rightCompressed={rightImageData}
|
||||||
|
leftImgContain={leftImgContain}
|
||||||
|
rightImgContain={rightImgContain}
|
||||||
|
onBack={onBack}
|
||||||
|
inputProcessorState={source && source.inputProcessorState}
|
||||||
|
onInputProcessorChange={this.onInputProcessorChange}
|
||||||
|
/>
|
||||||
|
{mobileView ? (
|
||||||
|
<div class={style.options}>
|
||||||
|
<multi-panel class={style.multiPanel} open-one-only>
|
||||||
|
{results[0]}
|
||||||
|
{options[0]}
|
||||||
|
{results[1]}
|
||||||
|
{options[1]}
|
||||||
|
</multi-panel>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
[
|
||||||
|
<div class={style.options} key="options0">
|
||||||
|
{options[0]}
|
||||||
|
{results[0]}
|
||||||
|
</div>,
|
||||||
|
<div class={style.options} key="options1">
|
||||||
|
{options[1]}
|
||||||
|
{results[1]}
|
||||||
|
</div>,
|
||||||
|
]
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/client/lazy-app/Compress/result-cache.ts
Normal file
77
src/client/lazy-app/Compress/result-cache.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { EncoderState } from '../../codecs/encoders';
|
||||||
|
import { shallowEqual } from '../../lib/util';
|
||||||
|
import { PreprocessorState } from '../../codecs/preprocessors';
|
||||||
|
|
||||||
|
import * as identity from '../../codecs/identity/encoder-meta';
|
||||||
|
|
||||||
|
interface CacheResult {
|
||||||
|
preprocessed: ImageData;
|
||||||
|
data: ImageData;
|
||||||
|
file: Fileish;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry extends CacheResult {
|
||||||
|
preprocessorState: PreprocessorState;
|
||||||
|
encoderState: EncoderState;
|
||||||
|
sourceData: ImageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE = 5;
|
||||||
|
|
||||||
|
export default class ResultCache {
|
||||||
|
private readonly _entries: CacheEntry[] = [];
|
||||||
|
|
||||||
|
add(entry: CacheEntry) {
|
||||||
|
if (entry.encoderState.type === identity.type)
|
||||||
|
throw Error('Cannot cache identity encodes');
|
||||||
|
// Add the new entry to the start
|
||||||
|
this._entries.unshift(entry);
|
||||||
|
// Remove the last entry if we're now bigger than SIZE
|
||||||
|
if (this._entries.length > SIZE) this._entries.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
match(
|
||||||
|
sourceData: ImageData,
|
||||||
|
preprocessorState: PreprocessorState,
|
||||||
|
encoderState: EncoderState,
|
||||||
|
): CacheResult | undefined {
|
||||||
|
const matchingIndex = this._entries.findIndex((entry) => {
|
||||||
|
// Check for quick exits:
|
||||||
|
if (entry.sourceData !== sourceData) return false;
|
||||||
|
if (entry.encoderState.type !== encoderState.type) return false;
|
||||||
|
|
||||||
|
// Check that each set of options in the preprocessor are the same
|
||||||
|
for (const prop in preprocessorState) {
|
||||||
|
if (
|
||||||
|
!shallowEqual(
|
||||||
|
(preprocessorState as any)[prop],
|
||||||
|
(entry.preprocessorState as any)[prop],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check detailed encoder options
|
||||||
|
if (!shallowEqual(encoderState.options, entry.encoderState.options))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingIndex === -1) return undefined;
|
||||||
|
|
||||||
|
const matchingEntry = this._entries[matchingIndex];
|
||||||
|
|
||||||
|
if (matchingIndex !== 0) {
|
||||||
|
// Move the matched result to 1st position (LRU)
|
||||||
|
this._entries.splice(matchingIndex, 1);
|
||||||
|
this._entries.unshift(matchingEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: matchingEntry.data,
|
||||||
|
preprocessed: matchingEntry.preprocessed,
|
||||||
|
file: matchingEntry.file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/client/lazy-app/Compress/style.scss
Normal file
75
src/client/lazy-app/Compress/style.scss
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
.compress {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
contain: strict;
|
||||||
|
display: grid;
|
||||||
|
align-items: end;
|
||||||
|
align-content: end;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: calc(100% - 60px);
|
||||||
|
max-height: calc(100% - 104px);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
max-height: calc(100% - 75px);
|
||||||
|
width: 300px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 860px) {
|
||||||
|
max-height: calc(100% - 40px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-panel {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// Reorder so headings appear after content:
|
||||||
|
& > :nth-child(1) {
|
||||||
|
order: 2;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :nth-child(2) {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :nth-child(3) {
|
||||||
|
order: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :nth-child(4) {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
margin-left: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[content-expanded] .expand-icon {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus .expand-icon {
|
||||||
|
fill: #34B9EB;
|
||||||
|
}
|
||||||
61
src/client/lazy-app/util.ts
Normal file
61
src/client/lazy-app/util.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
interface TransitionOptions {
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
duration?: number;
|
||||||
|
easing?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transitionHeight(
|
||||||
|
el: HTMLElement,
|
||||||
|
opts: TransitionOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const {
|
||||||
|
from = el.getBoundingClientRect().height,
|
||||||
|
to = el.getBoundingClientRect().height,
|
||||||
|
duration = 1000,
|
||||||
|
easing = 'ease-in-out',
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
if (from === to || duration === 0) {
|
||||||
|
el.style.height = to + 'px';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.height = from + 'px';
|
||||||
|
// Force a style calc so the browser picks up the start value.
|
||||||
|
getComputedStyle(el).transform;
|
||||||
|
el.style.transition = `height ${duration}ms ${easing}`;
|
||||||
|
el.style.height = to + 'px';
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const listener = (event: Event) => {
|
||||||
|
if (event.target !== el) return;
|
||||||
|
el.style.transition = '';
|
||||||
|
el.removeEventListener('transitionend', listener);
|
||||||
|
el.removeEventListener('transitioncancel', listener);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('transitionend', listener);
|
||||||
|
el.addEventListener('transitioncancel', listener);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a signal and promise, and returns a promise that rejects with an AbortError if the abort is
|
||||||
|
* signalled, otherwise resolves with the promise.
|
||||||
|
*/
|
||||||
|
export async function abortable<T>(
|
||||||
|
signal: AbortSignal,
|
||||||
|
promise: Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
if (signal.aborted) throw new DOMException('AbortError', 'AbortError');
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) => {
|
||||||
|
signal.addEventListener('abort', () =>
|
||||||
|
reject(new DOMException('AbortError', 'AbortError')),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
65
src/features/worker/bridge/index.ts
Normal file
65
src/features/worker/bridge/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { wrap } from 'comlink';
|
||||||
|
import { BridgeMethods, methodNames } from './meta';
|
||||||
|
import workerURL from 'omt:../index';
|
||||||
|
import type { ProcessorWorkerApi } from '../';
|
||||||
|
import { abortable } from '../../../client/lazy-app/util';
|
||||||
|
|
||||||
|
/** How long the worker should be idle before terminating. */
|
||||||
|
const workerTimeout = 10000;
|
||||||
|
|
||||||
|
interface WorkerBridge extends BridgeMethods {}
|
||||||
|
|
||||||
|
class WorkerBridge {
|
||||||
|
protected _queue = Promise.resolve() as Promise<unknown>;
|
||||||
|
/** Worker instance associated with this processor. */
|
||||||
|
protected _worker?: Worker;
|
||||||
|
/** Comlinked worker API. */
|
||||||
|
protected _workerApi?: ProcessorWorkerApi;
|
||||||
|
|
||||||
|
protected _terminateWorker() {
|
||||||
|
if (!this._worker) return;
|
||||||
|
this._worker.terminate();
|
||||||
|
this._worker = undefined;
|
||||||
|
this._workerApi = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _startWorker() {
|
||||||
|
this._worker = new Worker(workerURL);
|
||||||
|
this._workerApi = wrap<ProcessorWorkerApi>(this._worker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const methodName of methodNames) {
|
||||||
|
WorkerBridge.prototype[methodName] = function (
|
||||||
|
this: WorkerBridge,
|
||||||
|
signal: AbortSignal,
|
||||||
|
...args: any
|
||||||
|
) {
|
||||||
|
this._queue = this._queue
|
||||||
|
.catch(() => {})
|
||||||
|
.then(async () => {
|
||||||
|
if (signal.aborted) throw new DOMException('AbortError', 'AbortError');
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
if (done) return;
|
||||||
|
this._terminateWorker();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this._worker) this._startWorker();
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this._terminateWorker();
|
||||||
|
}, workerTimeout);
|
||||||
|
|
||||||
|
return abortable(signal, this._workerApi![methodName]() as any).finally(
|
||||||
|
() => {
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkerBridge;
|
||||||
13
src/features/worker/bridge/missing-types.d.ts
vendored
Normal file
13
src/features/worker/bridge/missing-types.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2020 Google Inc. All Rights Reserved.
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
/// <reference path="../../../../missing-types.d.ts" />
|
||||||
Reference in New Issue
Block a user