import { createHash, randomBytes } from 'node:crypto' import { promises as fs } from 'node:fs' import path from 'node:path' import sharp from 'sharp' import type { ImageFileItem, ProcessOptions, ProcessProgress, ProcessSummary, } from '../../src/shared/types' export const IMAGE_INPUT_EXTENSIONS = [ 'jpg', 'jpeg', 'jpe', 'jfif', 'png', 'webp', 'gif', 'tif', 'tiff', 'avif', 'svg', ] const IMAGE_EXTENSIONS = new Set(IMAGE_INPUT_EXTENSIONS.map((extension) => `.${extension}`)) const SOF_MARKERS = new Set([ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, ]) export class ImageProcessor { private cancelled = false cancel(): void { this.cancelled = true } async inspect(inputPaths: string[]): Promise { const files = await collectImagePaths(inputPaths) return Promise.all(files.map(async (filePath) => { const stat = await fs.stat(filePath) try { const metadata = await sharp(filePath).metadata() return { id: fileId(filePath), path: filePath, name: path.basename(filePath), size: stat.size, width: metadata.width ?? null, height: metadata.height ?? null, status: 'pending' as const, } } catch (error) { return { id: fileId(filePath), path: filePath, name: path.basename(filePath), size: stat.size, width: null, height: null, status: 'failed' as const, error: error instanceof Error ? error.message : '无法读取图片', } } })) } async process( options: ProcessOptions, onProgress: (progress: ProcessProgress) => void, ): Promise { validateOptions(options) this.cancelled = false let completed = 0 let failed = 0 for (let index = 0; index < options.paths.length; index++) { const inputPath = options.paths[index] if (this.cancelled) { break } const item = await inspectSingle(inputPath) item.status = 'processing' onProgress(progressFor(index, options.paths.length, item, false)) try { item.outputPath = await processSingleImage(inputPath, options, () => this.cancelled) item.status = this.cancelled ? 'cancelled' : 'completed' if (item.status === 'completed') { completed += 1 } } catch (error) { item.status = this.cancelled ? 'cancelled' : 'failed' item.error = error instanceof Error ? error.message : '处理失败' if (!this.cancelled) { failed += 1 } } onProgress(progressFor(index + 1, options.paths.length, item, this.cancelled)) } return { total: options.paths.length, completed, failed, cancelled: this.cancelled, } } } export async function processSingleImage( inputPath: string, options: ProcessOptions, isCancelled: () => boolean = () => false, ): Promise { const metadata = await sharp(inputPath, { page: 0, pages: 1 }).metadata() if (!metadata.width || !metadata.height || metadata.height <= 2) { throw new Error('图片尺寸过小,无法执行 AI 混淆处理') } const encoded = await sharp(inputPath, { page: 0, pages: 1 }) .flatten({ background: '#ffffff' }) .extract({ left: 0, top: 0, width: metadata.width, height: metadata.height - 2 }) .jpeg({ quality: 95 }) .toBuffer() const heightRestored = restoreSofHeight(encoded, metadata.height) const withoutEoi = removeLastEoi(heightRestored) const outputPath = buildOutputPath(inputPath, options) const temporaryPath = `${outputPath}.${process.pid}.tmp` await fs.mkdir(path.dirname(outputPath), { recursive: true }) try { await fs.writeFile(temporaryPath, withoutEoi) let remaining = Math.floor(options.junkSizeMb * 1024 * 1024) if (remaining > 0) { const handle = await fs.open(temporaryPath, 'a') try { while (remaining > 0) { if (isCancelled()) { throw new Error('处理已取消') } const chunkSize = Math.min(remaining, 1024 * 1024) await handle.write(randomBytes(chunkSize)) remaining -= chunkSize } } finally { await handle.close() } } await fs.rm(outputPath, { force: true }) await fs.rename(temporaryPath, outputPath) return outputPath } finally { await fs.rm(temporaryPath, { force: true }) } } export function restoreSofHeight(buffer: Buffer, originalHeight: number): Buffer { const result = Buffer.from(buffer) let index = 0 while (index < result.length - 1) { if (result[index] !== 0xff) { index += 1 continue } let markerIndex = index + 1 while (markerIndex < result.length && result[markerIndex] === 0xff) { markerIndex += 1 } const marker = result[markerIndex] if (SOF_MARKERS.has(marker)) { const heightOffset = markerIndex + 4 if (heightOffset + 1 >= result.length) { throw new Error('JPEG SOF 高度字段不完整') } result[heightOffset] = (originalHeight >> 8) & 0xff result[heightOffset + 1] = originalHeight & 0xff return result } if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { index = markerIndex + 1 continue } if (markerIndex + 2 >= result.length) { break } const segmentLength = result.readUInt16BE(markerIndex + 1) if (segmentLength < 2) { break } index = markerIndex + 1 + segmentLength } throw new Error('未找到 JPEG SOF 图片尺寸段') } export function removeLastEoi(buffer: Buffer): Buffer { for (let index = buffer.length - 1; index > 0; index--) { if (buffer[index - 1] === 0xff && buffer[index] === 0xd9) { return Buffer.concat([buffer.subarray(0, index - 1), buffer.subarray(index + 1)]) } } return buffer } export function readSofHeight(buffer: Buffer): number | null { let index = 0 while (index < buffer.length - 7) { if (buffer[index] === 0xff && SOF_MARKERS.has(buffer[index + 1])) { return buffer.readUInt16BE(index + 5) } index += 1 } return null } async function collectImagePaths(inputPaths: string[]): Promise { const result: string[] = [] const seen = new Set() const stack = [...inputPaths] while (stack.length > 0) { const candidate = stack.pop() if (!candidate) continue let stat try { stat = await fs.lstat(candidate) } catch { continue } if (stat.isSymbolicLink()) continue if (stat.isDirectory()) { const entries = await fs.readdir(candidate, { withFileTypes: true }) for (const entry of entries) { if (!entry.isSymbolicLink()) { stack.push(path.join(candidate, entry.name)) } } } else if (stat.isFile() && IMAGE_EXTENSIONS.has(path.extname(candidate).toLowerCase())) { const absolutePath = path.resolve(candidate) if (!seen.has(absolutePath)) { seen.add(absolutePath) result.push(absolutePath) } } } return result.sort((left, right) => left.localeCompare(right, 'zh-CN')) } async function inspectSingle(filePath: string): Promise { const [stat, metadata] = await Promise.all([ fs.stat(filePath), sharp(filePath).metadata(), ]) return { id: fileId(filePath), path: filePath, name: path.basename(filePath), size: stat.size, width: metadata.width ?? null, height: metadata.height ?? null, status: 'pending', } } function buildOutputPath(inputPath: string, options: ProcessOptions): string { const outputDirectory = options.useSourceDirectory ? path.dirname(inputPath) : path.resolve(options.outputDirectory || '') const basename = path.basename(inputPath, path.extname(inputPath)) const suffix = options.outputFormat === 'file-jpg' ? '.file.jpg' : '.file' return path.join(outputDirectory, `${basename}${suffix}`) } function validateOptions(options: ProcessOptions): void { if (!options.paths.length) { throw new Error('请先添加需要处理的图片') } if (!options.useSourceDirectory && !options.outputDirectory) { throw new Error('请选择输出目录') } if (!Number.isFinite(options.junkSizeMb) || options.junkSizeMb < 0 || options.junkSizeMb > 100) { throw new Error('追加数据大小必须在 0 到 100 MB 之间') } } function fileId(filePath: string): string { return createHash('sha1').update(filePath).digest('hex') } function progressFor( current: number, total: number, item: ImageFileItem, cancelled: boolean, ): ProcessProgress { return { current, total, percent: total === 0 ? 0 : Math.round((current / total) * 100), item, cancelled, } }