import shutil import subprocess import uuid import os import zipfile from pathlib import Path from typing import List from fastapi import APIRouter, UploadFile, File, HTTPException, BackgroundTasks from fastapi.responses import FileResponse, JSONResponse from app.core.cleanup import TEMP_DIR router = APIRouter() def get_png_color_type(file_path: Path) -> int | None: """PNG 파일의 color type 확인 (참조 문서 로직)""" try: with open(file_path, "rb") as f: header = f.read(26) if header[:8] != b'\x89PNG\r\n\x1a\n': return None return header[25] except Exception: return None @router.post("/png-compress") async def compress_png_files(files: List[UploadFile] = File(...)): """ 업로드된 PNG 파일들을 pngquant로 압축하고 통계를 반환합니다. """ # 작업 ID 생성 (폴더 분리용) job_id = str(uuid.uuid4()) job_dir = TEMP_DIR / job_id job_dir.mkdir(parents=True, exist_ok=True) results = [] compressed_files = [] try: for file in files: if not file.filename.lower().endswith('.png'): continue # 1. 파일 저장 file_path = job_dir / file.filename with open(file_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) orig_size = file_path.stat().st_size # 2. 이미 압축된 파일인지 확인 (Color Type 3 = Indexed) color_type = get_png_color_type(file_path) if color_type == 3: # 이미 압축됨 -> 그냥 결과에 추가 (압축 안 함) results.append({ "filename": file.filename, "original_size": orig_size, "compressed_size": orig_size, "ratio": 0.0, "status": "Skipped (Already Indexed)" }) compressed_files.append(file_path) continue # 3. pngquant 실행 output_path = job_dir / f"compressed_{file.filename}" try: # --force: 덮어쓰기 허용, --quality: 품질 설정 cmd = [ "pngquant", "--quality", "65-80", "--force", "--output", str(output_path), str(file_path) ] subprocess.run(cmd, check=True, capture_output=True) if output_path.exists(): comp_size = output_path.stat().st_size ratio = (1 - comp_size / orig_size) * 100 results.append({ "filename": file.filename, "original_size": orig_size, "compressed_size": comp_size, "ratio": round(ratio, 1), "status": "Success" }) compressed_files.append(output_path) else: raise Exception("Output file not created") except subprocess.CalledProcessError: # 압축 실패 (품질 기준 미달 등) -> 원본 유지 results.append({ "filename": file.filename, "original_size": orig_size, "compressed_size": orig_size, "ratio": 0.0, "status": "Failed (Quality check)" }) compressed_files.append(file_path) # 4. ZIP 생성 zip_filename = f"compressed_images_{job_id}.zip" zip_path = TEMP_DIR / zip_filename with zipfile.ZipFile(zip_path, 'w') as zipf: for f in compressed_files: # ZIP 내부에는 원래 파일명으로 저장 arcname = f.name.replace("compressed_", "") zipf.write(f, arcname) return { "job_id": job_id, "zip_filename": zip_filename, "stats": results } except Exception as e: cleanup_files([job_dir]) raise HTTPException(status_code=500, detail=str(e)) @router.get("/download/{filename}") async def download_file(filename: str): file_path = TEMP_DIR / filename if not file_path.exists(): raise HTTPException(status_code=404, detail="File not found") # 즉시 삭제하지 않고 스케줄러(10분 후)에 맡김 return FileResponse(file_path, filename=filename) @router.get("/download-single/{job_id}/{filename}") async def download_single_file(job_id: str, filename: str): """개별 파일 다운로드 (삭제 예약 없음 - 전체 ZIP 다운로드 등을 위해 유지)""" file_path = TEMP_DIR / job_id / f"compressed_{filename}" # 만약 compressed_ 접두사가 없다면 원본 파일일 수도 있음 (압축 실패 시) if not file_path.exists(): file_path = TEMP_DIR / job_id / filename if not file_path.exists(): raise HTTPException(status_code=404, detail="File not found") return FileResponse(file_path, filename=filename)