Initialize project
This commit is contained in:
144
backend/app/tools/image_ops.py
Normal file
144
backend/app/tools/image_ops.py
Normal 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)
|
||||
12
backend/app/tools/server_ops.py
Normal file
12
backend/app/tools/server_ops.py
Normal 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.
|
||||
|
||||
60
backend/app/tools/video_ops.py
Normal file
60
backend/app/tools/video_ops.py
Normal 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))
|
||||
Reference in New Issue
Block a user