validops-east-1 commited on
Commit
8f9855d
·
1 Parent(s): fe8cd16

feat: m2m api

Browse files
app/api/server.py CHANGED
@@ -96,6 +96,16 @@ async def lifespan(app: FastAPI):
96
 
97
  asyncio.create_task(_self_ping())
98
 
 
 
 
 
 
 
 
 
 
 
99
  await _scheduler_service.start()
100
  _logger.info("Scheduler service started")
101
 
@@ -111,6 +121,8 @@ async def lifespan(app: FastAPI):
111
  await close_maps_service()
112
  from app.api.v1.gcs import close_gcs_service
113
  await close_gcs_service()
 
 
114
  from app.services.supabase import get_supabase_client
115
  client = get_supabase_client()
116
  if client:
@@ -133,6 +145,7 @@ def create_application() -> FastAPI:
133
  {"name": "Verify", "description": "Phone number and identity verification"},
134
  {"name": "Vector Stores", "description": "Create, manage, and search vector stores for RAG"},
135
  {"name": "URL Shortener", "description": "Create and manage short URLs with analytics"},
 
136
  ],
137
  lifespan=lifespan,
138
  )
 
96
 
97
  asyncio.create_task(_self_ping())
98
 
99
+ if _settings.supabase_upload_enabled:
100
+ try:
101
+ from app.services.media_storage_service import get_storage_service
102
+
103
+ storage = await get_storage_service()
104
+ bucket = await storage.ensure_bucket(_settings.supabase_storage_bucket)
105
+ _logger.info("Supabase Storage bucket ensured: %s", bucket)
106
+ except Exception as exc:
107
+ _logger.error("Failed to ensure Supabase Storage bucket at startup: %s", exc)
108
+
109
  await _scheduler_service.start()
110
  _logger.info("Scheduler service started")
111
 
 
121
  await close_maps_service()
122
  from app.api.v1.gcs import close_gcs_service
123
  await close_gcs_service()
124
+ from app.services.media_storage_service import close_storage_service
125
+ await close_storage_service()
126
  from app.services.supabase import get_supabase_client
127
  client = get_supabase_client()
128
  if client:
 
145
  {"name": "Verify", "description": "Phone number and identity verification"},
146
  {"name": "Vector Stores", "description": "Create, manage, and search vector stores for RAG"},
147
  {"name": "URL Shortener", "description": "Create and manage short URLs with analytics"},
148
+ {"name": "Media-to-Media Conversion", "description": "PDF-to-image and image-to-image conversion with local or Supabase Storage output"},
149
  ],
150
  lifespan=lifespan,
151
  )
app/api/v1/media_convert.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Annotated, Any, Dict, Optional, Tuple
7
+ from urllib.parse import urlparse
8
+
9
+ import httpx
10
+ from fastapi import (
11
+ APIRouter,
12
+ File,
13
+ Form,
14
+ HTTPException,
15
+ UploadFile,
16
+ )
17
+ from fastapi.responses import FileResponse, JSONResponse
18
+ from pydantic import ValidationError
19
+
20
+ from app.config import get_settings
21
+ from app.core.logger import get_logger
22
+ from app.models.schemas import (
23
+ ImageConversionParams,
24
+ MediaConversionData,
25
+ MediaConversionResponse,
26
+ MediaFormatsResponse,
27
+ MediaImageFormat,
28
+ MediaImageUrlRequest,
29
+ MediaPDFUrlRequest,
30
+ PDFConversionParams,
31
+ )
32
+ from app.services.media_conversion_service import (
33
+ MediaConversionError,
34
+ _IMAGE_OUTPUT_FORMATS,
35
+ _PDF_OUTPUT_FORMATS,
36
+ media_conversion_service,
37
+ )
38
+ from app.services.media_storage_service import MediaStorageError
39
+
40
+ router = APIRouter()
41
+ _logger = get_logger(__name__)
42
+ _settings = get_settings()
43
+ _MAX_UPLOAD_BYTES = _settings.max_upload_bytes
44
+
45
+ _MIME_BY_EXT = {
46
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
47
+ ".png": "image/png", ".webp": "image/webp",
48
+ ".bmp": "image/bmp", ".gif": "image/gif", ".tiff": "image/tiff",
49
+ }
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def _ok_response(start: float, data: MediaConversionData) -> MediaConversionResponse:
57
+ return MediaConversionResponse(
58
+ success=True,
59
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
60
+ data=data,
61
+ )
62
+
63
+
64
+ def _error_response(start: float, exc: Exception) -> JSONResponse:
65
+ if isinstance(exc, MediaConversionError):
66
+ code, message = exc.status_code, exc.message
67
+ elif isinstance(exc, MediaStorageError):
68
+ code, message = exc.status_code, exc.message
69
+ else:
70
+ code, message = 500, f"Media conversion failed: {exc}"
71
+ _logger.error("media_conversion_error status=%s error=%s", code, message)
72
+ return JSONResponse(
73
+ status_code=code,
74
+ content={
75
+ "success": False,
76
+ "message": message,
77
+ "time_ms": round((time.perf_counter() - start) * 1000, 3),
78
+ },
79
+ )
80
+
81
+
82
+ async def _convert_with_timeout(coro) -> Any:
83
+ try:
84
+ return await asyncio.wait_for(
85
+ coro, timeout=_settings.media_conversion_timeout_seconds
86
+ )
87
+ except asyncio.TimeoutError as exc:
88
+ raise MediaConversionError(
89
+ f"Conversion timed out after {_settings.media_conversion_timeout_seconds}s.",
90
+ status_code=504,
91
+ ) from exc
92
+
93
+
94
+ def _build_model(model_cls, values: Dict[str, Any]) -> Any:
95
+ try:
96
+ return model_cls.model_validate(values)
97
+ except ValidationError as exc:
98
+ first = exc.errors()[0]
99
+ raise MediaConversionError(
100
+ f"Invalid conversion parameters: {first.get('msg', str(exc))}",
101
+ status_code=422,
102
+ ) from exc
103
+
104
+
105
+ def _read_upload(file: UploadFile) -> bytes:
106
+ if file is None or not file.filename:
107
+ raise HTTPException(status_code=400, detail={"success": False, "message": "No file provided."})
108
+ raw = file.file.read()
109
+ if not raw:
110
+ raise HTTPException(status_code=422, detail={"success": False, "message": "Uploaded file is empty."})
111
+ if len(raw) > _MAX_UPLOAD_BYTES:
112
+ raise HTTPException(
113
+ status_code=413,
114
+ detail={"success": False, "message": f"File exceeds {_settings.max_upload_mb} MB limit."},
115
+ )
116
+ return raw
117
+
118
+
119
+ async def _read_stream_limited(resp, limit: int) -> bytes:
120
+ """Read a streaming response, aborting with a 413 once it exceeds ``limit`` bytes."""
121
+ chunks: list[bytes] = []
122
+ total = 0
123
+ async for chunk in resp.aiter_bytes():
124
+ total += len(chunk)
125
+ if total > limit:
126
+ raise MediaConversionError(
127
+ f"Remote file exceeds {_settings.max_upload_mb} MB limit.", status_code=413
128
+ )
129
+ chunks.append(chunk)
130
+ return b"".join(chunks)
131
+
132
+
133
+ async def _fetch_url(url: str) -> Tuple[bytes, str]:
134
+ """Verify the URL is downloadable, then download it within the upload size cap."""
135
+ parsed = urlparse(url)
136
+ filename = parsed.path.split("/")[-1] or "media"
137
+ async with httpx.AsyncClient(
138
+ timeout=30.0,
139
+ follow_redirects=True,
140
+ headers={"User-Agent": "agentdeck-media-convert/1.0"},
141
+ ) as client:
142
+ # 1) Pre-flight: confirm the URL responds before pulling the body.
143
+ declared_length: Optional[int] = None
144
+ try:
145
+ head_resp = await client.head(url)
146
+ if head_resp.status_code in (405, 501): # HEAD not supported -> validate via GET
147
+ declared_length = None
148
+ else:
149
+ head_resp.raise_for_status()
150
+ content_length = head_resp.headers.get("content-length")
151
+ if content_length and content_length.isdigit():
152
+ declared_length = int(content_length)
153
+ except httpx.HTTPError as exc:
154
+ status = exc.response.status_code if exc.response else "network error"
155
+ raise MediaConversionError(
156
+ f"URL is not downloadable (HTTP {status}).",
157
+ status_code=400,
158
+ ) from exc
159
+
160
+ # 2) Reject oversized files up front using the declared Content-Length.
161
+ if declared_length is not None and declared_length > _MAX_UPLOAD_BYTES:
162
+ raise MediaConversionError(
163
+ f"Remote file is {declared_length} bytes, exceeding the "
164
+ f"{_settings.max_upload_mb} MB limit.",
165
+ status_code=413,
166
+ )
167
+
168
+ # 3) Stream the download with an enforced byte cap (guards against
169
+ # servers that omit or misreport Content-Length).
170
+ try:
171
+ async with client.stream("GET", url) as resp:
172
+ resp.raise_for_status()
173
+ data = await _read_stream_limited(resp, _MAX_UPLOAD_BYTES)
174
+ except httpx.HTTPError as exc:
175
+ raise MediaConversionError(
176
+ f"Failed to download URL: {exc}", status_code=400
177
+ ) from exc
178
+
179
+ if not data:
180
+ raise MediaConversionError("Remote file is empty.", status_code=422)
181
+ return data, filename
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Endpoints
186
+ # ---------------------------------------------------------------------------
187
+
188
+ @router.post(
189
+ "/media-convert/pdf",
190
+ response_model=MediaConversionResponse,
191
+ summary="Convert an uploaded PDF to images",
192
+ )
193
+ async def convert_pdf_file(
194
+ file: Annotated[UploadFile, File(description="PDF file to convert")],
195
+ format: Annotated[MediaImageFormat, Form(description="Output format (PNG/JPEG/WEBP)")] = MediaImageFormat.PNG,
196
+ dpi: int = Form(150, description="Render DPI (72-300)"),
197
+ quality: int = Form(85, description="Output quality (1-100)"),
198
+ pages: Optional[str] = Form(None, description="Page spec: '1', '1-3', '1,3,5-7'"),
199
+ grayscale: bool = Form(False),
200
+ transparent_bg: bool = Form(False),
201
+ split_page: bool = Form(False),
202
+ ):
203
+ start = time.perf_counter()
204
+ raw = _read_upload(file)
205
+ try:
206
+ params = _build_model(PDFConversionParams, {
207
+ "format": format, "dpi": dpi, "quality": quality, "pages": pages,
208
+ "grayscale": grayscale, "transparent_bg": transparent_bg, "split_page": split_page,
209
+ })
210
+ data = await _convert_with_timeout(
211
+ media_conversion_service.convert_pdf(raw, params, file.filename or "upload.pdf")
212
+ )
213
+ except Exception as exc:
214
+ return _error_response(start, exc)
215
+ return _ok_response(start, data)
216
+
217
+
218
+ @router.post(
219
+ "/media-convert/pdf/url",
220
+ response_model=MediaConversionResponse,
221
+ summary="Convert a PDF from a URL to images",
222
+ )
223
+ async def convert_pdf_url(body: MediaPDFUrlRequest):
224
+ start = time.perf_counter()
225
+ try:
226
+ raw, filename = await _fetch_url(body.url)
227
+ data = await _convert_with_timeout(
228
+ media_conversion_service.convert_pdf(raw, body.params, body.url)
229
+ )
230
+ except Exception as exc:
231
+ return _error_response(start, exc)
232
+ return _ok_response(start, data)
233
+
234
+
235
+ @router.post(
236
+ "/media-convert/image",
237
+ response_model=MediaConversionResponse,
238
+ summary="Convert an uploaded image to another image format",
239
+ )
240
+ async def convert_image_file(
241
+ file: Annotated[UploadFile, File(description="Image file to convert")],
242
+ format: Annotated[MediaImageFormat, Form(description="Target format")] = MediaImageFormat.JPEG,
243
+ quality: int = Form(85, description="Output quality (1-100)"),
244
+ grayscale: bool = Form(False),
245
+ width: Optional[int] = Form(None, description="Resize width"),
246
+ height: Optional[int] = Form(None, description="Resize height"),
247
+ rotate: float = Form(0.0, description="Rotate clockwise in degrees"),
248
+ flip: Optional[str] = Form(None, description="horizontal or vertical"),
249
+ ):
250
+ start = time.perf_counter()
251
+ raw = _read_upload(file)
252
+ try:
253
+ params = _build_model(ImageConversionParams, {
254
+ "format": format, "quality": quality, "grayscale": grayscale,
255
+ "width": width, "height": height, "rotate": rotate, "flip": flip,
256
+ })
257
+ data = await _convert_with_timeout(
258
+ media_conversion_service.convert_image(raw, file.filename or "image", params)
259
+ )
260
+ except Exception as exc:
261
+ return _error_response(start, exc)
262
+ return _ok_response(start, data)
263
+
264
+
265
+ @router.post(
266
+ "/media-convert/image/url",
267
+ response_model=MediaConversionResponse,
268
+ summary="Convert an image from a URL to another image format",
269
+ )
270
+ async def convert_image_url(body: MediaImageUrlRequest):
271
+ start = time.perf_counter()
272
+ try:
273
+ raw, filename = await _fetch_url(body.url)
274
+ data = await _convert_with_timeout(
275
+ media_conversion_service.convert_image(raw, body.url, body.params)
276
+ )
277
+ except Exception as exc:
278
+ return _error_response(start, exc)
279
+ return _ok_response(start, data)
280
+
281
+
282
+ @router.get(
283
+ "/media-convert/files/{job_id}/{filename}",
284
+ summary="Download a converted file (local mode)",
285
+ responses={404: {"description": "File not found"}},
286
+ )
287
+ async def download_file(job_id: str, filename: str):
288
+ root = Path(_settings.media_output_dir).resolve()
289
+ if job_id != Path(job_id).name or filename != Path(filename).name:
290
+ raise HTTPException(status_code=404, detail={"success": False, "message": "File not found."})
291
+ path = (root / job_id / "out" / filename).resolve()
292
+ if not str(path).startswith(str(root)) or not path.is_file():
293
+ raise HTTPException(status_code=404, detail={"success": False, "message": "File not found."})
294
+ media_type = _MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
295
+ return FileResponse(path, media_type=media_type, filename=filename)
296
+
297
+
298
+ @router.get(
299
+ "/media-convert/formats",
300
+ response_model=MediaFormatsResponse,
301
+ summary="List supported media conversions",
302
+ )
303
+ async def list_formats():
304
+ return MediaFormatsResponse(
305
+ success=True,
306
+ pdf_to_image=sorted(_PDF_OUTPUT_FORMATS),
307
+ image_to_image=sorted(_IMAGE_OUTPUT_FORMATS),
308
+ storage_enabled=_settings.supabase_upload_enabled,
309
+ signed_url_ttl_seconds=_settings.supabase_signed_url_ttl_seconds,
310
+ )
app/api/v1/qr_decoder.py CHANGED
@@ -6,6 +6,7 @@ from typing import List, Optional
6
  from fastapi import APIRouter, File, HTTPException, UploadFile
7
  from pydantic import BaseModel, Field
8
 
 
9
  from app.core.logger import get_logger
10
  from app.services.qr_decoder_service import QRDecoderService
11
 
@@ -13,9 +14,10 @@ logger = get_logger(__name__)
13
 
14
  router = APIRouter()
15
 
16
- MAX_UPLOAD_BYTES = 20 * 1024 * 1024
 
17
 
18
- _service = QRDecoderService()
19
 
20
 
21
  class QRDecodeUrlRequest(BaseModel):
 
6
  from fastapi import APIRouter, File, HTTPException, UploadFile
7
  from pydantic import BaseModel, Field
8
 
9
+ from app.config import get_settings
10
  from app.core.logger import get_logger
11
  from app.services.qr_decoder_service import QRDecoderService
12
 
 
14
 
15
  router = APIRouter()
16
 
17
+ _settings = get_settings()
18
+ MAX_UPLOAD_BYTES = _settings.max_upload_bytes
19
 
20
+ _service = QRDecoderService(max_file_size_mb=_settings.max_upload_mb)
21
 
22
 
23
  class QRDecodeUrlRequest(BaseModel):
app/api/v1/router.py CHANGED
@@ -16,6 +16,7 @@ from app.api.v1 import (
16
  google_oauth,
17
  json_extract,
18
  keys_extract,
 
19
  qr_decoder,
20
  qr_generator,
21
  reconcile,
@@ -57,6 +58,7 @@ api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
57
  api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
58
  api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
59
  api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
 
60
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
61
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
62
  api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
 
16
  google_oauth,
17
  json_extract,
18
  keys_extract,
19
+ media_convert,
20
  qr_decoder,
21
  qr_generator,
22
  reconcile,
 
58
  api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
59
  api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
60
  api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
61
+ api_v1_router.include_router(media_convert.router, tags=["Media-to-Media Conversion"])
62
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
63
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
64
  api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
app/config.py CHANGED
@@ -124,6 +124,24 @@ class Settings(BaseSettings):
124
  google_oauth_state_ttl_minutes: int = 10
125
  google_oauth_jwks_ttl_seconds: int = 3600
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  # Scheduler settings
128
  max_http_timeout: float = 300.0
129
  default_scheduler_timezone: str = "UTC"
 
124
  google_oauth_state_ttl_minutes: int = 10
125
  google_oauth_jwks_ttl_seconds: int = 3600
126
 
127
+ # Media-to-Media conversion settings
128
+ media_output_dir: str = "./data/media-convert"
129
+ media_max_workers: int = 4
130
+ media_default_dpi: int = 150
131
+ media_max_dpi: int = 300
132
+ media_default_quality: int = 85
133
+ media_max_pages: int = 200
134
+ media_max_memory_mb: int = 512
135
+ media_max_image_pixels: int = 50_000_000
136
+ media_upload_concurrency: int = 4
137
+ media_signed_url_ttl_seconds: int = 86400
138
+ media_conversion_timeout_seconds: int = 300
139
+
140
+ # Supabase Storage upload for converted media
141
+ supabase_upload_enabled: bool = Field(default=False, alias="SUPABASE_UPLOAD_ENABLED")
142
+ supabase_storage_bucket: str = Field(default="media-convert", alias="SUPABASE_STORAGE_BUCKET")
143
+ supabase_signed_url_ttl_seconds: int = Field(default=86400, alias="SUPABASE_SIGNED_URL_TTL_SECONDS")
144
+
145
  # Scheduler settings
146
  max_http_timeout: float = 300.0
147
  default_scheduler_timezone: str = "UTC"
app/models/schemas.py CHANGED
@@ -1449,3 +1449,188 @@ class GCSURLResponse(BaseModel):
1449
  method: Optional[str] = Field(None, description="HTTP method the signed URL authorizes")
1450
  expires_in_seconds: Optional[int] = Field(None, description="Signed URL lifetime in seconds")
1451
  error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1449
  method: Optional[str] = Field(None, description="HTTP method the signed URL authorizes")
1450
  expires_in_seconds: Optional[int] = Field(None, description="Signed URL lifetime in seconds")
1451
  error: Optional[str] = None
1452
+
1453
+
1454
+ # ---------------------------------------------------------------------------
1455
+ # Media-to-Media conversion
1456
+ # ---------------------------------------------------------------------------
1457
+
1458
+ class MediaImageFormat(str, Enum):
1459
+ """Output image formats supported by the media converter."""
1460
+
1461
+ JPEG = "JPEG"
1462
+ JPG = "JPEG"
1463
+ PNG = "PNG"
1464
+ WEBP = "WEBP"
1465
+ BMP = "BMP"
1466
+ GIF = "GIF"
1467
+ TIFF = "TIFF"
1468
+
1469
+
1470
+ class PDFConversionParams(BaseModel):
1471
+ """Options for PDF -> image conversion."""
1472
+
1473
+ format: MediaImageFormat = Field(MediaImageFormat.PNG, description="Output image format")
1474
+ dpi: int = Field(150, ge=72, le=300, description="Render DPI (72-300)")
1475
+ quality: int = Field(85, ge=1, le=100, description="Output quality (1-100, JPEG/WEBP/TIFF)")
1476
+ pages: Optional[str] = Field(None, description="Page spec: '1', '1-3', '1,3,5-7'. Empty = all pages.")
1477
+ grayscale: bool = Field(False, description="Convert output to grayscale")
1478
+ transparent_bg: bool = Field(False, description="Preserve transparency (PNG only)")
1479
+ split_page: bool = Field(False, description="True: each page is a separate image. False: stitch into one tall image.")
1480
+
1481
+ @field_validator("format", mode="before")
1482
+ @classmethod
1483
+ def normalise_format(cls, v):
1484
+ if isinstance(v, str):
1485
+ upper = v.upper()
1486
+ if upper == "JPG":
1487
+ return "JPEG"
1488
+ return upper
1489
+ return v
1490
+
1491
+ @field_validator("pages")
1492
+ @classmethod
1493
+ def validate_page_spec(cls, v):
1494
+ if v is None or str(v).strip() == "":
1495
+ return None
1496
+ spec = str(v)
1497
+ if not re.match(r"^\s*(\d+(-\d+)?)(\s*,\s*(\d+(-\d+)?))*\s*$", spec):
1498
+ raise ValueError(
1499
+ f"Invalid page spec '{v}'. Use comma-separated numbers or ranges, "
1500
+ "e.g. '1', '1-3', '1,3,5-7'."
1501
+ )
1502
+ for token in spec.split(","):
1503
+ token = token.strip()
1504
+ if "-" in token:
1505
+ start, end = (int(x) for x in token.split("-", 1))
1506
+ if start < 1 or end < 1:
1507
+ raise ValueError(f"Page numbers are 1-indexed; got '{token}'")
1508
+ if start > end:
1509
+ raise ValueError(f"Invalid range '{token}': start must be <= end")
1510
+ elif int(token) < 1:
1511
+ raise ValueError(f"Page numbers are 1-indexed; got '{token}'")
1512
+ return spec
1513
+
1514
+ @model_validator(mode="after")
1515
+ def validate_transparent_bg(self):
1516
+ if self.transparent_bg and self.format not in (MediaImageFormat.PNG, MediaImageFormat.WEBP):
1517
+ raise ValueError("transparent_bg is only supported for PNG or WEBP output")
1518
+ return self
1519
+
1520
+
1521
+ class ImageConversionParams(BaseModel):
1522
+ """Options for image -> image conversion."""
1523
+
1524
+ format: MediaImageFormat = Field(MediaImageFormat.JPEG, description="Target output format")
1525
+ quality: int = Field(85, ge=1, le=100, description="Output quality (1-100, JPEG/WEBP/TIFF)")
1526
+ grayscale: bool = Field(False, description="Convert output to grayscale")
1527
+ width: Optional[int] = Field(None, ge=1, le=100000, description="Resize width (keeps aspect ratio if height omitted)")
1528
+ height: Optional[int] = Field(None, ge=1, le=100000, description="Resize height (keeps aspect ratio if width omitted)")
1529
+ rotate: float = Field(0.0, ge=0, le=360, description="Rotate clockwise in degrees")
1530
+ flip: Optional[Literal["horizontal", "vertical"]] = Field(None, description="Flip the image")
1531
+
1532
+ @field_validator("format", mode="before")
1533
+ @classmethod
1534
+ def normalise_format(cls, v):
1535
+ if isinstance(v, str):
1536
+ upper = v.upper()
1537
+ if upper == "JPG":
1538
+ return "JPEG"
1539
+ return upper
1540
+ return v
1541
+
1542
+ @model_validator(mode="after")
1543
+ def validate_resize(self):
1544
+ if self.width is None and self.height is None:
1545
+ return self
1546
+ if self.width is not None and self.width < 1:
1547
+ raise ValueError("width must be a positive integer")
1548
+ if self.height is not None and self.height < 1:
1549
+ raise ValueError("height must be a positive integer")
1550
+ return self
1551
+
1552
+
1553
+ class MediaOutputFile(BaseModel):
1554
+ """A single converted media file produced by a conversion job."""
1555
+
1556
+ filename: str = Field(..., description="Output file name")
1557
+ page_number: Optional[int] = Field(None, description="Source page number for PDF conversions (1-indexed)")
1558
+ width: int = Field(0, description="Pixel width")
1559
+ height: int = Field(0, description="Pixel height")
1560
+ size_bytes: int = Field(0, description="File size in bytes")
1561
+ format: str = Field(..., description="Output format (e.g. JPEG, PNG)")
1562
+ content_type: str = Field("application/octet-stream", description="MIME type of the output")
1563
+ url: str = Field(..., description="Download URL (Supabase signed URL or local endpoint)")
1564
+ base64: Optional[str] = Field(None, description="Base64-encoded file bytes (local mode only)")
1565
+
1566
+
1567
+ class MediaUploadSummary(BaseModel):
1568
+ """How the converted files are exposed to the caller."""
1569
+
1570
+ mode: Literal["storage", "local"] = Field("local", description="'storage' = Supabase Storage, 'local' = served from this service")
1571
+ bucket: Optional[str] = Field(None, description="Supabase Storage bucket used (storage mode)")
1572
+ total_files: int = Field(0, description="Number of files uploaded / linked")
1573
+ failed_uploads: int = Field(0, description="Number of uploads that failed (storage mode)")
1574
+ url_ttl_seconds: Optional[int] = Field(None, description="Signed URL lifetime in seconds (storage mode)")
1575
+ expires_at: Optional[str] = Field(None, description="ISO-8601 timestamp when signed URLs expire (storage mode)")
1576
+
1577
+
1578
+ class MediaConversionData(BaseModel):
1579
+ """Structured result of a media-to-media conversion."""
1580
+
1581
+ job_id: str = Field(..., description="Unique job identifier")
1582
+ source: str = Field(..., description="Source file name or URL")
1583
+ input_format: str = Field(..., description="Detected input media format")
1584
+ output_format: str = Field(..., description="Primary output format")
1585
+ total_pages: int = Field(0, description="Source page count (PDF) or 1 (images)")
1586
+ converted_files: int = Field(0, description="Number of output files produced")
1587
+ outputs: List[MediaOutputFile] = Field(default_factory=list, description="Converted files with URLs")
1588
+ upload: MediaUploadSummary = Field(default_factory=MediaUploadSummary, description="Upload / exposure details")
1589
+ warning: Optional[str] = Field(None, description="Warnings, e.g. signed URL expiry notice")
1590
+
1591
+
1592
+ class MediaConversionResponse(BaseModel):
1593
+ """Envelope for synchronous media conversion responses."""
1594
+
1595
+ success: bool
1596
+ time_ms: float = 0.0
1597
+ data: Optional[MediaConversionData] = None
1598
+ error: Optional[str] = None
1599
+
1600
+
1601
+ class MediaFormatsResponse(BaseModel):
1602
+ """Lists the supported media-to-media conversions."""
1603
+
1604
+ success: bool
1605
+ pdf_to_image: List[str] = Field(default_factory=list, description="Output formats from PDF input")
1606
+ image_to_image: List[str] = Field(default_factory=list, description="Output formats from image input")
1607
+ storage_enabled: bool = Field(False, description="Whether Supabase Storage upload is enabled")
1608
+ signed_url_ttl_seconds: int = Field(86400, description="Signed URL lifetime when storage is enabled")
1609
+
1610
+
1611
+ class MediaPDFUrlRequest(BaseModel):
1612
+ """Body for converting a PDF fetched from a URL."""
1613
+
1614
+ url: str = Field(..., description="Publicly reachable PDF URL")
1615
+ params: PDFConversionParams = Field(default_factory=PDFConversionParams)
1616
+
1617
+ @field_validator("url")
1618
+ @classmethod
1619
+ def validate_scheme(cls, v: str) -> str:
1620
+ if not v.startswith(("http://", "https://")):
1621
+ raise ValueError("Only http/https URLs are supported.")
1622
+ return v
1623
+
1624
+
1625
+ class MediaImageUrlRequest(BaseModel):
1626
+ """Body for converting an image fetched from a URL."""
1627
+
1628
+ url: str = Field(..., description="Publicly reachable image URL")
1629
+ params: ImageConversionParams = Field(default_factory=ImageConversionParams)
1630
+
1631
+ @field_validator("url")
1632
+ @classmethod
1633
+ def validate_scheme(cls, v: str) -> str:
1634
+ if not v.startswith(("http://", "https://")):
1635
+ raise ValueError("Only http/https URLs are supported.")
1636
+ return v
app/services/media_conversion_service.py ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Media-to-Media conversion service.
2
+
3
+ Converts between media formats without blocking the FastAPI event loop:
4
+
5
+ * PDF -> images (JPEG / PNG / WEBP) using pypdfium2 + Pillow.
6
+ * Image -> image (JPEG / PNG / WEBP / BMP / GIF / TIFF) using Pillow.
7
+
8
+ All CPU-bound pixel work runs in the shared thread pool
9
+ (:mod:`app.core.thread_pool`), mirroring the architecture of the document
10
+ converter and the reference PDF-conversion service.
11
+
12
+ Output files are written to a per-job directory and then either:
13
+
14
+ * exposed through local download endpoints (``SUPABASE_UPLOAD_ENABLED=false``),
15
+ or
16
+ * uploaded to Supabase Storage with 24-hour signed URLs
17
+ (``SUPABASE_UPLOAD_ENABLED=true``) and a warning stating the expiry.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import base64
24
+ import io
25
+ import re
26
+ import time
27
+ import uuid
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional, Tuple
30
+
31
+ from app.config import get_settings
32
+ from app.core.logger import get_logger
33
+ from app.core.thread_pool import thread_pool as _thread_pool
34
+ from app.models.schemas import (
35
+ ImageConversionParams,
36
+ MediaConversionData,
37
+ MediaOutputFile,
38
+ MediaUploadSummary,
39
+ PDFConversionParams,
40
+ )
41
+
42
+ _logger = get_logger(__name__)
43
+ _settings = get_settings()
44
+
45
+ _PAGE_SPEC_RE = re.compile(r"^\s*(\d+(-\d+)?)(\s*,\s*(\d+(-\d+)?))*\s*$")
46
+
47
+ _PDF_OUTPUT_FORMATS = frozenset({"JPEG", "PNG", "WEBP"})
48
+ _IMAGE_OUTPUT_FORMATS = frozenset({"JPEG", "PNG", "WEBP", "BMP", "GIF", "TIFF"})
49
+
50
+ _EXT_BY_FORMAT: Dict[str, str] = {
51
+ "JPEG": "jpg", "PNG": "png", "WEBP": "webp",
52
+ "BMP": "bmp", "GIF": "gif", "TIFF": "tiff",
53
+ }
54
+ _MIME_BY_FORMAT: Dict[str, str] = {
55
+ "JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp",
56
+ "BMP": "image/bmp", "GIF": "image/gif", "TIFF": "image/tiff",
57
+ }
58
+
59
+
60
+ class MediaConversionError(Exception):
61
+ """Raised for invalid input or failed conversions, mapped to HTTP errors."""
62
+
63
+ def __init__(self, message: str, status_code: int = 400) -> None:
64
+ super().__init__(message)
65
+ self.message = message
66
+ self.status_code = status_code
67
+
68
+
69
+ def _fmt_str(fmt) -> str:
70
+ return fmt.value if hasattr(fmt, "value") else str(fmt)
71
+
72
+
73
+ def _ext_for(fmt: str) -> str:
74
+ return _EXT_BY_FORMAT.get(fmt, "bin")
75
+
76
+
77
+ def _save_kwargs(fmt: str, quality: int) -> Dict[str, Any]:
78
+ if fmt == "JPEG":
79
+ return {"quality": quality, "optimize": True}
80
+ if fmt == "PNG":
81
+ return {"optimize": True}
82
+ if fmt == "WEBP":
83
+ return {"quality": quality, "method": 4}
84
+ if fmt == "TIFF":
85
+ return {"compression": "tiff_lzw"}
86
+ return {}
87
+
88
+
89
+ def _normalise_for_jpeg(img):
90
+ """Return an RGB image suitable for JPEG, flattening alpha onto white."""
91
+ from PIL import Image
92
+
93
+ mode = img.mode
94
+ if mode == "RGB":
95
+ return img
96
+ if mode in ("RGBA", "LA", "P"):
97
+ if mode == "P":
98
+ img = img.convert("RGBA")
99
+ bg = Image.new("RGB", img.size, (255, 255, 255))
100
+ mask = img.split()[-1] if mode in ("RGBA", "LA") else None
101
+ bg.paste(img, mask=mask)
102
+ return bg
103
+ return img.convert("RGB")
104
+
105
+
106
+ def _normalise_for_png(img):
107
+ """Return an image whose mode PIL accepts for PNG / WEBP."""
108
+ if img.mode in ("RGB", "RGBA", "L", "LA"):
109
+ return img
110
+ if img.mode == "P":
111
+ return img.convert("RGBA")
112
+ return img.convert("RGB")
113
+
114
+
115
+ def _parse_pages(spec: Optional[str], total_pages: int) -> List[int]:
116
+ """Expand a page spec ('1', '1-3', '1,3,5-7') into 0-indexed page indices."""
117
+ if not spec or not spec.strip():
118
+ return list(range(total_pages))
119
+ indices: set[int] = set()
120
+ for token in str(spec).split(","):
121
+ token = token.strip()
122
+ if "-" in token:
123
+ start, end = (int(x) for x in token.split("-", 1))
124
+ indices.update(range(start - 1, end))
125
+ else:
126
+ indices.add(int(token) - 1)
127
+ valid = sorted(i for i in indices if 0 <= i < total_pages)
128
+ if not valid:
129
+ raise MediaConversionError(
130
+ f"Page spec '{spec}' contains no pages within the document's {total_pages} page(s).",
131
+ status_code=422,
132
+ )
133
+ return valid
134
+
135
+
136
+ def _guard_memory(page_count: int, dpi: int) -> None:
137
+ bytes_per_page = (dpi * 8.5) * (dpi * 11) * 3
138
+ estimated_mb = (bytes_per_page * page_count) / (1024 * 1024)
139
+ if estimated_mb > _settings.media_max_memory_mb:
140
+ raise MediaConversionError(
141
+ f"Requested conversion would need ~{estimated_mb:.0f} MB of memory "
142
+ f"({page_count} pages at {dpi} DPI). Reduce DPI or select fewer pages.",
143
+ status_code=422,
144
+ )
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Thread-pool worker functions (must stay top-level for pickling)
149
+ # ---------------------------------------------------------------------------
150
+
151
+ def _pdf_total_pages(data: bytes) -> int:
152
+ import pypdfium2 as pdfium
153
+
154
+ try:
155
+ doc = pdfium.PdfDocument(data)
156
+ except Exception as exc:
157
+ raise MediaConversionError(
158
+ f"Input is not a valid PDF: {exc}", status_code=422
159
+ ) from exc
160
+ try:
161
+ total = len(doc)
162
+ finally:
163
+ doc.close()
164
+ if total == 0:
165
+ raise MediaConversionError("PDF contains no pages.", status_code=422)
166
+ return total
167
+
168
+
169
+ def _split_pdf_pages(data: bytes, page_indices: List[int]) -> Dict[int, bytes]:
170
+ """Extract each requested page into its own single-page PDF blob."""
171
+ import pypdfium2 as pdfium
172
+
173
+ blobs: Dict[int, bytes] = {}
174
+ try:
175
+ src = pdfium.PdfDocument(data)
176
+ for page_idx in page_indices:
177
+ dst = pdfium.PdfDocument.new()
178
+ try:
179
+ dst.import_pages(src, pages=[page_idx])
180
+ with io.BytesIO() as buf:
181
+ dst.save(buf)
182
+ blobs[page_idx] = buf.getvalue()
183
+ finally:
184
+ dst.close()
185
+ finally:
186
+ src.close()
187
+ return blobs
188
+
189
+
190
+ def _render_pdf_page(blob: bytes, params: PDFConversionParams, out_path: Path) -> Tuple[int, int, int]:
191
+ """Render a single-page PDF blob to an image file. Returns (width, height, size_bytes)."""
192
+ from PIL import Image
193
+ import pypdfium2 as pdfium
194
+
195
+ fmt = _fmt_str(params.format)
196
+ doc = None
197
+ bitmap = None
198
+ try:
199
+ doc = pdfium.PdfDocument(blob)
200
+ scale = params.dpi / 72.0
201
+ bitmap = doc[0].render(scale=scale)
202
+ img = bitmap.to_pil()
203
+ except Exception as exc:
204
+ raise MediaConversionError(f"Failed to render PDF page: {exc}", status_code=500) from exc
205
+ finally:
206
+ if bitmap is not None:
207
+ try:
208
+ bitmap.close()
209
+ except Exception:
210
+ pass
211
+ if doc is not None:
212
+ try:
213
+ doc.close()
214
+ except Exception:
215
+ pass
216
+
217
+ if fmt == "JPEG":
218
+ img = _normalise_for_jpeg(img)
219
+ else:
220
+ if params.transparent_bg:
221
+ img = _normalise_for_png(img)
222
+ else:
223
+ img = _normalise_for_png(img)
224
+ if img.mode in ("RGBA", "LA"):
225
+ bg = Image.new("RGB", img.size, (255, 255, 255))
226
+ bg.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
227
+ img = bg
228
+ if params.grayscale:
229
+ img = img.convert("L")
230
+
231
+ try:
232
+ img.save(str(out_path), format=fmt, **_save_kwargs(fmt, params.quality))
233
+ except OSError as exc:
234
+ raise MediaConversionError(f"Failed to write output file '{out_path.name}': {exc}", status_code=500) from exc
235
+
236
+ stat = out_path.stat()
237
+ return img.width, img.height, stat.st_size
238
+
239
+
240
+ def _stitch_images(page_files: List[Tuple[int, Path]], fmt: str, out_path: Path, quality: int) -> Tuple[int, int, int]:
241
+ """Stitch rendered page images vertically into one tall image."""
242
+ from PIL import Image
243
+
244
+ images: List[Image.Image] = []
245
+ for _, path in sorted(page_files, key=lambda t: t[0]):
246
+ img = Image.open(path)
247
+ if fmt == "JPEG":
248
+ img = _normalise_for_jpeg(img)
249
+ else:
250
+ img = _normalise_for_png(img)
251
+ images.append(img)
252
+
253
+ total_width = max(im.width for im in images)
254
+ total_height = sum(im.height for im in images)
255
+ mode = images[0].mode
256
+ fill = (255, 255, 255) if mode == "RGB" else 255
257
+ stitched = Image.new(mode, (total_width, total_height), color=fill)
258
+ y_offset = 0
259
+ for img in images:
260
+ stitched.paste(img, (0, y_offset))
261
+ y_offset += img.height
262
+
263
+ stitched.save(str(out_path), format=fmt, **_save_kwargs(fmt, quality))
264
+ stat = out_path.stat()
265
+ return stitched.width, stitched.height, stat.st_size
266
+
267
+
268
+ def _convert_image_bytes(data: bytes, params: ImageConversionParams, out_path: Path) -> Tuple[int, int, int, str]:
269
+ """Convert raw image bytes to the requested format. Returns (width, height, size_bytes, detected_format)."""
270
+ from PIL import Image, UnidentifiedImageError
271
+
272
+ try:
273
+ img = Image.open(io.BytesIO(data))
274
+ detected = (img.format or "UNKNOWN").upper()
275
+ img.load()
276
+ except (UnidentifiedImageError, OSError, ValueError) as exc:
277
+ raise MediaConversionError(
278
+ f"Input is not a valid or supported image: {exc}", status_code=422
279
+ ) from exc
280
+
281
+ if params.rotate:
282
+ img = img.rotate(params.rotate % 360, expand=True)
283
+ if params.flip == "horizontal":
284
+ img = img.transpose(Image.FLIP_LEFT_RIGHT)
285
+ elif params.flip == "vertical":
286
+ img = img.transpose(Image.FLIP_TOP_BOTTOM)
287
+ if params.grayscale:
288
+ img = img.convert("L")
289
+
290
+ if params.width is not None or params.height is not None:
291
+ orig_w, orig_h = img.size
292
+ if params.width is not None and params.height is not None:
293
+ target = (params.width, params.height)
294
+ elif params.width is not None:
295
+ ratio = params.width / orig_w
296
+ target = (params.width, max(1, round(orig_h * ratio)))
297
+ else:
298
+ ratio = params.height / orig_h
299
+ target = (max(1, round(orig_w * ratio)), params.height)
300
+ if target[0] * target[1] > _settings.media_max_image_pixels:
301
+ raise MediaConversionError(
302
+ f"Resized image ({target[0]}x{target[1]}) exceeds the "
303
+ f"{_settings.media_max_image_pixels} pixel limit.",
304
+ status_code=422,
305
+ )
306
+ img = img.resize(target, Image.LANCZOS)
307
+
308
+ fmt = _fmt_str(params.format)
309
+ if fmt == "JPEG":
310
+ img = _normalise_for_jpeg(img)
311
+ elif fmt in ("PNG", "WEBP") and img.mode not in ("RGB", "RGBA", "L", "LA"):
312
+ img = _normalise_for_png(img)
313
+
314
+ try:
315
+ img.save(str(out_path), format=fmt, **_save_kwargs(fmt, params.quality))
316
+ except OSError as exc:
317
+ raise MediaConversionError(f"Failed to write output file '{out_path.name}': {exc}", status_code=500) from exc
318
+
319
+ stat = out_path.stat()
320
+ return img.width, img.height, stat.st_size, detected
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Service
325
+ # ---------------------------------------------------------------------------
326
+
327
+ class MediaConversionService:
328
+ """Orchestrates media conversion plus local/storage exposure of results."""
329
+
330
+ def __init__(self, storage=None) -> None:
331
+ self._storage = storage
332
+
333
+ async def _resolve_storage(self):
334
+ if self._storage is not None:
335
+ return self._storage
336
+ from app.services.media_storage_service import get_storage_service
337
+
338
+ return await get_storage_service()
339
+
340
+ async def convert_pdf(
341
+ self,
342
+ data: bytes,
343
+ params: PDFConversionParams,
344
+ source: str,
345
+ job_id: Optional[str] = None,
346
+ ) -> MediaConversionData:
347
+ """Convert a PDF to images. Runs validation + rendering in the thread pool."""
348
+ job_id = job_id or str(uuid.uuid4())
349
+ fmt = _fmt_str(params.format)
350
+ if fmt not in _PDF_OUTPUT_FORMATS:
351
+ raise MediaConversionError(
352
+ f"Unsupported output format '{fmt}'. PDF can only be converted to JPEG, PNG or WEBP.",
353
+ status_code=422,
354
+ )
355
+
356
+ loop = asyncio.get_running_loop()
357
+ total_pages = await loop.run_in_executor(_thread_pool, _pdf_total_pages, data)
358
+ if total_pages > _settings.media_max_pages:
359
+ raise MediaConversionError(
360
+ f"PDF has {total_pages} pages, exceeding the {_settings.media_max_pages} page limit.",
361
+ status_code=422,
362
+ )
363
+ page_indices = _parse_pages(params.pages, total_pages)
364
+ _guard_memory(len(page_indices), params.dpi)
365
+
366
+ start = time.perf_counter()
367
+ job_dir = self._job_dir(job_id)
368
+ out_dir = job_dir / "out"
369
+ out_dir.mkdir(parents=True, exist_ok=True)
370
+
371
+ page_blobs = await loop.run_in_executor(
372
+ _thread_pool, _split_pdf_pages, data, page_indices
373
+ )
374
+
375
+ if params.split_page:
376
+ render_coros = [
377
+ loop.run_in_executor(
378
+ _thread_pool,
379
+ _render_pdf_page,
380
+ page_blobs[idx],
381
+ params,
382
+ out_dir / f"page_{idx + 1:04d}.{_ext_for(fmt)}",
383
+ )
384
+ for idx in sorted(page_blobs)
385
+ ]
386
+ outcomes = await asyncio.gather(*render_coros, return_exceptions=True)
387
+ files: List[MediaOutputFile] = []
388
+ for idx, outcome in zip(sorted(page_blobs), outcomes):
389
+ if isinstance(outcome, Exception):
390
+ _logger.error("page_render_failed job=%s page=%d error=%s", job_id, idx + 1, outcome)
391
+ raise MediaConversionError(
392
+ f"Failed to render page {idx + 1}: {outcome}", status_code=500
393
+ ) from outcome
394
+ width, height, size = outcome
395
+ files.append(self._output_file(
396
+ f"page_{idx + 1:04d}.{_ext_for(fmt)}", idx + 1,
397
+ width, height, size, fmt,
398
+ ))
399
+ else:
400
+ if len(page_blobs) == 1:
401
+ width, height, size = await loop.run_in_executor(
402
+ _thread_pool,
403
+ _render_pdf_page,
404
+ page_blobs[sorted(page_blobs)[0]],
405
+ params,
406
+ out_dir / f"stitched.{_ext_for(fmt)}",
407
+ )
408
+ else:
409
+ render_coros = [
410
+ loop.run_in_executor(
411
+ _thread_pool,
412
+ _render_pdf_page,
413
+ page_blobs[idx],
414
+ params,
415
+ out_dir / f"_page_{idx + 1:04d}.{_ext_for(fmt)}",
416
+ )
417
+ for idx in sorted(page_blobs)
418
+ ]
419
+ outcomes = await asyncio.gather(*render_coros, return_exceptions=True)
420
+ for idx, outcome in zip(sorted(page_blobs), outcomes):
421
+ if isinstance(outcome, Exception):
422
+ raise MediaConversionError(
423
+ f"Failed to render page {idx + 1}: {outcome}", status_code=500
424
+ ) from outcome
425
+ page_files = [(idx, out_dir / f"_page_{idx + 1:04d}.{_ext_for(fmt)}") for idx in sorted(page_blobs)]
426
+ width, height, size = await loop.run_in_executor(
427
+ _thread_pool,
428
+ _stitch_images,
429
+ page_files,
430
+ fmt,
431
+ out_dir / f"stitched.{_ext_for(fmt)}",
432
+ params.quality,
433
+ )
434
+ for _, tmp_path in page_files:
435
+ tmp_path.unlink(missing_ok=True)
436
+ files = [self._output_file(
437
+ f"stitched.{_ext_for(fmt)}", None, width, height, size, fmt,
438
+ )]
439
+
440
+ upload, warning = await self._expose(files, job_id, out_dir, job_dir)
441
+ duration_ms = round((time.perf_counter() - start) * 1000, 2)
442
+ _logger.info(
443
+ "pdf_conversion_complete job=%s pages=%d duration_ms=%s mode=%s",
444
+ job_id, total_pages, duration_ms, upload.mode,
445
+ )
446
+ return MediaConversionData(
447
+ job_id=job_id,
448
+ source=source,
449
+ input_format="PDF",
450
+ output_format=fmt,
451
+ total_pages=total_pages,
452
+ converted_files=len(files),
453
+ outputs=files,
454
+ upload=upload,
455
+ warning=warning,
456
+ )
457
+
458
+ async def convert_image(
459
+ self,
460
+ data: bytes,
461
+ filename: str,
462
+ params: ImageConversionParams,
463
+ job_id: Optional[str] = None,
464
+ ) -> MediaConversionData:
465
+ """Convert an image to a target image format."""
466
+ job_id = job_id or str(uuid.uuid4())
467
+ fmt = _fmt_str(params.format)
468
+ if fmt not in _IMAGE_OUTPUT_FORMATS:
469
+ raise MediaConversionError(
470
+ f"Unsupported output format '{fmt}'.",
471
+ status_code=422,
472
+ )
473
+
474
+ start = time.perf_counter()
475
+ job_dir = self._job_dir(job_id)
476
+ out_dir = job_dir / "out"
477
+ out_dir.mkdir(parents=True, exist_ok=True)
478
+ stem = Path(filename or "image").stem or "image"
479
+ out_path = out_dir / f"{stem}.{_ext_for(fmt)}"
480
+
481
+ loop = asyncio.get_running_loop()
482
+ width, height, size, detected = await loop.run_in_executor(
483
+ _thread_pool, _convert_image_bytes, data, params, out_path
484
+ )
485
+
486
+ file = self._output_file(out_path.name, None, width, height, size, fmt)
487
+ upload, warning = await self._expose([file], job_id, out_dir, job_dir)
488
+ duration_ms = round((time.perf_counter() - start) * 1000, 2)
489
+ _logger.info(
490
+ "image_conversion_complete job=%s input=%s duration_ms=%s mode=%s",
491
+ job_id, detected, duration_ms, upload.mode,
492
+ )
493
+ return MediaConversionData(
494
+ job_id=job_id,
495
+ source=filename,
496
+ input_format=detected or "IMAGE",
497
+ output_format=fmt,
498
+ total_pages=1,
499
+ converted_files=1,
500
+ outputs=[file],
501
+ upload=upload,
502
+ warning=warning,
503
+ )
504
+
505
+ # ------------------------------------------------------------------
506
+ # Helpers
507
+ # ------------------------------------------------------------------
508
+
509
+ @staticmethod
510
+ def _job_dir(job_id: str) -> Path:
511
+ root = Path(_settings.media_output_dir).resolve()
512
+ root.mkdir(parents=True, exist_ok=True)
513
+ return root / job_id
514
+
515
+ @staticmethod
516
+ def _output_file(
517
+ filename: str,
518
+ page_number: Optional[int],
519
+ width: int,
520
+ height: int,
521
+ size_bytes: int,
522
+ fmt: str,
523
+ ) -> MediaOutputFile:
524
+ return MediaOutputFile(
525
+ filename=filename,
526
+ page_number=page_number,
527
+ width=width,
528
+ height=height,
529
+ size_bytes=size_bytes,
530
+ format=fmt,
531
+ content_type=_MIME_BY_FORMAT.get(fmt, "application/octet-stream"),
532
+ url="", # filled in by _expose
533
+ )
534
+
535
+ async def _expose(
536
+ self,
537
+ files: List[MediaOutputFile],
538
+ job_id: str,
539
+ out_dir: Path,
540
+ job_dir: Path,
541
+ ) -> Tuple[MediaUploadSummary, Optional[str]]:
542
+ """Expose output files via Supabase signed URLs or local download endpoints."""
543
+ if _settings.supabase_upload_enabled:
544
+ storage = await self._resolve_storage()
545
+ bucket = await storage.ensure_bucket(_settings.supabase_storage_bucket)
546
+ ttl = _settings.supabase_signed_url_ttl_seconds
547
+ semaphore = asyncio.Semaphore(max(1, _settings.media_upload_concurrency))
548
+
549
+ from app.services.media_storage_service import iso_expiry
550
+
551
+ async def _upload_one(f: MediaOutputFile) -> MediaOutputFile:
552
+ storage_path = f"{job_id}/{f.filename}"
553
+ async with semaphore:
554
+ await storage.upload_file(
555
+ bucket, storage_path,
556
+ (out_dir / f.filename).read_bytes(), f.content_type,
557
+ )
558
+ f.url = await storage.create_signed_url(bucket, storage_path, ttl)
559
+ return f
560
+
561
+ outcomes = await asyncio.gather(*[_upload_one(f) for f in files], return_exceptions=True)
562
+ failed = 0
563
+ for f, outcome in zip(files, outcomes):
564
+ if isinstance(outcome, Exception):
565
+ failed += 1
566
+ _logger.error("storage_upload_failed job=%s file=%s error=%s", job_id, f.filename, outcome)
567
+ f.url = f"/api/v1/media-convert/files/{job_id}/{f.filename}"
568
+
569
+ expires_at = iso_expiry(ttl)
570
+ warning = (
571
+ f"Converted files were uploaded to Supabase Storage bucket "
572
+ f"'{bucket}'. The returned signed URLs are valid for 24 hours "
573
+ f"(expire at {expires_at}). Regenerate by re-running the conversion."
574
+ )
575
+ return (
576
+ MediaUploadSummary(
577
+ mode="storage",
578
+ bucket=bucket,
579
+ total_files=len(files),
580
+ failed_uploads=failed,
581
+ url_ttl_seconds=ttl,
582
+ expires_at=expires_at,
583
+ ),
584
+ warning,
585
+ )
586
+
587
+ for f in files:
588
+ f.url = f"/api/v1/media-convert/files/{job_id}/{f.filename}"
589
+ try:
590
+ f.base64 = base64.b64encode((out_dir / f.filename).read_bytes()).decode("ascii")
591
+ except OSError:
592
+ f.base64 = None
593
+ return MediaUploadSummary(mode="local", total_files=len(files), failed_uploads=0), None
594
+
595
+
596
+ media_conversion_service = MediaConversionService()
app/services/media_storage_service.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supabase Storage integration for the Media-to-Media conversion APIs.
2
+
3
+ Provides a lazy, connection-light async client that:
4
+ 1. Ensures the destination bucket exists (creating it if needed).
5
+ 2. Uploads converted media files.
6
+ 3. Returns short-lived signed URLs (default 24 hours) for download.
7
+
8
+ All storage3 calls are already async/HTTP-based, so no thread-pool offload is
9
+ required here. The client is created on first use and closed during shutdown.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import time
16
+ from datetime import datetime, timezone
17
+ from typing import Any, Optional
18
+
19
+ from app.config import get_settings
20
+ from app.core.logger import get_logger
21
+
22
+ _logger = get_logger(__name__)
23
+ _settings = get_settings()
24
+
25
+
26
+ class MediaStorageError(Exception):
27
+ """Raised when Supabase Storage operations fail."""
28
+
29
+ def __init__(self, message: str, status_code: int = 502) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ self.status_code = status_code
33
+
34
+
35
+ class MediaStorageService:
36
+ """Async client for uploading converted media to Supabase Storage."""
37
+
38
+ def __init__(self) -> None:
39
+ self._client: Optional[Any] = None
40
+ self._client_lock = asyncio.Lock()
41
+ self._known_buckets: set[str] = set()
42
+
43
+ # ------------------------------------------------------------------
44
+ # Client management
45
+ # ------------------------------------------------------------------
46
+
47
+ async def _get_client(self):
48
+ """Lazily build the Supabase Storage async client."""
49
+ if self._client is None:
50
+ async with self._client_lock:
51
+ if self._client is None:
52
+ url = _settings.supabase_url
53
+ key = _settings.supabase_service_role_key
54
+ if not url or not key:
55
+ raise MediaStorageError(
56
+ "Supabase is not configured. Set SUPABASE_URL and "
57
+ "SUPABASE_SERVICE_ROLE_KEY to enable storage uploads.",
58
+ status_code=503,
59
+ )
60
+ from storage3 import AsyncStorageClient
61
+
62
+ self._client = AsyncStorageClient(
63
+ url,
64
+ headers={
65
+ "apikey": key,
66
+ "Authorization": f"Bearer {key}",
67
+ },
68
+ timeout=60,
69
+ )
70
+ _logger.info("Supabase Storage client initialized")
71
+ return self._client
72
+
73
+ async def close(self) -> None:
74
+ async with self._client_lock:
75
+ if self._client is not None:
76
+ try:
77
+ await self._client.aclose()
78
+ except Exception as exc: # pragma: no cover - defensive
79
+ _logger.debug("Error closing Supabase Storage client: %s", exc)
80
+ self._client = None
81
+
82
+ # ------------------------------------------------------------------
83
+ # Bucket management
84
+ # ------------------------------------------------------------------
85
+
86
+ async def ensure_bucket(self, bucket_id: str) -> str:
87
+ """Verify the bucket exists, creating it (private) if it does not."""
88
+ bucket_id = (bucket_id or _settings.supabase_storage_bucket or "media-convert").strip()
89
+ if not bucket_id:
90
+ raise MediaStorageError("A non-empty storage bucket name is required.", status_code=400)
91
+ if bucket_id in self._known_buckets:
92
+ return bucket_id
93
+
94
+ client = await self._get_client()
95
+ exists = False
96
+ try:
97
+ buckets = await client.list_buckets()
98
+ exists = any(getattr(b, "id", None) == bucket_id for b in buckets)
99
+ except Exception as exc:
100
+ _logger.warning("Could not list Supabase buckets: %s", exc)
101
+
102
+ if not exists:
103
+ try:
104
+ await client.create_bucket(bucket_id, name=bucket_id, options={"public": False})
105
+ _logger.info("Created Supabase Storage bucket: %s", bucket_id)
106
+ except Exception as exc:
107
+ raise MediaStorageError(
108
+ f"Failed to create storage bucket '{bucket_id}': {exc}", status_code=502
109
+ ) from exc
110
+
111
+ self._known_buckets.add(bucket_id)
112
+ return bucket_id
113
+
114
+ # ------------------------------------------------------------------
115
+ # Uploads & signed URLs
116
+ # ------------------------------------------------------------------
117
+
118
+ async def upload_file(self, bucket_id: str, path: str, data: bytes, content_type: str) -> None:
119
+ """Upload raw bytes to ``bucket_id`` under ``path``."""
120
+ client = await self._get_client()
121
+ try:
122
+ response = await client.from_(bucket_id).upload(
123
+ path,
124
+ data,
125
+ file_options={"content-type": content_type, "cache-control": "3600"},
126
+ )
127
+ if response is None or getattr(response, "error", None):
128
+ _logger.error("Supabase upload returned an error: %s", response)
129
+ raise MediaStorageError(
130
+ "Supabase Storage upload failed (unknown response).", status_code=502
131
+ )
132
+ except MediaStorageError:
133
+ raise
134
+ except Exception as exc:
135
+ raise MediaStorageError(
136
+ f"Supabase Storage upload failed for '{path}': {exc}", status_code=502
137
+ ) from exc
138
+
139
+ async def create_signed_url(self, bucket_id: str, path: str, ttl_seconds: int) -> str:
140
+ """Create a signed download URL valid for ``ttl_seconds``."""
141
+ client = await self._get_client()
142
+ try:
143
+ result = await client.from_(bucket_id).create_signed_url(path, int(ttl_seconds))
144
+ signed = getattr(result, "signed_url", None) or (result.get("signedURL") if isinstance(result, dict) else None)
145
+ if not signed:
146
+ raise MediaStorageError(
147
+ "Supabase returned an empty signed URL.", status_code=502
148
+ )
149
+ return signed
150
+ except MediaStorageError:
151
+ raise
152
+ except Exception as exc:
153
+ raise MediaStorageError(
154
+ f"Failed to create signed URL for '{path}': {exc}", status_code=502
155
+ ) from exc
156
+
157
+
158
+ _storage_service: Optional[MediaStorageService] = None
159
+ _storage_service_lock = asyncio.Lock()
160
+
161
+
162
+ async def get_storage_service() -> MediaStorageService:
163
+ """Return the shared MediaStorageService singleton."""
164
+ global _storage_service
165
+ if _storage_service is None:
166
+ async with _storage_service_lock:
167
+ if _storage_service is None:
168
+ _storage_service = MediaStorageService()
169
+ return _storage_service
170
+
171
+
172
+ async def close_storage_service() -> None:
173
+ global _storage_service
174
+ if _storage_service is not None:
175
+ await _storage_service.close()
176
+ _storage_service = None
177
+
178
+
179
+ def iso_expiry(ttl_seconds: int) -> str:
180
+ """ISO-8601 UTC timestamp ``ttl_seconds`` from now."""
181
+ return datetime.fromtimestamp(time.time() + ttl_seconds, tz=timezone.utc).isoformat()