|
@@ -2,7 +2,8 @@
|
|
|
* Gallery bulk uploader.
|
|
* Gallery bulk uploader.
|
|
|
*
|
|
*
|
|
|
* Per file, one request:
|
|
* Per file, one request:
|
|
|
- * 1. draw a small JPEG thumbnail on a canvas (browser-side)
|
|
|
|
|
|
|
+ * 1. draw a small JPEG thumbnail on a canvas (browser-side), and when the
|
|
|
|
|
+ * gallery caps its resolution, a downscaled copy of the original too
|
|
|
* 2. POST the original + thumbnail to admin/api.php as multipart/form-data
|
|
* 2. POST the original + thumbnail to admin/api.php as multipart/form-data
|
|
|
* 3. the webhost streams both to S3 and registers the image
|
|
* 3. the webhost streams both to S3 and registers the image
|
|
|
*
|
|
*
|
|
@@ -34,6 +35,11 @@
|
|
|
var uploadKey = zone.dataset.key || '';
|
|
var uploadKey = zone.dataset.key || '';
|
|
|
var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
|
|
var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
|
|
|
var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8;
|
|
var thumbQuality = parseFloat(zone.dataset.thumbQuality) || 0.8;
|
|
|
|
|
+ // Per-gallery cap on the longest edge of the stored image; 0 = keep the
|
|
|
|
|
+ // original. Best-effort by nature: it only applies to files this browser
|
|
|
|
|
+ // can decode, so a RAW still goes up untouched.
|
|
|
|
|
+ var maxRes = parseInt(zone.dataset.maxResolution, 10) || 0;
|
|
|
|
|
+ var resizeQuality = parseFloat(zone.dataset.resizeQuality) || 0.9;
|
|
|
// How many uploads may be in flight at once. Small on purpose: each one
|
|
// How many uploads may be in flight at once. Small on purpose: each one
|
|
|
// occupies a PHP worker on the webhost for its whole S3 round trip.
|
|
// occupies a PHP worker on the webhost for its whole S3 round trip.
|
|
|
var maxParallel = Math.max(1, parseInt(zone.dataset.concurrency, 10) || 3);
|
|
var maxParallel = Math.max(1, parseInt(zone.dataset.concurrency, 10) || 3);
|
|
@@ -144,57 +150,107 @@
|
|
|
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
|
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /* Thumbnail as JPEG blob; null when the browser cannot decode the file
|
|
|
|
|
- (e.g. RAW) — the original still uploads untouched.
|
|
|
|
|
-
|
|
|
|
|
- createImageBitmap gets a resize hint so it can downsample while decoding
|
|
|
|
|
- (a JPEG decoder scales by DCT factors) instead of materialising a
|
|
|
|
|
- full-resolution bitmap — much faster and far less memory on 40MP files.
|
|
|
|
|
- Only the width is given, so the aspect ratio is preserved; the canvas
|
|
|
|
|
- step below still fixes the exact long edge. The hint is skipped for small
|
|
|
|
|
- files, where it could upscale before we downscale again, and browsers
|
|
|
|
|
- that ignore resizeWidth simply return the full-size bitmap. */
|
|
|
|
|
- function makeThumb(file) {
|
|
|
|
|
|
|
+ /* Decode a file to a bitmap, downsampled to hintEdge where the browser can
|
|
|
|
|
+ do it during the decode itself (a JPEG decoder scales by DCT factors)
|
|
|
|
|
+ instead of materialising a full-resolution bitmap — much faster and far
|
|
|
|
|
+ less memory on 40MP files. Only the width is given, so the aspect ratio
|
|
|
|
|
+ is preserved; the canvas step below still fixes the exact long edge, and
|
|
|
|
|
+ browsers that ignore resizeWidth simply return the full-size bitmap.
|
|
|
|
|
+
|
|
|
|
|
+ Pass hintEdge 0 to decode at natural size. resizeWidth scales *up* as
|
|
|
|
|
+ readily as down and the bitmap keeps no record of which happened, so a
|
|
|
|
|
+ hinted decode cannot tell "shrunk from 6000px" from "stretched from
|
|
|
|
|
+ 900px". Harmless for a thumbnail, which ends up small either way; not
|
|
|
|
|
+ harmless for pixels we are about to store, which is why the resize path
|
|
|
|
|
+ decodes unhinted. Small files skip the hint for the same reason. */
|
|
|
|
|
+ function decodeImage(file, hintEdge) {
|
|
|
var options = { imageOrientation: 'from-image' };
|
|
var options = { imageOrientation: 'from-image' };
|
|
|
- if (file.size > 2 * 1024 * 1024) {
|
|
|
|
|
- options.resizeWidth = thumbSize;
|
|
|
|
|
|
|
+ if (hintEdge && file.size > 2 * 1024 * 1024) {
|
|
|
|
|
+ options.resizeWidth = hintEdge;
|
|
|
options.resizeQuality = 'high';
|
|
options.resizeQuality = 'high';
|
|
|
}
|
|
}
|
|
|
- var decode = window.createImageBitmap
|
|
|
|
|
- ? createImageBitmap(file, options)
|
|
|
|
|
- : new Promise(function (resolve, reject) {
|
|
|
|
|
- var img = new Image();
|
|
|
|
|
- img.onload = function () { resolve(img); };
|
|
|
|
|
- img.onerror = reject;
|
|
|
|
|
- img.src = URL.createObjectURL(file);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ if (window.createImageBitmap) return createImageBitmap(file, options);
|
|
|
|
|
+ return new Promise(function (resolve, reject) {
|
|
|
|
|
+ var img = new Image();
|
|
|
|
|
+ img.onload = function () { resolve(img); };
|
|
|
|
|
+ img.onerror = reject;
|
|
|
|
|
+ img.src = URL.createObjectURL(file);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- return decode.then(function (src) {
|
|
|
|
|
- var w = src.width, h = src.height;
|
|
|
|
|
- var scale = Math.min(1, thumbSize / Math.max(w, h));
|
|
|
|
|
- var canvas = document.createElement('canvas');
|
|
|
|
|
- canvas.width = Math.max(1, Math.round(w * scale));
|
|
|
|
|
- canvas.height = Math.max(1, Math.round(h * scale));
|
|
|
|
|
- canvas.getContext('2d').drawImage(src, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
- if (src.close) src.close();
|
|
|
|
|
- return new Promise(function (resolve) {
|
|
|
|
|
- canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', thumbQuality);
|
|
|
|
|
|
|
+ /* One JPEG blob, longest edge at most maxEdge. Never upscales. */
|
|
|
|
|
+ function drawScaled(src, maxEdge, quality) {
|
|
|
|
|
+ var scale = Math.min(1, maxEdge / Math.max(src.width, src.height));
|
|
|
|
|
+ var canvas = document.createElement('canvas');
|
|
|
|
|
+ canvas.width = Math.max(1, Math.round(src.width * scale));
|
|
|
|
|
+ canvas.height = Math.max(1, Math.round(src.height * scale));
|
|
|
|
|
+ var ctx = canvas.getContext('2d');
|
|
|
|
|
+ // JPEG has no alpha, so a transparent PNG would encode onto black.
|
|
|
|
|
+ ctx.fillStyle = '#fff';
|
|
|
|
|
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
+ ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
+ return new Promise(function (resolve) {
|
|
|
|
|
+ canvas.toBlob(function (blob) { resolve(blob); }, 'image/jpeg', quality);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* Decode/resize runs one file at a time even though uploads overlap: it is
|
|
|
|
|
+ main-thread canvas work, and a capped gallery decodes at full size, so
|
|
|
|
|
+ three at once is three 40MP bitmaps — enough to jank or exhaust the
|
|
|
|
|
+ phones that use the guest link. Parallelism is worth having on the
|
|
|
|
|
+ network leg, not here. */
|
|
|
|
|
+ var cpuChain = Promise.resolve();
|
|
|
|
|
+ function serialize(work) {
|
|
|
|
|
+ var result = cpuChain.then(work);
|
|
|
|
|
+ // Keep the chain alive after a rejection, and unhandled-rejection-free.
|
|
|
|
|
+ cpuChain = result.catch(function () {});
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* Derive what we send: the grid thumbnail, plus a downscaled original when
|
|
|
|
|
+ the gallery caps its resolution and the image exceeds it — both off a
|
|
|
|
|
+ single decode. Returns nulls when the browser cannot decode the file
|
|
|
|
|
+ (e.g. RAW) — the original then uploads untouched, thumbnail-less,
|
|
|
|
|
+ exactly as before.
|
|
|
|
|
+
|
|
|
|
|
+ An uncapped gallery decodes exactly as it did before this option
|
|
|
|
|
+ existed: hinted down to the thumbnail, since nothing else is kept. A
|
|
|
|
|
+ capped one pays for a full-size decode, which is why this stage is
|
|
|
|
|
+ serialised — one large bitmap at a time, not one per upload slot. */
|
|
|
|
|
+ function prepare(file) {
|
|
|
|
|
+ return serialize(function () {
|
|
|
|
|
+ return decodeImage(file, maxRes > 0 ? 0 : thumbSize).then(function (src) {
|
|
|
|
|
+ var oversized = maxRes > 0 && Math.max(src.width, src.height) > maxRes;
|
|
|
|
|
+ return drawScaled(src, thumbSize, thumbQuality).then(function (thumb) {
|
|
|
|
|
+ if (!oversized) return { thumb: thumb, resized: null };
|
|
|
|
|
+ return drawScaled(src, maxRes, resizeQuality).then(function (resized) {
|
|
|
|
|
+ return { thumb: thumb, resized: resized };
|
|
|
|
|
+ });
|
|
|
|
|
+ }).finally(function () { if (src.close) src.close(); });
|
|
|
});
|
|
});
|
|
|
- }).catch(function () { return null; });
|
|
|
|
|
|
|
+ }).catch(function () { return { thumb: null, resized: null }; });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* A re-encoded file must not be stored under its old extension. */
|
|
|
|
|
+ function jpegName(name) {
|
|
|
|
|
+ return name.replace(/\.[^.\/]*$/, '') + '.jpg';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function uploadOne(file, row) {
|
|
function uploadOne(file, row) {
|
|
|
var bar = row.querySelector('.bar i');
|
|
var bar = row.querySelector('.bar i');
|
|
|
- setState(row, 'thumbnail');
|
|
|
|
|
|
|
+ setState(row, maxRes ? 'resizing' : 'thumbnail');
|
|
|
|
|
|
|
|
- return makeThumb(file).then(function (thumbBlob) {
|
|
|
|
|
- // The same FormData is re-sent on retry: it reads from the File on
|
|
|
|
|
- // disk each time, so nothing is buffered between attempts.
|
|
|
|
|
|
|
+ return prepare(file).then(function (out) {
|
|
|
|
|
+ // The same FormData is re-sent on retry. An untouched original is
|
|
|
|
|
+ // read from the File on disk each time; a resized blob is held in
|
|
|
|
|
+ // memory for the job (a megabyte or two), which also makes a retry
|
|
|
|
|
+ // cheaper — nothing is decoded or re-encoded twice.
|
|
|
var form = new FormData();
|
|
var form = new FormData();
|
|
|
form.append('slug', slug);
|
|
form.append('slug', slug);
|
|
|
if (uploadKey) form.append('key', uploadKey);
|
|
if (uploadKey) form.append('key', uploadKey);
|
|
|
- form.append('original', file, file.name);
|
|
|
|
|
- if (thumbBlob) form.append('thumb', thumbBlob, 'thumb.jpg');
|
|
|
|
|
|
|
+ if (out.resized) form.append('original', out.resized, jpegName(file.name));
|
|
|
|
|
+ else form.append('original', file, file.name);
|
|
|
|
|
+ if (out.thumb) form.append('thumb', out.thumb, 'thumb.jpg');
|
|
|
|
|
|
|
|
function attempt(n) {
|
|
function attempt(n) {
|
|
|
setState(row, n === 1 ? 'uploading' : 'retrying ' + n + '/' + maxAttempts);
|
|
setState(row, n === 1 ? 'uploading' : 'retrying ' + n + '/' + maxAttempts);
|