Browse Source

adding forced resizing to each gallery upload

Medowar 10 giờ trước cách đây
mục cha
commit
3a3f63ebb6
11 tập tin đã thay đổi với 266 bổ sung48 xóa
  1. 4 0
      admin/galleries.php
  2. 14 2
      admin/gallery-edit.php
  3. 53 0
      app/partials.php
  4. 48 0
      app/storage.php
  5. 95 39
      assets/admin.js
  6. 7 1
      assets/site.css
  7. 4 0
      config/config.sample.php
  8. 14 3
      docs/ADMIN-GUIDE.md
  9. 16 1
      docs/ARCHITECTURE.md
  10. 1 0
      docs/SETUP.md
  11. 10 2
      upload.php

+ 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>