BlueSkyXN commited on
Commit
7421f15
·
1 Parent(s): f89d845
Files changed (6) hide show
  1. README.md +16 -0
  2. .github/workflows/sync-to-hf-space.yml +30 -0
  3. Dockerfile +35 -0
  4. entrypoint.sh +18 -0
  5. main.py +201 -0
  6. 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