Spaces:
Running
Running
| from __future__ import annotations | |
| import asyncio | |
| import time | |
| from typing import Annotated, Any, Dict, Optional, Tuple | |
| from urllib.parse import urlparse | |
| import httpx | |
| from fastapi import ( | |
| APIRouter, | |
| File, | |
| Form, | |
| HTTPException, | |
| UploadFile, | |
| ) | |
| from fastapi.responses import JSONResponse | |
| from pydantic import ValidationError | |
| from app.config import get_settings | |
| from app.core.logger import get_logger | |
| from app.models.schemas import ( | |
| ImageConversionParams, | |
| MediaConversionData, | |
| MediaConversionResponse, | |
| MediaFormatsResponse, | |
| MediaImageFormat, | |
| MediaImageUrlRequest, | |
| MediaPDFUrlRequest, | |
| PDFConversionParams, | |
| ) | |
| from app.services.media_conversion_service import ( | |
| MediaConversionError, | |
| _IMAGE_OUTPUT_FORMATS, | |
| _PDF_OUTPUT_FORMATS, | |
| media_conversion_service, | |
| ) | |
| from app.services.media_storage_service import MediaStorageError | |
| router = APIRouter() | |
| _logger = get_logger(__name__) | |
| _settings = get_settings() | |
| _MAX_UPLOAD_BYTES = _settings.max_upload_bytes | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _ok_response(start: float, data: MediaConversionData) -> MediaConversionResponse: | |
| return MediaConversionResponse( | |
| success=True, | |
| time_ms=round((time.perf_counter() - start) * 1000, 3), | |
| data=data, | |
| ) | |
| def _error_response(start: float, exc: Exception) -> JSONResponse: | |
| if isinstance(exc, MediaConversionError): | |
| code, message = exc.status_code, exc.message | |
| elif isinstance(exc, MediaStorageError): | |
| code, message = exc.status_code, exc.message | |
| else: | |
| code, message = 500, f"Media conversion failed: {exc}" | |
| _logger.error("media_conversion_error status=%s error=%s", code, message) | |
| return JSONResponse( | |
| status_code=code, | |
| content={ | |
| "success": False, | |
| "message": message, | |
| "time_ms": round((time.perf_counter() - start) * 1000, 3), | |
| }, | |
| ) | |
| async def _convert_with_timeout(coro) -> Any: | |
| try: | |
| return await asyncio.wait_for( | |
| coro, timeout=_settings.media_conversion_timeout_seconds | |
| ) | |
| except asyncio.TimeoutError as exc: | |
| raise MediaConversionError( | |
| f"Conversion timed out after {_settings.media_conversion_timeout_seconds}s.", | |
| status_code=504, | |
| ) from exc | |
| def _build_model(model_cls, values: Dict[str, Any]) -> Any: | |
| try: | |
| return model_cls.model_validate(values) | |
| except ValidationError as exc: | |
| first = exc.errors()[0] | |
| raise MediaConversionError( | |
| f"Invalid conversion parameters: {first.get('msg', str(exc))}", | |
| status_code=422, | |
| ) from exc | |
| def _read_upload(file: UploadFile) -> bytes: | |
| if file is None or not file.filename: | |
| raise HTTPException(status_code=400, detail={"success": False, "message": "No file provided."}) | |
| raw = file.file.read() | |
| if not raw: | |
| raise HTTPException(status_code=422, detail={"success": False, "message": "Uploaded file is empty."}) | |
| if len(raw) > _MAX_UPLOAD_BYTES: | |
| raise HTTPException( | |
| status_code=413, | |
| detail={"success": False, "message": f"File exceeds {_settings.max_upload_mb} MB limit."}, | |
| ) | |
| return raw | |
| async def _read_stream_limited(resp, limit: int) -> bytes: | |
| """Read a streaming response, aborting with a 413 once it exceeds ``limit`` bytes.""" | |
| chunks: list[bytes] = [] | |
| total = 0 | |
| async for chunk in resp.aiter_bytes(): | |
| total += len(chunk) | |
| if total > limit: | |
| raise MediaConversionError( | |
| f"Remote file exceeds {_settings.max_upload_mb} MB limit.", status_code=413 | |
| ) | |
| chunks.append(chunk) | |
| return b"".join(chunks) | |
| async def _fetch_url(url: str) -> Tuple[bytes, str]: | |
| """Verify the URL is downloadable, then download it within the upload size cap.""" | |
| parsed = urlparse(url) | |
| filename = parsed.path.split("/")[-1] or "media" | |
| async with httpx.AsyncClient( | |
| timeout=30.0, | |
| follow_redirects=True, | |
| headers={"User-Agent": "agentdeck-media-convert/1.0"}, | |
| ) as client: | |
| # 1) Pre-flight: confirm the URL responds before pulling the body. | |
| declared_length: Optional[int] = None | |
| try: | |
| head_resp = await client.head(url) | |
| if head_resp.status_code in (405, 501): # HEAD not supported -> validate via GET | |
| declared_length = None | |
| else: | |
| head_resp.raise_for_status() | |
| content_length = head_resp.headers.get("content-length") | |
| if content_length and content_length.isdigit(): | |
| declared_length = int(content_length) | |
| except httpx.HTTPError as exc: | |
| status = exc.response.status_code if exc.response else "network error" | |
| raise MediaConversionError( | |
| f"URL is not downloadable (HTTP {status}).", | |
| status_code=400, | |
| ) from exc | |
| # 2) Reject oversized files up front using the declared Content-Length. | |
| if declared_length is not None and declared_length > _MAX_UPLOAD_BYTES: | |
| raise MediaConversionError( | |
| f"Remote file is {declared_length} bytes, exceeding the " | |
| f"{_settings.max_upload_mb} MB limit.", | |
| status_code=413, | |
| ) | |
| # 3) Stream the download with an enforced byte cap (guards against | |
| # servers that omit or misreport Content-Length). | |
| try: | |
| async with client.stream("GET", url) as resp: | |
| resp.raise_for_status() | |
| data = await _read_stream_limited(resp, _MAX_UPLOAD_BYTES) | |
| except httpx.HTTPError as exc: | |
| raise MediaConversionError( | |
| f"Failed to download URL: {exc}", status_code=400 | |
| ) from exc | |
| if not data: | |
| raise MediaConversionError("Remote file is empty.", status_code=422) | |
| return data, filename | |
| # --------------------------------------------------------------------------- | |
| # Endpoints | |
| # --------------------------------------------------------------------------- | |
| async def convert_pdf_file( | |
| file: Annotated[UploadFile, File(description="PDF file to convert")], | |
| format: Annotated[MediaImageFormat, Form(description="Output format (PNG/JPEG/WEBP)")] = MediaImageFormat.PNG, | |
| dpi: int = Form(150, description="Render DPI (72-300)"), | |
| quality: int = Form(85, description="Output quality (1-100)"), | |
| pages: Optional[str] = Form(None, description="Page spec: '1', '1-3', '1,3,5-7'"), | |
| grayscale: bool = Form(False), | |
| transparent_bg: bool = Form(False), | |
| split_page: bool = Form(False), | |
| ): | |
| start = time.perf_counter() | |
| raw = _read_upload(file) | |
| try: | |
| params = _build_model(PDFConversionParams, { | |
| "format": format, "dpi": dpi, "quality": quality, "pages": pages, | |
| "grayscale": grayscale, "transparent_bg": transparent_bg, "split_page": split_page, | |
| }) | |
| data = await _convert_with_timeout( | |
| media_conversion_service.convert_pdf(raw, params, file.filename or "upload.pdf") | |
| ) | |
| except Exception as exc: | |
| return _error_response(start, exc) | |
| return _ok_response(start, data) | |
| async def convert_pdf_url(body: MediaPDFUrlRequest): | |
| start = time.perf_counter() | |
| try: | |
| raw, filename = await _fetch_url(body.url) | |
| data = await _convert_with_timeout( | |
| media_conversion_service.convert_pdf(raw, body.params, body.url) | |
| ) | |
| except Exception as exc: | |
| return _error_response(start, exc) | |
| return _ok_response(start, data) | |
| async def convert_image_file( | |
| file: Annotated[UploadFile, File(description="Image file to convert")], | |
| format: Annotated[MediaImageFormat, Form(description="Target format")] = MediaImageFormat.JPEG, | |
| quality: int = Form(85, description="Output quality (1-100)"), | |
| grayscale: bool = Form(False), | |
| width: Optional[int] = Form(None, description="Resize width"), | |
| height: Optional[int] = Form(None, description="Resize height"), | |
| rotate: float = Form(0.0, description="Rotate clockwise in degrees"), | |
| flip: Optional[str] = Form(None, description="horizontal or vertical"), | |
| ): | |
| start = time.perf_counter() | |
| raw = _read_upload(file) | |
| try: | |
| params = _build_model(ImageConversionParams, { | |
| "format": format, "quality": quality, "grayscale": grayscale, | |
| "width": width, "height": height, "rotate": rotate, "flip": flip, | |
| }) | |
| data = await _convert_with_timeout( | |
| media_conversion_service.convert_image(raw, file.filename or "image", params) | |
| ) | |
| except Exception as exc: | |
| return _error_response(start, exc) | |
| return _ok_response(start, data) | |
| async def convert_image_url(body: MediaImageUrlRequest): | |
| start = time.perf_counter() | |
| try: | |
| raw, filename = await _fetch_url(body.url) | |
| data = await _convert_with_timeout( | |
| media_conversion_service.convert_image(raw, body.url, body.params) | |
| ) | |
| except Exception as exc: | |
| return _error_response(start, exc) | |
| return _ok_response(start, data) | |
| async def list_formats(): | |
| return MediaFormatsResponse( | |
| success=True, | |
| pdf_to_image=sorted(_PDF_OUTPUT_FORMATS), | |
| image_to_image=sorted(_IMAGE_OUTPUT_FORMATS), | |
| storage_enabled=_settings.supabase_upload_enabled, | |
| signed_url_ttl_seconds=_settings.supabase_signed_url_ttl_seconds, | |
| ) | |