Spaces:
Running
Running
| from fastapi import APIRouter, UploadFile, File, Form, HTTPException | |
| from fastapi.responses import StreamingResponse, HTMLResponse | |
| from PIL import Image | |
| from io import BytesIO | |
| # ========================== | |
| # πΌοΈ Image Processing Router | |
| # ========================== | |
| router = APIRouter( | |
| prefix="/image", | |
| tags=["Image Compression"] | |
| ) | |
| # ========================== | |
| # π¦ API Endpoint (Backend) | |
| # ========================== | |
| async def compress_jpg_to_size( | |
| file: UploadFile = File(..., description="The JPG image file to compress."), | |
| target_size_kb: int = Form(..., description="Target file size in KB (e.g., 240).") | |
| ): | |
| """ | |
| π§ Compresses a JPEG image to fit within a specific file size (in KB). | |
| """ | |
| if file.content_type not in ["image/jpeg", "image/jpg"]: | |
| raise HTTPException(status_code=400, detail="Invalid file type. Only JPEG files are supported.") | |
| if target_size_kb <= 0: | |
| raise HTTPException(status_code=400, detail="Target size must be greater than 0 KB.") | |
| target_bytes = target_size_kb * 1024 | |
| content = await file.read() | |
| if len(content) <= target_bytes: | |
| return StreamingResponse( | |
| BytesIO(content), | |
| media_type="image/jpeg", | |
| headers={"Content-Disposition": f"attachment; filename=original_{file.filename}"} | |
| ) | |
| input_buffer = BytesIO(content) | |
| try: | |
| img = Image.open(input_buffer) | |
| if img.mode != 'RGB': | |
| img = img.convert('RGB') | |
| output_buffer = BytesIO() | |
| # Binary Search | |
| min_quality, max_quality = 1, 95 | |
| best_buffer = None | |
| while min_quality <= max_quality: | |
| quality = (min_quality + max_quality) // 2 | |
| output_buffer.seek(0) | |
| output_buffer.truncate() | |
| img.save(output_buffer, format="JPEG", quality=quality) | |
| if output_buffer.tell() <= target_bytes: | |
| best_buffer = BytesIO(output_buffer.getvalue()) | |
| min_quality = quality + 1 | |
| else: | |
| max_quality = quality - 1 | |
| # Resize Fallback | |
| if best_buffer is None: | |
| resize_factor = 0.9 | |
| while True: | |
| width, height = img.size | |
| new_width, new_height = int(width * resize_factor), int(height * resize_factor) | |
| if new_width < 10 or new_height < 10: break | |
| img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) | |
| output_buffer.seek(0) | |
| output_buffer.truncate() | |
| img.save(output_buffer, format="JPEG", quality=5) | |
| if output_buffer.tell() <= target_bytes: | |
| best_buffer = output_buffer | |
| break | |
| resize_factor *= 0.9 | |
| if best_buffer is None: | |
| output_buffer.seek(0) | |
| best_buffer = output_buffer | |
| best_buffer.seek(0) | |
| return StreamingResponse( | |
| best_buffer, | |
| media_type="image/jpeg", | |
| headers={"Content-Disposition": f"attachment; filename=compressed_{file.filename}"} | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Image processing error: {str(e)}") | |
| # ========================== | |
| # π¨ Modern UI Endpoint | |
| # ========================== | |
| async def compressor_ui(): | |
| html_content = """ | |
| <!DOCTYPE html> | |
| <html lang="en" data-theme="light"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>JPEG Compressor | by Sam</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| /* --- Shared Theme Config --- */ | |
| :root { | |
| --primary: #10b981; | |
| --primary-hover: #059669; | |
| --bg-body: #f8fafc; | |
| --bg-card: #ffffff; | |
| --text-main: #0f172a; | |
| --text-sub: #64748b; | |
| --border: #e2e8f0; | |
| --drop-bg: #f1f5f9; | |
| --drop-hover: #d1fae5; | |
| --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); | |
| } | |
| [data-theme="dark"] { | |
| --primary: #34d399; | |
| --primary-hover: #10b981; | |
| --bg-body: #0f172a; | |
| --bg-card: #1e293b; | |
| --text-main: #f8fafc; | |
| --text-sub: #94a3b8; | |
| --border: #334155; | |
| --drop-bg: #1e293b; | |
| --drop-hover: #064e3b; | |
| --shadow: 0 10px 15px -3px rgb(0 0 0 / 0.5); | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; transition: background-color 0.3s ease, color 0.3s ease; } | |
| body { | |
| font-family: 'Plus Jakarta Sans', sans-serif; | |
| background-color: var(--bg-body); | |
| color: var(--text-main); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 1rem; | |
| } | |
| /* --- Animations --- */ | |
| @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } | |
| /* --- Navbar --- */ | |
| .navbar { | |
| width: 100%; max-width: 1000px; display: flex; | |
| justify-content: space-between; align-items: center; margin-bottom: 2rem; | |
| animation: fadeIn 0.8s ease-out; | |
| } | |
| .brand { font-size: 1.5rem; font-weight: 800; display: flex; align-items: center; gap: 0.5rem; } | |
| .brand span { color: var(--primary); } | |
| .theme-toggle { | |
| background: none; border: none; cursor: pointer; color: var(--text-main); | |
| font-size: 1.2rem; padding: 8px; border-radius: 50%; | |
| background-color: var(--bg-card); border: 1px solid var(--border); | |
| } | |
| /* --- Main Container --- */ | |
| .container { | |
| background: var(--bg-card); width: 100%; max-width: 900px; | |
| border-radius: 24px; box-shadow: var(--shadow); padding: 2.5rem; | |
| border: 1px solid var(--border); | |
| animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1); | |
| } | |
| h1 { font-size: 2rem; font-weight: 700; text-align: center; margin-bottom: 0.5rem; } | |
| p.subtitle { text-align: center; color: var(--text-sub); margin-bottom: 2rem; } | |
| /* --- Controls --- */ | |
| .controls { | |
| display: flex; justify-content: center; gap: 1rem; margin-bottom: 1.5rem; | |
| align-items: center; flex-wrap: wrap; | |
| } | |
| .input-group { | |
| display: flex; flex-direction: column; gap: 0.5rem; | |
| } | |
| .input-group label { font-size: 0.9rem; font-weight: 600; color: var(--text-sub); } | |
| .input-field { | |
| padding: 0.8rem; border-radius: 12px; border: 1px solid var(--border); | |
| background-color: var(--bg-body); color: var(--text-main); | |
| font-family: inherit; font-size: 1rem; width: 150px; | |
| } | |
| /* --- Drop Zone --- */ | |
| .drop-area { | |
| border: 2px dashed var(--border); border-radius: 16px; padding: 2rem 1rem; | |
| text-align: center; cursor: pointer; background-color: var(--drop-bg); | |
| transition: all 0.3s; | |
| } | |
| .drop-area:hover, .drop-area.dragover { | |
| border-color: var(--primary); background-color: var(--drop-hover); transform: scale(1.01); | |
| } | |
| .drop-icon { font-size: 3rem; margin-bottom: 1rem; display: block; } | |
| /* --- Buttons --- */ | |
| .btn-action { margin-top: 1.5rem; display: flex; justify-content: center; } | |
| .btn { | |
| padding: 0.8rem 2rem; border-radius: 12px; font-weight: 600; | |
| cursor: pointer; border: none; font-size: 1rem; display: inline-flex; align-items: center; gap: 8px; | |
| transition: transform 0.2s; | |
| } | |
| .btn-primary { | |
| background-color: var(--primary); color: white; | |
| box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); | |
| } | |
| .btn-primary:hover { background-color: var(--primary-hover); transform: translateY(-2px); } | |
| .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } | |
| .btn-download { | |
| background-color: var(--primary); color: white; text-decoration: none; | |
| padding: 0.4rem 1rem; border-radius: 8px; font-size: 0.9rem; | |
| } | |
| .btn-download:hover { opacity: 0.9; } | |
| /* --- Results --- */ | |
| .results-container { | |
| display: none; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-top: 2rem; | |
| animation: fadeIn 0.5s ease; | |
| } | |
| .img-card { | |
| background: var(--bg-body); padding: 1rem; border-radius: 16px; border: 1px solid var(--border); | |
| } | |
| .card-header { | |
| font-size: 0.9rem; font-weight: 600; color: var(--text-sub); | |
| margin-bottom: 0.8rem; text-transform: uppercase; | |
| display: flex; justify-content: space-between; align-items: center; | |
| } | |
| .img-wrapper { | |
| width: 100%; height: 250px; border-radius: 12px; overflow: hidden; | |
| display: flex; align-items: center; justify-content: center; | |
| background-color: var(--drop-bg); | |
| } | |
| .img-wrapper img { max-width: 100%; max-height: 100%; object-fit: contain; } | |
| .loading-spinner { | |
| display: none; width: 24px; height: 24px; | |
| border: 3px solid rgba(255,255,255,0.3); border-radius: 50%; border-top-color: white; | |
| animation: spin 1s infinite linear; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* --- Developer Footer --- */ | |
| .dev-footer { | |
| margin-top: 3rem; | |
| text-align: center; | |
| font-size: 0.9rem; | |
| color: var(--text-sub); | |
| padding: 1rem; | |
| border-top: 1px solid var(--border); | |
| width: 100%; | |
| max-width: 600px; | |
| animation: fadeIn 1.2s ease-out; | |
| } | |
| .dev-badge { | |
| display: inline-block; | |
| background: var(--bg-card); | |
| padding: 5px 15px; | |
| border-radius: 20px; | |
| border: 1px solid var(--border); | |
| font-weight: 500; | |
| margin-top: 5px; | |
| } | |
| .dev-name { color: var(--primary); font-weight: 700; } | |
| @media (max-width: 768px) { | |
| .results-container { grid-template-columns: 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <nav class="navbar"> | |
| <div class="brand">β‘ JPEG Compressor <span>by sam</span></div> | |
| <button class="theme-toggle" onclick="toggleTheme()" id="themeBtn">π</button> | |
| </nav> | |
| <div class="container"> | |
| <h1>Smart Image Compression</h1> | |
| <p class="subtitle">Reduce file size without sacrificing quality.</p> | |
| <div class="controls"> | |
| <div class="input-group"> | |
| <label for="sizeInput">Target Size (KB)</label> | |
| <input type="number" id="sizeInput" class="input-field" value="200" min="10" placeholder="e.g. 200"> | |
| </div> | |
| </div> | |
| <div class="drop-area" id="dropArea"> | |
| <input type="file" id="fileInput" accept="image/jpeg, image/jpg" hidden> | |
| <span class="drop-icon">π¦</span> | |
| <h3 id="dropText" style="font-weight: 600; margin-bottom: 0.5rem;">Click to upload or drag & drop</h3> | |
| <p style="color: var(--text-sub); font-size: 0.9rem;">JPG or JPEG only</p> | |
| </div> | |
| <div class="btn-action"> | |
| <button class="btn btn-primary" id="processBtn" onclick="processImage()" disabled> | |
| <div class="loading-spinner" id="spinner"></div> | |
| <span id="btnText">Compress Image</span> | |
| </button> | |
| </div> | |
| <p id="errorMsg" style="color: #ef4444; text-align: center; margin-top: 10px; display: none;"></p> | |
| <div class="results-container" id="resultsGrid"> | |
| <div class="img-card"> | |
| <div class="card-header">Original</div> | |
| <div class="img-wrapper"> | |
| <img id="originalImg" src="" alt="Original"> | |
| </div> | |
| </div> | |
| <div class="img-card"> | |
| <div class="card-header"> | |
| <span>Compressed</span> | |
| <a id="downloadLink" href="#" class="btn-download">β¬ Save</a> | |
| </div> | |
| <div class="img-wrapper"> | |
| <img id="processedImg" src="" alt="Compressed"> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <footer class="dev-footer"> | |
| <div>Designed & Developed by</div> | |
| <div class="dev-badge"> | |
| <span class="dev-name">Sameer Banchhor</span> | Data Scientist | |
| </div> | |
| </footer> | |
| <script> | |
| // Elements | |
| const dropArea = document.getElementById('dropArea'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const processBtn = document.getElementById('processBtn'); | |
| const sizeInput = document.getElementById('sizeInput'); | |
| const spinner = document.getElementById('spinner'); | |
| const btnText = document.getElementById('btnText'); | |
| const resultsGrid = document.getElementById('resultsGrid'); | |
| const originalImg = document.getElementById('originalImg'); | |
| const processedImg = document.getElementById('processedImg'); | |
| const downloadLink = document.getElementById('downloadLink'); | |
| const errorMsg = document.getElementById('errorMsg'); | |
| const dropText = document.getElementById('dropText'); | |
| // Theme Logic | |
| const html = document.documentElement; | |
| const themeBtn = document.getElementById('themeBtn'); | |
| function toggleTheme() { | |
| const current = html.getAttribute('data-theme'); | |
| const next = current === 'light' ? 'dark' : 'light'; | |
| html.setAttribute('data-theme', next); | |
| themeBtn.textContent = next === 'light' ? 'π' : 'βοΈ'; | |
| localStorage.setItem('theme', next); | |
| } | |
| const savedTheme = localStorage.getItem('theme') || 'light'; | |
| html.setAttribute('data-theme', savedTheme); | |
| themeBtn.textContent = savedTheme === 'light' ? 'π' : 'βοΈ'; | |
| // Drag & Drop | |
| dropArea.addEventListener('click', () => fileInput.click()); | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { | |
| dropArea.addEventListener(eventName, (e) => { | |
| e.preventDefault(); e.stopPropagation(); | |
| }, false); | |
| }); | |
| ['dragenter', 'dragover'].forEach(name => dropArea.classList.add('dragover')); | |
| ['dragleave', 'drop'].forEach(name => dropArea.classList.remove('dragover')); | |
| dropArea.addEventListener('drop', (e) => { | |
| if(e.dataTransfer.files.length) { | |
| fileInput.files = e.dataTransfer.files; | |
| handleFileSelect(); | |
| } | |
| }); | |
| fileInput.addEventListener('change', handleFileSelect); | |
| function handleFileSelect() { | |
| const file = fileInput.files[0]; | |
| if (file) { | |
| originalImg.src = URL.createObjectURL(file); | |
| processBtn.disabled = false; | |
| dropText.textContent = "Selected: " + file.name; | |
| resultsGrid.style.display = 'none'; | |
| errorMsg.style.display = 'none'; | |
| } | |
| } | |
| // Process Logic | |
| async function processImage() { | |
| const file = fileInput.files[0]; | |
| const kbSize = sizeInput.value; | |
| if (!file || !kbSize) return; | |
| processBtn.disabled = true; | |
| spinner.style.display = 'block'; | |
| btnText.textContent = 'Compressing...'; | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| formData.append('target_size_kb', kbSize); | |
| try { | |
| const response = await fetch('/image/compress-jpg', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!response.ok) throw new Error(await response.text()); | |
| const blob = await response.blob(); | |
| const objectURL = URL.createObjectURL(blob); | |
| processedImg.src = objectURL; | |
| downloadLink.href = objectURL; | |
| downloadLink.download = "compressed_" + file.name; | |
| resultsGrid.style.display = 'grid'; | |
| resultsGrid.scrollIntoView({ behavior: 'smooth' }); | |
| } catch (err) { | |
| console.error(err); | |
| errorMsg.textContent = "Error: " + (err.message || "Compression failed"); | |
| errorMsg.style.display = 'block'; | |
| } finally { | |
| processBtn.disabled = false; | |
| spinner.style.display = 'none'; | |
| btnText.textContent = 'Compress Image'; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| return html_content |