diff --git a/.gitignore b/.gitignore index 220a647..8398d52 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,8 @@ coverage/ # Temporary folders tmp/ temp/ -jimeng-*.md +jimeng-*.md scripts/ +!scripts/ +!scripts/batch-colorize.mjs .ace-tool/ diff --git a/BATCH_COLORIZE.md b/BATCH_COLORIZE.md new file mode 100644 index 0000000..28904fb --- /dev/null +++ b/BATCH_COLORIZE.md @@ -0,0 +1,83 @@ +# 批量图生图上色 + +本项目新增了一个批量客户端脚本,用于读取文件夹中的图片,并按顺序调用本地 `jimeng-api` 的图生图接口完成上色。 + +## 启动 API 服务 + +先确保 `jimeng-api` 服务正在运行: + +```bash +npm install +npm run build +npm run start +``` + +默认接口地址为 `http://localhost:5100`。 + +## 批量处理 + +### 使用 UI + +启动服务后,在浏览器打开: + +```text +http://localhost:5100/batch-colorize +``` + +在页面中填写输入文件夹、输出文件夹、Session ID、提示词和模型参数,点击“开始上色”。任务会在页面右侧显示进度、队列状态和日志。 + +支持两种目录方式: + +- 点击“选择”按钮选择输入/输出目录:浏览器会直接读取输入目录图片,并把结果写入选择的输出目录。 +- 手动输入本机路径:由服务端按路径读取和保存,适合作为目录选择不可用时的兜底。 + +浏览器目录选择需要 Chromium 系浏览器支持 File System Access API;`localhost` 页面可直接使用。 + +### 使用命令行 + +```bash +npm run batch:colorize -- --input ./input-images --output ./colored-images --token YOUR_SESSION_ID +``` + +也可以使用环境变量保存 sessionid: + +```bash +$env:JIMENG_SESSION_ID="YOUR_SESSION_ID" +npm run batch:colorize -- --input ./input-images --output ./colored-images +``` + +国际站 token 按原项目规则添加地区前缀,例如 `us-YOUR_SESSION_ID`、`hk-YOUR_SESSION_ID`、`jp-YOUR_SESSION_ID`、`sg-YOUR_SESSION_ID`。 + +## 常用参数 + +- `--input `:输入图片文件夹,必填。 +- `--output `:输出文件夹,默认是 `/colorized`。 +- `--token `:即梦 sessionid,也可用 `JIMENG_SESSION_ID`。 +- `--api `:API 地址,默认 `http://localhost:5100`。 +- `--prompt `:上色提示词。 +- `--model `:模型,默认 `jimeng-4.5`。 +- `--ratio `:输出比例,默认 `1:1`。 +- `--resolution `:分辨率,默认 `2k`。 +- `--sample-strength `:图生图强度,默认 `0.5`。 +- `--negative-prompt `:负面提示词。 +- `--intelligent-ratio`:启用智能比例。 +- `--recursive`:递归读取子目录。 +- `--overwrite`:覆盖已存在输出文件。 +- `--delay `:每张图片之间等待的毫秒数。 +- `--continue-on-error `:单张失败后是否继续,默认 `true`。 + +## 示例 + +递归处理线稿目录,并降低请求频率: + +```bash +npm run batch:colorize -- --input ./linearts --output ./colored --recursive --delay 3000 --token us-YOUR_SESSION_ID +``` + +指定更明确的上色提示词: + +```bash +npm run batch:colorize -- --input ./input --output ./output --token YOUR_SESSION_ID --prompt "为黑白漫画线稿上色,保持线条清晰,色彩自然,人物肤色和服装层次丰富" +``` + +脚本会把生成结果保存为 `原文件名_colorized.webp`,并在输出目录写入 `batch-colorize-report.json`。 diff --git a/Dockerfile b/Dockerfile index 41613d5..9e5165e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,7 +50,8 @@ RUN npm ci --omit=dev --registry https://registry.npmmirror.com/ && \ # 从构建阶段复制构建产物 COPY --from=builder --chown=jimeng:nodejs /app/dist ./dist -COPY --from=builder --chown=jimeng:nodejs /app/configs ./configs +COPY --from=builder --chown=jimeng:nodejs /app/configs ./configs +COPY --from=builder --chown=jimeng:nodejs /app/public ./public # 创建应用需要的目录并设置权限 RUN mkdir -p /app/logs /app/tmp && \ diff --git a/package.json b/package.json index ddc5554..aaa4152 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ ], "scripts": { "dev": "tsup src/index.ts --format cjs,esm --sourcemap --dts --watch --onSuccess \"node --enable-source-maps --no-node-snapshot dist/index.js\"", - "start": "node --enable-source-maps --no-node-snapshot dist/index.js", - "build": "tsup src/index.ts --format cjs,esm --sourcemap --dts --clean", + "start": "node --enable-source-maps --no-node-snapshot dist/index.js", + "batch:colorize": "node scripts/batch-colorize.mjs", + "build": "tsup src/index.ts --format cjs,esm --sourcemap --dts --clean", "test": "echo \"Error: no test specified\" && exit 1", "format": "prettier --write \"src/**/*.{ts,js,json}\"", "type-check": "tsc --noEmit" diff --git a/public/batch-colorize.html b/public/batch-colorize.html new file mode 100644 index 0000000..3726a22 --- /dev/null +++ b/public/batch-colorize.html @@ -0,0 +1,1117 @@ + + + + + + 批量上色 + + + +
+ + +
+
+
总数0
+
当前0
+
成功0
+
失败0
+
跳过0
+
+ +
+
+ 没有正在运行的任务 + 0% +
+
+
+ +
+
+
+

图片队列

+ +
+
+ + + + + + + + + + + + +
状态输入输出错误
等待任务
+
+
+ +
+
+

日志

+ idle +
+

+        
+
+
+
+ + + + diff --git a/scripts/batch-colorize.mjs b/scripts/batch-colorize.mjs new file mode 100644 index 0000000..f6836b7 --- /dev/null +++ b/scripts/batch-colorize.mjs @@ -0,0 +1,356 @@ +#!/usr/bin/env node + +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const IMAGE_EXTENSIONS = new Set([ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".bmp", + ".gif", + ".tif", + ".tiff", +]); + +const MIME_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".gif": "image/gif", + ".tif": "image/tiff", + ".tiff": "image/tiff", +}; + +const DEFAULT_PROMPT = + "请为这张图片进行专业上色,保持原始构图、线条、人物和细节不变,补充自然协调的色彩、光影和材质,画面干净,高质量。"; + +const args = parseArgs(process.argv.slice(2)); + +if (args.help || args.h) { + printHelp(); + process.exit(0); +} + +const inputDir = path.resolve(requiredArg(args.input ?? args.i, "input")); +const outputDir = path.resolve(args.output ?? args.o ?? path.join(inputDir, "colorized")); +const apiBase = trimTrailingSlash(args.api ?? process.env.JIMENG_API_BASE ?? "http://localhost:5100"); +const token = args.token ?? process.env.JIMENG_SESSION_ID ?? process.env.JIMENG_API_TOKEN; +const model = args.model ?? "jimeng-4.5"; +const prompt = args.prompt ?? DEFAULT_PROMPT; +const ratio = args.ratio ?? "1:1"; +const resolution = args.resolution ?? "2k"; +const sampleStrength = args["sample-strength"] ?? args.sampleStrength ?? "0.5"; +const negativePrompt = args["negative-prompt"] ?? args.negativePrompt; +const intelligentRatio = parseBoolean(args["intelligent-ratio"] ?? args.intelligentRatio); +const recursive = parseBoolean(args.recursive); +const overwrite = parseBoolean(args.overwrite); +const continueOnError = parseBoolean(args["continue-on-error"] ?? args.continueOnError, true); +const delayMs = Number(args.delay ?? 0); + +if (!token) { + fail("缺少 session token。请通过 --token 传入,或设置 JIMENG_SESSION_ID 环境变量。"); +} + +await mkdir(outputDir, { recursive: true }); + +const imageFiles = await listImages(inputDir, recursive); +if (imageFiles.length === 0) { + fail(`输入目录中没有找到图片: ${inputDir}`); +} + +console.log(`输入目录: ${inputDir}`); +console.log(`输出目录: ${outputDir}`); +console.log(`接口地址: ${apiBase}/v1/images/compositions`); +console.log(`待处理图片: ${imageFiles.length} 张`); + +const results = []; +for (let index = 0; index < imageFiles.length; index += 1) { + const filePath = imageFiles[index]; + const label = `[${index + 1}/${imageFiles.length}] ${path.basename(filePath)}`; + + try { + console.log(`${label} 开始上色...`); + const generatedUrls = await colorizeImage(filePath); + const savedFiles = []; + + for (let resultIndex = 0; resultIndex < generatedUrls.length; resultIndex += 1) { + const outputPath = await buildOutputPath(filePath, generatedUrls[resultIndex], resultIndex); + if (!overwrite && await exists(outputPath)) { + console.log(`${label} 跳过已存在文件: ${outputPath}`); + savedFiles.push(outputPath); + continue; + } + + await downloadFile(generatedUrls[resultIndex], outputPath); + savedFiles.push(outputPath); + } + + results.push({ + input: filePath, + status: "success", + urls: generatedUrls, + outputs: savedFiles, + }); + console.log(`${label} 完成,保存 ${savedFiles.length} 个结果。`); + } catch (error) { + results.push({ + input: filePath, + status: "failed", + error: error.message, + }); + console.error(`${label} 失败: ${error.message}`); + + if (!continueOnError) { + break; + } + } + + if (delayMs > 0 && index < imageFiles.length - 1) { + await sleep(delayMs); + } +} + +const reportPath = path.join(outputDir, "batch-colorize-report.json"); +await writeFile(reportPath, JSON.stringify({ + createdAt: new Date().toISOString(), + inputDir, + outputDir, + apiBase, + model, + prompt, + ratio, + resolution, + sampleStrength: Number(sampleStrength), + intelligentRatio, + total: results.length, + success: results.filter((item) => item.status === "success").length, + failed: results.filter((item) => item.status === "failed").length, + results, +}, null, 2), "utf8"); + +console.log(`批处理结束。报告: ${reportPath}`); + +async function colorizeImage(filePath) { + const buffer = await readFile(filePath); + const extension = path.extname(filePath).toLowerCase(); + const form = new FormData(); + + form.append("prompt", prompt); + form.append("model", model); + form.append("ratio", ratio); + form.append("resolution", resolution); + form.append("sample_strength", String(sampleStrength)); + form.append("response_format", "url"); + form.append("images", new Blob([buffer], { + type: MIME_TYPES[extension] ?? "application/octet-stream", + }), path.basename(filePath)); + + if (negativePrompt) { + form.append("negative_prompt", negativePrompt); + } + + if (typeof intelligentRatio === "boolean") { + form.append("intelligent_ratio", String(intelligentRatio)); + } + + const response = await fetch(`${apiBase}/v1/images/compositions`, { + method: "POST", + headers: { + Authorization: formatAuthorization(token), + }, + body: form, + }); + + const bodyText = await response.text(); + let body; + try { + body = JSON.parse(bodyText); + } catch { + throw new Error(`接口返回非 JSON 内容,HTTP ${response.status}: ${bodyText.slice(0, 300)}`); + } + + if (!response.ok || body.code && body.code !== 0) { + throw new Error(body.message ?? `接口请求失败,HTTP ${response.status}`); + } + + const urls = extractUrls(body); + if (urls.length === 0) { + throw new Error(`接口未返回图片 URL: ${JSON.stringify(body).slice(0, 500)}`); + } + + return urls; +} + +async function listImages(dir, includeSubdirs) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (isSameOrInside(fullPath, outputDir)) { + continue; + } + if (includeSubdirs) { + files.push(...await listImages(fullPath, true)); + } + continue; + } + + if (entry.isFile() && IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + files.push(fullPath); + } + } + + return files.sort((a, b) => a.localeCompare(b, "zh-Hans-CN")); +} + +async function buildOutputPath(inputPath, url, resultIndex) { + const relativeInput = path.relative(inputDir, inputPath); + const parsed = path.parse(relativeInput); + const resultSuffix = resultIndex === 0 ? "" : `_${String(resultIndex + 1).padStart(2, "0")}`; + const remoteExt = getExtensionFromUrl(url) || ".webp"; + const targetDir = path.join(outputDir, parsed.dir); + + await mkdir(targetDir, { recursive: true }); + return path.join(targetDir, `${parsed.name}_colorized${resultSuffix}${remoteExt}`); +} + +async function downloadFile(url, outputPath) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`下载生成图片失败,HTTP ${response.status}: ${url}`); + } + + const bytes = Buffer.from(await response.arrayBuffer()); + await writeFile(outputPath, bytes); +} + +function extractUrls(body) { + const data = Array.isArray(body.data) ? body.data : []; + return data + .map((item) => item?.url) + .filter((url) => typeof url === "string" && url.length > 0); +} + +function parseArgs(argv) { + const parsed = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + continue; + } + + const equalsIndex = arg.indexOf("="); + const rawKey = equalsIndex === -1 ? arg.slice(2) : arg.slice(2, equalsIndex); + const inlineValue = equalsIndex === -1 ? undefined : arg.slice(equalsIndex + 1); + if (!rawKey) { + continue; + } + + if (inlineValue !== undefined) { + parsed[rawKey] = inlineValue; + continue; + } + + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + parsed[rawKey] = true; + continue; + } + + parsed[rawKey] = next; + i += 1; + } + + return parsed; +} + +function parseBoolean(value, defaultValue = undefined) { + if (value === undefined) return defaultValue; + if (typeof value === "boolean") return value; + return ["1", "true", "yes", "y", "on"].includes(String(value).toLowerCase()); +} + +function formatAuthorization(value) { + return value.toLowerCase().startsWith("bearer ") ? value : `Bearer ${value}`; +} + +function getExtensionFromUrl(value) { + try { + const pathname = new URL(value).pathname; + const ext = path.extname(pathname).toLowerCase(); + return ext && ext.length <= 6 ? ext : ""; + } catch { + return ""; + } +} + +function isSameOrInside(candidate, parent) { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + return relative === "" || Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +async function exists(filePath) { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +function requiredArg(value, name) { + if (!value) { + fail(`缺少必要参数 --${name}`); + } + + return value; +} + +function fail(message) { + console.error(message); + console.error("使用 --help 查看示例。"); + process.exit(1); +} + +function trimTrailingSlash(value) { + return String(value).replace(/\/+$/, ""); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function printHelp() { + console.log(` +批量图生图上色工具 + +用法: + node scripts/batch-colorize.mjs --input ./input --output ./output --token YOUR_SESSION_ID + +常用参数: + --input 输入图片文件夹,必填 + --output 输出文件夹,默认: /colorized + --token 即梦 sessionid,也可用环境变量 JIMENG_SESSION_ID + --api jimeng-api 地址,默认: http://localhost:5100 + --prompt 上色提示词 + --model 模型,默认: jimeng-4.5 + --ratio 输出比例,默认: 1:1 + --resolution 分辨率,默认: 2k + --sample-strength 图生图强度,默认: 0.5 + --negative-prompt 负面提示词 + --intelligent-ratio 启用智能比例 + --recursive 递归读取子目录 + --overwrite 覆盖已存在输出文件 + --delay 每张图片之间等待毫秒数 + --continue-on-error 单张失败后继续,默认: true + +示例: + node scripts/batch-colorize.mjs --input ./linearts --output ./colored --token us-YOUR_SESSION_ID --recursive --resolution 2k +`); +} diff --git a/src/api/routes/batch-colorize.ts b/src/api/routes/batch-colorize.ts new file mode 100644 index 0000000..fd90e44 --- /dev/null +++ b/src/api/routes/batch-colorize.ts @@ -0,0 +1,115 @@ +import path from "path"; +import { promises as fs } from "fs"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import _ from "lodash"; + +import Request from "@/lib/request/Request.ts"; +import Response from "@/lib/response/Response.ts"; +import { + cancelBatchColorizeJob, + createBatchColorizeJob, + getBatchColorizeJob, +} from "@/lib/batch-colorize.ts"; + +const pagePath = path.join(process.cwd(), "public", "batch-colorize.html"); +const execFileAsync = promisify(execFile); + +export default { + get: { + "/batch-colorize": async () => { + const html = await fs.readFile(pagePath, "utf8"); + return new Response(html, { type: "html" }); + }, + + "/v1/batch-colorize/jobs/:jobId": async (request: Request) => { + const job = getBatchColorizeJob(request.params.jobId); + if (!job) { + throw new Error("Batch job not found"); + } + return job; + }, + }, + + post: { + "/v1/batch-colorize/jobs": async (request: Request) => { + request + .validate("body.inputDir", _.isString) + .validate("body.token", _.isString); + + return await createBatchColorizeJob({ + inputDir: request.body.inputDir, + outputDir: request.body.outputDir, + token: request.body.token, + model: request.body.model, + prompt: request.body.prompt, + ratio: request.body.ratio, + resolution: request.body.resolution, + sampleStrength: request.body.sampleStrength, + negativePrompt: request.body.negativePrompt, + intelligentRatio: request.body.intelligentRatio, + recursive: request.body.recursive, + overwrite: request.body.overwrite, + continueOnError: request.body.continueOnError, + delayMs: request.body.delayMs, + }); + }, + + "/v1/batch-colorize/jobs/:jobId/cancel": async (request: Request) => { + const job = cancelBatchColorizeJob(request.params.jobId); + if (!job) { + throw new Error("Batch job not found"); + } + return job; + }, + + "/v1/batch-colorize/select-directory": async (request: Request) => { + const title = request.body?.title || "选择文件夹"; + const directory = await selectDirectory(title); + return { directory }; + }, + }, +}; + +async function selectDirectory(title: string) { + if (process.platform !== "win32") { + throw new Error("目录选择弹窗当前仅支持 Windows。请手动输入目录路径。"); + } + + const safeTitle = String(title).replace(/'/g, "''"); + const script = ` +Add-Type -AssemblyName System.Windows.Forms +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$dialog = New-Object System.Windows.Forms.FolderBrowserDialog +$dialog.Description = '${safeTitle}' +$dialog.ShowNewFolderButton = $true +$result = $dialog.ShowDialog() +if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + Write-Output $dialog.SelectedPath + exit 0 +} +exit 2 +`; + + try { + const { stdout } = await execFileAsync("powershell.exe", [ + "-NoProfile", + "-STA", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], { windowsHide: false }); + + const directory = stdout.trim(); + if (!directory) { + throw new Error("未选择目录。"); + } + return directory; + } catch (error) { + if (error?.code === 2) { + throw new Error("已取消选择目录。"); + } + throw error; + } +} diff --git a/src/api/routes/index.ts b/src/api/routes/index.ts index 19ebda4..4811461 100644 --- a/src/api/routes/index.ts +++ b/src/api/routes/index.ts @@ -4,6 +4,7 @@ import ping from "./ping.ts"; import token from './token.js'; import models from './models.ts'; import videos from './videos.ts'; +import batchColorize from './batch-colorize.ts'; export default [ { @@ -18,6 +19,7 @@ export default [ endpoints: { images: '/v1/images/generations', compositions: '/v1/images/compositions', + batchColorize: '/batch-colorize', videos: '/v1/videos/generations', models: '/v1/models', health: '/ping' @@ -30,5 +32,6 @@ export default [ ping, token, models, - videos + videos, + batchColorize ]; diff --git a/src/lib/batch-colorize.ts b/src/lib/batch-colorize.ts new file mode 100644 index 0000000..a2af919 --- /dev/null +++ b/src/lib/batch-colorize.ts @@ -0,0 +1,329 @@ +import path from "path"; +import { promises as fs } from "fs"; + +import axios from "axios"; + +import util from "@/lib/util.ts"; +import logger from "@/lib/logger.ts"; +import { generateImageComposition } from "@/api/controllers/images.ts"; +import { DEFAULT_IMAGE_MODEL } from "@/api/consts/common.ts"; + +const IMAGE_EXTENSIONS = new Set([ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".bmp", + ".gif", + ".tif", + ".tiff", +]); + +const DEFAULT_COLORIZE_PROMPT = + "请为这张图片进行专业上色,保持原始构图、线条、人物和细节不变,补充自然协调的色彩、光影和材质,画面干净,高质量。"; + +export type BatchStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; +export type BatchItemStatus = "queued" | "running" | "success" | "failed" | "skipped"; + +export interface BatchOptions { + inputDir: string; + outputDir?: string; + token: string; + model?: string; + prompt?: string; + ratio?: string; + resolution?: string; + sampleStrength?: number; + negativePrompt?: string; + intelligentRatio?: boolean; + recursive?: boolean; + overwrite?: boolean; + continueOnError?: boolean; + delayMs?: number; +} + +export interface BatchItem { + input: string; + status: BatchItemStatus; + urls: string[]; + outputs: string[]; + error?: string; +} + +export interface BatchJob { + id: string; + status: BatchStatus; + createdAt: string; + updatedAt: string; + startedAt?: string; + finishedAt?: string; + options: Omit & { outputDir: string }; + total: number; + current: number; + success: number; + failed: number; + skipped: number; + cancelled: boolean; + error?: string; + reportPath?: string; + items: BatchItem[]; + logs: string[]; +} + +const jobs = new Map(); + +export async function createBatchColorizeJob(options: BatchOptions): Promise { + if (!options.inputDir) { + throw new Error("inputDir is required"); + } + if (!options.token) { + throw new Error("token is required"); + } + + const inputDir = path.resolve(options.inputDir); + const outputDir = path.resolve(options.outputDir || path.join(inputDir, "colorized")); + const recursive = Boolean(options.recursive); + const files = await listImages(inputDir, recursive, outputDir); + + if (files.length === 0) { + throw new Error(`No images found in ${inputDir}`); + } + + await fs.mkdir(outputDir, { recursive: true }); + + const job: BatchJob = { + id: util.uuid(false), + status: "queued", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + options: { + inputDir, + outputDir, + model: options.model || DEFAULT_IMAGE_MODEL, + prompt: options.prompt || DEFAULT_COLORIZE_PROMPT, + ratio: options.ratio || "1:1", + resolution: options.resolution || "2k", + sampleStrength: Number.isFinite(Number(options.sampleStrength)) ? Number(options.sampleStrength) : 0.5, + negativePrompt: options.negativePrompt || "", + intelligentRatio: Boolean(options.intelligentRatio), + recursive, + overwrite: Boolean(options.overwrite), + continueOnError: options.continueOnError !== false, + delayMs: Number.isFinite(Number(options.delayMs)) ? Math.max(0, Number(options.delayMs)) : 0, + }, + total: files.length, + current: 0, + success: 0, + failed: 0, + skipped: 0, + cancelled: false, + items: files.map((file) => ({ + input: file, + status: "queued", + urls: [], + outputs: [], + })), + logs: [], + }; + + jobs.set(job.id, job); + runBatch(job, options.token).catch((error) => { + job.status = "failed"; + job.error = error.message; + addLog(job, `任务失败: ${error.message}`); + touch(job); + }); + + return sanitizeJob(job); +} + +export function getBatchColorizeJob(jobId: string): BatchJob | undefined { + const job = jobs.get(jobId); + return job ? sanitizeJob(job) : undefined; +} + +export function cancelBatchColorizeJob(jobId: string): BatchJob | undefined { + const job = jobs.get(jobId); + if (!job) return undefined; + job.cancelled = true; + if (job.status === "queued") { + job.status = "cancelled"; + } + addLog(job, "已请求停止,当前图片完成后将停止。"); + touch(job); + return sanitizeJob(job); +} + +async function runBatch(job: BatchJob, token: string) { + job.status = "running"; + job.startedAt = new Date().toISOString(); + addLog(job, `发现 ${job.total} 张图片,开始批量上色。`); + + for (let index = 0; index < job.items.length; index++) { + if (job.cancelled) { + job.status = "cancelled"; + addLog(job, "任务已停止。"); + break; + } + + const item = job.items[index]; + job.current = index + 1; + item.status = "running"; + addLog(job, `[${job.current}/${job.total}] 开始处理 ${path.basename(item.input)}`); + + try { + const outputPaths = await getPlannedOutputPaths(job, item.input, [".webp"]); + if (!job.options.overwrite && outputPaths.length > 0 && await exists(outputPaths[0])) { + item.status = "skipped"; + item.outputs = outputPaths; + job.skipped += 1; + addLog(job, `跳过已存在结果: ${outputPaths[0]}`); + continue; + } + + const buffer = await fs.readFile(item.input); + const urls = await generateImageComposition( + job.options.model || DEFAULT_IMAGE_MODEL, + job.options.prompt || DEFAULT_COLORIZE_PROMPT, + [buffer], + { + ratio: job.options.ratio, + resolution: job.options.resolution, + sampleStrength: job.options.sampleStrength, + negativePrompt: job.options.negativePrompt, + intelligentRatio: job.options.intelligentRatio, + }, + token, + ); + + item.urls = urls; + item.outputs = []; + for (let resultIndex = 0; resultIndex < urls.length; resultIndex++) { + const outputPath = await getOutputPath(job, item.input, urls[resultIndex], resultIndex); + await downloadImage(urls[resultIndex], outputPath); + item.outputs.push(outputPath); + } + + item.status = "success"; + job.success += 1; + addLog(job, `完成 ${path.basename(item.input)},保存 ${item.outputs.length} 个结果。`); + } catch (error) { + item.status = "failed"; + item.error = error.message; + job.failed += 1; + addLog(job, `失败 ${path.basename(item.input)}: ${error.message}`); + logger.error(error); + if (!job.options.continueOnError) { + job.status = "failed"; + job.error = error.message; + break; + } + } finally { + touch(job); + } + + if (job.options.delayMs && index < job.items.length - 1) { + await sleep(job.options.delayMs); + } + } + + if (job.status === "running") { + job.status = job.failed > 0 ? "failed" : "completed"; + } + + job.finishedAt = new Date().toISOString(); + job.reportPath = await writeReport(job); + addLog(job, `批处理结束。报告: ${job.reportPath}`); + touch(job); +} + +async function listImages(dir: string, recursive: boolean, outputDir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (isSameOrInside(fullPath, outputDir)) continue; + if (recursive) { + files.push(...await listImages(fullPath, true, outputDir)); + } + continue; + } + + if (entry.isFile() && IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + files.push(fullPath); + } + } + + return files.sort((a, b) => a.localeCompare(b, "zh-Hans-CN")); +} + +async function getPlannedOutputPaths(job: BatchJob, inputPath: string, extensions: string[]) { + return Promise.all(extensions.map((extension, index) => getOutputPath(job, inputPath, extension, index))); +} + +async function getOutputPath(job: BatchJob, inputPath: string, urlOrExtension: string, resultIndex: number) { + const relativeInput = path.relative(job.options.inputDir, inputPath); + const parsed = path.parse(relativeInput); + const resultSuffix = resultIndex === 0 ? "" : `_${String(resultIndex + 1).padStart(2, "0")}`; + const extension = urlOrExtension.startsWith(".") ? urlOrExtension : getExtensionFromUrl(urlOrExtension) || ".webp"; + const outputDir = path.join(job.options.outputDir, parsed.dir); + + await fs.mkdir(outputDir, { recursive: true }); + return path.join(outputDir, `${parsed.name}_colorized${resultSuffix}${extension}`); +} + +async function downloadImage(url: string, outputPath: string) { + const response = await axios.get(url, { responseType: "arraybuffer" }); + await fs.writeFile(outputPath, Buffer.from(response.data)); +} + +async function writeReport(job: BatchJob) { + const reportPath = path.join(job.options.outputDir, "batch-colorize-report.json"); + await fs.writeFile(reportPath, JSON.stringify(sanitizeJob(job), null, 2), "utf8"); + return reportPath; +} + +async function exists(filePath: string) { + try { + await fs.stat(filePath); + return true; + } catch { + return false; + } +} + +function addLog(job: BatchJob, message: string) { + job.logs.push(`[${new Date().toLocaleString()}] ${message}`); + if (job.logs.length > 300) { + job.logs = job.logs.slice(-300); + } + touch(job); +} + +function sanitizeJob(job: BatchJob): BatchJob { + return JSON.parse(JSON.stringify(job)); +} + +function touch(job: BatchJob) { + job.updatedAt = new Date().toISOString(); +} + +function getExtensionFromUrl(value: string) { + try { + const ext = path.extname(new URL(value).pathname).toLowerCase(); + return ext && ext.length <= 6 ? ext : ""; + } catch { + return ""; + } +} + +function isSameOrInside(candidate: string, parent: string) { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + return relative === "" || Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +}