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
+
+
+
+
+
+
+
+
+
+
+
+ | 状态 |
+ 输入 |
+ 输出 |
+ 错误 |
+
+
+
+ | 等待任务 |
+
+
+
+
+
+
+
+
+
+
+
+
+
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));
+}