Spaces:
Running
Running
BlueSkyXN
commited on
Commit
·
7421f15
1
Parent(s):
f89d845
0.0.1
Browse files- README.md +16 -0
- .github/workflows/sync-to-hf-space.yml +30 -0
- Dockerfile +35 -0
- entrypoint.sh +18 -0
- main.py +201 -0
- requirements.txt +3 -0
README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Magick 图像转换器
|
| 3 |
+
emoji: 🖼️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# 🧙♂️ Magick 图像转换 API
|
| 12 |
+
|
| 13 |
+
本项目提供一个基于 FastAPI 和 ImageMagick 的 REST API,用于:
|
| 14 |
+
|
| 15 |
+
* **POST /**: 将上传的图像转换为**无损 AVIF** 格式,并**保留所有元信息**。
|
| 16 |
+
* **GET /health**: 检查 ImageMagick 和 AVIF 编码器 (heif) 的可用状态。
|
.github/workflows/sync-to-hf-space.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main # 或者你的主分支名称,例如 master
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
sync:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- name: Checkout repository
|
| 13 |
+
uses: actions/checkout@v4 # 建议使用最新版本
|
| 14 |
+
with:
|
| 15 |
+
fetch-depth: 0 # 获取所有历史记录,以便正确推送
|
| 16 |
+
|
| 17 |
+
- name: Set up Git
|
| 18 |
+
run: |
|
| 19 |
+
git config --global user.email "action@github.com"
|
| 20 |
+
git config --global user.name "GitHub Action"
|
| 21 |
+
|
| 22 |
+
- name: Push to Hugging Face Space
|
| 23 |
+
env:
|
| 24 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }} # 引用你之前创建的 Secret
|
| 25 |
+
run: |
|
| 26 |
+
# 将 <your-hf-username> 和 <your-space-name> 替换为你的实际信息
|
| 27 |
+
git remote add space https://user:${HF_TOKEN}@huggingface.co/spaces/BlueSkyXN/ImageMagickAPI-HFS
|
| 28 |
+
# 强制推送到 Space 的 main 分支,覆盖原有内容
|
| 29 |
+
# 如果你的 Space 主分支不是 main,请相应修改
|
| 30 |
+
git push --force space main
|
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 1. 使用官方 Python 镜像
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# 设置环境变量
|
| 5 |
+
ENV PORT=8000
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
|
| 8 |
+
# 2. 安装 ImageMagick 和 AVIF/HEIC 依赖
|
| 9 |
+
# libheif-examples 提供了 magick 所需的 heif-enc 编码器
|
| 10 |
+
RUN apt-get update && apt-get install -y \
|
| 11 |
+
imagemagick \
|
| 12 |
+
libheif-examples \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
# 3. 设置工作目录 (结构同您的 OCR-HFS)
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# 4. 复制并安装 Python 依赖 (结构同您的 OCR-HFS)
|
| 19 |
+
COPY requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# 5. 复制应用代码 (结构同您的 OCR-HFS)
|
| 23 |
+
COPY main.py .
|
| 24 |
+
COPY entrypoint.sh .
|
| 25 |
+
RUN chmod +x /app/entrypoint.sh
|
| 26 |
+
|
| 27 |
+
# 6. 创建临时工作目录 (结构同您的 OCR-HFS)
|
| 28 |
+
RUN mkdir -p /app/temp
|
| 29 |
+
RUN chmod 777 /app/temp
|
| 30 |
+
|
| 31 |
+
# 7. 暴露端口 (结构同您的 OCR-HFS)
|
| 32 |
+
EXPOSE 8000
|
| 33 |
+
|
| 34 |
+
# 8. 使用入口脚本启动 (结构同您的 OCR-HFS)
|
| 35 |
+
ENTRYPOINT ["/app/entrypoint.sh"]
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
|
| 3 |
+
# 打印环境信息用于调试
|
| 4 |
+
echo "Starting Magick API Service"
|
| 5 |
+
echo "Environment: PORT=$PORT"
|
| 6 |
+
|
| 7 |
+
# 验证Magick是否可用
|
| 8 |
+
echo "Checking ImageMagick installation..."
|
| 9 |
+
magick --version | head -n 1
|
| 10 |
+
echo "Checking AVIF encoder (heif-enc) installation..."
|
| 11 |
+
which heif-enc
|
| 12 |
+
|
| 13 |
+
# 确保使用正确的端口变量
|
| 14 |
+
PORT="${PORT:-8000}"
|
| 15 |
+
echo "Using port: $PORT"
|
| 16 |
+
|
| 17 |
+
# 执行 uvicorn 服务器 (同您的 OCR-HFS)
|
| 18 |
+
exec uvicorn main:app --host 0.0.0.0 --port $PORT
|
main.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fastapi
|
| 2 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException, Response, BackgroundTasks
|
| 3 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 4 |
+
import subprocess
|
| 5 |
+
import asyncio # 使用 asyncio 来进行非阻塞的 subprocess 调用
|
| 6 |
+
import tempfile
|
| 7 |
+
import os
|
| 8 |
+
import shutil
|
| 9 |
+
import logging
|
| 10 |
+
import uuid
|
| 11 |
+
from typing import Literal, Optional, List
|
| 12 |
+
|
| 13 |
+
# --- 配置 ---
|
| 14 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
MAX_FILE_SIZE_MB = 200 # 最大文件大小
|
| 18 |
+
TIMEOUT_SECONDS = 300 # 转换超时 (5分钟)
|
| 19 |
+
TEMP_DIR = "/app/temp" # 临时文件目录
|
| 20 |
+
# ----------------
|
| 21 |
+
|
| 22 |
+
# 初始化 FastAPI 应用
|
| 23 |
+
app = FastAPI(
|
| 24 |
+
title="Magick AVIF Converter",
|
| 25 |
+
description="API to convert images to lossless AVIF, preserving metadata.",
|
| 26 |
+
version="1.0.0"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# 确保临时目录存在
|
| 30 |
+
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 31 |
+
|
| 32 |
+
# --- 健康检查 (适配 Magick) ---
|
| 33 |
+
@app.get("/health", summary="Health Check")
|
| 34 |
+
async def health_check():
|
| 35 |
+
"""提供详细的API和依赖健康状态检查"""
|
| 36 |
+
try:
|
| 37 |
+
# 检查 Magick 是否可用
|
| 38 |
+
proc_magick = await asyncio.subprocess.create_subprocess_exec(
|
| 39 |
+
'magick', '--version', stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
| 40 |
+
)
|
| 41 |
+
stdout_m, stderr_m = await proc_magick.communicate()
|
| 42 |
+
magick_version = stdout_m.decode().split('\n')[0] if proc_magick.returncode == 0 else "Not available"
|
| 43 |
+
|
| 44 |
+
# 检查 AVIF 编码器 (heif-enc) 是否可用
|
| 45 |
+
proc_heif = await asyncio.subprocess.create_subprocess_exec(
|
| 46 |
+
'which', 'heif-enc', stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
| 47 |
+
)
|
| 48 |
+
stdout_h, stderr_h = await proc_heif.communicate()
|
| 49 |
+
heif_encoder_path = stdout_h.decode().strip() if proc_heif.returncode == 0 else "Not available (AVIF conversion will fail)"
|
| 50 |
+
|
| 51 |
+
# 检查磁盘空间
|
| 52 |
+
disk_info = os.statvfs(TEMP_DIR)
|
| 53 |
+
free_space_mb = (disk_info.f_bavail * disk_info.f_frsize) / (1024 * 1024)
|
| 54 |
+
|
| 55 |
+
return {
|
| 56 |
+
"status": "healthy",
|
| 57 |
+
"imagemagick": magick_version,
|
| 58 |
+
"avif_encoder": heif_encoder_path,
|
| 59 |
+
"disk_space": {"free_mb": round(free_space_mb, 2), "temp_dir": TEMP_DIR},
|
| 60 |
+
"resource_limits": {
|
| 61 |
+
"max_file_size_mb": MAX_FILE_SIZE_MB,
|
| 62 |
+
"timeout_seconds": TIMEOUT_SECONDS
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Health check failed: {str(e)}")
|
| 67 |
+
return {"status": "unhealthy", "error": str(e)}
|
| 68 |
+
|
| 69 |
+
# --- 根路径 API 端点 (实现您的需求) ---
|
| 70 |
+
@app.post("/",
|
| 71 |
+
summary="Convert Image to Lossless AVIF",
|
| 72 |
+
response_class=FileResponse,
|
| 73 |
+
responses={
|
| 74 |
+
200: {"content": {"image/avif": {}}, "description": "Successfully converted image to lossless AVIF."},
|
| 75 |
+
400: {"description": "Bad Request (e.g., file too large)"},
|
| 76 |
+
500: {"description": "Internal Server Error (Magick conversion failed)"},
|
| 77 |
+
504: {"description": "Gateway Timeout (Conversion took too long)"}
|
| 78 |
+
})
|
| 79 |
+
async def convert_to_avif_lossless(
|
| 80 |
+
background_tasks: BackgroundTasks,
|
| 81 |
+
file: UploadFile = File(..., description="The image file to be converted (PNG, JPEG, WebP, etc.).")
|
| 82 |
+
):
|
| 83 |
+
"""
|
| 84 |
+
接收图像文件,将其无损转换为 AVIF,并保留元信息。
|
| 85 |
+
"""
|
| 86 |
+
logger.info(f"Received request: filename={file.filename}")
|
| 87 |
+
|
| 88 |
+
# --- 文件大小检查 ---
|
| 89 |
+
file_size_mb = await get_upload_file_size(file) / (1024 * 1024)
|
| 90 |
+
if file_size_mb > MAX_FILE_SIZE_MB:
|
| 91 |
+
logger.warning(f"File too large: {file_size_mb:.2f}MB (max: {MAX_FILE_SIZE_MB}MB)")
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=400,
|
| 94 |
+
detail=f"File too large. Maximum allowed size is {MAX_FILE_SIZE_MB}MB. Your file is {file_size_mb:.2f}MB."
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# --- 临时目录和路径 ---
|
| 98 |
+
session_id = str(uuid.uuid4())
|
| 99 |
+
temp_dir = os.path.join(TEMP_DIR, session_id)
|
| 100 |
+
os.makedirs(temp_dir, exist_ok=True)
|
| 101 |
+
|
| 102 |
+
# 获取原始文件扩展名
|
| 103 |
+
_, file_extension = os.path.splitext(file.filename)
|
| 104 |
+
input_path = os.path.join(temp_dir, f"input{file_extension}")
|
| 105 |
+
output_path = os.path.join(temp_dir, f"output.avif")
|
| 106 |
+
|
| 107 |
+
logger.info(f"Processing in temporary directory: {temp_dir}")
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
# --- 保存上传的文件 ---
|
| 111 |
+
logger.info(f"Saving uploaded file '{file.filename}' to '{input_path}'")
|
| 112 |
+
with open(input_path, "wb") as buffer:
|
| 113 |
+
shutil.copyfileobj(file.file, buffer)
|
| 114 |
+
logger.info("File saved successfully.")
|
| 115 |
+
|
| 116 |
+
# --- 构建 Magick 命令 (您的核心需求) ---
|
| 117 |
+
cmd = [
|
| 118 |
+
'magick',
|
| 119 |
+
input_path,
|
| 120 |
+
'-define', 'avif:lossless=true', # 无损
|
| 121 |
+
'-define', 'avif:speed=0', # 最佳压缩率
|
| 122 |
+
output_path # (无 -strip,保留元信息)
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
command_str = ' '.join(cmd)
|
| 126 |
+
logger.info(f"Executing command: {command_str}")
|
| 127 |
+
|
| 128 |
+
# --- 执行 Magick 命令 (异步) ---
|
| 129 |
+
process = await asyncio.wait_for(
|
| 130 |
+
asyncio.subprocess.create_subprocess_exec(
|
| 131 |
+
*cmd,
|
| 132 |
+
stdout=asyncio.subprocess.PIPE,
|
| 133 |
+
stderr=asyncio.subprocess.PIPE
|
| 134 |
+
),
|
| 135 |
+
timeout=TIMEOUT_SECONDS
|
| 136 |
+
)
|
| 137 |
+
stdout, stderr = await process.communicate()
|
| 138 |
+
|
| 139 |
+
# --- 检查命令执行结果 ---
|
| 140 |
+
if process.returncode != 0:
|
| 141 |
+
error_message = f"Magick failed with exit code {process.returncode}."
|
| 142 |
+
logger.error(f"{error_message}\nStderr: {stderr.decode()[:1000]}")
|
| 143 |
+
raise HTTPException(status_code=500, detail=f"ImageMagick conversion failed. Error: {stderr.decode()}")
|
| 144 |
+
|
| 145 |
+
if not os.path.exists(output_path):
|
| 146 |
+
error_message = "Magick command succeeded but output file was not found."
|
| 147 |
+
logger.error(error_message)
|
| 148 |
+
raise HTTPException(status_code=500, detail=error_message)
|
| 149 |
+
|
| 150 |
+
# --- 成功 ---
|
| 151 |
+
logger.info(f"Conversion successful. Output file: '{output_path}'")
|
| 152 |
+
|
| 153 |
+
# 生成下载文件名
|
| 154 |
+
original_filename_base = os.path.splitext(file.filename)[0]
|
| 155 |
+
download_filename = f"{original_filename_base}_lossless.avif"
|
| 156 |
+
|
| 157 |
+
# 注册后台清理
|
| 158 |
+
background_tasks.add_task(cleanup_temp_dir, temp_dir)
|
| 159 |
+
|
| 160 |
+
# 返回文件
|
| 161 |
+
return FileResponse(
|
| 162 |
+
path=output_path,
|
| 163 |
+
media_type='image/avif',
|
| 164 |
+
filename=download_filename,
|
| 165 |
+
background=background_tasks
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
except asyncio.TimeoutError:
|
| 169 |
+
logger.error(f"Magick processing timed out after {TIMEOUT_SECONDS}s for file '{file.filename}'.")
|
| 170 |
+
raise HTTPException(status_code=504, detail=f"Conversion timed out after {TIMEOUT_SECONDS} seconds.")
|
| 171 |
+
except HTTPException as http_exc:
|
| 172 |
+
raise http_exc
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
|
| 175 |
+
raise HTTPException(status_code=500, detail=f"An unexpected server error occurred.")
|
| 176 |
+
finally:
|
| 177 |
+
# 确保关闭文件句柄
|
| 178 |
+
await file.close()
|
| 179 |
+
# 如果没有成功注册后台任务,也尝试清理 (备用)
|
| 180 |
+
if not 'background_tasks' in locals() and os.path.exists(temp_dir):
|
| 181 |
+
cleanup_temp_dir(temp_dir)
|
| 182 |
+
|
| 183 |
+
# --- 清理函数 ---
|
| 184 |
+
def cleanup_temp_dir(temp_dir: str):
|
| 185 |
+
"""清理临时目录及其内容的辅助函数"""
|
| 186 |
+
try:
|
| 187 |
+
if os.path.exists(temp_dir):
|
| 188 |
+
logger.info(f"Cleaning up temporary directory: {temp_dir}")
|
| 189 |
+
shutil.rmtree(temp_dir)
|
| 190 |
+
logger.info("Temporary directory cleaned up successfully.")
|
| 191 |
+
except Exception as cleanup_error:
|
| 192 |
+
logger.error(f"Error cleaning up temporary directory {temp_dir}: {cleanup_error}", exc_info=True)
|
| 193 |
+
|
| 194 |
+
# --- 文件大小检查 ---
|
| 195 |
+
async def get_upload_file_size(upload_file: UploadFile) -> int:
|
| 196 |
+
"""获取上传文件的大小(以字节为单位)"""
|
| 197 |
+
current_position = upload_file.file.tell()
|
| 198 |
+
upload_file.file.seek(0, 2) # 2 表示从文件末尾
|
| 199 |
+
size = upload_file.file.tell()
|
| 200 |
+
upload_file.file.seek(current_position)
|
| 201 |
+
return size
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
python-multipart
|