ShadowHunter222 commited on
Commit
34a2ba3
·
verified ·
1 Parent(s): 8608b03

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +31 -0
  2. app.py +263 -0
  3. requirements.txt +15 -0
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # Image Compressor Pro — Python Backend (HuggingFace Spaces)
3
+ # ============================================================
4
+ FROM python:3.11-slim
5
+
6
+ # System dependencies for Pillow and pillow-heif
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ libheif-dev \
9
+ libavif-dev \
10
+ libjpeg-turbo-progs \
11
+ libjpeg62-turbo-dev \
12
+ libwebp-dev \
13
+ libtiff-dev \
14
+ libffi-dev \
15
+ gcc \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ WORKDIR /app
19
+
20
+ # Install Python dependencies first (Docker layer caching)
21
+ COPY requirements.txt .
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ # Copy application code
25
+ COPY app.py .
26
+
27
+ # HuggingFace Spaces expects port 7860
28
+ EXPOSE 7860
29
+
30
+ # Production: use multiple workers for concurrency
31
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "4", "--log-level", "info"]
app.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image Compressor Pro — Python Backend (FastAPI + Pillow)
3
+ ========================================================
4
+ A high-performance, concurrent image compression API designed to replace
5
+ the Node.js + Sharp backend. Uses Pillow (with pillow-heif for HEIF/AVIF)
6
+ and FastAPI with StreamingResponse for efficient, non-blocking I/O.
7
+
8
+ Deployment target: HuggingFace Spaces (Docker) or local testing.
9
+ """
10
+
11
+ import io
12
+ import logging
13
+ from contextlib import asynccontextmanager
14
+ from concurrent.futures import ThreadPoolExecutor
15
+
16
+ from fastapi import FastAPI, Request, Query, HTTPException
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from fastapi.responses import StreamingResponse, PlainTextResponse
19
+ from starlette.concurrency import run_in_threadpool
20
+
21
+ from PIL import Image
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Optional: HEIF / AVIF support via pillow-heif
25
+ # ---------------------------------------------------------------------------
26
+ try:
27
+ import pillow_heif
28
+ pillow_heif.register_heif_opener() # enables Image.open() for HEIF/AVIF
29
+ HEIF_AVAILABLE = True
30
+ except ImportError:
31
+ HEIF_AVAILABLE = False
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Logging
35
+ # ---------------------------------------------------------------------------
36
+ logging.basicConfig(
37
+ level=logging.INFO,
38
+ format="%(asctime)s | %(levelname)-7s | %(message)s",
39
+ datefmt="%H:%M:%S",
40
+ )
41
+ log = logging.getLogger("compressor")
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Thread pool — used to offload CPU-bound Pillow work
45
+ # ---------------------------------------------------------------------------
46
+ MAX_WORKERS = 4
47
+ _pool = ThreadPoolExecutor(max_workers=MAX_WORKERS)
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Lifespan (startup / shutdown)
51
+ # ---------------------------------------------------------------------------
52
+ @asynccontextmanager
53
+ async def lifespan(app: FastAPI):
54
+ log.info("Compressor backend starting (workers=%d)", MAX_WORKERS)
55
+ if HEIF_AVAILABLE:
56
+ log.info("pillow-heif is available — HEIF/AVIF support enabled")
57
+ else:
58
+ log.warning("pillow-heif not installed — HEIF/AVIF output disabled")
59
+ yield
60
+ _pool.shutdown(wait=False)
61
+ log.info("Compressor backend shut down")
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # App
65
+ # ---------------------------------------------------------------------------
66
+ app = FastAPI(
67
+ title="Image Compressor Pro API",
68
+ version="1.0.0",
69
+ lifespan=lifespan,
70
+ )
71
+
72
+ # CORS — allow all origins so the frontend can call from any domain
73
+ app.add_middleware(
74
+ CORSMiddleware,
75
+ allow_origins=["*"],
76
+ allow_credentials=True,
77
+ allow_methods=["*"],
78
+ allow_headers=["*"],
79
+ )
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Constants
83
+ # ---------------------------------------------------------------------------
84
+ MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB
85
+
86
+ # MIME types for each supported output format
87
+ MIME_MAP = {
88
+ "jpeg": "image/jpeg",
89
+ "png": "image/png",
90
+ "webp": "image/webp",
91
+ "avif": "image/avif",
92
+ "heif": "image/heic",
93
+ "tiff": "image/tiff",
94
+ }
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Core compression logic (runs in thread pool)
98
+ # ---------------------------------------------------------------------------
99
+ def _compress_image(raw_bytes: bytes, quality: int, fmt: str) -> bytes:
100
+ """
101
+ Open *raw_bytes* as an image, compress it into *fmt* at the given
102
+ *quality*, and return the resulting bytes.
103
+
104
+ This is intentionally a **synchronous** function because Pillow is
105
+ CPU-bound. It is called via ``run_in_threadpool`` so the event loop
106
+ is never blocked.
107
+ """
108
+ img = Image.open(io.BytesIO(raw_bytes))
109
+
110
+ # Convert RGBA → RGB for formats that don't support alpha
111
+ if fmt in ("jpeg", "tiff") and img.mode in ("RGBA", "LA", "PA"):
112
+ background = Image.new("RGB", img.size, (255, 255, 255))
113
+ background.paste(img, mask=img.split()[-1]) # alpha composite
114
+ img = background
115
+ elif fmt in ("jpeg",) and img.mode != "RGB":
116
+ img = img.convert("RGB")
117
+
118
+ out_buffer = io.BytesIO()
119
+
120
+ if fmt == "jpeg":
121
+ img.save(
122
+ out_buffer,
123
+ format="JPEG",
124
+ quality=quality,
125
+ optimize=True,
126
+ progressive=True,
127
+ subsampling="4:2:0" if quality < 90 else "4:4:4",
128
+ )
129
+ elif fmt == "png":
130
+ # PNG quality mapped to compress_level (0-9, higher = smaller)
131
+ compress_level = max(1, min(9, 9 - (quality // 12)))
132
+ img.save(
133
+ out_buffer,
134
+ format="PNG",
135
+ compress_level=compress_level,
136
+ optimize=True,
137
+ )
138
+ elif fmt == "webp":
139
+ img.save(
140
+ out_buffer,
141
+ format="WEBP",
142
+ quality=quality,
143
+ method=4, # 0-6, higher = slower + better compression
144
+ )
145
+ elif fmt == "avif":
146
+ if not HEIF_AVAILABLE:
147
+ raise ValueError("AVIF output requires pillow-heif (not installed)")
148
+ img.save(
149
+ out_buffer,
150
+ format="AVIF",
151
+ quality=quality,
152
+ )
153
+ elif fmt == "heif":
154
+ if not HEIF_AVAILABLE:
155
+ raise ValueError("HEIF output requires pillow-heif (not installed)")
156
+ img.save(
157
+ out_buffer,
158
+ format="HEIF",
159
+ quality=quality,
160
+ )
161
+ elif fmt == "tiff":
162
+ img.save(
163
+ out_buffer,
164
+ format="TIFF",
165
+ compression="tiff_lzw", # lossless LZW compression
166
+ )
167
+ else:
168
+ raise ValueError(f"Unsupported format: {fmt}")
169
+
170
+ out_buffer.seek(0)
171
+ return out_buffer.getvalue()
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Routes
175
+ # ---------------------------------------------------------------------------
176
+
177
+ @app.get("/ping", response_class=PlainTextResponse)
178
+ async def health_check():
179
+ """Simple health-check endpoint compatible with the Node backend."""
180
+ return "pong"
181
+
182
+
183
+ @app.get("/", response_class=PlainTextResponse)
184
+ async def root():
185
+ """Root endpoint — useful for HuggingFace Spaces health probes."""
186
+ return "Image Compressor Pro API is running"
187
+
188
+
189
+ @app.post("/api/process-stream")
190
+ async def process_stream(
191
+ request: Request,
192
+ quality: int = Query(default=80, ge=1, le=100),
193
+ format: str = Query(default="jpeg", pattern="^(jpeg|png|webp|avif|heif|tiff)$"),
194
+ ):
195
+ """
196
+ Receive a raw image in the request body (Content-Type: image/*),
197
+ compress it using Pillow, and stream the result back.
198
+
199
+ Query parameters
200
+ ----------------
201
+ quality : int (1–100)
202
+ Compression quality. Higher = better quality, larger file.
203
+ format : str
204
+ Target output format (jpeg, png, webp, avif, heif, tiff).
205
+
206
+ This mirrors the Node.js backend's ``POST /api/process-stream`` contract
207
+ so the frontend works without changes.
208
+ """
209
+ # ---- Read the incoming image bytes (with size guard) ----
210
+ body = await request.body()
211
+ if len(body) == 0:
212
+ raise HTTPException(status_code=400, detail="Empty request body")
213
+ if len(body) > MAX_FILE_SIZE:
214
+ raise HTTPException(
215
+ status_code=413,
216
+ detail=f"File too large ({len(body)} bytes). Max is {MAX_FILE_SIZE} bytes.",
217
+ )
218
+
219
+ # ---- Check HEIF/AVIF availability ----
220
+ if format in ("avif", "heif") and not HEIF_AVAILABLE:
221
+ raise HTTPException(
222
+ status_code=400,
223
+ detail=f"{format.upper()} format requires pillow-heif which is not installed.",
224
+ )
225
+
226
+ # ---- Offload CPU-bound compression to thread pool ----
227
+ try:
228
+ compressed = await run_in_threadpool(_compress_image, body, quality, format)
229
+ except ValueError as e:
230
+ raise HTTPException(status_code=400, detail=str(e))
231
+ except Exception as e:
232
+ log.exception("Compression failed")
233
+ raise HTTPException(status_code=500, detail=f"Compression error: {e}")
234
+
235
+ # ---- Stream result back ----
236
+ mime = MIME_MAP.get(format, "application/octet-stream")
237
+
238
+ return StreamingResponse(
239
+ io.BytesIO(compressed),
240
+ media_type=mime,
241
+ headers={
242
+ "Content-Disposition": f"attachment; filename=compressed_image.{format}",
243
+ "Content-Length": str(len(compressed)),
244
+ "X-Original-Size": str(len(body)),
245
+ "X-Compressed-Size": str(len(compressed)),
246
+ },
247
+ )
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Entry point for local development
252
+ # ---------------------------------------------------------------------------
253
+ if __name__ == "__main__":
254
+ import uvicorn
255
+
256
+ uvicorn.run(
257
+ "app:app",
258
+ host="0.0.0.0",
259
+ port=7860,
260
+ workers=1, # single worker for local dev; increase for prod
261
+ log_level="info",
262
+ reload=True, # auto-reload on code changes during dev
263
+ )
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # Image Compressor Pro — Python Backend Dependencies
3
+ # ============================================================
4
+ # Core
5
+ fastapi>=0.115.0
6
+ uvicorn[standard]>=0.30.0
7
+ python-multipart>=0.0.9
8
+
9
+ # Image processing
10
+ Pillow>=10.4.0
11
+ pillow-heif>=0.18.0 # HEIF / AVIF read & write support
12
+
13
+ # Performance (optional but recommended)
14
+ httptools>=0.6.0 # faster HTTP parsing for uvicorn
15
+ uvloop>=0.20.0; sys_platform != "win32" # faster event loop (Linux/Mac only)