fix: 限制图片视频处理内存峰值

This commit is contained in:
chenpei 2026-07-31 10:40:00 +08:00
parent 88c98cc081
commit e2f4911268
19 changed files with 476 additions and 187 deletions

View File

@ -100,7 +100,11 @@ export async function POST(request: NextRequest) {
// 读取图片
const buffer = await readFile(imagePath)
const metadata = await sharp(buffer).metadata()
const metadata = await sharp(buffer, {
limitInputPixels: 60_000_000,
sequentialRead: true,
failOn: 'error',
}).metadata()
console.log('[Regenerate Preview] Image size:', metadata.width, 'x', metadata.height)
// 切片处理

View File

@ -22,6 +22,10 @@ import {
} from '@/lib/standard-model-white-background'
import { normalizeStandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata'
import { uploadStandardModelWhiteBackground } from '@/lib/standard-model-white-background-service'
import {
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS,
WhiteBackgroundUploadValidationError,
} from '@/lib/standard-model-white-background-upload-guard'
export const runtime = 'nodejs'
@ -180,6 +184,22 @@ export async function POST(request: NextRequest) {
let idempotencyRecordId: string | null = null
try {
const contentLength = Number(request.headers.get('content-length'))
if (!Number.isFinite(contentLength) || contentLength <= 0) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 411 })
return finalizeResponse(
buildErrorResponse(411, 'CONTENT_LENGTH_REQUIRED', '必须提供有效的请求大小'),
trace,
)
}
if (contentLength > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxRequestBytes) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 413 })
return finalizeResponse(
buildErrorResponse(413, 'REQUEST_TOO_LARGE', '白底图上传请求过大'),
trace,
)
}
const formData = await request.formData()
const clientRequestId = String(formData.get('clientRequestId') || '').trim()
const barcode = String(formData.get('barcode') || '').trim()
@ -298,6 +318,14 @@ export async function POST(request: NextRequest) {
return finalizeResponse(buildErrorResponse(error.status, error.code, error.message), trace)
}
if (error instanceof WhiteBackgroundUploadValidationError) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(
buildErrorResponse(400, 'INVALID_IMAGE_UPLOAD', error.message),
trace,
)
}
const message = error instanceof Error ? error.message : '上传白底图失败'
console.error('[external/standard-model-white-background/by-barcode] POST error:', error)
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 500 })

View File

@ -24,6 +24,10 @@ import {
} from '@/lib/standard-model-white-background'
import { normalizeStandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata'
import { uploadStandardModelWhiteBackground } from '@/lib/standard-model-white-background-service'
import {
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS,
WhiteBackgroundUploadValidationError,
} from '@/lib/standard-model-white-background-upload-guard'
export const runtime = 'nodejs'
@ -574,6 +578,22 @@ export async function POST(request: NextRequest) {
let idempotencyRecordId: string | null = null
try {
const contentLength = Number(request.headers.get('content-length'))
if (!Number.isFinite(contentLength) || contentLength <= 0) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 411 })
return finalizeResponse(
buildErrorResponse(411, 'CONTENT_LENGTH_REQUIRED', '必须提供有效的请求大小'),
trace,
)
}
if (contentLength > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxRequestBytes) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 413 })
return finalizeResponse(
buildErrorResponse(413, 'REQUEST_TOO_LARGE', '白底图上传请求过大'),
trace,
)
}
const formData = await request.formData()
const clientRequestId = getFormText(formData, 'clientRequestId')
const materialType = getFormText(formData, 'materialType')
@ -746,6 +766,14 @@ export async function POST(request: NextRequest) {
return finalizeResponse(buildErrorResponse(error.status, error.code, error.message), trace)
}
if (error instanceof WhiteBackgroundUploadValidationError) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(
buildErrorResponse(400, 'INVALID_IMAGE_UPLOAD', error.message),
trace,
)
}
const message = error instanceof Error ? error.message : '上传白底图失败'
console.error('[external/standard-model-white-background/by-spu] POST error:', error)
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 500 })

View File

@ -426,10 +426,11 @@ export async function POST(request: NextRequest) {
console.log('[Upload] Preview image uploaded:', previewUrl);
}
// 处理其他预览图(作为展示图)- 并行处理以提高速度
// 处理其他预览图(作为展示图)。图片解码会使用 Sharp 原生内存,
// 这里必须串行处理,避免一个请求内同时解码多张大图。
// 详情页/详情模板:其他预览图也作为展示图处理
if (previewImages.length > 0 && !['模特'].includes(materialType)) {
console.log(`[Upload] Processing ${previewImages.length} preview images in parallel...`);
console.log(`[Upload] Processing ${previewImages.length} preview images sequentially...`);
// 验证所有图片大小
for (let i = 0; i < previewImages.length; i++) {
@ -441,8 +442,8 @@ export async function POST(request: NextRequest) {
}
}
// 并行处理所有预览图
const uploadPromises = previewImages.map(async (image, index) => {
for (let index = 0; index < previewImages.length; index += 1) {
const image = previewImages[index]
console.log(`[Upload] Processing preview image ${index + 2}/${previewImages.length + 1}...`);
const buffer = Buffer.from(await image.arrayBuffer());
@ -464,12 +465,8 @@ export async function POST(request: NextRequest) {
);
uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理
console.log(`[Upload] Preview image ${index + 2} uploaded:`, url);
return url;
});
// 等待所有预览图上传完成
const urls = await Promise.all(uploadPromises);
displayImages.push(...urls);
displayImages.push(url);
}
console.log(`[Upload] All ${previewImages.length} preview images uploaded successfully`);
}

View File

@ -0,0 +1,44 @@
# 生产图片/视频处理内存事故与处理记录
日期:2026-07-31
## 结论
这次“处理图片后整个服务崩掉”不是单一 Node.js 请求超出 6GB 容器限制,而是生产宿主机发生了全局内存耗尽(`global_oom`)。
真正的内存来源分两层:
1. 宿主机的 TrueNAS `middlewared` 长时间占用异常高的内存,历史峰值约 9.54GiB,并设置了 `oom_score_adj=-1000`,内核不会优先杀它。
2. 应用容器内的 `libvips`(Sharp 的底层图片库)和 `ffmpeg` 在处理大图/视频时产生原生内存尖峰。它们是把整机推过 OOM 阈值的触发器,内核随后杀掉了 `next-server` 或 MinIO。
因此,单纯把 Node.js 堆内存或应用容器上限调大不能解决问题,反而可能更快吃光宿主机余量。
## 已核实的生产时间线
| 时间(北京时间) | 生产证据 | 业务对应 |
| --- | --- | --- |
| 7 月 30 日 15:18 | `libvips worker` 触发 `global_oom`;内核杀掉 `next-server`,其匿名 RSS 约 1.405GiB | 同时段任务详情完成接口进入 `sharp.metadata()`、`sliceImage/compressImage`,Nginx 随后出现 502 |
| 7 月 30 日 16:18 | 同一应用容器同时有两个 `ffmpeg`,RSS 约 642MiB 和 372–408MiB;之后 MinIO 也被杀 | 同时段连续创建淘系视频任务;转码队列未配置时默认并发为 2 |
| 7 月 30 日 17:39 | `libvips worker` 再次触发 `global_oom`;同时还有一个约 325MiB 的 `ffmpeg`,最终 `next-server` 被杀 | 图片任务详情和抖音视频任务叠加,随后大量 502/504 |
三次事件都显示:宿主机没有 Swap,且是 `global_oom`,不是应用容器自己的 cgroup OOM。
## 本次代码处理
- Sharp 默认并发降为 1,限制 libvips 缓存和输入像素,并增加进程级图片处理门闩。
- 白底图内部和外部接口统一增加请求大小、单图大小、图片数量、格式、像素和动图门禁;服务层再次校验,防止新调用方绕过路由。
- 主上传的多张预览图改为串行处理,不再通过 `Promise.all` 同时解码多张图片。
- 任务详情预览在第一次 `sharp.metadata()` 前增加像素上限;长图限制最大尺寸和切片数。
- 视频转码队列固定为同一容器最多 1 个任务,FFmpeg 默认 1 线程,并限制进度日志缓存。
- 同批次关闭上传路径越界、任意文件删除、跨用户白底图替换等高危入口,并移除头像菜单中的网络诊断/缓存刷新入口。
## 仍需运维处理的风险
代码修复不能替代宿主机治理。生产运维需要单独完成:
- 查明 `middlewared` 从正常内存增长到数 GiB 的原因,并设置可用内存告警。
- 评估在维护窗口增加 Swap 或等效内存缓冲;这不是永久修复,只是降低瞬时 OOM 概率。
- 评估把 FFmpeg/Sharp 拆到独立 worker,避免媒体任务与 Next.js、MinIO 共用进程/容器。
- 持续采集容器 RSS、cgroup `memory.events`、宿主机可用内存和 OOM 日志。
本次排查未重启、杀进程、清理生产数据或修改生产部署脚本。

View File

@ -82,6 +82,7 @@ describe('external standard-model-white-background by barcode routes', () => {
headers: new Headers({
'X-API-Key': 'test-key',
'user-agent': 'jest',
'content-length': '1024',
}),
formData: jest.fn().mockResolvedValue(formData),
} as any
@ -154,6 +155,20 @@ describe('external standard-model-white-background by barcode routes', () => {
expect(uploadStandardModelWhiteBackground).not.toHaveBeenCalled()
})
test('缺少请求大小时在解析 multipart 前拒绝', async () => {
const request = {
headers: new Headers({ 'X-API-Key': 'test-key' }),
formData: jest.fn(),
} as any
const response = await uploadByBarcode(request)
const body = await response.json()
expect(response.status).toBe(411)
expect(body.error.code).toBe('CONTENT_LENGTH_REQUIRED')
expect(request.formData).not.toHaveBeenCalled()
})
test('cleanup 只按条码解析到的 SKU 调用 SKU 范围清理', async () => {
const response = await cleanupByBarcode(
createCleanupRequest({

View File

@ -95,6 +95,7 @@ describe('external standard-model-white-background by spu route', () => {
headers: new Headers({
'X-API-Key': 'test-key',
'user-agent': 'jest',
'content-length': '1024',
}),
formData: jest.fn().mockResolvedValue(formData),
} as any

View File

@ -0,0 +1,46 @@
const DEFAULT_MAX_CONCURRENT_IMAGE_JOBS = 1
const MAX_ALLOWED_IMAGE_JOBS = 2
function readMaxConcurrentImageJobs(): number {
const configured = Number.parseInt(process.env.IMAGE_PROCESSING_MAX_CONCURRENT || '', 10)
if (!Number.isFinite(configured)) {
return DEFAULT_MAX_CONCURRENT_IMAGE_JOBS
}
return Math.max(1, Math.min(MAX_ALLOWED_IMAGE_JOBS, configured))
}
const maxConcurrentImageJobs = readMaxConcurrentImageJobs()
let activeImageJobs = 0
const waitingImageJobs: Array<() => void> = []
async function acquireImageProcessingSlot(): Promise<void> {
if (activeImageJobs < maxConcurrentImageJobs) {
activeImageJobs += 1
return
}
await new Promise<void>((resolve) => waitingImageJobs.push(resolve))
activeImageJobs += 1
}
function releaseImageProcessingSlot(): void {
activeImageJobs = Math.max(0, activeImageJobs - 1)
const next = waitingImageJobs.shift()
if (next) {
next()
}
}
/**
* 跨请求限制 Sharp 任务数。单个请求内串行并不能阻止多个请求同时解码大图,
* 所以白底图、预览图和长图处理都必须共用这个进程级门闩。
*/
export async function withImageProcessingSlot<T>(task: () => Promise<T>): Promise<T> {
await acquireImageProcessingSlot()
try {
return await task()
} finally {
releaseImageProcessingSlot()
}
}

View File

@ -1,4 +1,32 @@
import sharp from 'sharp'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
configureSharpRuntime()
const IMAGE_PROCESSING_MAX_PIXELS = 60_000_000
const IMAGE_PROCESSING_MAX_DIMENSION = 20_000
const IMAGE_PROCESSING_MAX_SLICES = 60
function createSharpInput(buffer: Buffer) {
return sharp(buffer, {
limitInputPixels: IMAGE_PROCESSING_MAX_PIXELS,
sequentialRead: true,
failOn: 'error',
})
}
function assertProcessableMetadata(metadata: sharp.Metadata) {
if (!metadata.width || !metadata.height) {
throw new Error('无法获取图片尺寸')
}
if (metadata.width > IMAGE_PROCESSING_MAX_DIMENSION || metadata.height > IMAGE_PROCESSING_MAX_DIMENSION) {
throw new Error('图片尺寸过大')
}
if (Number(metadata.pages || 1) > 1) {
throw new Error('不支持动图或多页图片')
}
}
interface ProcessImageOptions {
maxSizeKB?: number // 最大文件大小(KB)
@ -24,63 +52,66 @@ export async function processImage(
format = 'webp', // 默认webP格式
} = options
try {
// 获取原始图片信息
const image = sharp(buffer)
const metadata = await image.metadata()
return withImageProcessingSlot(async () => {
try {
// 获取原始图片信息
const image = createSharpInput(buffer)
const metadata = await image.metadata()
assertProcessableMetadata(metadata)
// 计算新尺寸(保持比例,同时限制宽度和高度)
let width = metadata.width || maxWidth
let height = metadata.height || maxHeight
// 计算新尺寸(保持比例,同时限制宽度和高度)
let width = metadata.width || maxWidth
let height = metadata.height || maxHeight
// 如果宽度超过限制
if (width > maxWidth) {
const ratio = maxWidth / width
width = maxWidth
height = Math.round(height * ratio)
}
// 如果宽度超过限制
if (width > maxWidth) {
const ratio = maxWidth / width
width = maxWidth
height = Math.round(height * ratio)
}
// 如果高度超过限制,再次调整
if (height > maxHeight) {
const ratio = maxHeight / height
height = maxHeight
width = Math.round(width * ratio)
}
// 如果高度超过限制,再次调整
if (height > maxHeight) {
const ratio = maxHeight / height
height = maxHeight
width = Math.round(width * ratio)
}
// 优化:初始质量从80降到70,在保证视觉质量的前提下减少文件大小
let quality = 70
// 优化:初始质量从80降到70,在保证视觉质量的前提下减少文件大小
let quality = 70
let processedBuffer: Buffer
let processedBuffer: Buffer
// 第一次处理
processedBuffer = await image
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality })
.toBuffer()
// 如果文件还是太大,逐步降低质量
const maxBytes = maxSizeKB * 1024
while (processedBuffer.length > maxBytes && quality > 20) {
quality -= 10
processedBuffer = await sharp(buffer)
// 第一次处理
processedBuffer = await createSharpInput(buffer)
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality })
.toBuffer()
// 如果文件还是太大,逐步降低质量
const maxBytes = maxSizeKB * 1024
while (processedBuffer.length > maxBytes && quality > 20) {
quality -= 10
processedBuffer = await createSharpInput(buffer)
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality })
.toBuffer()
}
console.log(`图片处理完成: ${metadata.width}x${metadata.height} -> ${width}x${height}, ${(buffer.length / 1024).toFixed(2)}KB -> ${(processedBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`)
return processedBuffer
} catch (error) {
console.error('图片处理失败:', error)
throw new Error('图片处理失败')
}
console.log(`图片处理完成: ${metadata.width}x${metadata.height} -> ${width}x${height}, ${(buffer.length / 1024).toFixed(2)}KB -> ${(processedBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`)
return processedBuffer
} catch (error) {
console.error('图片处理失败:', error)
throw new Error('图片处理失败')
}
})
}
/**
@ -103,62 +134,50 @@ export async function sliceImage(
maxHeight: number = 1500,
maxSizeKB: number = 500
): Promise<Buffer[]> {
try {
const image = sharp(buffer)
const metadata = await image.metadata()
return withImageProcessingSlot(async () => {
try {
const image = createSharpInput(buffer)
const metadata = await image.metadata()
assertProcessableMetadata(metadata)
if (!metadata.width || !metadata.height) {
throw new Error('无法获取图片尺寸')
}
const { width, height } = metadata
const slices: Buffer[] = []
const maxBytes = maxSizeKB * 1024
const { width, height } = metadata
const slices: Buffer[] = []
const maxBytes = maxSizeKB * 1024
// 如果图片高度不超过maxHeight,直接返回原图(压缩处理)
if (height <= maxHeight) {
let quality = 90
let processedBuffer = await image
.webp({ quality })
.toBuffer()
// 如果文件还是太大,逐步降低质量
while (processedBuffer.length > maxBytes && quality > 20) {
quality -= 10
processedBuffer = await image
if (height <= maxHeight) {
let quality = 90
let processedBuffer = await createSharpInput(buffer)
.webp({ quality })
.toBuffer()
// 如果文件还是太大,逐步降低质量
while (processedBuffer.length > maxBytes && quality > 20) {
quality -= 10
processedBuffer = await createSharpInput(buffer)
.webp({ quality })
.toBuffer()
}
console.log(`图片无需切割: ${width}x${height}, 压缩到 ${(processedBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`)
return [processedBuffer]
}
console.log(`图片无需切割: ${width}x${height}, 压缩到 ${(processedBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`)
return [processedBuffer]
}
// 计算需要切割的片数
const sliceCount = Math.ceil(height / maxHeight)
console.log(`开始切割图片: ${width}x${height} -> ${sliceCount}个片段,每片限制${maxSizeKB}KB`)
const sliceCount = Math.ceil(height / maxHeight)
if (sliceCount > IMAGE_PROCESSING_MAX_SLICES) {
throw new Error(`图片过长,最多支持 ${IMAGE_PROCESSING_MAX_SLICES} 个切片`)
}
console.log(`开始切割图片: ${width}x${height} -> ${sliceCount}个片段,每片限制${maxSizeKB}KB`)
// 切割图片
for (let i = 0; i < sliceCount; i++) {
const top = i * maxHeight
const extractHeight = Math.min(maxHeight, height - top)
for (let i = 0; i < sliceCount; i++) {
const top = i * maxHeight
const extractHeight = Math.min(maxHeight, height - top)
// 初始质量
let quality = 90
let sliceBuffer = await sharp(buffer)
.extract({
left: 0,
top: top,
width: width,
height: extractHeight
})
.webp({ quality })
.toBuffer()
// 如果文件还是太大,逐步降低质量
while (sliceBuffer.length > maxBytes && quality > 20) {
quality -= 10
sliceBuffer = await sharp(buffer)
let quality = 90
let sliceBuffer = await createSharpInput(buffer)
.extract({
left: 0,
top: top,
@ -167,17 +186,31 @@ export async function sliceImage(
})
.webp({ quality })
.toBuffer()
// 如果文件还是太大,逐步降低质量
while (sliceBuffer.length > maxBytes && quality > 20) {
quality -= 10
sliceBuffer = await createSharpInput(buffer)
.extract({
left: 0,
top: top,
width: width,
height: extractHeight
})
.webp({ quality })
.toBuffer()
}
slices.push(sliceBuffer)
console.log(`切割片段 ${i + 1}/${sliceCount}: 高度 ${extractHeight}px, 大小 ${(sliceBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`)
}
slices.push(sliceBuffer)
console.log(`切割片段 ${i + 1}/${sliceCount}: 高度 ${extractHeight}px, 大小 ${(sliceBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`)
return slices
} catch (error) {
console.error('图片切割失败:', error)
throw new Error('图片切割失败')
}
return slices
} catch (error) {
console.error('图片切割失败:', error)
throw new Error('图片切割失败')
}
})
}
/**

View File

@ -11,6 +11,9 @@ import { readFile, unlink, writeFile, open } from 'fs/promises'
import { tmpdir } from 'os'
import path from 'path'
import sharp from 'sharp'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
configureSharpRuntime()
const execFileAsync = promisify(execFile)

View File

@ -1,4 +1,7 @@
import sharp from 'sharp'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
configureSharpRuntime()
export const PERFORMANCE_SURVEY_IMAGE_LIMITS = {
maxSourceCount: 60,

View File

@ -338,7 +338,15 @@ async function generatePreviewsFromAssets(
buffer = await readAssetBuffer(asset, type)
const metadata = await sharp.default(buffer).metadata()
if (buffer.length > 200 * 1024 * 1024) {
throw new Error(`预览源图片 ${asset.fileName} 不能超过 200MB`)
}
const metadata = await sharp.default(buffer, {
limitInputPixels: 60_000_000,
sequentialRead: true,
failOn: 'error',
}).metadata()
const shouldSlice = type === 'detail' && metadata.height && metadata.height > 1500
const buffers = shouldSlice
? await sliceImage(buffer, 1500, 200)

View File

@ -1,6 +1,9 @@
import { readPsd, initializeCanvas } from 'ag-psd'
import sharp from 'sharp'
import fs from 'fs/promises'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
configureSharpRuntime()
// 尝试初始化 canvas(如果可用)
try {

43
lib/sharp-runtime.ts Normal file
View File

@ -0,0 +1,43 @@
import sharp from 'sharp'
const DEFAULT_SHARP_CONCURRENCY = 1
const MAX_SHARP_CONCURRENCY = 2
const SHARP_CACHE_MEMORY_MB = 128
const SHARP_CACHE_ITEMS = 64
function readConcurrency(): number {
const configured = Number.parseInt(process.env.SHARP_CONCURRENCY || '', 10)
if (!Number.isFinite(configured)) {
return DEFAULT_SHARP_CONCURRENCY
}
return Math.max(1, Math.min(MAX_SHARP_CONCURRENCY, configured))
}
let configured = false
/**
* Sharp 的原生解码/编码内存不受 Node old-space 完全约束。
* 生产应用与 Next.js 共用一个容器,因此默认只允许一个 Sharp 任务,
* 并关闭磁盘缓存、限制内存缓存,避免并发图片请求把宿主机推入 OOM。
*/
export function configureSharpRuntime(): void {
if (configured) {
return
}
// 测试或极简运行时可能只提供 sharp() 工厂,没有全局调优方法;
// 这时保留原有行为,不让配置本身阻断业务。
if (typeof sharp.concurrency !== 'function' || typeof sharp.cache !== 'function') {
configured = true
return
}
sharp.concurrency(readConcurrency())
sharp.cache({
memory: SHARP_CACHE_MEMORY_MB,
items: SHARP_CACHE_ITEMS,
files: 0,
})
configured = true
}

View File

@ -37,6 +37,9 @@ import {
} from '@/lib/standard-model-white-background'
import type { StandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata'
import { buildWhiteBackgroundWebpVariants } from '@/lib/white-background-preview'
import {
assertWhiteBackgroundUploadFiles,
} from '@/lib/standard-model-white-background-upload-guard'
type WhiteBackgroundAssetRecord = WhiteBackgroundAssetLike & {
id: string
@ -197,6 +200,13 @@ export async function uploadStandardModelWhiteBackground(
throw new Error('请至少上传 1 张白底图')
}
// 服务层再次执行门禁,避免外部 API 或未来新增调用方绕过路由校验,
// 把超大、高像素或动图直接交给 Sharp。
await assertWhiteBackgroundUploadFiles({
fixedImages: input.fixedImages,
otherImages,
})
const existingAssets = await prisma.materialAsset.findMany({
where: {
materialId: input.materialId,

View File

@ -2,6 +2,10 @@ import sharp from 'sharp'
import type {
StandardModelWhiteBgKey,
} from '@/lib/standard-model-white-background'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
configureSharpRuntime()
export const STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS = {
maxOtherImages: 12,
@ -10,6 +14,7 @@ export const STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS = {
maxTotalBytes: 100 * 1024 * 1024,
maxPixelsPerImage: 40_000_000,
maxTotalPixels: 160_000_000,
maxRequestBytes: 105 * 1024 * 1024,
} as const
const ALLOWED_IMAGE_MIME_TYPES = new Set([
@ -111,47 +116,49 @@ export async function assertWhiteBackgroundUploadFiles(input: {
throw new WhiteBackgroundUploadValidationError('单次上传白底图总体积不能超过 100MB')
}
let totalPixels = 0
for (let index = 0; index < files.length; index++) {
const file = files[index]
const label = `图片「${file.name || index + 1}」`
await withImageProcessingSlot(async () => {
let totalPixels = 0
for (let index = 0; index < files.length; index++) {
const file = files[index]
const label = `图片「${file.name || index + 1}」`
try {
const buffer = Buffer.from(await file.arrayBuffer())
const metadata = await sharp(buffer, {
limitInputPixels: STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxPixelsPerImage,
sequentialRead: true,
failOn: 'error',
}).metadata()
try {
const buffer = Buffer.from(await file.arrayBuffer())
const metadata = await sharp(buffer, {
limitInputPixels: STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxPixelsPerImage,
sequentialRead: true,
failOn: 'error',
}).metadata()
if (
!metadata.width ||
!metadata.height ||
!metadata.format ||
!ALLOWED_IMAGE_FORMATS.has(metadata.format)
) {
throw new WhiteBackgroundUploadValidationError(`${label}不是支持的图片`)
}
if (Number(metadata.pages || 1) > 1) {
throw new WhiteBackgroundUploadValidationError(`${label}不支持动图或多页图片`)
}
if (
!metadata.width ||
!metadata.height ||
!metadata.format ||
!ALLOWED_IMAGE_FORMATS.has(metadata.format)
) {
throw new WhiteBackgroundUploadValidationError(`${label}不是支持的图片`)
}
if (Number(metadata.pages || 1) > 1) {
throw new WhiteBackgroundUploadValidationError(`${label}不支持动图或多页图片`)
}
const pixels = assertWhiteBackgroundImageDimensions(
metadata.width,
metadata.height,
label,
)
totalPixels += pixels
if (totalPixels > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalPixels) {
throw new WhiteBackgroundUploadValidationError('单次上传白底图总像素量过大')
const pixels = assertWhiteBackgroundImageDimensions(
metadata.width,
metadata.height,
label,
)
totalPixels += pixels
if (totalPixels > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalPixels) {
throw new WhiteBackgroundUploadValidationError('单次上传白底图总像素量过大')
}
} catch (error) {
if (error instanceof WhiteBackgroundUploadValidationError) {
throw error
}
throw new WhiteBackgroundUploadValidationError(
`${label}无法读取或像素总量过大`,
)
}
} catch (error) {
if (error instanceof WhiteBackgroundUploadValidationError) {
throw error
}
throw new WhiteBackgroundUploadValidationError(
`${label}无法读取或像素总量过大`,
)
}
}
})
}

View File

@ -38,6 +38,8 @@ export const TRANSCODE_CONFIG = {
timeout: parseInt(process.env.VIDEO_TRANSCODE_TIMEOUT || '3600000', 10),
// 临时文件大小限制(字节,默认 10GB)
maxTempFileSize: parseInt(process.env.VIDEO_MAX_TEMP_FILE_SIZE || '10737418240', 10),
// 与 Next.js 共用容器时限制 ffmpeg 线程,避免单个转码任务占满 CPU 和原生内存
threads: Math.max(1, Math.min(2, parseInt(process.env.VIDEO_TRANSCODE_THREADS || '1', 10) || 1)),
}
interface VideoTranscodeOptions {
@ -199,7 +201,7 @@ async function execFFmpegWithTimeout(
onProgress?: (progress: number) => void // progress: 0-100
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const childProcess = exec(command, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
const childProcess = exec(command, { maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
// FFmpeg 即使成功也可能返回非零退出码,需要检查 stderr 是否包含错误
if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) {
@ -217,7 +219,8 @@ async function execFFmpegWithTimeout(
if (onProgress && childProcess.stderr) {
childProcess.stderr.on('data', (chunk: Buffer) => {
stderrBuffer += chunk.toString()
// 只保留最近一段日志;FFmpeg 长任务会持续输出进度,不能让字符串无限增长。
stderrBuffer = `${stderrBuffer}${chunk.toString()}`.slice(-64 * 1024)
// 提取视频时长
const durationMatch = stderrBuffer.match(/Duration:\s*(\d{2}):(\d{2}):(\d{2})\.(\d{2})/)
@ -303,7 +306,7 @@ async function transcodeToPreview(
* -preset medium: 平衡质量和速度(H.265 转码时间约为 H.264 的 3-5 倍)
* -crf 22: 预览视频质量(中等-高质量,适合 4K 源视频缩放到 720P)
*/
const command = `ffmpeg -i "${inputPath}" -vf "scale=-2:${height}:flags=lanczos" -c:v libx265 -preset medium -crf ${TRANSCODE_CONFIG.previewCrf} -c:a aac -b:a 128k -movflags +faststart -y "${outputPath}"`
const command = `ffmpeg -threads ${TRANSCODE_CONFIG.threads} -i "${inputPath}" -vf "scale=-2:${height}:flags=lanczos" -c:v libx265 -preset medium -crf ${TRANSCODE_CONFIG.previewCrf} -c:a aac -b:a 128k -movflags +faststart -y "${outputPath}"`
const { stdout, stderr } = await execFFmpegWithTimeout(command, TRANSCODE_CONFIG.timeout, onProgress)
// ffmpeg 通常将进度信息输出到 stderr,这是正常的
@ -330,7 +333,7 @@ async function generateClip(
* -an: 移除音轨(卡片不需要声音,节省文件大小)
* 使用 libx265 (H.265) 编码器,相比 libx264 能以更低码率获得更好质量
*/
const command = `ffmpeg -i "${inputPath}" -t ${duration} -vf "scale=-2:${height}:flags=lanczos" -c:v libx265 -preset ultrafast -crf ${TRANSCODE_CONFIG.clipCrf} -an -movflags +faststart -y "${outputPath}"`
const command = `ffmpeg -threads ${TRANSCODE_CONFIG.threads} -i "${inputPath}" -t ${duration} -vf "scale=-2:${height}:flags=lanczos" -c:v libx265 -preset ultrafast -crf ${TRANSCODE_CONFIG.clipCrf} -an -movflags +faststart -y "${outputPath}"`
const { stdout, stderr } = await execFFmpegWithTimeout(command, TRANSCODE_CONFIG.timeout, onProgress)
if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) {
@ -349,7 +352,7 @@ async function extractCoverImage(
outputPath: string
): Promise<void> {
const height = TRANSCODE_CONFIG.previewResolution
const command = `ffmpeg -i "${videoPath}" -vf "scale=-2:${height}:flags=lanczos" -frames:v 1 -y "${outputPath}"`
const command = `ffmpeg -threads ${TRANSCODE_CONFIG.threads} -i "${videoPath}" -vf "scale=-2:${height}:flags=lanczos" -frames:v 1 -y "${outputPath}"`
const { stdout, stderr } = await execFFmpegWithTimeout(command)
if (stderr && !stderr.includes('frame=') && !stderr.includes('Duration:')) {

View File

@ -4,15 +4,13 @@
* 用于控制转码任务的并发数,充分利用多核 CPU
*
* 优化说明:
* - 默认并发数:2(充分利用多核 CPU)
* - 默认并发数:1(与 Next.js 共用容器,优先保护内存)
* - 可以通过环境变量配置:VIDEO_TRANSCODE_MAX_CONCURRENT
* - 支持队列管理,避免系统过载
* - 支持部署中断后自动恢复转码任务
*
* 并发数建议:
* - H.265 编码:2 并发(CPU 占用 60-80%)
* - H.265 编码:3-4 并发(高性能服务器,CPU 占用 80-100%)
* - H.264 编码:3-4 并发(CPU 占用 60-80%)
* - 当前生产容器只允许 1 个转码任务,避免 ffmpeg 与 Sharp 争抢宿主机内存。
*/
import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES } from './video-material-types'
@ -55,6 +53,8 @@ interface RecoverInterruptedTasksOptions {
videoTypes?: string[]
}
const SAFE_MAX_CONCURRENT_IN_SHARED_CONTAINER = 1
class VideoTranscodeQueue {
private queue: QueuedTask[] = []
private processing: Set<string> = new Set()
@ -64,21 +64,21 @@ class VideoTranscodeQueue {
private prisma: any = null // R1修复:Prisma 客户端延迟初始化
constructor(maxConcurrent?: number) {
// 默认并发数计算逻辑:
// 1. 优先使用环境变量配置
// 2. 如果没有配置,使用默认值 2(充分利用多核 CPU)
// 3. 可以根据实际负载情况调整到 3-4(高性能服务器)
// 当前 Next.js、Sharp 和 ffmpeg 共用一个容器;即使环境变量误配为 2,
// 也不能让两个 ffmpeg 同时进入同一容器。后续拆出独立 worker 后再放宽。
const envConcurrent = process.env.VIDEO_TRANSCODE_MAX_CONCURRENT
if (envConcurrent) {
this.maxConcurrent = parseInt(envConcurrent, 10) || 2
} else if (maxConcurrent !== undefined) {
this.maxConcurrent = maxConcurrent
} else {
// 默认值:2 并发(充分利用多核 CPU,提升 CPU 使用率)
// 可以根据实际负载情况调整到 3-4(高性能服务器)
// 注意:使用 H.265 (libx265) 时,2 并发可以达到 60-80% CPU 使用率
this.maxConcurrent = 2
}
const requestedConcurrent = envConcurrent
? parseInt(envConcurrent, 10)
: maxConcurrent
this.maxConcurrent = Math.max(
1,
Math.min(
SAFE_MAX_CONCURRENT_IN_SHARED_CONTAINER,
typeof requestedConcurrent === 'number' && Number.isFinite(requestedConcurrent)
? requestedConcurrent
: 1,
),
)
console.log(`[TranscodeQueue] Initialized with maxConcurrent: ${this.maxConcurrent}`)
}

View File

@ -6,6 +6,11 @@ import {
buildWhiteBackgroundPublicDisplayPathFromAssetPath,
} from '@/lib/public-display'
import { getWhiteBackgroundPreviewRelativePathFromAssetPath } from '@/lib/standard-model-white-background'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { configureSharpRuntime } from '@/lib/sharp-runtime'
import { STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS } from '@/lib/standard-model-white-background-upload-guard'
configureSharpRuntime()
export const WHITE_BACKGROUND_PREVIEW_MAX_DIMENSION = 1500
@ -38,26 +43,34 @@ function resolveAbsoluteLocalPath(fileUrl: string): string {
}
export async function buildWhiteBackgroundWebpVariants(buffer: Buffer) {
const original = await sharp(buffer)
.rotate()
.webp(WHITE_BACKGROUND_ORIGINAL_WEBP_OPTIONS)
.toBuffer()
return withImageProcessingSlot(async () => {
const sharpOptions = {
limitInputPixels: STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxPixelsPerImage,
sequentialRead: true,
failOn: 'error' as const,
}
const preview = await sharp(buffer)
.rotate()
.resize({
width: WHITE_BACKGROUND_PREVIEW_MAX_DIMENSION,
height: WHITE_BACKGROUND_PREVIEW_MAX_DIMENSION,
fit: 'inside',
withoutEnlargement: true,
})
.webp(WHITE_BACKGROUND_PREVIEW_WEBP_OPTIONS)
.toBuffer()
const original = await sharp(buffer, sharpOptions)
.rotate()
.webp(WHITE_BACKGROUND_ORIGINAL_WEBP_OPTIONS)
.toBuffer()
return {
original,
preview,
}
const preview = await sharp(buffer, sharpOptions)
.rotate()
.resize({
width: WHITE_BACKGROUND_PREVIEW_MAX_DIMENSION,
height: WHITE_BACKGROUND_PREVIEW_MAX_DIMENSION,
fit: 'inside',
withoutEnlargement: true,
})
.webp(WHITE_BACKGROUND_PREVIEW_WEBP_OPTIONS)
.toBuffer()
return {
original,
preview,
}
})
}
export function resolveWhiteBackgroundPreviewFileUrl(fileUrl?: string | null): string | null {