feat: build licensed desktop image processor

This commit is contained in:
2026-08-26 07:26:35 +08:00
parent 6ff3d71921
commit 1f31424015
32 changed files with 6515 additions and 2 deletions
+294
View File
@@ -0,0 +1,294 @@
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<ImageFileItem[]> {
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<ProcessSummary> {
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<string> {
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<string[]> {
const result: string[] = []
const seen = new Set<string>()
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<ImageFileItem> {
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,
}
}