Initialize project

This commit is contained in:
woozu.shin
2026-01-28 15:33:47 +09:00
commit 3fe5424732
43 changed files with 6108 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
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)

View File

@@ -0,0 +1,12 @@
import uuid
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter()
# --- Request/Response Models ---
# --- Server-side Logic Implementations ---
# Currently empty as UUID and Password generation moved to client-side.
# Future server-side logic can be added here.

View File

@@ -0,0 +1,60 @@
import os
import shutil
import uuid
from pathlib import Path
from fastapi import APIRouter, UploadFile, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
import ffmpeg
router = APIRouter()
TEMP_DIR = Path("temp_uploads")
TEMP_DIR.mkdir(exist_ok=True)
def cleanup_file(path: Path):
try:
if path.exists():
path.unlink()
except Exception as e:
print(f"Error cleaning up file {path}: {e}")
@router.post("/gif")
async def convert_video_to_gif(file: UploadFile, background_tasks: BackgroundTasks):
if not (file.content_type or "").startswith("video/"):
raise HTTPException(status_code=400, detail="File must be a video")
file_id = str(uuid.uuid4())
input_path = TEMP_DIR / f"{file_id}_{file.filename}"
output_filename = f"{file_id}.gif"
output_path = TEMP_DIR / output_filename
try:
with input_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
stream = ffmpeg.input(str(input_path))
stream = ffmpeg.filter(stream, 'fps', fps=10)
stream = ffmpeg.filter(stream, 'scale', 320, -1)
stream = ffmpeg.output(stream, str(output_path))
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
background_tasks.add_task(cleanup_file, input_path)
background_tasks.add_task(cleanup_file, output_path)
return FileResponse(
output_path,
media_type="image/gif",
filename="converted.gif"
)
except ffmpeg.Error as e:
cleanup_file(input_path)
if output_path.exists():
cleanup_file(output_path)
print(e.stderr.decode('utf8'))
raise HTTPException(status_code=500, detail="FFmpeg conversion failed")
except Exception as e:
cleanup_file(input_path)
if output_path.exists():
cleanup_file(output_path)
raise HTTPException(status_code=500, detail=str(e))