dschandra commited on
Commit
6a8513b
·
verified ·
1 Parent(s): c1362e6

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +590 -0
app.py ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import math
4
+ from flask import Flask, request, jsonify, send_file, render_template_string
5
+ from PIL import Image
6
+ from werkzeug.utils import secure_filename
7
+
8
+ app = Flask(__name__)
9
+ app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB max upload
10
+
11
+ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'webp'}
12
+
13
+ def allowed_file(filename):
14
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
15
+
16
+ def get_file_size_label(size_bytes):
17
+ if size_bytes < 1024 * 1024:
18
+ return f"{size_bytes / 1024:.1f} KB"
19
+ return f"{size_bytes / (1024 * 1024):.2f} MB"
20
+
21
+ def compress_to_target(img, fmt, target_bytes, original_bytes):
22
+ """
23
+ Smart compression: reduce file size to target_bytes without visible quality loss.
24
+ Strategy:
25
+ 1. Try lossless/near-lossless optimization first (subsampling, optimize flags).
26
+ 2. If still too large, gently reduce quality in steps.
27
+ 3. Never go below quality=75 to avoid visible degradation.
28
+ """
29
+ buf = io.BytesIO()
30
+
31
+ # ---- PNG path (lossless format) ----
32
+ if fmt == "PNG":
33
+ # PNG compression level 0-9; higher = smaller but slower
34
+ for level in range(1, 10):
35
+ buf = io.BytesIO()
36
+ img.save(buf, format="PNG", optimize=True, compress_level=level)
37
+ if buf.tell() <= target_bytes:
38
+ buf.seek(0)
39
+ return buf, buf.tell(), "PNG"
40
+ # If PNG can't reach target, convert to WebP lossless
41
+ buf = io.BytesIO()
42
+ img.save(buf, format="WEBP", lossless=True, quality=100)
43
+ if buf.tell() <= target_bytes:
44
+ buf.seek(0)
45
+ return buf, buf.tell(), "WEBP"
46
+ # Last resort: lossy WebP but keep quality high (>=80)
47
+ for q in range(95, 79, -5):
48
+ buf = io.BytesIO()
49
+ img.save(buf, format="WEBP", quality=q, method=6)
50
+ if buf.tell() <= target_bytes:
51
+ buf.seek(0)
52
+ return buf, buf.tell(), "WEBP"
53
+ buf.seek(0)
54
+ return buf, buf.tell(), "WEBP"
55
+
56
+ # ---- JPEG / WEBP path (lossy formats) ----
57
+ save_fmt = "JPEG" if fmt in ("JPEG", "JPG") else "WEBP"
58
+
59
+ # First pass: high quality with optimize
60
+ for q in range(95, 74, -2):
61
+ buf = io.BytesIO()
62
+ if save_fmt == "JPEG":
63
+ img.save(buf, format="JPEG", quality=q, optimize=True,
64
+ subsampling=0 if q >= 85 else 2)
65
+ else:
66
+ img.save(buf, format="WEBP", quality=q, method=6)
67
+ if buf.tell() <= target_bytes:
68
+ buf.seek(0)
69
+ return buf, buf.tell(), save_fmt
70
+
71
+ # Reached minimum quality — return best effort
72
+ buf.seek(0)
73
+ return buf, buf.tell(), save_fmt
74
+
75
+
76
+ HTML_PAGE = """
77
+ <!DOCTYPE html>
78
+ <html lang="en">
79
+ <head>
80
+ <meta charset="UTF-8" />
81
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
82
+ <title>ImagePress — Smart Compressor</title>
83
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@700;800&display=swap" rel="stylesheet"/>
84
+ <style>
85
+ :root {
86
+ --bg: #0a0a0f;
87
+ --surface: #111118;
88
+ --card: #16161f;
89
+ --border: #2a2a3a;
90
+ --accent: #7fff6e;
91
+ --accent2: #6ebaff;
92
+ --text: #e8e8f0;
93
+ --muted: #6b6b80;
94
+ --danger: #ff6b6b;
95
+ --radius: 14px;
96
+ }
97
+ * { box-sizing: border-box; margin: 0; padding: 0; }
98
+ body {
99
+ background: var(--bg);
100
+ color: var(--text);
101
+ font-family: 'DM Mono', monospace;
102
+ min-height: 100vh;
103
+ display: flex;
104
+ flex-direction: column;
105
+ align-items: center;
106
+ padding: 40px 20px 80px;
107
+ }
108
+ header {
109
+ text-align: center;
110
+ margin-bottom: 48px;
111
+ }
112
+ header h1 {
113
+ font-family: 'Syne', sans-serif;
114
+ font-size: clamp(2.4rem, 6vw, 4rem);
115
+ font-weight: 800;
116
+ letter-spacing: -1px;
117
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%);
118
+ -webkit-background-clip: text;
119
+ -webkit-text-fill-color: transparent;
120
+ }
121
+ header p {
122
+ color: var(--muted);
123
+ font-size: 0.85rem;
124
+ margin-top: 8px;
125
+ letter-spacing: 0.05em;
126
+ }
127
+
128
+ .card {
129
+ background: var(--card);
130
+ border: 1px solid var(--border);
131
+ border-radius: var(--radius);
132
+ padding: 32px;
133
+ width: 100%;
134
+ max-width: 640px;
135
+ }
136
+
137
+ /* Drop zone */
138
+ #drop-zone {
139
+ border: 2px dashed var(--border);
140
+ border-radius: 10px;
141
+ padding: 48px 24px;
142
+ text-align: center;
143
+ cursor: pointer;
144
+ transition: border-color .2s, background .2s;
145
+ position: relative;
146
+ }
147
+ #drop-zone.dragover {
148
+ border-color: var(--accent);
149
+ background: rgba(127,255,110,.05);
150
+ }
151
+ #drop-zone .icon { font-size: 2.8rem; margin-bottom: 12px; }
152
+ #drop-zone p { color: var(--muted); font-size: 0.8rem; line-height: 1.6; }
153
+ #drop-zone strong { color: var(--text); }
154
+ #file-input { display: none; }
155
+
156
+ /* Preview */
157
+ #preview-wrap {
158
+ display: none;
159
+ margin-top: 20px;
160
+ border-radius: 10px;
161
+ overflow: hidden;
162
+ border: 1px solid var(--border);
163
+ position: relative;
164
+ }
165
+ #preview-wrap img {
166
+ width: 100%;
167
+ max-height: 260px;
168
+ object-fit: contain;
169
+ display: block;
170
+ background: #0d0d14;
171
+ }
172
+ #preview-meta {
173
+ display: flex;
174
+ justify-content: space-between;
175
+ padding: 10px 14px;
176
+ font-size: 0.75rem;
177
+ color: var(--muted);
178
+ background: var(--surface);
179
+ border-top: 1px solid var(--border);
180
+ }
181
+
182
+ /* Controls */
183
+ .controls { margin-top: 28px; }
184
+ label.field-label {
185
+ display: block;
186
+ font-size: 0.72rem;
187
+ color: var(--muted);
188
+ letter-spacing: .08em;
189
+ text-transform: uppercase;
190
+ margin-bottom: 8px;
191
+ }
192
+
193
+ .target-row {
194
+ display: flex;
195
+ gap: 10px;
196
+ align-items: center;
197
+ }
198
+ .target-row input[type="number"] {
199
+ flex: 1;
200
+ background: var(--surface);
201
+ border: 1px solid var(--border);
202
+ border-radius: 8px;
203
+ padding: 12px 14px;
204
+ color: var(--text);
205
+ font-family: 'DM Mono', monospace;
206
+ font-size: 1rem;
207
+ outline: none;
208
+ transition: border-color .2s;
209
+ }
210
+ .target-row input[type="number"]:focus { border-color: var(--accent); }
211
+ .unit-toggle {
212
+ display: flex;
213
+ border: 1px solid var(--border);
214
+ border-radius: 8px;
215
+ overflow: hidden;
216
+ }
217
+ .unit-toggle button {
218
+ padding: 10px 18px;
219
+ background: var(--surface);
220
+ border: none;
221
+ color: var(--muted);
222
+ font-family: 'DM Mono', monospace;
223
+ font-size: 0.82rem;
224
+ cursor: pointer;
225
+ transition: background .15s, color .15s;
226
+ }
227
+ .unit-toggle button.active {
228
+ background: var(--accent);
229
+ color: #000;
230
+ font-weight: 600;
231
+ }
232
+
233
+ /* Compress button */
234
+ #compress-btn {
235
+ margin-top: 24px;
236
+ width: 100%;
237
+ padding: 15px;
238
+ border: none;
239
+ border-radius: 10px;
240
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
241
+ color: #000;
242
+ font-family: 'Syne', sans-serif;
243
+ font-size: 1rem;
244
+ font-weight: 700;
245
+ cursor: pointer;
246
+ transition: opacity .2s, transform .1s;
247
+ letter-spacing: .02em;
248
+ }
249
+ #compress-btn:hover { opacity: .88; }
250
+ #compress-btn:active { transform: scale(.98); }
251
+ #compress-btn:disabled { opacity: .35; cursor: not-allowed; }
252
+
253
+ /* Spinner */
254
+ #spinner {
255
+ display: none;
256
+ text-align: center;
257
+ margin-top: 20px;
258
+ color: var(--muted);
259
+ font-size: 0.8rem;
260
+ }
261
+ .dot-loader span {
262
+ display: inline-block;
263
+ width: 7px; height: 7px;
264
+ border-radius: 50%;
265
+ background: var(--accent);
266
+ margin: 0 3px;
267
+ animation: bounce 1s infinite;
268
+ }
269
+ .dot-loader span:nth-child(2) { animation-delay: .15s; }
270
+ .dot-loader span:nth-child(3) { animation-delay: .3s; }
271
+ @keyframes bounce {
272
+ 0%,80%,100% { transform: translateY(0); }
273
+ 40% { transform: translateY(-8px); }
274
+ }
275
+
276
+ /* Result */
277
+ #result-card {
278
+ display: none;
279
+ margin-top: 28px;
280
+ border: 1px solid var(--border);
281
+ border-radius: var(--radius);
282
+ overflow: hidden;
283
+ }
284
+ .result-header {
285
+ background: var(--surface);
286
+ padding: 14px 20px;
287
+ font-family: 'Syne', sans-serif;
288
+ font-size: 0.85rem;
289
+ font-weight: 700;
290
+ color: var(--accent);
291
+ letter-spacing: .05em;
292
+ border-bottom: 1px solid var(--border);
293
+ }
294
+ .stat-grid {
295
+ display: grid;
296
+ grid-template-columns: 1fr 1fr 1fr;
297
+ gap: 1px;
298
+ background: var(--border);
299
+ }
300
+ .stat {
301
+ background: var(--card);
302
+ padding: 18px 16px;
303
+ text-align: center;
304
+ }
305
+ .stat .val {
306
+ font-family: 'Syne', sans-serif;
307
+ font-size: 1.3rem;
308
+ font-weight: 800;
309
+ }
310
+ .stat .lbl { color: var(--muted); font-size: 0.68rem; margin-top: 4px; letter-spacing: .06em; }
311
+ .stat.saving .val { color: var(--accent); }
312
+
313
+ #download-btn {
314
+ display: block;
315
+ width: calc(100% - 32px);
316
+ margin: 16px;
317
+ padding: 13px;
318
+ border: 1px solid var(--accent);
319
+ border-radius: 9px;
320
+ background: transparent;
321
+ color: var(--accent);
322
+ font-family: 'Syne', sans-serif;
323
+ font-size: 0.9rem;
324
+ font-weight: 700;
325
+ cursor: pointer;
326
+ text-align: center;
327
+ text-decoration: none;
328
+ transition: background .2s, color .2s;
329
+ letter-spacing: .04em;
330
+ }
331
+ #download-btn:hover { background: var(--accent); color: #000; }
332
+
333
+ #error-msg {
334
+ display: none;
335
+ margin-top: 16px;
336
+ padding: 12px 16px;
337
+ border-radius: 8px;
338
+ background: rgba(255,107,107,.1);
339
+ border: 1px solid var(--danger);
340
+ color: var(--danger);
341
+ font-size: 0.78rem;
342
+ }
343
+ </style>
344
+ </head>
345
+ <body>
346
+ <header>
347
+ <h1>ImagePress</h1>
348
+ <p>// compress without the blur · target any file size · zero quality loss</p>
349
+ </header>
350
+
351
+ <div class="card">
352
+ <!-- Drop zone -->
353
+ <div id="drop-zone">
354
+ <div class="icon">🗜️</div>
355
+ <p><strong>Drop your image here</strong><br/>or click to browse<br/><span style="font-size:.72rem">PNG · JPG · JPEG · WEBP &nbsp;|&nbsp; up to 50 MB</span></p>
356
+ <input type="file" id="file-input" accept=".png,.jpg,.jpeg,.webp"/>
357
+ </div>
358
+
359
+ <!-- Preview -->
360
+ <div id="preview-wrap">
361
+ <img id="preview-img" src="" alt="preview"/>
362
+ <div id="preview-meta">
363
+ <span id="meta-name">—</span>
364
+ <span id="meta-size">—</span>
365
+ <span id="meta-dim">—</span>
366
+ </div>
367
+ </div>
368
+
369
+ <!-- Controls -->
370
+ <div class="controls">
371
+ <label class="field-label">Target file size</label>
372
+ <div class="target-row">
373
+ <input type="number" id="target-val" min="1" placeholder="e.g. 200" value="200"/>
374
+ <div class="unit-toggle">
375
+ <button id="btn-kb" class="active" onclick="setUnit('KB')">KB</button>
376
+ <button id="btn-mb" onclick="setUnit('MB')">MB</button>
377
+ </div>
378
+ </div>
379
+ </div>
380
+
381
+ <button id="compress-btn" disabled onclick="compress()">⚡ Compress Image</button>
382
+
383
+ <div id="spinner">
384
+ <div class="dot-loader"><span></span><span></span><span></span></div>
385
+ <p style="margin-top:10px">Compressing without touching quality…</p>
386
+ </div>
387
+
388
+ <div id="error-msg"></div>
389
+ </div>
390
+
391
+ <!-- Result -->
392
+ <div class="card" style="margin-top:20px; max-width:640px; width:100%;">
393
+ <div id="result-card">
394
+ <div class="result-header">✅ COMPRESSION RESULT</div>
395
+ <div class="stat-grid">
396
+ <div class="stat">
397
+ <div class="val" id="r-original">—</div>
398
+ <div class="lbl">ORIGINAL</div>
399
+ </div>
400
+ <div class="stat">
401
+ <div class="val" id="r-compressed">—</div>
402
+ <div class="lbl">COMPRESSED</div>
403
+ </div>
404
+ <div class="stat saving">
405
+ <div class="val" id="r-saving">—</div>
406
+ <div class="lbl">SAVED</div>
407
+ </div>
408
+ </div>
409
+ <a id="download-btn" href="#" download>⬇ Download Compressed Image</a>
410
+ </div>
411
+ </div>
412
+
413
+ <script>
414
+ let selectedFile = null;
415
+ let currentUnit = 'KB';
416
+ let downloadUrl = null;
417
+
418
+ const dropZone = document.getElementById('drop-zone');
419
+ const fileInput = document.getElementById('file-input');
420
+
421
+ dropZone.addEventListener('click', () => fileInput.click());
422
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
423
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
424
+ dropZone.addEventListener('drop', e => {
425
+ e.preventDefault();
426
+ dropZone.classList.remove('dragover');
427
+ if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
428
+ });
429
+ fileInput.addEventListener('change', () => { if (fileInput.files[0]) handleFile(fileInput.files[0]); });
430
+
431
+ function handleFile(file) {
432
+ selectedFile = file;
433
+ const reader = new FileReader();
434
+ reader.onload = ev => {
435
+ document.getElementById('preview-img').src = ev.target.result;
436
+ document.getElementById('preview-wrap').style.display = 'block';
437
+ };
438
+ reader.readAsDataURL(file);
439
+
440
+ const img = new window.Image();
441
+ img.onload = () => {
442
+ document.getElementById('meta-dim').textContent = img.width + ' × ' + img.height;
443
+ };
444
+ img.src = URL.createObjectURL(file);
445
+
446
+ document.getElementById('meta-name').textContent = file.name;
447
+ document.getElementById('meta-size').textContent = formatBytes(file.size);
448
+ document.getElementById('compress-btn').disabled = false;
449
+ document.getElementById('result-card').style.display = 'none';
450
+ document.getElementById('error-msg').style.display = 'none';
451
+ if (downloadUrl) { URL.revokeObjectURL(downloadUrl); downloadUrl = null; }
452
+ }
453
+
454
+ function setUnit(u) {
455
+ currentUnit = u;
456
+ document.getElementById('btn-kb').classList.toggle('active', u === 'KB');
457
+ document.getElementById('btn-mb').classList.toggle('active', u === 'MB');
458
+ }
459
+
460
+ function formatBytes(b) {
461
+ return b < 1048576 ? (b/1024).toFixed(1)+' KB' : (b/1048576).toFixed(2)+' MB';
462
+ }
463
+
464
+ async function compress() {
465
+ if (!selectedFile) return;
466
+ const val = parseFloat(document.getElementById('target-val').value);
467
+ if (!val || val <= 0) { showError('Please enter a valid target size.'); return; }
468
+
469
+ const targetBytes = currentUnit === 'KB' ? val * 1024 : val * 1024 * 1024;
470
+
471
+ document.getElementById('compress-btn').disabled = true;
472
+ document.getElementById('spinner').style.display = 'block';
473
+ document.getElementById('result-card').style.display = 'none';
474
+ document.getElementById('error-msg').style.display = 'none';
475
+
476
+ const fd = new FormData();
477
+ fd.append('image', selectedFile);
478
+ fd.append('target_bytes', Math.round(targetBytes));
479
+
480
+ try {
481
+ const res = await fetch('/compress', { method: 'POST', body: fd });
482
+ if (!res.ok) {
483
+ const j = await res.json().catch(() => ({}));
484
+ throw new Error(j.error || 'Server error');
485
+ }
486
+ const blob = await res.blob();
487
+ const origSize = selectedFile.size;
488
+ const compSize = blob.size;
489
+ const saved = Math.max(0, origSize - compSize);
490
+ const pct = ((saved / origSize) * 100).toFixed(1);
491
+
492
+ document.getElementById('r-original').textContent = formatBytes(origSize);
493
+ document.getElementById('r-compressed').textContent = formatBytes(compSize);
494
+ document.getElementById('r-saving').textContent = pct + '%';
495
+
496
+ if (downloadUrl) URL.revokeObjectURL(downloadUrl);
497
+ downloadUrl = URL.createObjectURL(blob);
498
+
499
+ const ext = res.headers.get('X-Output-Format') || 'jpg';
500
+ const baseName = selectedFile.name.replace(/\.[^.]+$/, '');
501
+ const dlBtn = document.getElementById('download-btn');
502
+ dlBtn.href = downloadUrl;
503
+ dlBtn.download = baseName + '_compressed.' + ext.toLowerCase();
504
+
505
+ document.getElementById('result-card').style.display = 'block';
506
+ } catch(err) {
507
+ showError(err.message || 'Compression failed. Please try again.');
508
+ } finally {
509
+ document.getElementById('compress-btn').disabled = false;
510
+ document.getElementById('spinner').style.display = 'none';
511
+ }
512
+ }
513
+
514
+ function showError(msg) {
515
+ const el = document.getElementById('error-msg');
516
+ el.textContent = '⚠ ' + msg;
517
+ el.style.display = 'block';
518
+ }
519
+ </script>
520
+ </body>
521
+ </html>
522
+ """
523
+
524
+ @app.route('/')
525
+ def index():
526
+ return render_template_string(HTML_PAGE)
527
+
528
+ @app.route('/compress', methods=['POST'])
529
+ def compress():
530
+ if 'image' not in request.files:
531
+ return jsonify({'error': 'No image uploaded'}), 400
532
+
533
+ file = request.files['image']
534
+ if file.filename == '' or not allowed_file(file.filename):
535
+ return jsonify({'error': 'Unsupported file type. Use PNG, JPG, JPEG, or WEBP.'}), 400
536
+
537
+ try:
538
+ target_bytes = int(request.form.get('target_bytes', 0))
539
+ except ValueError:
540
+ return jsonify({'error': 'Invalid target size'}), 400
541
+
542
+ if target_bytes <= 0:
543
+ return jsonify({'error': 'Target size must be greater than zero'}), 400
544
+
545
+ try:
546
+ img_data = file.read()
547
+ original_size = len(img_data)
548
+
549
+ img = Image.open(io.BytesIO(img_data))
550
+
551
+ # Normalize mode
552
+ fmt = img.format or 'JPEG'
553
+ if img.mode in ('RGBA', 'LA', 'P'):
554
+ if fmt == 'JPEG':
555
+ # JPEG can't handle alpha; convert to RGB
556
+ background = Image.new('RGB', img.size, (255, 255, 255))
557
+ if img.mode == 'P':
558
+ img = img.convert('RGBA')
559
+ background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
560
+ img = background
561
+ elif img.mode not in ('RGB', 'L'):
562
+ img = img.convert('RGB')
563
+
564
+ if target_bytes >= original_size:
565
+ # Already within target — return optimized original
566
+ buf = io.BytesIO()
567
+ save_fmt = 'PNG' if fmt == 'PNG' else 'JPEG'
568
+ img.save(buf, format=save_fmt, optimize=True, quality=95)
569
+ buf.seek(0)
570
+ out_ext = 'png' if save_fmt == 'PNG' else 'jpg'
571
+ response = send_file(buf, mimetype=f'image/{out_ext}',
572
+ as_attachment=False,
573
+ download_name=f'compressed.{out_ext}')
574
+ response.headers['X-Output-Format'] = out_ext
575
+ return response
576
+
577
+ buf, final_size, out_fmt = compress_to_target(img, fmt, target_bytes, original_size)
578
+ out_ext = 'webp' if out_fmt == 'WEBP' else ('png' if out_fmt == 'PNG' else 'jpg')
579
+ mime = f'image/{out_ext}'
580
+
581
+ response = send_file(buf, mimetype=mime, as_attachment=False,
582
+ download_name=f'compressed.{out_ext}')
583
+ response.headers['X-Output-Format'] = out_ext
584
+ return response
585
+
586
+ except Exception as e:
587
+ return jsonify({'error': f'Processing failed: {str(e)}'}), 500
588
+
589
+ if __name__ == '__main__':
590
+ app.run(debug=True, host='0.0.0.0', port=5000)