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
.env
.env.*
.bench
target
uploads
frontend/node_modules

1
.gitignore vendored
View File

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

17
Cargo.lock generated
View File

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

View File

@@ -42,6 +42,7 @@ aes-gcm = "0.10"
# Keep image-rs limited to formats exposed by the API. AVIF decoding uses
# 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"] }
jpeg-encoder = "0.7"
oxipng = "9"
ravif = { version = "0.11", default-features = false, features = ["threading"] }
webp = { version = "0.3", default-features = false }

View File

@@ -8,7 +8,7 @@
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICOGIF 仅静态,支持格式转换)
- **批量处理**:支持多图片同时上传和处理
- **压缩率**1-100数值越小压缩越强100 为不压缩
- **压缩率**1-100JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化
- **用户系统**注册、登录、API Key 管理
- **计费与用量**:套餐/订阅/配额/发票
- **管理员后台**:用户管理、系统监控、配置管理
@@ -84,7 +84,7 @@ imageforge/
- Rust建议使用最新 stable当前依赖链要求较新的 Rust建议 `>= 1.85`
- PostgreSQL 16+
- Redis 7+
- Node.js 20+(前端构建)
- Node.js 22+(前端构建)
### 本地开发
@@ -107,10 +107,14 @@ cd frontend && npm run dev
### Docker 部署
```bash
# 该 compose 仅包含 postgres/redis服务本体请按 docs/deployment.md 构建运行
docker compose -f docker/docker-compose.dev.yml up -d
cp docker/.env.production.example .env.production
# 修改全部密钥与 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)

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
@@ -14,35 +16,45 @@ COPY src ./src
COPY migrations ./migrations
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
COPY frontend/package*.json ./
RUN npm ci
RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci
COPY frontend ./
RUN npm run build
FROM debian:bookworm-slim
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libdav1d6 \
curl \
libdav1d7 \
&& 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
COPY --from=builder /app/target/release/imageforge ./imageforge
COPY --from=builder /app/imageforge ./imageforge
COPY --from=frontend-builder /app/frontend/dist ./static
COPY migrations ./migrations
RUN mkdir -p uploads
RUN mkdir -p uploads \
&& chown imageforge:imageforge uploads
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
USER 10001:10001
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 | 是 | 图片文件 |
| `compression_rate` | Integer | 否 | 压缩率 1-100(压缩后体积占原图比例,数值越小压缩越强100 表示不压缩优先级高于 `level` |
| `compression_rate` | Integer | 否 | 压缩率 1-100JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化100 表示不压缩优先级高于 `level` |
| `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_height` | Integer | 否 | 最大高度(等比缩放) |
| `target_size_bytes` | Integer | 否 | 目标体积(字节),仅 `jpeg/webp/avif` 输出支持;会优先保清晰度并在必要时小幅缩放 |
@@ -340,7 +340,7 @@ Idempotency-Key: <key> # 建议
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `files[]` | File[] | 是 | 图片文件数组(上限由套餐决定) |
| `compression_rate` | Integer | 否 | 压缩率 1-100(压缩后体积占原图比例,数值越小压缩越强100 表示不压缩优先级高于 `level` |
| `compression_rate` | Integer | 否 | 压缩率 1-100JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化100 表示不压缩优先级高于 `level` |
| `level` | String | 否 | `high` / `medium` / `low`(兼容参数) |
| `output_format` | String | 否 | 输出格式:`png/jpeg/webp/avif/gif/bmp/tiff/ico`(默认保持原格式) |
| `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`:
```yaml
version: '3.8'
- Linux x86_64
- Docker Engine 26+ 与 Docker Compose 2.20+
- 最低 2 核 CPU、4GB 内存;启用 AVIF 和独立 Worker 时建议 4 核、8GB 内存
- 首次构建可访问 Docker Hub 与 crates.io
services:
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
Debian 13、4 核 CPU、8GB 内存的实测起始值为 `WORKER_CONCURRENCY=2``IMAGE_PROCESSING_CONCURRENCY=2`。AVIF 是 CPU 密集型编码,不要直接把并发设置为 CPU 核数的数倍。
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:
image: postgres:16-alpine
environment:
POSTGRES_USER: imageforge
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: imageforge
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
```bash
cp docker/.env.production.example .env.production
chmod 600 .env.production
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
# 分别生成 POSTGRES_PASSWORD、JWT_SECRET、API_KEY_PEPPER 和管理员密码。
# hex 不包含数据库 URL 与 .env 需要转义的保留字符。
openssl rand -hex 32
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- api
restart: unless-stopped
# 编辑公开地址和全部 replace-with-* 值后检查配置。
docker compose \
--env-file .env.production \
-f docker/docker-compose.prod.yml \
config --quiet
volumes:
uploads:
postgres_data:
redis_data:
docker compose \
--env-file .env.production \
-f docker/docker-compose.prod.yml \
up -d --build
```
`docker/Dockerfile`:
```dockerfile
FROM rust:1.92-bookworm AS builder
API 健康后 Worker 才会启动,避免两个进程在首次部署时同时执行迁移。
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
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"]
```bash
docker compose --env-file .env.production -f docker/docker-compose.prod.yml ps
curl --fail http://127.0.0.1:8080/health
```
`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
events {
worker_connections 1024;
}
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 X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100m;
proxy_read_timeout 300s;
}
```
### 部署步骤
### 日志与备份
```bash
# 1. 创建 .env 文件
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
docker compose --env-file .env.production -f docker/docker-compose.prod.yml logs -f api worker
# 2. 获取 SSL 证书
sudo certbot certonly --standalone -d your-domain.com
# 3. 构建并启动
docker-compose -f docker/docker-compose.prod.yml up -d --build
# 4. 查看日志
docker-compose -f docker/docker-compose.prod.yml logs -f api
docker compose --env-file .env.production -f docker/docker-compose.prod.yml \
exec -T postgres pg_dump -U imageforge imageforge | gzip > imageforge.sql.gz
```
---
除数据库外,还要备份 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
#!/bin/bash
# backup.sh
cp .env.example .env
docker compose -f docker/docker-compose.dev.yml up -d
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/backups
# 终端 1
IMAGEFORGE_ROLE=api cargo run
# PostgreSQL 备份
docker exec postgres pg_dump -U imageforge imageforge | gzip > $BACKUP_DIR/db_$DATE.sql.gz
# 终端 2
IMAGEFORGE_ROLE=worker cargo run
# 保留最近 7 天的备份
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +7 -delete
# 可选:上传到 S3
# aws s3 cp $BACKUP_DIR/db_$DATE.sql.gz s3://your-bucket/backups/
# 终端 3
cd frontend
npm ci
npm run dev
```
添加到 crontab
API 或 Worker 启动时会通过 SQLx 顺序执行尚未应用的迁移。迁移失败时进程退出,不会在不完整的数据库结构上继续提供服务。
## 压缩质量回归
基准工具会下载固定 Picsum 照片,分别生成 JPEG、PNG、WebP、AVIF 输入,再通过真实 HTTP API 测量目标达标率、格式签名、SSIM、PSNR、分辨率和耗时。
```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
```
### 上传文件备份
如果使用本地存储,定期同步到 S3
```bash
aws s3 sync /app/uploads s3://your-bucket/uploads --delete
```
---
`--strict` 会在请求失败、输出格式不匹配或目标体积未达标时返回非零退出码。测试素材与结果位于 `.bench/`,不会提交到 Git。
## 故障排查
### 常见问题
**1. 数据库连接失败**
```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 logs api | grep ERROR
docker-compose logs worker | grep ERROR
# 数据库迁移状态
docker compose --env-file .env.production -f docker/docker-compose.prod.yml \
exec -T postgres psql -U imageforge -d imageforge \
-c 'SELECT version, success FROM _sqlx_migrations ORDER BY version;'
# 检查磁盘空间
# 主机资源
docker stats
df -h
```
**3. 内存不足**
```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
}
```
若图片压缩长时间排队,先检查 CPU再下调 `IMAGE_PROCESSING_CONCURRENCY`;若 API 健康但批量任务不推进,检查 Worker 日志和 Redis 状态。

View File

@@ -20,7 +20,7 @@
<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>支持格式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>
</ul>
</div>

View File

@@ -532,7 +532,9 @@ async function resendVerification() {
step="1"
class="w-full"
/>
<div class="text-xs text-slate-500">数值越小压缩越强目标为压缩后体积占原图比例100% 为不压缩</div>
<div class="text-xs text-slate-500">
数值越小压缩越强JPEG/WebP/AVIF 会以此为体积上限无损格式按安全方式优化并显示实际结果
</div>
</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::gif::{GifDecoder, GifEncoder};
use image::codecs::ico::IcoEncoder;
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
use image::codecs::tiff::TiffEncoder;
use image::{AnimationDecoder, GenericImageView};
@@ -14,12 +13,13 @@ use oxipng::StripChunks;
use rgb::FromSlice;
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_RESIZE_ATTEMPTS: usize = 5;
const TARGET_SCALE_REFINEMENT_ATTEMPTS: usize = 3;
const JPEG_TARGET_MIN_QUALITY: u8 = 40;
const WEBP_TARGET_MIN_QUALITY: u8 = 42;
const JPEG_TARGET_MIN_QUALITY: u8 = 25;
const WEBP_TARGET_MIN_QUALITY: u8 = 30;
const AVIF_TARGET_MIN_QUALITY: u8 = 38;
#[derive(Debug, Clone, Copy)]
@@ -378,7 +378,7 @@ fn compress_image_bytes_sync(
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 {
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> {
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 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
.encode(raw, w, h, ExtendedColorType::Rgb8)
.encode(raw, width, height, jpeg_encoder::ColorType::Rgb)
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err)
})?;
@@ -573,36 +579,33 @@ where
F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>,
{
let (orig_w, orig_h) = image.dimensions();
let min_w = ((orig_w as f64 * TARGET_MIN_SCALE).round() as u32)
.max(TARGET_MIN_DIMENSION.min(orig_w))
.max(1);
let min_h = ((orig_h as f64 * TARGET_MIN_SCALE).round() as u32)
.max(TARGET_MIN_DIMENSION.min(orig_h))
.max(1);
let long_edge = orig_w.max(orig_h);
let long_edge_floor = TARGET_MIN_LONG_EDGE.min(long_edge) as f64 / long_edge as f64;
let min_scale = TARGET_MIN_SCALE.max(long_edge_floor).min(1.0);
let mut scales = Vec::with_capacity(TARGET_RESIZE_ATTEMPTS + 1);
scales.push(1.0);
for step in 1..=TARGET_RESIZE_ATTEMPTS {
let ratio = step as f64 / TARGET_RESIZE_ATTEMPTS as f64;
let scale = 1.0 - (1.0 - TARGET_MIN_SCALE) * ratio;
scales.push(scale.max(TARGET_MIN_SCALE));
scales.push(1.0 - (1.0 - min_scale) * ratio);
}
let mut best_under: Option<(Vec<u8>, u32, u32, u64)> = None;
let mut best_over: Option<(Vec<u8>, u32, u32, u64)> = None;
let mut best_over: Option<(Vec<u8>, u64, u64)> = None;
let mut previous_over_scale = 1.0;
let mut last_dimensions: Option<(u32, u32)> = None;
for scale in scales {
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);
if new_w < min_w || new_h < min_h {
if last_dimensions == Some((new_w, new_h)) {
continue;
}
last_dimensions = Some((new_w, new_h));
let resized = if new_w == orig_w && new_h == orig_h {
image.clone()
} else {
image.resize(new_w, new_h, image::imageops::FilterType::Lanczos3)
image.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3)
};
let result =
@@ -610,52 +613,69 @@ where
let result_size = result.len() as u64;
if result_size <= target_size {
let should_update = match &best_under {
None => true,
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 new_w == orig_w && new_h == orig_h {
return Ok(result);
}
// 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;
}
};
if should_update {
best_under = Some((result, new_w, new_h, result_size));
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;
}
}
if new_w == orig_w && new_h == orig_h && target_size.saturating_sub(result_size) <= 1024
{
break;
}
return Ok(best_under);
} else {
let should_update = match &best_over {
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 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 best_pixels = (*best_w as u64).saturating_mul(*best_h as u64);
new_pixels > best_pixels
} else {
false
}
let new_pixels = (new_w as u64).saturating_mul(new_h as u64);
over < best_over_by || (over == best_over_by && new_pixels > *best_pixels)
}
};
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 {
return Ok(bytes);
}
if let Some((bytes, _, _, _)) = best_over {
if let Some((bytes, _, _)) = best_over {
return Ok(bytes);
}
@@ -673,51 +693,34 @@ fn encode_target_quality_with_image<F>(
where
F: FnMut(&DynamicImage, u8) -> Result<Vec<u8>, AppError>,
{
let mut best: Option<Vec<u8>> = None;
let mut best_diff = u64::MAX;
let mut best_is_under = false;
let mut consider = |bytes: Vec<u8>| {
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)?);
// Start with the highest quality. If it already fits, no lower-quality
// encodes can improve the result.
let max_quality = encode_fn(image, max_q)?;
if max_quality.len() as u64 <= target_size || min_q == max_q {
return Ok(max_quality);
}
let mut low = min_q;
let mut high = max_q;
for _ in 0..12 {
if low > high {
break;
}
let min_quality = encode_fn(image, min_q)?;
if min_quality.len() as u64 > target_size {
return Ok(min_quality);
}
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 bytes = encode_fn(image, mid)?;
let size = bytes.len() as u64;
consider(bytes);
if size > target_size {
high = mid.saturating_sub(1);
} else {
best_under = bytes;
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> {
@@ -765,6 +768,8 @@ fn encode_tiff(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 (w, h) = rgba.dimensions();
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, 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));
}
}