BlueSkyXN commited on
Commit
8dbd049
·
1 Parent(s): d52fe4f
Files changed (4) hide show
  1. Dockerfile +1 -0
  2. README.md +161 -4
  3. main.py +298 -110
  4. test_magick.py +97 -0
Dockerfile CHANGED
@@ -4,6 +4,7 @@ FROM python:3.10-slim
4
  # 设置环境变量
5
  ENV PORT=8000
6
  ENV PYTHONUNBUFFERED=1
 
7
 
8
  # 2. 安装 ImageMagick 和 AVIF/HEIC 依赖
9
  # libheif-examples 提供了 magick 所需的 heif-enc 编码器
 
4
  # 设置环境变量
5
  ENV PORT=8000
6
  ENV PYTHONUNBUFFERED=1
7
+ ENV TEMP_DIR=/app/temp
8
 
9
  # 2. 安装 ImageMagick 和 AVIF/HEIC 依赖
10
  # libheif-examples 提供了 magick 所需的 heif-enc 编码器
README.md CHANGED
@@ -8,9 +8,166 @@ app_port: 8000 # 你的 FastAPI 应用在容器内部监听的端口 (必须与
8
  pinned: false # 是否在你的个人资料页置顶这个 Space (可选)
9
  ---
10
 
11
- # 🧙‍♂️ Magick 图像转换 API
12
 
13
- 本项目提供一个基于 FastAPI 和 ImageMagick REST API,用于:
14
 
15
- * **POST /**: 将上传的图像转换为**无损 AVIF** 格式,并**保留所有元信息**。
16
- * **GET /health**: 检查 ImageMagick 和 AVIF 编码器 (heif) 的可用状态。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  pinned: false # 是否在你的个人资料页置顶这个 Space (可选)
9
  ---
10
 
11
+ # 🧙‍♂️ Magick 动态图像转换 API (V3)
12
 
13
+ 本项目提供一个基于 FastAPI 和 ImageMagick 的高性能 REST API,支持通过动态 URL 路径对图像进行多格式转换,包括动画图像处理。
14
 
15
+ ## 功能特性
16
+
17
+ * **🎯 动态路径 API**: 通过 URL 路径直接指定目标格式、转换模式和质量参数
18
+ * **🎬 动画支持**: 完整支持 GIF、Animated WebP/AVIF/APNG 等动画格式
19
+ * **🔄 多格式转换**: 支持 AVIF、WebP、JPEG、PNG、GIF、HEIF 格式互转
20
+ * **⚙️ 灵活配置**: 支持有损/无损两种模式,质量参数可在 0-100 范围自由调节
21
+ * **🛡️ 安全可靠**: 文件大小限制、超时控制、格式验证、依赖预检查
22
+ * **🚀 性能优化**: 智能 `-coalesce` 使用、异步处理、后台清理
23
+
24
+ ## 📡 API 端点
25
+
26
+ ### 1. 图像转换
27
+
28
+ **端点**: `POST /convert/{target_format}/{mode}/{setting}`
29
+
30
+ **路径参数**:
31
+ - `target_format`: 目标格式 (`avif` | `webp` | `jpeg` | `png` | `gif` | `heif`)
32
+ - `mode`: 转换模式 (`lossy` | `lossless`)
33
+ - `setting`: 质量/压缩参数 (0-100)
34
+ - **lossy 模式**: `0`=最低质量,`100`=最高质量
35
+ - **lossless 模式**: `0`=最慢/最佳压缩,`100`=最快/最低压缩
36
+
37
+ **请求体**: `multipart/form-data` 文件上传 (字段名: `file`)
38
+
39
+ **响应**: 转换后的图像文件
40
+
41
+ **支持的输入格式**: JPG, PNG, GIF, WebP, AVIF, HEIF, HEIC, BMP, TIFF
42
+
43
+ **示例**:
44
+ ```bash
45
+ # 转换为高质量有损 AVIF (质量 80)
46
+ curl -X POST "https://your-api.hf.space/convert/avif/lossy/80" \
47
+ -F "file=@input.jpg" \
48
+ -o output.avif
49
+
50
+ # 转换为无损 WebP (最佳压缩)
51
+ curl -X POST "https://your-api.hf.space/convert/webp/lossless/0" \
52
+ -F "file=@animation.gif" \
53
+ -o output.webp
54
+
55
+ # 转换为中等质量 JPEG (质量 75)
56
+ curl -X POST "https://your-api.hf.space/convert/jpeg/lossy/75" \
57
+ -F "file=@input.png" \
58
+ -o output.jpg
59
+ ```
60
+
61
+ ### 2. 健康检查
62
+
63
+ **端点**: `GET /health`
64
+
65
+ **响应**: JSON 格式的服务状态信息
66
+
67
+ ```json
68
+ {
69
+ "status": "healthy",
70
+ "imagemagick": "Version: ImageMagick 7.1.0-x",
71
+ "avif_encoder": "/usr/bin/heif-enc",
72
+ "disk_space": {
73
+ "free_mb": 15234.56,
74
+ "temp_dir": "/tmp"
75
+ },
76
+ "resource_limits": {
77
+ "max_file_size_mb": 200,
78
+ "timeout_seconds": 300
79
+ }
80
+ }
81
+ ```
82
+
83
+ ## 🔧 技术细节
84
+
85
+ ### 转换模式详解
86
+
87
+ #### Lossy (有损) 模式
88
+ - **AVIF**: 使用 `cq-level` 参数 (0-63),setting=100 映射为 cq=0 (最佳质量)
89
+ - **WebP**: 使用 `-quality` 参数 (0-100),直接映射
90
+ - **JPEG**: 使用 `-quality` 参数 (0-100),直接映射
91
+ - **HEIF**: 使用 `-quality` 参数 (0-100),直接映射
92
+ - **PNG/GIF**: 通过 `-colors` 减少调色板颜色模拟有损 (2-256 色)
93
+
94
+ #### Lossless (无损) 模式
95
+ - **AVIF**: 使用 `avif:lossless=true` + `avif:speed` (0-10)
96
+ - **WebP**: 使用 `webp:lossless=true` + `webp:method` (0-6)
97
+ - **PNG**: 使用 zlib 压缩级别 (0-9),映射到 `-quality` (91-100)
98
+ - **HEIF**: 使用 `heif:lossless=true` + `heif:speed` (0-10)
99
+ - **JPEG**: 使用 `-quality 100` (JPEG 无真正无损模式)
100
+ - **GIF**: 使用 `-layers optimize` 优化帧
101
+
102
+ ### 性能优化
103
+
104
+ 1. **智能 Coalesce**: 仅对动画格式 (GIF, WebP, APNG) 使用 `-coalesce`,避免静态图片性能损失
105
+ 2. **异步处理**: 使用 asyncio 进行非阻塞 I/O 操作
106
+ 3. **后台清理**: 使用 FastAPI BackgroundTasks 异步清理临时文件
107
+ 4. **超时控制**: 5 分钟超时保护,防止长时间占用资源
108
+
109
+ ### 安全特性
110
+
111
+ 1. **文件大小限制**: 默认最大 200MB
112
+ 2. **格式验证**: 仅接受白名单内的图像格式
113
+ 3. **依赖预检查**: AVIF/HEIF 转换前检查 heif-enc 可用性
114
+ 4. **路径隔离**: 每个请求使用独立的 UUID 临时目录
115
+ 5. **错误处理**: 完整的异常捕获和 HTTP 状态码返回
116
+
117
+ ## 🚀 部署
118
+
119
+ ### Docker 部署
120
+
121
+ ```bash
122
+ # 构建镜像
123
+ docker build -t magick-api .
124
+
125
+ # 运行容器
126
+ docker run -p 8000:8000 magick-api
127
+ ```
128
+
129
+ ### Hugging Face Spaces 部署
130
+
131
+ 1. Fork 或上传此仓库到 Hugging Face Spaces
132
+ 2. 确保 README.md 前置元数据配置正确 (`sdk: docker`, `app_port: 8000`)
133
+ 3. Space 会自动构建和部署
134
+
135
+ ### 环境变量
136
+
137
+ - `TEMP_DIR`: 临时文件目录 (默认: 系统临时目录,Docker 中为 `/app/temp`)
138
+ - `PORT`: 服务监听端口 (默认: 8000)
139
+
140
+ ## 📦 依赖
141
+
142
+ - Python 3.10+
143
+ - FastAPI
144
+ - Uvicorn
145
+ - ImageMagick 7+
146
+ - libheif-examples (提供 heif-enc 编码器)
147
+
148
+ ## 🐛 已知问题与修复
149
+
150
+ ### V3 版本修复 (当前版本)
151
+
152
+ 1. ✅ **修复 Timeout 实现**: 超���现在正确应用于进程执行而非进程创建
153
+ 2. ✅ **修复 WebP 无损质量**: 无损模式下 quality 固定为 100
154
+ 3. ✅ **修复 PNG 质量映射**: 修正为完整的 91-100 范围
155
+ 4. ✅ **修复 WebP effort 计算**: 使用线性插值确保精确映射 0-6
156
+ 5. ✅ **修复临时目录硬编码**: 支持环境变量和系统临时目录
157
+ 6. ✅ **优化 -coalesce 性能**: 仅对动画格式使用
158
+ 7. ✅ **修复 BackgroundTasks**: 移除重复参数传递
159
+ 8. ✅ **添加文件格式验证**: 上传前验证文件扩展名
160
+ 9. ✅ **添加依赖预检查**: AVIF/HEIF 转换前检查编码器可用性
161
+ 10. ✅ **修复测试脚本**: 使用正确的 API 路径格式
162
+
163
+ ## 📄 许可证
164
+
165
+ 本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
166
+
167
+ ## 🤝 贡献
168
+
169
+ 欢迎提交 Issue 和 Pull Request!
170
+
171
+ ## 📞 联系方式
172
+
173
+ 如有问题或建议,请在 GitHub Issues 中提出。
main.py CHANGED
@@ -1,54 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
@@ -63,139 +140,250 @@ async def health_check():
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
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ ImageMagick 动态图像转换 API
6
+
7
+ 本项目基于 FastAPI 和 ImageMagick,提供一个高性能的 RESTful API 服务。
8
+ 它允许通过动态 URL 路径对上传的图像文件进行多种格式的(有损或无损)转换,
9
+ 并支持动画图像(如 GIF, APNG, Animated WebP/AVIF)的处理。
10
+
11
+ 主要端点:
12
+ - POST /convert/{target_format}/{mode}/{setting}
13
+ - GET /health
14
+ """
15
+
16
  import fastapi
17
+ from fastapi import (
18
+ FastAPI,
19
+ File,
20
+ UploadFile,
21
+ HTTPException,
22
+ BackgroundTasks,
23
+ Path
24
+ )
25
  from fastapi.responses import FileResponse, JSONResponse
26
  import subprocess
27
+ import asyncio
28
  import tempfile
29
  import os
30
  import shutil
31
  import logging
32
  import uuid
33
+ from typing import Literal
34
 
35
+ # --- 1. 应用配置 ---
36
+
37
+ # 配置日志记录器
38
+ logging.basicConfig(
39
+ level=logging.INFO,
40
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
41
+ )
42
  logger = logging.getLogger(__name__)
43
 
44
+ # 资源限制
45
+ MAX_FILE_SIZE_MB = 200 # 允许上传的最大文件大小 (MB)
46
+ TIMEOUT_SECONDS = 300 # Magick 进程执行的超时时间 (秒)
47
+ TEMP_DIR = os.getenv("TEMP_DIR", tempfile.gettempdir()) # 临时文件存储目录,优先使用环境变量,否则使用系统临时目录
48
+
49
+ # --- 2. API 参数类型定义 ---
50
+
51
+ # 定义 API 路径中允许的目标格式
52
+ TargetFormat = Literal["avif", "webp", "jpeg", "png", "gif", "heif"]
53
+
54
+ # 定义 API 路径中允许的转换模式
55
+ ConversionMode = Literal["lossless", "lossy"]
56
+
57
+ # --- 3. FastAPI 应用初始化 ---
58
 
 
59
  app = FastAPI(
60
+ title="Magick 动态图像转换器 (V3)",
61
+ description="通过动态 API 路径实现多种格式的(无)损图像转换,支持动图。",
62
+ version="3.0.0"
63
  )
64
 
65
+ # 启动时确保临时目录存在
66
  os.makedirs(TEMP_DIR, exist_ok=True)
67
 
68
+ # --- 4. 辅助函数 ---
69
+
70
+ async def get_upload_file_size(upload_file: UploadFile) -> int:
71
+ """
72
+ 异步获取上传文件的大小(以字节为单位)。
73
+
74
+ 通过 seek 到文件末尾来测量大小,然后重置指针。
75
+ (继承自 ocrmypdf-hfs 实践)
76
+
77
+ Args:
78
+ upload_file: FastAPI 的 UploadFile 对象。
79
+
80
+ Returns:
81
+ 文件大小(字节)。
82
+ """
83
+ current_position = upload_file.file.tell()
84
+ upload_file.file.seek(0, 2) # 移动到文件末尾
85
+ size = upload_file.file.tell()
86
+ upload_file.file.seek(current_position) # 恢复原始指针位置
87
+ return size
88
+
89
+ def cleanup_temp_dir(temp_dir: str):
90
+ """
91
+ 在后台任务中安全地清理临时会话目录。
92
+ (继承自 ocrmypdf-hfs 实践)
93
+
94
+ Args:
95
+ temp_dir: 要递归删除的目录路径。
96
+ """
97
+ try:
98
+ if os.path.exists(temp_dir):
99
+ logger.info(f"后台清理:正在删除临时目录: {temp_dir}")
100
+ shutil.rmtree(temp_dir)
101
+ logger.info(f"后台清理:已成功删除 {temp_dir}")
102
+ except Exception as cleanup_error:
103
+ logger.error(f"后台清理:删除 {temp_dir} 失败: {cleanup_error}", exc_info=True)
104
+
105
+ # --- 5. API 端点 ---
106
+
107
+ @app.get("/health", summary="服务健康检查")
108
  async def health_check():
109
+ """
110
+ 提供详细的API和服务依赖(ImageMagick, heif-enc)的健康状态。
111
+ (继承自 imagemagickapi-hfs 实践)
112
+ """
113
  try:
114
+ # 检查 ImageMagick
115
  proc_magick = await asyncio.subprocess.create_subprocess_exec(
116
  'magick', '--version', stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
117
  )
118
  stdout_m, stderr_m = await proc_magick.communicate()
119
  magick_version = stdout_m.decode().split('\n')[0] if proc_magick.returncode == 0 else "Not available"
120
 
121
+ # 检查 AVIF/HEIF 编码器
122
  proc_heif = await asyncio.subprocess.create_subprocess_exec(
123
  'which', 'heif-enc', stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
124
  )
125
  stdout_h, stderr_h = await proc_heif.communicate()
126
+ heif_encoder_path = stdout_h.decode().strip() if proc_heif.returncode == 0 else "Not available (AVIF/HEIF conversion will fail)"
127
 
128
+ # 检查磁盘空间
129
  disk_info = os.statvfs(TEMP_DIR)
130
  free_space_mb = (disk_info.f_bavail * disk_info.f_frsize) / (1024 * 1024)
131
 
 
140
  }
141
  }
142
  except Exception as e:
143
+ logger.error(f"健康检查失败: {str(e)}")
144
+ return JSONResponse(status_code=500, content={"status": "unhealthy", "error": str(e)})
145
+
146
+ @app.post(
147
+ "/convert/{target_format}/{mode}/{setting}",
148
+ summary="动态转换图像 (支持动图)",
149
+ response_class=FileResponse,
150
+ responses={
151
+ 200: {"description": "转换成功,返回图像文件"},
152
+ 400: {"description": "请求无效(例如文件过大)"},
153
+ 422: {"description": "路径参数验证失败(例如格式不支持)"},
154
+ 500: {"description": "服务器内部转换失败"},
155
+ 504: {"description": "转换处理超时"}
156
+ }
157
+ )
158
+ async def convert_image_dynamic(
159
  background_tasks: BackgroundTasks,
160
+ target_format: TargetFormat,
161
+ mode: ConversionMode,
162
+ setting: int = Path(..., ge=0, le=100, description="质量(有损) 或 压缩速度(无损) (0-100)"),
163
+ file: UploadFile = File(..., description="要转换的图像文件 (支持动图)")
164
  ):
165
  """
166
+ 通过动态 URL 路径接收图像文件,执行转换并返回结果。
167
+
168
+ - **target_format**: 目标格式 (avif, webp, jpeg, png, gif, heif)
169
+ - **mode**: 转换模式 (lossless, lossy)
170
+ - **setting**: 模式设置 (0-100)
171
+ - mode=lossy: 0=最差质量, 100=最佳质量
172
+ - mode=lossless: 0=最慢/最佳压缩, 100=最快/最差压缩
173
  """
174
+ logger.info(f"收到动态转换请求: {target_format}/{mode}/{setting} (文件: {file.filename})")
175
+
176
+ # 预检查: AVIF/HEIF 格式需要 heif-enc 依赖
177
+ if target_format in ["avif", "heif"]:
178
+ try:
179
+ proc_check = await asyncio.subprocess.create_subprocess_exec(
180
+ 'which', 'heif-enc',
181
+ stdout=asyncio.subprocess.PIPE,
182
+ stderr=asyncio.subprocess.PIPE
183
+ )
184
+ await proc_check.communicate()
185
+ if proc_check.returncode != 0:
186
+ raise HTTPException(
187
+ status_code=503,
188
+ detail=f"AVIF/HEIF encoding is not available. heif-enc encoder not found."
189
+ )
190
+ except Exception as e:
191
+ logger.error(f"依赖检查失败: {e}")
192
+ raise HTTPException(
193
+ status_code=503,
194
+ detail=f"Unable to verify AVIF/HEIF encoder availability."
195
+ )
196
+
197
+ # 1. 验证文件扩展名
198
+ if not file.filename:
199
+ raise HTTPException(status_code=400, detail="Filename is required.")
200
+
201
+ file_ext = os.path.splitext(file.filename)[1].lower()
202
+ allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.heif', '.heic', '.bmp', '.tiff', '.tif'}
203
+ if file_ext not in allowed_extensions:
204
+ raise HTTPException(
205
+ status_code=400,
206
+ detail=f"Unsupported file format: {file_ext}. Allowed formats: {', '.join(allowed_extensions)}"
207
+ )
208
 
209
+ # 2. 验证文件大小
210
  file_size_mb = await get_upload_file_size(file) / (1024 * 1024)
211
  if file_size_mb > MAX_FILE_SIZE_MB:
212
+ logger.warning(f"文件过大: {file_size_mb:.2f}MB (最大: {MAX_FILE_SIZE_MB}MB)")
213
  raise HTTPException(
214
  status_code=400,
215
+ detail=f"File too large. Max size is {MAX_FILE_SIZE_MB}MB."
216
  )
217
 
218
+ # 3. 创建唯一的临时工作目录
219
  session_id = str(uuid.uuid4())
220
  temp_dir = os.path.join(TEMP_DIR, session_id)
221
  os.makedirs(temp_dir, exist_ok=True)
222
+
 
223
  _, file_extension = os.path.splitext(file.filename)
224
  input_path = os.path.join(temp_dir, f"input{file_extension}")
225
+ output_path = os.path.join(temp_dir, f"output.{target_format}")
226
 
227
+ logger.info(f"正在临时目录中处理: {temp_dir}")
228
 
229
+ cleanup_scheduled = False
230
  try:
231
+ # 4. 保存上传的文件到临时输入路径
232
+ logger.info(f"正在保存上传的文件 '{file.filename}' '{input_path}'")
233
  with open(input_path, "wb") as buffer:
234
  shutil.copyfileobj(file.file, buffer)
235
+ logger.info("文件保存成功。")
236
+
237
+ # 5. 动态构建 ImageMagick 命令行参数
238
+ cmd = ['magick', input_path]
239
+
240
+ # 关键: 仅对动画格式使用 -coalesce 以优化性能
241
+ # -coalesce 会合并所有帧,确保动图(GIF/WebP/AVIF)被正确处理
242
+ # 检测可能是动画的格式
243
+ animated_formats = ['.gif', '.webp', '.apng', '.png']
244
+ if file_extension.lower() in animated_formats or target_format in ['gif', 'webp']:
245
+ cmd.append('-coalesce')
246
+
247
+ # --- 5a. 无损 (lossless) 模式逻辑 ---
248
+ if mode == "lossless":
249
+ # 'setting' (0-100) 代表压缩速度 (0=最佳/最慢, 100=最快/最差)
250
+
251
+ if target_format == "avif":
252
+ # AVIF speed (0-10), 0 是最慢/最佳
253
+ avif_speed = min(10, int(setting / 10.0))
254
+ cmd.extend(['-define', 'avif:lossless=true'])
255
+ cmd.extend(['-define', f'avif:speed={avif_speed}'])
256
+
257
+ elif target_format == "heif":
258
+ # HEIF speed (0-10), 0 是最慢/最佳
259
+ heif_speed = min(10, int(setting / 10.0))
260
+ cmd.extend(['-define', 'heif:lossless=true'])
261
+ cmd.extend(['-define', f'heif:speed={heif_speed}'])
262
+
263
+ elif target_format == "webp":
264
+ # WebP method (0-6), 6 是最慢/最佳
265
+ # 映射: setting(0) -> method(6), setting(100) -> method(0)
266
+ # 使用线性插值确保精确映射
267
+ webp_method = round(6 - (setting / 100.0) * 6)
268
+ # WebP 无损模式下 quality 应始终为 100
269
+ cmd.extend(['-define', 'webp:lossless=true'])
270
+ cmd.extend(['-define', f'webp:method={webp_method}'])
271
+ cmd.extend(['-quality', '100'])
272
+
273
+ elif target_format == "jpeg":
274
+ # JPEG 几乎没有通用的无损模式,使用-quality 100作为最佳有损替代
275
+ cmd.extend(['-quality', '100'])
276
+
277
+ elif target_format == "png":
278
+ # PNG 始终无损
279
+ # 映射: setting(0) -> compression(9), setting(100) -> compression(0)
280
+ png_compression = min(9, int((100 - setting) * 0.09))
281
+ # Magick -quality 映射: 91=级别0, 100=级别9
282
+ cmd.extend(['-quality', str(91 + png_compression)])
283
+
284
+ elif target_format == "gif":
285
+ # GIF 始终是基于调色板的无损
286
+ # -layers optimize 用于优化动图帧
287
+ cmd.extend(['-layers', 'optimize'])
288
+ pass # Magick 默认值适用于无损GIF
289
+
290
+ # --- 5b. 有损 (lossy) 模式逻辑 ---
291
+ elif mode == "lossy":
292
+ # 'setting' (0-100) 代表 质量 (0=最差, 100=最佳)
293
+ quality = setting
294
+
295
+ if target_format == "avif":
296
+ # AVIF cq-level (0-63), 0 是最佳
297
+ # 映射: quality(100) -> cq(0) ; quality(0) -> cq(63)
298
+ cq_level = max(0, min(63, int(63 * (1 - quality / 100.0))))
299
+ cmd.extend(['-define', f'avif:cq-level={cq_level}'])
300
+ cmd.extend(['-define', 'avif:speed=4']) # 默认使用较快的速度
301
+
302
+ elif target_format == "heif":
303
+ # HEIF (heif-enc) 使用 -quality (0-100) 进行有损压缩
304
+ cmd.extend(['-quality', str(quality)])
305
+
306
+ elif target_format == "webp":
307
+ cmd.extend(['-quality', str(quality)])
308
+ cmd.extend(['-define', 'webp:method=4']) # 默认使用较快的速度
309
+
310
+ elif target_format == "jpeg":
311
+ cmd.extend(['-quality', str(quality)])
312
+
313
+ elif target_format == "png":
314
+ # PNG 本身无损,通过量化(减少颜色)模拟 "有损"
315
+ # 映射: quality(100) -> 256色, quality(0) -> 2色
316
+ colors = max(2, int(256 * (quality / 100.0)))
317
+ cmd.extend(['-colors', str(colors), '+dither'])
318
+
319
+ elif target_format == "gif":
320
+ # GIF "有损" 通过减少调色板颜色实现
321
+ colors = max(2, int(256 * (quality / 100.0)))
322
+ cmd.extend(['-colors', str(colors), '+dither'])
323
+ cmd.extend(['-layers', 'optimize'])
324
+
325
+
326
+ # 6. 添加输出路径并完成命令构建
327
+ cmd.append(output_path)
328
  command_str = ' '.join(cmd)
329
+ logger.info(f"正在执行命令: {command_str}")
330
 
331
+ # 7. 异步执行 Magick 命令 (继承自 imagemagickapi-hfs 实践)
332
+ process = await asyncio.subprocess.create_subprocess_exec(
333
+ *cmd,
334
+ stdout=asyncio.subprocess.PIPE,
335
+ stderr=asyncio.subprocess.PIPE
336
+ )
337
+ stdout, stderr = await asyncio.wait_for(
338
+ process.communicate(),
339
  timeout=TIMEOUT_SECONDS
340
  )
 
341
 
342
+ # 8. 检查命令执行结果
343
  if process.returncode != 0:
344
+ error_message = f"Magick failed: {stderr.decode()[:1000]}"
345
+ logger.error(error_message)
346
+ raise HTTPException(status_code=500, detail=error_message)
347
 
348
  if not os.path.exists(output_path):
349
+ error_message = "Magick 命令成功执行,但未找到输出文件。"
350
  logger.error(error_message)
351
  raise HTTPException(status_code=500, detail=error_message)
352
+
353
+ # 9. 成功:准备并返回文件响应
354
+ logger.info(f"转换成功。输出文件: '{output_path}'")
355
 
 
 
 
 
356
  original_filename_base = os.path.splitext(file.filename)[0]
357
+ download_filename = f"{original_filename_base}_{mode}_{setting}.{target_format}"
358
+
359
+ # 动态设置 MimeType
360
+ media_type = f"image/{target_format}"
361
+ if target_format == "heif":
362
+ media_type = "image/heif" # HEIF 的 MimeType
363
 
364
+ # 注册后台清理任务
365
  background_tasks.add_task(cleanup_temp_dir, temp_dir)
366
+ cleanup_scheduled = True
367
 
 
368
  return FileResponse(
369
  path=output_path,
370
+ media_type=media_type,
371
+ filename=download_filename
 
372
  )
373
 
374
  except asyncio.TimeoutError:
375
+ logger.error(f"Magick 处理超时 (>{TIMEOUT_SECONDS}s): {file.filename}")
376
  raise HTTPException(status_code=504, detail=f"Conversion timed out after {TIMEOUT_SECONDS} seconds.")
377
  except HTTPException as http_exc:
378
+ # 重新抛出已知的 HTTP 异常
379
  raise http_exc
380
  except Exception as e:
381
+ # 捕获所有其他意外错误
382
+ logger.error(f"发生意外错误: {e}", exc_info=True)
383
+ raise HTTPException(status_code=500, detail=f"An unexpected server error occurred: {str(e)}")
384
  finally:
385
+ # 确保关闭上传的文件句柄
386
  await file.close()
387
+ # 备用清理:仅当未注册后台任务时立即清理
388
+ if not cleanup_scheduled and os.path.exists(temp_dir):
389
+ cleanup_temp_dir(temp_dir)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_magick.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import os
3
+ import time
4
+
5
+ # --- 配置 ---
6
+
7
+ # 您的 API 端点基础 URL
8
+ api_base_url = "https://blueskyxn-imagemagickapi-hfs.hf.space"
9
+
10
+ # 转换参数: 格式/模式/设置
11
+ target_format = "avif" # avif, webp, jpeg, png, gif, heif
12
+ mode = "lossy" # lossy, lossless
13
+ setting = 80 # 0-100 (有损模式为质量, 无损模式为压缩速度)
14
+
15
+ # 构建完整的 API URL
16
+ api_url = f"{api_base_url}/convert/{target_format}/{mode}/{setting}"
17
+
18
+ # 您要测试的本地图片路径
19
+ input_image_path = r"/Volumes/TP4000PRO/582434E54A64FCB0285EFABF390AC3DB.jpg"
20
+
21
+ # 转换后的文件保存路径 (自动替换扩展名)
22
+ output_image_path = os.path.splitext(input_image_path)[0] + f".{target_format}"
23
+
24
+ # ----------------
25
+
26
+ # 检查输入文件是否存在
27
+ if not os.path.exists(input_image_path):
28
+ print(f"错误: 输入文件未找到: {input_image_path}")
29
+ exit()
30
+
31
+ print(f"开始处理文件: {input_image_path}")
32
+ try:
33
+ print(f"文件大小: {os.path.getsize(input_image_path)/1024/1024:.2f} MB")
34
+ except OSError as e:
35
+ print(f"无法访问文件: {e}")
36
+ exit()
37
+
38
+ start_time = time.time()
39
+
40
+ # 准备上传的文件
41
+ # 键 "file" 必须与您 main.py 中 FastAPI 的参数名一致
42
+ # (file: UploadFile = File(...))
43
+ file_handle = open(input_image_path, "rb")
44
+ files = {
45
+ "file": (
46
+ os.path.basename(input_image_path), # 发送原始文件名
47
+ file_handle, # 文件句柄
48
+ "image/jpeg" # 文件的 MIME 类型
49
+ )
50
+ }
51
+
52
+ try:
53
+ # 发送 POST 请求
54
+ print(f"正在发送请求到 Magick API: {api_url}")
55
+ # 注意:这个端点不需要 "data" 参数,只需要 "files"
56
+ response = requests.post(api_url, files=files)
57
+
58
+ # --- 处理响应 ---
59
+ if response.status_code == 200:
60
+ # 检查返回的是否是目标格式的图像
61
+ expected_content_type = f'image/{target_format}'
62
+ if target_format == 'jpeg':
63
+ expected_content_type = 'image/jpeg'
64
+
65
+ actual_content_type = response.headers.get('content-type')
66
+ if actual_content_type == expected_content_type or actual_content_type.startswith('image/'):
67
+ # 保存处理后的图像
68
+ with open(output_image_path, "wb") as f:
69
+ f.write(response.content)
70
+
71
+ end_time = time.time()
72
+ print("\n--- 转换成功! ---")
73
+ print(f"总耗时: {end_time - start_time:.2f} 秒")
74
+ print(f"结果已保存到: {output_image_path}")
75
+ print(f"输出文件大小: {os.path.getsize(output_image_path)/1024/1024:.2f} MB")
76
+ else:
77
+ print(f"处理失败! 服务器返回了 200 OK,但内容类型不匹配。")
78
+ print(f"期望的内容类型: {expected_content_type}")
79
+ print(f"返回的内容类型: {actual_content_type}")
80
+ print(f"响应内容 (前500字节): {response.text[:500]}...")
81
+
82
+ else:
83
+ # --- 处理错误 ---
84
+ print(f"\n--- 处理失败! ---")
85
+ print(f"状态码: {response.status_code}")
86
+ try:
87
+ # 尝试解析 FastAPI 返回的 JSON 错误详情
88
+ error_details = response.json()
89
+ print(f"错误详情: {error_details.get('detail', '无详情')}")
90
+ except requests.exceptions.JSONDecodeError:
91
+ # 如果返回的不是 JSON (例如 502 Bad Gateway)
92
+ print(f"响应内容 (前500字节): {response.text[:500]}...")
93
+
94
+ finally:
95
+ # 确保关闭文件句柄
96
+ file_handle.close()
97
+ print("\n测试完成。")