Kaynağa Gözat

adding forced resizing to each gallery upload

Medowar 7 saat önce
ebeveyn
işleme
3a3f63ebb6

+ 4 - 0
admin/galleries.php

@@ -23,6 +23,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
             'created_at'    => date('Y-m-d H:i:s'),
             'password_hash' => $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : null,
             'expires_at'    => trim((string)($_POST['expires_at'] ?? '')) ?: null,
+            // Longest edge the browser downscales uploads to; null = original.
+            'max_resolution' => parse_max_resolution($_POST),
             'images'        => [],
         ];
         // A guest upload link is just a per-gallery secret in the URL; presence
@@ -63,6 +65,7 @@ flash_render();
     <input type="text" id="p" name="password" autocomplete="off">
     <label for="ex">Expiry date <span style="text-transform:none;letter-spacing:0">(optional — gallery is hidden after this day)</span></label>
     <input type="date" id="ex" name="expires_at">
+    <?php resolution_field() ?>
     <p class="help"><label style="display:inline;text-transform:none;letter-spacing:0">
         <input type="checkbox" name="allow_uploads" value="1"> Allow guest uploads via a shared link
     </label></p>
@@ -82,6 +85,7 @@ flash_render();
         <td>
             <?= !empty($g['password_hash']) ? '<span class="tag tag-lock">password</span>' : '<span class="tag">open</span>' ?>
             <?= !empty($g['upload_key']) ? ' <span class="tag">uploads</span>' : '' ?>
+            <?= isset($g['max_resolution']) ? ' <span class="tag">' . e(resolution_label((int)$g['max_resolution'])) . '</span>' : '' ?>
         </td>
         <td>
             <?= e($g['expires_at'] ?? '—') ?>

+ 14 - 2
admin/gallery-edit.php

@@ -20,6 +20,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
     if ($action === 'settings') {
         $gallery['title'] = trim((string)($_POST['title'] ?? '')) ?: $gallery['title'];
         $gallery['expires_at'] = trim((string)($_POST['expires_at'] ?? '')) ?: null;
+        // Unlike the fields above there is no keep-current fallback: the select
+        // always posts, and an empty value genuinely means "back to Original".
+        $gallery['max_resolution'] = parse_max_resolution($_POST);
         if (!empty($_POST['remove_password'])) {
             $gallery['password_hash'] = null;
         } elseif (($pw = (string)($_POST['password'] ?? '')) !== '') {
@@ -96,9 +99,17 @@ flash_render();
          data-csrf="<?= e(csrf_token()) ?>"
          data-thumb-size="<?= (int)config('uploads.thumb_size', 600) ?>"
          data-thumb-quality="<?= e((string)config('uploads.thumb_quality', 0.8)) ?>"
-         data-concurrency="<?= (int)config('uploads.concurrency', 3) ?>">
+         data-concurrency="<?= (int)config('uploads.concurrency', 3) ?>"
+         data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
+         data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
         Drop images here or click to select.<br>
-        <small>Uploaded through the site to S3, in full resolution, unmodified.</small>
+        <small>
+            <?php if (isset($gallery['max_resolution'])): ?>
+                Uploaded through the site to S3, downscaled to <?= (int)$gallery['max_resolution'] ?> px on the longest edge.
+            <?php else: ?>
+                Uploaded through the site to S3, in full resolution, unmodified.
+            <?php endif; ?>
+        </small>
     </div>
     <input type="file" id="file-input" accept="image/*" multiple style="display:none">
     <div class="upload-list" id="upload-list"></div>
@@ -151,6 +162,7 @@ flash_render();
     <input type="text" id="t" name="title" value="<?= e($gallery['title']) ?>">
     <label for="ex">Expiry date (blank = never)</label>
     <input type="date" id="ex" name="expires_at" value="<?= e($gallery['expires_at'] ?? '') ?>">
+    <?php resolution_field(isset($gallery['max_resolution']) ? (int)$gallery['max_resolution'] : null) ?>
     <label for="p">Set new password (blank = keep current)</label>
     <input type="text" id="p" name="password" autocomplete="off">
     <?php if (!empty($gallery['password_hash'])): ?>

+ 53 - 0
app/partials.php

@@ -81,6 +81,59 @@ function admin_footer(): void
 <?php
 }
 
+/**
+ * The "resize uploads to" control, shared by the gallery create and settings
+ * forms. $current is the gallery's stored cap in pixels, or null for Original.
+ *
+ * The select carries pixel values, never names, so the handler
+ * (parse_max_resolution) never has to map a label back to a number. The custom
+ * input is revealed by a tiny delegated script emitted once per page —
+ * assets/admin.js is the uploader and is not loaded on galleries.php.
+ */
+function resolution_field(?int $current = null): void
+{
+    $isPreset = $current !== null && in_array($current, RESOLUTION_PRESETS, true);
+    $isCustom = $current !== null && !$isPreset;
+    ?>
+    <label for="res">Resize uploads to <span style="text-transform:none;letter-spacing:0">(longest edge)</span></label>
+    <div class="res-field">
+        <select id="res" name="max_resolution">
+            <option value="" <?= $current === null ? 'selected' : '' ?>>Original — no resize</option>
+            <?php foreach (RESOLUTION_PRESETS as $px): ?>
+                <option value="<?= $px ?>" <?= $current === $px ? 'selected' : '' ?>><?= e(resolution_label($px)) ?></option>
+            <?php endforeach; ?>
+            <option value="custom" <?= $isCustom ? 'selected' : '' ?>>Custom…</option>
+        </select>
+        <input type="number" name="max_resolution_custom" placeholder="e.g. 3000"
+               min="<?= RESOLUTION_MIN ?>" max="<?= RESOLUTION_MAX ?>"
+               value="<?= $isCustom ? (int)$current : '' ?>" <?= $isCustom ? '' : 'hidden' ?>>
+    </div>
+    <p class="help">
+        Images larger than this are downscaled in the browser before uploading,
+        which also keeps them under the server's upload limit. The re-encode
+        drops EXIF data (camera, lens, date, location) — choose Original to keep
+        it. Files the browser cannot read, such as RAW, are always uploaded
+        untouched.
+    </p>
+    <?php
+    static $scriptDone = false;
+    if ($scriptDone) {
+        return;
+    }
+    $scriptDone = true;
+    ?>
+    <script>
+    document.addEventListener('change', function (e) {
+        var select = e.target.closest('.res-field select');
+        if (!select) return;
+        var custom = select.parentNode.querySelector('input[name="max_resolution_custom"]');
+        custom.hidden = select.value !== 'custom';
+        if (!custom.hidden) custom.focus();
+    });
+    </script>
+    <?php
+}
+
 /** One-shot status message helpers (flash messages via session). */
 function flash_set(string $msg, string $kind = 'ok'): void
 {

+ 48 - 0
app/storage.php

@@ -287,3 +287,51 @@ function gallery_is_expired(array $gallery): bool
     // The gallery stays visible through the whole expiry day.
     return date('Y-m-d') > $expires;
 }
+
+// ---------------------------------------------------------------------------
+// Upload resolution cap (per gallery)
+// ---------------------------------------------------------------------------
+
+/**
+ * Named sizes offered in the gallery forms, largest first. Only the pixel value
+ * is ever stored, so renaming a preset here cannot orphan existing galleries —
+ * a gallery capped at 2560 simply starts reading as whatever that number is
+ * called now, and a value matching no preset renders as bare pixels.
+ */
+const RESOLUTION_PRESETS = [
+    'Ultra' => 4096,
+    'High'  => 2560,
+    'Mid'   => 1920,
+    'Low'   => 1280,
+];
+const RESOLUTION_MIN = 320;
+const RESOLUTION_MAX = 12000;
+
+/**
+ * Read a max_resolution choice from a submitted form: a preset's pixel value, a
+ * custom number, or null for "Original" (no resize). Out-of-range custom values
+ * are clamped rather than rejected — a typo becomes the nearest sane cap
+ * instead of silently turning the resize off.
+ */
+function parse_max_resolution(array $post): ?int
+{
+    $choice = trim((string)($post['max_resolution'] ?? ''));
+    $value  = $choice === 'custom'
+        ? trim((string)($post['max_resolution_custom'] ?? ''))
+        : $choice;
+
+    if ($value === '' || !ctype_digit($value)) {
+        return null;
+    }
+    return max(RESOLUTION_MIN, min(RESOLUTION_MAX, (int)$value));
+}
+
+/** Human label for a cap: "High (2560 px)", "800 px", or "Original". */
+function resolution_label(?int $px): string
+{
+    if ($px === null) {
+        return 'Original';
+    }
+    $name = array_search($px, RESOLUTION_PRESETS, true);
+    return $name === false ? "$px px" : "$name ($px px)";
+}

+ 95 - 39
assets/admin.js

@@ -2,7 +2,8 @@
  * Gallery bulk uploader.
  *
  * 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
  *   3. the webhost streams both to S3 and registers the image
  *
@@ -34,6 +35,11 @@
     var uploadKey = zone.dataset.key || '';
     var thumbSize = parseInt(zone.dataset.thumbSize, 10) || 600;
     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
     // occupies a PHP worker on the webhost for its whole S3 round trip.
     var maxParallel = Math.max(1, parseInt(zone.dataset.concurrency, 10) || 3);
@@ -144,57 +150,107 @@
         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' };
-        if (file.size > 2 * 1024 * 1024) {
-            options.resizeWidth = thumbSize;
+        if (hintEdge && file.size > 2 * 1024 * 1024) {
+            options.resizeWidth = hintEdge;
             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) {
         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();
             form.append('slug', slug);
             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) {
                 setState(row, n === 1 ? 'uploading' : 'retrying ' + n + '/' + maxAttempts);

+ 7 - 1
assets/site.css

@@ -390,7 +390,7 @@ body.reel-page { scroll-snap-type: y mandatory; }
 
 label { display: block; font-size: .8rem; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 1.1rem 0 .35rem; }
 
-input[type=text], input[type=password], input[type=date], textarea, select {
+input[type=text], input[type=password], input[type=date], input[type=number], textarea, select {
     width: 100%;
     padding: .7rem .9rem;
     background: var(--bg-raise);
@@ -402,6 +402,12 @@ input[type=text], input[type=password], input[type=date], textarea, select {
 
 input:focus, textarea:focus { outline: 1px solid var(--muted); }
 
+/* Resolution picker: preset select, plus a custom px box when "Custom…" is
+   chosen. The box is [hidden] otherwise, so the gap collapses on its own. */
+.res-field { display: flex; gap: .6rem; }
+.res-field select { flex: 1; }
+.res-field input[type=number] { flex: 0 0 9rem; }
+
 textarea { min-height: 9rem; resize: vertical; }
 
 button, .btn {

+ 4 - 0
config/config.sample.php

@@ -44,6 +44,10 @@ return [
         'thumb_size'    => 600,
         // JPEG quality for thumbnails (0.0 - 1.0, used by the browser canvas).
         'thumb_quality' => 0.8,
+        // JPEG quality for downscaled originals (0.0 - 1.0). Only applies to
+        // galleries that set a resolution cap; "Original" galleries are never
+        // re-encoded.
+        'resize_quality' => 0.9,
         // How many images the browser uploads at once. Each one holds a PHP
         // worker on the webhost for its whole S3 round trip, so keep this
         // below the host's per-site process limit. 1 restores serial uploads.

+ 14 - 3
docs/ADMIN-GUIDE.md

@@ -27,15 +27,26 @@ the webhost.
   set, change, or remove it later.
 - **Expiry date** — after this day the gallery link shows "not available".
   Nothing is deleted; clearing the date brings it back.
+- **Resize uploads to** — cap the longest edge of every image uploaded into
+  this gallery: *Ultra (4096 px)*, *High (2560 px)*, *Mid (1920 px)*,
+  *Low (1280 px)*, or your own number. The default, *Original*, stores what the
+  camera produced. Resizing happens in your browser before the file is sent, so
+  it also gets big images past the webhost's upload limit — but re-saving the
+  image drops its EXIF data (camera, lens, date, location), so choose
+  *Original* when that matters. Files the browser cannot read, such as RAW, are
+  uploaded at full size regardless.
+
+All of these can be changed later in the gallery editor. Changing the
+resolution affects new uploads only; images already in the gallery stay as they
+were stored.
 
 Each gallery gets an unguessable link like
 `/gallery.php?g=wedding-mueller-x7Kf3q` — copy the *Share link* from the
 gallery editor and send it to your client.
 
 **Upload images** by dropping them onto the upload area in the gallery editor.
-Files travel directly from your browser to the S3 storage in **full
-resolution, byte-for-byte unmodified** — the webhost never touches them, so
-there is no server upload limit. Keep the browser tab open until every file
+Unless the gallery caps its resolution (above), files are stored in **full
+resolution, byte-for-byte unmodified**. Keep the browser tab open until every file
 shows *done*; failed files offer a *retry* link. A small preview thumbnail is
 generated by your browser for the gallery grid; files the browser cannot
 decode (e.g. RAW) are uploaded anyway, just without a preview.

+ 16 - 1
docs/ARCHITECTURE.md

@@ -41,6 +41,7 @@ router.php         local dev only: applies the .htaccess rules under php -S
     "created_at": "2026-07-05 12:00:00",
     "password_hash": "$2y$...",        // or null
     "expires_at": "2026-12-31",         // or null
+    "max_resolution": 2560,             // longest edge in px, or null = original
     "images": [
       { "key":   "<prefix>/<slug>/originals/a1b2c3-DSC_0001.jpg",
         "thumb": "<prefix>/<slug>/thumbs/a1b2c3-DSC_0001.jpg.jpg",
@@ -105,7 +106,21 @@ rotation, with a `resizeWidth` hint so large JPEGs downsample during decode
 instead of being decoded at full resolution) and sent alongside the original;
 undecodable files (RAW, video) upload without a thumbnail and the grid falls
 back to the original key. A random 6-char token per file prevents same-filename
-collisions. Object keys are laid out as `<prefix>/<slug>/{originals,thumbs}/…`,
+collisions.
+
+A gallery may cap its stored resolution (`max_resolution`). The cap is applied in
+the browser, off the same decode as the thumbnail, so the smaller file is what
+crosses the wire and PHP's `upload_max_filesize` stops being the ceiling on image
+size. The re-encode costs the EXIF block, which is why "Original" is the default.
+
+Two consequences are worth knowing. A capped gallery decodes at natural size
+rather than using the `resizeWidth` hint: that hint scales up as readily as
+down and the resulting bitmap carries no memory of which happened, which is
+fine for a thumbnail but not for pixels about to be stored. To pay for that,
+decode and resize run one file at a time even while uploads overlap — it is
+main-thread canvas work, and concurrent full-size bitmaps are what actually
+exhausts a phone. Second, undecodable files (RAW) ignore the cap and upload
+whole, so it is best-effort, not enforced: the server stores what arrives. Object keys are laid out as `<prefix>/<slug>/{originals,thumbs}/…`,
 where `<prefix>` comes from `s3.prefix` (default `galleries`, `''` = bucket
 root).
 

+ 1 - 0
docs/SETUP.md

@@ -68,6 +68,7 @@ Edit `config/config.php`:
 | `s3.access_key` / `s3.secret_key` | S3 credentials |
 | `s3.url_ttl` | Lifetime of presigned view URLs in seconds |
 | `uploads.thumb_size` | Longest edge of grid thumbnails (browser-generated) |
+| `uploads.resize_quality` | JPEG quality (0.0–1.0) for galleries that cap their upload resolution |
 
 The default admin login is `admin` / `changeme` — **change it in the admin
 Settings page immediately after the first login.**

+ 10 - 2
upload.php

@@ -72,9 +72,17 @@ public_header(e($gallery['title']));
          data-key="<?= e((string)$gallery['upload_key']) ?>"
          data-thumb-size="<?= (int)config('uploads.thumb_size', 600) ?>"
          data-thumb-quality="<?= e((string)config('uploads.thumb_quality', 0.8)) ?>"
-         data-concurrency="<?= (int)config('uploads.concurrency', 3) ?>">
+         data-concurrency="<?= (int)config('uploads.concurrency', 3) ?>"
+         data-max-resolution="<?= (int)($gallery['max_resolution'] ?? 0) ?>"
+         data-resize-quality="<?= e((string)config('uploads.resize_quality', 0.9)) ?>">
         Drop images here or click to select.<br>
-        <small>Full resolution, unmodified.</small>
+        <small>
+            <?php if (isset($gallery['max_resolution'])): ?>
+                Downscaled to <?= (int)$gallery['max_resolution'] ?> px on the longest edge.
+            <?php else: ?>
+                Full resolution, unmodified.
+            <?php endif; ?>
+        </small>
     </div>
     <input type="file" id="file-input" accept="image/*" multiple style="display:none">
     <div class="upload-list" id="upload-list"></div>