Make image crop target size configurable

This commit is contained in:
2026-01-25 16:54:01 +01:00
parent 56760e380d
commit 78f0f379fc
4 changed files with 45 additions and 7 deletions

View File

@@ -20,6 +20,24 @@ def create_app():
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False) app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False)
app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads")) app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads"))
# Target output resolution for cropped images.
# This is used by the client-side cropper (to generate an upload) and by the server-side
# image processing (to cap the resulting WEBP size).
#
# Defaults to Full HD landscape (1920x1080). Portrait is derived by swapping.
# Override via env vars, e.g.:
# IMAGE_CROP_TARGET_W=1920
# IMAGE_CROP_TARGET_H=1080
def _env_int(name: str, default: int) -> int:
try:
v = int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
v = default
return max(1, v)
app.config.setdefault("IMAGE_CROP_TARGET_W", _env_int("IMAGE_CROP_TARGET_W", 1920))
app.config.setdefault("IMAGE_CROP_TARGET_H", _env_int("IMAGE_CROP_TARGET_H", 1080))
# NOTE: Videos should be max 250MB. # NOTE: Videos should be max 250MB.
# Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead). # Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead).
# We set this slightly above 250MB to allow for multipart/form fields overhead, while still # We set this slightly above 250MB to allow for multipart/form fields overhead, while still

View File

@@ -147,15 +147,22 @@ def _save_compressed_image(
img = img.convert("RGB") img = img.convert("RGB")
# Optional crop # Optional crop
# NOTE: The front-end may already upload a cropped image (canvas export), but we still
# enforce aspect + maximum output size here for consistency.
target_w = int(current_app.config.get("IMAGE_CROP_TARGET_W", 1920) or 1920)
target_h = int(current_app.config.get("IMAGE_CROP_TARGET_H", 1080) or 1080)
target_w = max(1, target_w)
target_h = max(1, target_h)
if cm == "16:9": if cm == "16:9":
img = _center_crop_to_aspect(img, 16, 9) img = _center_crop_to_aspect(img, 16, 9)
max_box = (1920, 1080) max_box = (target_w, target_h)
elif cm == "9:16": elif cm == "9:16":
img = _center_crop_to_aspect(img, 9, 16) img = _center_crop_to_aspect(img, 9, 16)
max_box = (1080, 1920) max_box = (target_h, target_w)
else: else:
# No crop: allow both portrait and landscape up to 1920px on the longest side. # No crop: allow both portrait and landscape up to target_w/target_h on the longest side.
max_box = (1920, 1920) max_box = (max(target_w, target_h),) * 2
# Resize down if very large (keeps aspect ratio) # Resize down if very large (keeps aspect ratio)
img.thumbnail(max_box) img.thumbnail(max_box)

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h1 class="page-title">Welcome{% if current_user and current_user.email %}, {{ current_user.email }}{% endif %}!</h1> <h1 class="page-title">Dashboard</h1>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">

View File

@@ -1,5 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
{# Expose server-side crop target sizes to the JS without embedding Jinja inside JS #}
<div
id="page-config"
class="d-none"
data-image-crop-target-w="{{ config.get('IMAGE_CROP_TARGET_W', 1920) }}"
data-image-crop-target-h="{{ config.get('IMAGE_CROP_TARGET_H', 1080) }}"
></div>
{# Cropper.js (used for image cropping) #} {# Cropper.js (used for image cropping) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" />
<style> <style>
@@ -912,9 +919,15 @@
cropStatus.textContent = 'Preparing cropped image…'; cropStatus.textContent = 'Preparing cropped image…';
const isPortrait = cm === '9:16'; const isPortrait = cm === '9:16';
// Export at Full HD by default (or whatever the server config says).
// We still enforce a server-side max output size in _save_compressed_image.
const cfg = document.getElementById('page-config');
const TARGET_W = parseInt(cfg?.dataset?.imageCropTargetW || '1920', 10) || 1920;
const TARGET_H = parseInt(cfg?.dataset?.imageCropTargetH || '1080', 10) || 1080;
const canvas = cropper.getCroppedCanvas({ const canvas = cropper.getCroppedCanvas({
width: isPortrait ? 720 : 1280, width: isPortrait ? TARGET_H : TARGET_W,
height: isPortrait ? 1280 : 720, height: isPortrait ? TARGET_W : TARGET_H,
imageSmoothingQuality: 'high', imageSmoothingQuality: 'high',
}); });
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));