genlab 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -2
- package/dist/cli.mjs +1689 -0
- package/package.json +64 -4
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1689 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { access, copyFile, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { openAsBlob } from "node:fs";
|
|
7
|
+
import { setTimeout } from "node:timers/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import writeFileAtomic from "write-file-atomic";
|
|
10
|
+
import { dereference } from "@apidevtools/json-schema-ref-parser";
|
|
11
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
12
|
+
import localizeZh from "ajv-i18n/localize/zh/index.js";
|
|
13
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
14
|
+
import { imageSize } from "image-size";
|
|
15
|
+
import { parseBuffer } from "music-metadata";
|
|
16
|
+
import lockfile from "proper-lockfile";
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
import { parseEnv, promisify } from "node:util";
|
|
19
|
+
import * as p from "@clack/prompts";
|
|
20
|
+
// package.json
|
|
21
|
+
var version = "0.1.1";
|
|
22
|
+
// src/errors.ts
|
|
23
|
+
/** 面向使用者的预期错误:只输出 message,不带堆栈。 */
|
|
24
|
+
var CliError = class extends Error {
|
|
25
|
+
name = "CliError";
|
|
26
|
+
};
|
|
27
|
+
// src/config.ts
|
|
28
|
+
/** 配置文件:$XDG_CONFIG_HOME/genlab/.env,默认 ~/.config/genlab/.env。 */
|
|
29
|
+
function configFile() {
|
|
30
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
|
|
31
|
+
return path.join(base, "genlab", ".env");
|
|
32
|
+
}
|
|
33
|
+
let envLoaded = false;
|
|
34
|
+
/** 读取配置文件;已存在的环境变量优先,文件不存在时只用环境变量。 */
|
|
35
|
+
function loadEnv() {
|
|
36
|
+
if (envLoaded) return;
|
|
37
|
+
envLoaded = true;
|
|
38
|
+
try {
|
|
39
|
+
process.loadEnvFile(configFile());
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.code !== "ENOENT") throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function requireEnv(name) {
|
|
45
|
+
loadEnv();
|
|
46
|
+
const value = process.env[name]?.trim();
|
|
47
|
+
if (!value) throw new CliError(`缺少配置 ${name}:在 ${configFile()} 中设置`);
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
/** 库的根目录:GENLAB_LIBRARY,支持 ~ 开头,必须是绝对路径。 */
|
|
51
|
+
function libraryRoot() {
|
|
52
|
+
const raw = requireEnv("GENLAB_LIBRARY");
|
|
53
|
+
const expanded = raw === "~" || raw.startsWith("~/") ? path.join(homedir(), raw.slice(1)) : raw;
|
|
54
|
+
if (!path.isAbsolute(expanded)) throw new CliError(`GENLAB_LIBRARY 必须是绝对路径:${raw}`);
|
|
55
|
+
return path.normalize(expanded);
|
|
56
|
+
}
|
|
57
|
+
// src/kie/client.ts
|
|
58
|
+
const API_BASE_URL = "https://api.kie.ai";
|
|
59
|
+
const UPLOAD_BASE_URL = "https://kieai.redpandaai.co";
|
|
60
|
+
/** 接口请求超时;上传与下载按文件大小放宽。 */
|
|
61
|
+
const REQUEST_TIMEOUT_MS = 6e4;
|
|
62
|
+
const TRANSFER_TIMEOUT_MS = 6e5;
|
|
63
|
+
/** 模型目录(models、schema、price、success-rate)共用每秒 1 次的额度。 */
|
|
64
|
+
const DISCOVERY_INTERVAL_MS = 1100;
|
|
65
|
+
async function createKieTask(task) {
|
|
66
|
+
const body = await kieFetch(`${API_BASE_URL}/api/v1/jobs/createTask`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
body: JSON.stringify(task)
|
|
69
|
+
});
|
|
70
|
+
if (!body.data?.taskId) throw new CliError("kie.ai 未返回 taskId");
|
|
71
|
+
return body.data.taskId;
|
|
72
|
+
}
|
|
73
|
+
async function listKieModels() {
|
|
74
|
+
return (await discoveryFetch(`${API_BASE_URL}/api/v1/models`)).data?.models ?? [];
|
|
75
|
+
}
|
|
76
|
+
/** 模型的 OpenAPI 文档;kie 尚未同步文档时为 null。模型标识里的斜杠不编码。 */
|
|
77
|
+
async function getKieModelSchema(model) {
|
|
78
|
+
return (await discoveryFetch(`${API_BASE_URL}/api/v1/models/${model}/schema`)).data?.openapi ?? null;
|
|
79
|
+
}
|
|
80
|
+
/** 账户余额;apiKey 缺省时用配置里的 Key。 */
|
|
81
|
+
async function getKieCredits(apiKey) {
|
|
82
|
+
return (await kieFetch(`${API_BASE_URL}/api/v1/chat/credit`, void 0, REQUEST_TIMEOUT_MS, apiKey)).data ?? 0;
|
|
83
|
+
}
|
|
84
|
+
/** 任务的原始记录(完整保存进素材记录,解析见 parseKieRecord)。 */
|
|
85
|
+
async function getKieRecord(taskId) {
|
|
86
|
+
const body = await kieFetch(`${API_BASE_URL}/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`);
|
|
87
|
+
if (!body.data) throw new CliError("kie.ai 未返回任务记录");
|
|
88
|
+
return body.data;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 把本机文件上传给 kie,换取模型可访问的临时地址(24 小时后删除,不保存)。
|
|
92
|
+
* 不指定文件名:kie 随机命名,避免同名覆盖。
|
|
93
|
+
*/
|
|
94
|
+
async function uploadKieFile(filePath) {
|
|
95
|
+
const form = new FormData();
|
|
96
|
+
form.append("file", await openAsBlob(filePath), path.basename(filePath));
|
|
97
|
+
form.append("uploadPath", "genlab");
|
|
98
|
+
const body = await kieFetch(`${UPLOAD_BASE_URL}/api/file-stream-upload`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
body: form
|
|
101
|
+
}, TRANSFER_TIMEOUT_MS);
|
|
102
|
+
if (!body.data?.downloadUrl) throw new CliError("kie.ai 上传未返回地址");
|
|
103
|
+
return body.data.downloadUrl;
|
|
104
|
+
}
|
|
105
|
+
/** 下载产物原文件。 */
|
|
106
|
+
async function downloadFile(url) {
|
|
107
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) });
|
|
108
|
+
if (!response.ok) throw new CliError(`下载产物失败:HTTP ${response.status}`);
|
|
109
|
+
return {
|
|
110
|
+
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
111
|
+
contentType: response.headers.get("content-type")
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
let lastDiscoveryAt = 0;
|
|
115
|
+
/** 目录类接口:同一进程内保持请求间隔,被限流时等一个间隔再试一次。 */
|
|
116
|
+
async function discoveryFetch(url) {
|
|
117
|
+
for (let attempt = 0;; attempt++) {
|
|
118
|
+
const wait = lastDiscoveryAt + DISCOVERY_INTERVAL_MS - Date.now();
|
|
119
|
+
if (wait > 0) await setTimeout(wait);
|
|
120
|
+
lastDiscoveryAt = Date.now();
|
|
121
|
+
try {
|
|
122
|
+
return await kieFetch(url);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (attempt > 0 || !(error instanceof KieRateLimitError)) throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
var KieRateLimitError = class extends CliError {};
|
|
129
|
+
async function kieFetch(url, init, timeoutMs = REQUEST_TIMEOUT_MS, apiKey = requireEnv("KIE_API_KEY")) {
|
|
130
|
+
const headers = new Headers(init?.headers);
|
|
131
|
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
132
|
+
if (typeof init?.body === "string") headers.set("Content-Type", "application/json");
|
|
133
|
+
const response = await fetch(url, {
|
|
134
|
+
...init,
|
|
135
|
+
headers,
|
|
136
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
137
|
+
});
|
|
138
|
+
let body;
|
|
139
|
+
try {
|
|
140
|
+
body = await response.json();
|
|
141
|
+
} catch {
|
|
142
|
+
throw new CliError(`kie.ai 请求失败:HTTP ${response.status}`);
|
|
143
|
+
}
|
|
144
|
+
if (!response.ok || body.code !== 200) throw new (body.code === 429 ? KieRateLimitError : CliError)(`kie.ai 请求失败(${body.code ?? response.status}):${body.msg || response.statusText}`);
|
|
145
|
+
return body;
|
|
146
|
+
}
|
|
147
|
+
// src/catalog/cache.ts
|
|
148
|
+
/** 模型目录与 schema 的本地缓存:$XDG_CACHE_HOME/genlab(默认 ~/.cache/genlab),一天有效。 */
|
|
149
|
+
const TTL_MS = 864e5;
|
|
150
|
+
function cacheDir() {
|
|
151
|
+
const base = process.env.XDG_CACHE_HOME || path.join(homedir(), ".cache");
|
|
152
|
+
return path.join(base, "genlab");
|
|
153
|
+
}
|
|
154
|
+
/** 缓存命中且未过期时直接返回,否则调用 load 并写入缓存;refresh 强制重新获取。 */
|
|
155
|
+
async function cached(name, load, refresh = false) {
|
|
156
|
+
const file = path.join(cacheDir(), name);
|
|
157
|
+
if (!refresh) try {
|
|
158
|
+
const entry = JSON.parse(await readFile(file, "utf8"));
|
|
159
|
+
if (Date.now() - entry.fetchedAt < TTL_MS) return entry.value;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!(error instanceof SyntaxError) && error.code !== "ENOENT") throw error;
|
|
162
|
+
}
|
|
163
|
+
const value = await load();
|
|
164
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
165
|
+
await writeFileAtomic(file, JSON.stringify({
|
|
166
|
+
fetchedAt: Date.now(),
|
|
167
|
+
value
|
|
168
|
+
}));
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
// src/catalog/catalog.ts
|
|
172
|
+
/** kie 模型目录:模型列表与产物类型(见 ADR-0005)。 */
|
|
173
|
+
/** 目录的任务类型 → 产物的素材类型;对话模型不产出媒体,不在其中。 */
|
|
174
|
+
const TASK_TYPE_KINDS = {
|
|
175
|
+
"Text to Image": "image",
|
|
176
|
+
"Image to Image": "image",
|
|
177
|
+
"Image Editing": "image",
|
|
178
|
+
"Text to Video": "video",
|
|
179
|
+
"Image to Video": "video",
|
|
180
|
+
"Video to Video": "video",
|
|
181
|
+
"Video Editing": "video",
|
|
182
|
+
"Lip Sync": "video",
|
|
183
|
+
"Speech to Video": "video",
|
|
184
|
+
"Text to Speech": "audio",
|
|
185
|
+
"Text to Music": "audio",
|
|
186
|
+
"Audio to Audio": "audio"
|
|
187
|
+
};
|
|
188
|
+
const CHAT_TASK_TYPE = "Chat";
|
|
189
|
+
/** 产物的素材类型;对话模型返回 null,类型未知或互相矛盾时报错。 */
|
|
190
|
+
function kindForModel(model) {
|
|
191
|
+
if (model.taskType.includes(CHAT_TASK_TYPE)) return null;
|
|
192
|
+
const kinds = new Set(model.taskType.map((taskType) => {
|
|
193
|
+
const kind = TASK_TYPE_KINDS[taskType];
|
|
194
|
+
if (!kind) throw new CliError(`无法识别模型 ${model.model} 的任务类型:${taskType}`);
|
|
195
|
+
return kind;
|
|
196
|
+
}));
|
|
197
|
+
if (kinds.size !== 1) throw new CliError(`模型 ${model.model} 的产物类型不明确:${model.taskType.join(", ")}`);
|
|
198
|
+
return [...kinds][0];
|
|
199
|
+
}
|
|
200
|
+
async function listModels(refresh = false) {
|
|
201
|
+
return cached("models.json", listKieModels, refresh);
|
|
202
|
+
}
|
|
203
|
+
/** 按标识找模型;缓存里没有时刷新一次目录再找。 */
|
|
204
|
+
async function findModel(id) {
|
|
205
|
+
const model = (await listModels()).find((item) => item.model === id) ?? (await listModels(true)).find((item) => item.model === id);
|
|
206
|
+
if (!model) throw new CliError(`未知模型:${id}(用 genlab models 查看)`);
|
|
207
|
+
const kind = kindForModel(model);
|
|
208
|
+
if (!kind) throw new CliError(`模型 ${id} 是对话模型,不支持`);
|
|
209
|
+
return {
|
|
210
|
+
...model,
|
|
211
|
+
kind
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
// src/catalog/input.ts
|
|
215
|
+
/**
|
|
216
|
+
* 命令行参数 → kie 的 input。`-p key=value` 的值是字符串,`-p key:=<JSON>` 的值按 JSON
|
|
217
|
+
* 解析,同一个键重复出现时合并成数组。字符串值以 `@` 开头表示文件:`@<路径>` 是本机
|
|
218
|
+
* 文件,`@source` 是 AI 编辑的源版本,`@@` 开头表示字面量 `@`。
|
|
219
|
+
*/
|
|
220
|
+
const SOURCE_TOKEN = "@source";
|
|
221
|
+
function parseInputArgs(args) {
|
|
222
|
+
const input = {};
|
|
223
|
+
for (const arg of args) {
|
|
224
|
+
const match = /^([^=:]+)(:?=)([\s\S]*)$/.exec(arg);
|
|
225
|
+
if (!match) throw new CliError(`参数格式应为 key=value 或 key:=JSON:${arg}`);
|
|
226
|
+
const [, key, operator, raw] = match;
|
|
227
|
+
let value = raw;
|
|
228
|
+
if (operator === ":=") try {
|
|
229
|
+
value = JSON.parse(raw);
|
|
230
|
+
} catch {
|
|
231
|
+
throw new CliError(`参数 ${key} 的 JSON 不合法:${raw}`);
|
|
232
|
+
}
|
|
233
|
+
if (key in input) {
|
|
234
|
+
const previous = input[key];
|
|
235
|
+
input[key] = Array.isArray(previous) ? [...previous, value] : [previous, value];
|
|
236
|
+
} else input[key] = value;
|
|
237
|
+
}
|
|
238
|
+
return input;
|
|
239
|
+
}
|
|
240
|
+
/** 对 value 里的每个字符串调用 fn,返回替换后的新值(对象与数组递归)。 */
|
|
241
|
+
function mapStrings(value, fn) {
|
|
242
|
+
if (typeof value === "string") return fn(value);
|
|
243
|
+
if (Array.isArray(value)) return value.map((item) => mapStrings(item, fn));
|
|
244
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, mapStrings(item, fn)]));
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
/** 是文件引用(`@source` 或 `@<路径>`)而不是字面量。 */
|
|
248
|
+
function isFileToken(text) {
|
|
249
|
+
return text.startsWith("@") && !text.startsWith("@@") && text.length > 1;
|
|
250
|
+
}
|
|
251
|
+
/** input 里引用的全部本机文件路径(不含 `@source`),按出现顺序去重。 */
|
|
252
|
+
function filePaths(input) {
|
|
253
|
+
const paths = [];
|
|
254
|
+
mapStrings(input, (text) => {
|
|
255
|
+
if (isFileToken(text) && text !== "@source") paths.push(text.slice(1));
|
|
256
|
+
return text;
|
|
257
|
+
});
|
|
258
|
+
return [...new Set(paths)];
|
|
259
|
+
}
|
|
260
|
+
function usesSource(input) {
|
|
261
|
+
let found = false;
|
|
262
|
+
mapStrings(input, (text) => {
|
|
263
|
+
found ||= text === SOURCE_TOKEN;
|
|
264
|
+
return text;
|
|
265
|
+
});
|
|
266
|
+
return found;
|
|
267
|
+
}
|
|
268
|
+
/** 把文件引用换成 resolve 的结果,`@@` 还原为字面量 `@`。 */
|
|
269
|
+
function resolveTokens(input, resolve) {
|
|
270
|
+
return mapStrings(input, (text) => {
|
|
271
|
+
if (text.startsWith("@@")) return text.slice(1);
|
|
272
|
+
return isFileToken(text) ? resolve(text) : text;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// src/catalog/schema.ts
|
|
276
|
+
/** 模型的输入参数定义:取自 kie 的 OpenAPI 文档,用于校验与展示。 */
|
|
277
|
+
const TASK_PATH = "/api/v1/jobs/createTask";
|
|
278
|
+
/** 模型 createTask 请求体里 input 的 JSON Schema($ref 已展开)。 */
|
|
279
|
+
async function getInputSchema(model, refresh = false) {
|
|
280
|
+
const doc = await cached(`schemas/${encodeURIComponent(model)}.json`, async () => {
|
|
281
|
+
const openapi = await getKieModelSchema(model);
|
|
282
|
+
if (!openapi) throw new CliError(`kie 尚未提供模型 ${model} 的参数定义`);
|
|
283
|
+
return openapi;
|
|
284
|
+
}, refresh);
|
|
285
|
+
const operation = (await dereference(structuredClone(doc), { resolve: { external: false } })).paths?.[TASK_PATH]?.post;
|
|
286
|
+
if (!operation) throw new CliError(`模型 ${model} 不走任务接口,不支持`);
|
|
287
|
+
const input = operation.requestBody?.content?.["application/json"]?.schema?.properties?.input;
|
|
288
|
+
if (!input || typeof input !== "object") throw new CliError(`模型 ${model} 的参数定义缺少 input`);
|
|
289
|
+
return input;
|
|
290
|
+
}
|
|
291
|
+
const ajv = new Ajv2020({
|
|
292
|
+
strict: false,
|
|
293
|
+
allErrors: true,
|
|
294
|
+
coerceTypes: "array",
|
|
295
|
+
validateFormats: false
|
|
296
|
+
});
|
|
297
|
+
/** 按 schema 校验并就地转换 input 的值类型;不通过时报错。 */
|
|
298
|
+
function validateInput(schema, input) {
|
|
299
|
+
const validate = ajv.compile(schema);
|
|
300
|
+
if (validate(input)) return;
|
|
301
|
+
localizeZh(validate.errors);
|
|
302
|
+
const messages = (validate.errors ?? []).map((error) => `${error.instancePath || "input"} ${error.message ?? ""}`.trim());
|
|
303
|
+
throw new CliError(`参数不符合模型定义:${[...new Set(messages)].join(";")}`);
|
|
304
|
+
}
|
|
305
|
+
const PARAM_KEYS = [
|
|
306
|
+
"type",
|
|
307
|
+
"enum",
|
|
308
|
+
"default",
|
|
309
|
+
"minimum",
|
|
310
|
+
"maximum",
|
|
311
|
+
"maxLength",
|
|
312
|
+
"items",
|
|
313
|
+
"description"
|
|
314
|
+
];
|
|
315
|
+
function describeObject(schema) {
|
|
316
|
+
const properties = schema.properties ?? {};
|
|
317
|
+
const required = new Set(schema.required ?? []);
|
|
318
|
+
return Object.entries(properties).map(([name, property]) => {
|
|
319
|
+
const info = {
|
|
320
|
+
name,
|
|
321
|
+
required: required.has(name)
|
|
322
|
+
};
|
|
323
|
+
for (const key of PARAM_KEYS) if (property[key] !== void 0) Object.assign(info, { [key]: property[key] });
|
|
324
|
+
return info;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/** 把 input 的 schema 整理成参数表(`genlab models <id>` 的输出)。 */
|
|
328
|
+
function describeInput(schema) {
|
|
329
|
+
const branches = schema.oneOf ?? schema.anyOf;
|
|
330
|
+
const description = { params: describeObject(schema) };
|
|
331
|
+
if (branches) description.variants = branches.map((branch) => ({ params: describeObject(branch) }));
|
|
332
|
+
return description;
|
|
333
|
+
}
|
|
334
|
+
// src/library/media.ts
|
|
335
|
+
/** 媒体文件识别:类型、扩展名与尺寸/时长。 */
|
|
336
|
+
const MEDIA_KINDS = [
|
|
337
|
+
"image",
|
|
338
|
+
"video",
|
|
339
|
+
"audio"
|
|
340
|
+
];
|
|
341
|
+
/** 按内容识别不出时,按下载响应的 Content-Type 兜住常见格式。 */
|
|
342
|
+
const MIME_EXTENSIONS = {
|
|
343
|
+
"image/png": "png",
|
|
344
|
+
"image/jpeg": "jpg",
|
|
345
|
+
"image/webp": "webp",
|
|
346
|
+
"video/mp4": "mp4",
|
|
347
|
+
"video/quicktime": "mov",
|
|
348
|
+
"video/webm": "webm",
|
|
349
|
+
"audio/mpeg": "mp3",
|
|
350
|
+
"audio/wav": "wav",
|
|
351
|
+
"audio/x-wav": "wav",
|
|
352
|
+
"audio/mp4": "m4a",
|
|
353
|
+
"audio/aac": "aac",
|
|
354
|
+
"audio/ogg": "ogg",
|
|
355
|
+
"audio/flac": "flac"
|
|
356
|
+
};
|
|
357
|
+
function kindForMime(mimeType) {
|
|
358
|
+
const type = mimeType.split("/")[0];
|
|
359
|
+
return type === "image" || type === "video" || type === "audio" ? type : null;
|
|
360
|
+
}
|
|
361
|
+
async function inspectMedia(bytes, contentType = null) {
|
|
362
|
+
const detected = await fileTypeFromBuffer(bytes);
|
|
363
|
+
const headerMime = contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
364
|
+
const mimeType = detected?.mime ?? headerMime;
|
|
365
|
+
const extension = detected?.ext ?? MIME_EXTENSIONS[headerMime];
|
|
366
|
+
const kind = kindForMime(mimeType);
|
|
367
|
+
if (!extension || !kind) throw new CliError(`无法识别的媒体类型:${mimeType || "未知"}`);
|
|
368
|
+
return {
|
|
369
|
+
mimeType,
|
|
370
|
+
extension,
|
|
371
|
+
kind,
|
|
372
|
+
...await measure(bytes, kind, mimeType)
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/** 尺寸与时长:解析失败不阻塞入库,按未知记录。 */
|
|
376
|
+
async function measure(bytes, kind, mimeType) {
|
|
377
|
+
const unknown = {
|
|
378
|
+
width: null,
|
|
379
|
+
height: null,
|
|
380
|
+
duration: null
|
|
381
|
+
};
|
|
382
|
+
try {
|
|
383
|
+
if (kind === "image") {
|
|
384
|
+
const size = imageSize(bytes);
|
|
385
|
+
return {
|
|
386
|
+
...unknown,
|
|
387
|
+
width: size.width,
|
|
388
|
+
height: size.height
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const metadata = await parseBuffer(bytes, { mimeType }, { duration: true });
|
|
392
|
+
const video = metadata.format.trackInfo.find((track) => track.video)?.video;
|
|
393
|
+
return {
|
|
394
|
+
width: video?.pixelWidth ?? null,
|
|
395
|
+
height: video?.pixelHeight ?? null,
|
|
396
|
+
duration: metadata.format.duration ?? null
|
|
397
|
+
};
|
|
398
|
+
} catch {
|
|
399
|
+
return unknown;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// src/library/paths.ts
|
|
403
|
+
/** 库内命名与路径约定(纯函数)。 */
|
|
404
|
+
/** 素材文件夹里的记录文件:含它的文件夹才是素材。 */
|
|
405
|
+
const ASSET_FILE = "asset.json";
|
|
406
|
+
/** 素材文件夹里存放参考输入副本的子目录,按生成编号分开。 */
|
|
407
|
+
const REFERENCES_DIR = "references";
|
|
408
|
+
/** 库根目录下的项目映射表。 */
|
|
409
|
+
const PROJECT_MAP_FILE = "projects.json";
|
|
410
|
+
const MAX_NAME_BYTES = 255;
|
|
411
|
+
/** 项目、文件夹、素材名:非空,不以 . 开头,不含路径分隔符与控制字符。 */
|
|
412
|
+
function validateName(name, what) {
|
|
413
|
+
const trimmed = name.trim();
|
|
414
|
+
if (!trimmed || trimmed !== name || trimmed.startsWith(".") || /[/\\:\p{Cc}]/u.test(trimmed) || Buffer.byteLength(trimmed) > MAX_NAME_BYTES) throw new CliError(`${what}不合法:${JSON.stringify(name)}`);
|
|
415
|
+
return trimmed;
|
|
416
|
+
}
|
|
417
|
+
/** 「文件夹/…/素材名」→ 各级名字。 */
|
|
418
|
+
function parseAssetPath(input) {
|
|
419
|
+
const segments = input.split("/");
|
|
420
|
+
if (segments.some((segment) => segment === "")) throw new CliError(`素材路径不合法:${JSON.stringify(input)}`);
|
|
421
|
+
return segments.map((segment) => validateName(segment, "素材路径"));
|
|
422
|
+
}
|
|
423
|
+
/** `3` 或 `v3` → 3。 */
|
|
424
|
+
function parseVersionArg(input) {
|
|
425
|
+
const match = /^v?(\d+)$/i.exec(input.trim());
|
|
426
|
+
const value = match ? Number(match[1]) : NaN;
|
|
427
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new CliError(`版本号不合法:${input}`);
|
|
428
|
+
return value;
|
|
429
|
+
}
|
|
430
|
+
/** 版本产物文件名:v<版本号>.<扩展名>。 */
|
|
431
|
+
function versionFileName(version, extension) {
|
|
432
|
+
return `v${version}.${extension}`;
|
|
433
|
+
}
|
|
434
|
+
/** 版本产物文件名 → 版本号;不是版本产物返回 null。 */
|
|
435
|
+
function versionFromFileName(fileName) {
|
|
436
|
+
const match = /^v(\d+)\.[^.]+$/.exec(fileName);
|
|
437
|
+
return match ? Number(match[1]) : null;
|
|
438
|
+
}
|
|
439
|
+
// src/library/record.ts
|
|
440
|
+
/**
|
|
441
|
+
* 素材记录文件:素材、生成、版本的全部信息,是素材的唯一真相(见 ADR-0002)。
|
|
442
|
+
* 写入一律原子替换;修改走 updateAsset,同一素材的读改写互斥。
|
|
443
|
+
*/
|
|
444
|
+
function assetFile(assetDir) {
|
|
445
|
+
return path.join(assetDir, ASSET_FILE);
|
|
446
|
+
}
|
|
447
|
+
async function readAsset(assetDir) {
|
|
448
|
+
let text;
|
|
449
|
+
try {
|
|
450
|
+
text = await readFile(assetFile(assetDir), "utf8");
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error.code === "ENOENT") throw new CliError(`素材不存在:${assetDir}`);
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
const record = JSON.parse(text);
|
|
456
|
+
if (record.schema !== 1) throw new CliError(`无法识别的素材记录版本:${assetFile(assetDir)}`);
|
|
457
|
+
return record;
|
|
458
|
+
}
|
|
459
|
+
async function writeAsset(assetDir, record) {
|
|
460
|
+
await writeFileAtomic(assetFile(assetDir), `${JSON.stringify(record, null, 2)}\n`);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* 互斥地读改写一个素材:mutate 就地修改 record 并返回附带结果;返回前写回。
|
|
464
|
+
* 多个 agent 同时操作同一素材时,后到的等待前一个写完。
|
|
465
|
+
*/
|
|
466
|
+
async function updateAsset(assetDir, mutate) {
|
|
467
|
+
const release = await lockfile.lock(assetFile(assetDir), {
|
|
468
|
+
retries: {
|
|
469
|
+
retries: 50,
|
|
470
|
+
minTimeout: 20,
|
|
471
|
+
maxTimeout: 200
|
|
472
|
+
},
|
|
473
|
+
stale: 3e4
|
|
474
|
+
});
|
|
475
|
+
try {
|
|
476
|
+
const record = await readAsset(assetDir);
|
|
477
|
+
const result = await mutate(record);
|
|
478
|
+
await writeAsset(assetDir, record);
|
|
479
|
+
return result;
|
|
480
|
+
} finally {
|
|
481
|
+
await release();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function findVersion(record, version) {
|
|
485
|
+
const found = record.versions.find((item) => item.number === version);
|
|
486
|
+
if (!found) throw new CliError(`版本不存在:v${version}`);
|
|
487
|
+
return found;
|
|
488
|
+
}
|
|
489
|
+
function findGeneration(record, id) {
|
|
490
|
+
return id == null ? void 0 : record.generations.find((generation) => generation.id === id);
|
|
491
|
+
}
|
|
492
|
+
function now() {
|
|
493
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
494
|
+
}
|
|
495
|
+
// src/library/rules.ts
|
|
496
|
+
function nextVersionNumber(versions) {
|
|
497
|
+
return Math.max(0, ...versions.map((version) => version.number)) + 1;
|
|
498
|
+
}
|
|
499
|
+
function nextGenerationId(generations) {
|
|
500
|
+
return Math.max(0, ...generations.map((generation) => generation.id)) + 1;
|
|
501
|
+
}
|
|
502
|
+
/** 来源链叶子:没有任何版本以它为源版本。 */
|
|
503
|
+
function isLeaf(versions, version) {
|
|
504
|
+
return !versions.some((item) => item.sourceVersion === version);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* 当前版本失败后的顺位目标:同一次生成里序号更靠后、尚未失败的版本,取版本号最小者
|
|
508
|
+
* (靠前优先、慢也等它)。没有可用版本时返回 null(指针停在失败版本)。
|
|
509
|
+
*/
|
|
510
|
+
function nextCurrentAfterFailure(versions, failed) {
|
|
511
|
+
if (failed.generation == null || failed.position == null) return null;
|
|
512
|
+
return versions.filter((version) => version.generation === failed.generation && version.position != null && version.position > failed.position && version.state !== "failed").sort((a, b) => a.number - b.number)[0]?.number ?? null;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* 删除当前版本后的回退目标:源版本(就绪)→ 最新就绪 → 最新生成中(均排除被删
|
|
516
|
+
* 版本)。都没有时返回 null(调用方应据此阻止删除)。
|
|
517
|
+
*/
|
|
518
|
+
function nextCurrentAfterDelete(versions, deleted, sourceVersion) {
|
|
519
|
+
const others = versions.filter((version) => version.number !== deleted);
|
|
520
|
+
if (sourceVersion != null) {
|
|
521
|
+
const source = others.find((version) => version.number === sourceVersion && version.state === "ready");
|
|
522
|
+
if (source) return source.number;
|
|
523
|
+
}
|
|
524
|
+
const byNumberDesc = [...others].sort((a, b) => b.number - a.number);
|
|
525
|
+
const ready = byNumberDesc.find((version) => version.state === "ready");
|
|
526
|
+
if (ready) return ready.number;
|
|
527
|
+
return byNumberDesc.find((version) => version.state === "generating")?.number ?? null;
|
|
528
|
+
}
|
|
529
|
+
// src/library/scan.ts
|
|
530
|
+
/** 扫描库目录找素材:含记录文件的文件夹即素材,不建总索引(见 ADR-0002)。 */
|
|
531
|
+
async function isAssetDir(dir) {
|
|
532
|
+
try {
|
|
533
|
+
await access(path.join(dir, ASSET_FILE));
|
|
534
|
+
return true;
|
|
535
|
+
} catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** dir 之下(含 dir)的全部素材文件夹;素材里不再往下找,隐藏目录跳过。 */
|
|
540
|
+
async function findAssetDirs(dir) {
|
|
541
|
+
if (await isAssetDir(dir)) return [dir];
|
|
542
|
+
let entries;
|
|
543
|
+
try {
|
|
544
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (error.code === "ENOENT") return [];
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
return (await Promise.all(entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => findAssetDirs(path.join(dir, entry.name))))).flat();
|
|
550
|
+
}
|
|
551
|
+
/** 文件所在的素材文件夹(在 root 之内逐级向上找);不在任何素材里返回 null。 */
|
|
552
|
+
async function findOwningAsset(root, filePath) {
|
|
553
|
+
const relative = path.relative(root, filePath);
|
|
554
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
|
|
555
|
+
for (let dir = path.dirname(filePath); dir.startsWith(root) && dir !== root; dir = path.dirname(dir)) if (await isAssetDir(dir)) return dir;
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
// src/library/ops.ts
|
|
559
|
+
/** 不经过模型的库操作:导入、设为当前版本、删除版本。 */
|
|
560
|
+
/** 把外部文件导入为新素材的 v1(没有生成输入,入库即就绪)。 */
|
|
561
|
+
async function importAsset(project, location, file) {
|
|
562
|
+
const source = path.resolve(file);
|
|
563
|
+
let bytes;
|
|
564
|
+
try {
|
|
565
|
+
bytes = await readFile(source);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (error.code === "ENOENT") throw new CliError(`文件不存在:${file}`);
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
const media = await inspectMedia(bytes);
|
|
571
|
+
await claimAssetDir(project, location);
|
|
572
|
+
try {
|
|
573
|
+
const fileName = versionFileName(1, media.extension);
|
|
574
|
+
const timestamp = now();
|
|
575
|
+
const record = {
|
|
576
|
+
schema: 1,
|
|
577
|
+
id: randomUUID(),
|
|
578
|
+
kind: media.kind,
|
|
579
|
+
currentVersion: 1,
|
|
580
|
+
createdAt: timestamp,
|
|
581
|
+
generations: [],
|
|
582
|
+
versions: [{
|
|
583
|
+
number: 1,
|
|
584
|
+
state: "ready",
|
|
585
|
+
origin: "import",
|
|
586
|
+
generation: null,
|
|
587
|
+
position: null,
|
|
588
|
+
resultIndex: null,
|
|
589
|
+
sourceVersion: null,
|
|
590
|
+
taskId: null,
|
|
591
|
+
kieRequest: null,
|
|
592
|
+
kieRecord: null,
|
|
593
|
+
failReason: null,
|
|
594
|
+
credits: null,
|
|
595
|
+
file: fileName,
|
|
596
|
+
mimeType: media.mimeType,
|
|
597
|
+
fileSize: bytes.byteLength,
|
|
598
|
+
width: media.width,
|
|
599
|
+
height: media.height,
|
|
600
|
+
duration: media.duration,
|
|
601
|
+
importedFrom: path.basename(source),
|
|
602
|
+
createdAt: timestamp,
|
|
603
|
+
updatedAt: timestamp
|
|
604
|
+
}]
|
|
605
|
+
};
|
|
606
|
+
await copyFile(source, path.join(location.dir, fileName));
|
|
607
|
+
await writeFile(assetFile(location.dir), `${JSON.stringify(record, null, 2)}\n`, { flag: "wx" });
|
|
608
|
+
} catch (error) {
|
|
609
|
+
await rm(location.dir, {
|
|
610
|
+
recursive: true,
|
|
611
|
+
force: true
|
|
612
|
+
});
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function setCurrentVersion(location, number) {
|
|
617
|
+
await updateAsset(location.dir, (record) => {
|
|
618
|
+
if (findVersion(record, number).state !== "ready") throw new CliError("只能把就绪的版本设为当前版本");
|
|
619
|
+
record.currentVersion = number;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* 删除来源链叶子版本;素材至少保留一个版本。删除当前版本时指针回到源版本,
|
|
624
|
+
* 没有则回到最新就绪版本。一次生成的版本全删完时,连同它的参考输入副本一起删。
|
|
625
|
+
*/
|
|
626
|
+
async function deleteVersion(location, number) {
|
|
627
|
+
await updateAsset(location.dir, async (record) => {
|
|
628
|
+
const version = findVersion(record, number);
|
|
629
|
+
if (!isLeaf(record.versions, number)) throw new CliError(`v${number} 有派生版本,不能删除`);
|
|
630
|
+
if (record.versions.length <= 1) throw new CliError("素材至少保留一个版本");
|
|
631
|
+
if (record.currentVersion === number) {
|
|
632
|
+
const next = nextCurrentAfterDelete(record.versions, number, version.sourceVersion);
|
|
633
|
+
if (next == null) throw new CliError("删除后没有可用的当前版本");
|
|
634
|
+
record.currentVersion = next;
|
|
635
|
+
}
|
|
636
|
+
record.versions = record.versions.filter((item) => item.number !== number);
|
|
637
|
+
if (version.file) await rm(path.join(location.dir, version.file), { force: true });
|
|
638
|
+
const generation = version.generation;
|
|
639
|
+
if (generation != null && !record.versions.some((item) => item.generation === generation)) {
|
|
640
|
+
record.generations = record.generations.filter((item) => item.id !== generation);
|
|
641
|
+
await rm(path.join(location.dir, REFERENCES_DIR, String(generation)), {
|
|
642
|
+
recursive: true,
|
|
643
|
+
force: true
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/** 占用素材文件夹:不能已存在,也不能建在另一个素材里面。 */
|
|
649
|
+
async function claimAssetDir(project, location) {
|
|
650
|
+
for (let dir = path.dirname(location.dir); dir.length > project.dir.length; dir = path.dirname(dir)) if (await isAssetDir(dir)) throw new CliError(`不能在素材里新建素材:${path.relative(project.dir, dir)}`);
|
|
651
|
+
await mkdir(path.dirname(location.dir), { recursive: true });
|
|
652
|
+
try {
|
|
653
|
+
await mkdir(location.dir);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
if (error.code === "EEXIST") throw new CliError(await isAssetDir(location.dir) ? `素材已存在:${location.path}` : `已存在同名文件夹:${location.path}`);
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// src/generation/state.ts
|
|
660
|
+
/** 一个 kie 任务对应的版本里,积分只记在结果序号 0 上,避免重复计量。 */
|
|
661
|
+
function creditsFor(version, credits) {
|
|
662
|
+
return (version.resultIndex ?? 0) === 0 ? credits : null;
|
|
663
|
+
}
|
|
664
|
+
function failVersion(record, number, reason, task = {
|
|
665
|
+
credits: null,
|
|
666
|
+
kieRecord: null
|
|
667
|
+
}) {
|
|
668
|
+
const version = record.versions.find((item) => item.number === number);
|
|
669
|
+
if (!version || version.state !== "generating") return;
|
|
670
|
+
version.state = "failed";
|
|
671
|
+
version.failReason = reason;
|
|
672
|
+
version.credits = creditsFor(version, task.credits);
|
|
673
|
+
version.kieRecord = task.kieRecord;
|
|
674
|
+
version.updatedAt = now();
|
|
675
|
+
if (record.currentVersion === number) {
|
|
676
|
+
const next = nextCurrentAfterFailure(record.versions, version);
|
|
677
|
+
if (next != null) record.currentVersion = next;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function fulfillVersion(record, number, product, task) {
|
|
681
|
+
const version = record.versions.find((item) => item.number === number);
|
|
682
|
+
if (!version || version.state !== "generating") return;
|
|
683
|
+
Object.assign(version, product, {
|
|
684
|
+
state: "ready",
|
|
685
|
+
failReason: null,
|
|
686
|
+
credits: creditsFor(version, task.credits),
|
|
687
|
+
kieRecord: task.kieRecord,
|
|
688
|
+
updatedAt: now()
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
// src/generation/submit.ts
|
|
692
|
+
/**
|
|
693
|
+
* 发起生成:先按模型的 schema 完成校验,再落记录(版本壳为生成中),最后上传文件、
|
|
694
|
+
* 向 kie 下单并回填任务编号。下单失败的版本直接标失败,可重试。
|
|
695
|
+
*/
|
|
696
|
+
const defaultDeps$1 = {
|
|
697
|
+
upload: uploadKieFile,
|
|
698
|
+
createTask: createKieTask
|
|
699
|
+
};
|
|
700
|
+
/** 一次生成最多并列几个 kie 任务。 */
|
|
701
|
+
const MAX_TASKS = 10;
|
|
702
|
+
/** 新建素材并发起第一次生成;返回新版本号。 */
|
|
703
|
+
async function createAsset(project, location, options, deps = defaultDeps$1) {
|
|
704
|
+
const plan = await planGeneration(options, null);
|
|
705
|
+
await claimAssetDir(project, location);
|
|
706
|
+
let created;
|
|
707
|
+
try {
|
|
708
|
+
created = await writeNewAsset(project, location, plan);
|
|
709
|
+
} catch (error) {
|
|
710
|
+
await rm(location.dir, {
|
|
711
|
+
recursive: true,
|
|
712
|
+
force: true
|
|
713
|
+
});
|
|
714
|
+
throw error;
|
|
715
|
+
}
|
|
716
|
+
await dispatch(location.dir, created.generation, created.versions, deps);
|
|
717
|
+
return created.versions;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* 在已有素材里发起生成。from 指定时沿用该版本的模型与 input(重新生成),`-p` 给出的
|
|
721
|
+
* 键覆盖沿用值;换了模型时不沿用 input。
|
|
722
|
+
*/
|
|
723
|
+
async function generateInAsset(project, location, options, deps = defaultDeps$1) {
|
|
724
|
+
const record = await readAsset(location.dir);
|
|
725
|
+
let plan;
|
|
726
|
+
if (options.from != null) {
|
|
727
|
+
const base = findGeneration(record, findVersion(record, options.from).generation);
|
|
728
|
+
if (!base) throw new CliError(`v${options.from} 不是生成产物,无法沿用生成输入`);
|
|
729
|
+
const modelId = options.model ?? base.model;
|
|
730
|
+
const inherited = modelId === base.model ? absoluteTemplate(location.dir, base.input) : {};
|
|
731
|
+
plan = await planGeneration({
|
|
732
|
+
...options,
|
|
733
|
+
model: modelId
|
|
734
|
+
}, base.sourceVersion, inherited);
|
|
735
|
+
} else plan = await planGeneration(options, null);
|
|
736
|
+
return appendAndDispatch(project, location, record, plan, deps);
|
|
737
|
+
}
|
|
738
|
+
/** AI 编辑:以某个就绪版本为源版本发起生成,参数里用 `@source` 引用它;未指定模型时沿用该版本的模型。 */
|
|
739
|
+
async function editVersion(project, location, sourceVersion, options, deps = defaultDeps$1) {
|
|
740
|
+
const record = await readAsset(location.dir);
|
|
741
|
+
const source = findVersion(record, sourceVersion);
|
|
742
|
+
if (source.state !== "ready" || !source.file) throw new CliError(`v${sourceVersion} 尚未就绪,不能作为 AI 编辑的源版本`);
|
|
743
|
+
return appendAndDispatch(project, location, record, await planGeneration({
|
|
744
|
+
...options,
|
|
745
|
+
model: options.model ?? findGeneration(record, source.generation)?.model
|
|
746
|
+
}, sourceVersion), deps);
|
|
747
|
+
}
|
|
748
|
+
/** 原地重试失败的版本:沿用它那次生成的输入发一个新任务,版本号不变。 */
|
|
749
|
+
async function retryVersion(location, number, deps = defaultDeps$1) {
|
|
750
|
+
const record = await readAsset(location.dir);
|
|
751
|
+
const version = findVersion(record, number);
|
|
752
|
+
const generation = findGeneration(record, version.generation);
|
|
753
|
+
if (version.state !== "failed") throw new CliError("只有失败的版本可以重试");
|
|
754
|
+
if (!generation) throw new CliError(`v${number} 不是生成产物,无法重试`);
|
|
755
|
+
await updateAsset(location.dir, (latest) => {
|
|
756
|
+
Object.assign(findVersion(latest, number), {
|
|
757
|
+
state: "generating",
|
|
758
|
+
resultIndex: 0,
|
|
759
|
+
failReason: null,
|
|
760
|
+
taskId: null,
|
|
761
|
+
kieRequest: null,
|
|
762
|
+
kieRecord: null,
|
|
763
|
+
credits: null,
|
|
764
|
+
updatedAt: now()
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
await dispatch(location.dir, generation, [number], deps);
|
|
768
|
+
return [number];
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* 解析 `-p` 并与沿用的 input 合并,按模型 schema 校验(值类型就地转换)。
|
|
772
|
+
* `@source` 只能用在 AI 编辑(或沿用了 AI 编辑的重新生成)里。
|
|
773
|
+
*/
|
|
774
|
+
async function planGeneration(options, sourceVersion, inherited = {}) {
|
|
775
|
+
if (!options.model) throw new CliError("缺少 --model");
|
|
776
|
+
const tasks = options.count ?? 1;
|
|
777
|
+
if (!Number.isInteger(tasks) || tasks < 1 || tasks > MAX_TASKS) throw new CliError(`count 范围是 1–${MAX_TASKS}`);
|
|
778
|
+
const model = await findModel(options.model);
|
|
779
|
+
const input = {
|
|
780
|
+
...inherited,
|
|
781
|
+
...absoluteFiles(parseInputArgs(options.params))
|
|
782
|
+
};
|
|
783
|
+
const hasSource = usesSource(input);
|
|
784
|
+
if (sourceVersion == null && hasSource) throw new CliError("@source 只能用于 AI 编辑");
|
|
785
|
+
if (sourceVersion != null && !hasSource && Object.keys(inherited).length === 0) throw new CliError("AI 编辑需要在参数里用 @source 引用源版本");
|
|
786
|
+
validateInput(await getInputSchema(model.model), input);
|
|
787
|
+
return {
|
|
788
|
+
model,
|
|
789
|
+
input,
|
|
790
|
+
sourceVersion: hasSource ? sourceVersion : null,
|
|
791
|
+
tasks
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/** 命令行里的文件路径转成绝对路径(相对调用时所在目录)。 */
|
|
795
|
+
function absoluteFiles(input) {
|
|
796
|
+
return mapStrings(input, (text) => isLocalFile(text) ? `@${path.resolve(text.slice(1))}` : text);
|
|
797
|
+
}
|
|
798
|
+
/** 记录里的 input 模板(文件相对素材文件夹)→ 绝对路径,供重新生成沿用。 */
|
|
799
|
+
function absoluteTemplate(dir, template) {
|
|
800
|
+
return mapStrings(template, (text) => isLocalFile(text) ? `@${path.join(dir, text.slice(1))}` : text);
|
|
801
|
+
}
|
|
802
|
+
function isLocalFile(text) {
|
|
803
|
+
return text.startsWith("@") && !text.startsWith("@@") && text !== "@source" && text.length > 1;
|
|
804
|
+
}
|
|
805
|
+
async function appendAndDispatch(project, location, record, plan, deps) {
|
|
806
|
+
if (plan.model.kind !== record.kind) throw new CliError(`模型 ${plan.model.model} 生成 ${plan.model.kind},与素材类型 ${record.kind} 不符`);
|
|
807
|
+
const references = await inspectReferences(project.root, plan.input);
|
|
808
|
+
const { generation, versions } = await updateAsset(location.dir, async (latest) => {
|
|
809
|
+
const id = nextGenerationId(latest.generations);
|
|
810
|
+
return appendGeneration(latest, id, plan, await copyReferences(location.dir, id, references));
|
|
811
|
+
});
|
|
812
|
+
await dispatch(location.dir, generation, versions, deps);
|
|
813
|
+
return versions;
|
|
814
|
+
}
|
|
815
|
+
/** 写入新素材的记录与参考输入副本;新素材的当前版本取版本号最靠前的版本壳。 */
|
|
816
|
+
async function writeNewAsset(project, location, plan) {
|
|
817
|
+
const references = await inspectReferences(project.root, plan.input);
|
|
818
|
+
const record = {
|
|
819
|
+
schema: 1,
|
|
820
|
+
id: randomUUID(),
|
|
821
|
+
kind: plan.model.kind,
|
|
822
|
+
currentVersion: 0,
|
|
823
|
+
createdAt: now(),
|
|
824
|
+
generations: [],
|
|
825
|
+
versions: []
|
|
826
|
+
};
|
|
827
|
+
const id = nextGenerationId(record.generations);
|
|
828
|
+
const appended = appendGeneration(record, id, plan, await copyReferences(location.dir, id, references));
|
|
829
|
+
record.currentVersion = appended.versions[0];
|
|
830
|
+
await writeFile(assetFile(location.dir), `${JSON.stringify(record, null, 2)}\n`, { flag: "wx" });
|
|
831
|
+
return appended;
|
|
832
|
+
}
|
|
833
|
+
/** input 引用的本机文件:识别类型;位于库内某个版本产物时记下出处。 */
|
|
834
|
+
async function inspectReferences(root, input) {
|
|
835
|
+
return Promise.all(filePaths(input).map(async (file) => {
|
|
836
|
+
let bytes;
|
|
837
|
+
try {
|
|
838
|
+
if (!(await stat(file)).isFile()) throw new CliError(`不是文件:${file}`);
|
|
839
|
+
bytes = await readFile(file);
|
|
840
|
+
} catch (error) {
|
|
841
|
+
if (error.code === "ENOENT") throw new CliError(`文件不存在:${file}`);
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
844
|
+
const media = await inspectMedia(bytes);
|
|
845
|
+
const owner = await findOwningAsset(root, file);
|
|
846
|
+
const version = versionFromFileName(path.basename(file));
|
|
847
|
+
return {
|
|
848
|
+
path: file,
|
|
849
|
+
name: path.basename(file),
|
|
850
|
+
kind: media.kind,
|
|
851
|
+
origin: owner && version != null ? {
|
|
852
|
+
asset: path.relative(root, owner).split(path.sep).join("/"),
|
|
853
|
+
version
|
|
854
|
+
} : null
|
|
855
|
+
};
|
|
856
|
+
}));
|
|
857
|
+
}
|
|
858
|
+
/** 参考输入复制到 references/<生成编号>/,保留原文件名,同名时加序号。 */
|
|
859
|
+
async function copyReferences(dir, generationId, references) {
|
|
860
|
+
if (references.length === 0) return [];
|
|
861
|
+
const folder = path.join(REFERENCES_DIR, String(generationId));
|
|
862
|
+
const used = /* @__PURE__ */ new Set();
|
|
863
|
+
const records = [];
|
|
864
|
+
await mkdir(path.join(dir, folder), { recursive: true });
|
|
865
|
+
for (const { path: source, ...reference } of references) {
|
|
866
|
+
const { name, ext } = path.parse(reference.name);
|
|
867
|
+
let fileName = reference.name;
|
|
868
|
+
for (let index = 2; used.has(fileName); index++) fileName = `${name}-${index}${ext}`;
|
|
869
|
+
used.add(fileName);
|
|
870
|
+
const file = path.join(folder, fileName).split(path.sep).join("/");
|
|
871
|
+
await copyFile(source, path.join(dir, file));
|
|
872
|
+
records.push({
|
|
873
|
+
...reference,
|
|
874
|
+
file,
|
|
875
|
+
source
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
return records;
|
|
879
|
+
}
|
|
880
|
+
/** 追加一次生成与它的版本壳(每个任务一个,就地修改 record);返回新版本号。 */
|
|
881
|
+
function appendGeneration(record, id, plan, references) {
|
|
882
|
+
const timestamp = now();
|
|
883
|
+
const copies = new Map(references.map((reference) => [reference.source, reference.file]));
|
|
884
|
+
const generation = {
|
|
885
|
+
id,
|
|
886
|
+
model: plan.model.model,
|
|
887
|
+
input: mapStrings(plan.input, (text) => isLocalFile(text) && copies.has(text.slice(1)) ? `@${copies.get(text.slice(1))}` : text),
|
|
888
|
+
references: references.map(({ source: _source, ...reference }) => reference),
|
|
889
|
+
sourceVersion: plan.sourceVersion,
|
|
890
|
+
createdAt: timestamp
|
|
891
|
+
};
|
|
892
|
+
const base = nextVersionNumber(record.versions);
|
|
893
|
+
const versions = [];
|
|
894
|
+
for (let position = 0; position < plan.tasks; position++) {
|
|
895
|
+
const number = base + position;
|
|
896
|
+
record.versions.push({
|
|
897
|
+
number,
|
|
898
|
+
state: "generating",
|
|
899
|
+
origin: "generation",
|
|
900
|
+
generation: id,
|
|
901
|
+
position,
|
|
902
|
+
resultIndex: 0,
|
|
903
|
+
sourceVersion: plan.sourceVersion,
|
|
904
|
+
taskId: null,
|
|
905
|
+
kieRequest: null,
|
|
906
|
+
kieRecord: null,
|
|
907
|
+
failReason: null,
|
|
908
|
+
credits: null,
|
|
909
|
+
file: null,
|
|
910
|
+
mimeType: null,
|
|
911
|
+
fileSize: null,
|
|
912
|
+
width: null,
|
|
913
|
+
height: null,
|
|
914
|
+
duration: null,
|
|
915
|
+
importedFrom: null,
|
|
916
|
+
createdAt: timestamp,
|
|
917
|
+
updatedAt: timestamp
|
|
918
|
+
});
|
|
919
|
+
versions.push(number);
|
|
920
|
+
}
|
|
921
|
+
record.generations.push(generation);
|
|
922
|
+
return {
|
|
923
|
+
generation,
|
|
924
|
+
versions
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* 上传 input 引用的文件与源版本换取临时地址(每次下单都重新上传,不保存),每个版本壳
|
|
929
|
+
* 各下一单并回填任务编号。上传或下单失败的版本标失败。
|
|
930
|
+
*/
|
|
931
|
+
async function dispatch(dir, generation, versions, deps) {
|
|
932
|
+
let task;
|
|
933
|
+
try {
|
|
934
|
+
const record = await readAsset(dir);
|
|
935
|
+
const sourceFile = generation.sourceVersion != null ? findVersion(record, generation.sourceVersion).file : null;
|
|
936
|
+
const files = new Map(filePaths(generation.input).map((file) => [`@${file}`, path.join(dir, file)]));
|
|
937
|
+
if (sourceFile && usesSource(generation.input)) files.set(SOURCE_TOKEN, path.join(dir, sourceFile));
|
|
938
|
+
const urls = new Map(await Promise.all([...files].map(async ([token, file]) => [token, await deps.upload(file)])));
|
|
939
|
+
task = {
|
|
940
|
+
model: generation.model,
|
|
941
|
+
input: resolveTokens(generation.input, (token) => urls.get(token))
|
|
942
|
+
};
|
|
943
|
+
} catch (error) {
|
|
944
|
+
await failVersions(dir, versions, error.message);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
await Promise.all(versions.map(async (number) => {
|
|
948
|
+
let taskId;
|
|
949
|
+
try {
|
|
950
|
+
taskId = await deps.createTask(task);
|
|
951
|
+
} catch (error) {
|
|
952
|
+
await failVersions(dir, [number], error.message, task);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
await updateAsset(dir, (record) => {
|
|
956
|
+
const version = record.versions.find((item) => item.number === number);
|
|
957
|
+
if (version?.state === "generating") {
|
|
958
|
+
version.taskId = taskId;
|
|
959
|
+
version.kieRequest = task;
|
|
960
|
+
version.updatedAt = now();
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
}));
|
|
964
|
+
}
|
|
965
|
+
async function failVersions(dir, numbers, reason, task = null) {
|
|
966
|
+
await updateAsset(dir, (record) => {
|
|
967
|
+
for (const number of numbers) {
|
|
968
|
+
const version = record.versions.find((item) => item.number === number);
|
|
969
|
+
if (version) version.kieRequest = task;
|
|
970
|
+
failVersion(record, number, reason);
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
// src/kie/record.ts
|
|
975
|
+
/**
|
|
976
|
+
* 把 kie.ai 的五态任务记录归约成三态。结果在 resultJson 里:图片、视频、语音为
|
|
977
|
+
* `{ resultUrls: [...] }`;Suno(音乐、音效)为 `{ data: [{ audio_url, duration }, ...] }`。
|
|
978
|
+
*/
|
|
979
|
+
function parseKieRecord(record) {
|
|
980
|
+
const credits = record.creditsConsumed ?? null;
|
|
981
|
+
if (record.state === "fail") return {
|
|
982
|
+
state: "failed",
|
|
983
|
+
results: [],
|
|
984
|
+
failReason: record.failMsg || "生成失败",
|
|
985
|
+
credits
|
|
986
|
+
};
|
|
987
|
+
if (record.state !== "success") return {
|
|
988
|
+
state: "pending",
|
|
989
|
+
results: [],
|
|
990
|
+
failReason: null,
|
|
991
|
+
credits
|
|
992
|
+
};
|
|
993
|
+
const results = parseResults(record.resultJson);
|
|
994
|
+
if (results.length === 0) return {
|
|
995
|
+
state: "failed",
|
|
996
|
+
results: [],
|
|
997
|
+
failReason: "生成结果为空",
|
|
998
|
+
credits
|
|
999
|
+
};
|
|
1000
|
+
return {
|
|
1001
|
+
state: "succeeded",
|
|
1002
|
+
results,
|
|
1003
|
+
failReason: null,
|
|
1004
|
+
credits
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/** resultJson → 产物列表;格式异常或为空返回空数组(由调用方按失败处理,避免卡在 pending)。 */
|
|
1008
|
+
function parseResults(resultJson) {
|
|
1009
|
+
let parsed;
|
|
1010
|
+
try {
|
|
1011
|
+
parsed = JSON.parse(resultJson ?? "{}");
|
|
1012
|
+
} catch {
|
|
1013
|
+
return [];
|
|
1014
|
+
}
|
|
1015
|
+
if (Array.isArray(parsed.resultUrls)) return parsed.resultUrls.filter((url) => typeof url === "string" && url !== "").map((url) => ({
|
|
1016
|
+
url,
|
|
1017
|
+
duration: null
|
|
1018
|
+
}));
|
|
1019
|
+
if (Array.isArray(parsed.data)) return parsed.data.filter((track) => typeof track?.audio_url === "string" && track.audio_url !== "").map((track) => ({
|
|
1020
|
+
url: track.audio_url,
|
|
1021
|
+
duration: typeof track.duration === "number" ? track.duration : null
|
|
1022
|
+
}));
|
|
1023
|
+
return [];
|
|
1024
|
+
}
|
|
1025
|
+
// src/generation/sync.ts
|
|
1026
|
+
/**
|
|
1027
|
+
* 向 kie 求证并收取产物:按任务分组,一次 recordInfo 推进同一任务的全部版本。
|
|
1028
|
+
* 产物先下载到素材文件夹里的临时文件,加锁确认版本仍在等待该任务后再就位,
|
|
1029
|
+
* 因此多个进程同时收取也只会落一份(幂等)。
|
|
1030
|
+
*/
|
|
1031
|
+
const defaultDeps = {
|
|
1032
|
+
getRecord: getKieRecord,
|
|
1033
|
+
download: downloadFile
|
|
1034
|
+
};
|
|
1035
|
+
/** 提交到一半中断(有记录、没任务编号)的版本,超过这个时长按失败处理,可重试。 */
|
|
1036
|
+
const STUCK_DISPATCH_MS = 6e5;
|
|
1037
|
+
const POLL_INTERVAL_MS = 5e3;
|
|
1038
|
+
/** 同步一批素材里所有生成中的版本;返回过程中遇到的问题(不中断其余任务)。 */
|
|
1039
|
+
async function syncAssets(assetDirs, deps = defaultDeps) {
|
|
1040
|
+
return (await Promise.all(assetDirs.map((dir) => syncAsset(dir, deps).catch((error) => [`${dir}:${error.message}`])))).flat();
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* 轮询直到目标版本全部到达终态或超时;返回是否全部完成与过程中的问题。
|
|
1044
|
+
* targets 的 versions 为空表示该素材里所有生成中的版本。
|
|
1045
|
+
*/
|
|
1046
|
+
async function waitForVersions(targets, timeoutMs, deps = defaultDeps) {
|
|
1047
|
+
const deadline = Date.now() + timeoutMs;
|
|
1048
|
+
const warnings = /* @__PURE__ */ new Set();
|
|
1049
|
+
for (;;) {
|
|
1050
|
+
for (const warning of await syncAssets(targets.map((target) => target.dir), deps)) warnings.add(warning);
|
|
1051
|
+
if (await allSettled(targets)) return {
|
|
1052
|
+
done: true,
|
|
1053
|
+
warnings: [...warnings]
|
|
1054
|
+
};
|
|
1055
|
+
if (Date.now() + POLL_INTERVAL_MS > deadline) return {
|
|
1056
|
+
done: false,
|
|
1057
|
+
warnings: [...warnings]
|
|
1058
|
+
};
|
|
1059
|
+
await setTimeout(POLL_INTERVAL_MS);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
async function allSettled(targets) {
|
|
1063
|
+
for (const target of targets) if ((await readAsset(target.dir)).versions.some((version) => version.state === "generating" && (!target.versions || target.versions.includes(version.number)))) return false;
|
|
1064
|
+
return true;
|
|
1065
|
+
}
|
|
1066
|
+
async function syncAsset(dir, deps) {
|
|
1067
|
+
const record = await readAsset(dir);
|
|
1068
|
+
const generating = record.versions.filter((version) => version.state === "generating");
|
|
1069
|
+
const stuck = generating.filter((version) => !version.taskId && Date.now() - Date.parse(version.updatedAt) > STUCK_DISPATCH_MS);
|
|
1070
|
+
if (stuck.length > 0) await updateAsset(dir, (latest) => {
|
|
1071
|
+
for (const version of stuck) if (!latest.versions.find((item) => item.number === version.number)?.taskId) failVersion(latest, version.number, "提交中断,可重试");
|
|
1072
|
+
});
|
|
1073
|
+
const taskIds = [...new Set(generating.map((version) => version.taskId).filter((taskId) => taskId != null))];
|
|
1074
|
+
const warnings = [];
|
|
1075
|
+
for (const taskId of taskIds) try {
|
|
1076
|
+
await syncTask(dir, record, taskId, deps);
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
warnings.push(`${path.basename(dir)} 任务 ${taskId} 同步失败:${error.message}`);
|
|
1079
|
+
}
|
|
1080
|
+
return warnings;
|
|
1081
|
+
}
|
|
1082
|
+
async function syncTask(dir, record, taskId, deps) {
|
|
1083
|
+
const kieRecord = await deps.getRecord(taskId);
|
|
1084
|
+
const result = parseKieRecord(kieRecord);
|
|
1085
|
+
if (result.state === "pending") return;
|
|
1086
|
+
const members = record.versions.filter((version) => version.state === "generating" && version.taskId === taskId);
|
|
1087
|
+
const task = {
|
|
1088
|
+
credits: result.credits,
|
|
1089
|
+
kieRecord
|
|
1090
|
+
};
|
|
1091
|
+
if (result.state === "failed") {
|
|
1092
|
+
await updateAsset(dir, (latest) => {
|
|
1093
|
+
for (const member of members) if (stillWaiting(latest, member.number, taskId)) failVersion(latest, member.number, result.failReason ?? "生成失败", task);
|
|
1094
|
+
});
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const downloads = await Promise.all(result.results.map((item, index) => downloadProduct(dir, record, taskId, index, item, deps)));
|
|
1098
|
+
await updateAsset(dir, async (latest) => {
|
|
1099
|
+
const waiting = members.filter((member) => stillWaiting(latest, member.number, taskId));
|
|
1100
|
+
const lead = waiting[0];
|
|
1101
|
+
for (const [index, product] of downloads.entries()) {
|
|
1102
|
+
const target = latest.versions.find((version) => version.taskId === taskId && version.resultIndex === index) ?? (lead ? appendResultVersion(latest, lead, index) : null);
|
|
1103
|
+
if (!target || target.state !== "generating") {
|
|
1104
|
+
if (!("error" in product)) await rm(product.temp, { force: true });
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
if ("error" in product) failVersion(latest, target.number, product.error, task);
|
|
1108
|
+
else {
|
|
1109
|
+
const file = versionFileName(target.number, product.extension);
|
|
1110
|
+
await rename(product.temp, path.join(dir, file));
|
|
1111
|
+
fulfillVersion(latest, target.number, {
|
|
1112
|
+
...product.info,
|
|
1113
|
+
file
|
|
1114
|
+
}, task);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
for (const member of waiting) if (stillWaiting(latest, member.number, taskId)) failVersion(latest, member.number, "生成结果为空", task);
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
function stillWaiting(record, number, taskId) {
|
|
1121
|
+
const version = record.versions.find((item) => item.number === number);
|
|
1122
|
+
return version?.state === "generating" && version.taskId === taskId;
|
|
1123
|
+
}
|
|
1124
|
+
/** 一个任务返回多个结果时,为多出来的结果追加同一次生成的新版本(排在该生成末尾)。 */
|
|
1125
|
+
function appendResultVersion(record, lead, resultIndex) {
|
|
1126
|
+
const positions = record.versions.filter((version) => version.generation === lead.generation).map((version) => version.position ?? 0);
|
|
1127
|
+
const version = {
|
|
1128
|
+
...lead,
|
|
1129
|
+
number: nextVersionNumber(record.versions),
|
|
1130
|
+
state: "generating",
|
|
1131
|
+
position: Math.max(...positions) + 1,
|
|
1132
|
+
resultIndex,
|
|
1133
|
+
createdAt: now(),
|
|
1134
|
+
updatedAt: now()
|
|
1135
|
+
};
|
|
1136
|
+
record.versions.push(version);
|
|
1137
|
+
return version;
|
|
1138
|
+
}
|
|
1139
|
+
/** 下载并识别产物;类型与素材不符时返回错误(按失败处理),网络错误直接抛出(下次再收)。 */
|
|
1140
|
+
async function downloadProduct(dir, record, taskId, index, item, deps) {
|
|
1141
|
+
const { bytes, contentType } = await deps.download(item.url);
|
|
1142
|
+
const media = await inspectMedia(bytes, contentType);
|
|
1143
|
+
if (media.kind !== record.kind) return { error: `产物类型(${media.kind})与素材类型(${record.kind})不符` };
|
|
1144
|
+
const temp = path.join(dir, `.${taskId}-${index}.${process.pid}.part`);
|
|
1145
|
+
await writeFile(temp, bytes);
|
|
1146
|
+
return {
|
|
1147
|
+
temp,
|
|
1148
|
+
extension: media.extension,
|
|
1149
|
+
info: {
|
|
1150
|
+
mimeType: media.mimeType,
|
|
1151
|
+
fileSize: bytes.byteLength,
|
|
1152
|
+
width: media.width,
|
|
1153
|
+
height: media.height,
|
|
1154
|
+
duration: media.duration ?? item.duration
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
// src/project/identify.ts
|
|
1159
|
+
/** 识别调用时所在的代码项目:远程地址 + 主仓库位置(见 ADR-0004)。 */
|
|
1160
|
+
const run = promisify(execFile);
|
|
1161
|
+
async function git(cwd, args) {
|
|
1162
|
+
try {
|
|
1163
|
+
const { stdout } = await run("git", [
|
|
1164
|
+
"-C",
|
|
1165
|
+
cwd,
|
|
1166
|
+
...args
|
|
1167
|
+
]);
|
|
1168
|
+
return stdout.trim() || null;
|
|
1169
|
+
} catch {
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
async function identifyCodeProject(cwd) {
|
|
1174
|
+
const dir = await realpath(cwd);
|
|
1175
|
+
const commonDir = await git(dir, [
|
|
1176
|
+
"rev-parse",
|
|
1177
|
+
"--path-format=absolute",
|
|
1178
|
+
"--git-common-dir"
|
|
1179
|
+
]);
|
|
1180
|
+
if (!commonDir) return {
|
|
1181
|
+
remote: null,
|
|
1182
|
+
path: dir
|
|
1183
|
+
};
|
|
1184
|
+
const mainPath = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : await git(dir, ["rev-parse", "--show-toplevel"]) ?? dir;
|
|
1185
|
+
return {
|
|
1186
|
+
remote: await remoteUrl(mainPath),
|
|
1187
|
+
path: await realpath(mainPath)
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
/** 优先 origin,否则取第一个远程。 */
|
|
1191
|
+
async function remoteUrl(repo) {
|
|
1192
|
+
const remotes = (await git(repo, ["remote"]))?.split("\n") ?? [];
|
|
1193
|
+
const name = remotes.includes("origin") ? "origin" : remotes[0];
|
|
1194
|
+
return name ? git(repo, [
|
|
1195
|
+
"remote",
|
|
1196
|
+
"get-url",
|
|
1197
|
+
name
|
|
1198
|
+
]) : null;
|
|
1199
|
+
}
|
|
1200
|
+
// src/project/map.ts
|
|
1201
|
+
/**
|
|
1202
|
+
* 项目映射:代码项目 → 库里的项目(见 ADR-0004)。有远程仓库的按远程地址识别、
|
|
1203
|
+
* 首次自动以仓库名登记;没有的按主仓库位置识别、首次必须指定名字;中途建了远程
|
|
1204
|
+
* 仓库的,按位置找到原条目补上远程地址,名字不变。
|
|
1205
|
+
*/
|
|
1206
|
+
/** 远程地址 → host/owner/repo(小写、去掉协议、用户名与 .git),用于判断是否同一仓库。 */
|
|
1207
|
+
function normalizeRemote(url) {
|
|
1208
|
+
return remotePath(url).toLowerCase();
|
|
1209
|
+
}
|
|
1210
|
+
/** 远程仓库名(保留大小写),如 https://github.com/owner/pixel-quest.git → pixel-quest。 */
|
|
1211
|
+
function repoName(url) {
|
|
1212
|
+
return remotePath(url).split("/").pop() ?? "";
|
|
1213
|
+
}
|
|
1214
|
+
function remotePath(url) {
|
|
1215
|
+
const trimmed = url.trim();
|
|
1216
|
+
let location;
|
|
1217
|
+
if (trimmed.includes("://")) {
|
|
1218
|
+
const parsed = new URL(trimmed);
|
|
1219
|
+
location = `${parsed.hostname}${parsed.pathname}`;
|
|
1220
|
+
} else location = trimmed.slice(trimmed.indexOf("@") + 1).replace(":", "/");
|
|
1221
|
+
return location.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
1222
|
+
}
|
|
1223
|
+
function findEntry(map, project) {
|
|
1224
|
+
if (project.remote) {
|
|
1225
|
+
const remote = normalizeRemote(project.remote);
|
|
1226
|
+
const byRemote = map.projects.find((entry) => entry.remote != null && normalizeRemote(entry.remote) === remote);
|
|
1227
|
+
if (byRemote) return byRemote;
|
|
1228
|
+
}
|
|
1229
|
+
return map.projects.find((entry) => entry.remote == null && entry.path === project.path);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* 按位置找到的条目补上新建的远程地址;返回是否有变化。按远程地址识别的条目不再
|
|
1233
|
+
* 依赖位置,位置保持登记时的值(同一远程的多个副本不会来回改写)。
|
|
1234
|
+
*/
|
|
1235
|
+
function refreshEntry(entry, project) {
|
|
1236
|
+
if (project.remote && entry.remote == null) {
|
|
1237
|
+
entry.remote = project.remote;
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
/** 查表;有远程仓库且未登记时自动以仓库名登记。就地修改 map,返回条目与是否有变化。 */
|
|
1243
|
+
function resolveProject(map, project) {
|
|
1244
|
+
const existing = findEntry(map, project);
|
|
1245
|
+
if (existing) return {
|
|
1246
|
+
entry: existing,
|
|
1247
|
+
changed: refreshEntry(existing, project)
|
|
1248
|
+
};
|
|
1249
|
+
if (!project.remote) throw new CliError(`当前代码项目未登记,运行 genlab project <名字> 登记:${project.path}`);
|
|
1250
|
+
const name = validateName(repoName(project.remote), "项目名");
|
|
1251
|
+
if (map.projects.some((entry) => entry.name === name)) throw new CliError(`项目名 ${name} 已被其他代码项目使用,运行 genlab project <名字> 指定`);
|
|
1252
|
+
const entry = {
|
|
1253
|
+
name,
|
|
1254
|
+
remote: project.remote,
|
|
1255
|
+
path: project.path
|
|
1256
|
+
};
|
|
1257
|
+
map.projects.push(entry);
|
|
1258
|
+
return {
|
|
1259
|
+
entry,
|
|
1260
|
+
changed: true
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
/** 登记或修改当前代码项目对应的项目名;只改对应关系,不移动素材文件夹。 */
|
|
1264
|
+
function assignProject(map, project, name) {
|
|
1265
|
+
const validName = validateName(name, "项目名");
|
|
1266
|
+
if (validName === "projects.json") throw new CliError(`项目名不能是 ${PROJECT_MAP_FILE}`);
|
|
1267
|
+
const existing = findEntry(map, project);
|
|
1268
|
+
if (existing) {
|
|
1269
|
+
refreshEntry(existing, project);
|
|
1270
|
+
existing.name = validName;
|
|
1271
|
+
return existing;
|
|
1272
|
+
}
|
|
1273
|
+
const entry = {
|
|
1274
|
+
name: validName,
|
|
1275
|
+
remote: project.remote,
|
|
1276
|
+
path: project.path
|
|
1277
|
+
};
|
|
1278
|
+
map.projects.push(entry);
|
|
1279
|
+
return entry;
|
|
1280
|
+
}
|
|
1281
|
+
/** 互斥地读改写库根目录下的映射表;mutate 返回是否需要写回。 */
|
|
1282
|
+
async function updateProjectMap(root, mutate) {
|
|
1283
|
+
const file = path.join(root, PROJECT_MAP_FILE);
|
|
1284
|
+
await mkdir(root, { recursive: true });
|
|
1285
|
+
try {
|
|
1286
|
+
await writeFile(file, `${JSON.stringify({ projects: [] }, null, 2)}\n`, { flag: "wx" });
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
if (error.code !== "EEXIST") throw error;
|
|
1289
|
+
}
|
|
1290
|
+
const release = await lockfile.lock(file, {
|
|
1291
|
+
retries: {
|
|
1292
|
+
retries: 50,
|
|
1293
|
+
minTimeout: 20,
|
|
1294
|
+
maxTimeout: 200
|
|
1295
|
+
},
|
|
1296
|
+
stale: 3e4
|
|
1297
|
+
});
|
|
1298
|
+
try {
|
|
1299
|
+
const map = JSON.parse(await readFile(file, "utf8"));
|
|
1300
|
+
const { result, changed } = mutate(map);
|
|
1301
|
+
if (changed) await writeFileAtomic(file, `${JSON.stringify(map, null, 2)}\n`);
|
|
1302
|
+
return result;
|
|
1303
|
+
} finally {
|
|
1304
|
+
await release();
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
// src/library/context.ts
|
|
1308
|
+
/** 命令的运行上下文:库根目录 + 当前代码项目对应的项目 + 素材定位。 */
|
|
1309
|
+
async function currentProject(cwd = process.cwd()) {
|
|
1310
|
+
const root = libraryRoot();
|
|
1311
|
+
const codeProject = await identifyCodeProject(cwd);
|
|
1312
|
+
const name = await updateProjectMap(root, (map) => {
|
|
1313
|
+
const { entry, changed } = resolveProject(map, codeProject);
|
|
1314
|
+
return {
|
|
1315
|
+
result: entry.name,
|
|
1316
|
+
changed
|
|
1317
|
+
};
|
|
1318
|
+
});
|
|
1319
|
+
return {
|
|
1320
|
+
root,
|
|
1321
|
+
name,
|
|
1322
|
+
dir: path.join(root, name),
|
|
1323
|
+
codeProject
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
function locateAsset(project, assetPath) {
|
|
1327
|
+
const segments = parseAssetPath(assetPath);
|
|
1328
|
+
return {
|
|
1329
|
+
path: segments.join("/"),
|
|
1330
|
+
dir: path.join(project.dir, ...segments)
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
// src/present.ts
|
|
1334
|
+
/** 命令输出的 JSON 形状:路径一律绝对路径,空字段省略。 */
|
|
1335
|
+
/** 去掉值为 null / undefined 的字段。 */
|
|
1336
|
+
function compact(object) {
|
|
1337
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value != null));
|
|
1338
|
+
}
|
|
1339
|
+
function presentVersion(dir, version) {
|
|
1340
|
+
return compact({
|
|
1341
|
+
version: version.number,
|
|
1342
|
+
state: version.state,
|
|
1343
|
+
file: version.file ? path.join(dir, version.file) : null,
|
|
1344
|
+
mimeType: version.mimeType,
|
|
1345
|
+
width: version.width,
|
|
1346
|
+
height: version.height,
|
|
1347
|
+
duration: version.duration,
|
|
1348
|
+
fileSize: version.fileSize,
|
|
1349
|
+
credits: version.credits,
|
|
1350
|
+
failReason: version.failReason,
|
|
1351
|
+
generation: version.generation,
|
|
1352
|
+
sourceVersion: version.sourceVersion,
|
|
1353
|
+
importedFrom: version.importedFrom
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* 加上与这些版本同一个 kie 任务的版本:一个任务返回多个结果时,多出来的结果在收取时
|
|
1358
|
+
* 才追加为新版本。
|
|
1359
|
+
*/
|
|
1360
|
+
function withTaskSiblings(record, versions) {
|
|
1361
|
+
const taskIds = new Set(record.versions.filter((version) => versions.includes(version.number)).map((version) => version.taskId).filter((taskId) => taskId != null));
|
|
1362
|
+
return record.versions.filter((version) => versions.includes(version.number) || version.taskId != null && taskIds.has(version.taskId)).map((version) => version.number);
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* versions 指定时只列这些版本(连同同一任务追加出的版本);pending 表示其中仍有生成中
|
|
1366
|
+
* 的版本。
|
|
1367
|
+
*/
|
|
1368
|
+
function presentAsset(project, location, record, versions) {
|
|
1369
|
+
const wanted = versions && withTaskSiblings(record, versions);
|
|
1370
|
+
const listed = record.versions.filter((version) => !wanted || wanted.includes(version.number));
|
|
1371
|
+
return {
|
|
1372
|
+
project: project.name,
|
|
1373
|
+
asset: location.path,
|
|
1374
|
+
dir: location.dir,
|
|
1375
|
+
kind: record.kind,
|
|
1376
|
+
current: record.currentVersion,
|
|
1377
|
+
pending: listed.some((version) => version.state === "generating"),
|
|
1378
|
+
versions: listed.map((version) => presentVersion(location.dir, version))
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
function presentGenerations(dir, record) {
|
|
1382
|
+
return record.generations.map((generation) => compact({
|
|
1383
|
+
generation: generation.id,
|
|
1384
|
+
model: generation.model,
|
|
1385
|
+
input: generation.input,
|
|
1386
|
+
sourceVersion: generation.sourceVersion,
|
|
1387
|
+
references: generation.references.map((reference) => compact({
|
|
1388
|
+
file: path.join(dir, reference.file),
|
|
1389
|
+
name: reference.name,
|
|
1390
|
+
kind: reference.kind,
|
|
1391
|
+
origin: reference.origin
|
|
1392
|
+
})),
|
|
1393
|
+
createdAt: generation.createdAt
|
|
1394
|
+
}));
|
|
1395
|
+
}
|
|
1396
|
+
// src/commands/output.ts
|
|
1397
|
+
/** 命令输出:结果 JSON 写 stdout,警告与错误 JSON 写 stderr。 */
|
|
1398
|
+
function warn(message) {
|
|
1399
|
+
process.stderr.write(`${JSON.stringify({ warning: message })}\n`);
|
|
1400
|
+
}
|
|
1401
|
+
/** 包装命令动作:输出结果,出错时输出错误并以退出码 1 结束。 */
|
|
1402
|
+
function action(run) {
|
|
1403
|
+
return async (...args) => {
|
|
1404
|
+
try {
|
|
1405
|
+
const result = await run(...args);
|
|
1406
|
+
const indent = process.stdout.isTTY ? 2 : void 0;
|
|
1407
|
+
process.stdout.write(`${JSON.stringify(result, null, indent)}\n`);
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1410
|
+
process.stderr.write(`${JSON.stringify({ error: message })}\n`);
|
|
1411
|
+
process.exitCode = 1;
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
// src/commands/generation.ts
|
|
1416
|
+
/** 发起生成与等待结果的命令:create / generate / edit / retry / wait。 */
|
|
1417
|
+
function toGenerateOptions(args) {
|
|
1418
|
+
return {
|
|
1419
|
+
model: args.model,
|
|
1420
|
+
params: args.param,
|
|
1421
|
+
count: args.count
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
/** 按需等待新版本完成,再输出这些版本的状态。 */
|
|
1425
|
+
async function finish(project, location, versions, options) {
|
|
1426
|
+
if (options.wait) {
|
|
1427
|
+
const { warnings } = await waitForVersions([{
|
|
1428
|
+
dir: location.dir,
|
|
1429
|
+
versions
|
|
1430
|
+
}], options.timeout * 1e3);
|
|
1431
|
+
warnings.forEach(warn);
|
|
1432
|
+
}
|
|
1433
|
+
return presentAsset(project, location, await readAsset(location.dir), versions);
|
|
1434
|
+
}
|
|
1435
|
+
async function createCommand(assetPath, args) {
|
|
1436
|
+
const project = await currentProject();
|
|
1437
|
+
const location = locateAsset(project, assetPath);
|
|
1438
|
+
return finish(project, location, await createAsset(project, location, toGenerateOptions(args)), args);
|
|
1439
|
+
}
|
|
1440
|
+
async function generateCommand(assetPath, args) {
|
|
1441
|
+
const project = await currentProject();
|
|
1442
|
+
const location = locateAsset(project, assetPath);
|
|
1443
|
+
return finish(project, location, await generateInAsset(project, location, {
|
|
1444
|
+
...toGenerateOptions(args),
|
|
1445
|
+
from: args.from == null ? void 0 : parseVersionArg(args.from)
|
|
1446
|
+
}), args);
|
|
1447
|
+
}
|
|
1448
|
+
async function editCommand(assetPath, version, args) {
|
|
1449
|
+
const project = await currentProject();
|
|
1450
|
+
const location = locateAsset(project, assetPath);
|
|
1451
|
+
return finish(project, location, await editVersion(project, location, parseVersionArg(version), toGenerateOptions(args)), args);
|
|
1452
|
+
}
|
|
1453
|
+
async function retryCommand(assetPath, version, args) {
|
|
1454
|
+
const project = await currentProject();
|
|
1455
|
+
const location = locateAsset(project, assetPath);
|
|
1456
|
+
return finish(project, location, await retryVersion(location, parseVersionArg(version)), args);
|
|
1457
|
+
}
|
|
1458
|
+
/** 等待某个素材、或当前项目里全部生成中的版本。 */
|
|
1459
|
+
async function waitCommand(assetPath, args) {
|
|
1460
|
+
const project = await currentProject();
|
|
1461
|
+
const dirs = assetPath ? [locateAsset(project, assetPath).dir] : await findAssetDirs(project.dir);
|
|
1462
|
+
const targets = [];
|
|
1463
|
+
for (const dir of dirs) {
|
|
1464
|
+
const record = await readAsset(dir);
|
|
1465
|
+
const versions = record.versions.filter((version) => version.state === "generating").map((version) => version.number);
|
|
1466
|
+
if (versions.length > 0) targets.push({
|
|
1467
|
+
dir,
|
|
1468
|
+
versions
|
|
1469
|
+
});
|
|
1470
|
+
else if (assetPath) targets.push({
|
|
1471
|
+
dir,
|
|
1472
|
+
versions: record.versions.map((v) => v.number)
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
const { warnings } = await waitForVersions(targets, args.timeout * 1e3);
|
|
1476
|
+
warnings.forEach(warn);
|
|
1477
|
+
return Promise.all(targets.map(async ({ dir, versions }) => presentAsset(project, {
|
|
1478
|
+
path: path.relative(project.dir, dir).split(path.sep).join("/"),
|
|
1479
|
+
dir
|
|
1480
|
+
}, await readAsset(dir), versions)));
|
|
1481
|
+
}
|
|
1482
|
+
// src/commands/library.ts
|
|
1483
|
+
/** 库与元信息命令:models / project / import / use / delete / show / list。 */
|
|
1484
|
+
/** 收取库里所有已完成的生成(每条库命令执行前跑一遍,不等待)。 */
|
|
1485
|
+
async function syncLibrary() {
|
|
1486
|
+
(await syncAssets(await findAssetDirs(libraryRoot()))).forEach(warn);
|
|
1487
|
+
}
|
|
1488
|
+
function parseKind(kind) {
|
|
1489
|
+
if (kind != null && !MEDIA_KINDS.includes(kind)) throw new CliError(`类型可选值:${MEDIA_KINDS.join(", ")}`);
|
|
1490
|
+
return kind;
|
|
1491
|
+
}
|
|
1492
|
+
/** 模型列表(来自 kie 模型目录),或单个模型的价格与参数表。 */
|
|
1493
|
+
async function modelsCommand(id, args) {
|
|
1494
|
+
if (id) {
|
|
1495
|
+
const model = await findModel(id);
|
|
1496
|
+
return {
|
|
1497
|
+
model: model.model,
|
|
1498
|
+
kind: model.kind,
|
|
1499
|
+
taskType: model.taskType,
|
|
1500
|
+
provider: model.provider,
|
|
1501
|
+
price: model.pricingDesc,
|
|
1502
|
+
...describeInput(await getInputSchema(model.model, args.refresh))
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
const kind = parseKind(args.kind);
|
|
1506
|
+
const search = args.search?.toLowerCase();
|
|
1507
|
+
const models = [];
|
|
1508
|
+
for (const model of await listModels(args.refresh)) {
|
|
1509
|
+
let modelKind;
|
|
1510
|
+
try {
|
|
1511
|
+
modelKind = kindForModel(model);
|
|
1512
|
+
} catch {
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
if (!modelKind || kind && modelKind !== kind) continue;
|
|
1516
|
+
const text = `${model.model} ${model.provider ?? ""} ${model.title ?? ""}`;
|
|
1517
|
+
if (search && !text.toLowerCase().includes(search)) continue;
|
|
1518
|
+
models.push({
|
|
1519
|
+
model: model.model,
|
|
1520
|
+
kind: modelKind,
|
|
1521
|
+
taskType: model.taskType,
|
|
1522
|
+
provider: model.provider
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
return models;
|
|
1526
|
+
}
|
|
1527
|
+
async function projectCommand(name) {
|
|
1528
|
+
if (name) {
|
|
1529
|
+
const root = libraryRoot();
|
|
1530
|
+
const codeProject = await identifyCodeProject(process.cwd());
|
|
1531
|
+
await updateProjectMap(root, (map) => ({
|
|
1532
|
+
result: assignProject(map, codeProject, name),
|
|
1533
|
+
changed: true
|
|
1534
|
+
}));
|
|
1535
|
+
}
|
|
1536
|
+
const project = await currentProject();
|
|
1537
|
+
return {
|
|
1538
|
+
project: project.name,
|
|
1539
|
+
dir: project.dir,
|
|
1540
|
+
remote: project.codeProject.remote,
|
|
1541
|
+
codeProject: project.codeProject.path
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
async function importCommand(assetPath, file) {
|
|
1545
|
+
const project = await currentProject();
|
|
1546
|
+
const location = locateAsset(project, assetPath);
|
|
1547
|
+
await importAsset(project, location, file);
|
|
1548
|
+
return presentAsset(project, location, await readAsset(location.dir));
|
|
1549
|
+
}
|
|
1550
|
+
async function useCommand(assetPath, version) {
|
|
1551
|
+
const project = await currentProject();
|
|
1552
|
+
const location = locateAsset(project, assetPath);
|
|
1553
|
+
await setCurrentVersion(location, parseVersionArg(version));
|
|
1554
|
+
return presentAsset(project, location, await readAsset(location.dir));
|
|
1555
|
+
}
|
|
1556
|
+
async function deleteCommand(assetPath, version) {
|
|
1557
|
+
const project = await currentProject();
|
|
1558
|
+
const location = locateAsset(project, assetPath);
|
|
1559
|
+
await deleteVersion(location, parseVersionArg(version));
|
|
1560
|
+
return presentAsset(project, location, await readAsset(location.dir));
|
|
1561
|
+
}
|
|
1562
|
+
async function showCommand(assetPath) {
|
|
1563
|
+
const project = await currentProject();
|
|
1564
|
+
const location = locateAsset(project, assetPath);
|
|
1565
|
+
const record = await readAsset(location.dir);
|
|
1566
|
+
return {
|
|
1567
|
+
...presentAsset(project, location, record),
|
|
1568
|
+
record: assetFile(location.dir),
|
|
1569
|
+
generations: presentGenerations(location.dir, record)
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
async function listCommand(folder, args) {
|
|
1573
|
+
const kind = parseKind(args.kind);
|
|
1574
|
+
const project = await currentProject();
|
|
1575
|
+
const base = folder ? path.join(project.dir, ...parseAssetPath(folder)) : project.dir;
|
|
1576
|
+
const assets = [];
|
|
1577
|
+
for (const dir of await findAssetDirs(base)) {
|
|
1578
|
+
const record = await readAsset(dir);
|
|
1579
|
+
if (kind && record.kind !== kind) continue;
|
|
1580
|
+
const current = record.versions.find((version) => version.number === record.currentVersion);
|
|
1581
|
+
assets.push({
|
|
1582
|
+
asset: path.relative(project.dir, dir).split(path.sep).join("/"),
|
|
1583
|
+
kind: record.kind,
|
|
1584
|
+
versions: record.versions.length,
|
|
1585
|
+
generating: record.versions.filter((v) => v.state === "generating").length,
|
|
1586
|
+
failed: record.versions.filter((v) => v.state === "failed").length,
|
|
1587
|
+
current: current ? presentVersion(dir, current) : null
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
return {
|
|
1591
|
+
project: project.name,
|
|
1592
|
+
dir: project.dir,
|
|
1593
|
+
assets
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
// src/commands/init.ts
|
|
1597
|
+
/** genlab init:写入配置文件(API Key 与库目录),先验证 Key 可用。 */
|
|
1598
|
+
const DEFAULT_LIBRARY = "~/GenLab";
|
|
1599
|
+
async function readConfig(file) {
|
|
1600
|
+
try {
|
|
1601
|
+
return parseEnv(await readFile(file, "utf8"));
|
|
1602
|
+
} catch (error) {
|
|
1603
|
+
if (error.code === "ENOENT") return {};
|
|
1604
|
+
throw error;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
/** 交互输入被取消时退出。 */
|
|
1608
|
+
function answered(value) {
|
|
1609
|
+
if (p.isCancel(value)) {
|
|
1610
|
+
p.cancel("已取消");
|
|
1611
|
+
process.exit(1);
|
|
1612
|
+
}
|
|
1613
|
+
return value;
|
|
1614
|
+
}
|
|
1615
|
+
function expandHome(dir) {
|
|
1616
|
+
return dir === "~" || dir.startsWith("~/") ? path.join(homedir(), dir.slice(1)) : dir;
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* 未通过选项给出的值:终端里交互询问(直接回车沿用已有配置),非终端时沿用已有配置,
|
|
1620
|
+
* 都没有就报错。
|
|
1621
|
+
*/
|
|
1622
|
+
async function initCommand(args) {
|
|
1623
|
+
const file = configFile();
|
|
1624
|
+
const existing = await readConfig(file);
|
|
1625
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1626
|
+
let key = args.key ?? existing.KIE_API_KEY;
|
|
1627
|
+
let library = args.library ?? existing.GENLAB_LIBRARY;
|
|
1628
|
+
if (interactive && !args.key) key = answered(await p.password({
|
|
1629
|
+
message: existing.KIE_API_KEY ? "kie.ai API Key(回车沿用已有)" : "kie.ai API Key(https://kie.ai/api-key)",
|
|
1630
|
+
validate: (value) => value || existing.KIE_API_KEY ? void 0 : "不能为空"
|
|
1631
|
+
})) || existing.KIE_API_KEY;
|
|
1632
|
+
if (interactive && !args.library) library = answered(await p.text({
|
|
1633
|
+
message: "库目录",
|
|
1634
|
+
initialValue: existing.GENLAB_LIBRARY ?? DEFAULT_LIBRARY,
|
|
1635
|
+
validate: (value) => value ? void 0 : "不能为空"
|
|
1636
|
+
}));
|
|
1637
|
+
if (!key) throw new CliError("缺少 --key");
|
|
1638
|
+
library ||= DEFAULT_LIBRARY;
|
|
1639
|
+
if (!path.isAbsolute(expandHome(library))) throw new CliError(`库目录必须是绝对路径:${library}`);
|
|
1640
|
+
const credits = await getKieCredits(key);
|
|
1641
|
+
await mkdir(expandHome(library), { recursive: true });
|
|
1642
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1643
|
+
await writeFileAtomic(file, `KIE_API_KEY=${JSON.stringify(key)}\nGENLAB_LIBRARY=${JSON.stringify(library)}\n`, { mode: 384 });
|
|
1644
|
+
return {
|
|
1645
|
+
config: file,
|
|
1646
|
+
library: expandHome(library),
|
|
1647
|
+
credits
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
// src/cli.ts
|
|
1651
|
+
/** 单次命令默认最多等待的秒数:agent 执行命令通常 2 分钟超时。 */
|
|
1652
|
+
const DEFAULT_TIMEOUT_SECONDS = 90;
|
|
1653
|
+
function positiveInteger(value) {
|
|
1654
|
+
const parsed = Number(value);
|
|
1655
|
+
if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("应为正整数");
|
|
1656
|
+
return parsed;
|
|
1657
|
+
}
|
|
1658
|
+
function collect(value, previous) {
|
|
1659
|
+
return [...previous, value];
|
|
1660
|
+
}
|
|
1661
|
+
/** 先收取库里已完成的生成,再执行命令。 */
|
|
1662
|
+
function librarySynced(run) {
|
|
1663
|
+
return action(async (...args) => {
|
|
1664
|
+
await syncLibrary();
|
|
1665
|
+
return run(...args);
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
const program = new Command("genlab").description("AI 媒体生成(kie.ai),产物与生成记录保存在本机的库里").version(version).showHelpAfterError();
|
|
1669
|
+
function generationOptions(command, requireModel) {
|
|
1670
|
+
return command.option("-m, --model <id>", requireModel ? "模型(必填,见 genlab models)" : "模型,默认沿用").option("-p, --param <key=value>", "模型参数,可重复;key:=JSON 传 JSON 值,值写 @文件 上传本机文件", collect, []).option("-n, --count <n>", "并列发起几个任务", positiveInteger);
|
|
1671
|
+
}
|
|
1672
|
+
function waitOptions(command) {
|
|
1673
|
+
return command.option("--no-wait", "提交后立即返回,不等待结果").option("--timeout <seconds>", "最多等待的秒数", positiveInteger, DEFAULT_TIMEOUT_SECONDS);
|
|
1674
|
+
}
|
|
1675
|
+
program.command("init").description("写入配置(kie.ai API Key 与库目录)").option("--key <key>", "kie.ai API Key").option("--library <dir>", "库目录").action(action(initCommand));
|
|
1676
|
+
program.command("models").description("列出 kie 模型;指定模型时输出价格与参数表").argument("[id]", "模型标识").option("--kind <kind>", "按类型筛选:image / video / audio").option("--search <text>", "按模型标识、供应商、标题筛选").option("--refresh", "忽略缓存,重新获取").action(action(modelsCommand));
|
|
1677
|
+
program.command("project").description("查看当前代码项目对应的项目;带名字时登记或修改").argument("[name]", "项目名").action(action(projectCommand));
|
|
1678
|
+
waitOptions(generationOptions(program.command("create").description("新建素材并发起第一次生成").argument("<asset>", "素材路径,如 角色/主角立绘"), true)).action(librarySynced(createCommand));
|
|
1679
|
+
waitOptions(generationOptions(program.command("generate").description("在已有素材里发起生成").argument("<asset>", "素材路径").option("--from <version>", "沿用某个版本的生成输入(重新生成)"), false)).action(librarySynced(generateCommand));
|
|
1680
|
+
waitOptions(generationOptions(program.command("edit").description("AI 编辑:以某个就绪版本为源版本发起生成,参数里用 @source 引用它").argument("<asset>", "素材路径").argument("<version>", "源版本号"), false)).action(librarySynced(editCommand));
|
|
1681
|
+
waitOptions(program.command("retry").description("原地重试失败的版本").argument("<asset>", "素材路径").argument("<version>", "版本号")).action(librarySynced(retryCommand));
|
|
1682
|
+
program.command("wait").description("等待生成中的版本;不指定素材时等待当前项目的全部").argument("[asset]", "素材路径").option("--timeout <seconds>", "最多等待的秒数", positiveInteger, DEFAULT_TIMEOUT_SECONDS).action(action(waitCommand));
|
|
1683
|
+
program.command("import").description("把外部文件导入为新素材的第一个版本").argument("<asset>", "素材路径").argument("<file>", "文件").action(librarySynced(importCommand));
|
|
1684
|
+
program.command("use").description("设为当前版本").argument("<asset>", "素材路径").argument("<version>", "版本号").action(librarySynced(useCommand));
|
|
1685
|
+
program.command("delete").description("删除版本(只能删没有派生版本的版本)").argument("<asset>", "素材路径").argument("<version>", "版本号").action(librarySynced(deleteCommand));
|
|
1686
|
+
program.command("show").description("查看素材的全部版本、生成输入与文件路径").argument("<asset>", "素材路径").action(librarySynced(showCommand));
|
|
1687
|
+
program.command("list").description("列出当前项目的素材").argument("[folder]", "只列某个文件夹").option("--kind <kind>", "按类型筛选:image / video / audio").action(librarySynced(listCommand));
|
|
1688
|
+
await program.parseAsync();
|
|
1689
|
+
export {};
|