Spaces:
Running
Running
BlueSkyXN
commited on
Commit
·
8dbd049
1
Parent(s):
d52fe4f
0.1.0
Browse files- Dockerfile +1 -0
- README.md +161 -4
- main.py +298 -110
- 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
|
| 12 |
|
| 13 |
-
本项目提供一个基于 FastAPI 和 ImageMagick
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from fastapi.responses import FileResponse, JSONResponse
|
| 4 |
import subprocess
|
| 5 |
-
import asyncio
|
| 6 |
import tempfile
|
| 7 |
import os
|
| 8 |
import shutil
|
| 9 |
import logging
|
| 10 |
import uuid
|
| 11 |
-
from typing import Literal
|
| 12 |
|
| 13 |
-
# ---
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
# 初始化 FastAPI 应用
|
| 23 |
app = FastAPI(
|
| 24 |
-
title="Magick
|
| 25 |
-
description="API
|
| 26 |
-
version="
|
| 27 |
)
|
| 28 |
|
| 29 |
-
#
|
| 30 |
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 31 |
|
| 32 |
-
# ---
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
async def health_check():
|
| 35 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 36 |
try:
|
| 37 |
-
# 检查
|
| 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 编码器
|
| 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"
|
| 67 |
-
return {"status": "unhealthy", "error": str(e)}
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
| 80 |
background_tasks: BackgroundTasks,
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
| 82 |
):
|
| 83 |
"""
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
"""
|
| 86 |
-
logger.info(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 92 |
raise HTTPException(
|
| 93 |
status_code=400,
|
| 94 |
-
detail=f"File too large.
|
| 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.
|
| 106 |
|
| 107 |
-
logger.info(f"
|
| 108 |
|
|
|
|
| 109 |
try:
|
| 110 |
-
#
|
| 111 |
-
logger.info(f"
|
| 112 |
with open(input_path, "wb") as buffer:
|
| 113 |
shutil.copyfileobj(file.file, buffer)
|
| 114 |
-
logger.info("
|
| 115 |
-
|
| 116 |
-
#
|
| 117 |
-
cmd = [
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
]
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
command_str = ' '.join(cmd)
|
| 126 |
-
logger.info(f"
|
| 127 |
|
| 128 |
-
#
|
| 129 |
-
process = await asyncio.
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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
|
| 142 |
-
logger.error(
|
| 143 |
-
raise HTTPException(status_code=500, detail=
|
| 144 |
|
| 145 |
if not os.path.exists(output_path):
|
| 146 |
-
error_message = "Magick
|
| 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}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
-
#
|
| 158 |
background_tasks.add_task(cleanup_temp_dir, temp_dir)
|
|
|
|
| 159 |
|
| 160 |
-
# 返回文件
|
| 161 |
return FileResponse(
|
| 162 |
path=output_path,
|
| 163 |
-
media_type=
|
| 164 |
-
filename=download_filename
|
| 165 |
-
background=background_tasks
|
| 166 |
)
|
| 167 |
|
| 168 |
except asyncio.TimeoutError:
|
| 169 |
-
logger.error(f"Magick
|
| 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 |
-
|
| 175 |
-
|
|
|
|
| 176 |
finally:
|
| 177 |
-
#
|
| 178 |
await file.close()
|
| 179 |
-
#
|
| 180 |
-
if not
|
| 181 |
-
|
| 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测试完成。")
|