feat: optimize compression and production deployment

This commit is contained in:
237899745
2026-07-25 11:20:45 +08:00
parent 9d7668bdee
commit 0ff9eae56d
15 changed files with 882 additions and 771 deletions

View File

@@ -1,5 +1,7 @@
.git .git
.env .env
.env.*
.bench
target target
uploads uploads
frontend/node_modules frontend/node_modules

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
/uploads /uploads
/static /static
/logs /logs
/.bench
/frontend/node_modules /frontend/node_modules
/frontend/dist /frontend/dist

17
Cargo.lock generated
View File

@@ -982,7 +982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1642,6 +1642,7 @@ dependencies = [
"hmac", "hmac",
"image", "image",
"img-parts", "img-parts",
"jpeg-encoder",
"jsonwebtoken", "jsonwebtoken",
"lettre", "lettre",
"oxipng", "oxipng",
@@ -1762,6 +1763,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "jpeg-encoder"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b0b36cbb4e6704f12f5b5d7b01dac593982c6550859ebd5a66fb15c9ea27fd5"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.83" version = "0.3.83"
@@ -2130,7 +2137,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2553,7 +2560,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2 0.6.1", "socket2 0.6.1",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -2870,7 +2877,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3550,7 +3557,7 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View File

@@ -42,6 +42,7 @@ aes-gcm = "0.10"
# Keep image-rs limited to formats exposed by the API. AVIF decoding uses # Keep image-rs limited to formats exposed by the API. AVIF decoding uses
# libdav1d on Linux; encoding is handled by the direct ravif dependency. # libdav1d on Linux; encoding is handled by the direct ravif dependency.
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "tiff", "webp"] } image = { version = "0.25", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "tiff", "webp"] }
jpeg-encoder = "0.7"
oxipng = "9" oxipng = "9"
ravif = { version = "0.11", default-features = false, features = ["threading"] } ravif = { version = "0.11", default-features = false, features = ["threading"] }
webp = { version = "0.3", default-features = false } webp = { version = "0.3", default-features = false }

View File

@@ -8,7 +8,7 @@
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICOGIF 仅静态,支持格式转换) - **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICOGIF 仅静态,支持格式转换)
- **批量处理**:支持多图片同时上传和处理 - **批量处理**:支持多图片同时上传和处理
- **压缩率**1-100数值越小压缩越强100 为不压缩 - **压缩率**1-100JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化
- **用户系统**注册、登录、API Key 管理 - **用户系统**注册、登录、API Key 管理
- **计费与用量**:套餐/订阅/配额/发票 - **计费与用量**:套餐/订阅/配额/发票
- **管理员后台**:用户管理、系统监控、配置管理 - **管理员后台**:用户管理、系统监控、配置管理
@@ -84,7 +84,7 @@ imageforge/
- Rust建议使用最新 stable当前依赖链要求较新的 Rust建议 `>= 1.85` - Rust建议使用最新 stable当前依赖链要求较新的 Rust建议 `>= 1.85`
- PostgreSQL 16+ - PostgreSQL 16+
- Redis 7+ - Redis 7+
- Node.js 20+(前端构建) - Node.js 22+(前端构建)
### 本地开发 ### 本地开发
@@ -107,10 +107,14 @@ cd frontend && npm run dev
### Docker 部署 ### Docker 部署
```bash ```bash
# 该 compose 仅包含 postgres/redis服务本体请按 docs/deployment.md 构建运行 cp docker/.env.production.example .env.production
docker compose -f docker/docker-compose.dev.yml up -d # 修改全部密钥与 PUBLIC_BASE_URL 后启动完整生产栈
docker compose --env-file .env.production -f docker/docker-compose.prod.yml up -d --build
curl --fail http://127.0.0.1:8080/health
``` ```
详细配置、更新、备份与真实图片质量回归见 [部署指南](./docs/deployment.md)。
## 文档索引 ## 文档索引
- [开工前确认清单](./docs/confirm.md) - [开工前确认清单](./docs/confirm.md)

View File

@@ -0,0 +1,45 @@
# Image tag built by docker/docker-compose.prod.yml
IMAGEFORGE_TAG=local
# Public listener and URL
IMAGEFORGE_BIND_ADDRESS=0.0.0.0
IMAGEFORGE_PORT=8080
PUBLIC_BASE_URL=http://192.0.2.10:8080
# Replace every secret before starting the stack.
POSTGRES_PASSWORD=replace-with-a-long-random-password
JWT_SECRET=replace-with-at-least-32-random-bytes
API_KEY_PEPPER=replace-with-an-independent-random-secret
# Initial administrator created on first startup.
ADMIN_EMAIL=admin@example.com
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-strong-admin-password
# A four-core host should start with two image jobs per process.
DATABASE_MAX_CONNECTIONS=10
WORKER_CONCURRENCY=2
IMAGE_PROCESSING_CONCURRENCY=2
ALLOW_ANONYMOUS_UPLOAD=true
ANON_MAX_FILE_SIZE_MB=5
ANON_MAX_FILES_PER_BATCH=5
ANON_DAILY_UNITS=10
ANON_RETENTION_HOURS=24
MAX_IMAGE_PIXELS=40000000
IDEMPOTENCY_TTL_HOURS=24
# Enable only when the API port is reachable exclusively through a trusted proxy.
TRUST_PROXY_HEADERS=false
MAIL_ENABLED=false
MAIL_LOG_LINKS_WHEN_DISABLED=false
# STRIPE_SECRET_KEY=sk_live_replace_me
# STRIPE_WEBHOOK_SECRET=whsec_replace_me
# MAIL_PROVIDER=custom
# MAIL_FROM=noreply@example.com
# MAIL_PASSWORD=replace-with-smtp-authorization-code
# MAIL_FROM_NAME=ImageForge
# MAIL_SMTP_HOST=smtp.example.com
# MAIL_SMTP_PORT=465
# MAIL_SMTP_ENCRYPTION=ssl
RUST_LOG=info,tower_http=info,imageforge=info

View File

@@ -1,4 +1,6 @@
FROM rust:1.92-bookworm AS builder # syntax=docker/dockerfile:1.7
FROM rust:1.92-trixie AS builder
WORKDIR /app WORKDIR /app
@@ -14,35 +16,45 @@ COPY src ./src
COPY migrations ./migrations COPY migrations ./migrations
COPY templates ./templates COPY templates ./templates
RUN cargo build --release RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/app/target,sharing=locked \
cargo build --release --locked \
&& cp /app/target/release/imageforge /app/imageforge
FROM node:20-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci
COPY frontend ./ COPY frontend ./
RUN npm run build RUN npm run build
FROM debian:bookworm-slim FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
libdav1d6 \ curl \
libdav1d7 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 10001 imageforge \
&& useradd --system --uid 10001 --gid imageforge --home-dir /app --shell /usr/sbin/nologin imageforge
WORKDIR /app WORKDIR /app
COPY --from=builder /app/target/release/imageforge ./imageforge COPY --from=builder /app/imageforge ./imageforge
COPY --from=frontend-builder /app/frontend/dist ./static COPY --from=frontend-builder /app/frontend/dist ./static
COPY migrations ./migrations COPY migrations ./migrations
RUN mkdir -p uploads RUN mkdir -p uploads \
&& chown imageforge:imageforge uploads
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=8080 ENV PORT=8080
EXPOSE 8080 EXPOSE 8080
USER 10001:10001
CMD ["./imageforge"] CMD ["./imageforge"]

View File

@@ -0,0 +1,128 @@
name: imageforge
x-imageforge-environment: &imageforge-environment
DATABASE_URL: postgres://imageforge:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/imageforge
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10}
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
JWT_EXPIRY_HOURS: ${JWT_EXPIRY_HOURS:-168}
API_KEY_PEPPER: ${API_KEY_PEPPER:?API_KEY_PEPPER is required}
BILLING_PROVIDER: ${BILLING_PROVIDER:-stripe}
STORAGE_TYPE: local
STORAGE_PATH: /app/uploads
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8080}
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-2}
IMAGE_PROCESSING_CONCURRENCY: ${IMAGE_PROCESSING_CONCURRENCY:-2}
ALLOW_ANONYMOUS_UPLOAD: ${ALLOW_ANONYMOUS_UPLOAD:-true}
ANON_MAX_FILE_SIZE_MB: ${ANON_MAX_FILE_SIZE_MB:-5}
ANON_MAX_FILES_PER_BATCH: ${ANON_MAX_FILES_PER_BATCH:-5}
ANON_DAILY_UNITS: ${ANON_DAILY_UNITS:-10}
ANON_RETENTION_HOURS: ${ANON_RETENTION_HOURS:-24}
MAX_IMAGE_PIXELS: ${MAX_IMAGE_PIXELS:-40000000}
IDEMPOTENCY_TTL_HOURS: ${IDEMPOTENCY_TTL_HOURS:-24}
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
RUST_LOG: ${RUST_LOG:-info,tower_http=info,imageforge=info}
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: imageforge
POSTGRES_USER: imageforge
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U imageforge -d imageforge"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
api:
image: imageforge:${IMAGEFORGE_TAG:-local}
build:
context: ..
dockerfile: docker/Dockerfile
init: true
environment:
<<: *imageforge-environment
IMAGEFORGE_ROLE: api
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}"
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
MAIL_ENABLED: ${MAIL_ENABLED:-false}
MAIL_LOG_LINKS_WHEN_DISABLED: ${MAIL_LOG_LINKS_WHEN_DISABLED:-false}
MAIL_PROVIDER: ${MAIL_PROVIDER:-qq}
MAIL_FROM: ${MAIL_FROM:-noreply@example.com}
MAIL_PASSWORD: "${MAIL_PASSWORD:-}"
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-ImageForge}
MAIL_SMTP_HOST: "${MAIL_SMTP_HOST:-}"
MAIL_SMTP_PORT: "${MAIL_SMTP_PORT:-}"
MAIL_SMTP_ENCRYPTION: "${MAIL_SMTP_ENCRYPTION:-}"
ports:
- "${IMAGEFORGE_BIND_ADDRESS:-0.0.0.0}:${IMAGEFORGE_PORT:-8080}:8080"
volumes:
- uploads:/app/uploads
tmpfs:
- /tmp:size=256m,mode=1777
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 3s
retries: 12
start_period: 20s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
restart: unless-stopped
worker:
image: imageforge:${IMAGEFORGE_TAG:-local}
init: true
environment:
<<: *imageforge-environment
IMAGEFORGE_ROLE: worker
volumes:
- uploads:/app/uploads
tmpfs:
- /tmp:size=256m,mode=1777
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
api:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
restart: unless-stopped
volumes:
postgres_data:
redis_data:
uploads:

View File

@@ -272,9 +272,9 @@ Idempotency-Key: <key> # 建议
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---| |---|---|---:|---|
| `file` | File | 是 | 图片文件 | | `file` | File | 是 | 图片文件 |
| `compression_rate` | Integer | 否 | 压缩率 1-100(压缩后体积占原图比例,数值越小压缩越强100 表示不压缩优先级高于 `level` | | `compression_rate` | Integer | 否 | 压缩率 1-100JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化100 表示不压缩优先级高于 `level` |
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium` | | `level` | String | 否 | `high` / `medium` / `low`(兼容参数,默认 `medium` |
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式) | | `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式ICO 自动等比缩至 256x256 边界 |
| `max_width` | Integer | 否 | 最大宽度(等比缩放) | | `max_width` | Integer | 否 | 最大宽度(等比缩放) |
| `max_height` | Integer | 否 | 最大高度(等比缩放) | | `max_height` | Integer | 否 | 最大高度(等比缩放) |
| `target_size_bytes` | Integer | 否 | 目标体积(字节),仅 `jpeg/webp/avif` 输出支持;会优先保清晰度并在必要时小幅缩放 | | `target_size_bytes` | Integer | 否 | 目标体积(字节),仅 `jpeg/webp/avif` 输出支持;会优先保清晰度并在必要时小幅缩放 |
@@ -340,7 +340,7 @@ Idempotency-Key: <key> # 建议
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---| |---|---|---:|---|
| `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) | | `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) |
| `compression_rate` | Integer | 否 | 压缩率 1-100(压缩后体积占原图比例,数值越小压缩越强100 表示不压缩优先级高于 `level` | | `compression_rate` | Integer | 否 | 压缩率 1-100JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化100 表示不压缩优先级高于 `level` |
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数) | | `level` | String | 否 | `high` / `medium` / `low`(兼容参数) |
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式) | | `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式) |
| `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false` | | `preserve_metadata` | Boolean | 否 | 是否保留元数据(默认 `false` |

View File

@@ -1,714 +1,155 @@
# 部署指南 # 部署指南
## 环境准备
### 系统要求
- Linux (Ubuntu 22.04+ / Debian 12+ 推荐)
- 2+ CPU 核心(启用独立 Worker 建议 4+
- 4GB+ 内存
- 50GB+ 磁盘空间
### 依赖安装
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install -y \
build-essential \
pkg-config \
libssl-dev \
libpq-dev \
cmake \
libdav1d-dev \
nasm \
libjpeg-dev \
libpng-dev \
libwebp-dev
# 安装 Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
# 初始化数据库会用到 psql建议安装 PostgreSQL client
sudo apt install -y postgresql-client
# 安装 Node.js (前端构建)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
```
---
## 本地开发
### 1. 启动数据库服务
```bash
# 使用 Docker Compose 启动 PostgreSQL 和 Redis
docker-compose -f docker/docker-compose.dev.yml up -d
```
`docker/docker-compose.dev.yml`:
```yaml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: imageforge
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: imageforge
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
# 规划:接入 S3 存储后可增加 MinIO当前版本仅支持本地存储
# minio:
# image: minio/minio:RELEASE.2024-01-28T20-20-01Z
# command: server /data --console-address ":9001"
# environment:
# MINIO_ROOT_USER: minioadmin
# MINIO_ROOT_PASSWORD: minioadmin
# ports:
# - "9000:9000"
# - "9001:9001"
# volumes:
# - minio_data:/data
volumes:
postgres_data:
redis_data:
minio_data:
```
### 2. 配置环境变量
```bash
cp .env.example .env
```
`.env.example`:
```bash
# 运行模式:建议将 API 与 Worker 分开运行
IMAGEFORGE_ROLE=api # api | worker
# 服务配置
HOST=0.0.0.0
PORT=8080
PUBLIC_BASE_URL=http://localhost:8080
RUST_LOG=info,imageforge=debug
# 数据库
DATABASE_URL=postgres://imageforge:devpassword@localhost:5432/imageforge
# Redis
REDIS_URL=redis://localhost:6379
# Worker 并发(每个批量任务内同时处理的文件数)
WORKER_CONCURRENCY=4
# 图片处理全局并发(每个 API/Worker 进程)
IMAGE_PROCESSING_CONCURRENCY=4
# 仅在后端端口不对公网开放、请求必经可信代理时启用
TRUST_PROXY_HEADERS=false
# JWT网站/管理后台)
JWT_SECRET=your-super-secret-key-change-in-production
JWT_EXPIRY_HOURS=168
# API Key
API_KEY_PEPPER=please-change-this-in-production
# 存储(当前实现仅支持 local
STORAGE_TYPE=local
STORAGE_PATH=./uploads
# 计费已确认Stripe
BILLING_PROVIDER=stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# 限制(默认值;最终以套餐/用户覆盖为准)
ALLOW_ANONYMOUS_UPLOAD=true
ANON_MAX_FILE_SIZE_MB=5
ANON_MAX_FILES_PER_BATCH=5
ANON_DAILY_UNITS=10
MAX_IMAGE_PIXELS=40000000
IDEMPOTENCY_TTL_HOURS=24
# 结果保留(匿名默认;登录用户按套餐 retention_days
ANON_RETENTION_HOURS=24
# 管理员初始账户
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme123
```
### 3. 初始化数据库
API 或 Worker 启动时会通过 SQLx 自动、顺序执行 `migrations/` 下尚未应用的迁移。迁移失败时进程会退出,不会在不完整的数据库结构上继续提供服务。
### 4. 启动开发服务器
```bash
# 后端 API (热重载)
cargo install cargo-watch
IMAGEFORGE_ROLE=api cargo watch -x run
# 后端 Worker另一个终端处理异步/批量任务)
IMAGEFORGE_ROLE=worker cargo watch -x run
# 前端 (另一个终端)
cd frontend
npm install
npm run dev
```
### 5. Stripe Webhook 本地调试(可选)
本地调试 Stripe 订阅/支付状态,通常需要将 Stripe Webhook 转发到本机:
```bash
# 1) 安装并登录 Stripe CLI按官方文档
# 2) 监听并转发到你的后端回调地址
stripe listen --forward-to http://localhost:8080/api/v1/webhooks/stripe
# CLI 会输出一个 whsec_...,写入 .env 的 STRIPE_WEBHOOK_SECRET
```
---
## 生产部署 ## 生产部署
> 注意:以下内容为生产部署模板示例;仓库当前首期仅提供开发用 `docker/docker-compose.dev.yml`,生产 compose/Dockerfile/nginx/k8s 等可在开工阶段按需落地并调整 仓库提供完整的 `docker/Dockerfile``docker/docker-compose.prod.yml`。生产编排包含 API、Worker、PostgreSQL 和 Redis数据库与 Redis 不发布宿主机端口API 和 Worker 使用同一上传卷,并以非 root、只读根文件系统运行
### 方案一Docker Compose推荐小规模 ### 环境要求
`docker/docker-compose.prod.yml`: - Linux x86_64
```yaml - Docker Engine 26+ 与 Docker Compose 2.20+
version: '3.8' - 最低 2 核 CPU、4GB 内存;启用 AVIF 和独立 Worker 时建议 4 核、8GB 内存
- 首次构建可访问 Docker Hub 与 crates.io
services: Debian 13、4 核 CPU、8GB 内存的实测起始值为 `WORKER_CONCURRENCY=2``IMAGE_PROCESSING_CONCURRENCY=2`。AVIF 是 CPU 密集型编码,不要直接把并发设置为 CPU 核数的数倍。
api:
build:
context: ..
dockerfile: docker/Dockerfile
environment:
- IMAGEFORGE_ROLE=api
- TRUST_PROXY_HEADERS=true
- BILLING_PROVIDER=stripe
- PUBLIC_BASE_URL=https://your-domain.com
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- DATABASE_URL=postgres://imageforge:${DB_PASSWORD}@postgres:5432/imageforge
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
- API_KEY_PEPPER=${API_KEY_PEPPER}
- STORAGE_TYPE=local
- STORAGE_PATH=/app/uploads
expose:
- "8080"
volumes:
- uploads:/app/uploads
depends_on:
- postgres
- redis
restart: unless-stopped
worker: ### 首次启动
build:
context: ..
dockerfile: docker/Dockerfile
environment:
- IMAGEFORGE_ROLE=worker
- DATABASE_URL=postgres://imageforge:${DB_PASSWORD}@postgres:5432/imageforge
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
- API_KEY_PEPPER=${API_KEY_PEPPER}
- STORAGE_TYPE=local
- STORAGE_PATH=/app/uploads
volumes:
- uploads:/app/uploads
depends_on:
- postgres
- redis
restart: unless-stopped
postgres: ```bash
image: postgres:16-alpine cp docker/.env.production.example .env.production
environment: chmod 600 .env.production
POSTGRES_USER: imageforge
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: imageforge
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis: # 分别生成 POSTGRES_PASSWORD、JWT_SECRET、API_KEY_PEPPER 和管理员密码。
image: redis:7-alpine # hex 不包含数据库 URL 与 .env 需要转义的保留字符。
volumes: openssl rand -hex 32
- redis_data:/data
restart: unless-stopped
nginx: # 编辑公开地址和全部 replace-with-* 值后检查配置。
image: nginx:alpine docker compose \
ports: --env-file .env.production \
- "80:80" -f docker/docker-compose.prod.yml \
- "443:443" config --quiet
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- api
restart: unless-stopped
volumes: docker compose \
uploads: --env-file .env.production \
postgres_data: -f docker/docker-compose.prod.yml \
redis_data: up -d --build
``` ```
`docker/Dockerfile`: API 健康后 Worker 才会启动,避免两个进程在首次部署时同时执行迁移。
```dockerfile
FROM rust:1.92-bookworm AS builder
WORKDIR /app ```bash
docker compose --env-file .env.production -f docker/docker-compose.prod.yml ps
RUN apt-get update && apt-get install -y --no-install-recommends \ curl --fail http://127.0.0.1:8080/health
cmake \
libdav1d-dev \
nasm \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
COPY templates ./templates
RUN cargo build --release
# 前端构建阶段
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend ./
RUN npm run build
# 运行阶段
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libdav1d6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/imageforge ./imageforge
COPY --from=frontend-builder /app/frontend/dist ./static
COPY migrations ./migrations
RUN mkdir -p uploads
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
CMD ["./imageforge"]
``` ```
`docker/nginx.conf`: 预期健康响应:
```json
{"status":"healthy","database":"connected","redis":"connected"}
```
### 更新与回滚
更新代码后保留 `.env.production` 和命名卷,重新构建并滚动重建:
```bash
git pull --ff-only
docker compose --env-file .env.production -f docker/docker-compose.prod.yml build api
docker compose --env-file .env.production -f docker/docker-compose.prod.yml up -d
```
生产镜像应使用不可变的 `IMAGEFORGE_TAG`。回滚时把该值改回上一镜像标签,然后再次运行 `up -d`
### 反向代理
直接通过服务器地址访问时保持 `TRUST_PROXY_HEADERS=false`。只有当 8080 端口不对客户端开放、所有请求都经过可信反向代理时,才设置为 `true`,并由代理覆盖 `X-Forwarded-For``X-Forwarded-Proto`
代理至少需要:
```nginx ```nginx
events { location / {
worker_connections 1024; proxy_pass http://127.0.0.1:8080;
}
http {
include mime.types;
default_type application/octet-stream;
# 日志
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# 文件上传大小限制
client_max_body_size 100M;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript;
upstream backend {
server api:8080;
}
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# SSL 配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# 静态文件
location /static/ {
proxy_pass http://backend;
expires 30d;
add_header Cache-Control "public, immutable";
}
# WebSocket
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# API 和其他请求
location / {
proxy_pass http://backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} client_max_body_size 100m;
} proxy_read_timeout 300s;
} }
``` ```
### 部署步骤 ### 日志与备份
```bash ```bash
# 1. 创建 .env 文件 docker compose --env-file .env.production -f docker/docker-compose.prod.yml logs -f api worker
cat > .env << EOF
DB_PASSWORD=your-secure-db-password
JWT_SECRET=your-very-long-random-jwt-secret-at-least-32-chars
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
EOF
# 2. 获取 SSL 证书 docker compose --env-file .env.production -f docker/docker-compose.prod.yml \
sudo certbot certonly --standalone -d your-domain.com exec -T postgres pg_dump -U imageforge imageforge | gzip > imageforge.sql.gz
# 3. 构建并启动
docker-compose -f docker/docker-compose.prod.yml up -d --build
# 4. 查看日志
docker-compose -f docker/docker-compose.prod.yml logs -f api
``` ```
--- 除数据库外,还要备份 Compose 的 `uploads` 命名卷。恢复前停止 API 与 Worker避免数据库记录与文件卷产生时间差。
### 方案二Kubernetes大规模 ## 本地开发
`k8s/deployment.yaml`:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: imageforge
spec:
replicas: 3
selector:
matchLabels:
app: imageforge
template:
metadata:
labels:
app: imageforge
spec:
containers:
- name: imageforge
image: your-registry/imageforge:latest
ports:
- containerPort: 8080
env:
- name: IMAGEFORGE_ROLE
value: api
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: redis-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: jwt-secret
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: imageforge
spec:
selector:
app: imageforge
ports:
- port: 80
targetPort: 8080
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: imageforge
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- your-domain.com
secretName: imageforge-tls
rules:
- host: your-domain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: imageforge
port:
number: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: imageforge-worker
spec:
replicas: 2
selector:
matchLabels:
app: imageforge-worker
template:
metadata:
labels:
app: imageforge-worker
spec:
containers:
- name: imageforge-worker
image: your-registry/imageforge:latest
env:
- name: IMAGEFORGE_ROLE
value: worker
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: redis-url
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: imageforge-secrets
key: jwt-secret
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "4Gi"
cpu: "4000m"
```
---
## 监控与日志
### Prometheus 指标
应用暴露 `/metrics` 端点:
```rust
// 在代码中添加指标
use prometheus::{Counter, Histogram};
lazy_static! {
static ref COMPRESSION_REQUESTS: Counter = Counter::new(
"imageforge_compression_requests_total",
"Total number of compression requests"
).unwrap();
static ref COMPRESSION_DURATION: Histogram = Histogram::with_opts(
HistogramOpts::new(
"imageforge_compression_duration_seconds",
"Time spent compressing images"
)
).unwrap();
}
```
### Grafana 仪表板
监控项目:
- 请求量 / QPS
- 响应时间 P50/P95/P99
- 错误率
- 压缩任务队列长度
- CPU / 内存使用率
- 磁盘使用率
### 日志聚合
使用 ELK Stack 或 Loki
```yaml
# docker-compose 添加 Loki
loki:
image: grafana/loki:2.9.0
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
promtail:
image: grafana/promtail:2.9.0
volumes:
- /var/log:/var/log
- ./promtail-config.yml:/etc/promtail/config.yml
command: -config.file=/etc/promtail/config.yml
```
---
## 备份策略
### 数据库备份
```bash ```bash
#!/bin/bash cp .env.example .env
# backup.sh docker compose -f docker/docker-compose.dev.yml up -d
DATE=$(date +%Y%m%d_%H%M%S) # 终端 1
BACKUP_DIR=/backups IMAGEFORGE_ROLE=api cargo run
# PostgreSQL 备份 # 终端 2
docker exec postgres pg_dump -U imageforge imageforge | gzip > $BACKUP_DIR/db_$DATE.sql.gz IMAGEFORGE_ROLE=worker cargo run
# 保留最近 7 天的备份 # 终端 3
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +7 -delete cd frontend
npm ci
# 可选:上传到 S3 npm run dev
# aws s3 cp $BACKUP_DIR/db_$DATE.sql.gz s3://your-bucket/backups/
``` ```
添加到 crontab API 或 Worker 启动时会通过 SQLx 顺序执行尚未应用的迁移。迁移失败时进程退出,不会在不完整的数据库结构上继续提供服务。
## 压缩质量回归
基准工具会下载固定 Picsum 照片,分别生成 JPEG、PNG、WebP、AVIF 输入,再通过真实 HTTP API 测量目标达标率、格式签名、SSIM、PSNR、分辨率和耗时。
```bash ```bash
0 3 * * * /path/to/backup.sh python3 -m venv .venv-benchmark
. .venv-benchmark/bin/activate
pip install -r scripts/requirements-benchmark.txt
python scripts/benchmark_compression.py generate --output-dir .bench/corpus
IMAGEFORGE_BENCH_EMAIL=admin@example.com \
IMAGEFORGE_BENCH_PASSWORD='replace-me' \
python scripts/benchmark_compression.py run \
--base-url http://127.0.0.1:8080 \
--input-dir .bench/corpus \
--output-dir .bench/results \
--formats jpeg,webp,avif \
--rates 30,50,70 \
--strict
``` ```
### 上传文件备份 `--strict` 会在请求失败、输出格式不匹配或目标体积未达标时返回非零退出码。测试素材与结果位于 `.bench/`,不会提交到 Git。
如果使用本地存储,定期同步到 S3
```bash
aws s3 sync /app/uploads s3://your-bucket/uploads --delete
```
---
## 故障排查 ## 故障排查
### 常见问题
**1. 数据库连接失败**
```bash ```bash
# 检查 PostgreSQL 状态 # 容器与健康状态
docker-compose logs postgres docker compose --env-file .env.production -f docker/docker-compose.prod.yml ps
# 测试连接 # 最近日志
docker exec -it postgres psql -U imageforge -d imageforge -c "SELECT 1" docker compose --env-file .env.production -f docker/docker-compose.prod.yml logs --tail=200 api worker
```
**2. 压缩失败** # 数据库迁移状态
```bash docker compose --env-file .env.production -f docker/docker-compose.prod.yml \
# 检查应用日志 exec -T postgres psql -U imageforge -d imageforge \
docker-compose logs api | grep ERROR -c 'SELECT version, success FROM _sqlx_migrations ORDER BY version;'
docker-compose logs worker | grep ERROR
# 检查磁盘空间 # 主机资源
docker stats
df -h df -h
``` ```
**3. 内存不足** 若图片压缩长时间排队,先检查 CPU再下调 `IMAGE_PROCESSING_CONCURRENCY`;若 API 健康但批量任务不推进,检查 Worker 日志和 Redis 状态。
```bash
# 查看内存使用
docker stats
# 调整容器内存限制
```
**4. 上传超时**
```bash
# 检查 Nginx 配置
# client_max_body_size 和 proxy_read_timeout
```
### 健康检查端点
```
GET /health
{
"status": "healthy",
"database": "connected",
"redis": "connected",
"storage": "available",
"uptime": 3600
}
```

View File

@@ -20,7 +20,7 @@
<li>Base URL<code>https://ys.workyai.cn/api/v1</code></li> <li>Base URL<code>https://ys.workyai.cn/api/v1</code></li>
<li>认证方式<code>X-API-Key</code>推荐 <code>Authorization: Bearer &lt;token&gt;</code></li> <li>认证方式<code>X-API-Key</code>推荐 <code>Authorization: Bearer &lt;token&gt;</code></li>
<li>支持格式PNG / JPG / JPEG / WebP / AVIF / GIF静态/ BMP / TIFF / ICO支持 output_format 转码</li> <li>支持格式PNG / JPG / JPEG / WebP / AVIF / GIF静态/ BMP / TIFF / ICO支持 output_format 转码</li>
<li>压缩率<code>compression_rate</code> 1-100表示压缩后体积占原图比例100 为不压缩</li> <li>压缩率<code>compression_rate</code> 1-100JPEG/WebP/AVIF 以该比例为体积上限无损格式为尽力优化</li>
<li>计量成功压缩 1 个文件计 1 若体积未变小或压缩率为 100则不扣额度</li> <li>计量成功压缩 1 个文件计 1 若体积未变小或压缩率为 100则不扣额度</li>
</ul> </ul>
</div> </div>

View File

@@ -532,7 +532,9 @@ async function resendVerification() {
step="1" step="1"
class="w-full" class="w-full"
/> />
<div class="text-xs text-slate-500">数值越小压缩越强目标为压缩后体积占原图比例100% 为不压缩</div> <div class="text-xs text-slate-500">
数值越小压缩越强JPEG/WebP/AVIF 会以此为体积上限无损格式按安全方式优化并显示实际结果
</div>
</label> </label>
<!-- 目标大小模式 --> <!-- 目标大小模式 -->

View File

@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""Generate a real-photo corpus and benchmark ImageForge through its HTTP API."""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import statistics
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
from http.cookiejar import CookieJar
from pathlib import Path
from typing import Any
import numpy as np
from PIL import Image, ImageOps
CORPUS = (
("photo_1015_jpeg", 1015, "JPEG", "jpg"),
("photo_1016_png", 1016, "PNG", "png"),
("photo_1025_webp", 1025, "WEBP", "webp"),
("photo_1039_avif", 1039, "AVIF", "avif"),
)
FORMAT_EXTENSIONS = {"jpeg": "jpg", "webp": "webp", "avif": "avif"}
PIL_FORMATS = {"jpeg": "JPEG", "webp": "WEBP", "avif": "AVIF"}
def parse_csv_arg(value: str, cast: type = str) -> list[Any]:
return [cast(item.strip()) for item in value.split(",") if item.strip()]
def open_image(path: Path) -> Image.Image:
with Image.open(path) as image:
return ImageOps.exif_transpose(image).convert("RGB")
def download(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": "ImageForge benchmark/1.0"})
with urllib.request.urlopen(request, timeout=90) as response:
return response.read()
def generate_corpus(output_dir: Path, width: int, height: int) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
manifest: list[dict[str, Any]] = []
for name, image_id, image_format, extension in CORPUS:
source_url = f"https://picsum.photos/id/{image_id}/{width}/{height}.jpg"
source_bytes = download(source_url)
source_path = output_dir / f".{name}.source.jpg"
source_path.write_bytes(source_bytes)
image = open_image(source_path)
source_path.unlink()
# Normalize dimensions so format and content complexity, not resolution, drive comparisons.
image = ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS)
output_path = output_dir / f"{name}.{extension}"
if image_format == "JPEG":
image.save(output_path, format=image_format, quality=95, subsampling=0, optimize=True)
elif image_format == "PNG":
image.save(output_path, format=image_format, optimize=True, compress_level=9)
elif image_format == "WEBP":
image.save(output_path, format=image_format, lossless=True, method=6)
else:
image.save(output_path, format=image_format, quality=95, speed=6)
manifest.append(
{
"file": output_path.name,
"source_url": source_url,
"picsum_id": image_id,
"format": image_format.lower(),
"width": image.width,
"height": image.height,
"size_bytes": output_path.stat().st_size,
}
)
(output_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"generated {len(manifest)} images in {output_dir}")
def multipart_body(file_path: Path, fields: dict[str, str]) -> tuple[bytes, str]:
boundary = f"----imageforge-{uuid.uuid4().hex}"
chunks: list[bytes] = []
for name, value in fields.items():
chunks.extend(
(
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
value.encode(),
b"\r\n",
)
)
chunks.extend(
(
f"--{boundary}\r\n".encode(),
(
f'Content-Disposition: form-data; name="file"; '
f'filename="{file_path.name}"\r\n'
).encode(),
b"Content-Type: application/octet-stream\r\n\r\n",
file_path.read_bytes(),
b"\r\n",
f"--{boundary}--\r\n".encode(),
)
)
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
def request_json(
opener: urllib.request.OpenerDirector,
url: str,
data: bytes,
headers: dict[str, str],
timeout: int,
) -> dict[str, Any]:
request = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with opener.open(request, timeout=timeout) as response:
payload = json.load(response)
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {error.code}: {detail}") from error
if not payload.get("success") or "data" not in payload:
raise RuntimeError(f"unexpected API response: {payload}")
return payload["data"]
def login(
opener: urllib.request.OpenerDirector,
base_url: str,
email: str,
password: str,
timeout: int,
) -> str:
body = json.dumps({"email": email, "password": password}).encode()
data = request_json(
opener,
f"{base_url}/api/v1/auth/login",
body,
{"Content-Type": "application/json"},
timeout,
)
return str(data["token"])
def block_ssim(reference: np.ndarray, candidate: np.ndarray, block: int = 8) -> float:
ref = 0.2126 * reference[..., 0] + 0.7152 * reference[..., 1] + 0.0722 * reference[..., 2]
out = 0.2126 * candidate[..., 0] + 0.7152 * candidate[..., 1] + 0.0722 * candidate[..., 2]
height = (ref.shape[0] // block) * block
width = (ref.shape[1] // block) * block
if height == 0 or width == 0:
height, width, block = ref.shape[0], ref.shape[1], 1
def blocks(array: np.ndarray) -> np.ndarray:
return (
array[:height, :width]
.reshape(height // block, block, width // block, block)
.transpose(0, 2, 1, 3)
)
ref_blocks = blocks(ref)
out_blocks = blocks(out)
axes = (-1, -2)
ref_mean = ref_blocks.mean(axis=axes)
out_mean = out_blocks.mean(axis=axes)
ref_var = ref_blocks.var(axis=axes)
out_var = out_blocks.var(axis=axes)
covariance = ((ref_blocks - ref_mean[..., None, None]) * (out_blocks - out_mean[..., None, None])).mean(axis=axes)
c1 = (0.01 * 255.0) ** 2
c2 = (0.03 * 255.0) ** 2
numerator = (2 * ref_mean * out_mean + c1) * (2 * covariance + c2)
denominator = (ref_mean**2 + out_mean**2 + c1) * (ref_var + out_var + c2)
return float(np.mean(numerator / np.maximum(denominator, 1e-12)))
def image_metrics(reference_path: Path, output_path: Path) -> dict[str, Any]:
reference = open_image(reference_path)
with Image.open(output_path) as opened:
detected_format = (opened.format or "unknown").upper()
output = ImageOps.exif_transpose(opened).convert("RGB")
output_width, output_height = output.size
if output.size != reference.size:
output = output.resize(reference.size, Image.Resampling.LANCZOS)
ref_array = np.asarray(reference, dtype=np.float64)
out_array = np.asarray(output, dtype=np.float64)
mse = float(np.mean((ref_array - out_array) ** 2))
psnr = 99.0 if mse == 0 else 20.0 * math.log10(255.0 / math.sqrt(mse))
return {
"detected_format": detected_format,
"width": output_width,
"height": output_height,
"pixel_ratio_pct": output_width * output_height * 100.0 / (reference.width * reference.height),
"ssim": block_ssim(ref_array, out_array),
"psnr_db": psnr,
}
def summarize(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
groups: dict[tuple[str, int], list[dict[str, Any]]] = {}
for row in rows:
if row.get("error"):
continue
groups.setdefault((str(row["output_format"]), int(row["requested_rate"])), []).append(row)
summary: list[dict[str, Any]] = []
for (output_format, requested_rate), group in sorted(groups.items()):
summary.append(
{
"output_format": output_format,
"requested_rate": requested_rate,
"cases": len(group),
"target_met": sum(bool(row["target_met"]) for row in group),
"format_ok": sum(bool(row["format_ok"]) for row in group),
"mean_actual_rate_pct": statistics.mean(float(row["actual_rate_pct"]) for row in group),
"mean_saved_pct": statistics.mean(float(row["saved_pct"]) for row in group),
"mean_ssim": statistics.mean(float(row["ssim"]) for row in group),
"mean_psnr_db": statistics.mean(float(row["psnr_db"]) for row in group),
"mean_pixel_ratio_pct": statistics.mean(float(row["pixel_ratio_pct"]) for row in group),
"median_elapsed_ms": statistics.median(float(row["elapsed_ms"]) for row in group),
}
)
return summary
def benchmark(args: argparse.Namespace) -> int:
input_dir = Path(args.input_dir)
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
inputs = sorted(path for path in input_dir.iterdir() if path.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".avif"})
if not inputs:
raise RuntimeError(f"no benchmark images found in {input_dir}")
rates = parse_csv_arg(args.rates, int)
formats = [str(value).lower() for value in parse_csv_arg(args.formats)]
unsupported = sorted(set(formats) - set(FORMAT_EXTENSIONS))
if unsupported:
raise RuntimeError(f"unsupported output formats: {', '.join(unsupported)}")
base_url = args.base_url.rstrip("/")
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(CookieJar()))
token = args.token or os.getenv("IMAGEFORGE_BENCH_TOKEN", "")
if not token:
email = args.email or os.getenv("IMAGEFORGE_BENCH_EMAIL", "")
password = args.password or os.getenv("IMAGEFORGE_BENCH_PASSWORD", "")
if email and password:
token = login(opener, base_url, email, password, args.timeout)
auth_headers = {"Authorization": f"Bearer {token}"} if token else {}
rows: list[dict[str, Any]] = []
total = len(inputs) * len(formats) * len(rates)
case_number = 0
for input_path in inputs:
original_size = input_path.stat().st_size
for output_format in formats:
for rate in rates:
case_number += 1
output_path = output_dir / f"{input_path.stem}__{output_format}__r{rate}.{FORMAT_EXTENSIONS[output_format]}"
row: dict[str, Any] = {
"input": input_path.name,
"input_format": input_path.suffix.lower().lstrip("."),
"output_format": output_format,
"requested_rate": rate,
"original_size": original_size,
}
try:
body, content_type = multipart_body(
input_path,
{"compression_rate": str(rate), "output_format": output_format},
)
started = time.perf_counter()
data = request_json(
opener,
f"{base_url}/api/v1/compress",
body,
{**auth_headers, "Content-Type": content_type},
args.timeout,
)
elapsed_ms = (time.perf_counter() - started) * 1000.0
download_request = urllib.request.Request(
urllib.parse.urljoin(f"{base_url}/", str(data["download_url"]).lstrip("/")),
headers=auth_headers,
)
with opener.open(download_request, timeout=args.timeout) as response:
output_path.write_bytes(response.read())
compressed_size = output_path.stat().st_size
metrics = image_metrics(input_path, output_path)
tolerance_bytes = max(1024, int(original_size * 0.01))
target_bytes = original_size * rate / 100.0
row.update(
{
"compressed_size": compressed_size,
"actual_rate_pct": compressed_size * 100.0 / original_size,
"saved_pct": max(0.0, (original_size - compressed_size) * 100.0 / original_size),
"target_error_pct_points": compressed_size * 100.0 / original_size - rate,
"target_met": compressed_size <= target_bytes + tolerance_bytes,
"format_ok": metrics["detected_format"] == PIL_FORMATS[output_format],
"api_size_matches": int(data["compressed_size"]) == compressed_size,
"elapsed_ms": elapsed_ms,
**metrics,
"error": "",
}
)
except Exception as error: # Continue to expose the full failure matrix.
row["error"] = str(error)
rows.append(row)
status = "ERROR" if row.get("error") else f"{row['actual_rate_pct']:.1f}% SSIM={row['ssim']:.4f}"
print(f"[{case_number:02d}/{total:02d}] {input_path.name} -> {output_format} r{rate}: {status}", flush=True)
if args.delay:
time.sleep(args.delay)
summary = summarize(rows)
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"base_url": base_url,
"inputs": len(inputs),
"cases": len(rows),
"errors": sum(bool(row.get("error")) for row in rows),
"format_failures": sum(not bool(row.get("format_ok")) for row in rows if not row.get("error")),
"target_failures": sum(not bool(row.get("target_met")) for row in rows if not row.get("error")),
"summary": summary,
"results": rows,
}
(output_dir / "report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
fieldnames = sorted({key for row in rows for key in row})
with (output_dir / "results.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
print("\nformat rate target format-ok actual% saved% SSIM PSNR pixel% median-ms")
for item in summary:
print(
f"{item['output_format']:>6} {item['requested_rate']:>4} "
f"{item['target_met']}/{item['cases']} {item['format_ok']}/{item['cases']} "
f"{item['mean_actual_rate_pct']:>7.2f} {item['mean_saved_pct']:>6.2f} "
f"{item['mean_ssim']:.4f} {item['mean_psnr_db']:>5.2f} "
f"{item['mean_pixel_ratio_pct']:>6.2f} {item['median_elapsed_ms']:>9.1f}"
)
print(f"\nreport: {output_dir / 'report.json'}")
return 1 if args.strict and (report["errors"] or report["format_failures"] or report["target_failures"]) else 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
generate = subparsers.add_parser("generate", help="download and build the fixed real-photo corpus")
generate.add_argument("--output-dir", default=".bench/corpus")
generate.add_argument("--width", type=int, default=960)
generate.add_argument("--height", type=int, default=640)
run = subparsers.add_parser("run", help="benchmark a running ImageForge deployment")
run.add_argument("--base-url", default="http://127.0.0.1:8080")
run.add_argument("--input-dir", default=".bench/corpus")
run.add_argument("--output-dir", default=".bench/results")
run.add_argument("--formats", default="jpeg,webp,avif")
run.add_argument("--rates", default="30,50,70")
run.add_argument("--email", default="")
run.add_argument("--password", default="")
run.add_argument("--token", default="")
run.add_argument("--timeout", type=int, default=300)
run.add_argument("--delay", type=float, default=0.05)
run.add_argument("--strict", action="store_true")
return parser
def main() -> int:
args = build_parser().parse_args()
if args.command == "generate":
generate_corpus(Path(args.output_dir), args.width, args.height)
return 0
return benchmark(args)
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
raise SystemExit(130) from None
except Exception as error:
print(f"benchmark failed: {error}", file=sys.stderr)
raise SystemExit(1) from error

View File

@@ -0,0 +1,2 @@
numpy>=2,<3
Pillow>=11,<13

View File

@@ -4,7 +4,6 @@ use crate::state::AppState;
use image::codecs::bmp::BmpEncoder; use image::codecs::bmp::BmpEncoder;
use image::codecs::gif::{GifDecoder, GifEncoder}; use image::codecs::gif::{GifDecoder, GifEncoder};
use image::codecs::ico::IcoEncoder; use image::codecs::ico::IcoEncoder;
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder; use image::codecs::png::PngEncoder;
use image::codecs::tiff::TiffEncoder; use image::codecs::tiff::TiffEncoder;
use image::{AnimationDecoder, GenericImageView}; use image::{AnimationDecoder, GenericImageView};
@@ -14,12 +13,13 @@ use oxipng::StripChunks;
use rgb::FromSlice; use rgb::FromSlice;
use std::io::Cursor; use std::io::Cursor;
const TARGET_MIN_DIMENSION: u32 = 640; const TARGET_MIN_LONG_EDGE: u32 = 640;
const TARGET_MIN_SCALE: f64 = 0.55; const TARGET_MIN_SCALE: f64 = 0.55;
const TARGET_RESIZE_ATTEMPTS: usize = 5; const TARGET_RESIZE_ATTEMPTS: usize = 5;
const TARGET_SCALE_REFINEMENT_ATTEMPTS: usize = 3;
const JPEG_TARGET_MIN_QUALITY: u8 = 40; const JPEG_TARGET_MIN_QUALITY: u8 = 25;
const WEBP_TARGET_MIN_QUALITY: u8 = 42; const WEBP_TARGET_MIN_QUALITY: u8 = 30;
const AVIF_TARGET_MIN_QUALITY: u8 = 38; const AVIF_TARGET_MIN_QUALITY: u8 = 38;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -378,7 +378,7 @@ fn compress_image_bytes_sync(
output = apply_metadata(output, icc_profile, exif)?; output = apply_metadata(output, icc_profile, exif)?;
} }
if !resized && output.len() >= input.len() { if format_in == format_out && !resized && output.len() >= input.len() {
if preserve_metadata { if preserve_metadata {
return Ok(input); return Ok(input);
} }
@@ -475,10 +475,16 @@ fn encode_jpeg_with_quality(image: DynamicImage, quality: u8) -> Result<Vec<u8>,
} }
fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, AppError> { fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, AppError> {
let width = u16::try_from(w)
.map_err(|_| AppError::new(ErrorCode::InvalidImage, "JPEG 宽度不能超过 65535 像素"))?;
let height = u16::try_from(h)
.map_err(|_| AppError::new(ErrorCode::InvalidImage, "JPEG 高度不能超过 65535 像素"))?;
let mut out = Vec::new(); let mut out = Vec::new();
let mut encoder = JpegEncoder::new_with_quality(&mut out, quality); let mut encoder = jpeg_encoder::Encoder::new(&mut out, quality);
encoder.set_optimized_huffman_tables(true);
encoder.set_progressive(true);
encoder encoder
.encode(raw, w, h, ExtendedColorType::Rgb8) .encode(raw, width, height, jpeg_encoder::ColorType::Rgb)
.map_err(|err| { .map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err) AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err)
})?; })?;
@@ -573,36 +579,33 @@ where
F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>, F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>,
{ {
let (orig_w, orig_h) = image.dimensions(); let (orig_w, orig_h) = image.dimensions();
let min_w = ((orig_w as f64 * TARGET_MIN_SCALE).round() as u32) let long_edge = orig_w.max(orig_h);
.max(TARGET_MIN_DIMENSION.min(orig_w)) let long_edge_floor = TARGET_MIN_LONG_EDGE.min(long_edge) as f64 / long_edge as f64;
.max(1); let min_scale = TARGET_MIN_SCALE.max(long_edge_floor).min(1.0);
let min_h = ((orig_h as f64 * TARGET_MIN_SCALE).round() as u32)
.max(TARGET_MIN_DIMENSION.min(orig_h))
.max(1);
let mut scales = Vec::with_capacity(TARGET_RESIZE_ATTEMPTS + 1); let mut scales = Vec::with_capacity(TARGET_RESIZE_ATTEMPTS + 1);
scales.push(1.0); scales.push(1.0);
for step in 1..=TARGET_RESIZE_ATTEMPTS { for step in 1..=TARGET_RESIZE_ATTEMPTS {
let ratio = step as f64 / TARGET_RESIZE_ATTEMPTS as f64; let ratio = step as f64 / TARGET_RESIZE_ATTEMPTS as f64;
let scale = 1.0 - (1.0 - TARGET_MIN_SCALE) * ratio; scales.push(1.0 - (1.0 - min_scale) * ratio);
scales.push(scale.max(TARGET_MIN_SCALE));
} }
let mut best_under: Option<(Vec<u8>, u32, u32, u64)> = None; let mut best_over: Option<(Vec<u8>, u64, u64)> = None;
let mut best_over: Option<(Vec<u8>, u32, u32, u64)> = None; let mut previous_over_scale = 1.0;
let mut last_dimensions: Option<(u32, u32)> = None;
for scale in scales { for scale in scales {
let new_w = ((orig_w as f64 * scale).round() as u32).clamp(1, orig_w); let new_w = ((orig_w as f64 * scale).round() as u32).clamp(1, orig_w);
let new_h = ((orig_h as f64 * scale).round() as u32).clamp(1, orig_h); let new_h = ((orig_h as f64 * scale).round() as u32).clamp(1, orig_h);
if last_dimensions == Some((new_w, new_h)) {
if new_w < min_w || new_h < min_h {
continue; continue;
} }
last_dimensions = Some((new_w, new_h));
let resized = if new_w == orig_w && new_h == orig_h { let resized = if new_w == orig_w && new_h == orig_h {
image.clone() image.clone()
} else { } else {
image.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) image.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3)
}; };
let result = let result =
@@ -610,52 +613,69 @@ where
let result_size = result.len() as u64; let result_size = result.len() as u64;
if result_size <= target_size { if result_size <= target_size {
let should_update = match &best_under { if new_w == orig_w && new_h == orig_h {
None => true, return Ok(result);
Some((_bytes, best_w, best_h, best_size)) => {
let new_pixels = (new_w as u64).saturating_mul(new_h as u64);
let best_pixels = (*best_w as u64).saturating_mul(*best_h as u64);
new_pixels > best_pixels
|| (new_pixels == best_pixels && result_size > *best_size)
}
};
if should_update {
best_under = Some((result, new_w, new_h, result_size));
} }
if new_w == orig_w && new_h == orig_h && target_size.saturating_sub(result_size) <= 1024 // The first passing coarse scale has the highest resolution. Refine the
{ // boundary between it and the preceding failing scale before returning.
let mut best_under = result;
let mut under_scale = scale;
let mut over_scale = previous_over_scale;
let mut under_dimensions = (new_w, new_h);
for _ in 0..TARGET_SCALE_REFINEMENT_ATTEMPTS {
let candidate_scale = (under_scale + over_scale) / 2.0;
let candidate_w =
((orig_w as f64 * candidate_scale).round() as u32).clamp(1, orig_w);
let candidate_h =
((orig_h as f64 * candidate_scale).round() as u32).clamp(1, orig_h);
if (candidate_w, candidate_h) == under_dimensions {
break; break;
} }
let candidate = image.resize_exact(
candidate_w,
candidate_h,
image::imageops::FilterType::Lanczos3,
);
let candidate_result = encode_target_quality_with_image(
&candidate,
min_q,
max_q,
target_size,
&mut encode_fn,
)?;
if candidate_result.len() as u64 <= target_size {
best_under = candidate_result;
under_scale = candidate_scale;
under_dimensions = (candidate_w, candidate_h);
} else {
over_scale = candidate_scale;
}
}
return Ok(best_under);
} else { } else {
let should_update = match &best_over { let should_update = match &best_over {
None => true, None => true,
Some((_bytes, best_w, best_h, best_size)) => { Some((_bytes, best_size, best_pixels)) => {
let over = result_size.saturating_sub(target_size); let over = result_size.saturating_sub(target_size);
let best_over_by = best_size.saturating_sub(target_size); let best_over_by = best_size.saturating_sub(target_size);
if over < best_over_by {
true
} else if over == best_over_by {
let new_pixels = (new_w as u64).saturating_mul(new_h as u64); let new_pixels = (new_w as u64).saturating_mul(new_h as u64);
let best_pixels = (*best_w as u64).saturating_mul(*best_h as u64); over < best_over_by || (over == best_over_by && new_pixels > *best_pixels)
new_pixels > best_pixels
} else {
false
}
} }
}; };
if should_update { if should_update {
best_over = Some((result, new_w, new_h, result_size)); let pixels = (new_w as u64).saturating_mul(new_h as u64);
best_over = Some((result, result_size, pixels));
} }
previous_over_scale = scale;
} }
} }
if let Some((bytes, _, _, _)) = best_under { if let Some((bytes, _, _)) = best_over {
return Ok(bytes);
}
if let Some((bytes, _, _, _)) = best_over {
return Ok(bytes); return Ok(bytes);
} }
@@ -673,51 +693,34 @@ fn encode_target_quality_with_image<F>(
where where
F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>, F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>,
{ {
let mut best: Option<Vec<u8>> = None; // Start with the highest quality. If it already fits, no lower-quality
let mut best_diff = u64::MAX; // encodes can improve the result.
let mut best_is_under = false; let max_quality = encode_fn(image, max_q)?;
if max_quality.len() as u64 <= target_size || min_q == max_q {
let mut consider = |bytes: Vec<u8>| { return Ok(max_quality);
let size = bytes.len() as u64;
let is_under = size <= target_size;
let diff = size.abs_diff(target_size);
let should_update = match (best_is_under, is_under) {
(false, true) => true,
(true, false) => false,
_ => diff < best_diff,
};
if should_update {
best_diff = diff;
best_is_under = is_under;
best = Some(bytes);
}
};
consider(encode_fn(image, min_q)?);
if min_q != max_q {
consider(encode_fn(image, max_q)?);
} }
let mut low = min_q; let min_quality = encode_fn(image, min_q)?;
let mut high = max_q; if min_quality.len() as u64 > target_size {
for _ in 0..12 { return Ok(min_quality);
if low > high {
break;
} }
let mut best_under = min_quality;
let mut low = min_q.saturating_add(1);
let mut high = max_q.saturating_sub(1);
while low <= high {
let mid = (low + high) / 2; let mid = (low + high) / 2;
let bytes = encode_fn(image, mid)?; let bytes = encode_fn(image, mid)?;
let size = bytes.len() as u64; let size = bytes.len() as u64;
consider(bytes);
if size > target_size { if size > target_size {
high = mid.saturating_sub(1); high = mid.saturating_sub(1);
} else { } else {
best_under = bytes;
low = mid.saturating_add(1); low = mid.saturating_add(1);
} }
} }
best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败")) Ok(best_under)
} }
fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> { fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
@@ -765,6 +768,8 @@ fn encode_tiff(image: DynamicImage) -> Result<Vec<u8>, AppError> {
} }
fn encode_ico(image: DynamicImage) -> Result<Vec<u8>, AppError> { fn encode_ico(image: DynamicImage) -> Result<Vec<u8>, AppError> {
// A single ICO directory entry can represent at most 256x256 pixels.
let (image, _) = resize_if_needed(image, Some(256), Some(256));
let rgba = image.to_rgba8(); let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions(); let (w, h) = rgba.dimensions();
let mut out = Vec::new(); let mut out = Vec::new();
@@ -941,4 +946,61 @@ mod tests {
assert_eq!(target_size_from_rate(10_000, 55), 5_500); assert_eq!(target_size_from_rate(10_000, 55), 5_500);
assert_eq!(target_size_from_rate(10_000, 100), 10_000); assert_eq!(target_size_from_rate(10_000, 100), 10_000);
} }
#[test]
fn target_encoder_stops_when_full_resolution_meets_target() {
use std::cell::Cell;
let calls = Cell::new(0);
let image = DynamicImage::new_rgb8(800, 600);
let result = encode_with_auto_resize(image, 100, 40, 95, |_image, quality| {
calls.set(calls.get() + 1);
Ok(vec![0; quality as usize])
})
.unwrap();
assert_eq!(result.len(), 95);
assert_eq!(calls.get(), 1);
}
#[test]
fn target_encoder_can_reduce_a_landscape_at_the_long_edge_floor() {
let image = DynamicImage::new_rgb8(960, 640);
let result = encode_with_auto_resize(image, 40_000, 40, 40, |image, _quality| {
let (width, height) = image.dimensions();
Ok(vec![0; (width as usize * height as usize) / 10])
})
.unwrap();
assert!(result.len() <= 40_000);
}
#[test]
fn format_conversion_never_returns_the_original_encoding() {
let input = encode_png(DynamicImage::new_rgba8(10, 10), 100, false).unwrap();
let output = compress_image_bytes_sync(
input,
ImageFmt::Png,
ImageFmt::Bmp,
CompressionLevel::Medium,
None,
None,
None,
None,
false,
1_000_000,
)
.unwrap();
assert!(output.starts_with(b"BM"));
}
#[test]
fn ico_encoder_fits_large_images_within_the_format_limit() {
let output = encode_ico(DynamicImage::new_rgba8(960, 640)).unwrap();
let decoded = image::load_from_memory(&output).unwrap();
assert!(output.starts_with(b"\x00\x00\x01\x00"));
assert_eq!(decoded.dimensions(), (256, 171));
}
} }