Compare commits

..

6 Commits

Author SHA1 Message Date
Jake Archibald
b9611716fc Fixing lossless slider for webP. Previously you couldn't select "9" :D (#236)
* MozJPEG chroma subsampling and quality (#235)

* Adding chroma subsampling for mozjpeg

* Adding separate chroma quality.

* Preact sometimes removes the inline styles, this fixes it.

* Simplifying chroma subsample

* Adding comments

* Fixing lossless slider for webP. Previously you couldn't select "9" :D

* Destructuring args.
2018-11-06 13:47:42 +00:00
Jake Archibald
798f7ee275 Adding comments 2018-11-06 13:46:17 +00:00
Jake Archibald
e8192b4aed Simplifying chroma subsample 2018-11-06 13:46:17 +00:00
Jake Archibald
4b671395fb Preact sometimes removes the inline styles, this fixes it. 2018-11-06 13:46:16 +00:00
Jake Archibald
c59f4c4aaf Adding separate chroma quality. 2018-11-06 13:46:16 +00:00
Jake Archibald
1bdc23364a Adding chroma subsampling for mozjpeg 2018-11-06 13:46:16 +00:00
121 changed files with 3705 additions and 9717 deletions

13
.babelrc Normal file
View File

@@ -0,0 +1,13 @@
{
"plugins": [
"transform-class-properties",
"transform-react-constant-elements",
"transform-react-remove-prop-types",
[
"transform-react-jsx",
{
"pragma": "h"
}
]
]
}

View File

@@ -1,36 +0,0 @@
---
name: Bug report
about: Something is not working as expected
labels:
---
**Before you start**
Please take a look at the [FAQ](https://github.com/GoogleChromeLabs/squoosh/wiki/FAQ) as well as the already opened issues! If nothing fits your problem, go ahead and fill out the following template:
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Version:**
- OS w/ version: [e.g. iOS 12]
- Browser w/ version [e.g. Chrome 70]
- Node version: [e.g. 10.11.0]
- npm version: [e.g. 6.4.1]
**Is your issue related to the quality of image compression?**
Please attach original and output images (you can drag & drop to attach).
- Original image
- Output image from Squoosh
**Additional context, screenshots, screencasts**
Add any other context about the problem here.

View File

@@ -1,18 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
labels:
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Does other service/app have this feature?**
Add any service you know/use that has this feature (We want to know for research)
**Additional context**
Add any other context or screenshots about the feature request here.

1
.nvmrc
View File

@@ -1 +0,0 @@
10.15.3

View File

@@ -1,4 +1,5 @@
language: node_js language: node_js
node_js:
- node
cache: npm cache: npm
script: npm run build script: npm run build || npm run build # scss ts definitions need to be generated before an actual build
after_success: npm run sizereport

View File

@@ -1,31 +1,5 @@
# [Squoosh]! # Squoosh!
[Squoosh] is an image compression web app that allows you to dive into the advanced options provided Squoosh will be an image compression web app that allows you to dive into the
by various image compressors. advanced options provided by various image compressors.
# Privacy
Google Analytics is used to record the following:
* [Basic visit data](https://support.google.com/analytics/answer/6004245?ref_topic=2919631).
* Before and after image size once an image is downloaded. These values are rounded to the nearest
kilobyte.
Image compression is handled locally; no additional data is sent to the server.
# Building locally
Clone the repo, and:
```sh
npm install
npm run build
```
You can run the development server with:
```sh
npm start
```
[Squoosh]: https://squoosh.app

View File

@@ -1,18 +0,0 @@
# Long-term cache by default.
/*
Cache-Control: max-age=31536000
# And here are the exceptions:
/
Cache-Control: no-cache
/serviceworker.js
Cache-Control: no-cache
/manifest.json
Cache-Control: must-revalidate, max-age=3600
# URLs in /assets do not include a hash and are mutable.
# But it isn't a big deal if the user gets an old version.
/assets/*
Cache-Control: must-revalidate, max-age=3600

View File

@@ -1,2 +0,0 @@
/index.html / 301
/* /index.html 301

View File

@@ -11,6 +11,6 @@ $ npm install
$ npm run build $ npm run build
``` ```
This will build two files: `<codec name>_<enc or dec>.js` and `<codec name>_<enc or dec>.wasm`. It will most likely be necessary to set [`Module["locateFile"]`](https://kripken.github.io/emscripten-site/docs/api_reference/module.html#affecting-execution) to successfully load the `.wasm` file. When the `.js` file is loaded, a global `<codec name>_<enc or dec>` is created with the same API as an [Emscripten `Module`](https://kripken.github.io/emscripten-site/docs/api_reference/module.html). This will build two files: `<codec name>_<enc or dec>.js` and `<codec name>_<enc or dec>.wasm`. It will most likely be necessary to set [`Module["locateFile"]`](https://kripken.github.io/emscripten-site/docs/api_reference/module.html#affecting-execution) to sucessfully load the `.wasm` file. When the `.js` file is loaded, a global `<codec name>_<enc or dec>` is created with the same API as an [Emscripten `Module`](https://kripken.github.io/emscripten-site/docs/api_reference/module.html).
Each codec will document its API in its README. Each codec will document its API in its README.

View File

@@ -8,6 +8,6 @@
"libimagequant": "ImageOptim/libimagequant#2.12.1" "libimagequant": "ImageOptim/libimagequant#2.12.1"
}, },
"devDependencies": { "devDependencies": {
"napa": "3.0.0" "napa": "^3.0.0"
} }
} }

View File

@@ -8,6 +8,6 @@
"mozjpeg": "mozilla/mozjpeg#v3.3.1" "mozjpeg": "mozilla/mozjpeg#v3.3.1"
}, },
"devDependencies": { "devDependencies": {
"napa": "3.0.0" "napa": "^3.0.0"
} }
} }

View File

@@ -1,6 +1,6 @@
# OptiPNG # OptiPNG
- Source: <http://optipng.sourceforge.net/> - Source: <https://sourceforge.net/project/optipng>
- Version: v0.7.7 - Version: v0.7.7
## Dependencies ## Dependencies

View File

@@ -16,7 +16,7 @@
"zlib": "emscripten-ports/zlib" "zlib": "emscripten-ports/zlib"
}, },
"dependencies": { "dependencies": {
"napa": "3.0.0", "napa": "^3.0.0",
"tar-dependency": "0.0.3" "tar-dependency": "0.0.3"
} }
} }

View File

@@ -1,5 +0,0 @@
**/*.rs.bk
target
Cargo.lock
bin/
pkg/README.md

View File

@@ -1,37 +0,0 @@
[package]
name = "resize"
version = "0.1.0"
authors = ["Surma <surma@surma.link>"]
[lib]
#crate-type = ["cdylib", "rlib"]
crate-type = ["cdylib"]
[features]
default = ["console_error_panic_hook", "wee_alloc"]
[dependencies]
cfg-if = "0.1.2"
wasm-bindgen = "0.2.38"
resize = "0.3.0"
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1.1", optional = true }
# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size
# compared to the default allocator's ~10K. It is slower than the default
# allocator, however.
#
# Unfortunately, `wee_alloc` requires nightly Rust when targeting wasm for now.
wee_alloc = { version = "0.4.2", optional = true }
[dev-dependencies]
wasm-bindgen-test = "0.2"
[profile.release]
# Tell `rustc` to optimize for small code size.
opt-level = "s"
lto = true

View File

@@ -1,18 +0,0 @@
FROM ubuntu
RUN apt-get update && \
apt-get install -qqy git build-essential cmake python2.7
RUN git clone --recursive https://github.com/WebAssembly/wabt /usr/src/wabt
RUN mkdir -p /usr/src/wabt/build
WORKDIR /usr/src/wabt/build
RUN cmake .. -DCMAKE_INSTALL_PREFIX=/opt/wabt && \
make && \
make install
FROM rust
RUN rustup install nightly && \
rustup target add --toolchain nightly wasm32-unknown-unknown && \
cargo install wasm-pack
COPY --from=0 /opt/wabt /opt/wabt
ENV PATH="/opt/wabt/bin:${PATH}"
WORKDIR /src

View File

@@ -1,22 +0,0 @@
#!/bin/bash
set -e
echo "============================================="
echo "Compiling wasm"
echo "============================================="
(
rustup run nightly \
wasm-pack build --target no-modules
wasm-strip pkg/resize_bg.wasm
)
echo "============================================="
echo "Compiling wasm done"
echo "============================================="
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo "Did you update your docker image?"
echo "Run \`docker pull ubuntu\`"
echo "Run \`docker pull rust\`"
echo "Run \`docker build -t squoosh-resize .\`"
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"

View File

@@ -1,4 +0,0 @@
{
"name": "resize",
"lockfileVersion": 1
}

View File

@@ -1,7 +0,0 @@
{
"name": "resize",
"scripts": {
"build:image": "docker build -t squoosh-resize .",
"build": "docker run --rm -v $(pwd):/src squoosh-resize ./build.sh"
}
}

View File

@@ -1,11 +0,0 @@
/* tslint:disable */
/**
* @param {Uint8Array} arg0
* @param {number} arg1
* @param {number} arg2
* @param {number} arg3
* @param {number} arg4
* @param {number} arg5
* @returns {Uint8Array}
*/
export function resize(arg0: Uint8Array, arg1: number, arg2: number, arg3: number, arg4: number, arg5: number): Uint8Array;

View File

@@ -1,112 +0,0 @@
(function() {
var wasm;
const __exports = {};
let cachegetUint8Memory = null;
function getUint8Memory() {
if (cachegetUint8Memory === null || cachegetUint8Memory.buffer !== wasm.memory.buffer) {
cachegetUint8Memory = new Uint8Array(wasm.memory.buffer);
}
return cachegetUint8Memory;
}
let WASM_VECTOR_LEN = 0;
function passArray8ToWasm(arg) {
const ptr = wasm.__wbindgen_malloc(arg.length * 1);
getUint8Memory().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function getArrayU8FromWasm(ptr, len) {
return getUint8Memory().subarray(ptr / 1, ptr / 1 + len);
}
let cachedGlobalArgumentPtr = null;
function globalArgumentPtr() {
if (cachedGlobalArgumentPtr === null) {
cachedGlobalArgumentPtr = wasm.__wbindgen_global_argument_ptr();
}
return cachedGlobalArgumentPtr;
}
let cachegetUint32Memory = null;
function getUint32Memory() {
if (cachegetUint32Memory === null || cachegetUint32Memory.buffer !== wasm.memory.buffer) {
cachegetUint32Memory = new Uint32Array(wasm.memory.buffer);
}
return cachegetUint32Memory;
}
/**
* @param {Uint8Array} arg0
* @param {number} arg1
* @param {number} arg2
* @param {number} arg3
* @param {number} arg4
* @param {number} arg5
* @returns {Uint8Array}
*/
__exports.resize = function(arg0, arg1, arg2, arg3, arg4, arg5) {
const ptr0 = passArray8ToWasm(arg0);
const len0 = WASM_VECTOR_LEN;
const retptr = globalArgumentPtr();
wasm.resize(retptr, ptr0, len0, arg1, arg2, arg3, arg4, arg5);
const mem = getUint32Memory();
const rustptr = mem[retptr / 4];
const rustlen = mem[retptr / 4 + 1];
const realRet = getArrayU8FromWasm(rustptr, rustlen).slice();
wasm.__wbindgen_free(rustptr, rustlen * 1);
return realRet;
};
const heap = new Array(32);
heap.fill(undefined);
heap.push(undefined, null, true, false);
let heap_next = heap.length;
function dropObject(idx) {
if (idx < 36) return;
heap[idx] = heap_next;
heap_next = idx;
}
__exports.__wbindgen_object_drop_ref = function(i) { dropObject(i); };
function init(path_or_module) {
let instantiation;
const imports = { './resize': __exports };
if (path_or_module instanceof WebAssembly.Module) {
instantiation = WebAssembly.instantiate(path_or_module, imports)
.then(instance => {
return { instance, module: path_or_module }
});
} else {
const data = fetch(path_or_module);
if (typeof WebAssembly.instantiateStreaming === 'function') {
instantiation = WebAssembly.instantiateStreaming(data, imports)
.catch(e => {
console.warn("`WebAssembly.instantiateStreaming` failed. Assuming this is because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
return data
.then(r => r.arrayBuffer())
.then(bytes => WebAssembly.instantiate(bytes, imports));
});
} else {
instantiation = data
.then(response => response.arrayBuffer())
.then(buffer => WebAssembly.instantiate(buffer, imports));
}
}
return instantiation.then(({instance}) => {
wasm = init.wasm = instance.exports;
});
};
self.wasm_bindgen = Object.assign(init, __exports);
})();

View File

@@ -1,6 +0,0 @@
/* tslint:disable */
export const memory: WebAssembly.Memory;
export function __wbindgen_global_argument_ptr(): number;
export function __wbindgen_malloc(a: number): number;
export function __wbindgen_free(a: number, b: number): void;
export function resize(a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number): void;

Binary file not shown.

View File

@@ -1,52 +0,0 @@
extern crate cfg_if;
extern crate resize;
extern crate wasm_bindgen;
mod utils;
use cfg_if::cfg_if;
use resize::Pixel::RGBA;
use resize::Type;
use wasm_bindgen::prelude::*;
cfg_if! {
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
if #[cfg(feature = "wee_alloc")] {
extern crate wee_alloc;
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
}
}
#[wasm_bindgen]
#[no_mangle]
pub fn resize(
input_image: Vec<u8>,
input_width: usize,
input_height: usize,
output_width: usize,
output_height: usize,
typ_idx: usize,
) -> Vec<u8> {
let typ = match typ_idx {
0 => Type::Triangle,
1 => Type::Catrom,
2 => Type::Mitchell,
3 => Type::Lanczos3,
_ => panic!("Nope"),
};
let num_output_pixels = output_width * output_height;
let mut resizer = resize::new(
input_width,
input_height,
output_width,
output_height,
RGBA,
typ,
);
let mut output_image = Vec::<u8>::with_capacity(num_output_pixels * 4);
output_image.resize(num_output_pixels * 4, 0);
resizer.resize(input_image.as_slice(), output_image.as_mut_slice());
return output_image;
}

View File

@@ -1,17 +0,0 @@
use cfg_if::cfg_if;
cfg_if! {
// When the `console_error_panic_hook` feature is enabled, we can call the
// `set_panic_hook` function at least once during initialization, and then
// we will get better error messages if our code ever panics.
//
// For more details see
// https://github.com/rustwasm/console_error_panic_hook#readme
if #[cfg(feature = "console_error_panic_hook")] {
extern crate console_error_panic_hook;
pub use self::console_error_panic_hook::set_once as set_panic_hook;
} else {
#[inline]
pub fn set_panic_hook() {}
}
}

View File

@@ -1,2 +0,0 @@
target
Cargo.lock

View File

@@ -1,14 +0,0 @@
[package]
name = "rotate"
version = "0.1.0"
authors = ["Surma <surma@google.com>"]
edition = "2018"
[lib]
name = "rotate"
path = "rotate.rs"
crate-type = ["cdylib", "rlib"]
[profile.release]
lto = true
opt-level = "s"

View File

@@ -1,17 +0,0 @@
FROM ubuntu
RUN apt-get update && \
apt-get install -qqy git build-essential cmake python2.7
RUN git clone --recursive https://github.com/WebAssembly/wabt /usr/src/wabt
RUN mkdir -p /usr/src/wabt/build
WORKDIR /usr/src/wabt/build
RUN cmake .. -DCMAKE_INSTALL_PREFIX=/opt/wabt && \
make && \
make install
FROM rust
RUN rustup install nightly && \
rustup target add --toolchain nightly wasm32-unknown-unknown
COPY --from=0 /opt/wabt /opt/wabt
ENV PATH="/opt/wabt/bin:${PATH}"
WORKDIR /src

View File

@@ -1,45 +0,0 @@
// THIS IS NOT A NODE SCRIPT
// This is a d8 script. Please install jsvu[1] and install v8.
// Then run `npm run --silent benchmark`.
// [1]: https://github.com/GoogleChromeLabs/jsvu
async function init() {
// Adjustable constants.
const imageDimensions = 4096;
const iterations = new Array(100);
// Constants. Dont change.
const imageByteSize = imageDimensions * imageDimensions * 4;
const wasmPageSize = 64 * 1024;
const buffer = readbuffer("rotate.wasm");
const { instance } = await WebAssembly.instantiate(buffer);
const pagesAvailable = Math.floor(
instance.exports.memory.buffer.byteLength / wasmPageSize
);
const pagesNeeded = Math.floor((imageByteSize * 2 + 4) / wasmPageSize) + 1;
const additionalPagesNeeded = pagesNeeded - pagesAvailable;
if (additionalPagesNeeded > 0) {
instance.exports.memory.grow(additionalPagesNeeded);
}
[0, 90, 180, 270].forEach(rotation => {
print(`\n${rotation} degrees`);
print(`==============================`);
for (let i = 0; i < 100; i++) {
const start = Date.now();
instance.exports.rotate(imageDimensions, imageDimensions, rotation);
iterations[i] = Date.now() - start;
}
const average = iterations.reduce((sum, c) => sum + c) / iterations.length;
const stddev = Math.sqrt(
iterations
.map(i => Math.pow(i - average, 2))
.reduce((sum, c) => sum + c) / iterations.length
);
print(`n = ${iterations.length}`);
print(`Average: ${average}`);
print(`StdDev: ${stddev}`);
});
}
init().catch(e => console.error(e.stack));

View File

@@ -1,25 +0,0 @@
#!/bin/bash
set -e
echo "============================================="
echo "Compiling wasm"
echo "============================================="
(
rustup run nightly \
cargo build \
--target wasm32-unknown-unknown \
--release
cp target/wasm32-unknown-unknown/release/rotate.wasm .
wasm-strip rotate.wasm
)
echo "============================================="
echo "Compiling wasm done"
echo "============================================="
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo "Did you update your docker image?"
echo "Run \`docker pull ubuntu\`"
echo "Run \`docker pull rust\`"
echo "Run \`docker build -t squoosh-rotate .\`"
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"

View File

@@ -1,11 +0,0 @@
{
"name": "rotate",
"scripts": {
"build:image": "docker build -t squoosh-rotate .",
"build": "docker run --rm -v $(pwd):/src squoosh-rotate ./build.sh",
"benchmark": "echo File size after gzip && npm run benchmark:filesize && echo Optimizing && npm run -s benchmark:optimizing",
"benchmark:baseline": "v8 --liftoff --no-wasm-tier-up --no-opt ./benchmark.js",
"benchmark:optimizing": "v8 --no-liftoff --no-wasm-tier-up ./benchmark.js",
"benchmark:filesize": "cat rotate.wasm | gzip -c9n | wc -c"
}
}

View File

@@ -1,113 +0,0 @@
use std::slice::{from_raw_parts, from_raw_parts_mut};
// This function is taken from Zachary Dremann
// https://github.com/GoogleChromeLabs/squoosh/pull/462
trait HardUnwrap<T> {
fn unwrap_hard(self) -> T;
}
impl<T> HardUnwrap<T> for Option<T> {
#[cfg(not(debug_assertions))]
#[inline]
fn unwrap_hard(self) -> T {
match self {
Some(t) => t,
None => std::process::abort(),
}
}
#[cfg(debug_assertions)]
fn unwrap_hard(self) -> T {
self.unwrap()
}
}
const TILE_SIZE: usize = 16;
fn get_buffers<'a>(width: usize, height: usize) -> (&'a [u32], &'a mut [u32]) {
let num_pixels = width * height;
let in_b: &[u32];
let out_b: &mut [u32];
unsafe {
in_b = from_raw_parts::<u32>(8 as *const u32, num_pixels);
out_b = from_raw_parts_mut::<u32>((num_pixels * 4 + 8) as *mut u32, num_pixels);
}
return (in_b, out_b);
}
#[inline(never)]
fn rotate_0(width: usize, height: usize) {
let (in_b, out_b) = get_buffers(width, height);
for (in_p, out_p) in in_b.iter().zip(out_b.iter_mut()) {
*out_p = *in_p;
}
}
#[inline(never)]
fn rotate_90(width: usize, height: usize) {
let (in_b, out_b) = get_buffers(width, height);
let new_width = height;
let _new_height = width;
for y_start in (0..height).step_by(TILE_SIZE) {
for x_start in (0..width).step_by(TILE_SIZE) {
for y in y_start..(y_start + TILE_SIZE).min(height) {
let in_offset = y * width;
let in_bounds = if x_start + TILE_SIZE < width {
(in_offset + x_start)..(in_offset + x_start + TILE_SIZE)
} else {
(in_offset + x_start)..(in_offset + width)
};
let in_chunk = in_b.get(in_bounds).unwrap_hard();
for (x, in_p) in in_chunk.iter().enumerate() {
let new_x = (new_width - 1) - y;
let new_y = x + x_start;
*out_b.get_mut(new_y * new_width + new_x).unwrap_hard() = *in_p;
}
}
}
}
}
#[inline(never)]
fn rotate_180(width: usize, height: usize) {
let (in_b, out_b) = get_buffers(width, height);
for (in_p, out_p) in in_b.iter().zip(out_b.iter_mut().rev()) {
*out_p = *in_p;
}
}
#[inline(never)]
fn rotate_270(width: usize, height: usize) {
let (in_b, out_b) = get_buffers(width, height);
let new_width = height;
let new_height = width;
for y_start in (0..height).step_by(TILE_SIZE) {
for x_start in (0..width).step_by(TILE_SIZE) {
for y in y_start..(y_start + TILE_SIZE).min(height) {
let in_offset = y * width;
let in_bounds = if x_start + TILE_SIZE < width {
(in_offset + x_start)..(in_offset + x_start + TILE_SIZE)
} else {
(in_offset + x_start)..(in_offset + width)
};
let in_chunk = in_b.get(in_bounds).unwrap_hard();
for (x, in_p) in in_chunk.iter().enumerate() {
let new_x = y;
let new_y = new_height - 1 - (x_start + x);
*out_b.get_mut(new_y * new_width + new_x).unwrap_hard() = *in_p;
}
}
}
}
}
#[no_mangle]
fn rotate(width: usize, height: usize, rotate: usize) {
match rotate {
0 => rotate_0(width, height),
90 => rotate_90(width, height),
180 => rotate_180(width, height),
270 => rotate_270(width, height),
_ => std::process::abort(),
}
}

Binary file not shown.

View File

@@ -1,7 +1,7 @@
# WebP decoder # WebP decoder
- Source: <https://github.com/webmproject/libwebp> - Source: <https://github.com/webmproject/libwebp>
- Version: v1.0.2 - Version: v0.6.1
## Example ## Example

View File

@@ -6,33 +6,7 @@ export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}" export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}" export CFLAGS="${OPTIMIZE}"
export CPPFLAGS="${OPTIMIZE}" export CPPFLAGS="${OPTIMIZE}"
apt-get update
apt-get install -qqy autoconf libtool libpng-dev pkg-config
echo "============================================="
echo "Compiling libwebp"
echo "============================================="
test -n "$SKIP_LIBWEBP" || (
cd node_modules/libwebp
autoreconf -fiv
rm -rf build || true
mkdir -p build && cd build
emconfigure ../configure \
--disable-libwebpdemux \
--disable-wic \
--disable-gif \
--disable-tiff \
--disable-jpeg \
--disable-png \
--disable-sdl \
--disable-gl \
--disable-threading \
--disable-neon-rtcd \
--disable-neon \
--disable-sse2 \
--disable-sse4.1
emmake make
)
echo "=============================================" echo "============================================="
echo "Compiling wasm bindings" echo "Compiling wasm bindings"
echo "=============================================" echo "============================================="
@@ -46,9 +20,9 @@ echo "============================================="
--std=c++11 \ --std=c++11 \
-I node_modules/libwebp \ -I node_modules/libwebp \
-o ./webp_dec.js \ -o ./webp_dec.js \
node_modules/libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c \
-x c++ \ -x c++ \
webp_dec.cpp \ webp_dec.cpp
node_modules/libwebp/build/src/.libs/libwebp.a
) )
echo "=============================================" echo "============================================="
echo "Compiling wasm bindings done" echo "Compiling wasm bindings done"

View File

@@ -5,9 +5,9 @@
"build": "docker run --rm -v $(pwd):/src trzeci/emscripten ./build.sh" "build": "docker run --rm -v $(pwd):/src trzeci/emscripten ./build.sh"
}, },
"napa": { "napa": {
"libwebp": "webmproject/libwebp#v1.0.2" "libwebp": "webmproject/libwebp#v1.0.0"
}, },
"devDependencies": { "devDependencies": {
"napa": "3.0.0" "napa": "^3.0.0"
} }
} }

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,7 +1,7 @@
# WebP encoder # WebP encoder
- Source: <https://github.com/webmproject/libwebp> - Source: <https://github.com/webmproject/libwebp>
- Version: v1.0.2 - Version: v0.6.1
## Dependencies ## Dependencies

View File

@@ -7,33 +7,6 @@ export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}" export CFLAGS="${OPTIMIZE}"
export CPPFLAGS="${OPTIMIZE}" export CPPFLAGS="${OPTIMIZE}"
apt-get update
apt-get install -qqy autoconf libtool libpng-dev pkg-config
echo "============================================="
echo "Compiling libwebp"
echo "============================================="
test -n "$SKIP_LIBWEBP" || (
cd node_modules/libwebp
autoreconf -fiv
rm -rf build || true
mkdir -p build && cd build
emconfigure ../configure \
--disable-libwebpdemux \
--disable-wic \
--disable-gif \
--disable-tiff \
--disable-jpeg \
--disable-png \
--disable-sdl \
--disable-gl \
--disable-threading \
--disable-neon-rtcd \
--disable-neon \
--disable-sse2 \
--disable-sse4.1
emmake make
)
echo "=============================================" echo "============================================="
echo "Compiling wasm bindings" echo "Compiling wasm bindings"
echo "=============================================" echo "============================================="
@@ -47,9 +20,9 @@ echo "============================================="
--std=c++11 \ --std=c++11 \
-I node_modules/libwebp \ -I node_modules/libwebp \
-o ./webp_enc.js \ -o ./webp_enc.js \
node_modules/libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c \
-x c++ \ -x c++ \
webp_enc.cpp \ webp_enc.cpp
node_modules/libwebp/build/src/.libs/libwebp.a
) )
echo "=============================================" echo "============================================="
echo "Compiling wasm bindings done" echo "Compiling wasm bindings done"

View File

@@ -5,9 +5,9 @@
"build": "docker run --rm -v $(pwd):/src trzeci/emscripten ./build.sh" "build": "docker run --rm -v $(pwd):/src trzeci/emscripten ./build.sh"
}, },
"napa": { "napa": {
"libwebp": "webmproject/libwebp#v1.0.2" "libwebp": "webmproject/libwebp#v1.0.0"
}, },
"devDependencies": { "devDependencies": {
"napa": "3.0.0" "napa": "^3.0.0"
} }
} }

View File

@@ -26,8 +26,7 @@ val encode(std::string img, int width, int height, WebPConfig config) {
throw std::runtime_error("Unexpected error"); throw std::runtime_error("Unexpected error");
} }
// Only use use_argb if we really need it, as it's slower. pic.use_argb = !!config.lossless;
pic.use_argb = config.lossless || config.use_sharp_yuv || config.preprocessing > 0;
pic.width = width; pic.width = width;
pic.height = height; pic.height = height;
pic.writer = WebPMemoryWrite; pic.writer = WebPMemoryWrite;

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,74 +0,0 @@
const DtsCreator = require('typed-css-modules');
const chokidar = require('chokidar');
const util = require('util');
const sass = require('node-sass');
const sassRender = util.promisify(sass.render);
async function sassToCss(path) {
const result = await sassRender({ file: path });
return result.css;
}
/**
* @typedef {Object} Opts
* @property {boolean} watch Watch for changes
*/
/**
* Create typing files for CSS & SCSS.
*
* @param {string[]} rootPaths Paths to search within
* @param {Opts} [opts={}] Options.
*/
function addCssTypes(rootPaths, opts = {}) {
return new Promise((resolve) => {
const { watch = false } = opts;
const paths = [];
const preReadyPromises = [];
let ready = false;
for (const rootPath of rootPaths) {
// Look for scss & css in each path.
paths.push(rootPath + '/**/*.scss');
paths.push(rootPath + '/**/*.css');
}
// For simplicity, the watcher is used even if we're not watching.
// If we're not watching, we stop the watcher after the initial files are found.
const watcher = chokidar.watch(paths, {
// Avoid processing already-processed files.
ignored: '*.d.*',
// Without this, travis and netlify builds never complete. I'm not sure why, but it might be
// related to https://github.com/paulmillr/chokidar/pull/758
persistent: watch,
});
function change(path) {
const promise = (async function() {
const creator = new DtsCreator({ camelCase: true });
const result = path.endsWith('.scss') ?
await creator.create(path, await sassToCss(path)) :
await creator.create(path);
await result.writeFile();
})();
if (!ready) preReadyPromises.push(promise);
}
watcher.on('change', change);
watcher.on('add', change);
// 'ready' is when events have been fired for file discovery.
watcher.on('ready', () => {
ready = true;
// Wait for the current set of processing to finish.
Promise.all(preReadyPromises).then(resolve);
// And if we're not watching, close the watcher.
if (!watch) watcher.close();
});
})
}
module.exports = addCssTypes;

View File

@@ -1,47 +0,0 @@
const path = require('path');
const fs = require('fs');
const ejs = require('ejs');
const AssetsPlugin = require('assets-webpack-plugin');
module.exports = class AssetTemplatePlugin extends AssetsPlugin {
constructor(options) {
options = options || {};
if (!options.template) throw Error('AssetTemplatePlugin: template option is required.');
super({
useCompilerPath: true,
filename: options.filename,
processOutput: files => this._processOutput(files)
});
this._template = path.resolve(process.cwd(), options.template);
const ignore = options.ignore || /(manifest\.json|\.DS_Store)$/;
this._ignore = typeof ignore === 'function' ? ({ test: ignore }) : ignore;
}
_processOutput(files) {
const mapping = {
all: [],
byType: {},
entries: {}
};
for (const entryName in files) {
// non-entry-point-derived assets are collected under an empty string key
// since that's a bit awkward, we'll call them "assets"
const name = entryName === '' ? 'assets' : entryName;
const listing = files[entryName];
const entry = mapping.entries[name] = {
all: [],
byType: {}
};
for (let type in listing) {
const list = [].concat(listing[type]).filter(file => !this._ignore.test(file));
if (!list.length) continue;
mapping.all = mapping.all.concat(list);
mapping.byType[type] = (mapping.byType[type] || []).concat(list);
entry.all = entry.all.concat(list);
entry.byType[type] = (entry.byType[type] || []).concat(list);
}
}
mapping.files = mapping.all;
return ejs.render(fs.readFileSync(this._template, 'utf8'), mapping);
}
};

View File

@@ -1,158 +0,0 @@
const util = require('util');
const minimatch = require('minimatch');
const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin');
const WebWorkerTemplatePlugin = require('webpack/lib/webworker/WebWorkerTemplatePlugin');
const ParserHelpers = require('webpack/lib/ParserHelpers');
const NAME = 'auto-sw-plugin';
const JS_TYPES = ['auto', 'esm', 'dynamic'];
/**
* Automatically finds and bundles Service Workers by looking for navigator.serviceWorker.register(..).
* An Array of webpack assets is injected into the Service Worker bundle as a `BUILD_ASSETS` global.
* Hidden and `.map` files are excluded by default, and this can be customized using the include & exclude options.
* @example
* // webpack config
* plugins: [
* new AutoSWPlugin({
* exclude: [
* '**\/.*', // don't expose hidden files (default)
* '**\/*.map', // don't precache sourcemaps (default)
* 'index.html' // don't cache the page itself
* ]
* })
* ]
* @param {Object} [options={}]
* @param {string[]} [options.exclude] Minimatch pattern(s) of which assets to omit from BUILD_ASSETS.
* @param {string[]} [options.include] Minimatch pattern(s) of assets to allow in BUILD_ASSETS.
*/
module.exports = class AutoSWPlugin {
constructor(options) {
this.options = Object.assign({
exclude: [
'**/*.map',
'**/.*'
]
}, options || {});
}
apply(compiler) {
const serviceWorkers = [];
compiler.hooks.emit.tapPromise(NAME, compilation => this.emit(compiler, compilation, serviceWorkers));
compiler.hooks.normalModuleFactory.tap(NAME, (factory) => {
for (const type of JS_TYPES) {
factory.hooks.parser.for(`javascript/${type}`).tap(NAME, parser => {
let counter = 0;
const processRegisterCall = expr => {
const dep = parser.evaluateExpression(expr.arguments[0]);
if (!dep.isString()) {
parser.state.module.warnings.push({
message: 'navigator.serviceWorker.register() will only be bundled if passed a String literal.'
});
return false;
}
const filename = dep.string;
const outputFilename = this.options.filename || 'serviceworker.js'
const context = parser.state.current.context;
serviceWorkers.push({
outputFilename,
filename,
context
});
const id = `__webpack__serviceworker__${++counter}`;
ParserHelpers.toConstantDependency(parser, id)(expr.arguments[0]);
return ParserHelpers.addParsedVariableToModule(parser, id, '__webpack_public_path__ + ' + JSON.stringify(outputFilename));
};
parser.hooks.call.for('navigator.serviceWorker.register').tap(NAME, processRegisterCall);
parser.hooks.call.for('self.navigator.serviceWorker.register').tap(NAME, processRegisterCall);
parser.hooks.call.for('window.navigator.serviceWorker.register').tap(NAME, processRegisterCall);
});
}
});
}
createFilter(list) {
const filters = [].concat(list);
for (let i=0; i<filters.length; i++) {
if (typeof filters[i] === 'string') {
filters[i] = minimatch.filter(filters[i]);
}
}
return filters;
}
async emit(compiler, compilation, serviceWorkers) {
let assetMapping = Object.keys(compilation.assets);
if (this.options.include) {
const filters = this.createFilter(this.options.include);
assetMapping = assetMapping.filter(filename => {
for (const filter of filters) {
if (filter(filename)) return true;
}
return false;
});
}
if (this.options.exclude) {
const filters = this.createFilter(this.options.exclude);
assetMapping = assetMapping.filter(filename => {
for (const filter of filters) {
if (filter(filename)) return false;
}
return true;
});
}
await Promise.all(serviceWorkers.map(
(serviceWorker, index) => this.compileServiceWorker(compiler, compilation, serviceWorker, index, assetMapping)
));
}
async compileServiceWorker(compiler, compilation, options, index, assetMapping) {
const entryFilename = options.filename;
const chunkFilename = compiler.options.output.chunkFilename.replace(/\.([a-z]+)$/i, '.serviceworker.$1');
const workerOptions = {
filename: options.outputFilename, // chunkFilename.replace(/\.?\[(?:chunkhash|contenthash|hash)(:\d+(?::\d+)?)?\]/g, ''),
chunkFilename: this.options.chunkFilename || chunkFilename,
globalObject: 'self'
};
const childCompiler = compilation.createChildCompiler(NAME, { filename: workerOptions.filename });
(new WebWorkerTemplatePlugin(workerOptions)).apply(childCompiler);
/* The duplication DefinePlugin ends up causing is problematic (it doesn't hoist injections), so we'll do it manually. */
// (new DefinePlugin({
// BUILD_ASSETS: JSON.stringify(assetMapping)
// })).apply(childCompiler);
(new SingleEntryPlugin(options.context, entryFilename, workerOptions.filename)).apply(childCompiler);
const subCache = `subcache ${__dirname} ${entryFilename} ${index}`;
let childCompilation;
childCompiler.hooks.compilation.tap(NAME, c => {
childCompilation = c;
if (childCompilation.cache) {
if (!childCompilation.cache[subCache]) childCompilation.cache[subCache] = {};
childCompilation.cache = childCompilation.cache[subCache];
}
});
await (util.promisify(childCompiler.runAsChild.bind(childCompiler)))();
const versionVar = this.options.version ?
`var VERSION = ${JSON.stringify(this.options.version)};` : '';
const original = childCompilation.assets[workerOptions.filename].source();
const source = `${versionVar}var BUILD_ASSETS=${JSON.stringify(assetMapping)};${original}`;
childCompilation.assets[workerOptions.filename] = {
source: () => source,
size: () => Buffer.byteLength(source, 'utf8')
};
Object.assign(compilation.assets, childCompilation.assets);
}
};

View File

@@ -1,203 +0,0 @@
const path = require('path');
const { URL } = require('url');
const gzipSize = require('gzip-size');
const fetch = require('node-fetch');
const prettyBytes = require('pretty-bytes');
const escapeRE = require('escape-string-regexp');
const readdirp = require('readdirp');
const chalk = new require('chalk').constructor({ level: 4 });
function fetchTravis(path, options = {}) {
const url = new URL(path, 'https://api.travis-ci.org');
url.search = new URLSearchParams(options);
return fetch(url, {
headers: { 'Travis-API-Version': '3' },
});
}
function fetchTravisBuildInfo(user, repo, branch) {
return fetchTravis(`/repo/${encodeURIComponent(`${user}/${repo}`)}/builds`, {
'branch.name': branch,
state: 'passed',
limit: 1,
event_type: 'push',
}).then(r => r.json());
}
function fetchTravisText(path) {
return fetchTravis(path).then(r => r.text());
}
/**
* Recursively-read a directory and turn it into an array of { name, size, gzipSize }
*/
async function dirToInfoArray(startPath) {
const results = await new Promise((resolve, reject) => {
readdirp({ root: startPath }, (err, results) => {
if (err) reject(err); else resolve(results);
});
});
return Promise.all(
results.files.map(async (entry) => ({
name: entry.path,
gzipSize: await gzipSize.file(entry.fullPath),
size: entry.stat.size,
})),
);
}
/**
* Try to treat two entries with different file name hashes as the same file.
*/
function findHashedMatch(name, buildInfo) {
const nameParts = /^(.+\.)[a-f0-9]+(\..+)$/.exec(name);
if (!nameParts) return;
const matchRe = new RegExp(`^${escapeRE(nameParts[1])}[a-f0-9]+${escapeRE(nameParts[2])}$`);
const matchingEntry = buildInfo.find(entry => matchRe.test(entry.name));
return matchingEntry;
}
const buildSizePrefix = '=== BUILD SIZES: ';
const buildSizePrefixRe = new RegExp(`^${escapeRE(buildSizePrefix)}(.+)$`, 'm');
async function getPreviousBuildInfo() {
const buildData = await fetchTravisBuildInfo('GoogleChromeLabs', 'squoosh', 'master');
const jobUrl = buildData.builds[0].jobs[0]['@href'];
const log = await fetchTravisText(jobUrl + '/log.txt');
const reResult = buildSizePrefixRe.exec(log);
if (!reResult) return;
return JSON.parse(reResult[1]);
}
/**
* Generate an array that represents the difference between builds.
* Returns an array of { beforeName, afterName, beforeSize, afterSize }.
* Sizes are gzipped size.
* Before/after properties are missing if resource isn't in the previous/new build.
*/
function getChanges(previousBuildInfo, buildInfo) {
const buildChanges = [];
const alsoInPreviousBuild = new Set();
for (const oldEntry of previousBuildInfo) {
const newEntry = buildInfo.find(entry => entry.name === oldEntry.name) ||
findHashedMatch(oldEntry.name, buildInfo);
// Entry is in previous build, but not the new build.
if (!newEntry) {
buildChanges.push({
beforeName: oldEntry.name,
beforeSize: oldEntry.gzipSize,
});
continue;
}
// Mark this entry so we know we've dealt with it.
alsoInPreviousBuild.add(newEntry);
// If they're the same, just ignore.
// Using size rather than gzip size. I've seen different platforms produce different zipped
// sizes.
if (
oldEntry.size === newEntry.size &&
oldEntry.name === newEntry.name
) continue;
// Entry is in both builds (maybe renamed).
buildChanges.push({
beforeName: oldEntry.name,
afterName: newEntry.name,
beforeSize: oldEntry.gzipSize,
afterSize: newEntry.gzipSize,
});
}
// Look for entries that are only in the new build.
for (const newEntry of buildInfo) {
if (alsoInPreviousBuild.has(newEntry)) continue;
buildChanges.push({
afterName: newEntry.name,
afterSize: newEntry.gzipSize,
});
}
return buildChanges;
}
async function main() {
// Output the current build sizes for later retrieval.
const buildInfo = await dirToInfoArray(__dirname + '/../build');
console.log(buildSizePrefix + JSON.stringify(buildInfo));
console.log('\nBuild change report:');
let previousBuildInfo;
try {
previousBuildInfo = await getPreviousBuildInfo();
} catch (err) {
console.log(` Couldn't parse previous build info`);
return;
}
if (!previousBuildInfo) {
console.log(` Couldn't find previous build info`);
return;
}
const buildChanges = getChanges(previousBuildInfo, buildInfo);
if (buildChanges.length === 0) {
console.log(' No changes');
return;
}
// One letter references, so it's easier to get the spacing right.
const y = chalk.yellow;
const g = chalk.green;
const r = chalk.red;
for (const change of buildChanges) {
// New file.
if (!change.beforeSize) {
console.log(` ${g('ADDED')} ${change.afterName} - ${prettyBytes(change.afterSize)}`);
continue;
}
// Removed file.
if (!change.afterSize) {
console.log(` ${r('REMOVED')} ${change.beforeName} - was ${prettyBytes(change.beforeSize)}`);
continue;
}
// Changed file.
let size;
if (change.beforeSize === change.afterSize) {
// Just renamed.
size = `${prettyBytes(change.afterSize)} -> no change`;
} else {
const color = change.afterSize > change.beforeSize ? r : g;
const sizeDiff = prettyBytes(change.afterSize - change.beforeSize, { signed: true });
const relativeDiff = Math.round((change.afterSize / change.beforeSize) * 1000) / 1000;
size = `${prettyBytes(change.beforeSize)} -> ${prettyBytes(change.afterSize)}` +
' (' +
color(`${sizeDiff}, ${relativeDiff}x`) +
')';
}
console.log(` ${y('CHANGED')} ${change.afterName} - ${size}`);
if (change.beforeName !== change.afterName) {
console.log(` Renamed from: ${change.beforeName}`);
}
}
}
main();

7
global.d.ts vendored
View File

@@ -1,21 +1,16 @@
declare const __webpack_public_path__: string; declare const __webpack_public_path__: string;
declare const PRERENDER: boolean;
declare interface NodeModule { declare interface NodeModule {
hot: any; hot: any;
} }
declare interface Window { declare interface Window {
STATE: any; STATE: any
ga: typeof ga;
} }
declare namespace JSX { declare namespace JSX {
interface Element { } interface Element { }
interface IntrinsicElements { } interface IntrinsicElements { }
interface HTMLAttributes {
decoding?: string;
}
} }
declare module 'classnames' { declare module 'classnames' {

9902
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
{ {
"private": true, "private": true,
"name": "squoosh", "name": "squoosh",
"version": "1.4.0", "version": "0.0.0",
"license": "apache-2.0", "license": "apache-2.0",
"scripts": { "scripts": {
"start": "webpack-dev-server --host 0.0.0.0 --hot", "start": "webpack serve --host 0.0.0.0 --hot",
"build": "webpack -p", "build": "webpack -p",
"lint": "tslint -c tslint.json -p tsconfig.json -t verbose", "lint": "tslint -c tslint.json -t verbose 'src/**/*.{ts,tsx,js,jsx}'",
"lintfix": "tslint -c tslint.json -p tsconfig.json -t verbose --fix 'src/**/*.{ts,tsx,js,jsx}'", "lintfix": "tslint -c tslint.json -t verbose --fix 'src/**/*.{ts,tsx,js,jsx}'"
"sizereport": "node config/size-report.js"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
@@ -16,60 +15,56 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@types/node": "10.12.29", "@types/node": "^9.6.23",
"@types/pretty-bytes": "5.1.0", "@types/pretty-bytes": "^5.1.0",
"@types/webassembly-js-api": "0.0.2", "@types/webassembly-js-api": "0.0.1",
"@webcomponents/custom-elements": "1.2.1", "@webcomponents/custom-elements": "^1.2.0",
"@webpack-cli/serve": "0.1.3", "babel-loader": "^7.1.5",
"assets-webpack-plugin": "3.9.10", "babel-plugin-jsx-pragmatic": "^1.0.2",
"chokidar": "2.1.2", "babel-plugin-syntax-dynamic-import": "^6.18.0",
"chalk": "2.4.2", "babel-plugin-transform-class-properties": "^6.24.1",
"classnames": "2.2.6", "babel-plugin-transform-decorators-legacy": "^1.3.5",
"clean-webpack-plugin": "1.0.1", "babel-plugin-transform-object-rest-spread": "^6.26.0",
"comlink": "3.1.1", "babel-plugin-transform-react-constant-elements": "^6.23.0",
"copy-webpack-plugin": "5.0.0", "babel-plugin-transform-react-jsx": "^6.24.1",
"critters-webpack-plugin": "2.3.0", "babel-plugin-transform-react-remove-prop-types": "^0.4.14",
"css-loader": "1.0.1", "babel-preset-env": "^1.7.0",
"ejs": "2.6.1", "babel-register": "^6.26.0",
"escape-string-regexp": "1.0.5", "clean-webpack-plugin": "^0.1.19",
"exports-loader": "0.7.0", "copy-webpack-plugin": "^4.5.2",
"file-drop-element": "0.2.0", "css-loader": "^0.28.11",
"file-loader": "3.0.1", "exports-loader": "^0.7.0",
"gzip-size": "5.0.0", "file-loader": "^1.1.11",
"html-webpack-plugin": "3.2.0", "html-webpack-plugin": "^3.2.0",
"husky": "1.3.1", "husky": "^1.0.0-rc.13",
"idb-keyval": "3.1.0", "if-env": "^1.0.4",
"linkstate": "1.1.1", "loader-utils": "^1.1.0",
"loader-utils": "1.2.3", "mini-css-extract-plugin": "^0.3.0",
"mini-css-extract-plugin": "0.5.0", "node-sass": "^4.9.3",
"minimatch": "3.0.4", "optimize-css-assets-webpack-plugin": "^4.0.3",
"node-fetch": "2.3.0", "progress-bar-webpack-plugin": "^1.11.0",
"node-sass": "4.11.0", "raw-loader": "^0.5.1",
"optimize-css-assets-webpack-plugin": "5.0.1", "sass-loader": "^7.1.0",
"pointer-tracker": "2.0.3", "script-ext-html-webpack-plugin": "^2.0.1",
"preact": "8.4.2", "source-map-loader": "^0.2.3",
"prerender-loader": "1.3.0", "style-loader": "^0.22.1",
"pretty-bytes": "5.1.0", "ts-loader": "^4.4.2",
"progress-bar-webpack-plugin": "1.12.1", "tslint": "^5.11.0",
"raw-loader": "1.0.0", "tslint-config-airbnb": "^5.9.2",
"readdirp": "2.2.1", "tslint-config-semistandard": "^7.0.0",
"sass-loader": "7.1.0", "tslint-react": "^3.6.0",
"script-ext-html-webpack-plugin": "2.1.3", "typescript": "^2.9.2",
"source-map-loader": "0.2.4", "typings-for-css-modules-loader": "^1.7.0",
"style-loader": "0.23.1", "webpack": "^4.19.1",
"terser-webpack-plugin": "1.2.3", "webpack-bundle-analyzer": "^2.13.1",
"ts-loader": "5.3.3", "webpack-cli": "^2.1.5",
"tslint": "5.13.1", "webpack-dev-server": "^3.1.5",
"tslint-config-airbnb": "5.11.1", "webpack-plugin-replace": "^1.1.1",
"tslint-config-semistandard": "7.0.0", "classnames": "^2.2.6",
"tslint-react": "3.6.0", "comlink": "^3.0.3",
"typed-css-modules": "0.4.1", "linkstate": "^1.1.1",
"typescript": "3.3.3333", "preact": "^8.3.1",
"url-loader": "1.1.2", "pretty-bytes": "^5.1.0",
"webpack": "4.28.0", "worker-plugin": "^1.1.1"
"webpack-bundle-analyzer": "3.1.0",
"webpack-cli": "3.2.3",
"webpack-dev-server": "3.2.1",
"worker-plugin": "3.1.0"
} }
} }

View File

@@ -1,5 +0,0 @@
{
"extends": [
"config:base"
]
}

View File

@@ -1,8 +1,9 @@
import { nativeDecode, sniffMimeType, canDecodeImage } from '../lib/util'; import { nativeDecode, sniffMimeType, canDecodeImage } from '../lib/util';
import Processor from './processor'; import Processor from './processor';
import webpDataUrl from 'url-loader!./tiny.webp';
const nativeWebPSupported = canDecodeImage(webpDataUrl); // tslint:disable-next-line:max-line-length Its a data URL. Whatcha gonna do?
const webpFile = 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=';
const nativeWebPSupported = canDecodeImage(webpFile);
export async function decodeImage(blob: Blob, processor: Processor): Promise<ImageData> { export async function decodeImage(blob: Blob, processor: Processor): Promise<ImageData> {
const mimeType = await sniffMimeType(blob); const mimeType = await sniffMimeType(blob);

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber, konami, preventDefault } from '../../lib/util'; import { inputFieldValueAsNumber, konami } from '../../lib/util';
import { QuantizeOptions } from './processor-meta'; import { QuantizeOptions } from './processor-meta';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
import Expander from '../../components/expander'; import Expander from '../../components/expander';
@@ -42,7 +42,7 @@ export default class QuantizerOptions extends Component<Props, State> {
render({ options }: Props, { extendedSettings }: State) { render({ options }: Props, { extendedSettings }: State) {
return ( return (
<form class={style.optionsSection} onSubmit={preventDefault}> <form class={style.optionsSection}>
<Expander> <Expander>
{extendedSettings ? {extendedSettings ?
<label class={style.optionTextFirst}> <label class={style.optionTextFirst}>

View File

@@ -1,12 +1,12 @@
import imagequant, { QuantizerModule } from '../../../codecs/imagequant/imagequant'; import imagequant, { QuantizerModule } from '../../../codecs/imagequant/imagequant';
import wasmUrl from '../../../codecs/imagequant/imagequant.wasm'; import wasmUrl from '../../../codecs/imagequant/imagequant.wasm';
import { QuantizeOptions } from './processor-meta'; import { QuantizeOptions } from './processor-meta';
import { initEmscriptenModule } from '../util'; import { initWasmModule } from '../util';
let emscriptenModule: Promise<QuantizerModule>; let emscriptenModule: Promise<QuantizerModule>;
export async function process(data: ImageData, opts: QuantizeOptions): Promise<ImageData> { export async function process(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
if (!emscriptenModule) emscriptenModule = initEmscriptenModule(imagequant, wasmUrl); if (!emscriptenModule) emscriptenModule = initWasmModule(imagequant, wasmUrl);
const module = await emscriptenModule; const module = await emscriptenModule;

View File

@@ -1,9 +0,0 @@
import { defaultOptions as rotateDefaultOptions } from './rotate/processor-meta';
export interface InputProcessorState {
rotate: import('./rotate/processor-meta').RotateOptions;
}
export const defaultInputProcessorState: InputProcessorState = {
rotate: rotateDefaultOptions,
};

View File

@@ -1,12 +1,12 @@
import mozjpeg_enc, { MozJPEGModule } from '../../../codecs/mozjpeg_enc/mozjpeg_enc'; import mozjpeg_enc, { MozJPEGModule } from '../../../codecs/mozjpeg_enc/mozjpeg_enc';
import wasmUrl from '../../../codecs/mozjpeg_enc/mozjpeg_enc.wasm'; import wasmUrl from '../../../codecs/mozjpeg_enc/mozjpeg_enc.wasm';
import { EncodeOptions } from './encoder-meta'; import { EncodeOptions } from './encoder-meta';
import { initEmscriptenModule } from '../util'; import { initWasmModule } from '../util';
let emscriptenModule: Promise<MozJPEGModule>; let emscriptenModule: Promise<MozJPEGModule>;
export async function encode(data: ImageData, options: EncodeOptions): Promise<ArrayBuffer> { export async function encode(data: ImageData, options: EncodeOptions): Promise<ArrayBuffer> {
if (!emscriptenModule) emscriptenModule = initEmscriptenModule(mozjpeg_enc, wasmUrl); if (!emscriptenModule) emscriptenModule = initWasmModule(mozjpeg_enc, wasmUrl);
const module = await emscriptenModule; const module = await emscriptenModule;
const resultView = module.encode(data.data, data.width, data.height, options); const resultView = module.encode(data.data, data.width, data.height, options);

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldChecked, inputFieldValueAsNumber, preventDefault } from '../../lib/util'; import { inputFieldChecked, inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions, MozJpegColorSpace } from './encoder-meta'; import { EncodeOptions, MozJpegColorSpace } from './encoder-meta';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox'; import Checkbox from '../../components/checkbox';
@@ -58,7 +58,7 @@ export default class MozJPEGEncoderOptions extends Component<Props, State> {
// I'm rendering both lossy and lossless forms, as it becomes much easier when // I'm rendering both lossy and lossless forms, as it becomes much easier when
// gathering the data. // gathering the data.
return ( return (
<form class={style.optionsSection} onSubmit={preventDefault}> <form class={style.optionsSection}>
<div class={style.optionOneCell}> <div class={style.optionOneCell}>
<Range <Range
name="quality" name="quality"

View File

@@ -1,12 +1,12 @@
import optipng, { OptiPngModule } from '../../../codecs/optipng/optipng'; import optipng, { OptiPngModule } from '../../../codecs/optipng/optipng';
import wasmUrl from '../../../codecs/optipng/optipng.wasm'; import wasmUrl from '../../../codecs/optipng/optipng.wasm';
import { EncodeOptions } from './encoder-meta'; import { EncodeOptions } from './encoder-meta';
import { initEmscriptenModule } from '../util'; import { initWasmModule } from '../util';
let emscriptenModule: Promise<OptiPngModule>; let emscriptenModule: Promise<OptiPngModule>;
export async function compress(data: BufferSource, options: EncodeOptions): Promise<ArrayBuffer> { export async function compress(data: BufferSource, options: EncodeOptions): Promise<ArrayBuffer> {
if (!emscriptenModule) emscriptenModule = initEmscriptenModule(optipng, wasmUrl); if (!emscriptenModule) emscriptenModule = initWasmModule(optipng, wasmUrl);
const module = await emscriptenModule; const module = await emscriptenModule;
const resultView = module.compress(data, options); const resultView = module.compress(data, options);

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldValueAsNumber, preventDefault } from '../../lib/util'; import { inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions } from './encoder-meta'; import { EncodeOptions } from './encoder-meta';
import Range from '../../components/range'; import Range from '../../components/range';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
@@ -23,7 +23,7 @@ export default class OptiPNGEncoderOptions extends Component<Props, {}> {
render({ options }: Props) { render({ options }: Props) {
return ( return (
<form class={style.optionsSection} onSubmit={preventDefault}> <form class={style.optionsSection}>
<div class={style.optionOneCell}> <div class={style.optionOneCell}>
<Range <Range
name="level" name="level"

View File

@@ -0,0 +1,41 @@
import { expose } from 'comlink';
import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta';
import { QuantizeOptions } from './imagequant/processor-meta';
import { EncodeOptions as OptiPNGEncoderOptions } from './optipng/encoder-meta';
import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta';
async function mozjpegEncode(
data: ImageData, options: MozJPEGEncoderOptions,
): Promise<ArrayBuffer> {
const { encode } = await import('./mozjpeg/encoder');
return encode(data, options);
}
async function quantize(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
const { process } = await import('./imagequant/processor');
return process(data, opts);
}
async function optiPngEncode(
data: BufferSource, options: OptiPNGEncoderOptions,
): Promise<ArrayBuffer> {
const { compress } = await import('./optipng/encoder');
return compress(data, options);
}
async function webpEncode(
data: ImageData, options: WebPEncoderOptions,
): Promise<ArrayBuffer> {
const { encode } = await import('./webp/encoder');
return encode(data, options);
}
async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import('./webp/decoder');
return decode(data);
}
const exports = { mozjpegEncode, quantize, optiPngEncode, webpEncode, webpDecode };
export type ProcessorWorkerApi = typeof exports;
expose(exports, self);

View File

@@ -1,69 +0,0 @@
import { expose } from 'comlink';
async function mozjpegEncode(
data: ImageData, options: import('../mozjpeg/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { encode } = await import(
/* webpackChunkName: "process-mozjpeg-enc" */
'../mozjpeg/encoder');
return encode(data, options);
}
async function quantize(
data: ImageData, opts: import('../imagequant/processor-meta').QuantizeOptions,
): Promise<ImageData> {
const { process } = await import(
/* webpackChunkName: "process-imagequant" */
'../imagequant/processor');
return process(data, opts);
}
async function rotate(
data: ImageData, opts: import('../rotate/processor-meta').RotateOptions,
): Promise<ImageData> {
const { rotate } = await import(
/* webpackChunkName: "process-rotate" */
'../rotate/processor');
return rotate(data, opts);
}
async function resize(
data: ImageData, opts: import('../resize/processor-meta').WorkerResizeOptions,
): Promise<ImageData> {
const { resize } = await import(
/* webpackChunkName: "process-resize" */
'../resize/processor');
return resize(data, opts);
}
async function optiPngEncode(
data: BufferSource, options: import('../optipng/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { compress } = await import(
/* webpackChunkName: "process-optipng" */
'../optipng/encoder');
return compress(data, options);
}
async function webpEncode(
data: ImageData, options: import('../webp/encoder-meta').EncodeOptions,
): Promise<ArrayBuffer> {
const { encode } = await import(
/* webpackChunkName: "process-webp-enc" */
'../webp/encoder');
return encode(data, options);
}
async function webpDecode(data: ArrayBuffer): Promise<ImageData> {
const { decode } = await import(
/* webpackChunkName: "process-webp-dec" */
'../webp/decoder');
return decode(data);
}
const exports = { mozjpegEncode, quantize, rotate, resize, optiPngEncode, webpEncode, webpDecode };
export type ProcessorWorkerApi = typeof exports;
expose(exports, self);

View File

@@ -1,18 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"target": "esnext",
"module": "esnext",
"lib": [
"webworker",
"esnext"
],
"moduleResolution": "node",
"experimentalDecorators": true,
"noUnusedLocals": true,
"sourceMap": true,
"allowJs": false,
"baseUrl": "."
}
}

View File

@@ -1,13 +1,14 @@
import { proxy } from 'comlink'; import { proxy } from 'comlink';
import { QuantizeOptions } from './imagequant/processor-meta'; import { QuantizeOptions } from './imagequant/processor-meta';
import { ProcessorWorkerApi } from './processor-worker';
import { canvasEncode, blobToArrayBuffer } from '../lib/util'; import { canvasEncode, blobToArrayBuffer } from '../lib/util';
import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta'; import { EncodeOptions as MozJPEGEncoderOptions } from './mozjpeg/encoder-meta';
import { EncodeOptions as OptiPNGEncoderOptions } from './optipng/encoder-meta'; import { EncodeOptions as OptiPNGEncoderOptions } from './optipng/encoder-meta';
import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta'; import { EncodeOptions as WebPEncoderOptions } from './webp/encoder-meta';
import { EncodeOptions as BrowserJPEGOptions } from './browser-jpeg/encoder-meta'; import { EncodeOptions as BrowserJPEGOptions } from './browser-jpeg/encoder-meta';
import { EncodeOptions as BrowserWebpEncodeOptions } from './browser-webp/encoder-meta'; import { EncodeOptions as BrowserWebpEncodeOptions } from './browser-webp/encoder-meta';
import { BrowserResizeOptions, VectorResizeOptions } from './resize/processor-meta'; import { BitmapResizeOptions, VectorResizeOptions } from './resize/processor-meta';
import { browserResize, vectorResize } from './resize/processor-sync'; import { resize, vectorResize } from './resize/processor';
import * as browserBMP from './browser-bmp/encoder'; import * as browserBMP from './browser-bmp/encoder';
import * as browserPNG from './browser-png/encoder'; import * as browserPNG from './browser-png/encoder';
import * as browserJPEG from './browser-jpeg/encoder'; import * as browserJPEG from './browser-jpeg/encoder';
@@ -17,10 +18,8 @@ import * as browserTIFF from './browser-tiff/encoder';
import * as browserJP2 from './browser-jp2/encoder'; import * as browserJP2 from './browser-jp2/encoder';
import * as browserPDF from './browser-pdf/encoder'; import * as browserPDF from './browser-pdf/encoder';
type ProcessorWorkerApi = import('./processor-worker').ProcessorWorkerApi;
/** How long the worker should be idle before terminating. */ /** How long the worker should be idle before terminating. */
const workerTimeout = 10000; const workerTimeout = 1000;
interface ProcessingJobOptions { interface ProcessingJobOptions {
needsWorker?: boolean; needsWorker?: boolean;
@@ -62,10 +61,7 @@ export default class Processor {
// worker-loader does magic here. // worker-loader does magic here.
// @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the // @ts-ignore - Typescript doesn't know about the 2nd param to new Worker, and the
// definition can't be overwritten. // definition can't be overwritten.
this._worker = new Worker( this._worker = new Worker('./processor-worker.ts', { type: 'module' }) as Worker;
'./processor-worker',
{ name: 'processor-worker', type: 'module' },
) as Worker;
// Need to do some TypeScript trickery to make the type match. // Need to do some TypeScript trickery to make the type match.
this._workerApi = proxy(this._worker) as any as ProcessorWorkerApi; this._workerApi = proxy(this._worker) as any as ProcessorWorkerApi;
} }
@@ -118,25 +114,12 @@ export default class Processor {
} }
// Off main thread jobs: // Off main thread jobs:
@Processor._processingJob({ needsWorker: true }) @Processor._processingJob({ needsWorker: true })
imageQuant(data: ImageData, opts: QuantizeOptions): Promise<ImageData> { imageQuant(data: ImageData, opts: QuantizeOptions): Promise<ImageData> {
return this._workerApi!.quantize(data, opts); return this._workerApi!.quantize(data, opts);
} }
@Processor._processingJob({ needsWorker: true })
rotate(
data: ImageData, opts: import('./rotate/processor-meta').RotateOptions,
): Promise<ImageData> {
return this._workerApi!.rotate(data, opts);
}
@Processor._processingJob({ needsWorker: true })
workerResize(
data: ImageData, opts: import('./resize/processor-meta').WorkerResizeOptions,
): Promise<ImageData> {
return this._workerApi!.resize(data, opts);
}
@Processor._processingJob({ needsWorker: true }) @Processor._processingJob({ needsWorker: true })
mozjpegEncode( mozjpegEncode(
data: ImageData, opts: MozJPEGEncoderOptions, data: ImageData, opts: MozJPEGEncoderOptions,
@@ -209,9 +192,9 @@ export default class Processor {
// Synchronous jobs // Synchronous jobs
resize(data: ImageData, opts: BrowserResizeOptions) { resize(data: ImageData, opts: BitmapResizeOptions) {
this.abortCurrent(); this.abortCurrent();
return browserResize(data, opts); return resize(data, opts);
} }
vectorResize(data: HTMLImageElement, opts: VectorResizeOptions) { vectorResize(data: HTMLImageElement, opts: VectorResizeOptions) {

View File

@@ -1,7 +1,7 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import linkState from 'linkstate'; import linkState from 'linkstate';
import { bind, linkRef } from '../../lib/initial-util'; import { bind, linkRef } from '../../lib/initial-util';
import { inputFieldValueAsNumber, inputFieldValue, preventDefault } from '../../lib/util'; import { inputFieldValueAsNumber, inputFieldValue } from '../../lib/util';
import { ResizeOptions } from './processor-meta'; import { ResizeOptions } from './processor-meta';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox'; import Checkbox from '../../components/checkbox';
@@ -78,7 +78,7 @@ export default class ResizerOptions extends Component<Props, State> {
render({ options, isVector }: Props, { maintainAspect }: State) { render({ options, isVector }: Props, { maintainAspect }: State) {
return ( return (
<form ref={linkRef(this, 'form')} class={style.optionsSection} onSubmit={preventDefault}> <form ref={linkRef(this, 'form')} class={style.optionsSection}>
<label class={style.optionTextFirst}> <label class={style.optionTextFirst}>
Method: Method:
<Select <Select
@@ -87,10 +87,6 @@ export default class ResizerOptions extends Component<Props, State> {
onChange={this.onChange} onChange={this.onChange}
> >
{isVector && <option value="vector">Vector</option>} {isVector && <option value="vector">Vector</option>}
<option value="lanczos3">Lanczos3</option>
<option value="mitchell">Mitchell</option>
<option value="catrom">Catmull-Rom</option>
<option value="triangle">Triangle</option>
<option value="browser-pixelated">Browser pixelated</option> <option value="browser-pixelated">Browser pixelated</option>
<option value="browser-low">Browser low quality</option> <option value="browser-low">Browser low quality</option>
<option value="browser-medium">Browser medium quality</option> <option value="browser-medium">Browser medium quality</option>
@@ -139,7 +135,7 @@ export default class ResizerOptions extends Component<Props, State> {
onChange={this.onChange} onChange={this.onChange}
> >
<option value="stretch">Stretch</option> <option value="stretch">Stretch</option>
<option value="contain">Contain</option> <option value="cover">Cover</option>
</Select> </Select>
</label> </label>
} }

View File

@@ -1,19 +1,14 @@
type BrowserResizeMethods = 'browser-pixelated' | 'browser-low' | 'browser-medium' | 'browser-high'; type BitmapResizeMethods = 'browser-pixelated' | 'browser-low' | 'browser-medium' | 'browser-high';
type WorkerResizeMethods = 'point' | 'triangle' | 'catrom' | 'mitchell' | 'lanczos3';
export interface ResizeOptions { export interface ResizeOptions {
width: number; width: number;
height: number; height: number;
method: 'vector' | BrowserResizeMethods | WorkerResizeMethods; method: 'vector' | BitmapResizeMethods;
fitMethod: 'stretch' | 'contain'; fitMethod: 'stretch' | 'cover';
} }
export interface BrowserResizeOptions extends ResizeOptions { export interface BitmapResizeOptions extends ResizeOptions {
method: BrowserResizeMethods; method: BitmapResizeMethods;
}
export interface WorkerResizeOptions extends ResizeOptions {
method: WorkerResizeMethods;
} }
export interface VectorResizeOptions extends ResizeOptions { export interface VectorResizeOptions extends ResizeOptions {
@@ -26,6 +21,6 @@ export const defaultOptions: ResizeOptions = {
width: 1, width: 1,
height: 1, height: 1,
// This will be set to 'vector' if the input is SVG. // This will be set to 'vector' if the input is SVG.
method: 'lanczos3', method: 'browser-high',
fitMethod: 'stretch', fitMethod: 'stretch',
}; };

View File

@@ -1,35 +0,0 @@
import { nativeResize, NativeResizeMethod, drawableToImageData } from '../../lib/util';
import { BrowserResizeOptions, VectorResizeOptions } from './processor-meta';
import { getContainOffsets } from './util';
export function browserResize(data: ImageData, opts: BrowserResizeOptions): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;
if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
}
return nativeResize(
data, sx, sy, sw, sh, opts.width, opts.height,
opts.method.slice('browser-'.length) as NativeResizeMethod,
);
}
export function vectorResize(data: HTMLImageElement, opts: VectorResizeOptions): ImageData {
let sx = 0;
let sy = 0;
let sw = data.width;
let sh = data.height;
if (opts.fitMethod === 'contain') {
({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height));
}
return drawableToImageData(data, {
sx, sy, sw, sh,
width: opts.width, height: opts.height,
});
}

View File

@@ -1,52 +1,49 @@
import wasmUrl from '../../../codecs/resize/pkg/resize_bg.wasm'; import { nativeResize, NativeResizeMethod, drawableToImageData } from '../../lib/util';
import '../../../codecs/resize/pkg/resize'; import { BitmapResizeOptions, VectorResizeOptions } from './processor-meta';
import { WorkerResizeOptions } from './processor-meta';
import { getContainOffsets } from './util';
interface WasmBindgenExports { function getCoverOffsets(sw: number, sh: number, dw: number, dh: number) {
resize: typeof import('../../../codecs/resize/pkg/resize').resize; const currentAspect = sw / sh;
const endAspect = dw / dh;
if (endAspect > currentAspect) {
const newSh = sw / endAspect;
const newSy = (sh - newSh) / 2;
return { sw, sh: newSh, sx: 0, sy: newSy };
} }
type WasmBindgen = ((url: string) => Promise<void>) & WasmBindgenExports; const newSw = sh * endAspect;
const newSx = (sw - newSw) / 2;
declare var wasm_bindgen: WasmBindgen; return { sh, sw: newSw, sx: newSx, sy: 0 };
const ready = wasm_bindgen(wasmUrl);
function crop(data: ImageData, sx: number, sy: number, sw: number, sh: number): ImageData {
const inputPixels = new Uint32Array(data.data.buffer);
// Copy within the same buffer for speed and memory efficiency.
for (let y = 0; y < sh; y += 1) {
const start = ((y + sy) * data.width) + sx;
inputPixels.copyWithin(y * sw, start, start + sw);
} }
return new ImageData( export function resize(data: ImageData, opts: BitmapResizeOptions): ImageData {
new Uint8ClampedArray(inputPixels.buffer.slice(0, sw * sh * 4)), let sx = 0;
sw, sh, let sy = 0;
let sw = data.width;
let sh = data.height;
if (opts.fitMethod === 'cover') {
({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height));
}
return nativeResize(
data, sx, sy, sw, sh, opts.width, opts.height,
opts.method.slice('browser-'.length) as NativeResizeMethod,
); );
} }
/** Resize methods by index */ export function vectorResize(data: HTMLImageElement, opts: VectorResizeOptions): ImageData {
const resizeMethods: WorkerResizeOptions['method'][] = [ let sx = 0;
'triangle', 'catrom', 'mitchell', 'lanczos3', let sy = 0;
]; let sw = data.width;
let sh = data.height;
export async function resize(data: ImageData, opts: WorkerResizeOptions): Promise<ImageData> { if (opts.fitMethod === 'cover') {
let input = data; ({ sx, sy, sw, sh } = getCoverOffsets(sw, sh, opts.width, opts.height));
if (opts.fitMethod === 'contain') {
const { sx, sy, sw, sh } = getContainOffsets(data.width, data.height, opts.width, opts.height);
input = crop(input, Math.round(sx), Math.round(sy), Math.round(sw), Math.round(sh));
} }
await ready; return drawableToImageData(data, {
sx, sy, sw, sh,
const result = wasm_bindgen.resize( width: opts.width, height: opts.height,
new Uint8Array(input.data.buffer), input.width, input.height, opts.width, opts.height, });
resizeMethods.indexOf(opts.method),
);
return new ImageData(new Uint8ClampedArray(result.buffer), opts.width, opts.height);
} }

View File

@@ -1,14 +0,0 @@
export function getContainOffsets(sw: number, sh: number, dw: number, dh: number) {
const currentAspect = sw / sh;
const endAspect = dw / dh;
if (endAspect > currentAspect) {
const newSh = sw / endAspect;
const newSy = (sh - newSh) / 2;
return { sw, sh: newSh, sx: 0, sy: newSy };
}
const newSw = sh * endAspect;
const newSx = (sw - newSw) / 2;
return { sh, sw: newSw, sx: newSx, sy: 0 };
}

View File

@@ -1,12 +0,0 @@
export interface RotateOptions {
rotate: 0 | 90 | 180 | 270;
}
export const defaultOptions: RotateOptions = { rotate: 0 };
export interface RotateModuleInstance {
exports: {
memory: WebAssembly.Memory;
rotate(width: number, height: number, rotate: 0 | 90 | 180 | 270): void;
};
}

View File

@@ -1,43 +0,0 @@
import wasmUrl from '../../../codecs/rotate/rotate.wasm';
import { RotateOptions, RotateModuleInstance } from './processor-meta';
// We are loading a 500B module here. Loading the code to feature-detect
// `instantiateStreaming` probably takes longer to load than the time we save by
// using `instantiateStreaming` in the first place. So lets just use
// `ArrayBuffer`s here.
const instancePromise = fetch(wasmUrl)
.then(r => r.arrayBuffer())
.then(buf => WebAssembly.instantiate(buf));
export async function rotate(
data: ImageData,
opts: RotateOptions,
): Promise<ImageData> {
const { instance } = (await instancePromise) as {
instance: RotateModuleInstance;
};
// Number of wasm memory pages (á 64KiB) needed to store the image twice.
const bytesPerImage = data.width * data.height * 4;
const numPagesNeeded = Math.ceil((bytesPerImage * 2 + 8) / (64 * 1024));
// Only count full pages, just to be safe.
const numPagesAvailable = Math.floor(
instance.exports.memory.buffer.byteLength / (64 * 1024),
);
const additionalPagesToAllocate = numPagesNeeded - numPagesAvailable;
if (additionalPagesToAllocate > 0) {
instance.exports.memory.grow(additionalPagesToAllocate);
}
const view = new Uint8ClampedArray(instance.exports.memory.buffer);
view.set(data.data, 8);
instance.exports.rotate(data.width, data.height, opts.rotate);
const flipDimensions = opts.rotate % 180 !== 0;
return new ImageData(
view.slice(bytesPerImage + 8, bytesPerImage * 2 + 8),
flipDimensions ? data.height : data.width,
flipDimensions ? data.width : data.height,
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 B

View File

@@ -2,7 +2,7 @@ type ModuleFactory<M extends EmscriptenWasm.Module> = (
opts: EmscriptenWasm.ModuleOpts, opts: EmscriptenWasm.ModuleOpts,
) => M; ) => M;
export function initEmscriptenModule<T extends EmscriptenWasm.Module>( export function initWasmModule<T extends EmscriptenWasm.Module>(
moduleFactory: ModuleFactory<T>, moduleFactory: ModuleFactory<T>,
wasmUrl: string, wasmUrl: string,
): Promise<T> { ): Promise<T> {

View File

@@ -1,11 +1,11 @@
import webp_dec, { WebPModule } from '../../../codecs/webp_dec/webp_dec'; import webp_dec, { WebPModule } from '../../../codecs/webp_dec/webp_dec';
import wasmUrl from '../../../codecs/webp_dec/webp_dec.wasm'; import wasmUrl from '../../../codecs/webp_dec/webp_dec.wasm';
import { initEmscriptenModule } from '../util'; import { initWasmModule } from '../util';
let emscriptenModule: Promise<WebPModule>; let emscriptenModule: Promise<WebPModule>;
export async function decode(data: ArrayBuffer): Promise<ImageData> { export async function decode(data: ArrayBuffer): Promise<ImageData> {
if (!emscriptenModule) emscriptenModule = initEmscriptenModule(webp_dec, wasmUrl); if (!emscriptenModule) emscriptenModule = initWasmModule(webp_dec, wasmUrl);
const module = await emscriptenModule; const module = await emscriptenModule;
const rawImage = module.decode(data); const rawImage = module.decode(data);

View File

@@ -1,12 +1,12 @@
import webp_enc, { WebPModule } from '../../../codecs/webp_enc/webp_enc'; import webp_enc, { WebPModule } from '../../../codecs/webp_enc/webp_enc';
import wasmUrl from '../../../codecs/webp_enc/webp_enc.wasm'; import wasmUrl from '../../../codecs/webp_enc/webp_enc.wasm';
import { EncodeOptions } from './encoder-meta'; import { EncodeOptions } from './encoder-meta';
import { initEmscriptenModule } from '../util'; import { initWasmModule } from '../util';
let emscriptenModule: Promise<WebPModule>; let emscriptenModule: Promise<WebPModule>;
export async function encode(data: ImageData, options: EncodeOptions): Promise<ArrayBuffer> { export async function encode(data: ImageData, options: EncodeOptions): Promise<ArrayBuffer> {
if (!emscriptenModule) emscriptenModule = initEmscriptenModule(webp_enc, wasmUrl); if (!emscriptenModule) emscriptenModule = initWasmModule(webp_enc, wasmUrl);
const module = await emscriptenModule; const module = await emscriptenModule;
const resultView = module.encode(data.data, data.width, data.height, options); const resultView = module.encode(data.data, data.width, data.height, options);

View File

@@ -1,6 +1,6 @@
import { h, Component } from 'preact'; import { h, Component } from 'preact';
import { bind } from '../../lib/initial-util'; import { bind } from '../../lib/initial-util';
import { inputFieldCheckedAsNumber, inputFieldValueAsNumber, preventDefault } from '../../lib/util'; import { inputFieldCheckedAsNumber, inputFieldValueAsNumber } from '../../lib/util';
import { EncodeOptions, WebPImageHint } from './encoder-meta'; import { EncodeOptions, WebPImageHint } from './encoder-meta';
import * as style from '../../components/Options/style.scss'; import * as style from '../../components/Options/style.scss';
import Checkbox from '../../components/checkbox'; import Checkbox from '../../components/checkbox';
@@ -319,7 +319,7 @@ export default class WebPEncoderOptions extends Component<Props, State> {
// I'm rendering both lossy and lossless forms, as it becomes much easier when // I'm rendering both lossy and lossless forms, as it becomes much easier when
// gathering the data. // gathering the data.
return ( return (
<form class={style.optionsSection} onSubmit={preventDefault}> <form class={style.optionsSection}>
<label class={style.optionInputFirst}> <label class={style.optionInputFirst}>
<Checkbox <Checkbox
name="lossless" name="lossless"

View File

@@ -0,0 +1,153 @@
import { bind } from '../../../../lib/initial-util';
import './styles.css';
// tslint:disable-next-line:max-line-length
function firstMatchingItem(list: DataTransferItemList, acceptVal: string): DataTransferItem | undefined {
// Split accepts values by ',' then by '/'. Trim everything & lowercase.
const accepts = acceptVal.toLowerCase().split(',').map((accept) => {
return accept.trim().split('/').map(part => part.trim());
}).filter(acceptParts => acceptParts.length === 2); // Filter invalid values
return Array.from(list).find((item) => {
if (item.kind !== 'file') return false;
// 'Parse' the type.
const [typeMain, typeSub] = item.type.toLowerCase().split('/').map(s => s.trim());
for (const [acceptMain, acceptSub] of accepts) {
// Look for an exact match, or a partial match if * is accepted, eg image/*.
if (typeMain === acceptMain && (acceptSub === '*' || typeSub === acceptSub)) {
return true;
}
}
return false;
});
}
function getFileData(data: DataTransfer, accept: string): File | undefined {
const dragDataItem = firstMatchingItem(data.items, accept);
if (!dragDataItem) return;
return dragDataItem.getAsFile() || undefined;
}
interface FileDropEventInit extends EventInit {
action: FileDropAccept;
file: File;
}
type FileDropAccept = 'drop' | 'paste';
// Safari and Edge don't quite support extending Event, this works around it.
function fixExtendedEvent(instance: Event, type: Function) {
if (!(instance instanceof type)) {
Object.setPrototypeOf(instance, type.prototype);
}
}
export class FileDropEvent extends Event {
private _action: FileDropAccept;
private _file: File;
constructor(typeArg: string, eventInitDict: FileDropEventInit) {
super(typeArg, eventInitDict);
fixExtendedEvent(this, FileDropEvent);
this._file = eventInitDict.file;
this._action = eventInitDict.action;
}
get action() {
return this._action;
}
get file() {
return this._file;
}
}
/*
Example Usage.
<file-drop
accept='image/*'
class='drop-valid|drop-invalid'
>
[everything in here is a drop target.]
</file-drop>
dropElement.addEventListner('dropfile', (event) => console.log(event.detail))
*/
export class FileDrop extends HTMLElement {
private _dragEnterCount = 0;
constructor() {
super();
this.addEventListener('dragover', event => event.preventDefault());
this.addEventListener('drop', this._onDrop);
this.addEventListener('dragenter', this._onDragEnter);
this.addEventListener('dragend', () => this._reset());
this.addEventListener('dragleave', this._onDragLeave);
this.addEventListener('paste', this._onPaste);
}
get accept() {
return this.getAttribute('accept') || '';
}
set accept(val: string) {
this.setAttribute('accept', val);
}
@bind
private _onDragEnter(event: DragEvent) {
this._dragEnterCount += 1;
if (this._dragEnterCount > 1) return;
// We don't have data, attempt to get it and if it matches, set the correct state.
const validDrop: boolean = event.dataTransfer.items.length ?
!!firstMatchingItem(event.dataTransfer.items, this.accept) :
// Safari doesn't give file information on drag enter, so the best we can do is return valid.
true;
if (validDrop) {
this.classList.add('drop-valid');
} else {
this.classList.add('drop-invalid');
}
}
@bind
private _onDragLeave() {
this._dragEnterCount -= 1;
if (this._dragEnterCount === 0) {
this._reset();
}
}
@bind
private _onDrop(event: DragEvent) {
event.preventDefault();
this._reset();
const action = 'drop';
const file = getFileData(event.dataTransfer, this.accept);
if (file === undefined) return;
this.dispatchEvent(new FileDropEvent('filedrop', { action, file }));
}
@bind
private _onPaste(event: ClipboardEvent) {
const action = 'paste';
const file = getFileData(event.clipboardData, this.accept);
if (file === undefined) return;
this.dispatchEvent(new FileDropEvent('filedrop', { action, file }));
}
private _reset() {
this._dragEnterCount = 0;
this.classList.remove('drop-valid');
this.classList.remove('drop-invalid');
}
}
customElements.define('file-drop', FileDrop);

View File

@@ -0,0 +1,19 @@
import { FileDropEvent, FileDrop } from '.';
declare global {
interface HTMLElementEventMap {
'filedrop': FileDropEvent;
}
namespace JSX {
interface IntrinsicElements {
'file-drop': FileDropAttributes;
}
interface FileDropAttributes extends HTMLAttributes {
accept?: string;
onfiledrop?: ((this: FileDrop, ev: FileDropEvent) => any) | null;
}
}
}

View File

@@ -0,0 +1,3 @@
file-drop {
display: block;
}

View File

@@ -2,37 +2,31 @@ import { h, Component } from 'preact';
import { bind, linkRef, Fileish } from '../../lib/initial-util'; import { bind, linkRef, Fileish } from '../../lib/initial-util';
import * as style from './style.scss'; import * as style from './style.scss';
import { FileDropEvent } from 'file-drop-element'; import { FileDropEvent } from './custom-els/FileDrop';
import 'file-drop-element'; import './custom-els/FileDrop';
import SnackBarElement, { SnackOptions } from '../../lib/SnackBar'; import SnackBarElement, { SnackOptions } from '../../lib/SnackBar';
import '../../lib/SnackBar'; import '../../lib/SnackBar';
import Intro from '../intro'; import Intro from '../intro';
import '../custom-els/LoadingSpinner'; import '../custom-els/LoadingSpinner';
const ROUTE_EDITOR = '/editor'; // This is imported for TypeScript only. It isn't used.
import Compress from '../compress';
const compressPromise = import( export interface SourceImage {
/* webpackChunkName: "main-app" */ file: File | Fileish;
'../compress'); data: ImageData;
const offlinerPromise = import( vectorImage?: HTMLImageElement;
/* webpackChunkName: "offliner" */
'../../lib/offliner');
function back() {
window.history.back();
} }
interface Props {} interface Props {}
interface State { interface State {
file?: File | Fileish; file?: File | Fileish;
isEditorOpen: Boolean; Compress?: typeof Compress;
Compress?: typeof import('../compress').default;
} }
export default class App extends Component<Props, State> { export default class App extends Component<Props, State> {
state: State = { state: State = {
isEditorOpen: false,
file: undefined, file: undefined,
Compress: undefined, Compress: undefined,
}; };
@@ -42,46 +36,32 @@ export default class App extends Component<Props, State> {
constructor() { constructor() {
super(); super();
compressPromise.then((module) => { import('../compress').then((module) => {
this.setState({ Compress: module.default }); this.setState({ Compress: module.default });
}).catch(() => { }).catch(() => {
this.showSnack('Failed to load app'); this.showSnack('Failed to load app');
}); });
offlinerPromise.then(({ offliner }) => offliner(this.showSnack));
// In development, persist application state across hot reloads: // In development, persist application state across hot reloads:
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
this.setState(window.STATE); this.setState(window.STATE);
const oldCDU = this.componentDidUpdate; const oldCDU = this.componentDidUpdate;
this.componentDidUpdate = (props, state, prev) => { this.componentDidUpdate = (props, state) => {
if (oldCDU) oldCDU.call(this, props, state, prev); if (oldCDU) oldCDU.call(this, props, state);
window.STATE = this.state; window.STATE = this.state;
}; };
} }
// 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
// zoom the image. Once you've done this, it's really difficult to undo. Anyway, this seems to
// prevent it.
document.body.addEventListener('gesturestart', (event) => {
event.preventDefault();
});
window.addEventListener('popstate', this.onPopState);
} }
@bind @bind
private onFileDrop({ files }: FileDropEvent) { private onFileDrop(event: FileDropEvent) {
if (!files || files.length === 0) return; const { file } = event;
const file = files[0]; if (!file) return;
this.openEditor();
this.setState({ file }); this.setState({ file });
} }
@bind @bind
private onIntroPickFile(file: File | Fileish) { private onIntroPickFile(file: File | Fileish) {
this.openEditor();
this.setState({ file }); this.setState({ file });
} }
@@ -91,26 +71,14 @@ export default class App extends Component<Props, State> {
return this.snackbar.showSnackbar(message, options); return this.snackbar.showSnackbar(message, options);
} }
@bind render({}: Props, { file, Compress }: State) {
private onPopState() {
this.setState({ isEditorOpen: location.pathname === ROUTE_EDITOR });
}
@bind
private openEditor() {
if (this.state.isEditorOpen) return;
history.pushState(null, '', ROUTE_EDITOR);
this.setState({ isEditorOpen: true });
}
render({}: Props, { file, isEditorOpen, Compress }: State) {
return ( return (
<div id="app" class={style.app}> <div id="app" class={style.app}>
<file-drop accept="image/*" onfiledrop={this.onFileDrop} class={style.drop}> <file-drop accept="image/*" onfiledrop={this.onFileDrop} class={style.drop}>
{!isEditorOpen {(!file)
? <Intro onFile={this.onIntroPickFile} showSnack={this.showSnack} /> ? <Intro onFile={this.onIntroPickFile} showSnack={this.showSnack} />
: (Compress) : (Compress)
? <Compress file={file!} showSnack={this.showSnack} onBack={back} /> ? <Compress file={file} showSnack={this.showSnack} />
: <loading-spinner class={style.appLoader}/> : <loading-spinner class={style.appLoader}/>
} }
<snack-bar ref={linkRef(this, 'snackbar')} /> <snack-bar ref={linkRef(this, 'snackbar')} />

View File

@@ -35,14 +35,12 @@ import {
import { QuantizeOptions } from '../../codecs/imagequant/processor-meta'; import { QuantizeOptions } from '../../codecs/imagequant/processor-meta';
import { ResizeOptions } from '../../codecs/resize/processor-meta'; import { ResizeOptions } from '../../codecs/resize/processor-meta';
import { PreprocessorState } from '../../codecs/preprocessors'; import { PreprocessorState } from '../../codecs/preprocessors';
import { SourceImage } from '../compress'; import { SourceImage } from '../App';
import Checkbox from '../checkbox'; import Checkbox from '../checkbox';
import Expander from '../expander'; import Expander from '../expander';
import Select from '../select'; import Select from '../select';
const encoderOptionsComponentMap: { const encoderOptionsComponentMap = {
[x: string]: (new (...args: any[]) => Component<any, any>) | undefined;
} = {
[identity.type]: undefined, [identity.type]: undefined,
[optiPNG.type]: OptiPNGEncoderOptions, [optiPNG.type]: OptiPNGEncoderOptions,
[mozJPEG.type]: MozJpegEncoderOptions, [mozJPEG.type]: MozJpegEncoderOptions,
@@ -83,7 +81,7 @@ export default class Options extends Component<Props, State> {
} }
@bind @bind
private onEncoderTypeChange(event: Event) { onEncoderTypeChange(event: Event) {
const el = event.currentTarget as HTMLSelectElement; const el = event.currentTarget as HTMLSelectElement;
// The select element only has values matching encoder types, // The select element only has values matching encoder types,
@@ -93,7 +91,7 @@ export default class Options extends Component<Props, State> {
} }
@bind @bind
private onPreprocessorEnabledChange(event: Event) { onPreprocessorEnabledChange(event: Event) {
const el = event.currentTarget as HTMLInputElement; const el = event.currentTarget as HTMLInputElement;
const preprocessor = el.name.split('.')[0] as keyof PreprocessorState; const preprocessor = el.name.split('.')[0] as keyof PreprocessorState;
@@ -103,14 +101,14 @@ export default class Options extends Component<Props, State> {
} }
@bind @bind
private onQuantizerOptionsChange(opts: QuantizeOptions) { onQuantizerOptionsChange(opts: QuantizeOptions) {
this.props.onPreprocessorOptionsChange( this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'quantizer', opts), cleanMerge(this.props.preprocessorState, 'quantizer', opts),
); );
} }
@bind @bind
private onResizeOptionsChange(opts: ResizeOptions) { onResizeOptionsChange(opts: ResizeOptions) {
this.props.onPreprocessorOptionsChange( this.props.onPreprocessorOptionsChange(
cleanMerge(this.props.preprocessorState, 'resize', opts), cleanMerge(this.props.preprocessorState, 'resize', opts),
); );
@@ -146,7 +144,7 @@ export default class Options extends Component<Props, State> {
{preprocessorState.resize.enabled ? {preprocessorState.resize.enabled ?
<ResizeOptionsComponent <ResizeOptionsComponent
isVector={Boolean(source && source.vectorImage)} isVector={Boolean(source && source.vectorImage)}
aspect={source ? source.processed.width / source.processed.height : 1} aspect={source ? (source.data.width / source.data.height) : 1}
options={preprocessorState.resize} options={preprocessorState.resize}
onChange={this.onResizeOptionsChange} onChange={this.onResizeOptionsChange}
/> />

View File

@@ -43,7 +43,6 @@ $horizontalPadding: 15px;
.text-field { .text-field {
background: #fff; background: #fff;
color: #000;
font: inherit; font: inherit;
border: none; border: none;
padding: 2px 0 2px 10px; padding: 2px 0 2px 10px;
@@ -55,5 +54,4 @@ $horizontalPadding: 15px;
.options-scroller { .options-scroller {
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch;
} }

View File

@@ -1,5 +1,5 @@
import PointerTracker, { Pointer } from 'pointer-tracker';
import './styles.css'; import './styles.css';
import { PointerTracker, Pointer } from '../../../../lib/PointerTracker';
interface Point { interface Point {
clientX: number; clientX: number;
@@ -242,7 +242,7 @@ export default class PinchZoom extends HTMLElement {
/** /**
* Update transform values without checking bounds. This is only called in setTransform. * Update transform values without checking bounds. This is only called in setTransform.
*/ */
private _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) { _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) {
// Avoid scaling to zero // Avoid scaling to zero
if (scale < MIN_SCALE) return; if (scale < MIN_SCALE) return;

View File

@@ -1,5 +1,5 @@
import PointerTracker, { Pointer } from 'pointer-tracker';
import * as styles from './styles.css'; import * as styles from './styles.css';
import { PointerTracker, Pointer } from '../../../../lib/PointerTracker';
const legacyClipCompatAttr = 'legacy-clip-compat'; const legacyClipCompatAttr = 'legacy-clip-compat';
const orientationAttr = 'orientation'; const orientationAttr = 'orientation';
@@ -70,13 +70,12 @@ export default class TwoUp extends HTMLElement {
connectedCallback() { connectedCallback() {
this._childrenChange(); this._childrenChange();
if (!this._everConnected) {
this._handle.innerHTML = `<div class="${styles.scrubber}">${ this._handle.innerHTML = `<div class="${styles.scrubber}">${
`<svg viewBox="0 0 27 20" fill="currentColor">${ `<svg viewBox="0 0 27 20" fill="currentColor">${
'<path d="M17 19.2l9.5-9.6L16.9 0zM9.6 0L0 9.6l9.6 9.6z"/>' '<path d="M17 19.2l9.5-9.6L16.9 0zM9.6 0L0 9.6l9.6 9.6z"/>'
}</svg>` }</svg>`
}</div>`; }</div>`;
if (!this._everConnected) {
this._resetPosition(); this._resetPosition();
this._everConnected = true; this._everConnected = true;
} }

View File

@@ -5,29 +5,16 @@ import './custom-els/TwoUp';
import * as style from './style.scss'; import * as style from './style.scss';
import { bind, linkRef } from '../../lib/initial-util'; import { bind, linkRef } from '../../lib/initial-util';
import { shallowEqual, drawDataToCanvas } from '../../lib/util'; import { shallowEqual, drawDataToCanvas } from '../../lib/util';
import { import { ToggleIcon, AddIcon, RemoveIcon } from '../../lib/icons';
ToggleBackgroundIcon,
AddIcon,
RemoveIcon,
BackIcon,
ToggleBackgroundActiveIcon,
RotateIcon,
} from '../../lib/icons';
import { twoUpHandle } from './custom-els/TwoUp/styles.css'; import { twoUpHandle } from './custom-els/TwoUp/styles.css';
import { InputProcessorState } from '../../codecs/input-processors';
import { cleanSet } from '../../lib/clean-modify';
import { SourceImage } from '../compress';
interface Props { interface Props {
source?: SourceImage; originalImage?: ImageData;
inputProcessorState?: InputProcessorState;
mobileView: boolean; mobileView: boolean;
leftCompressed?: ImageData; leftCompressed?: ImageData;
rightCompressed?: ImageData; rightCompressed?: ImageData;
leftImgContain: boolean; leftImgContain: boolean;
rightImgContain: boolean; rightImgContain: boolean;
onBack: () => void;
onInputProcessorChange: (newState: InputProcessorState) => void;
} }
interface State { interface State {
@@ -60,15 +47,6 @@ export default class Output extends Component<Props, State> {
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); const rightDraw = this.rightDrawable();
// Reset the pinch zoom, which may have an position set from the previous view, after pressing
// the back button.
this.pinchZoomLeft!.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
if (this.canvasLeft && leftDraw) { if (this.canvasLeft && leftDraw) {
drawDataToCanvas(this.canvasLeft, leftDraw); drawDataToCanvas(this.canvasLeft, leftDraw);
} }
@@ -82,38 +60,6 @@ export default class Output extends Component<Props, State> {
const prevRightDraw = this.rightDrawable(prevProps); const prevRightDraw = this.rightDrawable(prevProps);
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); const rightDraw = this.rightDrawable();
const sourceFileChanged =
// Has the value become (un)defined?
(!!this.props.source !== !!prevProps.source) ||
// Or has the file changed?
(this.props.source && prevProps.source && this.props.source.file !== prevProps.source.file);
const oldSourceData = prevProps.source && prevProps.source.processed;
const newSourceData = this.props.source && this.props.source.processed;
const pinchZoom = this.pinchZoomLeft!;
if (sourceFileChanged) {
// New image? Reset the pinch-zoom.
pinchZoom.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
} else if (oldSourceData && newSourceData && oldSourceData !== newSourceData) {
// Since the pinch zoom transform origin is the top-left of the content, we need to flip
// things around a bit when the content size changes, so the new content appears as if it were
// central to the previous content.
const scaleChange = 1 - pinchZoom.scale;
const oldXScaleOffset = oldSourceData.width / 2 * scaleChange;
const oldYScaleOffset = oldSourceData.height / 2 * scaleChange;
pinchZoom.setTransform({
allowChangeEvent: true,
x: pinchZoom.x - oldXScaleOffset + oldYScaleOffset,
y: pinchZoom.y - oldYScaleOffset + oldXScaleOffset,
});
}
if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) { if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) {
drawDataToCanvas(this.canvasLeft, leftDraw); drawDataToCanvas(this.canvasLeft, leftDraw);
@@ -121,6 +67,16 @@ export default class Output extends Component<Props, State> {
if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) { if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) {
drawDataToCanvas(this.canvasRight, rightDraw); drawDataToCanvas(this.canvasRight, rightDraw);
} }
if (this.props.originalImage !== prevProps.originalImage && this.pinchZoomLeft) {
// New image? Reset the pinch-zoom.
this.pinchZoomLeft.setTransform({
allowChangeEvent: true,
x: 0,
y: 0,
scale: 1,
});
}
} }
shouldComponentUpdate(nextProps: Props, nextState: State) { shouldComponentUpdate(nextProps: Props, nextState: State) {
@@ -128,11 +84,11 @@ export default class Output extends Component<Props, State> {
} }
private leftDrawable(props: Props = this.props): ImageData | undefined { private leftDrawable(props: Props = this.props): ImageData | undefined {
return props.leftCompressed || (props.source && props.source.processed); return props.leftCompressed || props.originalImage;
} }
private rightDrawable(props: Props = this.props): ImageData | undefined { private rightDrawable(props: Props = this.props): ImageData | undefined {
return props.rightCompressed || (props.source && props.source.processed); return props.rightCompressed || props.originalImage;
} }
@bind @bind
@@ -156,20 +112,6 @@ export default class Output extends Component<Props, State> {
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts); this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
} }
@bind
private onRotateClick() {
const { inputProcessorState } = this.props;
if (!inputProcessorState) return;
const newState = cleanSet(
inputProcessorState,
'rotate.rotate',
(inputProcessorState.rotate.rotate + 90) % 360,
);
this.props.onInputProcessorChange(newState);
}
@bind @bind
private onScaleValueFocus() { private onScaleValueFocus() {
this.setState({ editingScale: true }, () => { this.setState({ editingScale: true }, () => {
@@ -235,27 +177,14 @@ export default class Output extends Component<Props, State> {
const clonedEvent = new (event.constructor as typeof Event)(event.type, event); const clonedEvent = new (event.constructor as typeof Event)(event.type, event);
this.retargetedEvents.add(clonedEvent); this.retargetedEvents.add(clonedEvent);
this.pinchZoomLeft.dispatchEvent(clonedEvent); this.pinchZoomLeft.dispatchEvent(clonedEvent);
// Unfocus any active element on touchend. This fixes an issue on (at least) Android Chrome,
// where the software keyboard is hidden, but the input remains focused, then after interaction
// with this element the keyboard reappears for NO GOOD REASON. Thanks Android.
if (
event.type === 'touchend' &&
document.activeElement &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
} }
render( render(
{ mobileView, leftImgContain, rightImgContain, source, onBack }: Props, { mobileView, leftImgContain, rightImgContain, originalImage }: Props,
{ scale, editingScale, altBackground }: State, { scale, editingScale, altBackground }: State,
) { ) {
const leftDraw = this.leftDrawable(); const leftDraw = this.leftDrawable();
const rightDraw = this.rightDrawable(); const rightDraw = this.rightDrawable();
// To keep position stable, the output is put in a square using the longest dimension.
const originalImage = source && source.processed;
return ( return (
<div class={`${style.output} ${altBackground ? style.altBackground : ''}`}> <div class={`${style.output} ${altBackground ? style.altBackground : ''}`}>
@@ -277,7 +206,7 @@ export default class Output extends Component<Props, State> {
ref={linkRef(this, 'pinchZoomLeft')} ref={linkRef(this, 'pinchZoomLeft')}
> >
<canvas <canvas
class={style.pinchTarget} class={style.outputCanvas}
ref={linkRef(this, 'canvasLeft')} ref={linkRef(this, 'canvasLeft')}
width={leftDraw && leftDraw.width} width={leftDraw && leftDraw.width}
height={leftDraw && leftDraw.height} height={leftDraw && leftDraw.height}
@@ -290,7 +219,7 @@ export default class Output extends Component<Props, State> {
</pinch-zoom> </pinch-zoom>
<pinch-zoom class={style.pinchZoom} ref={linkRef(this, 'pinchZoomRight')}> <pinch-zoom class={style.pinchZoom} ref={linkRef(this, 'pinchZoomRight')}>
<canvas <canvas
class={style.pinchTarget} class={style.outputCanvas}
ref={linkRef(this, 'canvasRight')} ref={linkRef(this, 'canvasRight')}
width={rightDraw && rightDraw.width} width={rightDraw && rightDraw.width}
height={rightDraw && rightDraw.height} height={rightDraw && rightDraw.height}
@@ -303,12 +232,6 @@ export default class Output extends Component<Props, State> {
</pinch-zoom> </pinch-zoom>
</two-up> </two-up>
<div class={style.back}>
<button class={style.button} onClick={onBack}>
<BackIcon />
</button>
</div>
<div class={style.controls}> <div class={style.controls}>
<div class={style.zoomControls}> <div class={style.zoomControls}>
<button class={style.button} onClick={this.zoomOut}> <button class={style.button} onClick={this.zoomOut}>
@@ -336,21 +259,10 @@ export default class Output extends Component<Props, State> {
<AddIcon /> <AddIcon />
</button> </button>
</div> </div>
<div class={style.buttonsNoWrap}> <button class={style.button} onClick={this.toggleBackground}>
<button class={style.button} onClick={this.onRotateClick} title="Rotate image"> <ToggleIcon />
<RotateIcon /> Toggle Background
</button> </button>
<button
class={`${style.button} ${altBackground ? style.active : ''}`}
onClick={this.toggleBackground}
title="Change canvas color"
>
{altBackground
? <ToggleBackgroundActiveIcon />
: <ToggleBackgroundIcon />
}
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -31,15 +31,6 @@
align-items: center; align-items: center;
} }
.pinch-target {
// This fixes a severe painting bug in Chrome.
// We should try to remove this once the issue is fixed.
// https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
will-change: auto;
// Prevent the image becoming misshapen due to default flexbox layout.
flex-shrink: 0;
}
.controls { .controls {
position: absolute; position: absolute;
display: flex; display: flex;
@@ -47,7 +38,7 @@
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
padding: 9px 84px; padding: 9px;
overflow: hidden; overflow: hidden;
flex-wrap: wrap; flex-wrap: wrap;
contain: content; contain: content;
@@ -59,12 +50,10 @@
} }
@media (min-width: 860px) { @media (min-width: 860px) {
padding: 9px;
top: auto; top: auto;
left: 320px; left: 320px;
right: 320px; right: 320px;
bottom: 0; bottom: 0;
flex-wrap: wrap-reverse;
} }
} }
@@ -89,20 +78,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
height: 48px;
padding: 0 16px;
margin: 4px; margin: 4px;
background-color: #fff; background-color: #fff;
border: 1px solid rgba(0,0,0,0.2); border: 1px solid rgba(0,0,0,0.2);
border-radius: 5px; border-radius: 5px;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
height: 36px;
padding: 0 8px;
cursor: pointer;
@media (min-width: 600px) {
height: 48px;
padding: 0 16px;
}
&:focus { &:focus {
box-shadow: 0 0 0 2px var(--button-fg); box-shadow: 0 0 0 2px var(--button-fg);
@@ -112,20 +95,15 @@
} }
.button { .button {
text-transform: uppercase;
color: var(--button-fg); color: var(--button-fg);
cursor: pointer;
text-indent: 6px;
font-size: 110%;
&:hover { &:hover {
background-color: #eee; background-color: #eee;
} }
&.active {
background: #34B9EB;
color: #fff;
&:hover {
background: #32a3ce;
}
}
} }
.zoom { .zoom {
@@ -149,18 +127,10 @@
border-bottom: 1px dashed #999; border-bottom: 1px dashed #999;
} }
.back { .output-canvas {
position: absolute; flex-shrink: 0;
top: 0; // This fixes a severe painting bug in Chrome.
left: 0; // We should try to remove this once the issue is fixed.
padding: 9px; // https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10
} will-change: auto;
.buttons-no-wrap {
display: flex;
pointer-events: none;
& > * {
pointer-events: auto;
}
} }

View File

@@ -103,7 +103,7 @@ export default class MultiPanel extends HTMLElement {
// KeyDown event handler // KeyDown event handler
private _onKeyDown(event: KeyboardEvent) { private _onKeyDown(event: KeyboardEvent) {
const selectedEl = document.activeElement!; const selectedEl = document.activeElement;
const heading = getClosestHeading(selectedEl); const heading = getClosestHeading(selectedEl);
// if keydown event is not on heading element, ignore // if keydown event is not on heading element, ignore
@@ -253,7 +253,7 @@ export default class MultiPanel extends HTMLElement {
} }
// previous Element of active Element is previous Content, // previous Element of active Element is previous Content,
// previous Element of previous Content is previousHeading // previous Element of previous Content is previousHeading
const previousContent = document.activeElement!.previousElementSibling; const previousContent = document.activeElement.previousElementSibling;
if (previousContent) { if (previousContent) {
return previousContent.previousElementSibling as HTMLElement; return previousContent.previousElementSibling as HTMLElement;
} }
@@ -263,7 +263,7 @@ export default class MultiPanel extends HTMLElement {
private _nextHeading() { private _nextHeading() {
// activeElement would be the currently selected heading // activeElement would be the currently selected heading
// 2 elemements after that would be the next heading. // 2 elemements after that would be the next heading.
const nextContent = document.activeElement!.nextElementSibling; const nextContent = document.activeElement.nextElementSibling;
if (nextContent) { if (nextContent) {
return nextContent.nextElementSibling as HTMLElement; return nextContent.nextElementSibling as HTMLElement;
} }

View File

@@ -31,37 +31,25 @@ import {
import { decodeImage } from '../../codecs/decoders'; import { decodeImage } from '../../codecs/decoders';
import { cleanMerge, cleanSet } from '../../lib/clean-modify'; import { cleanMerge, cleanSet } from '../../lib/clean-modify';
import Processor from '../../codecs/processor'; import Processor from '../../codecs/processor';
import { import { VectorResizeOptions, BitmapResizeOptions } from '../../codecs/resize/processor-meta';
VectorResizeOptions,
BrowserResizeOptions,
WorkerResizeOptions,
} from '../../codecs/resize/processor-meta';
import './custom-els/MultiPanel'; import './custom-els/MultiPanel';
import Results from '../results'; import Results from '../results';
import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons'; import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons';
import SnackBarElement from '../../lib/SnackBar'; import SnackBarElement from 'src/lib/SnackBar';
import { InputProcessorState, defaultInputProcessorState } from '../../codecs/input-processors';
export interface SourceImage { export interface SourceImage {
file: File | Fileish; file: File | Fileish;
decoded: ImageData; data: ImageData;
processed: ImageData;
vectorImage?: HTMLImageElement; vectorImage?: HTMLImageElement;
inputProcessorState: InputProcessorState;
} }
interface SideSettings { interface EncodedImage {
preprocessorState: PreprocessorState;
encoderState: EncoderState;
}
interface Side {
preprocessed?: ImageData; preprocessed?: ImageData;
file?: Fileish; file?: Fileish;
downloadUrl?: string; downloadUrl?: string;
data?: ImageData; data?: ImageData;
latestSettings: SideSettings; preprocessorState: PreprocessorState;
encodedSettings?: SideSettings; encoderState: EncoderState;
loading: boolean; loading: boolean;
/** Counter of the latest bmp currently encoding */ /** Counter of the latest bmp currently encoding */
loadingCounter: number; loadingCounter: number;
@@ -72,12 +60,11 @@ interface Side {
interface Props { interface Props {
file: File | Fileish; file: File | Fileish;
showSnack: SnackBarElement['showSnackbar']; showSnack: SnackBarElement['showSnackbar'];
onBack: () => void;
} }
interface State { interface State {
source?: SourceImage; source?: SourceImage;
sides: [Side, Side]; images: [EncodedImage, EncodedImage];
/** Source image load */ /** Source image load */
loading: boolean; loading: boolean;
loadingCounter: number; loadingCounter: number;
@@ -89,37 +76,20 @@ interface UpdateImageOptions {
skipPreprocessing?: boolean; 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( async function preprocessImage(
source: SourceImage, source: SourceImage,
preprocessData: PreprocessorState, preprocessData: PreprocessorState,
processor: Processor, processor: Processor,
): Promise<ImageData> { ): Promise<ImageData> {
let result = source.processed; let result = source.data;
if (preprocessData.resize.enabled) { if (preprocessData.resize.enabled) {
if (preprocessData.resize.method === 'vector' && source.vectorImage) { if (preprocessData.resize.method === 'vector' && source.vectorImage) {
result = processor.vectorResize( result = processor.vectorResize(
source.vectorImage, source.vectorImage,
preprocessData.resize as VectorResizeOptions, preprocessData.resize as VectorResizeOptions,
); );
} else if (preprocessData.resize.method.startsWith('browser-')) {
result = processor.resize(result, preprocessData.resize as BrowserResizeOptions);
} else { } else {
result = await processor.workerResize(result, preprocessData.resize as WorkerResizeOptions); result = processor.resize(result, preprocessData.resize as BitmapResizeOptions);
} }
} }
if (preprocessData.quantizer.enabled) { if (preprocessData.quantizer.enabled) {
@@ -160,26 +130,6 @@ async function compressImage(
); );
} }
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> { async function processSvg(blob: Blob): Promise<HTMLImageElement> {
// Firefox throws if you try to draw an SVG to canvas that doesn't have width/height. // 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. // In Chrome it loads, but drawImage behaves weirdly.
@@ -211,8 +161,6 @@ const resultTitles = ['Top', 'Bottom'];
const buttonPositions = const buttonPositions =
['download-left', 'download-right'] as ('download-left' | 'download-right')[]; ['download-left', 'download-right'] as ('download-left' | 'download-right')[];
const originalDocumentTitle = document.title;
export default class Compress extends Component<Props, State> { export default class Compress extends Component<Props, State> {
widthQuery = window.matchMedia('(max-width: 599px)'); widthQuery = window.matchMedia('(max-width: 599px)');
@@ -220,21 +168,17 @@ export default class Compress extends Component<Props, State> {
source: undefined, source: undefined,
loading: false, loading: false,
loadingCounter: 0, loadingCounter: 0,
sides: [ images: [
{ {
latestSettings: {
preprocessorState: defaultPreprocessorState, preprocessorState: defaultPreprocessorState,
encoderState: { type: identity.type, options: identity.defaultOptions }, encoderState: { type: identity.type, options: identity.defaultOptions },
},
loadingCounter: 0, loadingCounter: 0,
loadedCounter: 0, loadedCounter: 0,
loading: false, loading: false,
}, },
{ {
latestSettings: {
preprocessorState: defaultPreprocessorState, preprocessorState: defaultPreprocessorState,
encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions }, encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions },
},
loadingCounter: 0, loadingCounter: 0,
loadedCounter: 0, loadedCounter: 0,
loading: false, loading: false,
@@ -246,15 +190,11 @@ export default class Compress extends Component<Props, State> {
private readonly encodeCache = new ResultCache(); private readonly encodeCache = new ResultCache();
private readonly leftProcessor = new Processor(); private readonly leftProcessor = new Processor();
private readonly rightProcessor = 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) { constructor(props: Props) {
super(props); super(props);
this.widthQuery.addListener(this.onMobileWidthChange); this.widthQuery.addListener(this.onMobileWidthChange);
this.updateFile(props.file); this.updateFile(props.file);
import('../../lib/offliner').then(({ mainAppLoaded }) => mainAppLoaded());
} }
@bind @bind
@@ -264,7 +204,7 @@ export default class Compress extends Component<Props, State> {
private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void { private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void {
this.setState({ this.setState({
sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState`, { images: cleanSet(this.state.images, `${index}.encoderState`, {
type: newType, type: newType,
options: encoderMap[newType].defaultOptions, options: encoderMap[newType].defaultOptions,
}), }),
@@ -273,50 +213,39 @@ export default class Compress extends Component<Props, State> {
private onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void { private onPreprocessorOptionsChange(index: 0 | 1, options: PreprocessorState): void {
this.setState({ this.setState({
sides: cleanSet(this.state.sides, `${index}.latestSettings.preprocessorState`, options), images: cleanSet(this.state.images, `${index}.preprocessorState`, options),
}); });
} }
private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void { private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void {
this.setState({ this.setState({
sides: cleanSet(this.state.sides, `${index}.latestSettings.encoderState.options`, options), images: cleanSet(this.state.images, `${index}.encoderState.options`, options),
}); });
} }
private updateDocumentTitle(filename: string = ''): void {
document.title = filename ? `${filename} - ${originalDocumentTitle}` : originalDocumentTitle;
}
componentWillReceiveProps(nextProps: Props): void { componentWillReceiveProps(nextProps: Props): void {
if (nextProps.file !== this.props.file) { if (nextProps.file !== this.props.file) {
this.updateFile(nextProps.file); this.updateFile(nextProps.file);
} }
} }
componentWillUnmount(): void {
this.updateDocumentTitle();
}
componentDidUpdate(prevProps: Props, prevState: State): void { componentDidUpdate(prevProps: Props, prevState: State): void {
const { source, sides } = this.state; const { source, images } = this.state;
const sourceDataChanged = for (const [i, image] of images.entries()) {
// Has the source object become set/unset? const prevImage = prevState.images[i];
!!source !== !!prevState.source || const sourceChanged = source !== prevState.source;
// Or has the processed data changed? const encoderChanged = image.encoderState !== prevImage.encoderState;
(source && prevState.source && source.processed !== prevState.source.processed); const preprocessorChanged = image.preprocessorState !== prevImage.preprocessorState;
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 // The image only needs updated if the encoder/preprocessor settings have changed, or the
// source has changed. // source has changed.
if (sourceDataChanged || encoderChanged || preprocessorChanged) { if (sourceChanged || encoderChanged || preprocessorChanged) {
this.queueUpdateImage(i, { if (prevImage.downloadUrl) URL.revokeObjectURL(prevImage.downloadUrl);
skipPreprocessing: !sourceDataChanged && !preprocessorChanged, this.updateImage(i, {
skipPreprocessing: !sourceChanged && !preprocessorChanged,
}).catch((err) => {
console.error(err);
}); });
} }
} }
@@ -324,15 +253,10 @@ export default class Compress extends Component<Props, State> {
private async onCopyToOtherClick(index: 0 | 1) { private async onCopyToOtherClick(index: 0 | 1) {
const otherIndex = (index + 1) % 2; const otherIndex = (index + 1) % 2;
const oldSettings = this.state.sides[otherIndex]; const oldSettings = this.state.images[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({ this.setState({
sides: cleanSet(this.state.sides, otherIndex, newSettings), images: cleanSet(this.state.images, otherIndex, this.state.images[index]),
}); });
const result = await this.props.showSnack('Settings copied across', { const result = await this.props.showSnack('Settings copied across', {
@@ -343,67 +267,13 @@ export default class Compress extends Component<Props, State> {
if (result !== 'undo') return; if (result !== 'undo') return;
this.setState({ this.setState({
sides: cleanSet(this.state.sides, otherIndex, oldSettings), images: cleanSet(this.state.images, 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 @bind
private async updateFile(file: File | Fileish) { private async updateFile(file: File | Fileish) {
const loadingCounter = this.state.loadingCounter + 1; const loadingCounter = this.state.loadingCounter + 1;
// Either processor is good enough here.
const processor = this.leftProcessor;
this.setState({ loadingCounter, loading: true }); this.setState({ loadingCounter, loading: true });
@@ -412,7 +282,7 @@ export default class Compress extends Component<Props, State> {
this.rightProcessor.abortCurrent(); this.rightProcessor.abortCurrent();
try { try {
let decoded: ImageData; let data: ImageData;
let vectorImage: HTMLImageElement | undefined; let vectorImage: HTMLImageElement | undefined;
// Special-case SVG. We need to avoid createImageBitmap because of // Special-case SVG. We need to avoid createImageBitmap because of
@@ -420,96 +290,73 @@ export default class Compress extends Component<Props, State> {
// Also, we cache the HTMLImageElement so we can perform vector resizing later. // Also, we cache the HTMLImageElement so we can perform vector resizing later.
if (file.type.startsWith('image/svg+xml')) { if (file.type.startsWith('image/svg+xml')) {
vectorImage = await processSvg(file); vectorImage = await processSvg(file);
decoded = drawableToImageData(vectorImage); data = drawableToImageData(vectorImage);
} else { } else {
// Either processor is good enough here. // Either processor is good enough here.
decoded = await decodeImage(file, processor); data = await decodeImage(file, this.leftProcessor);
} }
const processed = await processInput(decoded, defaultInputProcessorState, processor); // Another file has been opened before this one processed.
// Another file has been opened/processed before this one processed.
if (this.state.loadingCounter !== loadingCounter) return; if (this.state.loadingCounter !== loadingCounter) return;
let newState: State = { let newState: State = {
...this.state, ...this.state,
source: { source: { data, file, vectorImage },
decoded, file, vectorImage, processed,
inputProcessorState: defaultInputProcessorState,
},
loading: false, loading: false,
}; };
newState = stateForNewSourceData(newState, newState.source!);
for (const i of [0, 1]) { for (const i of [0, 1]) {
// Ditch previous encodings
const downloadUrl = this.state.images[i].downloadUrl;
if (downloadUrl) URL.revokeObjectURL(downloadUrl!);
newState = cleanMerge(newState, `images.${i}`, {
preprocessed: undefined,
file: undefined,
downloadUrl: undefined,
data: undefined,
});
// Default resize values come from the image: // Default resize values come from the image:
newState = cleanMerge(newState, `sides.${i}.latestSettings.preprocessorState.resize`, { newState = cleanMerge(newState, `images.${i}.preprocessorState.resize`, {
width: processed.width, width: data.width,
height: processed.height, height: data.height,
method: vectorImage ? 'vector' : 'lanczos3', method: vectorImage ? 'vector' : 'browser-high',
}); });
} }
this.updateDocumentTitle(file.name);
this.setState(newState); this.setState(newState);
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
console.error(err); console.error(err);
// Another file has been opened/processed before this one processed. // Another file has been opened before this one processed.
if (this.state.loadingCounter !== loadingCounter) return; if (this.state.loadingCounter !== loadingCounter) return;
this.props.showSnack('Invalid image'); this.props.showSnack('Invalid image');
this.setState({ loading: false }); 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> { private async updateImage(index: number, options: UpdateImageOptions = {}): Promise<void> {
const { const { skipPreprocessing = false } = options;
skipPreprocessing = false,
} = options;
const { source } = this.state; const { source } = this.state;
if (!source) return; if (!source) return;
// Each time we trigger an async encode, the counter changes. // Each time we trigger an async encode, the counter changes.
const loadingCounter = this.state.sides[index].loadingCounter + 1; const loadingCounter = this.state.images[index].loadingCounter + 1;
let sides = cleanMerge(this.state.sides, index, { let images = cleanMerge(this.state.images, index, {
loadingCounter, loadingCounter,
loading: true, loading: true,
}); });
this.setState({ sides }); this.setState({ images });
const side = sides[index]; const image = images[index];
const settings = side.latestSettings;
let file: File | Fileish | undefined; let file: File | Fileish | undefined;
let preprocessed: ImageData | undefined; let preprocessed: ImageData | undefined;
let data: ImageData | undefined; let data: ImageData | undefined;
const cacheResult = this.encodeCache.match( const cacheResult = this.encodeCache.match(source, image.preprocessorState, image.encoderState);
source.processed, settings.preprocessorState, settings.encoderState,
);
const processor = (index === 0) ? this.leftProcessor : this.rightProcessor; const processor = (index === 0) ? this.leftProcessor : this.rightProcessor;
// Abort anything the processor is currently doing. // Abort anything the processor is currently doing.
@@ -522,113 +369,95 @@ export default class Compress extends Component<Props, State> {
} else { } else {
try { try {
// Special case for identity // Special case for identity
if (settings.encoderState.type === identity.type) { if (image.encoderState.type === identity.type) {
file = source.file; ({ file, data } = source);
data = source.processed;
} else { } else {
preprocessed = (skipPreprocessing && side.preprocessed) preprocessed = (skipPreprocessing && image.preprocessed)
? side.preprocessed ? image.preprocessed
: await preprocessImage(source, settings.preprocessorState, processor); : await preprocessImage(source, image.preprocessorState, processor);
file = await compressImage( file = await compressImage(preprocessed, image.encoderState, source.file.name, processor);
preprocessed, settings.encoderState, source.file.name, processor,
);
data = await decodeImage(file, processor); data = await decodeImage(file, processor);
this.encodeCache.add({ this.encodeCache.add({
source,
data, data,
preprocessed, preprocessed,
file, file,
sourceData: source.processed, encoderState: image.encoderState,
encoderState: settings.encoderState, preprocessorState: image.preprocessorState,
preprocessorState: settings.preprocessorState,
}); });
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
this.props.showSnack(`Processing error (type=${settings.encoderState.type}): ${err}`); this.props.showSnack(`Processing error (type=${image.encoderState.type}): ${err}`);
throw err; throw err;
} }
} }
const latestData = this.state.sides[index]; const latestImage = this.state.images[index];
// If a later encode has landed before this one, return. // If a later encode has landed before this one, return.
if (loadingCounter < latestData.loadedCounter) { if (loadingCounter < latestImage.loadedCounter) {
return; return;
} }
if (latestData.downloadUrl) URL.revokeObjectURL(latestData.downloadUrl); images = cleanMerge(this.state.images, index, {
sides = cleanMerge(this.state.sides, index, {
file, file,
data, data,
preprocessed, preprocessed,
downloadUrl: URL.createObjectURL(file), downloadUrl: URL.createObjectURL(file),
loading: sides[index].loadingCounter !== loadingCounter, loading: images[index].loadingCounter !== loadingCounter,
loadedCounter: loadingCounter, loadedCounter: loadingCounter,
encodedSettings: settings,
}); });
this.setState({ sides }); this.setState({ images });
} }
render({ onBack }: Props, { loading, sides, source, mobileView }: State) { render({ }: Props, { loading, images, source, mobileView }: State) {
const [leftSide, rightSide] = sides; const [leftImage, rightImage] = images;
const [leftImageData, rightImageData] = sides.map(i => i.data); const [leftImageData, rightImageData] = images.map(i => i.data);
const options = sides.map((side, index) => ( const options = images.map((image, index) => (
<Options <Options
source={source} source={source}
mobileView={mobileView} mobileView={mobileView}
preprocessorState={side.latestSettings.preprocessorState} preprocessorState={image.preprocessorState}
encoderState={side.latestSettings.encoderState} encoderState={image.encoderState}
onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index as 0|1)} onEncoderTypeChange={this.onEncoderTypeChange.bind(this, index)}
onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index as 0|1)} onEncoderOptionsChange={this.onEncoderOptionsChange.bind(this, index)}
onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index as 0|1)} onPreprocessorOptionsChange={this.onPreprocessorOptionsChange.bind(this, index)}
/> />
)); ));
const copyDirections = const copyDirections =
(mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][]; (mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][];
const results = sides.map((side, index) => ( const results = images.map((image, index) => (
<Results <Results
downloadUrl={side.downloadUrl} downloadUrl={image.downloadUrl}
imageFile={side.file} imageFile={image.file}
source={source} source={source}
loading={loading || side.loading} loading={loading || image.loading}
copyDirection={copyDirections[index]} copyDirection={copyDirections[index]}
onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index as 0|1)} onCopyToOtherClick={this.onCopyToOtherClick.bind(this, index)}
buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]} buttonPosition={mobileView ? 'stack-right' : buttonPositions[index]}
> >
{!mobileView ? null : [ {!mobileView ? null : [
<ExpandIcon class={style.expandIcon} key="expand-icon"/>, <ExpandIcon class={style.expandIcon} key="expand-icon"/>,
`${resultTitles[index]} (${encoderMap[side.latestSettings.encoderState.type].label})`, `${resultTitles[index]} (${encoderMap[image.encoderState.type].label})`,
]} ]}
</Results> </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 ( return (
<div class={style.compress}> <div class={style.compress}>
<Output <Output
source={source} originalImage={source && source.data}
mobileView={mobileView} mobileView={mobileView}
leftCompressed={leftImageData} leftCompressed={leftImageData}
rightCompressed={rightImageData} rightCompressed={rightImageData}
leftImgContain={leftImgContain} leftImgContain={leftImage.preprocessorState.resize.fitMethod === 'cover'}
rightImgContain={rightImgContain} rightImgContain={rightImage.preprocessorState.resize.fitMethod === 'cover'}
onBack={onBack}
inputProcessorState={source && source.inputProcessorState}
onInputProcessorChange={this.onInputProcessorChange}
/> />
{mobileView {mobileView
? ( ? (

View File

@@ -1,6 +1,7 @@
import { EncoderState } from '../../codecs/encoders'; import { EncoderState } from '../../codecs/encoders';
import { Fileish } from '../../lib/initial-util'; import { Fileish } from '../../lib/initial-util';
import { shallowEqual } from '../../lib/util'; import { shallowEqual } from '../../lib/util';
import { SourceImage } from '.';
import { PreprocessorState } from '../../codecs/preprocessors'; import { PreprocessorState } from '../../codecs/preprocessors';
import * as identity from '../../codecs/identity/encoder-meta'; import * as identity from '../../codecs/identity/encoder-meta';
@@ -14,7 +15,7 @@ interface CacheResult {
interface CacheEntry extends CacheResult { interface CacheEntry extends CacheResult {
preprocessorState: PreprocessorState; preprocessorState: PreprocessorState;
encoderState: EncoderState; encoderState: EncoderState;
sourceData: ImageData; source: SourceImage;
} }
const SIZE = 5; const SIZE = 5;
@@ -31,13 +32,13 @@ export default class ResultCache {
} }
match( match(
sourceData: ImageData, source: SourceImage,
preprocessorState: PreprocessorState, preprocessorState: PreprocessorState,
encoderState: EncoderState, encoderState: EncoderState,
): CacheResult | undefined { ): CacheResult | undefined {
const matchingIndex = this._entries.findIndex((entry) => { const matchingIndex = this._entries.findIndex((entry) => {
// Check for quick exits: // Check for quick exits:
if (entry.sourceData !== sourceData) return false; if (entry.source !== source) return false;
if (entry.encoderState.type !== encoderState.type) return false; if (entry.encoderState.type !== encoderState.type) return false;
// Check that each set of options in the preprocessor are the same // Check that each set of options in the preprocessor are the same

View File

@@ -22,7 +22,7 @@
max-width: 400px; max-width: 400px;
margin: 0 auto; margin: 0 auto;
width: calc(100% - 60px); width: calc(100% - 60px);
max-height: calc(100% - 104px); max-height: calc(100% - 143px);
overflow: hidden; overflow: hidden;
@media (min-width: 600px) { @media (min-width: 600px) {
@@ -32,7 +32,7 @@
} }
@media (min-width: 860px) { @media (min-width: 860px) {
max-height: calc(100% - 40px); max-height: 100%;
} }
} }
@@ -40,7 +40,6 @@
position: relative; position: relative;
display: flex; display: flex;
flex-flow: column; flex-flow: column;
overflow: hidden;
// Reorder so headings appear after content: // Reorder so headings appear after content:
& > :nth-child(1) { & > :nth-child(1) {

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -4,13 +4,13 @@ import { bind, linkRef, Fileish } from '../../lib/initial-util';
import '../custom-els/LoadingSpinner'; import '../custom-els/LoadingSpinner';
import logo from './imgs/logo.svg'; import logo from './imgs/logo.svg';
import largePhoto from './imgs/demos/demo-large-photo.jpg'; import largePhoto from './imgs/demos/large-photo.jpg';
import artwork from './imgs/demos/demo-artwork.jpg'; import artwork from './imgs/demos/artwork.jpg';
import deviceScreen from './imgs/demos/demo-device-screen.png'; import deviceScreen from './imgs/demos/device-screen.png';
import largePhotoIcon from './imgs/demos/icon-demo-large-photo.jpg'; import largePhotoIcon from './imgs/demos/large-photo-icon.jpg';
import artworkIcon from './imgs/demos/icon-demo-artwork.jpg'; import artworkIcon from './imgs/demos/artwork-icon.jpg';
import deviceScreenIcon from './imgs/demos/icon-demo-device-screen.jpg'; import deviceScreenIcon from './imgs/demos/device-screen-icon.jpg';
import logoIcon from './imgs/demos/icon-demo-logo.png'; import logoIcon from './imgs/demos/logo-icon.png';
import * as style from './style.scss'; import * as style from './style.scss';
import SnackBarElement from '../../lib/SnackBar'; import SnackBarElement from '../../lib/SnackBar';
@@ -90,7 +90,7 @@ export default class Intro extends Component<Props, State> {
<div> <div>
<div class={style.logoSizer}> <div class={style.logoSizer}>
<div class={style.logoContainer}> <div class={style.logoContainer}>
<img src={logo} class={style.logo} alt="Squoosh" decoding="async" /> <img src={logo} class={style.logo} alt="Squoosh" />
</div> </div>
</div> </div>
<p class={style.openImageGuide}> <p class={style.openImageGuide}>
@@ -111,7 +111,7 @@ export default class Intro extends Component<Props, State> {
<div class={style.demo}> <div class={style.demo}>
<div class={style.demoImgContainer}> <div class={style.demoImgContainer}>
<div class={style.demoImgAspect}> <div class={style.demoImgAspect}>
<img class={style.demoIcon} src={demo.iconUrl} alt="" decoding="async" /> <img class={style.demoIcon} src={demo.iconUrl} alt=""/>
{fetchingDemoIndex === i && {fetchingDemoIndex === i &&
<div class={style.demoLoading}> <div class={style.demoLoading}>
<loading-spinner class={style.demoLoadingSpinner}/> <loading-spinner class={style.demoLoadingSpinner}/>
@@ -129,11 +129,6 @@ export default class Intro extends Component<Props, State> {
<ul class={style.relatedLinks}> <ul class={style.relatedLinks}>
<li><a href="https://github.com/GoogleChromeLabs/squoosh/">View the code</a></li> <li><a href="https://github.com/GoogleChromeLabs/squoosh/">View the code</a></li>
<li><a href="https://github.com/GoogleChromeLabs/squoosh/issues">Report a bug</a></li> <li><a href="https://github.com/GoogleChromeLabs/squoosh/issues">Report a bug</a></li>
<li>
<a href="https://github.com/GoogleChromeLabs/squoosh/blob/master/README.md#privacy">
Privacy
</a>
</li>
</ul> </ul>
</div> </div>
); );

View File

@@ -2,7 +2,6 @@
font-family: 'intro-text'; font-family: 'intro-text';
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
font-display: block;
// This only contains the chars for "Drag & drop or" // This only contains the chars for "Drag & drop or"
src: url('data:font/woff2;base64,d09GMgABAAAAAAXcAA4AAAAACowAAAWJAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg2gcMAZgAGwRCAqIQIcnCxYAATYCJAMoBCAFgwAHIBvqCFEU84FMI2Xh/P3g+Tfn532yQ/IgYz4BrJyhtkkZwFBYAZ49sI63e5v/NnqzIfbADyE0qxOqK8ESLoNNdULHihxbW0W86/qHEk4wT/eHShPRZJYYqUGkQdLSWCeSemZBzwpKyX/LRoAhMEQhqCFBw5RHNCc4hbVn35FsxtTXVHYyo7miu5VN2AW1fwzVauRgXnIGo2IWsYdViUoLu6mms5VFAn+SeQ4eBazfj7QodrMt4oyQHaGADEPRpTbDqJaoTENNK6DpOralUszf6gI/QsAhWZSMKVOirikSJxZRLBVD0S4mB0kTBRwopjZ/mt/2/25+bcSipgiHRmwiFI1g+XhwlshyEAsbJzGiGH+U5whHNgiXooplafI1rMFbmIqjGAPhmcSkVFxeu9hw87aXsGyL+dPE05qUpK2WyaVQcZVW+aDmw3aalLJKNmQORcpZYtBIuTrncN4xXoVZY617TBSsx2T1DHgGU6u4etE04wha1GEwjVkEaDttOrl1FCOwUMxgHnuooJo62ukcWEuc1/aT+dZ8b142t5tbzc3mGnP1EJqVTEGMYTjG14YxtGEEG+0E2axhe6Oa1E8UrDHDFjhTRywYNWrU9JHTlw7RmaslkrrGcTJ+znW4EzzP0zovE4Z5d0hqVhBobftBIKkwL09SOv3hhCuv1Dp9taTeCJ2Mj3KDT8iDng5DkWzPw/UdP8idNDkMnUyOwEauwnYLQeLC7GskNe72QKe97AmuA42E5FjfyYTM+HTdQ+Xqb+q4JvptyKZN1w47qMMwL58fyKZM1U6NXgWlOFdxx7DpXHDTz4UB89WMK3HH3uY7mavFopGF+u36lGlqZsL4ugmbqvZxveycMO+a4uyN3o7GT2qdHpfr6W++kNTn1crdx7Z+FW7PfffTmfnXV/2ivsh5UX93zdlzct6QlSuHSumG3oGNNT9/m9yXnDcnKfsmDx8xUaoKi+uvGs99H2ieUJUg8bTnVwQcDd/SPKwYWDUv+QkpT6MulMrcPTXNWYnIowxvoiwnX+opTMkvzOMGgpNpqnK32CNVwCnassw0BwQwTa0rLS3m1DfIoxx5PIE8SvEmSk3pHSWZiRVKjOOQSylJSHGXkhT/u/tg/Vm9UZQcS59TGb1qjcuuT0925iaaU1vaWpZJM4ukqWWlrdWSIcVNlOImvnrzLn53UpnSLzbGT5lUlpTiKiPJFEmyqywFLtOhcaYJkWkaGe/oGBlnmiIiIiKYpqHxLmdaWg5JpxxHSXpajsuVlkPSvb1JelqOC0pubbAn2A2UsDdYmTmjvbVlgTRhVBSSxpbF1nZD+jvkUR4rcJeSFBp2d19SUsVW5DjkUkoSoITHJ7iJEpZnZaL4OiF7g92DN1mz8b1RiM9RDk9ps9pcanamlnj2ftqbJpHJ0wpkRn2+RJ6qsGflpYrPnxG6A4r9zqGY3qCcqDuhsWGQhoXpQ0663cWFM4qNR0Jxj1R0UBT36pahMneH4NYV27jElOeyAAAACACAABy4uvGyOsj21Y9h3gIA3PuxYAYAuC/7vftf7L+PXunMAQDwBQIAAAIA5vR/HwCvOQ//TzLL7cPIHUC0zMI5v7+tHiVfzWOeSrJKZbFabWGNSnJE+jmsnmTjTZm6kBi9r0aLgm8qNk6t67ATuPlEitG+g+E7in1GMYxCxmIF9YzNJK7lRoSPc6PCD+8fxhp+YjdttDNAJw3UUU83M1jFClaylkpkU08NVZqkh0oaaBLPnaCTNhqpoaok2UkPZqy/JyfpKnVLkhrq6KGZCjpZxTJWqN9uJofD5HGMzSXHLaVbOmuTSnOp6cTQgJlaB6oF7RIITul8N+1sYjnL6aJqqoZ2inaxDIY2s2zwlXUs5zj7OPJmAPao+ZhVHy0A') format('woff2'); src: url('data:font/woff2;base64,d09GMgABAAAAAAXcAA4AAAAACowAAAWJAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg2gcMAZgAGwRCAqIQIcnCxYAATYCJAMoBCAFgwAHIBvqCFEU84FMI2Xh/P3g+Tfn532yQ/IgYz4BrJyhtkkZwFBYAZ49sI63e5v/NnqzIfbADyE0qxOqK8ESLoNNdULHihxbW0W86/qHEk4wT/eHShPRZJYYqUGkQdLSWCeSemZBzwpKyX/LRoAhMEQhqCFBw5RHNCc4hbVn35FsxtTXVHYyo7miu5VN2AW1fwzVauRgXnIGo2IWsYdViUoLu6mms5VFAn+SeQ4eBazfj7QodrMt4oyQHaGADEPRpTbDqJaoTENNK6DpOralUszf6gI/QsAhWZSMKVOirikSJxZRLBVD0S4mB0kTBRwopjZ/mt/2/25+bcSipgiHRmwiFI1g+XhwlshyEAsbJzGiGH+U5whHNgiXooplafI1rMFbmIqjGAPhmcSkVFxeu9hw87aXsGyL+dPE05qUpK2WyaVQcZVW+aDmw3aalLJKNmQORcpZYtBIuTrncN4xXoVZY617TBSsx2T1DHgGU6u4etE04wha1GEwjVkEaDttOrl1FCOwUMxgHnuooJo62ukcWEuc1/aT+dZ8b142t5tbzc3mGnP1EJqVTEGMYTjG14YxtGEEG+0E2axhe6Oa1E8UrDHDFjhTRywYNWrU9JHTlw7RmaslkrrGcTJ+znW4EzzP0zovE4Z5d0hqVhBobftBIKkwL09SOv3hhCuv1Dp9taTeCJ2Mj3KDT8iDng5DkWzPw/UdP8idNDkMnUyOwEauwnYLQeLC7GskNe72QKe97AmuA42E5FjfyYTM+HTdQ+Xqb+q4JvptyKZN1w47qMMwL58fyKZM1U6NXgWlOFdxx7DpXHDTz4UB89WMK3HH3uY7mavFopGF+u36lGlqZsL4ugmbqvZxveycMO+a4uyN3o7GT2qdHpfr6W++kNTn1crdx7Z+FW7PfffTmfnXV/2ivsh5UX93zdlzct6QlSuHSumG3oGNNT9/m9yXnDcnKfsmDx8xUaoKi+uvGs99H2ieUJUg8bTnVwQcDd/SPKwYWDUv+QkpT6MulMrcPTXNWYnIowxvoiwnX+opTMkvzOMGgpNpqnK32CNVwCnassw0BwQwTa0rLS3m1DfIoxx5PIE8SvEmSk3pHSWZiRVKjOOQSylJSHGXkhT/u/tg/Vm9UZQcS59TGb1qjcuuT0925iaaU1vaWpZJM4ukqWWlrdWSIcVNlOImvnrzLn53UpnSLzbGT5lUlpTiKiPJFEmyqywFLtOhcaYJkWkaGe/oGBlnmiIiIiKYpqHxLmdaWg5JpxxHSXpajsuVlkPSvb1JelqOC0pubbAn2A2UsDdYmTmjvbVlgTRhVBSSxpbF1nZD+jvkUR4rcJeSFBp2d19SUsVW5DjkUkoSoITHJ7iJEpZnZaL4OiF7g92DN1mz8b1RiM9RDk9ps9pcanamlnj2ftqbJpHJ0wpkRn2+RJ6qsGflpYrPnxG6A4r9zqGY3qCcqDuhsWGQhoXpQ0663cWFM4qNR0Jxj1R0UBT36pahMneH4NYV27jElOeyAAAACACAABy4uvGyOsj21Y9h3gIA3PuxYAYAuC/7vftf7L+PXunMAQDwBQIAAAIA5vR/HwCvOQ//TzLL7cPIHUC0zMI5v7+tHiVfzWOeSrJKZbFabWGNSnJE+jmsnmTjTZm6kBi9r0aLgm8qNk6t67ATuPlEitG+g+E7in1GMYxCxmIF9YzNJK7lRoSPc6PCD+8fxhp+YjdttDNAJw3UUU83M1jFClaylkpkU08NVZqkh0oaaBLPnaCTNhqpoaok2UkPZqy/JyfpKnVLkhrq6KGZCjpZxTJWqN9uJofD5HGMzSXHLaVbOmuTSnOp6cTQgJlaB6oF7RIITul8N+1sYjnL6aJqqoZ2inaxDIY2s2zwlXUs5zj7OPJmAPao+ZhVHy0A') format('woff2');
} }
@@ -11,7 +10,6 @@
font-family: 'intro-text'; font-family: 'intro-text';
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: block;
// Only contains the chars for "select an image" // Only contains the chars for "select an image"
src: url('data:font/woff2;base64,d09GMgABAAAAAAXMAA4AAAAACwQAAAV5AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg1IcMAZgAGwRCAqJUId/CxoAATYCJAMwBCAFgnAHIBskCcjEh6dNff8Ou/9Tj9VZGnUhJeqFzWGiVVOkxkTthr9f6kWkRdsBbkIj3YuLaloFZWr7aBg22z7IOoWqBWCW5cZU3GBrh0n+dAcBYAUlzYHAzWTYchqKXEyAT0zOLIS1qqm+B8q2ur4OhEMy0PHHUH8KklaSr8T0mp/EU7kRvXlI1E09HXA1qLN8Djxa0AsSDOg3cJARb9mtQJoSK3gvEn372/gcAigg/gOnbsT/MYv491GTReW4rJC5LA+h5FFclF6QQgoZ5Kx7GbsuGeytUgFClkOomY2Gdake3m9HegkHieAx/a0hBTALsy4jvpxBcnFXUnjC+2ZS5zHnDeEaJVwi+ZWqzOm4Uvgy4k6kGv4kFDVkfjk1gkVRRkk2zlo42PBbRJmG30cClJQjak7BnfQqHza4ITKftQZ/ZMUaEiyy1+mYCh4clKhDA5rQglZ0oG9jw+qiNvT+SfxIXCeuFdeIq8VV4gpxyRaGOl0JiChiCocfc7e93DwZIPvWgPiZJLcJugxyjW7UQyl4TJk6dWqYU7Cn0WQiWnNJCdGeprqjW63fpVS3mKy4YGZ6I3ya4nbIVgM1mwkpNEBzixlNxfPmH7owvdE4973OM9quvk11dwvnzDUy/Zn5S5Ywpn/PeqXBQI2m4lna05CRtsI6+GIENjS9K4jWRHUGYA2ozdZm2Smmf0DI3aqpeNbsJfxe7YdFmcZAn5gXLCFa2/Umqiu017APFhMZ0rfQp4sJX0ZrJ+n9UtAljr5VYWb6oj1MrpvX3qe6u8WRJg0bj7aPkDOa7m+E0Oa9Y8eY/gbRbr+efH7hcO49bMd28fbDVHcUmm3XkozQGKjeBHSJ4TQnI879LIFmF2v/BJuEQlffJPfE9oKayS/PsPE44fvM4MsBESxbuEEV39d5pw6oW4vD6S1WQC3UpSbHNbK0Jikl0bphSs+0CGW8Ew4Kzw7zarmcVz873JHTFhKYay18R8vY0ozPiHPAGyROAqlW5fLj5+HbWBn9TpgekKsOy8N+4dlFfL9i/Nk3+gY1bwzZUAvLVNiFpvqHRenetSoVrgn2obGtPsltEVxEeHJAQFhyBIcHT9rDvTJm06e0TLgase2gd2RffGJNg0o1zrdRyi9s1bYE5bi85cK+o/nUwvBR5+jweEBaSMoCub29fEFISmib9Dn5yl5kVFpoGrPQSmZhafQ7WimttNCH7Cktohb6kFpoEfIsdDF7SgvZTbaY3mKFyLXQh+wpzWE3mUNQQkWnR+lgiW9Afkunej49Nz3sYlI8XFTRdkNhUR5d6h4oOpJc8OjcItMXVqoHW2fSW6ycWuiM8NDYoICouLAZ9BYrwwmhycvKlt4Q8hUlCV5nZ7vOm2ut2rizcFpuWpSrT3K1Z3xinbuHnXBTyGAljV/XzHZaNGu6y7vLDziMpIyUdOBBRXXlxznUQiOoheZsZk9njG8er1mNmz2eOCQ3x9BbLP+Zxt+VrbEz9aWxmimRvyl4/sumyoM/nw+LNzV4/uP0/9T/P5f08cvhl38USAS/xTYs2fL/VNFF0vd+SVsRB/khPwW4SCi5SHhx8fDgVPAiAqIJRQL/EuK5bsRzNnAiezD1i7u2VyHHAKRU+E2YUaA5DUsE2ZfbApAmsJcxjBwmQ2Xk4Y2BqZJ+oxSzsNEogz1O3/xkBMKEBHSiC8PoQStaoEIflPCHL/wQBCUKoUITlMhHP+olt5ojykUPOrEQTWgY+f449KMPKnSiB72jGrLQhEZO24925LtbkG5DndTkD2/4um+NQBEyUIJsRBxX24tcDGmbWtGJjq05jhzTaMgCcR+6EA4f+KAXDay5u6RsL7xJsm3w3mzvFvggB8nI/AYBdVnxNvx/2wA=') format('woff2'); src: url('data:font/woff2;base64,d09GMgABAAAAAAXMAA4AAAAACwQAAAV5AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg1IcMAZgAGwRCAqJUId/CxoAATYCJAMwBCAFgnAHIBskCcjEh6dNff8Ou/9Tj9VZGnUhJeqFzWGiVVOkxkTthr9f6kWkRdsBbkIj3YuLaloFZWr7aBg22z7IOoWqBWCW5cZU3GBrh0n+dAcBYAUlzYHAzWTYchqKXEyAT0zOLIS1qqm+B8q2ur4OhEMy0PHHUH8KklaSr8T0mp/EU7kRvXlI1E09HXA1qLN8Djxa0AsSDOg3cJARb9mtQJoSK3gvEn372/gcAigg/gOnbsT/MYv491GTReW4rJC5LA+h5FFclF6QQgoZ5Kx7GbsuGeytUgFClkOomY2Gdake3m9HegkHieAx/a0hBTALsy4jvpxBcnFXUnjC+2ZS5zHnDeEaJVwi+ZWqzOm4Uvgy4k6kGv4kFDVkfjk1gkVRRkk2zlo42PBbRJmG30cClJQjak7BnfQqHza4ITKftQZ/ZMUaEiyy1+mYCh4clKhDA5rQglZ0oG9jw+qiNvT+SfxIXCeuFdeIq8VV4gpxyRaGOl0JiChiCocfc7e93DwZIPvWgPiZJLcJugxyjW7UQyl4TJk6dWqYU7Cn0WQiWnNJCdGeprqjW63fpVS3mKy4YGZ6I3ya4nbIVgM1mwkpNEBzixlNxfPmH7owvdE4973OM9quvk11dwvnzDUy/Zn5S5Ywpn/PeqXBQI2m4lna05CRtsI6+GIENjS9K4jWRHUGYA2ozdZm2Smmf0DI3aqpeNbsJfxe7YdFmcZAn5gXLCFa2/Umqiu017APFhMZ0rfQp4sJX0ZrJ+n9UtAljr5VYWb6oj1MrpvX3qe6u8WRJg0bj7aPkDOa7m+E0Oa9Y8eY/gbRbr+efH7hcO49bMd28fbDVHcUmm3XkozQGKjeBHSJ4TQnI879LIFmF2v/BJuEQlffJPfE9oKayS/PsPE44fvM4MsBESxbuEEV39d5pw6oW4vD6S1WQC3UpSbHNbK0Jikl0bphSs+0CGW8Ew4Kzw7zarmcVz873JHTFhKYay18R8vY0ozPiHPAGyROAqlW5fLj5+HbWBn9TpgekKsOy8N+4dlFfL9i/Nk3+gY1bwzZUAvLVNiFpvqHRenetSoVrgn2obGtPsltEVxEeHJAQFhyBIcHT9rDvTJm06e0TLgase2gd2RffGJNg0o1zrdRyi9s1bYE5bi85cK+o/nUwvBR5+jweEBaSMoCub29fEFISmib9Dn5yl5kVFpoGrPQSmZhafQ7WimttNCH7Cktohb6kFpoEfIsdDF7SgvZTbaY3mKFyLXQh+wpzWE3mUNQQkWnR+lgiW9Afkunej49Nz3sYlI8XFTRdkNhUR5d6h4oOpJc8OjcItMXVqoHW2fSW6ycWuiM8NDYoICouLAZ9BYrwwmhycvKlt4Q8hUlCV5nZ7vOm2ut2rizcFpuWpSrT3K1Z3xinbuHnXBTyGAljV/XzHZaNGu6y7vLDziMpIyUdOBBRXXlxznUQiOoheZsZk9njG8er1mNmz2eOCQ3x9BbLP+Zxt+VrbEz9aWxmimRvyl4/sumyoM/nw+LNzV4/uP0/9T/P5f08cvhl38USAS/xTYs2fL/VNFF0vd+SVsRB/khPwW4SCi5SHhx8fDgVPAiAqIJRQL/EuK5bsRzNnAiezD1i7u2VyHHAKRU+E2YUaA5DUsE2ZfbApAmsJcxjBwmQ2Xk4Y2BqZJ+oxSzsNEogz1O3/xkBMKEBHSiC8PoQStaoEIflPCHL/wQBCUKoUITlMhHP+olt5ojykUPOrEQTWgY+f449KMPKnSiB72jGrLQhEZO24925LtbkG5DndTkD2/4um+NQBEyUIJsRBxX24tcDGmbWtGJjq05jhzTaMgCcR+6EA4f+KAXDay5u6RsL7xJsm3w3mzvFvggB8nI/AYBdVnxNvx/2wA=') format('woff2');
} }
@@ -21,6 +19,7 @@
} }
.intro { .intro {
composes: abs-fill from '../../lib/util.scss';
display: grid; display: grid;
grid-template-rows: 1fr min-content; grid-template-rows: 1fr min-content;
align-items: center; align-items: center;
@@ -30,9 +29,6 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overflow: auto; overflow: auto;
padding: 20px 0 0; padding: 20px 0 0;
height: 100%;
box-sizing: border-box;
overscroll-behavior: contain;
} }
.logo-container { .logo-container {
@@ -42,17 +38,16 @@
.logo-sizer { .logo-sizer {
width: 90%; width: 90%;
max-width: 52vh; max-width: 480px;
margin: 0 auto; margin: 0 auto;
} }
.logo { .logo {
composes: abs-fill from '../../lib/util.scss'; composes: abs-fill from '../../lib/util.scss';
pointer-events: none;
} }
.open-image-guide { .open-image-guide {
font: 300 11vw intro-text, sans-serif; font: 300 11vw intro-text;
margin-bottom: 0; margin-bottom: 0;
@media (min-width: 460px) { @media (min-width: 460px) {
@@ -145,7 +140,6 @@
.demo-icon { .demo-icon {
composes: abs-fill from '../../lib/util.scss'; composes: abs-fill from '../../lib/util.scss';
pointer-events: none;
} }
.demo-description { .demo-description {

Some files were not shown because too many files have changed in this diff Show More