feat: optimize compression and production deployment
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.bench
|
||||||
target
|
target
|
||||||
uploads
|
uploads
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
17
Cargo.lock
generated
@@ -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]]
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(GIF 仅静态,支持格式转换)
|
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(GIF 仅静态,支持格式转换)
|
||||||
- **批量处理**:支持多图片同时上传和处理
|
- **批量处理**:支持多图片同时上传和处理
|
||||||
- **压缩率**:1-100(数值越小压缩越强,100 为不压缩)
|
- **压缩率**:1-100(JPEG/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)
|
||||||
|
|||||||
45
docker/.env.production.example
Normal file
45
docker/.env.production.example
Normal 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
|
||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
128
docker/docker-compose.prod.yml
Normal file
128
docker/docker-compose.prod.yml
Normal 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:
|
||||||
@@ -272,9 +272,9 @@ Idempotency-Key: <key> # 建议
|
|||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|---|---|---:|---|
|
|---|---|---:|---|
|
||||||
| `file` | File | 是 | 图片文件 |
|
| `file` | File | 是 | 图片文件 |
|
||||||
| `compression_rate` | Integer | 否 | 压缩率 1-100(压缩后体积占原图比例,数值越小压缩越强,100 表示不压缩),优先级高于 `level` |
|
| `compression_rate` | Integer | 否 | 压缩率 1-100;JPEG/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-100;JPEG/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`) |
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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 <token></code></li>
|
<li>认证方式:<code>X-API-Key</code>(推荐)或 <code>Authorization: Bearer <token></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-100;JPEG/WebP/AVIF 以该比例为体积上限,无损格式为尽力优化</li>
|
||||||
<li>计量:成功压缩 1 个文件计 1 次;若体积未变小或压缩率为 100,则不扣额度</li>
|
<li>计量:成功压缩 1 个文件计 1 次;若体积未变小或压缩率为 100,则不扣额度</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<!-- 目标大小模式 -->
|
<!-- 目标大小模式 -->
|
||||||
|
|||||||
404
scripts/benchmark_compression.py
Normal file
404
scripts/benchmark_compression.py
Normal 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
|
||||||
2
scripts/requirements-benchmark.txt
Normal file
2
scripts/requirements-benchmark.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
numpy>=2,<3
|
||||||
|
Pillow>=11,<13
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user