@lingjingai/lj-awb-cli-pre 0.3.15
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 +335 -0
- package/build/_shared.mjs +130 -0
- package/build/build.mjs +50 -0
- package/build/pre-publish.mjs +57 -0
- package/build/pre.mjs +42 -0
- package/build/prod.mjs +52 -0
- package/install.mjs +53 -0
- package/package.json +44 -0
- package/packages/awb-cli/README.md +19 -0
- package/packages/awb-cli/bin/lj-awb +19 -0
- package/packages/awb-cli/bin/lj-awb.js +11 -0
- package/packages/awb-cli/package.json +18 -0
- package/packages/awb-core/README.md +12 -0
- package/packages/awb-core/package.json +21 -0
- package/packages/awb-core/src/api.js +349 -0
- package/packages/awb-core/src/artifact.js +936 -0
- package/packages/awb-core/src/auth.js +80 -0
- package/packages/awb-core/src/commands.js +1321 -0
- package/packages/awb-core/src/common.js +508 -0
- package/packages/awb-core/src/output.js +1189 -0
- package/packages/awb-core/src/services.js +3811 -0
- package/packages/awb-core/src/standalone.js +1213 -0
- package/skills/lj-awb/SKILL.md +160 -0
- package/skills/lj-awb/VERSION +1 -0
- package/skills/lj-awb/compat.json +6 -0
- package/skills/lj-awb/modules/account.md +30 -0
- package/skills/lj-awb/modules/artifact/asset.md +64 -0
- package/skills/lj-awb/modules/artifact/clip.md +65 -0
- package/skills/lj-awb/modules/artifact/script.md +37 -0
- package/skills/lj-awb/modules/artifact/video.md +65 -0
- package/skills/lj-awb/modules/artifact.md +65 -0
- package/skills/lj-awb/modules/asset.md +53 -0
- package/skills/lj-awb/modules/auth.md +30 -0
- package/skills/lj-awb/modules/create-contract.md +118 -0
- package/skills/lj-awb/modules/credits.md +28 -0
- package/skills/lj-awb/modules/evals.md +186 -0
- package/skills/lj-awb/modules/image.md +75 -0
- package/skills/lj-awb/modules/model.md +110 -0
- package/skills/lj-awb/modules/project.md +30 -0
- package/skills/lj-awb/modules/subject.md +32 -0
- package/skills/lj-awb/modules/task-manual.md +185 -0
- package/skills/lj-awb/modules/task.md +62 -0
- package/skills/lj-awb/modules/upload.md +33 -0
- package/skills/lj-awb/modules/video.md +102 -0
- package/skills/lj-awb/modules/workflows.md +482 -0
- package/skills/lj-awb/references/error-codes.md +102 -0
- package/skills/lj-awb/references/model-options-read.md +49 -0
- package/skills/lj-awb/references/output-fields.md +113 -0
- package/skills/lj-awb/scripts/resolve-lj-awb-cmd.sh +10 -0
|
@@ -0,0 +1,3811 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import {
|
|
8
|
+
API_ORIGIN,
|
|
9
|
+
APP_HOME_DIR,
|
|
10
|
+
AUTH_PATH,
|
|
11
|
+
DEFAULT_API_ORIGIN,
|
|
12
|
+
STATE_PATH,
|
|
13
|
+
TASK_UPLOAD_SCENE,
|
|
14
|
+
LingjingAwbCliError,
|
|
15
|
+
buildCosAuthorization,
|
|
16
|
+
encodeObjectNamePath,
|
|
17
|
+
firstArray,
|
|
18
|
+
flattenRecord,
|
|
19
|
+
guessMimeType,
|
|
20
|
+
inspectLocalFile,
|
|
21
|
+
inspectLocalFiles,
|
|
22
|
+
isSuccessTaskStatus,
|
|
23
|
+
isTerminalTaskStatus,
|
|
24
|
+
loadState,
|
|
25
|
+
normalizeFeedTaskType,
|
|
26
|
+
nowIso,
|
|
27
|
+
parseJsonArg,
|
|
28
|
+
parseListArg,
|
|
29
|
+
safeFileName,
|
|
30
|
+
saveState,
|
|
31
|
+
sleep,
|
|
32
|
+
splitCsv,
|
|
33
|
+
taskStatusText,
|
|
34
|
+
taskTypeToStatusKind,
|
|
35
|
+
toBool,
|
|
36
|
+
toInt,
|
|
37
|
+
toNumberOrNull,
|
|
38
|
+
trimToNull,
|
|
39
|
+
uniqueNonEmpty,
|
|
40
|
+
} from './common.js';
|
|
41
|
+
import { loadAuth, resolveAuthContext, summarizeAuth } from './auth.js';
|
|
42
|
+
import * as awbApi from './api.js';
|
|
43
|
+
|
|
44
|
+
const SITE = 'lj-awb';
|
|
45
|
+
const REQUEST_SOURCE_CLI = 'LINGJING_AWB_CLI';
|
|
46
|
+
const DEFAULT_TASK_RECORD_FILE_ENV = process.env.LINGJING_AWB_TASK_RECORD_FILE || process.env.AWB_TASK_RECORD_FILE;
|
|
47
|
+
const execFileAsync = promisify(execFile);
|
|
48
|
+
const COMMON_IMAGE_FORMATS = new Set(['jpg', 'jpeg', 'jfif', 'png', 'webp']);
|
|
49
|
+
|
|
50
|
+
export function normalizeUserInfo(payload) {
|
|
51
|
+
const data = payload && typeof payload === 'object' ? payload : {};
|
|
52
|
+
return {
|
|
53
|
+
userId: data.userId ?? data.id ?? null,
|
|
54
|
+
userName: data.userName ?? data.name ?? data.nickName ?? null,
|
|
55
|
+
phone: data.phone ?? null,
|
|
56
|
+
avatarUrl: data.avatarUrl ?? data.avatar ?? null,
|
|
57
|
+
groupId: data.groupId ?? data.currentGroupId ?? null,
|
|
58
|
+
groupName: data.groupName ?? data.currentGroupName ?? null,
|
|
59
|
+
permission: data.permission ?? null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizeRows(payload) {
|
|
64
|
+
if (Array.isArray(payload)) return payload;
|
|
65
|
+
if (!payload || typeof payload !== 'object') return [];
|
|
66
|
+
for (const value of Object.values(payload)) {
|
|
67
|
+
if (Array.isArray(value)) return value;
|
|
68
|
+
}
|
|
69
|
+
return firstArray(payload);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function argumentError(message, hint = '') {
|
|
73
|
+
return new LingjingAwbCliError(message, {
|
|
74
|
+
type: 'argument_error',
|
|
75
|
+
exitCode: 2,
|
|
76
|
+
hint,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function compactRecord(record = {}) {
|
|
81
|
+
return Object.fromEntries(Object.entries(record)
|
|
82
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== ''));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function requireValue(kwargs, key, label = key) {
|
|
86
|
+
const value = trimToNull(kwargs?.[key]);
|
|
87
|
+
if (!value) {
|
|
88
|
+
throw argumentError(`缺少参数:--${label.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`)}`);
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ensureConfirmed(kwargs, message, details = {}) {
|
|
94
|
+
if (toBool(kwargs?.dryRun)) return;
|
|
95
|
+
if (toBool(kwargs?.yes)) return;
|
|
96
|
+
throw new LingjingAwbCliError(message, {
|
|
97
|
+
type: 'confirmation_required',
|
|
98
|
+
exitCode: 10,
|
|
99
|
+
hint: '确认后追加 --yes 重试;如果只是预览,请追加 --dry-run。',
|
|
100
|
+
details,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function envProjectGroupNo() {
|
|
105
|
+
return trimToNull(
|
|
106
|
+
process.env.LINGJING_AWB_PROJECT_GROUP_NO
|
|
107
|
+
|| process.env.AWB_PROJECT_GROUP_NO
|
|
108
|
+
|| process.env.PROJECT_GROUP_NO
|
|
109
|
+
|| process.env.SANDBOX_PROJECT_GROUP_NO,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractProjectGroupNo(payload) {
|
|
114
|
+
if (typeof payload === 'string') return trimToNull(payload);
|
|
115
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
116
|
+
return trimToNull(
|
|
117
|
+
payload.projectGroupNo
|
|
118
|
+
?? payload.lastProjectGroupNo
|
|
119
|
+
?? payload.no
|
|
120
|
+
?? payload.projectGroup?.projectGroupNo
|
|
121
|
+
?? payload.projectGroup?.no,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function resolveProjectGroupNo(explicit, options = {}) {
|
|
126
|
+
const explicitProjectGroupNo = trimToNull(explicit);
|
|
127
|
+
if (explicitProjectGroupNo) {
|
|
128
|
+
if (!options.noSave) await saveState({ currentProjectGroupNo: explicitProjectGroupNo }).catch(() => {});
|
|
129
|
+
return explicitProjectGroupNo;
|
|
130
|
+
}
|
|
131
|
+
const fromEnv = envProjectGroupNo();
|
|
132
|
+
if (fromEnv) {
|
|
133
|
+
if (!options.noSave) await saveState({ currentProjectGroupNo: fromEnv }).catch(() => {});
|
|
134
|
+
return fromEnv;
|
|
135
|
+
}
|
|
136
|
+
if (!options.noNetwork) {
|
|
137
|
+
const current = await awbApi.fetchCurrentProjectGroup().catch(() => null);
|
|
138
|
+
const currentNo = extractProjectGroupNo(current);
|
|
139
|
+
if (currentNo) {
|
|
140
|
+
if (!options.noSave) await saveState({ currentProjectGroupNo: currentNo }).catch(() => {});
|
|
141
|
+
return currentNo;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const state = await loadState().catch(() => ({}));
|
|
145
|
+
if (trimToNull(state?.currentProjectGroupNo)) return trimToNull(state.currentProjectGroupNo);
|
|
146
|
+
if (options.allowNull) return null;
|
|
147
|
+
throw argumentError('未识别当前项目组', '请传 --project-group-no <no>,或先运行 project list 查看并选择。');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function extractPointBalance(payload) {
|
|
151
|
+
if (payload == null) return null;
|
|
152
|
+
if (typeof payload === 'number') return payload;
|
|
153
|
+
if (typeof payload !== 'object') return null;
|
|
154
|
+
for (const value of [
|
|
155
|
+
payload.point,
|
|
156
|
+
payload.groupPoint,
|
|
157
|
+
payload.availablePoint,
|
|
158
|
+
payload.totalPoint,
|
|
159
|
+
payload.teamIntegral,
|
|
160
|
+
payload.projectGroupIntegralCurrent,
|
|
161
|
+
payload.personIntegralCurrent,
|
|
162
|
+
]) {
|
|
163
|
+
const numeric = toNumberOrNull(value);
|
|
164
|
+
if (numeric != null) return numeric;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizeProjectGroupRecord(item, selectedProjectGroupNo = null) {
|
|
170
|
+
const projectGroupNo = item?.projectGroupNo ?? item?.no ?? null;
|
|
171
|
+
return {
|
|
172
|
+
projectGroupNo,
|
|
173
|
+
projectGroupName: item?.projectGroupName ?? item?.name ?? null,
|
|
174
|
+
point: item?.point ?? null,
|
|
175
|
+
isSelected: Boolean(projectGroupNo && selectedProjectGroupNo && projectGroupNo === selectedProjectGroupNo),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function listProjectGroups(kwargs = {}) {
|
|
180
|
+
const [listPayload, currentPayload] = await Promise.all([
|
|
181
|
+
awbApi.fetchProjectGroups(),
|
|
182
|
+
awbApi.fetchCurrentProjectGroup().catch(() => null),
|
|
183
|
+
]);
|
|
184
|
+
const selectedProjectGroupNo = extractProjectGroupNo(currentPayload);
|
|
185
|
+
if (selectedProjectGroupNo) await saveState({ currentProjectGroupNo: selectedProjectGroupNo }).catch(() => {});
|
|
186
|
+
const keyword = trimToNull(kwargs.name ?? kwargs.keyword)?.toLowerCase();
|
|
187
|
+
const rows = normalizeRows(listPayload)
|
|
188
|
+
.map((item) => normalizeProjectGroupRecord(item, selectedProjectGroupNo))
|
|
189
|
+
.filter((item) => {
|
|
190
|
+
if (!keyword) return true;
|
|
191
|
+
return [item.projectGroupNo, item.projectGroupName]
|
|
192
|
+
.map((value) => String(value ?? '').toLowerCase())
|
|
193
|
+
.some((value) => value.includes(keyword));
|
|
194
|
+
});
|
|
195
|
+
return { projectGroups: rows, currentProjectGroupNo: selectedProjectGroupNo };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function fetchProjectGroupSummary(projectGroupNo) {
|
|
199
|
+
const actualProjectGroupNo = await resolveProjectGroupNo(projectGroupNo);
|
|
200
|
+
const [groupsPayload, currentPayload, integralPayload] = await Promise.all([
|
|
201
|
+
awbApi.fetchProjectGroups().catch(() => []),
|
|
202
|
+
awbApi.fetchCurrentProjectGroup().catch(() => null),
|
|
203
|
+
awbApi.fetchProjectGroupIntegral(actualProjectGroupNo).catch(() => null),
|
|
204
|
+
]);
|
|
205
|
+
const groups = normalizeRows(groupsPayload);
|
|
206
|
+
const currentNo = extractProjectGroupNo(currentPayload);
|
|
207
|
+
const row = groups.find((item) => (item?.projectGroupNo ?? item?.no) === actualProjectGroupNo);
|
|
208
|
+
const summary = {
|
|
209
|
+
projectGroupNo: actualProjectGroupNo,
|
|
210
|
+
projectGroupName: row?.projectGroupName ?? row?.name ?? null,
|
|
211
|
+
isSelected: actualProjectGroupNo === currentNo,
|
|
212
|
+
projectBudgetBalance: integralPayload?.projectGroupIntegralCurrent ?? extractPointBalance(integralPayload),
|
|
213
|
+
projectBudgetMax: integralPayload?.projectGroupIntegralMax ?? null,
|
|
214
|
+
};
|
|
215
|
+
await saveState({
|
|
216
|
+
currentProjectGroupNo: summary.projectGroupNo,
|
|
217
|
+
currentProjectGroupName: summary.projectGroupName,
|
|
218
|
+
}).catch(() => {});
|
|
219
|
+
return summary;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function accountInfo() {
|
|
223
|
+
const [userInfo, pointPayload, currentProjectGroup] = await Promise.all([
|
|
224
|
+
awbApi.fetchUserInfo(),
|
|
225
|
+
awbApi.fetchPoints().catch(() => null),
|
|
226
|
+
fetchProjectGroupSummary().catch(() => null),
|
|
227
|
+
]);
|
|
228
|
+
const user = normalizeUserInfo(userInfo);
|
|
229
|
+
await saveState({
|
|
230
|
+
currentUserId: user.userId,
|
|
231
|
+
currentUserName: user.userName,
|
|
232
|
+
currentGroupId: user.groupId,
|
|
233
|
+
currentGroupName: user.groupName,
|
|
234
|
+
currentProjectGroupNo: currentProjectGroup?.projectGroupNo ?? null,
|
|
235
|
+
currentProjectGroupName: currentProjectGroup?.projectGroupName ?? null,
|
|
236
|
+
}).catch(() => {});
|
|
237
|
+
return {
|
|
238
|
+
...user,
|
|
239
|
+
billingPointBalance: extractPointBalance(pointPayload),
|
|
240
|
+
teamPointBalance: extractPointBalance(pointPayload),
|
|
241
|
+
currentProjectGroupNo: currentProjectGroup?.projectGroupNo ?? null,
|
|
242
|
+
currentProjectGroupName: currentProjectGroup?.projectGroupName ?? null,
|
|
243
|
+
projectBudgetBalance: currentProjectGroup?.projectBudgetBalance ?? currentProjectGroup?.projectPointBalance ?? null,
|
|
244
|
+
projectBudgetMax: currentProjectGroup?.projectBudgetMax ?? currentProjectGroup?.projectPointMax ?? null,
|
|
245
|
+
projectPointBalance: currentProjectGroup?.projectPointBalance ?? null,
|
|
246
|
+
projectPointMax: currentProjectGroup?.projectPointMax ?? null,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function pathExists(filePath) {
|
|
251
|
+
if (!filePath) return false;
|
|
252
|
+
try {
|
|
253
|
+
await fs.access(filePath);
|
|
254
|
+
return true;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function doctorCheck(name, status, message, details = {}) {
|
|
261
|
+
return compactRecord({ name, status, message, details: Object.keys(details).length ? details : undefined });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function doctorStatus(checks) {
|
|
265
|
+
if (checks.some((item) => item.status === 'error')) return 'error';
|
|
266
|
+
if (checks.some((item) => item.status === 'warning')) return 'warning';
|
|
267
|
+
return 'ready';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function doctorUserSummary(user) {
|
|
271
|
+
return compactRecord({
|
|
272
|
+
userId: user?.userId,
|
|
273
|
+
userName: user?.userName,
|
|
274
|
+
groupId: user?.groupId,
|
|
275
|
+
groupName: user?.groupName,
|
|
276
|
+
permission: user?.permission,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function doctor(kwargs = {}) {
|
|
281
|
+
const verify = toBool(kwargs.verify);
|
|
282
|
+
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
|
|
283
|
+
const auth = await loadAuth().catch(() => ({}));
|
|
284
|
+
const authContext = await resolveAuthContext({ required: false }).catch(() => ({ authenticated: false }));
|
|
285
|
+
const skillInstallDir = trimToNull(process.env.LINGJING_AWB_SKILL_INSTALL_DIR || process.env.ANIME_SKILL_INSTALL_DIR)
|
|
286
|
+
|| path.join(process.env.HOME || '', '.cc-switch', 'skills', 'lj-awb');
|
|
287
|
+
const skillInstalled = await pathExists(path.join(skillInstallDir, 'SKILL.md'));
|
|
288
|
+
const locale = trimToNull(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG);
|
|
289
|
+
const localeIsUtf8 = locale ? /utf-?8/i.test(locale) : false;
|
|
290
|
+
const apiOriginIsDefault = API_ORIGIN === DEFAULT_API_ORIGIN;
|
|
291
|
+
const checks = [
|
|
292
|
+
doctorCheck(
|
|
293
|
+
'node',
|
|
294
|
+
nodeMajor >= 20 ? 'ok' : 'error',
|
|
295
|
+
nodeMajor >= 20 ? 'Node.js 版本满足 CLI 要求' : 'Node.js 版本过低,CLI 要求 >=20',
|
|
296
|
+
{ nodeVersion: process.versions.node },
|
|
297
|
+
),
|
|
298
|
+
doctorCheck(
|
|
299
|
+
'auth',
|
|
300
|
+
authContext.authenticated ? 'ok' : 'warning',
|
|
301
|
+
authContext.authenticated ? '已配置 access key' : '未配置 access key,只能运行本地检查类命令',
|
|
302
|
+
{ source: authContext.accessKeyInfo?.source ?? null, sourceName: authContext.accessKeyInfo?.sourceName ?? null },
|
|
303
|
+
),
|
|
304
|
+
doctorCheck(
|
|
305
|
+
'api_origin',
|
|
306
|
+
'ok',
|
|
307
|
+
apiOriginIsDefault ? '正在使用默认 API origin' : '已显式配置 API origin',
|
|
308
|
+
{ apiOrigin: API_ORIGIN, isDefault: apiOriginIsDefault },
|
|
309
|
+
),
|
|
310
|
+
doctorCheck(
|
|
311
|
+
'locale',
|
|
312
|
+
localeIsUtf8 ? 'ok' : 'warning',
|
|
313
|
+
localeIsUtf8 ? '终端字符集支持 UTF-8' : '当前终端字符集可能不是 UTF-8,中文参数可能出现乱码',
|
|
314
|
+
{ locale },
|
|
315
|
+
),
|
|
316
|
+
doctorCheck(
|
|
317
|
+
'skill',
|
|
318
|
+
skillInstalled ? 'ok' : 'warning',
|
|
319
|
+
skillInstalled ? '已检测到本地 lj-awb skill' : '未检测到本地 lj-awb skill;不影响 CLI,但会影响 Agent 工作流体验',
|
|
320
|
+
{ skillInstallDir },
|
|
321
|
+
),
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
let user = null;
|
|
325
|
+
let projectGroup = null;
|
|
326
|
+
if (verify) {
|
|
327
|
+
if (!authContext.authenticated) {
|
|
328
|
+
checks.push(doctorCheck('network_auth', 'error', '无法联网校验:缺少 access key'));
|
|
329
|
+
} else {
|
|
330
|
+
try {
|
|
331
|
+
user = doctorUserSummary(normalizeUserInfo(await awbApi.fetchUserInfo()));
|
|
332
|
+
checks.push(doctorCheck('network_auth', 'ok', 'access key 联网校验通过', {
|
|
333
|
+
userId: user.userId,
|
|
334
|
+
userName: user.userName,
|
|
335
|
+
groupId: user.groupId,
|
|
336
|
+
groupName: user.groupName,
|
|
337
|
+
}));
|
|
338
|
+
} catch (error) {
|
|
339
|
+
checks.push(doctorCheck('network_auth', 'error', 'access key 联网校验失败', {
|
|
340
|
+
type: error?.type ?? 'error',
|
|
341
|
+
message: error?.message ?? String(error),
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const projectGroupNo = await resolveProjectGroupNo(kwargs.projectGroupNo, { allowNull: true });
|
|
346
|
+
projectGroup = projectGroupNo ? await fetchProjectGroupSummary(projectGroupNo) : null;
|
|
347
|
+
checks.push(doctorCheck(
|
|
348
|
+
'project_group',
|
|
349
|
+
projectGroup?.projectGroupNo ? 'ok' : 'warning',
|
|
350
|
+
projectGroup?.projectGroupNo ? '已识别当前项目组' : '未识别当前项目组;生成任务前需要传 --project-group-no 或先选择项目组',
|
|
351
|
+
{ projectGroupNo: projectGroup?.projectGroupNo ?? null, projectGroupName: projectGroup?.projectGroupName ?? null },
|
|
352
|
+
));
|
|
353
|
+
} catch (error) {
|
|
354
|
+
checks.push(doctorCheck('project_group', 'warning', '项目组检查失败', {
|
|
355
|
+
type: error?.type ?? 'error',
|
|
356
|
+
message: error?.message ?? String(error),
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} else {
|
|
361
|
+
checks.push(doctorCheck('network', 'skipped', '默认不做联网校验;需要时追加 --verify'));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
doctorStatus: doctorStatus(checks),
|
|
366
|
+
verify,
|
|
367
|
+
runtime: {
|
|
368
|
+
nodeVersion: process.versions.node,
|
|
369
|
+
platform: process.platform,
|
|
370
|
+
arch: process.arch,
|
|
371
|
+
},
|
|
372
|
+
origins: {
|
|
373
|
+
apiOrigin: API_ORIGIN,
|
|
374
|
+
assetEditOrigin: awbApi.ASSET_EDIT_ORIGIN,
|
|
375
|
+
},
|
|
376
|
+
auth: summarizeAuth(auth, authContext.accessKeyInfo),
|
|
377
|
+
paths: {
|
|
378
|
+
appHomeDir: APP_HOME_DIR,
|
|
379
|
+
authPath: AUTH_PATH,
|
|
380
|
+
statePath: STATE_PATH,
|
|
381
|
+
skillInstallDir,
|
|
382
|
+
},
|
|
383
|
+
user,
|
|
384
|
+
projectGroup,
|
|
385
|
+
checks,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function selectTeam(kwargs = {}) {
|
|
390
|
+
const groupId = requireValue(kwargs, 'groupId', 'group-id');
|
|
391
|
+
if (toBool(kwargs.dryRun)) {
|
|
392
|
+
return { dryRun: true, action: 'account switch-team', selected: false, request: { groupId } };
|
|
393
|
+
}
|
|
394
|
+
ensureConfirmed(kwargs, '切换团队会改变后续 CLI 调用上下文,需要确认', {
|
|
395
|
+
action: 'account switch-team',
|
|
396
|
+
groupId,
|
|
397
|
+
});
|
|
398
|
+
await awbApi.updateCurrentTeam(groupId);
|
|
399
|
+
await saveState({ currentGroupId: groupId }).catch(() => {});
|
|
400
|
+
return { selected: true, groupId };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function selectProjectGroupCommand(kwargs = {}) {
|
|
404
|
+
const projectGroupNo = requireValue(kwargs, 'projectGroupNo', 'project-group-no');
|
|
405
|
+
if (toBool(kwargs.dryRun)) {
|
|
406
|
+
return {
|
|
407
|
+
dryRun: true,
|
|
408
|
+
action: 'project use',
|
|
409
|
+
selected: false,
|
|
410
|
+
request: { projectGroupNo },
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
ensureConfirmed(kwargs, '切换项目组会改变后续生成任务归属,需要确认', {
|
|
414
|
+
action: 'project use',
|
|
415
|
+
projectGroupNo,
|
|
416
|
+
});
|
|
417
|
+
await awbApi.selectProjectGroup(projectGroupNo);
|
|
418
|
+
await saveState({ currentProjectGroupNo: projectGroupNo }).catch(() => {});
|
|
419
|
+
return { selected: true, ...(await fetchProjectGroupSummary(projectGroupNo)) };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function buildProjectGroupUsersFromArgs(kwargs, allUsers = []) {
|
|
423
|
+
const membersJson = parseJsonArg(kwargs.membersJson, null);
|
|
424
|
+
if (Array.isArray(membersJson)) return membersJson;
|
|
425
|
+
const selectedUserIds = new Set(parseListArg(kwargs.userIds));
|
|
426
|
+
const rows = Array.isArray(allUsers) ? allUsers : [];
|
|
427
|
+
const groupUser = rows
|
|
428
|
+
.filter((user) => Boolean(user?.isCheck) || selectedUserIds.has(user?.userId))
|
|
429
|
+
.map((user) => ({
|
|
430
|
+
userId: user.userId,
|
|
431
|
+
role: user.isCheck ? user.role ?? 'CREATOR' : 'USER',
|
|
432
|
+
}))
|
|
433
|
+
.filter((item) => item.userId);
|
|
434
|
+
if (!groupUser.length && selectedUserIds.size) {
|
|
435
|
+
return [...selectedUserIds].map((userId, index) => ({ userId, role: index === 0 ? 'CREATOR' : 'USER' }));
|
|
436
|
+
}
|
|
437
|
+
if (groupUser.length && !groupUser.some((item) => item.role === 'CREATOR')) {
|
|
438
|
+
groupUser[0] = { ...groupUser[0], role: 'CREATOR' };
|
|
439
|
+
}
|
|
440
|
+
return groupUser;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export async function createProjectGroupCommand(kwargs = {}) {
|
|
444
|
+
const name = requireValue(kwargs, 'name');
|
|
445
|
+
const point = toInt(kwargs.point, 0);
|
|
446
|
+
if (toBool(kwargs.dryRun)) {
|
|
447
|
+
const groupUser = buildProjectGroupUsersFromArgs(kwargs, []);
|
|
448
|
+
return {
|
|
449
|
+
dryRun: true,
|
|
450
|
+
action: 'project create',
|
|
451
|
+
created: false,
|
|
452
|
+
request: { projectGroupName: name, point, groupUser },
|
|
453
|
+
memberCount: groupUser.length,
|
|
454
|
+
note: groupUser.length ? null : 'dry-run 未联网拉取团队默认成员;正式执行会自动获取已选成员。',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
ensureConfirmed(kwargs, '创建项目组是云端写入动作,需要确认', {
|
|
458
|
+
action: 'project create',
|
|
459
|
+
projectGroupName: name,
|
|
460
|
+
point,
|
|
461
|
+
});
|
|
462
|
+
const before = normalizeRows(await awbApi.fetchProjectGroups().catch(() => []));
|
|
463
|
+
const allUsers = await awbApi.fetchProjectGroupUsers();
|
|
464
|
+
const groupUser = buildProjectGroupUsersFromArgs(kwargs, allUsers);
|
|
465
|
+
if (!groupUser.length) {
|
|
466
|
+
throw argumentError('未解析到项目组成员', '请先运行 project users,或传 --members-json / --user-ids。');
|
|
467
|
+
}
|
|
468
|
+
await awbApi.createProjectGroup({ projectGroupName: name, point, groupUser });
|
|
469
|
+
const after = normalizeRows(await awbApi.fetchProjectGroups());
|
|
470
|
+
const beforeIds = new Set(before.map((item) => item?.projectGroupNo ?? item?.no).filter(Boolean));
|
|
471
|
+
const created = after.find((item) => !beforeIds.has(item?.projectGroupNo ?? item?.no))
|
|
472
|
+
?? after.find((item) => (item?.projectGroupName ?? item?.name) === name);
|
|
473
|
+
const projectGroupNo = created?.projectGroupNo ?? created?.no ?? null;
|
|
474
|
+
if (!projectGroupNo) {
|
|
475
|
+
throw new LingjingAwbCliError('项目组已创建,但没有解析到新 projectGroupNo', {
|
|
476
|
+
type: 'api_error',
|
|
477
|
+
exitCode: 1,
|
|
478
|
+
details: { name },
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
await awbApi.selectProjectGroup(projectGroupNo).catch(() => {});
|
|
482
|
+
return {
|
|
483
|
+
...(await fetchProjectGroupSummary(projectGroupNo)),
|
|
484
|
+
created: true,
|
|
485
|
+
memberCount: groupUser.length,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function updateProjectGroupCommand(kwargs = {}) {
|
|
490
|
+
const projectGroupNo = await resolveProjectGroupNo(kwargs.projectGroupNo, {
|
|
491
|
+
allowNull: toBool(kwargs.dryRun),
|
|
492
|
+
noNetwork: toBool(kwargs.dryRun),
|
|
493
|
+
noSave: toBool(kwargs.dryRun),
|
|
494
|
+
});
|
|
495
|
+
if (!projectGroupNo) {
|
|
496
|
+
throw argumentError('缺少项目组编号', '传 --project-group-no <no>,或先选择当前项目组。');
|
|
497
|
+
}
|
|
498
|
+
const body = { projectGroupNo };
|
|
499
|
+
if (kwargs.name != null && String(kwargs.name).trim() !== '') {
|
|
500
|
+
body.projectGroupName = String(kwargs.name).trim();
|
|
501
|
+
}
|
|
502
|
+
if (kwargs.point != null && String(kwargs.point).trim() !== '') {
|
|
503
|
+
body.point = toInt(kwargs.point, 0);
|
|
504
|
+
}
|
|
505
|
+
if (Object.keys(body).length === 1) {
|
|
506
|
+
throw argumentError('缺少项目组更新字段', '至少传 --name 或 --point。');
|
|
507
|
+
}
|
|
508
|
+
if (toBool(kwargs.dryRun)) {
|
|
509
|
+
return {
|
|
510
|
+
dryRun: true,
|
|
511
|
+
action: 'project update',
|
|
512
|
+
updated: false,
|
|
513
|
+
request: body,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
ensureConfirmed(kwargs, '修改项目组是云端写入动作,需要确认', {
|
|
517
|
+
action: 'project update',
|
|
518
|
+
body,
|
|
519
|
+
});
|
|
520
|
+
await awbApi.updateProjectGroup(body);
|
|
521
|
+
return {
|
|
522
|
+
...(await fetchProjectGroupSummary(projectGroupNo).catch(() => ({ projectGroupNo }))),
|
|
523
|
+
updated: true,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function ensureProjectGroupCommand(kwargs = {}) {
|
|
528
|
+
const name = requireValue(kwargs, 'name');
|
|
529
|
+
if (toBool(kwargs.dryRun)) {
|
|
530
|
+
return {
|
|
531
|
+
dryRun: true,
|
|
532
|
+
action: 'project ensure',
|
|
533
|
+
request: {
|
|
534
|
+
projectGroupName: name,
|
|
535
|
+
point: toInt(kwargs.point, 0),
|
|
536
|
+
selectExisting: kwargs.selectExisting !== false,
|
|
537
|
+
},
|
|
538
|
+
note: 'dry-run 不联网判断是否已存在;正式执行会先查找同名项目组。',
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const { projectGroups } = await listProjectGroups({ name });
|
|
542
|
+
const existing = projectGroups.find((item) => item.projectGroupName === name) ?? null;
|
|
543
|
+
if (existing?.projectGroupNo) {
|
|
544
|
+
ensureConfirmed(kwargs, '确认复用并切换到已存在项目组', {
|
|
545
|
+
action: 'project ensure',
|
|
546
|
+
projectGroupNo: existing.projectGroupNo,
|
|
547
|
+
});
|
|
548
|
+
await awbApi.selectProjectGroup(existing.projectGroupNo).catch(() => {});
|
|
549
|
+
return {
|
|
550
|
+
...(await fetchProjectGroupSummary(existing.projectGroupNo)),
|
|
551
|
+
created: false,
|
|
552
|
+
reused: true,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
return await createProjectGroupCommand(kwargs);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export async function creditsBalance(kwargs = {}) {
|
|
559
|
+
const projectGroupNo = await resolveProjectGroupNo(kwargs.projectGroupNo, { allowNull: true }).catch(() => null);
|
|
560
|
+
const [teamPayload, projectGroup] = await Promise.all([
|
|
561
|
+
awbApi.fetchPoints(),
|
|
562
|
+
projectGroupNo ? fetchProjectGroupSummary(projectGroupNo).catch(() => null) : Promise.resolve(null),
|
|
563
|
+
]);
|
|
564
|
+
const billingPointBalance = extractPointBalance(teamPayload);
|
|
565
|
+
return {
|
|
566
|
+
billingPointBalance,
|
|
567
|
+
teamPointBalance: billingPointBalance,
|
|
568
|
+
currentProjectGroupNo: projectGroup?.projectGroupNo ?? projectGroupNo,
|
|
569
|
+
currentProjectGroupName: projectGroup?.projectGroupName ?? null,
|
|
570
|
+
projectBudgetBalance: projectGroup?.projectBudgetBalance ?? projectGroup?.projectPointBalance ?? null,
|
|
571
|
+
projectBudgetMax: projectGroup?.projectBudgetMax ?? projectGroup?.projectPointMax ?? null,
|
|
572
|
+
projectPointBalance: projectGroup?.projectPointBalance ?? null,
|
|
573
|
+
projectPointMax: projectGroup?.projectPointMax ?? null,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function parseTimeMs(value, fallback = null) {
|
|
578
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
579
|
+
const numeric = Number(value);
|
|
580
|
+
if (Number.isFinite(numeric)) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
|
|
581
|
+
const text = String(value).trim();
|
|
582
|
+
const parsed = Date.parse(/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(text) ? `${text.replace(' ', 'T')}+08:00` : text);
|
|
583
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function taskTimeMs(row) {
|
|
587
|
+
return parseTimeMs(row.gmtCreate ?? row.createdAt ?? row.createTime, null);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function usageBucket() {
|
|
591
|
+
return {
|
|
592
|
+
taskCount: 0,
|
|
593
|
+
successTaskCount: 0,
|
|
594
|
+
resultCount: 0,
|
|
595
|
+
pointTotal: 0,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function addTaskToUsage(bucket, row) {
|
|
600
|
+
bucket.taskCount += 1;
|
|
601
|
+
if (isSuccessTaskStatus(row.taskStatus)) bucket.successTaskCount += 1;
|
|
602
|
+
bucket.resultCount += toInt(row.resultCount, 0);
|
|
603
|
+
bucket.pointTotal += toNumberOrNull(row.pointNo) ?? 0;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export async function creditsUsageSummary(kwargs = {}) {
|
|
607
|
+
const projectGroupNo = await resolveProjectGroupNo(kwargs.projectGroupNo);
|
|
608
|
+
const sinceMs = parseTimeMs(kwargs.sinceMs ?? kwargs.since, null)
|
|
609
|
+
?? (kwargs.lastHours ? Date.now() - toNumberOrNull(kwargs.lastHours) * 60 * 60 * 1000 : null);
|
|
610
|
+
const untilMs = parseTimeMs(kwargs.untilMs ?? kwargs.until, Date.now());
|
|
611
|
+
const taskTypes = parseListArg(kwargs.taskTypes).length
|
|
612
|
+
? parseListArg(kwargs.taskTypes)
|
|
613
|
+
: ['IMAGE_CREATE', 'IMAGE_EDIT', 'VIDEO_GROUP'];
|
|
614
|
+
const pageSize = Math.min(Math.max(toInt(kwargs.pageSize, 100), 1), 200);
|
|
615
|
+
const tasks = [];
|
|
616
|
+
for (const taskType of taskTypes) {
|
|
617
|
+
const rows = await taskList({
|
|
618
|
+
taskType,
|
|
619
|
+
projectGroupNo,
|
|
620
|
+
pageSize,
|
|
621
|
+
minTime: untilMs,
|
|
622
|
+
});
|
|
623
|
+
for (const row of rows.tasks) {
|
|
624
|
+
const createdMs = taskTimeMs(row);
|
|
625
|
+
if (sinceMs != null && createdMs != null && createdMs < sinceMs) continue;
|
|
626
|
+
if (untilMs != null && createdMs != null && createdMs > untilMs) continue;
|
|
627
|
+
tasks.push(row);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const buckets = {};
|
|
631
|
+
for (const row of tasks) {
|
|
632
|
+
const key = normalizeFeedTaskType(row.feedTaskType ?? row.taskType);
|
|
633
|
+
buckets[key] ??= usageBucket();
|
|
634
|
+
addTaskToUsage(buckets[key], row);
|
|
635
|
+
}
|
|
636
|
+
const pointTotal = Object.values(buckets).reduce((sum, bucket) => sum + bucket.pointTotal, 0);
|
|
637
|
+
return {
|
|
638
|
+
projectGroupNo,
|
|
639
|
+
sinceMs,
|
|
640
|
+
sinceText: sinceMs ? new Date(sinceMs).toISOString() : null,
|
|
641
|
+
untilMs,
|
|
642
|
+
untilText: untilMs ? new Date(untilMs).toISOString() : null,
|
|
643
|
+
scannedTaskCount: tasks.length,
|
|
644
|
+
pointTotal,
|
|
645
|
+
buckets,
|
|
646
|
+
...(toBool(kwargs.includeTasks) ? { tasks } : {}),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function modelListMetadata(kind, paramKeys = [], rulesByKey = new Map()) {
|
|
651
|
+
const keys = new Set(paramKeys);
|
|
652
|
+
const imageReferenceParams = ['cref', 'sref', 'iref', 'resources'].filter((key) => keys.has(key));
|
|
653
|
+
const videoResourceParams = ['frames', 'multi_param', 'multi_prompt'].filter((key) => keys.has(key));
|
|
654
|
+
const inputModes = createSpecSupportedIntents(createSpecInputModes(kind, keys, rulesByKey), kind)
|
|
655
|
+
.map((item) => item.mode);
|
|
656
|
+
const controls = [];
|
|
657
|
+
if (keys.has('ratio')) controls.push('ratio');
|
|
658
|
+
if (keys.has('quality')) controls.push('quality');
|
|
659
|
+
if (kind === 'image' && keys.has('generate_num')) controls.push('generateNum');
|
|
660
|
+
if (kind === 'video' && keys.has('generated_time')) controls.push('duration');
|
|
661
|
+
if (kind === 'video' && (keys.has('need_audio') || keys.has('audio'))) controls.push('needAudio');
|
|
662
|
+
return {
|
|
663
|
+
inputModes,
|
|
664
|
+
controls,
|
|
665
|
+
resourceParams: kind === 'image' ? imageReferenceParams : videoResourceParams,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function normalizeModelRows(payload, kind, options = {}) {
|
|
670
|
+
const includeRaw = Boolean(options.includeRaw);
|
|
671
|
+
const includeInternal = Boolean(options.includeInternal);
|
|
672
|
+
return normalizeRows(payload).map((item) => {
|
|
673
|
+
const modelCode = item?.modelCode ?? item?.code ?? item?.value ?? null;
|
|
674
|
+
const modelGroupCode = item?.modelGroupCode ?? item?.groupCode ?? item?.modelGroup?.modelGroupCode ?? item?.modelGroup?.code ?? null;
|
|
675
|
+
const rawText = JSON.stringify(item);
|
|
676
|
+
const rawOptions = normalizeRawModelOptions(item);
|
|
677
|
+
const rawParamKeys = rawOptions.map((option) => option.paramKey).filter(Boolean);
|
|
678
|
+
const paramKeys = rawParamKeys.length ? uniqueNonEmpty(rawParamKeys) : uniqueNonEmpty([
|
|
679
|
+
...rawText.matchAll(/"paramKey"\s*:\s*"([^"]+)"/g),
|
|
680
|
+
].map((match) => match[1]));
|
|
681
|
+
const rulesByKey = new Map(rawOptions.map((option) => [option.paramKey, option.rules]).filter(([key, rules]) => key && rules));
|
|
682
|
+
const metadata = modelListMetadata(kind, paramKeys, rulesByKey);
|
|
683
|
+
const row = compactRecord({
|
|
684
|
+
modelGroupCode,
|
|
685
|
+
displayName: item?.modelName ?? item?.name ?? item?.label ?? null,
|
|
686
|
+
provider: item?.provider ?? item?.vendor ?? item?.supplier ?? item?.componyName ?? null,
|
|
687
|
+
enabled: item?.enabled ?? item?.available ?? item?.status ?? null,
|
|
688
|
+
modelStatus: item?.modelStatus ?? null,
|
|
689
|
+
taskQueueNum: item?.taskQueueNum ?? null,
|
|
690
|
+
feeCalcType: item?.feeCalcType ?? item?.feeType ?? null,
|
|
691
|
+
inputModes: metadata.inputModes,
|
|
692
|
+
params: metadata.controls,
|
|
693
|
+
_searchText: JSON.stringify([
|
|
694
|
+
modelCode,
|
|
695
|
+
modelGroupCode,
|
|
696
|
+
item?.modelName ?? item?.name ?? item?.label,
|
|
697
|
+
item?.provider ?? item?.vendor ?? item?.supplier ?? item?.componyName,
|
|
698
|
+
metadata.inputModes.join(','),
|
|
699
|
+
metadata.controls.join(','),
|
|
700
|
+
metadata.resourceParams.join(','),
|
|
701
|
+
paramKeys.join(','),
|
|
702
|
+
]),
|
|
703
|
+
...(includeInternal ? { _modelCode: modelCode } : {}),
|
|
704
|
+
});
|
|
705
|
+
return includeRaw ? { ...row, raw: item } : row;
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export async function listModels(kind, kwargs = {}) {
|
|
710
|
+
const usage = kind === 'image' ? 'IMAGE_CREATE' : 'VIDEO_CREATE';
|
|
711
|
+
const payload = await awbApi.fetchModelsByUsage(usage, {});
|
|
712
|
+
const keyword = trimToNull(kwargs.model ?? kwargs.keyword)?.toLowerCase();
|
|
713
|
+
const includeRaw = toBool(kwargs.includeRaw);
|
|
714
|
+
const models = normalizeModelRows(payload, kind, { includeRaw })
|
|
715
|
+
.filter((item) => {
|
|
716
|
+
if (!keyword) return true;
|
|
717
|
+
return String(item._searchText ?? '').toLowerCase().includes(keyword);
|
|
718
|
+
})
|
|
719
|
+
.map(({ _searchText, _modelCode, ...item }) => item);
|
|
720
|
+
return { usage, models };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function findModelGroup(modelGroupCode, options = {}) {
|
|
724
|
+
const includeRaw = Boolean(options.includeRaw);
|
|
725
|
+
const [imageResult, videoResult] = await Promise.allSettled([
|
|
726
|
+
awbApi.fetchModelsByUsage('IMAGE_CREATE', {}),
|
|
727
|
+
awbApi.fetchModelsByUsage('VIDEO_CREATE', {}),
|
|
728
|
+
]);
|
|
729
|
+
const imageModels = imageResult.status === 'fulfilled'
|
|
730
|
+
? normalizeModelRows(imageResult.value, 'image', { includeRaw, includeInternal: true }).map((item) => ({ ...item, kind: 'image' }))
|
|
731
|
+
: [];
|
|
732
|
+
const videoModels = videoResult.status === 'fulfilled'
|
|
733
|
+
? normalizeModelRows(videoResult.value, 'video', { includeRaw, includeInternal: true }).map((item) => ({ ...item, kind: 'video' }))
|
|
734
|
+
: [];
|
|
735
|
+
return [...imageModels, ...videoModels].find((item) => item.modelGroupCode === modelGroupCode) ?? null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function resolveTaskModel(kind, kwargs = {}) {
|
|
739
|
+
const modelGroupCode = trimToNull(kwargs.modelGroupCode);
|
|
740
|
+
if (!modelGroupCode) {
|
|
741
|
+
throw argumentError('缺少模型组编码', '传 --model-group-code <code>;推荐先运行 model image-models / model video-models。');
|
|
742
|
+
}
|
|
743
|
+
return {
|
|
744
|
+
kind,
|
|
745
|
+
modelGroupCode,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function fetchNormalizedModelOptions(options = {}) {
|
|
750
|
+
const payload = await awbApi.fetchModelOptions({
|
|
751
|
+
modelCode: options.modelCode,
|
|
752
|
+
modelGroupCode: options.modelGroupCode,
|
|
753
|
+
selectedConfigs: options.selectedConfigs,
|
|
754
|
+
});
|
|
755
|
+
const normalizedOptions = normalizeRows(payload).map((item) => ({
|
|
756
|
+
rank: item?.rank ?? null,
|
|
757
|
+
paramKey: item?.paramKey ?? null,
|
|
758
|
+
paramName: item?.paramName ?? null,
|
|
759
|
+
paramType: item?.paramType ?? null,
|
|
760
|
+
optionList: typeof item?.optionList === 'string' ? parseJsonArg(item.optionList, item.optionList) : item?.optionList ?? null,
|
|
761
|
+
required: item?.required ?? item?.rule?.required ?? null,
|
|
762
|
+
hasConstraint: item?.hasConstraint ?? null,
|
|
763
|
+
rules: item?.rules ?? item?.rule?.rules ?? null,
|
|
764
|
+
raw: options.includeRaw ? item : undefined,
|
|
765
|
+
}));
|
|
766
|
+
return {
|
|
767
|
+
modelGroupCode: options.modelGroupCode,
|
|
768
|
+
selectedConfigs: options.selectedConfigs,
|
|
769
|
+
options: normalizedOptions,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function normalizeRawModelOptions(rawModel = {}, options = {}) {
|
|
774
|
+
const rawParams = Array.isArray(rawModel?.modelParams) ? rawModel.modelParams : [];
|
|
775
|
+
return rawParams.map((item) => ({
|
|
776
|
+
rank: item?.rank ?? null,
|
|
777
|
+
paramKey: item?.paramKey ?? null,
|
|
778
|
+
paramName: item?.paramName ?? null,
|
|
779
|
+
paramType: item?.paramType ?? null,
|
|
780
|
+
optionList: typeof item?.optionList === 'string' ? parseJsonArg(item.optionList, item.optionList) : item?.optionList ?? null,
|
|
781
|
+
required: item?.required ?? item?.rules?.required ?? null,
|
|
782
|
+
hasConstraint: item?.hasConstraint ?? null,
|
|
783
|
+
rules: item?.rules ?? null,
|
|
784
|
+
raw: options.includeRaw ? item : undefined,
|
|
785
|
+
}));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function modelSummaryFromRaw(rawModel = {}, modelGroupCode, taskKind) {
|
|
789
|
+
return compactRecord({
|
|
790
|
+
modelGroupCode: rawModel?.modelGroupCode ?? modelGroupCode,
|
|
791
|
+
displayName: rawModel?.modelName ?? rawModel?.name ?? null,
|
|
792
|
+
provider: rawModel?.componyName ?? rawModel?.provider ?? rawModel?.vendor ?? null,
|
|
793
|
+
taskKind,
|
|
794
|
+
modelStatus: rawModel?.modelStatus ?? null,
|
|
795
|
+
taskQueueNum: rawModel?.taskQueueNum ?? null,
|
|
796
|
+
feeCalcType: rawModel?.feeCalcType ?? null,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function fetchModelConstraintSchema(rawModel = {}, modelGroupCode) {
|
|
801
|
+
const usage = trimToNull(rawModel?.usage);
|
|
802
|
+
if (!usage) return [];
|
|
803
|
+
const payload = await awbApi.fetchModelsByUsage(usage, { includeConstraintSchema: true }).catch(() => null);
|
|
804
|
+
const rows = normalizeRows(payload);
|
|
805
|
+
const found = rows.find((item) => (
|
|
806
|
+
item?.modelGroupCode === modelGroupCode
|
|
807
|
+
|| (rawModel?.modelCode && item?.modelCode === rawModel.modelCode)
|
|
808
|
+
));
|
|
809
|
+
return Array.isArray(found?.constraintSchema) ? found.constraintSchema : [];
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function configCodeDecisionKey(code, taskKind = 'image') {
|
|
813
|
+
const normalized = String(code || '');
|
|
814
|
+
const mappings = {
|
|
815
|
+
generated_time: 'duration',
|
|
816
|
+
generate_num: 'generateNum',
|
|
817
|
+
need_audio: 'needAudio',
|
|
818
|
+
audio: 'needAudio',
|
|
819
|
+
frames: 'resource.image.frame',
|
|
820
|
+
multi_param: 'resource.reference',
|
|
821
|
+
multi_prompt: 'resource.keyframe',
|
|
822
|
+
iref: 'resource.image.reference',
|
|
823
|
+
cref: 'resource.image.reference',
|
|
824
|
+
sref: 'resource.image.reference',
|
|
825
|
+
resources: 'resource.image.reference',
|
|
826
|
+
generated_mode: 'generatedMode',
|
|
827
|
+
generate_mode: 'generatedMode',
|
|
828
|
+
};
|
|
829
|
+
if (normalized === 'prompt') return 'prompt';
|
|
830
|
+
if (normalized === 'ratio') return 'ratio';
|
|
831
|
+
if (normalized === 'quality') return 'quality';
|
|
832
|
+
if (taskKind === 'video' && normalized === 'duration') return 'duration';
|
|
833
|
+
return mappings[normalized] ?? normalized;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function constraintTriggerMeaning(value) {
|
|
837
|
+
if (value === '__NOT_EMPTY__') return 'present';
|
|
838
|
+
if (value === '__EMPTY__') return 'empty';
|
|
839
|
+
return 'equals';
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function normalizeConstraintConditions(conditions = [], taskKind = 'image') {
|
|
843
|
+
return (Array.isArray(conditions) ? conditions : []).map((condition) => compactRecord({
|
|
844
|
+
key: configCodeDecisionKey(condition?.triggerConfigCode, taskKind),
|
|
845
|
+
configCode: condition?.triggerConfigCode,
|
|
846
|
+
value: condition?.triggerConfigValue,
|
|
847
|
+
meaning: constraintTriggerMeaning(condition?.triggerConfigValue),
|
|
848
|
+
}));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function normalizeModelConstraints(constraintSchema = [], taskKind = 'image') {
|
|
852
|
+
return (Array.isArray(constraintSchema) ? constraintSchema : []).map((constraint) => {
|
|
853
|
+
const allowValues = Array.isArray(constraint?.allowValues) ? constraint.allowValues : [];
|
|
854
|
+
return compactRecord({
|
|
855
|
+
id: constraint?.constraintId,
|
|
856
|
+
name: constraint?.constraintName,
|
|
857
|
+
target: configCodeDecisionKey(constraint?.targetConfigCode, taskKind),
|
|
858
|
+
targetConfigCode: constraint?.targetConfigCode,
|
|
859
|
+
allowValues,
|
|
860
|
+
effect: allowValues.length ? 'allow_only_values' : 'no_selectable_values',
|
|
861
|
+
priority: constraint?.priority,
|
|
862
|
+
conditions: normalizeConstraintConditions(constraint?.conditions, taskKind),
|
|
863
|
+
});
|
|
864
|
+
}).filter((constraint) => constraint.target || constraint.targetConfigCode);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function resourceUsageList(usage) {
|
|
868
|
+
return (Array.isArray(usage) ? usage : [usage])
|
|
869
|
+
.map((item) => trimToNull(item))
|
|
870
|
+
.filter(Boolean);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function resourceMediaType(resource = {}) {
|
|
874
|
+
const type = String(resource.type || '').toLowerCase();
|
|
875
|
+
if (type === 'image') return 'IMAGE';
|
|
876
|
+
if (type === 'video') return 'VIDEO';
|
|
877
|
+
if (type === 'audio') return 'AUDIO';
|
|
878
|
+
if (type === 'subject') return 'SUBJECT';
|
|
879
|
+
return type.toUpperCase();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function resourceRuleMatches(rule = {}, resource = {}) {
|
|
883
|
+
return String(rule.mediaType || '').toUpperCase() === resourceMediaType(resource)
|
|
884
|
+
&& resourceUsageList(rule.usage).includes(resource.usage);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function resourceText(resource = {}) {
|
|
888
|
+
return `${resource.type}:${resource.usage}${resource.reference_key ? `:${resource.reference_key}` : ''}`;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function resourceSourceKindForValidation(detail = {}) {
|
|
892
|
+
if (detail.localFile) return 'local_file';
|
|
893
|
+
const source = detail.resource?.source || {};
|
|
894
|
+
if (source.kind === 'asset_id') return 'asset_id';
|
|
895
|
+
const value = trimToNull(source.value);
|
|
896
|
+
if (/^https?:\/\//i.test(value || '')) return 'http_url';
|
|
897
|
+
return 'backendPath';
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function sourceAllowedByRule(rule = {}, sourceKind) {
|
|
901
|
+
const sources = Array.isArray(rule.sources) ? rule.sources : [];
|
|
902
|
+
if (!sources.length) return true;
|
|
903
|
+
return sources.includes(sourceKind);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function resourceFormatForValidation(detail = {}) {
|
|
907
|
+
if (detail.localFile?.format) return String(detail.localFile.format).toLowerCase();
|
|
908
|
+
const value = detail.resource?.source?.value;
|
|
909
|
+
const pathname = (() => {
|
|
910
|
+
try {
|
|
911
|
+
return new URL(String(value)).pathname;
|
|
912
|
+
} catch {
|
|
913
|
+
return String(value || '').split('?')[0].split('#')[0];
|
|
914
|
+
}
|
|
915
|
+
})();
|
|
916
|
+
const ext = path.extname(pathname).replace(/^\./, '').toLowerCase();
|
|
917
|
+
return ext || null;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function normalizeFileFormat(value) {
|
|
921
|
+
const format = trimToNull(value)?.toLowerCase();
|
|
922
|
+
if (!format) return null;
|
|
923
|
+
if (format === 'jpeg' || format === 'jfif') return 'jpg';
|
|
924
|
+
return format;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function normalizedFileTypes(fileTypes = []) {
|
|
928
|
+
return Array.isArray(fileTypes)
|
|
929
|
+
? uniqueNonEmpty(fileTypes.map((item) => normalizeFileFormat(item)))
|
|
930
|
+
: [];
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function fileTypeIncludes(fileTypes = [], format) {
|
|
934
|
+
const normalized = normalizeFileFormat(format);
|
|
935
|
+
if (!normalized) return true;
|
|
936
|
+
return normalizedFileTypes(fileTypes).includes(normalized);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function isImageRule(rule = {}) {
|
|
940
|
+
return String(rule.mediaType || '').toUpperCase() === 'IMAGE';
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function imageFormatPolicy(fileTypes = []) {
|
|
944
|
+
const types = normalizedFileTypes(fileTypes);
|
|
945
|
+
if (!types.length) {
|
|
946
|
+
return {
|
|
947
|
+
kind: 'normal_without_webp',
|
|
948
|
+
webpSupported: false,
|
|
949
|
+
autoConvertTo: 'jpg',
|
|
950
|
+
strict: false,
|
|
951
|
+
summary: 'normal_without_webp',
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
if (types.length === 1 && types[0] === 'webp') {
|
|
955
|
+
return {
|
|
956
|
+
kind: 'normal_plus_webp',
|
|
957
|
+
webpSupported: true,
|
|
958
|
+
strict: false,
|
|
959
|
+
summary: 'normal_plus_webp',
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
kind: 'strict',
|
|
964
|
+
webpSupported: types.includes('webp'),
|
|
965
|
+
allowed: types,
|
|
966
|
+
autoConvertTo: preferredImageConversionTarget(types),
|
|
967
|
+
strict: true,
|
|
968
|
+
summary: `strict:${types.join('|')}`,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function preferredImageConversionTarget(fileTypes = []) {
|
|
973
|
+
const types = normalizedFileTypes(fileTypes);
|
|
974
|
+
if (types.includes('jpg')) return 'jpg';
|
|
975
|
+
if (types.includes('png')) return 'png';
|
|
976
|
+
if (types.includes('webp')) return 'webp';
|
|
977
|
+
return types[0] || 'jpg';
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function isConvertibleLocalImage(detail = {}) {
|
|
981
|
+
return Boolean(detail.localFile?.exists && String(detail.localFile?.mimeType || '').startsWith('image/'));
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function resourceFormatConversion(rule = {}, detail = {}) {
|
|
985
|
+
if (!isImageRule(rule)) return null;
|
|
986
|
+
const format = normalizeFileFormat(resourceFormatForValidation(detail));
|
|
987
|
+
if (!format) return null;
|
|
988
|
+
const policy = imageFormatPolicy(rule.fileTypes);
|
|
989
|
+
if (policy.kind === 'normal_without_webp') {
|
|
990
|
+
if (format !== 'webp') return null;
|
|
991
|
+
return {
|
|
992
|
+
fromFormat: format,
|
|
993
|
+
toFormat: policy.autoConvertTo,
|
|
994
|
+
reason: 'image_webp_not_supported',
|
|
995
|
+
possible: isConvertibleLocalImage(detail),
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
if (policy.kind === 'normal_plus_webp') return null;
|
|
999
|
+
if (!policy.strict || fileTypeIncludes(policy.allowed, format)) return null;
|
|
1000
|
+
return {
|
|
1001
|
+
fromFormat: format,
|
|
1002
|
+
toFormat: policy.autoConvertTo,
|
|
1003
|
+
reason: 'image_strict_format_mismatch',
|
|
1004
|
+
possible: isConvertibleLocalImage(detail),
|
|
1005
|
+
allowed: policy.allowed,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function fileTypeAllowedByRule(rule = {}, format) {
|
|
1010
|
+
const fileTypes = normalizedFileTypes(rule.fileTypes);
|
|
1011
|
+
if (!format) return true;
|
|
1012
|
+
if (isImageRule(rule)) {
|
|
1013
|
+
const policy = imageFormatPolicy(fileTypes);
|
|
1014
|
+
if (policy.kind === 'normal_without_webp') return normalizeFileFormat(format) !== 'webp';
|
|
1015
|
+
if (policy.kind === 'normal_plus_webp') return COMMON_IMAGE_FORMATS.has(normalizeFileFormat(format));
|
|
1016
|
+
return fileTypeIncludes(policy.allowed, format);
|
|
1017
|
+
}
|
|
1018
|
+
if (!fileTypes.length) return true;
|
|
1019
|
+
return fileTypeIncludes(fileTypes, format);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function assertResourceShapeAgainstModel(resourceDetails = [], resourceRules = []) {
|
|
1023
|
+
for (const [index, detail] of resourceDetails.entries()) {
|
|
1024
|
+
const resource = detail.resource;
|
|
1025
|
+
const rule = resourceRules.find((item) => resourceRuleMatches(item, resource));
|
|
1026
|
+
if (!rule) {
|
|
1027
|
+
throw argumentError(
|
|
1028
|
+
`模型不支持素材输入:${resourceText(resource)}`,
|
|
1029
|
+
'请先运行 model options --model-group-code <code>,只使用 resources[] 中列出的 media/usage;上传音频参考必须存在 media=AUDIO usage=reference。',
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
const sourceKind = resourceSourceKindForValidation(detail);
|
|
1033
|
+
if (!sourceAllowedByRule(rule, sourceKind)) {
|
|
1034
|
+
throw argumentError(
|
|
1035
|
+
`模型不支持 ${resourceText(resource)} 的来源:${sourceKind}`,
|
|
1036
|
+
`该资源允许来源:${(rule.sources || []).join(', ') || '未限制'}。`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
const format = resourceFormatForValidation(detail);
|
|
1040
|
+
const conversion = resourceFormatConversion(rule, detail);
|
|
1041
|
+
if (conversion && !conversion.possible) {
|
|
1042
|
+
throw argumentError(
|
|
1043
|
+
`素材格式需要转换但无法自动处理:${resourceText(resource)}`,
|
|
1044
|
+
`当前格式 ${conversion.fromFormat},目标格式 ${conversion.toFormat};请使用本地文件,或先转换后再传远程 URL / asset。`,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
if (!conversion && !fileTypeAllowedByRule(rule, format)) {
|
|
1048
|
+
throw argumentError(
|
|
1049
|
+
`模型不支持 ${resourceText(resource)} 的文件格式:${format}`,
|
|
1050
|
+
`该资源允许格式:${(rule.fileTypes || []).join(', ')}。`,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
if (detail.localFile?.size != null && rule.maxSizeKB != null && detail.localFile.size > rule.maxSizeKB * 1024) {
|
|
1054
|
+
throw argumentError(
|
|
1055
|
+
`素材超过模型大小限制:${resourceText(resource)}`,
|
|
1056
|
+
`文件大小 ${(detail.localFile.size / 1024).toFixed(0)}KB,模型限制 <=${rule.maxSizeKB}KB。`,
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
if (resource.usage === 'keyframe') {
|
|
1060
|
+
if (rule.maxPromptLength != null && resource.prompt && resource.prompt.length > rule.maxPromptLength) {
|
|
1061
|
+
throw argumentError(`keyframe prompt 超过模型长度限制:${resource.prompt.length}/${rule.maxPromptLength}`);
|
|
1062
|
+
}
|
|
1063
|
+
if (rule.minDurationMs != null && resource.duration != null && resource.duration * 1000 < rule.minDurationMs) {
|
|
1064
|
+
throw argumentError(`keyframe duration 小于模型限制:${resource.duration}s`, `最小 ${(rule.minDurationMs / 1000).toFixed(3).replace(/\.?0+$/, '')}s。`);
|
|
1065
|
+
}
|
|
1066
|
+
if (rule.maxDurationMs != null && resource.duration != null && resource.duration * 1000 > rule.maxDurationMs) {
|
|
1067
|
+
throw argumentError(`keyframe duration 超过模型限制:${resource.duration}s`, `最大 ${(rule.maxDurationMs / 1000).toFixed(3).replace(/\.?0+$/, '')}s。`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function assertResourceCountsAgainstModel(resourceDetails = [], resourceRules = []) {
|
|
1074
|
+
for (const rule of resourceRules) {
|
|
1075
|
+
const matches = resourceDetails.filter((detail) => resourceRuleMatches(rule, detail.resource));
|
|
1076
|
+
if (!matches.length) continue;
|
|
1077
|
+
const count = matches.length;
|
|
1078
|
+
const minCount = rule.minFiles ?? rule.minItems;
|
|
1079
|
+
const maxCount = rule.maxFiles ?? rule.maxItems;
|
|
1080
|
+
if (minCount != null && count < minCount) {
|
|
1081
|
+
throw argumentError(`模型素材数量不足:${rule.mediaType}:${resourceUsageList(rule.usage).join('|')}`, `当前 ${count} 个,至少 ${minCount} 个。`);
|
|
1082
|
+
}
|
|
1083
|
+
if (maxCount != null && count > maxCount) {
|
|
1084
|
+
throw argumentError(`模型素材数量超限:${rule.mediaType}:${resourceUsageList(rule.usage).join('|')}`, `当前 ${count} 个,最多 ${maxCount} 个。`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const firstFrames = resourceDetails.filter((detail) => detail.resource.type === 'image' && detail.resource.usage === 'first_frame');
|
|
1089
|
+
const lastFrames = resourceDetails.filter((detail) => detail.resource.type === 'image' && detail.resource.usage === 'last_frame');
|
|
1090
|
+
if (firstFrames.length > 1) throw argumentError('同一请求最多只能有 1 个 first_frame');
|
|
1091
|
+
if (lastFrames.length > 1) throw argumentError('同一请求最多只能有 1 个 last_frame');
|
|
1092
|
+
if (lastFrames.length && !firstFrames.length) {
|
|
1093
|
+
const frameRule = resourceRules.find((rule) => (
|
|
1094
|
+
String(rule.mediaType || '').toUpperCase() === 'IMAGE'
|
|
1095
|
+
&& resourceUsageList(rule.usage).includes('last_frame')
|
|
1096
|
+
));
|
|
1097
|
+
if (frameRule?.supportLastFrameOnly !== true) {
|
|
1098
|
+
throw argumentError('当前模型不支持仅尾帧输入', '请同时提供 image:first_frame,或换用 supportLastFrameOnly=true 的模型。');
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const keyframes = resourceDetails.filter((detail) => detail.resource.usage === 'keyframe');
|
|
1103
|
+
const orders = new Set();
|
|
1104
|
+
for (const detail of keyframes) {
|
|
1105
|
+
const order = detail.resource.order;
|
|
1106
|
+
if (orders.has(order)) throw argumentError(`keyframe order 重复:${order}`);
|
|
1107
|
+
orders.add(order);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function promptParamValue(promptParams = {}, key) {
|
|
1112
|
+
if (key === 'duration') return promptParams.duration;
|
|
1113
|
+
if (key === 'generateNum') return promptParams.generate_num;
|
|
1114
|
+
if (key === 'needAudio') return promptParams.need_audio;
|
|
1115
|
+
return promptParams[key];
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function providedPromptParamKeys(promptParams = {}) {
|
|
1119
|
+
const keys = [];
|
|
1120
|
+
for (const key of ['prompt', 'ratio', 'quality', 'duration', 'generateNum', 'needAudio']) {
|
|
1121
|
+
const value = promptParamValue(promptParams, key);
|
|
1122
|
+
if (value !== undefined && value !== null && value !== '') keys.push(key);
|
|
1123
|
+
}
|
|
1124
|
+
return keys;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function inferGeneratedMode(resources = []) {
|
|
1128
|
+
if (resources.some((resource) => resource.usage === 'keyframe')) return 'multi_prompt';
|
|
1129
|
+
if (resources.some((resource) => ['first_frame', 'last_frame'].includes(resource.usage))) return 'frames';
|
|
1130
|
+
if (resources.some((resource) => resource.usage === 'reference')) return 'multi_param';
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function conditionValue(condition = {}, promptParams = {}) {
|
|
1135
|
+
const resources = Array.isArray(promptParams.resources) ? promptParams.resources : [];
|
|
1136
|
+
if (condition.key === 'resource.image.frame') return resources.some((resource) => resource.type === 'image' && ['first_frame', 'last_frame'].includes(resource.usage));
|
|
1137
|
+
if (condition.key === 'resource.reference') return resources.some((resource) => resource.usage === 'reference');
|
|
1138
|
+
if (condition.key === 'resource.keyframe') return resources.some((resource) => resource.usage === 'keyframe');
|
|
1139
|
+
if (condition.key === 'generatedMode') return inferGeneratedMode(resources);
|
|
1140
|
+
return promptParamValue(promptParams, condition.key);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function constraintConditionMatches(condition = {}, promptParams = {}) {
|
|
1144
|
+
const actual = conditionValue(condition, promptParams);
|
|
1145
|
+
if (condition.meaning === 'present') return actual !== undefined && actual !== null && actual !== '' && actual !== false;
|
|
1146
|
+
if (condition.meaning === 'empty') return actual === undefined || actual === null || actual === '' || actual === false;
|
|
1147
|
+
return String(actual ?? '') === String(condition.value ?? '');
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function assertParamsAgainstModel(promptParams = {}, params = [], constraints = []) {
|
|
1151
|
+
const paramByKey = new Map(params.map((item) => [item.key, item]));
|
|
1152
|
+
for (const key of providedPromptParamKeys(promptParams)) {
|
|
1153
|
+
const param = paramByKey.get(key);
|
|
1154
|
+
if (!param) {
|
|
1155
|
+
throw argumentError(`模型不支持参数:${key}`, '请先运行 model options,只传 params[] 中暴露的参数。');
|
|
1156
|
+
}
|
|
1157
|
+
const value = promptParamValue(promptParams, key);
|
|
1158
|
+
if (Array.isArray(param.values) && param.values.length && !param.values.map(String).includes(String(value))) {
|
|
1159
|
+
throw argumentError(`参数 ${key} 不在模型允许值中:${value}`, `允许值:${param.values.join(', ')}。`);
|
|
1160
|
+
}
|
|
1161
|
+
if (param.maxLength != null && typeof value === 'string' && value.length > param.maxLength) {
|
|
1162
|
+
throw argumentError(`参数 ${key} 超过模型长度限制:${value.length}/${param.maxLength}`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
for (const constraint of constraints) {
|
|
1167
|
+
const conditions = Array.isArray(constraint.conditions) ? constraint.conditions : [];
|
|
1168
|
+
if (!conditions.length || !conditions.every((condition) => constraintConditionMatches(condition, promptParams))) continue;
|
|
1169
|
+
const target = constraint.target;
|
|
1170
|
+
const value = promptParamValue(promptParams, target);
|
|
1171
|
+
if (value === undefined || value === null || value === '') continue;
|
|
1172
|
+
const allowedValues = Array.isArray(constraint.allowValues) ? constraint.allowValues : [];
|
|
1173
|
+
if (!allowedValues.length) {
|
|
1174
|
+
throw argumentError(`当前输入触发模型约束,不能传参数:${target}`, `约束:${constraint.name || constraint.targetConfigCode || target}。`);
|
|
1175
|
+
}
|
|
1176
|
+
if (!allowedValues.map(String).includes(String(value))) {
|
|
1177
|
+
throw argumentError(`参数 ${target} 不满足当前输入触发的模型约束:${value}`, `允许值:${allowedValues.join(', ')}。`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async function validateCreateRequestAgainstModel(kind, modelGroupCode, promptParams = {}, resourceDetails = []) {
|
|
1183
|
+
const context = await loadModelOptionContext(modelGroupCode, { includeConstraintSchema: true });
|
|
1184
|
+
if (context.taskKind !== kind) {
|
|
1185
|
+
throw argumentError(`模型任务类型不匹配:${context.taskKind}`, `当前命令是 ${kind} create,请换用对应任务类型的模型。`);
|
|
1186
|
+
}
|
|
1187
|
+
const params = modelOptionParams(context.options, context.taskKind);
|
|
1188
|
+
const resources = modelOptionsResources(context.inputModes);
|
|
1189
|
+
assertParamsAgainstModel(promptParams, params, context.constraints);
|
|
1190
|
+
assertResourceShapeAgainstModel(resourceDetails, resources);
|
|
1191
|
+
assertResourceCountsAgainstModel(resourceDetails, resources);
|
|
1192
|
+
if (kind === 'video') {
|
|
1193
|
+
const supportedIntents = createSpecSupportedIntents(context.inputModes, context.taskKind);
|
|
1194
|
+
const inputRequirement = createSpecInputRequirement(context.taskKind, context.inputModes, supportedIntents);
|
|
1195
|
+
if (inputRequirement.visualInputRequired && !resourceDetails.length) {
|
|
1196
|
+
throw argumentError('当前视频模型不能只传 prompt 创建', `必须选择一种受支持的素材输入:${inputRequirement.acceptedModes?.join(', ') || 'reference/frames/storyboard'}。`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return { context, params, resourceRules: resources };
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function loadModelOptionContext(modelGroupCode, options = {}) {
|
|
1203
|
+
const rawModel = await awbApi.fetchModelGroupInfoAll(modelGroupCode);
|
|
1204
|
+
const rawOptions = normalizeRawModelOptions(rawModel, { includeRaw: options.includeRaw });
|
|
1205
|
+
let configOptions = [];
|
|
1206
|
+
if (rawModel?.modelCode) {
|
|
1207
|
+
const optionsPayload = await fetchNormalizedModelOptions({
|
|
1208
|
+
modelCode: rawModel.modelCode,
|
|
1209
|
+
modelGroupCode,
|
|
1210
|
+
selectedConfigs: options.selectedConfigs ?? {},
|
|
1211
|
+
includeRaw: options.includeRaw,
|
|
1212
|
+
});
|
|
1213
|
+
configOptions = Array.isArray(optionsPayload.options) ? optionsPayload.options : [];
|
|
1214
|
+
}
|
|
1215
|
+
const mergedOptions = mergeModelParamRules(configOptions.length ? configOptions : rawOptions, rawModel);
|
|
1216
|
+
const optionKeys = new Set(mergedOptions.map((item) => item.paramKey).filter(Boolean));
|
|
1217
|
+
const taskKind = inferTaskKind(rawModel, optionKeys);
|
|
1218
|
+
const rulesByKey = new Map(mergedOptions.map((item) => [item.paramKey, item.rules]).filter(([key, rules]) => key && rules));
|
|
1219
|
+
const inputModes = createSpecInputModes(taskKind, optionKeys, rulesByKey);
|
|
1220
|
+
const createParams = mergedOptions.map((option) => createParamForModelOption(option, taskKind));
|
|
1221
|
+
const constraintSchema = options.includeConstraintSchema
|
|
1222
|
+
? await fetchModelConstraintSchema(rawModel, modelGroupCode)
|
|
1223
|
+
: [];
|
|
1224
|
+
return {
|
|
1225
|
+
rawModel,
|
|
1226
|
+
model: modelSummaryFromRaw(rawModel, modelGroupCode, taskKind),
|
|
1227
|
+
options: mergedOptions,
|
|
1228
|
+
constraintSchema,
|
|
1229
|
+
constraints: normalizeModelConstraints(constraintSchema, taskKind),
|
|
1230
|
+
optionKeys,
|
|
1231
|
+
rulesByKey,
|
|
1232
|
+
taskKind,
|
|
1233
|
+
inputModes,
|
|
1234
|
+
createParams,
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function optionAllowedValues(option = {}) {
|
|
1239
|
+
if (!Array.isArray(option.optionList)) return [];
|
|
1240
|
+
return option.optionList
|
|
1241
|
+
.filter((item) => item?.available !== false)
|
|
1242
|
+
.map((item) => item?.enumValue ?? item?.value ?? item?.code ?? item?.id ?? item)
|
|
1243
|
+
.filter((value) => value !== undefined && value !== null && value !== '');
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function optionAllowedOptions(option = {}) {
|
|
1247
|
+
if (!Array.isArray(option.optionList)) return [];
|
|
1248
|
+
return option.optionList
|
|
1249
|
+
.filter((item) => item?.available !== false)
|
|
1250
|
+
.map((item) => compactRecord({
|
|
1251
|
+
label: item?.enumName ?? item?.label ?? item?.name ?? item?.enumValue ?? item?.value ?? null,
|
|
1252
|
+
value: item?.enumValue ?? item?.value ?? item?.code ?? item?.id ?? item,
|
|
1253
|
+
rank: item?.rank,
|
|
1254
|
+
}));
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function arrayOrUndefined(value) {
|
|
1258
|
+
return Array.isArray(value) && value.length ? value : undefined;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function ruleNumber(value) {
|
|
1262
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
1263
|
+
const number = Number(value);
|
|
1264
|
+
return Number.isFinite(number) ? number : undefined;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function normalizeMediaRule(rule = {}) {
|
|
1268
|
+
if (!rule || typeof rule !== 'object') return {};
|
|
1269
|
+
return compactRecord({
|
|
1270
|
+
mediaType: rule.mediaType,
|
|
1271
|
+
fileTypes: arrayOrUndefined(rule.supportedFileTypes),
|
|
1272
|
+
minFiles: ruleNumber(rule.minFrameNum ?? rule.minPromptNum),
|
|
1273
|
+
maxFiles: ruleNumber(rule.fileListMaxNum ?? rule.maxFrameNum),
|
|
1274
|
+
maxSizeKB: ruleNumber(rule.resourceFileMaxSize),
|
|
1275
|
+
minDurationMs: ruleNumber(rule.minDuration),
|
|
1276
|
+
maxDurationMs: ruleNumber(rule.maxDuration),
|
|
1277
|
+
maxTotalDurationMs: ruleNumber(rule.maxTotalDuration),
|
|
1278
|
+
minItems: ruleNumber(rule.minPromptNum),
|
|
1279
|
+
maxItems: ruleNumber(rule.maxPromptNum),
|
|
1280
|
+
maxPromptLength: ruleNumber(rule.maxPromptLength),
|
|
1281
|
+
supportLastFrameOnly: rule.supportLastFrame,
|
|
1282
|
+
required: rule.required,
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function frameRuleCapabilities(rule = {}) {
|
|
1287
|
+
const maxFrameNum = ruleNumber(rule.maxFrameNum ?? rule.fileListMaxNum);
|
|
1288
|
+
const minFrameNum = ruleNumber(rule.minFrameNum ?? rule.fileListMinNum);
|
|
1289
|
+
const supportLastFrameOnly = rule.supportLastFrame === true;
|
|
1290
|
+
const supportsFirstFrame = maxFrameNum == null || maxFrameNum >= 1;
|
|
1291
|
+
const supportsFirstLastFrame = maxFrameNum != null && maxFrameNum >= 2;
|
|
1292
|
+
const usages = ['first_frame'];
|
|
1293
|
+
if (supportsFirstLastFrame || supportLastFrameOnly) usages.push('last_frame');
|
|
1294
|
+
return compactRecord({
|
|
1295
|
+
minFrameNum,
|
|
1296
|
+
maxFrameNum,
|
|
1297
|
+
supportsFirstFrame,
|
|
1298
|
+
supportsFirstLastFrame,
|
|
1299
|
+
supportLastFrameOnly,
|
|
1300
|
+
usages,
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function mediaRuleFromMultiParam(rules = {}, mediaType) {
|
|
1305
|
+
const resources = Array.isArray(rules?.resources) ? rules.resources : [];
|
|
1306
|
+
const found = resources.find((item) => String(item?.mediaType || '').toUpperCase() === mediaType);
|
|
1307
|
+
return found ? normalizeMediaRule(found) : {};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function mergeResourceLimits(base = {}, overrides = {}) {
|
|
1311
|
+
return compactRecord({ ...base, ...overrides });
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function mergeModelParamRules(options = [], rawModel = null) {
|
|
1315
|
+
const rawParams = Array.isArray(rawModel?.modelParams) ? rawModel.modelParams : [];
|
|
1316
|
+
const rawByKey = new Map(rawParams.map((item) => [item?.paramKey, item]).filter(([key]) => key));
|
|
1317
|
+
return options.map((option) => {
|
|
1318
|
+
const raw = rawByKey.get(option.paramKey);
|
|
1319
|
+
return {
|
|
1320
|
+
...option,
|
|
1321
|
+
hasConstraint: option.hasConstraint ?? raw?.hasConstraint ?? null,
|
|
1322
|
+
rules: option.rules ?? raw?.rules ?? null,
|
|
1323
|
+
};
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function createParamForModelOption(option = {}, taskKind = 'image') {
|
|
1328
|
+
const key = option.paramKey;
|
|
1329
|
+
const common = {
|
|
1330
|
+
modelParamKey: key,
|
|
1331
|
+
modelParamName: option.paramName ?? null,
|
|
1332
|
+
modelParamType: option.paramType ?? null,
|
|
1333
|
+
allowedValues: optionAllowedValues(option),
|
|
1334
|
+
allowedOptions: optionAllowedOptions(option),
|
|
1335
|
+
};
|
|
1336
|
+
const definitions = {
|
|
1337
|
+
prompt: {
|
|
1338
|
+
key: 'prompt',
|
|
1339
|
+
cliArg: '--prompt',
|
|
1340
|
+
requestPath: 'promptParams.prompt',
|
|
1341
|
+
materialLegacyKey: 'prompt',
|
|
1342
|
+
meaning: taskKind === 'image' ? '用户提供的图片生成提示词文本。' : '用户提供的视频生成提示词文本。',
|
|
1343
|
+
valueSource: '以用户明确的创作描述为 prompt 基底;CLI/Agent 负责资源占位符、key 对齐、转义清理和模型约束组装。需求不明确时先追问或使用最小中性表达,不主动编内容补空白。',
|
|
1344
|
+
},
|
|
1345
|
+
ratio: {
|
|
1346
|
+
key: 'ratio',
|
|
1347
|
+
cliArg: '--ratio',
|
|
1348
|
+
requestPath: 'promptParams.ratio',
|
|
1349
|
+
materialLegacyKey: 'ratio',
|
|
1350
|
+
meaning: taskKind === 'image' ? '图片画幅比例。' : '视频画面比例。',
|
|
1351
|
+
valueSource: '只能从 allowedValues 选择。',
|
|
1352
|
+
},
|
|
1353
|
+
quality: {
|
|
1354
|
+
key: 'quality',
|
|
1355
|
+
cliArg: '--quality',
|
|
1356
|
+
requestPath: 'promptParams.quality',
|
|
1357
|
+
materialLegacyKey: 'quality',
|
|
1358
|
+
meaning: taskKind === 'image' ? '图片清晰度 / 分辨率档位。' : '视频清晰度 / 分辨率档位。',
|
|
1359
|
+
valueSource: '只能从 allowedValues 选择。',
|
|
1360
|
+
},
|
|
1361
|
+
generate_num: {
|
|
1362
|
+
key: 'generateNum',
|
|
1363
|
+
cliArg: '--generate-num',
|
|
1364
|
+
requestPath: 'promptParams.generate_num',
|
|
1365
|
+
materialLegacyKey: 'generate_num',
|
|
1366
|
+
meaning: '一次任务生成的图片张数。',
|
|
1367
|
+
valueSource: '只能从 allowedValues 选择;CLI 会转成整数。',
|
|
1368
|
+
},
|
|
1369
|
+
generated_time: {
|
|
1370
|
+
key: 'duration',
|
|
1371
|
+
cliArg: '--duration',
|
|
1372
|
+
requestPath: 'promptParams.duration',
|
|
1373
|
+
materialLegacyKey: 'generated_time',
|
|
1374
|
+
meaning: '视频生成时长,单位秒。',
|
|
1375
|
+
valueSource: '只能从 allowedValues 选择;CLI 会转成整数。',
|
|
1376
|
+
},
|
|
1377
|
+
need_audio: {
|
|
1378
|
+
key: 'needAudio',
|
|
1379
|
+
cliArg: '--need-audio',
|
|
1380
|
+
requestPath: 'promptParams.need_audio',
|
|
1381
|
+
materialLegacyKey: 'need_audio',
|
|
1382
|
+
meaning: '是否需要输出音效 / 模型生成音频。',
|
|
1383
|
+
valueSource: '布尔值 true / false;仅模型配置存在该项且用户明确要求输出音效时使用。它不是上传音频文件的入口。',
|
|
1384
|
+
controlKind: 'output_audio_toggle',
|
|
1385
|
+
},
|
|
1386
|
+
audio: {
|
|
1387
|
+
key: 'needAudio',
|
|
1388
|
+
cliArg: '--need-audio',
|
|
1389
|
+
requestPath: 'promptParams.need_audio',
|
|
1390
|
+
materialLegacyKey: 'audio',
|
|
1391
|
+
meaning: '是否需要输出音效 / 模型生成音频。',
|
|
1392
|
+
valueSource: '布尔值 true / false;仅模型配置存在该项且用户明确要求输出音效时使用。它不是上传音频文件的入口。',
|
|
1393
|
+
controlKind: 'output_audio_toggle',
|
|
1394
|
+
},
|
|
1395
|
+
iref: {
|
|
1396
|
+
key: 'resources',
|
|
1397
|
+
cliArg: '--resource / --resources-json',
|
|
1398
|
+
requestPath: 'promptParams.resources[]',
|
|
1399
|
+
materialLegacyKey: 'iref',
|
|
1400
|
+
meaning: '图片任务的参考图输入。',
|
|
1401
|
+
valueSource: '本地文件、http(s) URL 或 material backendPath。',
|
|
1402
|
+
resourceSyntax: ['image:reference=./ref.png'],
|
|
1403
|
+
},
|
|
1404
|
+
resources: {
|
|
1405
|
+
key: 'resources',
|
|
1406
|
+
cliArg: '--resource / --resources-json',
|
|
1407
|
+
requestPath: 'promptParams.resources[]',
|
|
1408
|
+
materialLegacyKey: 'resources',
|
|
1409
|
+
meaning: '模型声明的素材输入;CLI 统一用 resources 表达。',
|
|
1410
|
+
valueSource: '本地文件、http(s) URL 或 material backendPath。',
|
|
1411
|
+
resourceSyntax: taskKind === 'image' ? ['image:reference=./ref.png'] : ['image:first_frame=./first.png'],
|
|
1412
|
+
},
|
|
1413
|
+
frames: {
|
|
1414
|
+
key: 'resources',
|
|
1415
|
+
cliArg: '--resource / --resources-json',
|
|
1416
|
+
requestPath: 'promptParams.resources[]',
|
|
1417
|
+
materialLegacyKey: 'frames',
|
|
1418
|
+
meaning: '视频首帧 / 尾帧 / 关键帧输入。',
|
|
1419
|
+
valueSource: '首帧/尾帧可用本地文件、http(s) URL、material backendPath 或 asset:<assetId>;keyframe 仅用文件/URL/backendPath。',
|
|
1420
|
+
resourceSyntax: ['image:first_frame=./first.png', 'image:first_frame=asset:<assetId>', 'image:last_frame=asset:<assetId>', 'image:keyframe#1=./key1.png'],
|
|
1421
|
+
},
|
|
1422
|
+
multi_param: {
|
|
1423
|
+
key: 'resources',
|
|
1424
|
+
cliArg: '--resource / --resources-json',
|
|
1425
|
+
requestPath: 'promptParams.resources[]',
|
|
1426
|
+
materialLegacyKey: 'multi_param',
|
|
1427
|
+
meaning: '参考模式输入;图片、视频、音频和主体都用 type=...、usage=reference 表达。',
|
|
1428
|
+
valueSource: 'reference_key 是可选绑定。prompt 可以不写占位符;如果写了 <<<key>>>,必须能匹配同名 reference 资源。',
|
|
1429
|
+
resourceSyntax: [
|
|
1430
|
+
'image:reference=./hero.png',
|
|
1431
|
+
'image:reference:hero=./hero.png',
|
|
1432
|
+
'image:reference:hero=asset:<assetId>',
|
|
1433
|
+
'video:reference=./motion.mp4',
|
|
1434
|
+
'video:reference:motion=./motion.mp4',
|
|
1435
|
+
'audio:reference=./music.mp3',
|
|
1436
|
+
'audio:reference:voice=./voice.wav',
|
|
1437
|
+
'subject:reference:hero=asset:<externalId>',
|
|
1438
|
+
],
|
|
1439
|
+
},
|
|
1440
|
+
multi_prompt: {
|
|
1441
|
+
key: 'resources',
|
|
1442
|
+
cliArg: '--resources-json',
|
|
1443
|
+
requestPath: 'promptParams.resources[]',
|
|
1444
|
+
materialLegacyKey: 'multi_prompt',
|
|
1445
|
+
meaning: '多段提示 / 关键帧提示输入;仅模型配置存在该项时使用。',
|
|
1446
|
+
valueSource: '使用 resources-json,在 keyframe resource 上携带 prompt / duration。',
|
|
1447
|
+
resourceSyntax: ['[{"type":"image","usage":"keyframe","order":1,"prompt":"...","source":{"kind":"url","value":"./k1.png"}}]'],
|
|
1448
|
+
},
|
|
1449
|
+
generated_mode: {
|
|
1450
|
+
key: 'generatedMode',
|
|
1451
|
+
cliArg: null,
|
|
1452
|
+
requestPath: null,
|
|
1453
|
+
materialLegacyKey: 'generated_mode',
|
|
1454
|
+
meaning: '平台旧参数的生成模式。Agent 不要手动传;CLI/Material 会根据 resources usage 推导 frames 或 multi_param。',
|
|
1455
|
+
valueSource: '由资源类型推导,不是用户输入。',
|
|
1456
|
+
internalOnly: true,
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
return {
|
|
1460
|
+
...(definitions[key] ?? {
|
|
1461
|
+
key,
|
|
1462
|
+
cliArg: null,
|
|
1463
|
+
requestPath: null,
|
|
1464
|
+
materialLegacyKey: key,
|
|
1465
|
+
meaning: '模型配置存在该参数,但当前 CLI 没有暴露为稳定创建参数;不要为了调用而硬塞旧字段。',
|
|
1466
|
+
valueSource: '如确实需要,先扩展 CLI 显式参数和 Material adapter。',
|
|
1467
|
+
notExposedByCli: true,
|
|
1468
|
+
}),
|
|
1469
|
+
...common,
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function inferTaskKind(found, optionKeys) {
|
|
1474
|
+
if (found?.kind) return found.kind;
|
|
1475
|
+
if (found?.usage === 'IMAGE_CREATE') return 'image';
|
|
1476
|
+
if (found?.usage === 'VIDEO_CREATE') return 'video';
|
|
1477
|
+
return ['generated_time', 'frames', 'multi_param', 'multi_prompt', 'generated_mode', 'need_audio']
|
|
1478
|
+
.some((key) => optionKeys.has(key)) ? 'video' : 'image';
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function createSpecInputModes(taskKind, optionKeys, rulesByKey = new Map()) {
|
|
1482
|
+
const hasPrompt = optionKeys.has('prompt');
|
|
1483
|
+
const hasImageReference = ['iref', 'cref', 'sref', 'resources'].some((key) => optionKeys.has(key));
|
|
1484
|
+
const hasFrames = optionKeys.has('frames');
|
|
1485
|
+
const hasMultiParam = optionKeys.has('multi_param');
|
|
1486
|
+
const hasMultiPrompt = optionKeys.has('multi_prompt');
|
|
1487
|
+
const imageReferenceRules = rulesByKey.get('iref') || rulesByKey.get('cref') || rulesByKey.get('sref') || rulesByKey.get('resources') || {};
|
|
1488
|
+
const framesRules = rulesByKey.get('frames') || {};
|
|
1489
|
+
const multiParamRules = rulesByKey.get('multi_param') || {};
|
|
1490
|
+
const multiPromptRules = rulesByKey.get('multi_prompt') || {};
|
|
1491
|
+
const multiParamResources = Array.isArray(multiParamRules.resources) ? multiParamRules.resources : [];
|
|
1492
|
+
const supportsMultiMedia = (mediaType) => {
|
|
1493
|
+
if (!hasMultiParam) return false;
|
|
1494
|
+
if (!multiParamResources.length) return true;
|
|
1495
|
+
return multiParamResources.some((item) => String(item?.mediaType || '').toUpperCase() === mediaType);
|
|
1496
|
+
};
|
|
1497
|
+
const supportsMultiImage = supportsMultiMedia('IMAGE');
|
|
1498
|
+
const supportsMultiVideo = supportsMultiMedia('VIDEO');
|
|
1499
|
+
const supportsMultiAudio = supportsMultiMedia('AUDIO');
|
|
1500
|
+
const frameCapabilities = frameRuleCapabilities(framesRules);
|
|
1501
|
+
const frameOptionResourceLimits = normalizeMediaRule(framesRules);
|
|
1502
|
+
const supportsFirstFrame = hasFrames && frameCapabilities.supportsFirstFrame !== false;
|
|
1503
|
+
const supportsFirstLastFrame = hasFrames && frameCapabilities.supportsFirstLastFrame === true;
|
|
1504
|
+
const supportLastFrameOnly = hasFrames && frameCapabilities.supportLastFrameOnly === true;
|
|
1505
|
+
const frameMinFiles = frameOptionResourceLimits.minFiles ?? 0;
|
|
1506
|
+
const framesRequired = hasFrames && (framesRules.required === true || frameMinFiles > 0);
|
|
1507
|
+
const videoPromptOnlyBlocked = taskKind === 'video' && hasPrompt && (framesRequired || hasMultiParam || hasMultiPrompt);
|
|
1508
|
+
const modes = [];
|
|
1509
|
+
modes.push({
|
|
1510
|
+
mode: 'prompt_only',
|
|
1511
|
+
supported: taskKind === 'image' ? hasPrompt : hasPrompt && !videoPromptOnlyBlocked,
|
|
1512
|
+
intent: taskKind === 'image' ? '文生图' : '文生视频',
|
|
1513
|
+
requiredArgs: ['--model-group-code', '--prompt'],
|
|
1514
|
+
userInput: '只提供 prompt,不提供参考素材。',
|
|
1515
|
+
...(videoPromptOnlyBlocked ? { unsupportedReason: '该视频模型配置要求在 reference、frames 或 storyboard 模式中选择一种素材输入,不能只传 prompt。' } : {}),
|
|
1516
|
+
});
|
|
1517
|
+
modes.push({
|
|
1518
|
+
mode: 'reference_image',
|
|
1519
|
+
supported: taskKind === 'image' ? hasImageReference : supportsMultiImage,
|
|
1520
|
+
intent: taskKind === 'image' ? '参考图生图 / 图像编辑式生成' : '参考图生视频',
|
|
1521
|
+
requiredArgs: taskKind === 'image'
|
|
1522
|
+
? ['--model-group-code', '--prompt', '--resource image:reference=...']
|
|
1523
|
+
: ['--model-group-code', '--prompt', '--resource image:reference[:key]=...'],
|
|
1524
|
+
userInput: taskKind === 'image' ? '用户提供参考图,希望生成或编辑图片。' : '用户提供参考图,希望参考图中主体、风格或构图生成视频。',
|
|
1525
|
+
resourceSyntax: taskKind === 'image'
|
|
1526
|
+
? ['image:reference=./ref.png']
|
|
1527
|
+
: ['image:reference:hero=./hero.png', 'image:reference:hero=asset:<assetId>'],
|
|
1528
|
+
requiredResources: taskKind === 'image' ? ['image:reference'] : ['image:reference[:key]'],
|
|
1529
|
+
sourceKinds: taskKind === 'image' ? ['local_file', 'http_url', 'backendPath'] : ['local_file', 'http_url', 'backendPath', 'asset_id'],
|
|
1530
|
+
resourceLimits: taskKind === 'image' ? normalizeMediaRule(imageReferenceRules) : mediaRuleFromMultiParam(multiParamRules, 'IMAGE'),
|
|
1531
|
+
promptBinding: taskKind === 'video' ? 'optional_key' : 'none',
|
|
1532
|
+
promptPlaceholder: taskKind === 'video' ? 'reference_key 可选;prompt 中写了 <<<key>>> 时必须有同名 image reference。' : null,
|
|
1533
|
+
resourceConstraints: taskKind === 'video' ? ['reference_key_optional', 'if_prompt_has_key_then_resource_must_exist'] : [],
|
|
1534
|
+
...((taskKind === 'image' ? !hasImageReference : !supportsMultiImage) ? { unsupportedReason: '模型配置没有对应参考图片资源参数。' } : {}),
|
|
1535
|
+
});
|
|
1536
|
+
if (taskKind === 'video') {
|
|
1537
|
+
modes.push({
|
|
1538
|
+
mode: 'reference_audio',
|
|
1539
|
+
supported: supportsMultiAudio,
|
|
1540
|
+
intent: '参考音频生视频',
|
|
1541
|
+
requiredArgs: ['--model-group-code', '--prompt', '--resource audio:reference=...'],
|
|
1542
|
+
userInput: '用户上传或提供音乐、声音、配音、节奏等音频,希望作为生成参考;这属于 resources 输入,不是 --need-audio。',
|
|
1543
|
+
resourceSyntax: ['audio:reference=./music.mp3', 'audio:reference:voice=./voice.wav'],
|
|
1544
|
+
requiredResources: ['audio:reference'],
|
|
1545
|
+
sourceKinds: ['local_file', 'http_url', 'backendPath', 'asset_id'],
|
|
1546
|
+
resourceLimits: mediaRuleFromMultiParam(multiParamRules, 'AUDIO'),
|
|
1547
|
+
promptBinding: 'optional_key',
|
|
1548
|
+
promptPlaceholder: '普通音频参考可不写 key;当用户强调音频也要参考 / 显式绑定音频时,可用 audio:reference:<key> 并在 prompt 中包含同名 <<<key>>>。',
|
|
1549
|
+
resourceConstraints: ['use_resource_not_need_audio', 'if_prompt_has_key_then_resource_must_exist'],
|
|
1550
|
+
...(!supportsMultiAudio ? { unsupportedReason: '模型配置没有音频参考资源规则。' } : {}),
|
|
1551
|
+
});
|
|
1552
|
+
modes.push({
|
|
1553
|
+
mode: 'reference_video',
|
|
1554
|
+
supported: supportsMultiVideo,
|
|
1555
|
+
intent: '参考视频生视频',
|
|
1556
|
+
requiredArgs: ['--model-group-code', '--prompt', '--resource video:reference[:key]=...'],
|
|
1557
|
+
userInput: '用户提供参考视频,希望参考运动、节奏或镜头生成视频。',
|
|
1558
|
+
resourceSyntax: ['video:reference:motion=./motion.mp4', 'video:reference:motion=asset:<assetId>'],
|
|
1559
|
+
requiredResources: ['video:reference[:key]'],
|
|
1560
|
+
sourceKinds: ['local_file', 'http_url', 'backendPath', 'asset_id'],
|
|
1561
|
+
resourceLimits: mediaRuleFromMultiParam(multiParamRules, 'VIDEO'),
|
|
1562
|
+
promptBinding: 'optional_key',
|
|
1563
|
+
promptPlaceholder: 'reference_key 可选;prompt 中写了 <<<key>>> 时必须有同名 video reference。',
|
|
1564
|
+
resourceConstraints: ['reference_key_optional', 'if_prompt_has_key_then_resource_must_exist'],
|
|
1565
|
+
...(!supportsMultiVideo ? { unsupportedReason: '模型配置没有视频参考资源规则。' } : {}),
|
|
1566
|
+
});
|
|
1567
|
+
modes.push({
|
|
1568
|
+
mode: 'first_frame',
|
|
1569
|
+
supported: supportsFirstFrame,
|
|
1570
|
+
intent: '首帧生视频',
|
|
1571
|
+
requiredArgs: ['--model-group-code', '--resource image:first_frame=...'],
|
|
1572
|
+
userInput: '用户提供起始画面,希望从这张图延展成视频。',
|
|
1573
|
+
resourceSyntax: ['image:first_frame=./first.png', 'image:first_frame=asset:<assetId>'],
|
|
1574
|
+
requiredResources: ['image:first_frame'],
|
|
1575
|
+
sourceKinds: ['local_file', 'http_url', 'backendPath', 'asset_id'],
|
|
1576
|
+
resourceLimits: mergeResourceLimits(normalizeMediaRule(framesRules), { minFiles: 1, maxFiles: 1 }),
|
|
1577
|
+
optionResourceLimits: frameOptionResourceLimits,
|
|
1578
|
+
optionResourceUsages: frameCapabilities.usages,
|
|
1579
|
+
promptBinding: 'none',
|
|
1580
|
+
promptPlaceholder: '不需要 <<<key>>> 占位符。',
|
|
1581
|
+
resourceConstraints: ['last_frame_not_required', 'key_not_used'],
|
|
1582
|
+
supportLastFrameOnly,
|
|
1583
|
+
...(!hasFrames ? { unsupportedReason: '模型配置没有 frames 参数。' } : (!supportsFirstFrame ? { unsupportedReason: '模型配置 frames.maxFrameNum 小于 1。' } : {})),
|
|
1584
|
+
});
|
|
1585
|
+
modes.push({
|
|
1586
|
+
mode: 'first_last_frame',
|
|
1587
|
+
supported: supportsFirstLastFrame,
|
|
1588
|
+
intent: '首尾帧过渡生视频',
|
|
1589
|
+
requiredArgs: ['--model-group-code', '--resource image:first_frame=...', '--resource image:last_frame=...'],
|
|
1590
|
+
userInput: '用户提供起始画面和结束画面,希望生成中间过渡视频。',
|
|
1591
|
+
resourceSyntax: ['image:first_frame=./first.png', 'image:last_frame=./last.png', 'image:first_frame=asset:<assetId>', 'image:last_frame=asset:<assetId>'],
|
|
1592
|
+
requiredResources: ['image:first_frame', 'image:last_frame'],
|
|
1593
|
+
sourceKinds: ['local_file', 'http_url', 'backendPath', 'asset_id'],
|
|
1594
|
+
resourceLimits: mergeResourceLimits(normalizeMediaRule(framesRules), { minFiles: 2, maxFiles: 2 }),
|
|
1595
|
+
optionResourceLimits: frameOptionResourceLimits,
|
|
1596
|
+
optionResourceUsages: frameCapabilities.usages,
|
|
1597
|
+
promptBinding: 'none',
|
|
1598
|
+
promptPlaceholder: '不需要 <<<key>>> 占位符。',
|
|
1599
|
+
resourceConstraints: ['both_first_and_last_required', 'key_not_used'],
|
|
1600
|
+
supportLastFrameOnly,
|
|
1601
|
+
...(!hasFrames ? { unsupportedReason: '模型配置没有 frames 参数。' } : (!supportsFirstLastFrame ? { unsupportedReason: '模型配置 frames.maxFrameNum 小于 2。' } : {})),
|
|
1602
|
+
});
|
|
1603
|
+
modes.push({
|
|
1604
|
+
mode: 'last_frame_only',
|
|
1605
|
+
supported: supportLastFrameOnly,
|
|
1606
|
+
intent: '仅尾帧生视频',
|
|
1607
|
+
requiredArgs: ['--model-group-code', '--resource image:last_frame=...'],
|
|
1608
|
+
userInput: '用户只提供结束画面,希望反向约束视频收束到该画面;这是少数模型才支持的特殊能力。',
|
|
1609
|
+
resourceSyntax: ['image:last_frame=./last.png', 'image:last_frame=asset:<assetId>'],
|
|
1610
|
+
requiredResources: ['image:last_frame'],
|
|
1611
|
+
sourceKinds: ['local_file', 'http_url', 'backendPath', 'asset_id'],
|
|
1612
|
+
resourceLimits: mergeResourceLimits(normalizeMediaRule(framesRules), { minFiles: 1, maxFiles: 1 }),
|
|
1613
|
+
optionResourceLimits: frameOptionResourceLimits,
|
|
1614
|
+
optionResourceUsages: frameCapabilities.usages,
|
|
1615
|
+
promptBinding: 'none',
|
|
1616
|
+
promptPlaceholder: 'last_frame 不使用 reference_key;prompt 可选,用于补充动作、镜头或画面要求。',
|
|
1617
|
+
resourceConstraints: ['last_frame_only_requires_supportLastFrameOnly', 'key_not_used'],
|
|
1618
|
+
supportLastFrameOnly,
|
|
1619
|
+
...(!hasFrames ? { unsupportedReason: '模型配置没有 frames 参数。' } : (!supportLastFrameOnly ? { unsupportedReason: '模型配置未声明支持仅尾帧。' } : {})),
|
|
1620
|
+
});
|
|
1621
|
+
modes.push({
|
|
1622
|
+
mode: 'subject_reference',
|
|
1623
|
+
supported: supportsMultiImage,
|
|
1624
|
+
intent: '可复用主体参考生视频',
|
|
1625
|
+
requiredArgs: ['--model-group-code', '--prompt "<<<key>>> ..."', '--resource subject:reference:<key>=asset:<externalId>'],
|
|
1626
|
+
userInput: '用户要长期复用同一个角色 / 主体,并希望 prompt 显式引用该主体。',
|
|
1627
|
+
resourceSyntax: ['subject:reference:hero=asset:<externalId>'],
|
|
1628
|
+
requiredResources: ['subject:reference:<key>=asset:<externalId>'],
|
|
1629
|
+
sourceKinds: ['asset_id'],
|
|
1630
|
+
resourceLimits: mediaRuleFromMultiParam(multiParamRules, 'IMAGE'),
|
|
1631
|
+
promptBinding: 'optional_key',
|
|
1632
|
+
promptPlaceholder: 'subject reference 必须带 reference_key;是否在 prompt 中写 <<<key>>> 由用户表达决定。',
|
|
1633
|
+
resourceConstraints: ['external_id_from_subject_wait', 'reference_key_required_for_subject'],
|
|
1634
|
+
...(!supportsMultiImage ? { unsupportedReason: '模型配置没有图片 / 主体参考资源规则。' } : {}),
|
|
1635
|
+
});
|
|
1636
|
+
modes.push({
|
|
1637
|
+
mode: 'storyboard',
|
|
1638
|
+
supported: hasMultiPrompt,
|
|
1639
|
+
intent: '故事板 / 多段关键帧生视频',
|
|
1640
|
+
requiredArgs: ['--model-group-code', '--resources-json <keyframe[]>'],
|
|
1641
|
+
userInput: '用户提供多段分镜,每段可包含 keyframe 图片、局部 prompt 和小数秒 duration。',
|
|
1642
|
+
resourceSyntax: ['[{"type":"image","usage":"keyframe","order":1,"prompt":"...","duration":0.8,"source":{"kind":"url","value":"./k1.png"}}]'],
|
|
1643
|
+
requiredResources: ['image:keyframe#<order>'],
|
|
1644
|
+
sourceKinds: ['local_file', 'http_url', 'backendPath'],
|
|
1645
|
+
resourceLimits: normalizeMediaRule(multiPromptRules),
|
|
1646
|
+
promptBinding: 'none',
|
|
1647
|
+
promptPlaceholder: '不需要 <<<key>>> 占位符;每段文案写在 keyframe.prompt。',
|
|
1648
|
+
resourceConstraints: ['order_required_positive_integer', 'duration_allows_decimal_seconds', 'asset_id_not_supported'],
|
|
1649
|
+
...(!hasMultiPrompt ? { unsupportedReason: '模型配置没有 multi_prompt 参数。' } : {}),
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
return modes;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function createSpecSupportedIntents(inputModes, taskKind = null) {
|
|
1656
|
+
const supported = (mode) => inputModes.find((item) => item.mode === mode && item.supported);
|
|
1657
|
+
const referenceModes = ['reference_image', 'reference_audio', 'reference_video', 'subject_reference']
|
|
1658
|
+
.map((mode) => supported(mode))
|
|
1659
|
+
.filter(Boolean);
|
|
1660
|
+
const firstFrame = supported('first_frame');
|
|
1661
|
+
const firstLastFrame = supported('first_last_frame');
|
|
1662
|
+
const lastFrameOnly = supported('last_frame_only');
|
|
1663
|
+
const intents = [];
|
|
1664
|
+
const promptOnly = supported('prompt_only');
|
|
1665
|
+
if (promptOnly) {
|
|
1666
|
+
intents.push(compactRecord({
|
|
1667
|
+
mode: 'prompt_only',
|
|
1668
|
+
intent: promptOnly.intent,
|
|
1669
|
+
userInput: promptOnly.userInput,
|
|
1670
|
+
requiredArgs: promptOnly.requiredArgs,
|
|
1671
|
+
promptBinding: 'none',
|
|
1672
|
+
}));
|
|
1673
|
+
}
|
|
1674
|
+
if (referenceModes.length) {
|
|
1675
|
+
const referenceLabels = uniqueNonEmpty(referenceModes.map((item) => ({
|
|
1676
|
+
reference_image: '图片',
|
|
1677
|
+
reference_audio: '音频',
|
|
1678
|
+
reference_video: '视频',
|
|
1679
|
+
subject_reference: '主体',
|
|
1680
|
+
}[item.mode])));
|
|
1681
|
+
if (taskKind === 'image') {
|
|
1682
|
+
intents.push(compactRecord({
|
|
1683
|
+
mode: 'reference',
|
|
1684
|
+
intent: '参考图生图',
|
|
1685
|
+
userInput: '用户提供图片作为参考素材;图片任务不使用 reference_key 或 <<<key>>>,prompt 用图一、图二、参考图、主体等自然描述指代素材。',
|
|
1686
|
+
requiredArgs: ['--model-group-code', '--prompt', '--resource image:reference=...'],
|
|
1687
|
+
resourceUsages: ['reference'],
|
|
1688
|
+
resourceSyntax: uniqueNonEmpty(referenceModes.flatMap((item) => item.resourceSyntax || [])),
|
|
1689
|
+
promptBinding: 'none',
|
|
1690
|
+
promptPlaceholder: '图片任务不使用 reference_key 或 <<<key>>>;在 prompt 中用自然语言指代参考图。',
|
|
1691
|
+
}));
|
|
1692
|
+
} else {
|
|
1693
|
+
intents.push(compactRecord({
|
|
1694
|
+
mode: 'reference',
|
|
1695
|
+
intent: '参考模式',
|
|
1696
|
+
userInput: `用户提供${referenceLabels.join('、')}作为参考素材;同一 reference_key 可聚合多条参考资源。`,
|
|
1697
|
+
requiredArgs: ['--model-group-code', '--prompt', '--resource <type>:reference[:key]=...'],
|
|
1698
|
+
resourceUsages: ['reference'],
|
|
1699
|
+
resourceSyntax: uniqueNonEmpty(referenceModes.flatMap((item) => item.resourceSyntax || []).flatMap((syntax) => {
|
|
1700
|
+
const text = String(syntax);
|
|
1701
|
+
if (text.startsWith('image:reference:hero=')) return [text.replace('image:reference:hero=', 'image:reference='), text];
|
|
1702
|
+
if (text.startsWith('video:reference:motion=')) return [text.replace('video:reference:motion=', 'video:reference='), text];
|
|
1703
|
+
return [text];
|
|
1704
|
+
})),
|
|
1705
|
+
promptBinding: 'optional_key',
|
|
1706
|
+
promptPlaceholder: 'reference_key 可选;prompt 可以不写占位符。如果 prompt 写了 <<<key>>>,必须能匹配同名 reference 资源。',
|
|
1707
|
+
}));
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
if (firstFrame || firstLastFrame || lastFrameOnly) {
|
|
1711
|
+
const resourceSyntax = uniqueNonEmpty([
|
|
1712
|
+
...(firstFrame?.resourceSyntax || []),
|
|
1713
|
+
...(firstLastFrame?.resourceSyntax || []),
|
|
1714
|
+
...(lastFrameOnly?.resourceSyntax || []),
|
|
1715
|
+
]);
|
|
1716
|
+
const resourceUsages = uniqueNonEmpty([
|
|
1717
|
+
...(firstFrame ? ['first_frame'] : []),
|
|
1718
|
+
...(firstLastFrame || lastFrameOnly ? ['last_frame'] : []),
|
|
1719
|
+
]);
|
|
1720
|
+
const userInputParts = uniqueNonEmpty([
|
|
1721
|
+
firstFrame ? '可提供 first_frame 作为起始画面' : null,
|
|
1722
|
+
firstLastFrame ? '可同时提供 first_frame + last_frame 表达起止画面' : null,
|
|
1723
|
+
lastFrameOnly ? '也支持只提供 last_frame 作为结束画面约束' : null,
|
|
1724
|
+
]);
|
|
1725
|
+
intents.push(compactRecord({
|
|
1726
|
+
mode: 'frames',
|
|
1727
|
+
intent: 'frames 模式',
|
|
1728
|
+
userInput: `${userInputParts.join(';')}。`,
|
|
1729
|
+
requiredArgs: firstFrame
|
|
1730
|
+
? ['--model-group-code', '--resource image:first_frame=...', ...(firstLastFrame ? ['[--resource image:last_frame=...]'] : [])]
|
|
1731
|
+
: ['--model-group-code', '--resource image:last_frame=...'],
|
|
1732
|
+
resourceUsages,
|
|
1733
|
+
resourceSyntax,
|
|
1734
|
+
promptBinding: 'none',
|
|
1735
|
+
promptPlaceholder: 'first_frame / last_frame 不使用 reference_key;prompt 可选,用于补充动作、镜头或画面要求。',
|
|
1736
|
+
}));
|
|
1737
|
+
}
|
|
1738
|
+
const storyboard = supported('storyboard');
|
|
1739
|
+
if (storyboard) {
|
|
1740
|
+
intents.push(compactRecord({
|
|
1741
|
+
mode: 'storyboard',
|
|
1742
|
+
intent: '多帧 / 故事板模式',
|
|
1743
|
+
userInput: storyboard.userInput,
|
|
1744
|
+
requiredArgs: storyboard.requiredArgs,
|
|
1745
|
+
resourceUsages: ['keyframe'],
|
|
1746
|
+
resourceSyntax: storyboard.resourceSyntax,
|
|
1747
|
+
promptBinding: 'none',
|
|
1748
|
+
promptPlaceholder: 'keyframe 不使用 reference_key;每帧可在 resources-json 中携带 prompt / duration。',
|
|
1749
|
+
}));
|
|
1750
|
+
}
|
|
1751
|
+
return intents;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function createSpecResourceRequirements(inputModes) {
|
|
1755
|
+
return inputModes
|
|
1756
|
+
.filter((item) => item.supported && Array.isArray(item.resourceSyntax) && item.resourceSyntax.length)
|
|
1757
|
+
.map((item) => compactRecord({
|
|
1758
|
+
mode: item.mode,
|
|
1759
|
+
intent: item.intent,
|
|
1760
|
+
resource: item.requiredResources,
|
|
1761
|
+
syntax: item.resourceSyntax,
|
|
1762
|
+
sources: item.sourceKinds,
|
|
1763
|
+
...item.resourceLimits,
|
|
1764
|
+
promptBinding: item.promptBinding,
|
|
1765
|
+
constraints: item.resourceConstraints,
|
|
1766
|
+
}));
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function createSpecUnsupportedIntents(inputModes) {
|
|
1770
|
+
return inputModes
|
|
1771
|
+
.filter((item) => !item.supported)
|
|
1772
|
+
.map((item) => compactRecord({
|
|
1773
|
+
mode: item.mode,
|
|
1774
|
+
intent: item.intent,
|
|
1775
|
+
userInput: item.userInput,
|
|
1776
|
+
reason: item.unsupportedReason,
|
|
1777
|
+
}));
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function createSpecInputRequirement(taskKind, inputModes, supportedIntents = createSpecSupportedIntents(inputModes, taskKind)) {
|
|
1781
|
+
const supportedModes = supportedIntents.map((item) => item.mode);
|
|
1782
|
+
const promptRequiredModes = supportedIntents
|
|
1783
|
+
.filter((item) => (item.requiredArgs || []).some((arg) => String(arg).includes('--prompt')))
|
|
1784
|
+
.map((item) => item.mode);
|
|
1785
|
+
const promptOptionalModes = supportedIntents
|
|
1786
|
+
.filter((item) => !(item.requiredArgs || []).some((arg) => String(arg).includes('--prompt')))
|
|
1787
|
+
.map((item) => item.mode);
|
|
1788
|
+
const promptOnly = inputModes.find((item) => item.mode === 'prompt_only');
|
|
1789
|
+
const visualModes = supportedModes.filter((mode) => mode !== 'prompt_only');
|
|
1790
|
+
const visualInputRequired = taskKind === 'video' && promptOnly && !promptOnly.supported && visualModes.length > 0;
|
|
1791
|
+
const promptRequired = taskKind === 'image'
|
|
1792
|
+
? true
|
|
1793
|
+
: supportedModes.length > 0 && promptRequiredModes.length === supportedModes.length;
|
|
1794
|
+
return compactRecord({
|
|
1795
|
+
promptRequired,
|
|
1796
|
+
promptRequiredModes: arrayOrUndefined(promptRequiredModes),
|
|
1797
|
+
promptOptionalModes: arrayOrUndefined(promptOptionalModes),
|
|
1798
|
+
visualInputRequired,
|
|
1799
|
+
summary: visualInputRequired
|
|
1800
|
+
? '必须选择 reference、frames 或 storyboard 中的一种视觉输入;不能只传 prompt 创建视频。'
|
|
1801
|
+
: (taskKind === 'image' ? '至少需要 prompt;参考图按用户意图选择。' : '至少需要 prompt;是否需要素材取决于 selected input mode。'),
|
|
1802
|
+
acceptedModes: supportedModes,
|
|
1803
|
+
blockedModes: inputModes.filter((item) => !item.supported).map((item) => item.mode),
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function resourceRequiredInModes(legacyKey, inputModes = []) {
|
|
1808
|
+
const supportedModes = inputModes.filter((item) => item.supported);
|
|
1809
|
+
return supportedModes
|
|
1810
|
+
.filter((item) => {
|
|
1811
|
+
const syntax = Array.isArray(item.resourceSyntax) ? item.resourceSyntax : [];
|
|
1812
|
+
if (['iref', 'cref', 'sref', 'resources'].includes(legacyKey)) {
|
|
1813
|
+
return item.mode === 'reference_image' && syntax.some((value) => String(value).startsWith('image:reference'));
|
|
1814
|
+
}
|
|
1815
|
+
if (legacyKey === 'frames') {
|
|
1816
|
+
return syntax.some((value) => /image:(first_frame|last_frame)=/.test(String(value)));
|
|
1817
|
+
}
|
|
1818
|
+
if (legacyKey === 'multi_param') {
|
|
1819
|
+
return syntax.some((value) => /^(image|video|audio|subject):reference/.test(String(value)));
|
|
1820
|
+
}
|
|
1821
|
+
if (legacyKey === 'multi_prompt') return item.mode === 'storyboard';
|
|
1822
|
+
return false;
|
|
1823
|
+
})
|
|
1824
|
+
.map((item) => item.mode);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function createSpecParameterControls(createParams, inputModes = []) {
|
|
1828
|
+
const userParams = createParams
|
|
1829
|
+
.filter((item) => item.cliArg && item.key !== 'resources')
|
|
1830
|
+
.map((item) => compactRecord({
|
|
1831
|
+
key: item.key,
|
|
1832
|
+
cliArg: item.cliArg,
|
|
1833
|
+
meaning: item.meaning,
|
|
1834
|
+
valueSource: item.valueSource,
|
|
1835
|
+
controlKind: item.controlKind,
|
|
1836
|
+
allowedValues: item.allowedValues,
|
|
1837
|
+
}));
|
|
1838
|
+
const resourceParams = createParams
|
|
1839
|
+
.filter((item) => item.key === 'resources')
|
|
1840
|
+
.map((item) => compactRecord({
|
|
1841
|
+
legacyKey: item.materialLegacyKey,
|
|
1842
|
+
meaning: item.meaning,
|
|
1843
|
+
resourceSyntax: item.resourceSyntax,
|
|
1844
|
+
requiredInModes: resourceRequiredInModes(item.materialLegacyKey, inputModes),
|
|
1845
|
+
valueSource: item.valueSource,
|
|
1846
|
+
}));
|
|
1847
|
+
const hasAudioResource = resourceParams.some((item) => item.legacyKey === 'multi_param');
|
|
1848
|
+
const hasGeneratedAudioToggle = userParams.some((item) => item.key === 'needAudio');
|
|
1849
|
+
return compactRecord({
|
|
1850
|
+
userParams,
|
|
1851
|
+
resourceParams,
|
|
1852
|
+
audioSemantics: {
|
|
1853
|
+
uploadedAudioInput: hasAudioResource
|
|
1854
|
+
? '用户上传音频、音乐、配音、节奏参考时,使用 --resource audio:reference=... 或 resources-json 中 type=audio;不要用 --need-audio。'
|
|
1855
|
+
: '当前模型规格没有 multi_param 资源通道,create-spec 未声明可上传音频参考资源。',
|
|
1856
|
+
generatedAudioToggle: hasGeneratedAudioToggle
|
|
1857
|
+
? '该模型支持“是否需要输出音效”控制;命令层用 --need-audio true/false,不接收音频文件或 URL。'
|
|
1858
|
+
: '当前模型没有暴露“是否需要输出音效”控制项;这不等价于不能使用音频参考资源。',
|
|
1859
|
+
},
|
|
1860
|
+
note: '这里只列当前模型明确暴露的 CLI 可控参数。不要主动把未列出的参数总结成“不支持”;只有用户明确要求对应控制时,才说明 create-spec 没有该参数。',
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function createSpecAgentGuidance(taskKind, inputRequirement) {
|
|
1865
|
+
const keyParamGuidance = taskKind === 'video'
|
|
1866
|
+
? 'fee 或 create --dry-run 前,必须先确认用户未提供的价格 / 效果关键参数:quality、duration、约束后仍可选的 ratio;needAudio 只在用户明确要输出音效时确认。不要静默使用 defaultValue 先估价。'
|
|
1867
|
+
: 'fee 或 create --dry-run 前,必须先确认用户未提供的价格 / 效果关键参数:quality、ratio、generateNum。不要静默使用 defaultValue 先估价。';
|
|
1868
|
+
const guidance = [
|
|
1869
|
+
'先用 model options 获取枚举值、文件类型、数量、大小、时长和条件约束;create-spec 只负责创建方式。',
|
|
1870
|
+
'用 inputRequirement 和 supportedIntents 在 prompt_only、reference、frames、storyboard 中选择一种输入模式;不要按媒体类型拆成多个任务。',
|
|
1871
|
+
];
|
|
1872
|
+
if (taskKind === 'image') {
|
|
1873
|
+
guidance.push('图片任务不使用 reference_key 或 <<<key>>>。参考图只用 --resource image:reference=... 传入,prompt 用“图一 / 图二 / 参考图 / 白发女 / 背景图”等自然语言指代。');
|
|
1874
|
+
} else {
|
|
1875
|
+
guidance.push('reference_key 是视频参考资源的可选绑定;prompt 可以不写占位符。如果 prompt 写了 <<<key>>>,必须能匹配同名 reference 资源。');
|
|
1876
|
+
guidance.push('只有 model options.resources[] 存在 media=AUDIO usage=reference 时,音频文件 / URL / asset 才能走 --resource audio:reference=...;是否需要输出音效才使用 --need-audio true/false。');
|
|
1877
|
+
}
|
|
1878
|
+
guidance.push(
|
|
1879
|
+
keyParamGuidance,
|
|
1880
|
+
'费用确认优先使用 billingPointBalance / billingPointRemainingAfter,它来自 queryGroupPoint,表示实际可扣积分;projectBudgetBalance 只是项目组预算信息。',
|
|
1881
|
+
'prompt 以用户明确文本为基底;不要为了引用素材而强行补 <<<key>>>。',
|
|
1882
|
+
);
|
|
1883
|
+
if (taskKind === 'video' && inputRequirement.visualInputRequired) {
|
|
1884
|
+
guidance.push('该视频模型必须带视觉输入;创建前必须从 supportedIntents 中选择 reference、frames 或 storyboard。');
|
|
1885
|
+
}
|
|
1886
|
+
return guidance;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function createSpecValidationRules(taskKind) {
|
|
1890
|
+
const rules = [
|
|
1891
|
+
'ratio、quality、duration、generateNum 等参数值以 model options.params[].values 为准。',
|
|
1892
|
+
'素材类型、文件格式、数量、大小和时长以 model options.resources[] 为准。',
|
|
1893
|
+
'参数 / 资源联动限制以 model options.constraints[] 为准;触发 no_selectable_values 后不要传该目标参数。',
|
|
1894
|
+
'prompt 以用户明确创作描述为基底;没有明确需求时先追问或使用最小中性表达,不主动编内容补空白。',
|
|
1895
|
+
'fee 和 create --dry-run 前,先让用户选择或确认缺失的关键价格 / 效果参数;defaultValue 只能作为候选默认展示。',
|
|
1896
|
+
'fee 输出中的 billingPointBalance 是实际可扣积分余额;projectBudgetBalance 是项目组预算余额,不要混用。',
|
|
1897
|
+
'用户确认关键参数后先运行 fee,再运行 create --dry-run,给用户确认后才追加 --yes。',
|
|
1898
|
+
];
|
|
1899
|
+
if (taskKind === 'video') {
|
|
1900
|
+
rules.splice(4, 0, 'reference_key 仅用于视频 reference 资源的可选 prompt 占位符绑定;prompt 中出现 <<<key>>> 时,必须有同名 reference 资源。');
|
|
1901
|
+
rules.push('subject reference 必须使用 asset:<externalId>,externalId 应来自 subject publish + subject wait。');
|
|
1902
|
+
} else {
|
|
1903
|
+
rules.push('图片任务 resources 只接受 image reference,且不接受 reference_key 或 <<<key>>>;请用自然 prompt 描述图一、图二、参考图、主体等映射。');
|
|
1904
|
+
}
|
|
1905
|
+
return rules;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
function createSpecPreflight(taskKind) {
|
|
1909
|
+
const modelListCommand = taskKind === 'image' ? 'model image-models' : 'model video-models';
|
|
1910
|
+
const feeCommand = taskKind === 'image' ? 'image fee' : 'video fee';
|
|
1911
|
+
const createCommand = taskKind === 'image' ? 'image create' : 'video create';
|
|
1912
|
+
return [
|
|
1913
|
+
'doctor --verify',
|
|
1914
|
+
`${modelListCommand} --model <keyword>`,
|
|
1915
|
+
'model options --model-group-code <code>',
|
|
1916
|
+
'model create-spec --model-group-code <code>',
|
|
1917
|
+
'按 options 的约束和 create-spec 的 supportedIntents 组装参数',
|
|
1918
|
+
'向用户确认缺失的关键参数候选值',
|
|
1919
|
+
feeCommand,
|
|
1920
|
+
`${createCommand} --dry-run`,
|
|
1921
|
+
'向用户确认模型、项目组、最终 prompt 文本、素材、关键参数、预估积分、可扣积分余额和项目组预算',
|
|
1922
|
+
`${createCommand} --yes`,
|
|
1923
|
+
];
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function createSpecExamples(taskKind, supportedIntents) {
|
|
1927
|
+
const examples = [];
|
|
1928
|
+
const hasIntent = (mode) => supportedIntents.some((item) => item.mode === mode);
|
|
1929
|
+
if (taskKind === 'image') {
|
|
1930
|
+
if (hasIntent('prompt_only')) examples.push('lj-awb image create --model-group-code <code> --prompt "品牌吉祥物表情包四宫格" --ratio 1:1 --quality 1K --dry-run');
|
|
1931
|
+
if (hasIntent('reference')) examples.push('lj-awb image create --model-group-code <code> --prompt "保持参考图主体,生成海报" --resource image:reference=./ref.png --dry-run');
|
|
1932
|
+
} else {
|
|
1933
|
+
if (hasIntent('prompt_only')) examples.push('lj-awb video create --model-group-code <code> --prompt "机器人站在白色展台中央,缓慢转身" --duration 6 --dry-run');
|
|
1934
|
+
if (hasIntent('reference')) {
|
|
1935
|
+
const referenceIntent = supportedIntents.find((item) => item.mode === 'reference');
|
|
1936
|
+
examples.push('lj-awb video create --model-group-code <code> --prompt "人物转身看向镜头" --resource image:reference=./hero.png --duration 5 --dry-run');
|
|
1937
|
+
if (referenceIntent?.resourceSyntax?.some((item) => String(item).startsWith('audio:reference'))) {
|
|
1938
|
+
examples.push('lj-awb video create --model-group-code <code> --prompt "让 <<<hero>>> 跟随音乐节奏转身" --resource image:reference:hero=./hero.png --resource audio:reference=./music.mp3 --duration 5 --dry-run');
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
if (hasIntent('frames')) {
|
|
1942
|
+
const framesIntent = supportedIntents.find((item) => item.mode === 'frames');
|
|
1943
|
+
examples.push('lj-awb video create --model-group-code <code> --prompt "镜头缓慢推进" --resource image:first_frame=./first.png --duration 5 --dry-run');
|
|
1944
|
+
examples.push('lj-awb video create --model-group-code <code> --prompt "镜头缓慢推进" --resource image:first_frame=asset:<assetId> --duration 5 --dry-run');
|
|
1945
|
+
if (framesIntent?.resourceUsages?.includes('last_frame')) {
|
|
1946
|
+
examples.push('lj-awb video create --model-group-code <code> --prompt "从第一张过渡到第二张" --resource image:first_frame=./first.png --resource image:last_frame=./last.png --duration 5 --dry-run');
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
if (hasIntent('storyboard')) examples.push('lj-awb video create --model-group-code <code> --resources-json ./storyboard.json --duration 5 --dry-run');
|
|
1950
|
+
}
|
|
1951
|
+
return examples;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function cliValueType(option = {}, allowedValues = []) {
|
|
1955
|
+
if (allowedValues.length) return 'enum';
|
|
1956
|
+
if (option.paramType === 'Prompt') return 'text';
|
|
1957
|
+
if (option.paramType === 'BooleanType') return 'boolean';
|
|
1958
|
+
if (/Number|Integer|Float/i.test(String(option.paramType || ''))) return 'number';
|
|
1959
|
+
return option.paramType || undefined;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function modelOptionParams(options = [], taskKind = 'image') {
|
|
1963
|
+
return options
|
|
1964
|
+
.map((option) => ({ option, createParam: createParamForModelOption(option, taskKind) }))
|
|
1965
|
+
.filter(({ createParam }) => createParam.cliArg && createParam.key !== 'resources' && !createParam.internalOnly)
|
|
1966
|
+
.map(({ option, createParam }) => {
|
|
1967
|
+
const rules = option.rules || {};
|
|
1968
|
+
const allowedValues = optionAllowedValues(option);
|
|
1969
|
+
const required = option.required === true || rules.required === true ? true : undefined;
|
|
1970
|
+
const maxLength = option.paramType === 'Prompt'
|
|
1971
|
+
? ruleNumber(rules.maxValue ?? rules.maxLength ?? rules.maxPromptLength)
|
|
1972
|
+
: undefined;
|
|
1973
|
+
return compactRecord({
|
|
1974
|
+
key: createParam.key,
|
|
1975
|
+
label: option.paramName,
|
|
1976
|
+
valueType: cliValueType(option, allowedValues),
|
|
1977
|
+
values: allowedValues.length ? allowedValues : undefined,
|
|
1978
|
+
defaultValue: rules.defaultValue,
|
|
1979
|
+
defaultName: rules.defaultName,
|
|
1980
|
+
maxLength,
|
|
1981
|
+
required,
|
|
1982
|
+
});
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function optionResourceMedia(mode) {
|
|
1987
|
+
if (mode === 'reference_audio') return 'AUDIO';
|
|
1988
|
+
if (mode === 'reference_video') return 'VIDEO';
|
|
1989
|
+
if (mode === 'subject_reference') return 'SUBJECT';
|
|
1990
|
+
return 'IMAGE';
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function optionResourceUsage(mode) {
|
|
1994
|
+
if (mode === 'first_frame') return 'first_frame';
|
|
1995
|
+
if (mode === 'first_last_frame') return ['first_frame', 'last_frame'];
|
|
1996
|
+
if (mode === 'last_frame_only') return 'last_frame';
|
|
1997
|
+
if (mode === 'storyboard') return 'keyframe';
|
|
1998
|
+
return 'reference';
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function optionResourceMode(mode) {
|
|
2002
|
+
if (['reference_image', 'reference_audio', 'reference_video', 'subject_reference'].includes(mode)) return 'reference';
|
|
2003
|
+
if (['first_frame', 'first_last_frame', 'last_frame_only'].includes(mode)) return 'frames';
|
|
2004
|
+
if (mode === 'storyboard') return 'storyboard';
|
|
2005
|
+
return mode;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
function modelOptionsResources(inputModes = []) {
|
|
2009
|
+
const resources = [];
|
|
2010
|
+
const frameModes = inputModes.filter((item) => ['first_frame', 'first_last_frame', 'last_frame_only'].includes(item.mode));
|
|
2011
|
+
const supportedFrameMode = frameModes.find((item) => item.supported);
|
|
2012
|
+
let frameResourcePushed = false;
|
|
2013
|
+
for (const item of createSpecResourceRequirements(inputModes)) {
|
|
2014
|
+
const sourceMode = item.mode;
|
|
2015
|
+
if (['first_frame', 'first_last_frame', 'last_frame_only'].includes(sourceMode)) {
|
|
2016
|
+
if (!frameResourcePushed && supportedFrameMode) {
|
|
2017
|
+
const framePolicy = imageFormatPolicy(supportedFrameMode.optionResourceLimits?.fileTypes);
|
|
2018
|
+
resources.push(compactRecord({
|
|
2019
|
+
mode: 'frames',
|
|
2020
|
+
mediaType: 'IMAGE',
|
|
2021
|
+
usage: supportedFrameMode.optionResourceUsages,
|
|
2022
|
+
sources: supportedFrameMode.sourceKinds,
|
|
2023
|
+
...supportedFrameMode.optionResourceLimits,
|
|
2024
|
+
formatPolicy: framePolicy.summary,
|
|
2025
|
+
webpSupported: framePolicy.webpSupported,
|
|
2026
|
+
autoConvertTo: framePolicy.autoConvertTo,
|
|
2027
|
+
supportLastFrameOnly: supportedFrameMode.supportLastFrameOnly,
|
|
2028
|
+
}));
|
|
2029
|
+
frameResourcePushed = true;
|
|
2030
|
+
}
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
const mediaType = optionResourceMedia(sourceMode);
|
|
2034
|
+
const imagePolicy = mediaType === 'IMAGE' ? imageFormatPolicy(item.fileTypes) : null;
|
|
2035
|
+
resources.push(compactRecord({
|
|
2036
|
+
mode: optionResourceMode(sourceMode),
|
|
2037
|
+
mediaType,
|
|
2038
|
+
usage: optionResourceUsage(sourceMode),
|
|
2039
|
+
sources: item.sources,
|
|
2040
|
+
fileTypes: sourceMode === 'subject_reference' ? undefined : item.fileTypes,
|
|
2041
|
+
formatPolicy: imagePolicy?.summary,
|
|
2042
|
+
webpSupported: imagePolicy?.webpSupported,
|
|
2043
|
+
autoConvertTo: imagePolicy?.autoConvertTo,
|
|
2044
|
+
minFiles: sourceMode === 'subject_reference' ? undefined : item.minFiles,
|
|
2045
|
+
maxFiles: sourceMode === 'subject_reference' ? undefined : item.maxFiles,
|
|
2046
|
+
maxSizeKB: sourceMode === 'subject_reference' ? undefined : item.maxSizeKB,
|
|
2047
|
+
minDurationMs: sourceMode === 'subject_reference' ? undefined : item.minDurationMs,
|
|
2048
|
+
maxDurationMs: sourceMode === 'subject_reference' ? undefined : item.maxDurationMs,
|
|
2049
|
+
maxTotalDurationMs: sourceMode === 'subject_reference' ? undefined : item.maxTotalDurationMs,
|
|
2050
|
+
minItems: item.minItems,
|
|
2051
|
+
maxItems: item.maxItems,
|
|
2052
|
+
maxPromptLength: item.maxPromptLength,
|
|
2053
|
+
supportLastFrameOnly: item.supportLastFrameOnly,
|
|
2054
|
+
}));
|
|
2055
|
+
}
|
|
2056
|
+
return resources;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
export function modelInputGuide() {
|
|
2060
|
+
return {
|
|
2061
|
+
schemaVersion: 1,
|
|
2062
|
+
commonFields: [
|
|
2063
|
+
{ field: 'prompt', description: '提示词,描述希望生成什么。' },
|
|
2064
|
+
{ field: 'quality', description: '清晰度 / 尺寸档位。' },
|
|
2065
|
+
{ field: 'ratio', description: '宽高比,例如 16:9、9:16、1:1。' },
|
|
2066
|
+
{ field: 'duration', description: '视频时长,单位秒。' },
|
|
2067
|
+
{ field: 'generate_num', description: '一次生成几张图。' },
|
|
2068
|
+
{ field: 'need_audio', description: '是否生成音频 / 音效;仅部分视频模型支持。' },
|
|
2069
|
+
{ field: 'prompt_optimizer', description: '是否开启提示词增强;不传等同 false。' },
|
|
2070
|
+
{ field: 'resources', description: '输入素材列表,例如首帧、尾帧、参考图、关键帧、参考视频、参考音频。' },
|
|
2071
|
+
{ field: 'subject', description: '主体创建输入,仅主体创建专用接口使用。' },
|
|
2072
|
+
],
|
|
2073
|
+
resourceFields: [
|
|
2074
|
+
{ field: 'resources[].type', values: ['image', 'video', 'audio', 'subject'], description: '资源本体类型;subject 表示已创建的主体对象。' },
|
|
2075
|
+
{ field: 'resources[].usage', values: ['first_frame', 'last_frame', 'reference', 'keyframe'], description: '素材用途。' },
|
|
2076
|
+
{ field: 'resources[].reference_key', values: ['custom string'], description: '仅视频 reference 资源需要占位绑定时使用;图片生图 image:reference 不使用 reference_key。subject reference 必须传。' },
|
|
2077
|
+
{ field: 'resources[].source.kind', values: ['url', 'asset_id'], description: '默认建议使用 url;asset_id 表示平台资产或主体对象 ID。' },
|
|
2078
|
+
{ field: 'resources[].source.value', description: '资源值,必填。url 传素材地址;asset_id 传资源 ID。' },
|
|
2079
|
+
{ field: 'resources[].order', description: '仅 usage=keyframe 时需要,且同一请求内不能重复。' },
|
|
2080
|
+
{ field: 'resources[].duration', description: '仅 keyframe 场景下用于表达该帧持续时长,可传小数秒。' },
|
|
2081
|
+
{ field: 'resources[].prompt', description: '仅 keyframe 场景下可为该帧补充说明。' },
|
|
2082
|
+
],
|
|
2083
|
+
resourceRules: [
|
|
2084
|
+
'同一请求里最多只能有 1 个 first_frame。',
|
|
2085
|
+
'同一请求里最多只能有 1 个 last_frame;默认必须和 first_frame 成对使用,只有模型声明 supportLastFrameOnly=true 时才可仅传 last_frame。',
|
|
2086
|
+
'图片生图的 image:reference 不使用 reference_key 或 <<<key>>>;在 prompt 中用图一、图二、参考图、主体等自然语言描述资源映射。',
|
|
2087
|
+
'视频 reference prompt 中可以不写 <<<reference_key>>>;如果写了,必须能匹配某个 reference 素材的 reference_key。',
|
|
2088
|
+
'视频 reference 同一个 reference_key 可以对应多条 image / video / audio / subject 参考资源,系统会按 provider 规则聚合后提交。',
|
|
2089
|
+
'type=subject 只支持 usage=reference,且必须传 reference_key。',
|
|
2090
|
+
'first_frame / last_frame / keyframe 不需要、也不使用 reference_key。',
|
|
2091
|
+
],
|
|
2092
|
+
referenceKeyGuide: [
|
|
2093
|
+
'图片生图没有 reference_key 概念;多张参考图按资源顺序和 prompt 中“图一 / 图二 / 参考图 / 背景图”等自然描述理解。',
|
|
2094
|
+
'视频普通参考图 / 参考视频 / 参考音频可以不传 reference_key。',
|
|
2095
|
+
'视频里需要在 prompt 中用 <<<hero>>> 明确指代素材时,再传 reference_key=hero。',
|
|
2096
|
+
'视频同一个主体可以由多条资源组成,例如形象图、动作视频、音频都使用 reference_key=hero。',
|
|
2097
|
+
'视频里不需要被 prompt 占位符引用的普通场景参考,继续使用 type=image / video / audio + usage=reference。',
|
|
2098
|
+
],
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
export async function modelOptions(kwargs = {}) {
|
|
2103
|
+
const modelGroupCode = trimToNull(kwargs.modelGroupCode);
|
|
2104
|
+
if (!modelGroupCode) {
|
|
2105
|
+
throw argumentError('缺少模型组编码', '传 --model-group-code <code>。');
|
|
2106
|
+
}
|
|
2107
|
+
const selectedConfigs = parseJsonArg(kwargs.selectedConfigsJson, {}) ?? {};
|
|
2108
|
+
const includeRaw = toBool(kwargs.includeRaw);
|
|
2109
|
+
const context = await loadModelOptionContext(modelGroupCode, { selectedConfigs, includeRaw, includeConstraintSchema: true });
|
|
2110
|
+
return compactRecord({
|
|
2111
|
+
schemaVersion: 1,
|
|
2112
|
+
modelGroupCode,
|
|
2113
|
+
selectedConfigs,
|
|
2114
|
+
taskKind: context.taskKind,
|
|
2115
|
+
model: context.model,
|
|
2116
|
+
params: modelOptionParams(context.options, context.taskKind),
|
|
2117
|
+
resources: modelOptionsResources(context.inputModes),
|
|
2118
|
+
constraints: context.constraints,
|
|
2119
|
+
...(includeRaw ? { options: context.options, constraintSchema: context.constraintSchema, rawModel: context.rawModel } : {}),
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
export async function modelCreateSpec(kwargs = {}) {
|
|
2124
|
+
const modelGroupCode = trimToNull(kwargs.modelGroupCode);
|
|
2125
|
+
if (!modelGroupCode) {
|
|
2126
|
+
throw argumentError('缺少模型组编码', '传 --model-group-code <code>。');
|
|
2127
|
+
}
|
|
2128
|
+
const includeRaw = toBool(kwargs.includeRaw);
|
|
2129
|
+
const context = await loadModelOptionContext(modelGroupCode, { includeRaw });
|
|
2130
|
+
const supportedIntents = createSpecSupportedIntents(context.inputModes, context.taskKind);
|
|
2131
|
+
const inputRequirement = createSpecInputRequirement(context.taskKind, context.inputModes, supportedIntents);
|
|
2132
|
+
return compactRecord({
|
|
2133
|
+
schemaVersion: 1,
|
|
2134
|
+
modelGroupCode,
|
|
2135
|
+
taskKind: context.taskKind,
|
|
2136
|
+
createCommand: context.taskKind === 'image' ? 'image create' : 'video create',
|
|
2137
|
+
feeCommand: context.taskKind === 'image' ? 'image fee' : 'video fee',
|
|
2138
|
+
statusCommandTaskType: context.taskKind === 'image' ? 'IMAGE_CREATE' : 'VIDEO_GROUP',
|
|
2139
|
+
optionsCommand: `model options --model-group-code ${modelGroupCode}`,
|
|
2140
|
+
model: context.model,
|
|
2141
|
+
inputRequirement,
|
|
2142
|
+
supportedIntents,
|
|
2143
|
+
validationRules: createSpecValidationRules(context.taskKind),
|
|
2144
|
+
agentGuidance: createSpecAgentGuidance(context.taskKind, inputRequirement),
|
|
2145
|
+
preflight: createSpecPreflight(context.taskKind),
|
|
2146
|
+
examples: createSpecExamples(context.taskKind, supportedIntents),
|
|
2147
|
+
...(includeRaw ? { options: context.options, rawModel: context.rawModel } : {}),
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
async function readJsonMaybeFile(value, fallback) {
|
|
2152
|
+
const text = trimToNull(value);
|
|
2153
|
+
if (!text) return fallback;
|
|
2154
|
+
if (text.startsWith('{') || text.startsWith('[')) return parseJsonArg(text, fallback);
|
|
2155
|
+
const fileText = await fs.readFile(path.resolve(text), 'utf8');
|
|
2156
|
+
return JSON.parse(fileText);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
async function collectFileSpecs(kwargs = {}, defaultSceneType = TASK_UPLOAD_SCENE.DEFAULT) {
|
|
2160
|
+
const specs = [];
|
|
2161
|
+
for (const file of parseListArg(kwargs.file)) specs.push({ file });
|
|
2162
|
+
for (const file of parseListArg(kwargs.files)) specs.push({ file });
|
|
2163
|
+
const filesJson = await readJsonMaybeFile(kwargs.filesJson, null).catch((error) => {
|
|
2164
|
+
throw argumentError(`files-json 解析失败:${error.message}`);
|
|
2165
|
+
});
|
|
2166
|
+
if (Array.isArray(filesJson)) {
|
|
2167
|
+
for (const item of filesJson) {
|
|
2168
|
+
specs.push(typeof item === 'string' ? { file: item } : item);
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
return specs
|
|
2172
|
+
.map((item) => ({
|
|
2173
|
+
file: trimToNull(item.file ?? item.path ?? item.filePath),
|
|
2174
|
+
sceneType: trimToNull(item.sceneType ?? kwargs.sceneType) ?? defaultSceneType,
|
|
2175
|
+
projectNo: trimToNull(item.projectNo ?? kwargs.projectNo) ?? '',
|
|
2176
|
+
}))
|
|
2177
|
+
.filter((item) => item.file);
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function dryRunBackendPath(filePath, sceneType) {
|
|
2181
|
+
return `/${sceneType}/__dry_run__/${safeFileName(filePath)}`;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
export async function uploadFilesCommand(kwargs = {}) {
|
|
2185
|
+
const specs = await collectFileSpecs(kwargs, trimToNull(kwargs.sceneType) ?? TASK_UPLOAD_SCENE.DEFAULT);
|
|
2186
|
+
if (!specs.length) throw argumentError('缺少上传文件', '传 --file <path> 或 --files a.png,b.mp4。');
|
|
2187
|
+
if (toBool(kwargs.dryRun)) {
|
|
2188
|
+
const files = [];
|
|
2189
|
+
for (const spec of specs) {
|
|
2190
|
+
const inspected = await inspectLocalFile(spec.file);
|
|
2191
|
+
files.push(compactRecord({
|
|
2192
|
+
...inspected,
|
|
2193
|
+
sceneType: spec.sceneType,
|
|
2194
|
+
projectNo: spec.projectNo,
|
|
2195
|
+
backendPath: dryRunBackendPath(spec.file, spec.sceneType),
|
|
2196
|
+
url: null,
|
|
2197
|
+
dryRun: true,
|
|
2198
|
+
}));
|
|
2199
|
+
}
|
|
2200
|
+
return { dryRun: true, files };
|
|
2201
|
+
}
|
|
2202
|
+
const files = [];
|
|
2203
|
+
for (const spec of specs) {
|
|
2204
|
+
files.push(await uploadLocalFile(spec.file, spec));
|
|
2205
|
+
}
|
|
2206
|
+
return { files };
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
export async function uploadLocalFile(filePath, options = {}) {
|
|
2210
|
+
const inspected = await inspectLocalFile(filePath);
|
|
2211
|
+
if (!inspected.exists) {
|
|
2212
|
+
throw argumentError(`文件不存在:${filePath}`);
|
|
2213
|
+
}
|
|
2214
|
+
const buffer = await fs.readFile(inspected.filePath);
|
|
2215
|
+
const sceneType = options.sceneType ?? TASK_UPLOAD_SCENE.DEFAULT;
|
|
2216
|
+
const groupId = crypto.randomUUID().replaceAll('-', '');
|
|
2217
|
+
const secret = await awbApi.fetchUploadSecret({
|
|
2218
|
+
sceneType,
|
|
2219
|
+
groupId,
|
|
2220
|
+
projectNo: options.projectNo ?? '',
|
|
2221
|
+
});
|
|
2222
|
+
const credentials = secret.credentials ?? secret;
|
|
2223
|
+
const objectName = `${secret.path ?? ''}${secret.prefix ?? ''}${Date.now()}-${safeFileName(inspected.filePath)}`.replace(/^\/+/, '');
|
|
2224
|
+
const host = `${secret.bucket}.cos.${secret.region}.myqcloud.com`;
|
|
2225
|
+
const authorization = buildCosAuthorization({
|
|
2226
|
+
secretKey: credentials.tmpSecretKey,
|
|
2227
|
+
secretId: credentials.tmpSecretId,
|
|
2228
|
+
method: 'PUT',
|
|
2229
|
+
objectName,
|
|
2230
|
+
contentLength: buffer.length,
|
|
2231
|
+
host,
|
|
2232
|
+
startTime: secret.startTime,
|
|
2233
|
+
expiredTime: secret.expiredTime,
|
|
2234
|
+
});
|
|
2235
|
+
const response = await fetch(`https://${host}/${encodeObjectNamePath(objectName)}`, {
|
|
2236
|
+
method: 'PUT',
|
|
2237
|
+
headers: {
|
|
2238
|
+
authorization,
|
|
2239
|
+
'content-length': String(buffer.length),
|
|
2240
|
+
'content-type': inspected.mimeType || guessMimeType(inspected.filePath),
|
|
2241
|
+
host,
|
|
2242
|
+
'x-cos-security-token': credentials.sessionToken,
|
|
2243
|
+
},
|
|
2244
|
+
body: buffer,
|
|
2245
|
+
});
|
|
2246
|
+
if (!response.ok) {
|
|
2247
|
+
throw new LingjingAwbCliError(`上传 COS 失败:${response.status} ${response.statusText}`, {
|
|
2248
|
+
type: 'upload_failed',
|
|
2249
|
+
exitCode: 30,
|
|
2250
|
+
details: { filePath: inspected.filePath, objectName },
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
return compactRecord({
|
|
2254
|
+
...inspected,
|
|
2255
|
+
sceneType,
|
|
2256
|
+
projectNo: options.projectNo ?? '',
|
|
2257
|
+
backendPath: `/${objectName}`,
|
|
2258
|
+
url: `https://${host}/${encodeObjectNamePath(objectName)}`,
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function resolveCustomBizId(explicit) {
|
|
2263
|
+
return trimToNull(explicit);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
const RESOURCE_OBJECT_KEYS = new Set(['type', 'usage', 'reference_key', 'source', 'order', 'prompt', 'duration']);
|
|
2267
|
+
const RESOURCE_SOURCE_KEYS = new Set(['kind', 'value']);
|
|
2268
|
+
const RESOURCE_TYPES = new Set(['image', 'video', 'audio', 'subject']);
|
|
2269
|
+
const RESOURCE_USAGES = new Set(['reference', 'first_frame', 'last_frame', 'keyframe']);
|
|
2270
|
+
|
|
2271
|
+
function parseIntegerParam(value, name, minValue) {
|
|
2272
|
+
if (value == null || value === '') return null;
|
|
2273
|
+
const text = String(value).trim();
|
|
2274
|
+
if (!/^[+-]?\d+$/.test(text)) {
|
|
2275
|
+
throw argumentError(`${name} 必须是整数`);
|
|
2276
|
+
}
|
|
2277
|
+
const parsed = Number(text);
|
|
2278
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
2279
|
+
throw argumentError(`${name} 超出安全整数范围`);
|
|
2280
|
+
}
|
|
2281
|
+
if (parsed < minValue) {
|
|
2282
|
+
throw argumentError(`${name} 必须是${minValue === 0 ? '非负整数' : '正整数'}`);
|
|
2283
|
+
}
|
|
2284
|
+
return parsed;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
function normalizeUnifiedPromptParams(promptParams) {
|
|
2288
|
+
if (promptParams.duration != null && promptParams.duration !== '') {
|
|
2289
|
+
promptParams.duration = parseIntegerParam(promptParams.duration, 'promptParams.duration', 0);
|
|
2290
|
+
}
|
|
2291
|
+
if (promptParams.generate_num != null && promptParams.generate_num !== '') {
|
|
2292
|
+
promptParams.generate_num = parseIntegerParam(promptParams.generate_num, 'promptParams.generate_num', 1);
|
|
2293
|
+
}
|
|
2294
|
+
if (promptParams.need_audio != null && promptParams.need_audio !== '') {
|
|
2295
|
+
promptParams.need_audio = toBool(promptParams.need_audio);
|
|
2296
|
+
}
|
|
2297
|
+
return promptParams;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
function normalizeResourceType(type, index) {
|
|
2301
|
+
const normalized = String(type ?? '').trim().toLowerCase();
|
|
2302
|
+
if (!RESOURCE_TYPES.has(normalized)) {
|
|
2303
|
+
throw argumentError(`resource[${index}] type 不支持:${type}`, '支持 image、video、audio、subject。');
|
|
2304
|
+
}
|
|
2305
|
+
return normalized;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function normalizeResourceUsage(usage, index) {
|
|
2309
|
+
const normalized = String(usage ?? '').trim().toLowerCase();
|
|
2310
|
+
if (!RESOURCE_USAGES.has(normalized)) {
|
|
2311
|
+
throw argumentError(`resource[${index}] usage 不支持:${usage}`, '支持 reference、first_frame、last_frame、keyframe。');
|
|
2312
|
+
}
|
|
2313
|
+
return normalized;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function stripAssetPrefix(value) {
|
|
2317
|
+
const text = trimToNull(value);
|
|
2318
|
+
if (!text) return null;
|
|
2319
|
+
return text.startsWith('asset:') ? text.slice('asset:'.length) : text;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
function normalizeResourceSource(source, type, index, options = {}) {
|
|
2323
|
+
const explicitKind = trimToNull(source.source?.kind);
|
|
2324
|
+
const rawValue = trimToNull(source.source?.value ?? (options.allowShortcutValue ? source.value : null));
|
|
2325
|
+
if (!options.allowShortcutValue && !explicitKind) {
|
|
2326
|
+
throw argumentError(`resource[${index}] source.kind 不能为空`);
|
|
2327
|
+
}
|
|
2328
|
+
const assetValue = stripAssetPrefix(rawValue);
|
|
2329
|
+
const kind = explicitKind
|
|
2330
|
+
? explicitKind.toLowerCase()
|
|
2331
|
+
: (rawValue?.startsWith('asset:') || type === 'subject' ? 'asset_id' : 'url');
|
|
2332
|
+
if (!['url', 'asset_id'].includes(kind)) {
|
|
2333
|
+
throw argumentError(`resource[${index}] source.kind 不支持:${kind}`, '支持 url、asset_id。');
|
|
2334
|
+
}
|
|
2335
|
+
const value = kind === 'asset_id' ? assetValue : rawValue;
|
|
2336
|
+
if (!value) {
|
|
2337
|
+
throw argumentError(`resource[${index}] source.value 不能为空`);
|
|
2338
|
+
}
|
|
2339
|
+
return { kind, value };
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
function parseResourceShortcut(input, index) {
|
|
2343
|
+
const text = String(input ?? '').trim();
|
|
2344
|
+
const eqIndex = text.indexOf('=');
|
|
2345
|
+
if (!text || eqIndex <= 0) {
|
|
2346
|
+
throw argumentError(
|
|
2347
|
+
`resource[${index}] 格式错误`,
|
|
2348
|
+
'格式:--resource image:reference=./ref.png,或 --resource image:first_frame=material/a.png。',
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
const left = text.slice(0, eqIndex).trim();
|
|
2352
|
+
const value = text.slice(eqIndex + 1).trim();
|
|
2353
|
+
const parts = left.split(':').map((item) => item.trim()).filter(Boolean);
|
|
2354
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
2355
|
+
throw argumentError(`resource[${index}] 格式错误`, '格式:type:usage[:key]=value。');
|
|
2356
|
+
}
|
|
2357
|
+
const usageMatch = parts[1].match(/^([A-Za-z_]+)(?:#([0-9]+))?$/);
|
|
2358
|
+
if (!usageMatch) {
|
|
2359
|
+
throw argumentError(`resource[${index}] usage 格式错误`);
|
|
2360
|
+
}
|
|
2361
|
+
return {
|
|
2362
|
+
type: parts[0],
|
|
2363
|
+
usage: usageMatch[1],
|
|
2364
|
+
key: parts[2] || null,
|
|
2365
|
+
value,
|
|
2366
|
+
order: usageMatch[2] ? Number.parseInt(usageMatch[2], 10) : undefined,
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function normalizeResourceObject(item, index) {
|
|
2371
|
+
const isShortcut = typeof item === 'string';
|
|
2372
|
+
const source = isShortcut ? parseResourceShortcut(item, index) : item;
|
|
2373
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
|
2374
|
+
throw argumentError(`resource[${index}] 必须是字符串短语法或 JSON 对象`);
|
|
2375
|
+
}
|
|
2376
|
+
if (!isShortcut) assertAllowedResourceObjectKeys(source, index);
|
|
2377
|
+
const type = normalizeResourceType(source.type, index);
|
|
2378
|
+
const usage = normalizeResourceUsage(source.usage, index);
|
|
2379
|
+
const resourceSource = normalizeResourceSource(source, type, index, { allowShortcutValue: isShortcut });
|
|
2380
|
+
const referenceKey = trimToNull(source.reference_key ?? (isShortcut ? source.key : null));
|
|
2381
|
+
const duration = normalizeResourceDuration(source.duration, index);
|
|
2382
|
+
const normalized = {
|
|
2383
|
+
type,
|
|
2384
|
+
usage,
|
|
2385
|
+
...(referenceKey ? { reference_key: referenceKey } : {}),
|
|
2386
|
+
...(source.order != null ? { order: Number(source.order) } : {}),
|
|
2387
|
+
...(trimToNull(source.prompt) ? { prompt: trimToNull(source.prompt) } : {}),
|
|
2388
|
+
...(duration != null ? { duration } : {}),
|
|
2389
|
+
_source: resourceSource,
|
|
2390
|
+
};
|
|
2391
|
+
if (usage !== 'reference' && referenceKey) {
|
|
2392
|
+
throw argumentError(`resource[${index}] reference_key 只能用于 usage=reference`);
|
|
2393
|
+
}
|
|
2394
|
+
if (usage !== 'keyframe' && normalized.order != null) {
|
|
2395
|
+
throw argumentError(`resource[${index}] order 只能用于 usage=keyframe`);
|
|
2396
|
+
}
|
|
2397
|
+
if (usage !== 'keyframe' && duration != null) {
|
|
2398
|
+
throw argumentError(`resource[${index}] duration 只能用于 usage=keyframe`);
|
|
2399
|
+
}
|
|
2400
|
+
if (usage !== 'keyframe' && normalized.prompt) {
|
|
2401
|
+
throw argumentError(`resource[${index}] prompt 只能用于 usage=keyframe`);
|
|
2402
|
+
}
|
|
2403
|
+
if (usage === 'keyframe' && resourceSource.kind === 'asset_id') {
|
|
2404
|
+
throw argumentError(`resource[${index}] keyframe 暂只支持 url,本地文件会自动上传为 url`);
|
|
2405
|
+
}
|
|
2406
|
+
if (type === 'subject' && usage !== 'reference') {
|
|
2407
|
+
throw argumentError(`resource[${index}] type=subject 只能用于 usage=reference`);
|
|
2408
|
+
}
|
|
2409
|
+
if (type === 'subject' && !referenceKey) {
|
|
2410
|
+
throw argumentError(`resource[${index}] type=subject 时 reference_key 不能为空`);
|
|
2411
|
+
}
|
|
2412
|
+
if (type === 'subject' && resourceSource.kind !== 'asset_id') {
|
|
2413
|
+
throw argumentError(`resource[${index}] type=subject 时 source.kind 必须为 asset_id`);
|
|
2414
|
+
}
|
|
2415
|
+
if (usage === 'keyframe') {
|
|
2416
|
+
if (!Number.isInteger(normalized.order) || normalized.order <= 0) {
|
|
2417
|
+
throw argumentError(`resource[${index}] keyframe 必须使用正整数 order,例如 image:keyframe#1=./k1.png`);
|
|
2418
|
+
}
|
|
2419
|
+
if (duration != null && duration < 0) {
|
|
2420
|
+
throw argumentError(`resource[${index}] keyframe duration 不能为负数`);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
return normalized;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function assertAllowedResourceObjectKeys(source, index) {
|
|
2427
|
+
for (const key of Object.keys(source)) {
|
|
2428
|
+
if (!RESOURCE_OBJECT_KEYS.has(key)) {
|
|
2429
|
+
throw argumentError(
|
|
2430
|
+
`resource[${index}] JSON 对象存在未知字段:${key}`,
|
|
2431
|
+
'JSON 素材只接受 type、usage、reference_key、source、order、prompt、duration。',
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
if (source.source != null) {
|
|
2436
|
+
if (!source.source || typeof source.source !== 'object' || Array.isArray(source.source)) {
|
|
2437
|
+
throw argumentError(`resource[${index}] source 必须是 JSON 对象`);
|
|
2438
|
+
}
|
|
2439
|
+
for (const key of Object.keys(source.source)) {
|
|
2440
|
+
if (!RESOURCE_SOURCE_KEYS.has(key)) {
|
|
2441
|
+
throw argumentError(
|
|
2442
|
+
`resource[${index}] source 存在未知字段:${key}`,
|
|
2443
|
+
'source 只接受 kind、value。',
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
function normalizeResourceDuration(value, index) {
|
|
2451
|
+
if (value == null || value === '') return null;
|
|
2452
|
+
const duration = Number(value);
|
|
2453
|
+
if (!Number.isFinite(duration)) {
|
|
2454
|
+
throw argumentError(`resource[${index}] duration 必须是数字`);
|
|
2455
|
+
}
|
|
2456
|
+
return duration;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
async function collectResourceSpecs(kwargs = {}, options = {}) {
|
|
2460
|
+
const items = [];
|
|
2461
|
+
for (const item of parseListArg(kwargs.resource)) items.push(item);
|
|
2462
|
+
if (Array.isArray(kwargs.resources)) items.push(...kwargs.resources);
|
|
2463
|
+
const resourcesJson = await readJsonMaybeFile(kwargs.resourcesJson, null).catch((error) => {
|
|
2464
|
+
throw argumentError(`resources-json 解析失败:${error.message}`);
|
|
2465
|
+
});
|
|
2466
|
+
if (Array.isArray(resourcesJson)) items.push(...resourcesJson);
|
|
2467
|
+
return items.map((item, index) => normalizeResourceObject(item, index)).filter((item) => {
|
|
2468
|
+
if (options.kind !== 'image') return true;
|
|
2469
|
+
if (item.type !== 'image') {
|
|
2470
|
+
throw argumentError('图片任务 resource 只支持 type=image');
|
|
2471
|
+
}
|
|
2472
|
+
if (item.usage !== 'reference') {
|
|
2473
|
+
throw argumentError('图片任务 resource 只支持 usage=reference');
|
|
2474
|
+
}
|
|
2475
|
+
if (item.reference_key) {
|
|
2476
|
+
throw argumentError(
|
|
2477
|
+
'图片任务 reference 不支持 reference_key',
|
|
2478
|
+
'生图参考图不会按 <<<key>>> 绑定;请使用 image:reference=...,并在 prompt 中用“图一 / 图二 / 参考图 / 主体”等自然语言指代。',
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
if (item._source.kind !== 'url') {
|
|
2482
|
+
throw argumentError('图片任务 resource 暂只支持 url,本地文件会自动上传为 url');
|
|
2483
|
+
}
|
|
2484
|
+
return true;
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function isRemoteOrBackendPath(value) {
|
|
2489
|
+
const text = trimToNull(value);
|
|
2490
|
+
if (!text) return false;
|
|
2491
|
+
return /^https?:\/\//i.test(text)
|
|
2492
|
+
|| /^[a-z][a-z0-9+.-]*:\/\//i.test(text)
|
|
2493
|
+
|| text.startsWith('material/')
|
|
2494
|
+
|| text.startsWith('/material/');
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
function normalizeMaterialUrlValue(value) {
|
|
2498
|
+
const text = trimToNull(value);
|
|
2499
|
+
if (!text) return text;
|
|
2500
|
+
if (/^https?:\/\//i.test(text)) {
|
|
2501
|
+
try {
|
|
2502
|
+
const pathname = decodeURIComponent(new URL(text).pathname);
|
|
2503
|
+
if (pathname.startsWith('/material/')) return pathname;
|
|
2504
|
+
} catch {}
|
|
2505
|
+
}
|
|
2506
|
+
return text;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
function conversionOutputExtension(format) {
|
|
2510
|
+
const normalized = normalizeFileFormat(format);
|
|
2511
|
+
if (normalized === 'jpg') return 'jpg';
|
|
2512
|
+
return normalized || 'jpg';
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
async function convertLocalImageFile(filePath, targetFormat) {
|
|
2516
|
+
const target = normalizeFileFormat(targetFormat) || 'jpg';
|
|
2517
|
+
const outputPath = path.join(tmpdir(), `lj-awb-${crypto.randomUUID()}.${conversionOutputExtension(target)}`);
|
|
2518
|
+
const codecArgs = target === 'jpg' ? ['-q:v', '2'] : [];
|
|
2519
|
+
try {
|
|
2520
|
+
await execFileAsync('ffmpeg', [
|
|
2521
|
+
'-y',
|
|
2522
|
+
'-hide_banner',
|
|
2523
|
+
'-loglevel',
|
|
2524
|
+
'error',
|
|
2525
|
+
'-i',
|
|
2526
|
+
filePath,
|
|
2527
|
+
'-frames:v',
|
|
2528
|
+
'1',
|
|
2529
|
+
...codecArgs,
|
|
2530
|
+
outputPath,
|
|
2531
|
+
]);
|
|
2532
|
+
} catch (error) {
|
|
2533
|
+
await fs.rm(outputPath, { force: true }).catch(() => {});
|
|
2534
|
+
throw argumentError(
|
|
2535
|
+
`图片格式转换失败:${path.basename(filePath)}`,
|
|
2536
|
+
`需要把该帧图片转换为 ${target} 后提交。请确认本机可用 ffmpeg,或手动转换后重试。${error?.message ? ` 原因:${error.message}` : ''}`,
|
|
2537
|
+
);
|
|
2538
|
+
}
|
|
2539
|
+
const inspected = await inspectLocalFile(outputPath);
|
|
2540
|
+
if (!inspected.exists) {
|
|
2541
|
+
throw argumentError(`图片格式转换失败:${path.basename(filePath)}`, `未生成 ${target} 文件。`);
|
|
2542
|
+
}
|
|
2543
|
+
return inspected;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
async function resolveResourceFileValue(resource, options = {}) {
|
|
2547
|
+
if (resource.type === 'subject') {
|
|
2548
|
+
if (resource._source.kind !== 'asset_id') throw argumentError('subject resource 必须使用 asset_id,例如 subject:reference:hero=asset:element_123');
|
|
2549
|
+
return {
|
|
2550
|
+
resource: {
|
|
2551
|
+
type: resource.type,
|
|
2552
|
+
usage: resource.usage,
|
|
2553
|
+
...(resource.reference_key ? { reference_key: resource.reference_key } : {}),
|
|
2554
|
+
source: resource._source,
|
|
2555
|
+
},
|
|
2556
|
+
upload: null,
|
|
2557
|
+
localFile: null,
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
const base = {
|
|
2561
|
+
type: resource.type,
|
|
2562
|
+
usage: resource.usage,
|
|
2563
|
+
...(resource.reference_key ? { reference_key: resource.reference_key } : {}),
|
|
2564
|
+
...(resource.order ? { order: resource.order } : {}),
|
|
2565
|
+
...(resource.prompt ? { prompt: resource.prompt } : {}),
|
|
2566
|
+
...(resource.duration != null ? { duration: resource.duration } : {}),
|
|
2567
|
+
};
|
|
2568
|
+
if (resource._source.kind === 'asset_id') {
|
|
2569
|
+
return { resource: { ...base, source: resource._source }, upload: null, localFile: null };
|
|
2570
|
+
}
|
|
2571
|
+
const value = resource._source.value;
|
|
2572
|
+
if (isRemoteOrBackendPath(value)) {
|
|
2573
|
+
return { resource: { ...base, source: { kind: 'url', value: normalizeMaterialUrlValue(value) } }, upload: null, localFile: null };
|
|
2574
|
+
}
|
|
2575
|
+
const inspected = await inspectLocalFile(value);
|
|
2576
|
+
if (!inspected.exists) {
|
|
2577
|
+
throw argumentError(
|
|
2578
|
+
`资源文件不存在:${value}`,
|
|
2579
|
+
'source.kind=url 只接受完整 http(s) URL、material backendPath,或当前工作目录下存在的本地文件路径。',
|
|
2580
|
+
);
|
|
2581
|
+
}
|
|
2582
|
+
const conversion = options.resourceRules
|
|
2583
|
+
? resourceFormatConversion(
|
|
2584
|
+
options.resourceRules.find((item) => resourceRuleMatches(item, { ...base, source: { kind: 'url', value } })),
|
|
2585
|
+
{ resource: { ...base, source: { kind: 'url', value } }, localFile: inspected },
|
|
2586
|
+
)
|
|
2587
|
+
: null;
|
|
2588
|
+
if (conversion) {
|
|
2589
|
+
const conversionRecord = compactRecord({
|
|
2590
|
+
resource: resourceText(base),
|
|
2591
|
+
fromFormat: conversion.fromFormat,
|
|
2592
|
+
toFormat: conversion.toFormat,
|
|
2593
|
+
reason: conversion.reason,
|
|
2594
|
+
sourceFile: inspected.filePath,
|
|
2595
|
+
dryRun: options.dryRun || undefined,
|
|
2596
|
+
});
|
|
2597
|
+
if (options.dryRun) {
|
|
2598
|
+
const convertedName = `${path.basename(inspected.filePath, path.extname(inspected.filePath))}.${conversionOutputExtension(conversion.toFormat)}`;
|
|
2599
|
+
return {
|
|
2600
|
+
resource: { ...base, source: { kind: 'url', value: dryRunBackendPath(convertedName, options.sceneType) } },
|
|
2601
|
+
upload: null,
|
|
2602
|
+
localFile: inspected,
|
|
2603
|
+
conversion: conversionRecord,
|
|
2604
|
+
};
|
|
2605
|
+
}
|
|
2606
|
+
const converted = await convertLocalImageFile(inspected.filePath, conversion.toFormat);
|
|
2607
|
+
try {
|
|
2608
|
+
const upload = await uploadLocalFile(converted.filePath, { sceneType: options.sceneType });
|
|
2609
|
+
return {
|
|
2610
|
+
resource: { ...base, source: { kind: 'url', value: upload.backendPath } },
|
|
2611
|
+
upload,
|
|
2612
|
+
localFile: inspected,
|
|
2613
|
+
conversion: {
|
|
2614
|
+
...conversionRecord,
|
|
2615
|
+
convertedFile: converted.filePath,
|
|
2616
|
+
convertedSize: converted.size,
|
|
2617
|
+
},
|
|
2618
|
+
};
|
|
2619
|
+
} finally {
|
|
2620
|
+
await fs.rm(converted.filePath, { force: true }).catch(() => {});
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
if (options.dryRun) {
|
|
2624
|
+
return {
|
|
2625
|
+
resource: { ...base, source: { kind: 'url', value: dryRunBackendPath(inspected.filePath, options.sceneType) } },
|
|
2626
|
+
upload: null,
|
|
2627
|
+
localFile: inspected,
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
const upload = await uploadLocalFile(inspected.filePath, { sceneType: options.sceneType });
|
|
2631
|
+
return {
|
|
2632
|
+
resource: { ...base, source: { kind: 'url', value: upload.backendPath } },
|
|
2633
|
+
upload,
|
|
2634
|
+
localFile: inspected,
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
async function resolveResources(kwargs = {}, options = {}) {
|
|
2639
|
+
const specs = await collectResourceSpecs(kwargs, options);
|
|
2640
|
+
const resources = [];
|
|
2641
|
+
const uploads = [];
|
|
2642
|
+
const localFiles = [];
|
|
2643
|
+
const resourceDetails = [];
|
|
2644
|
+
const resourceConversions = [];
|
|
2645
|
+
for (const spec of specs) {
|
|
2646
|
+
const resolved = await resolveResourceFileValue(spec, options);
|
|
2647
|
+
resources.push(resolved.resource);
|
|
2648
|
+
if (resolved.upload) uploads.push(resolved.upload);
|
|
2649
|
+
if (resolved.localFile) localFiles.push(resolved.localFile);
|
|
2650
|
+
if (resolved.conversion) resourceConversions.push(resolved.conversion);
|
|
2651
|
+
resourceDetails.push({
|
|
2652
|
+
resource: resolved.resource,
|
|
2653
|
+
upload: resolved.upload,
|
|
2654
|
+
localFile: resolved.localFile,
|
|
2655
|
+
});
|
|
2656
|
+
}
|
|
2657
|
+
return { resources, uploads, localFiles, resourceDetails, resourceConversions };
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
function extractPromptReferenceKeys(prompt) {
|
|
2661
|
+
const keys = new Set();
|
|
2662
|
+
const matcher = /<<<([^<>\s]+)>>>/g;
|
|
2663
|
+
const text = String(prompt ?? '');
|
|
2664
|
+
let match;
|
|
2665
|
+
while ((match = matcher.exec(text)) !== null) {
|
|
2666
|
+
keys.add(match[1]);
|
|
2667
|
+
}
|
|
2668
|
+
return keys;
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
function assertVideoReferenceKeysUsed(prompt, resources = []) {
|
|
2672
|
+
const declaredKeys = new Set(resources
|
|
2673
|
+
.filter((resource) => resource?.usage === 'reference' && trimToNull(resource.reference_key))
|
|
2674
|
+
.map((resource) => trimToNull(resource.reference_key)));
|
|
2675
|
+
const promptKeys = extractPromptReferenceKeys(prompt);
|
|
2676
|
+
for (const key of promptKeys) {
|
|
2677
|
+
if (!declaredKeys.has(key)) {
|
|
2678
|
+
throw argumentError(`prompt 引用了未声明的资源占位符:<<<${key}>>>`);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
async function buildImageRequest(kwargs = {}, options = {}) {
|
|
2684
|
+
const dryRun = Boolean(options.dryRun);
|
|
2685
|
+
const model = resolveTaskModel('image', kwargs);
|
|
2686
|
+
const prompt = trimToNull(kwargs.prompt) ?? '';
|
|
2687
|
+
if (!prompt) {
|
|
2688
|
+
throw argumentError('缺少生图 prompt', '传 --prompt "..."。');
|
|
2689
|
+
}
|
|
2690
|
+
const promptReferenceKeys = extractPromptReferenceKeys(prompt);
|
|
2691
|
+
if (promptReferenceKeys.size) {
|
|
2692
|
+
throw argumentError(
|
|
2693
|
+
'图片任务 prompt 不支持 <<<key>>> 资源占位符',
|
|
2694
|
+
'生图参考图没有 reference_key 绑定概念;请改用自然描述,例如“图一中的白发女在图二背景里坐秋千”。',
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
const validationResources = await resolveResources(kwargs, {
|
|
2698
|
+
dryRun: true,
|
|
2699
|
+
kind: 'image',
|
|
2700
|
+
sceneType: TASK_UPLOAD_SCENE.IMAGE_CREATE,
|
|
2701
|
+
});
|
|
2702
|
+
const defaultParams = {
|
|
2703
|
+
prompt,
|
|
2704
|
+
resources: validationResources.resources,
|
|
2705
|
+
};
|
|
2706
|
+
if (kwargs.ratio != null && kwargs.ratio !== '') defaultParams.ratio = kwargs.ratio;
|
|
2707
|
+
if (kwargs.quality != null && kwargs.quality !== '') defaultParams.quality = kwargs.quality;
|
|
2708
|
+
if (kwargs.generateNum != null && kwargs.generateNum !== '') {
|
|
2709
|
+
defaultParams.generate_num = parseIntegerParam(kwargs.generateNum, '--generate-num', 1);
|
|
2710
|
+
}
|
|
2711
|
+
const promptParams = normalizeUnifiedPromptParams(defaultParams);
|
|
2712
|
+
const validation = await validateCreateRequestAgainstModel('image', model.modelGroupCode, promptParams, validationResources.resourceDetails);
|
|
2713
|
+
const resolvedResources = await resolveResources(kwargs, {
|
|
2714
|
+
dryRun,
|
|
2715
|
+
kind: 'image',
|
|
2716
|
+
sceneType: TASK_UPLOAD_SCENE.IMAGE_CREATE,
|
|
2717
|
+
resourceRules: validation.resourceRules,
|
|
2718
|
+
});
|
|
2719
|
+
const requestPromptParams = normalizeUnifiedPromptParams({ ...defaultParams, resources: resolvedResources.resources });
|
|
2720
|
+
const projectGroupNo = await resolveProjectGroupNo(kwargs.projectGroupNo, { allowNull: dryRun, noNetwork: dryRun, noSave: dryRun });
|
|
2721
|
+
const customBizId = options.includeCustomBizId === false ? null : resolveCustomBizId(kwargs.customBizId);
|
|
2722
|
+
return {
|
|
2723
|
+
model,
|
|
2724
|
+
projectGroupNo,
|
|
2725
|
+
uploads: resolvedResources.uploads,
|
|
2726
|
+
localFiles: resolvedResources.localFiles,
|
|
2727
|
+
resourceConversions: resolvedResources.resourceConversions,
|
|
2728
|
+
request: {
|
|
2729
|
+
requestSource: REQUEST_SOURCE_CLI,
|
|
2730
|
+
modelGroupCode: model.modelGroupCode,
|
|
2731
|
+
...(customBizId ? { customBizId } : {}),
|
|
2732
|
+
promptParams: requestPromptParams,
|
|
2733
|
+
...(projectGroupNo ? { projectGroupNo } : {}),
|
|
2734
|
+
},
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function extractPointCost(payload) {
|
|
2739
|
+
if (typeof payload === 'number') return payload;
|
|
2740
|
+
return toNumberOrNull(payload?.point ?? payload?.pointNo ?? payload?.cost ?? payload);
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
async function pointEstimate(pointCost, projectGroupNo) {
|
|
2744
|
+
const snapshot = projectGroupNo ? await creditsBalance({ projectGroupNo }).catch(() => null) : null;
|
|
2745
|
+
const normalizedPointCost = extractPointCost(pointCost);
|
|
2746
|
+
const billingPointBalance = toNumberOrNull(snapshot?.billingPointBalance ?? snapshot?.teamPointBalance);
|
|
2747
|
+
const projectBudgetBalance = toNumberOrNull(snapshot?.projectBudgetBalance ?? snapshot?.projectPointBalance);
|
|
2748
|
+
return {
|
|
2749
|
+
pointCost: normalizedPointCost,
|
|
2750
|
+
billingPointBalance,
|
|
2751
|
+
billingPointRemainingAfter: normalizedPointCost != null && billingPointBalance != null ? billingPointBalance - normalizedPointCost : null,
|
|
2752
|
+
projectBudgetBalance,
|
|
2753
|
+
projectBudgetMax: snapshot?.projectBudgetMax ?? snapshot?.projectPointMax ?? null,
|
|
2754
|
+
projectBudgetRemainingAfter: normalizedPointCost != null && projectBudgetBalance != null ? projectBudgetBalance - normalizedPointCost : null,
|
|
2755
|
+
teamPointBalance: billingPointBalance,
|
|
2756
|
+
projectPointBalance: projectBudgetBalance,
|
|
2757
|
+
projectPointMax: snapshot?.projectPointMax ?? null,
|
|
2758
|
+
teamPointRemainingAfter: normalizedPointCost != null && billingPointBalance != null ? billingPointBalance - normalizedPointCost : null,
|
|
2759
|
+
projectPointRemainingAfter: normalizedPointCost != null && projectBudgetBalance != null ? projectBudgetBalance - normalizedPointCost : null,
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
export async function imageFee(kwargs = {}) {
|
|
2764
|
+
if (toBool(kwargs.dryRun)) {
|
|
2765
|
+
const built = await buildImageRequest(kwargs, { dryRun: true, includeCustomBizId: false });
|
|
2766
|
+
return compactRecord({
|
|
2767
|
+
dryRun: true,
|
|
2768
|
+
action: 'image fee',
|
|
2769
|
+
request: built.request,
|
|
2770
|
+
...(built.resourceConversions.length ? { resourceConversions: built.resourceConversions } : {}),
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
const built = await buildImageRequest(kwargs, { includeCustomBizId: false });
|
|
2774
|
+
const payload = await awbApi.fetchImageFee(built.request);
|
|
2775
|
+
return {
|
|
2776
|
+
data: payload,
|
|
2777
|
+
...(await pointEstimate(payload, built.projectGroupNo)),
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
function normalizeCreateUploads(uploads = []) {
|
|
2782
|
+
return uploads.map((item) => compactRecord({
|
|
2783
|
+
filePath: item.filePath,
|
|
2784
|
+
fileName: item.fileName,
|
|
2785
|
+
exists: item.exists,
|
|
2786
|
+
size: item.size,
|
|
2787
|
+
mimeType: item.mimeType,
|
|
2788
|
+
width: item.width,
|
|
2789
|
+
height: item.height,
|
|
2790
|
+
format: item.format,
|
|
2791
|
+
backendPath: item.backendPath,
|
|
2792
|
+
url: item.url,
|
|
2793
|
+
}));
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function extractCreatedTaskId(payload, flat = {}) {
|
|
2797
|
+
if (typeof payload === 'string' || typeof payload === 'number') return String(payload);
|
|
2798
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
2799
|
+
return payload.taskId
|
|
2800
|
+
?? payload.id
|
|
2801
|
+
?? payload.task_id
|
|
2802
|
+
?? flat.taskId
|
|
2803
|
+
?? flat.id
|
|
2804
|
+
?? flat.task_id
|
|
2805
|
+
?? null;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
function normalizeCreatedTask(payload, extra = {}) {
|
|
2809
|
+
const flat = payload && typeof payload === 'object' ? flattenRecord(payload) : {};
|
|
2810
|
+
const taskId = extractCreatedTaskId(payload, flat);
|
|
2811
|
+
return compactRecord({
|
|
2812
|
+
...(taskId ? { taskId: String(taskId) } : {}),
|
|
2813
|
+
...flat,
|
|
2814
|
+
...extra,
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
export async function imageCreate(kwargs = {}) {
|
|
2819
|
+
if (toBool(kwargs.dryRun)) {
|
|
2820
|
+
const built = await buildImageRequest(kwargs, { dryRun: true });
|
|
2821
|
+
return compactRecord({
|
|
2822
|
+
dryRun: true,
|
|
2823
|
+
action: 'image create',
|
|
2824
|
+
request: built.request,
|
|
2825
|
+
...(built.localFiles.length ? { localFiles: built.localFiles } : {}),
|
|
2826
|
+
...(built.resourceConversions.length ? { resourceConversions: built.resourceConversions } : {}),
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
ensureConfirmed(kwargs, '正式生图会消耗积分,需要确认', { action: 'image create' });
|
|
2830
|
+
const built = await buildImageRequest(kwargs);
|
|
2831
|
+
const feePayload = await awbApi.fetchImageFee(built.request).catch(() => null);
|
|
2832
|
+
const estimate = await pointEstimate(feePayload, built.projectGroupNo).catch(() => ({}));
|
|
2833
|
+
const payload = await awbApi.createImageTask(built.request);
|
|
2834
|
+
const result = normalizeCreatedTask(payload, {
|
|
2835
|
+
...estimate,
|
|
2836
|
+
projectGroupNo: built.projectGroupNo,
|
|
2837
|
+
modelGroupCode: built.request.modelGroupCode,
|
|
2838
|
+
...(built.uploads.length ? { uploads: normalizeCreateUploads(built.uploads) } : {}),
|
|
2839
|
+
});
|
|
2840
|
+
await appendTaskRecord(kwargs, {
|
|
2841
|
+
taskId: result.taskId,
|
|
2842
|
+
taskType: 'IMAGE_CREATE',
|
|
2843
|
+
projectGroupNo: built.projectGroupNo,
|
|
2844
|
+
modelGroupCode: built.request.modelGroupCode,
|
|
2845
|
+
promptSummary: taskPromptSummary({ prompt: built.request.promptParams?.prompt }),
|
|
2846
|
+
});
|
|
2847
|
+
if (toInt(kwargs.waitSeconds, 0) > 0 && result.taskId) {
|
|
2848
|
+
return {
|
|
2849
|
+
...result,
|
|
2850
|
+
waited: await waitTask({
|
|
2851
|
+
taskId: result.taskId,
|
|
2852
|
+
taskType: 'IMAGE_CREATE',
|
|
2853
|
+
projectGroupNo: built.projectGroupNo,
|
|
2854
|
+
waitSeconds: kwargs.waitSeconds,
|
|
2855
|
+
pollIntervalMs: kwargs.pollIntervalMs,
|
|
2856
|
+
}),
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
return {
|
|
2860
|
+
...result,
|
|
2861
|
+
nextCommand: result.taskId
|
|
2862
|
+
? `lj-awb task wait --task-id ${result.taskId} --task-type IMAGE_CREATE --project-group-no ${built.projectGroupNo} -f json`
|
|
2863
|
+
: null,
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
async function buildVideoRequest(kwargs = {}, options = {}) {
|
|
2868
|
+
const dryRun = Boolean(options.dryRun);
|
|
2869
|
+
const model = resolveTaskModel('video', kwargs);
|
|
2870
|
+
const validationResources = await resolveResources(kwargs, {
|
|
2871
|
+
dryRun: true,
|
|
2872
|
+
kind: 'video',
|
|
2873
|
+
sceneType: TASK_UPLOAD_SCENE.VIDEO_CREATE,
|
|
2874
|
+
});
|
|
2875
|
+
const prompt = trimToNull(kwargs.prompt) ?? '';
|
|
2876
|
+
if (!prompt && !validationResources.resources.length) {
|
|
2877
|
+
throw argumentError('缺少视频生成输入', '至少传 --prompt 或 --resource。');
|
|
2878
|
+
}
|
|
2879
|
+
assertVideoReferenceKeysUsed(prompt, validationResources.resources);
|
|
2880
|
+
const defaultParams = {
|
|
2881
|
+
prompt,
|
|
2882
|
+
resources: validationResources.resources,
|
|
2883
|
+
};
|
|
2884
|
+
if (kwargs.duration != null && kwargs.duration !== '') {
|
|
2885
|
+
defaultParams.duration = parseIntegerParam(kwargs.duration, '--duration', 0);
|
|
2886
|
+
}
|
|
2887
|
+
if (kwargs.ratio != null && kwargs.ratio !== '') defaultParams.ratio = kwargs.ratio;
|
|
2888
|
+
if (kwargs.quality != null && kwargs.quality !== '') defaultParams.quality = kwargs.quality;
|
|
2889
|
+
if (kwargs.needAudio != null && kwargs.needAudio !== '') defaultParams.need_audio = toBool(kwargs.needAudio);
|
|
2890
|
+
const promptParams = normalizeUnifiedPromptParams(defaultParams);
|
|
2891
|
+
const validation = await validateCreateRequestAgainstModel('video', model.modelGroupCode, promptParams, validationResources.resourceDetails);
|
|
2892
|
+
const resolvedResources = await resolveResources(kwargs, {
|
|
2893
|
+
dryRun,
|
|
2894
|
+
kind: 'video',
|
|
2895
|
+
sceneType: TASK_UPLOAD_SCENE.VIDEO_CREATE,
|
|
2896
|
+
resourceRules: validation.resourceRules,
|
|
2897
|
+
});
|
|
2898
|
+
const requestPromptParams = normalizeUnifiedPromptParams({ ...defaultParams, resources: resolvedResources.resources });
|
|
2899
|
+
const projectGroupNo = await resolveProjectGroupNo(kwargs.projectGroupNo, { allowNull: dryRun, noNetwork: dryRun, noSave: dryRun });
|
|
2900
|
+
const customBizId = options.includeCustomBizId === false ? null : resolveCustomBizId(kwargs.customBizId);
|
|
2901
|
+
return {
|
|
2902
|
+
model,
|
|
2903
|
+
projectGroupNo,
|
|
2904
|
+
uploads: resolvedResources.uploads,
|
|
2905
|
+
localFiles: resolvedResources.localFiles,
|
|
2906
|
+
resourceConversions: resolvedResources.resourceConversions,
|
|
2907
|
+
request: {
|
|
2908
|
+
requestSource: REQUEST_SOURCE_CLI,
|
|
2909
|
+
modelGroupCode: model.modelGroupCode,
|
|
2910
|
+
...(customBizId ? { customBizId } : {}),
|
|
2911
|
+
promptParams: requestPromptParams,
|
|
2912
|
+
...(projectGroupNo ? { projectGroupNo } : {}),
|
|
2913
|
+
},
|
|
2914
|
+
};
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
export async function videoFee(kwargs = {}) {
|
|
2918
|
+
if (toBool(kwargs.dryRun)) {
|
|
2919
|
+
const built = await buildVideoRequest(kwargs, { dryRun: true, includeCustomBizId: false });
|
|
2920
|
+
return compactRecord({
|
|
2921
|
+
dryRun: true,
|
|
2922
|
+
action: 'video fee',
|
|
2923
|
+
request: built.request,
|
|
2924
|
+
...(built.resourceConversions.length ? { resourceConversions: built.resourceConversions } : {}),
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
const built = await buildVideoRequest(kwargs, { includeCustomBizId: false });
|
|
2928
|
+
const payload = await awbApi.fetchVideoFee(built.request);
|
|
2929
|
+
return {
|
|
2930
|
+
data: payload,
|
|
2931
|
+
...(await pointEstimate(payload, built.projectGroupNo)),
|
|
2932
|
+
};
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
export async function videoCreate(kwargs = {}) {
|
|
2936
|
+
if (toBool(kwargs.dryRun)) {
|
|
2937
|
+
const built = await buildVideoRequest(kwargs, { dryRun: true });
|
|
2938
|
+
return compactRecord({
|
|
2939
|
+
dryRun: true,
|
|
2940
|
+
action: 'video create',
|
|
2941
|
+
request: built.request,
|
|
2942
|
+
...(built.localFiles.length ? { localFiles: built.localFiles } : {}),
|
|
2943
|
+
...(built.resourceConversions.length ? { resourceConversions: built.resourceConversions } : {}),
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
ensureConfirmed(kwargs, '正式生视频会消耗积分,需要确认', { action: 'video create' });
|
|
2947
|
+
const built = await buildVideoRequest(kwargs);
|
|
2948
|
+
const feePayload = await awbApi.fetchVideoFee(built.request).catch(() => null);
|
|
2949
|
+
const estimate = await pointEstimate(feePayload, built.projectGroupNo).catch(() => ({}));
|
|
2950
|
+
const payload = await awbApi.createVideoTask(built.request);
|
|
2951
|
+
const result = normalizeCreatedTask(payload, {
|
|
2952
|
+
...estimate,
|
|
2953
|
+
projectGroupNo: built.projectGroupNo,
|
|
2954
|
+
modelGroupCode: built.request.modelGroupCode,
|
|
2955
|
+
...(built.uploads.length ? { uploads: normalizeCreateUploads(built.uploads) } : {}),
|
|
2956
|
+
});
|
|
2957
|
+
await appendTaskRecord(kwargs, {
|
|
2958
|
+
taskId: result.taskId,
|
|
2959
|
+
taskType: 'VIDEO_GROUP',
|
|
2960
|
+
projectGroupNo: built.projectGroupNo,
|
|
2961
|
+
modelGroupCode: built.request.modelGroupCode,
|
|
2962
|
+
promptSummary: taskPromptSummary({ prompt: built.request.promptParams?.prompt }),
|
|
2963
|
+
});
|
|
2964
|
+
if (toInt(kwargs.waitSeconds, 0) > 0 && result.taskId) {
|
|
2965
|
+
return {
|
|
2966
|
+
...result,
|
|
2967
|
+
waited: await waitTask({
|
|
2968
|
+
taskId: result.taskId,
|
|
2969
|
+
taskType: 'VIDEO_GROUP',
|
|
2970
|
+
projectGroupNo: built.projectGroupNo,
|
|
2971
|
+
waitSeconds: kwargs.waitSeconds,
|
|
2972
|
+
pollIntervalMs: kwargs.pollIntervalMs,
|
|
2973
|
+
}),
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
return {
|
|
2977
|
+
...result,
|
|
2978
|
+
nextCommand: result.taskId
|
|
2979
|
+
? `lj-awb task wait --task-id ${result.taskId} --task-type VIDEO_GROUP --project-group-no ${built.projectGroupNo} -f json`
|
|
2980
|
+
: null,
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
export async function loadBatchItems(inputFile, mode = 'generic') {
|
|
2985
|
+
const filePath = trimToNull(inputFile);
|
|
2986
|
+
if (!filePath) throw argumentError('缺少批量输入文件', '传 --input-file <json|jsonl|txt>。');
|
|
2987
|
+
const text = await fs.readFile(path.resolve(filePath), 'utf8');
|
|
2988
|
+
const trimmed = text.trim();
|
|
2989
|
+
if (!trimmed) return [];
|
|
2990
|
+
if (trimmed.startsWith('[')) {
|
|
2991
|
+
const parsed = JSON.parse(trimmed);
|
|
2992
|
+
if (!Array.isArray(parsed)) throw argumentError('批量 JSON 必须是数组');
|
|
2993
|
+
return parsed;
|
|
2994
|
+
}
|
|
2995
|
+
const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2996
|
+
if (lines.every((line) => line.startsWith('{'))) return lines.map((line) => JSON.parse(line));
|
|
2997
|
+
if (lines.length === 1 && lines[0].startsWith('{')) {
|
|
2998
|
+
const parsed = JSON.parse(lines[0]);
|
|
2999
|
+
if (Array.isArray(parsed.items)) return parsed.items;
|
|
3000
|
+
return [parsed];
|
|
3001
|
+
}
|
|
3002
|
+
return lines.map((line) => (mode === 'subject' ? { name: line } : { prompt: line }));
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
const IMAGE_BATCH_ITEM_KEYS = new Set([
|
|
3006
|
+
'prompt',
|
|
3007
|
+
'ratio',
|
|
3008
|
+
'quality',
|
|
3009
|
+
'generate_num',
|
|
3010
|
+
'generateNum',
|
|
3011
|
+
'resource',
|
|
3012
|
+
'resources',
|
|
3013
|
+
'resourcesJson',
|
|
3014
|
+
'customBizId',
|
|
3015
|
+
]);
|
|
3016
|
+
const VIDEO_BATCH_ITEM_KEYS = new Set([
|
|
3017
|
+
'prompt',
|
|
3018
|
+
'ratio',
|
|
3019
|
+
'quality',
|
|
3020
|
+
'duration',
|
|
3021
|
+
'need_audio',
|
|
3022
|
+
'needAudio',
|
|
3023
|
+
'resource',
|
|
3024
|
+
'resources',
|
|
3025
|
+
'resourcesJson',
|
|
3026
|
+
'customBizId',
|
|
3027
|
+
]);
|
|
3028
|
+
|
|
3029
|
+
function normalizeTaskBatchItem(item, kind, index) {
|
|
3030
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
3031
|
+
throw argumentError(`批量输入第 ${index + 1} 项必须是 JSON 对象或纯文本 prompt`);
|
|
3032
|
+
}
|
|
3033
|
+
const allowedKeys = kind === 'image' ? IMAGE_BATCH_ITEM_KEYS : VIDEO_BATCH_ITEM_KEYS;
|
|
3034
|
+
const unknownKeys = Object.keys(item).filter((key) => !allowedKeys.has(key));
|
|
3035
|
+
if (unknownKeys.length) {
|
|
3036
|
+
throw argumentError(
|
|
3037
|
+
`批量输入第 ${index + 1} 项存在未知字段:${unknownKeys.join(', ')}`,
|
|
3038
|
+
kind === 'image'
|
|
3039
|
+
? '生图批量项只接受 prompt、ratio、quality、generate_num、resources、resource、customBizId。'
|
|
3040
|
+
: '生视频批量项只接受 prompt、ratio、quality、duration、need_audio、resources、resource、customBizId。',
|
|
3041
|
+
);
|
|
3042
|
+
}
|
|
3043
|
+
const next = { ...item };
|
|
3044
|
+
if (next.generate_num != null && next.generateNum == null) next.generateNum = next.generate_num;
|
|
3045
|
+
if (next.need_audio != null && next.needAudio == null) next.needAudio = next.need_audio;
|
|
3046
|
+
return next;
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
async function runConcurrent(items, limit, worker) {
|
|
3050
|
+
const safeLimit = Math.max(1, toInt(limit, 1));
|
|
3051
|
+
const results = new Array(items.length);
|
|
3052
|
+
let nextIndex = 0;
|
|
3053
|
+
async function runOne() {
|
|
3054
|
+
while (nextIndex < items.length) {
|
|
3055
|
+
const index = nextIndex;
|
|
3056
|
+
nextIndex += 1;
|
|
3057
|
+
try {
|
|
3058
|
+
results[index] = await worker(items[index], index);
|
|
3059
|
+
} catch (error) {
|
|
3060
|
+
results[index] = {
|
|
3061
|
+
inputIndex: index,
|
|
3062
|
+
status: 'error',
|
|
3063
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
await Promise.all(Array.from({ length: Math.min(safeLimit, Math.max(items.length, 1)) }, runOne));
|
|
3069
|
+
return results;
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
export async function imageCreateBatch(kwargs = {}) {
|
|
3073
|
+
const items = (await loadBatchItems(kwargs.inputFile, 'image'))
|
|
3074
|
+
.map((item, index) => normalizeTaskBatchItem(item, 'image', index));
|
|
3075
|
+
if (toBool(kwargs.dryRun)) {
|
|
3076
|
+
const results = [];
|
|
3077
|
+
for (const [index, item] of items.entries()) {
|
|
3078
|
+
results.push({ inputIndex: index, ...(await imageCreate({ ...kwargs, ...item, dryRun: true })) });
|
|
3079
|
+
}
|
|
3080
|
+
return { dryRun: true, count: items.length, results };
|
|
3081
|
+
}
|
|
3082
|
+
ensureConfirmed(kwargs, '批量生图会多次消耗积分,需要确认', { action: 'image create-batch', count: items.length });
|
|
3083
|
+
const results = await runConcurrent(items, kwargs.concurrency ?? 1, async (item, index) => ({
|
|
3084
|
+
inputIndex: index,
|
|
3085
|
+
status: 'success',
|
|
3086
|
+
...(await imageCreate({ ...kwargs, ...item, yes: true, dryRun: false })),
|
|
3087
|
+
}));
|
|
3088
|
+
return { count: items.length, results };
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
export async function videoCreateBatch(kwargs = {}) {
|
|
3092
|
+
const items = (await loadBatchItems(kwargs.inputFile, 'video'))
|
|
3093
|
+
.map((item, index) => normalizeTaskBatchItem(item, 'video', index));
|
|
3094
|
+
if (toBool(kwargs.dryRun)) {
|
|
3095
|
+
const results = [];
|
|
3096
|
+
for (const [index, item] of items.entries()) {
|
|
3097
|
+
results.push({ inputIndex: index, ...(await videoCreate({ ...kwargs, ...item, dryRun: true })) });
|
|
3098
|
+
}
|
|
3099
|
+
return { dryRun: true, count: items.length, results };
|
|
3100
|
+
}
|
|
3101
|
+
ensureConfirmed(kwargs, '批量生视频会多次消耗积分,需要确认', { action: 'video create-batch', count: items.length });
|
|
3102
|
+
const results = await runConcurrent(items, kwargs.concurrency ?? 1, async (item, index) => ({
|
|
3103
|
+
inputIndex: index,
|
|
3104
|
+
status: 'success',
|
|
3105
|
+
...(await videoCreate({ ...kwargs, ...item, yes: true, dryRun: false })),
|
|
3106
|
+
}));
|
|
3107
|
+
return { count: items.length, results };
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
function taskPromptSummary(input = {}) {
|
|
3111
|
+
const text = trimToNull(input.prompt ?? input.taskPrompt);
|
|
3112
|
+
return text ? text.slice(0, 160) : null;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function resolveTaskRecordFile(kwargs = {}) {
|
|
3116
|
+
const filePath = trimToNull(kwargs.taskRecordFile ?? DEFAULT_TASK_RECORD_FILE_ENV);
|
|
3117
|
+
return filePath ? path.resolve(filePath) : null;
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
async function appendTaskRecord(kwargs, record) {
|
|
3121
|
+
const filePath = resolveTaskRecordFile(kwargs);
|
|
3122
|
+
if (!filePath || !record?.taskId) return null;
|
|
3123
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
3124
|
+
await fs.appendFile(filePath, `${JSON.stringify({
|
|
3125
|
+
schemaVersion: 1,
|
|
3126
|
+
site: SITE,
|
|
3127
|
+
recordedAt: nowIso(),
|
|
3128
|
+
...record,
|
|
3129
|
+
})}\n`, 'utf8');
|
|
3130
|
+
return filePath;
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
function normalizeTask(payload, taskType = null, extra = {}) {
|
|
3134
|
+
const item = Array.isArray(payload) ? payload[0] : payload ?? {};
|
|
3135
|
+
const resultFileList = Array.isArray(item?.resultFileList) ? item.resultFileList : [];
|
|
3136
|
+
const resultFileDisplayList = Array.isArray(item?.resultFileDisplayList) ? item.resultFileDisplayList : [];
|
|
3137
|
+
const taskExt = item?.taskExt && typeof item.taskExt === 'object' ? item.taskExt : null;
|
|
3138
|
+
const originUrls = uniqueNonEmpty([
|
|
3139
|
+
...parseListArg(taskExt?.ORIGIN_URL ?? taskExt?.originUrl ?? taskExt?.origin_url),
|
|
3140
|
+
...parseListArg(item?.originUrl ?? item?.origin_url),
|
|
3141
|
+
]);
|
|
3142
|
+
const resultUrls = uniqueNonEmpty([
|
|
3143
|
+
...resultFileDisplayList,
|
|
3144
|
+
...resultFileList,
|
|
3145
|
+
...parseListArg(item?.resultUrl),
|
|
3146
|
+
...parseListArg(item?.url),
|
|
3147
|
+
]);
|
|
3148
|
+
const taskStatus = item?.taskStatus ?? item?.status ?? item?.state ?? null;
|
|
3149
|
+
return compactRecord({
|
|
3150
|
+
taskId: item?.taskId ?? extra.taskId ?? null,
|
|
3151
|
+
taskType: item?.taskType ?? taskType ?? extra.taskType ?? null,
|
|
3152
|
+
...(extra.projectGroupNo ? { projectGroupNo: extra.projectGroupNo } : {}),
|
|
3153
|
+
taskStatus,
|
|
3154
|
+
isTerminal: isTerminalTaskStatus(taskStatus),
|
|
3155
|
+
modelGroupCode: item?.modelGroupCode ?? null,
|
|
3156
|
+
pointNo: item?.pointNo ?? null,
|
|
3157
|
+
gmtCreate: item?.gmtCreate ?? item?.createTime ?? item?.createdAt ?? null,
|
|
3158
|
+
gmtModified: item?.gmtModified ?? item?.updateTime ?? item?.updatedAt ?? null,
|
|
3159
|
+
promptSummary: taskPromptSummary({ prompt: item?.taskPrompt }),
|
|
3160
|
+
resultCount: resultUrls.length,
|
|
3161
|
+
...(resultUrls.length ? { resultUrls } : {}),
|
|
3162
|
+
...(originUrls.length ? { originUrls } : {}),
|
|
3163
|
+
errorMessage: item?.errorMsg ?? item?.resultMsg ?? item?.message ?? null,
|
|
3164
|
+
...extra,
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
export async function taskStatus(kwargs = {}) {
|
|
3169
|
+
const taskId = requireValue(kwargs, 'taskId', 'task-id');
|
|
3170
|
+
const taskType = normalizeFeedTaskType(kwargs.taskType ?? 'IMAGE_CREATE');
|
|
3171
|
+
const kind = taskTypeToStatusKind(taskType);
|
|
3172
|
+
const payload = kind === 'video' ? await awbApi.fetchVideoTask(taskId) : await awbApi.fetchImageTask(taskId);
|
|
3173
|
+
return normalizeTask(payload, taskType, {
|
|
3174
|
+
taskId,
|
|
3175
|
+
taskType,
|
|
3176
|
+
...(trimToNull(kwargs.projectGroupNo) ? { projectGroupNo: trimToNull(kwargs.projectGroupNo) } : {}),
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
export async function taskList(kwargs = {}) {
|
|
3181
|
+
const taskType = normalizeFeedTaskType(kwargs.taskType ?? 'IMAGE_CREATE');
|
|
3182
|
+
const projectGroupNo = await resolveProjectGroupNo(kwargs.projectGroupNo);
|
|
3183
|
+
const request = {
|
|
3184
|
+
taskType,
|
|
3185
|
+
minTime: toInt(kwargs.minTime, Date.now()),
|
|
3186
|
+
pageSize: Math.min(Math.max(toInt(kwargs.pageSize, 20), 1), 200),
|
|
3187
|
+
projectGroupNo,
|
|
3188
|
+
};
|
|
3189
|
+
const payload = await awbApi.fetchTaskFeed(request);
|
|
3190
|
+
const tasks = normalizeRows(payload).map((item) => normalizeTask(item, item?.taskType ?? taskType, {
|
|
3191
|
+
projectGroupNo,
|
|
3192
|
+
}));
|
|
3193
|
+
return {
|
|
3194
|
+
taskType,
|
|
3195
|
+
projectGroupNo,
|
|
3196
|
+
pageSize: request.pageSize,
|
|
3197
|
+
minTime: request.minTime,
|
|
3198
|
+
tasks,
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
export async function waitTask(kwargs = {}) {
|
|
3203
|
+
const taskId = requireValue(kwargs, 'taskId', 'task-id');
|
|
3204
|
+
const taskType = normalizeFeedTaskType(kwargs.taskType ?? 'IMAGE_CREATE');
|
|
3205
|
+
const waitSeconds = Math.max(0, toInt(kwargs.waitSeconds, 300));
|
|
3206
|
+
const pollIntervalMs = Math.max(500, toInt(kwargs.pollIntervalMs, 5000));
|
|
3207
|
+
const deadline = Date.now() + waitSeconds * 1000;
|
|
3208
|
+
let last = null;
|
|
3209
|
+
while (true) {
|
|
3210
|
+
last = await taskStatus({ ...kwargs, taskId, taskType }).catch(async (error) => {
|
|
3211
|
+
if (['network_error', 'auth_failed'].includes(error?.type)) {
|
|
3212
|
+
throw error;
|
|
3213
|
+
}
|
|
3214
|
+
const list = await taskList({ ...kwargs, taskId, taskType, pageSize: kwargs.pageSize ?? 100 }).catch((listError) => {
|
|
3215
|
+
if (['network_error', 'auth_failed'].includes(listError?.type)) {
|
|
3216
|
+
throw listError;
|
|
3217
|
+
}
|
|
3218
|
+
return { tasks: [] };
|
|
3219
|
+
});
|
|
3220
|
+
return list.tasks.find((item) => item.taskId === taskId) ?? null;
|
|
3221
|
+
});
|
|
3222
|
+
if (last?.isTerminal) {
|
|
3223
|
+
return { ...last, timedOut: false, waitedMs: waitSeconds * 1000 - Math.max(0, deadline - Date.now()) };
|
|
3224
|
+
}
|
|
3225
|
+
if (Date.now() >= deadline || waitSeconds === 0) break;
|
|
3226
|
+
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
|
|
3227
|
+
}
|
|
3228
|
+
throw new LingjingAwbCliError('任务仍在运行,当前等待窗口已结束', {
|
|
3229
|
+
type: 'task_still_running',
|
|
3230
|
+
exitCode: 20,
|
|
3231
|
+
hint: '稍后再次运行 task wait,或增大 --wait-seconds。',
|
|
3232
|
+
details: {
|
|
3233
|
+
taskId,
|
|
3234
|
+
taskType,
|
|
3235
|
+
waitSeconds,
|
|
3236
|
+
last,
|
|
3237
|
+
},
|
|
3238
|
+
});
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
export async function taskRecords(kwargs = {}) {
|
|
3242
|
+
const filePath = resolveTaskRecordFile(kwargs);
|
|
3243
|
+
if (!filePath) throw argumentError('缺少任务台账文件', '传 --task-record-file <path>,或设置 LINGJING_AWB_TASK_RECORD_FILE。');
|
|
3244
|
+
const text = await fs.readFile(filePath, 'utf8').catch((error) => {
|
|
3245
|
+
if (error?.code === 'ENOENT') return '';
|
|
3246
|
+
throw error;
|
|
3247
|
+
});
|
|
3248
|
+
const records = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
|
|
3249
|
+
const latest = new Map();
|
|
3250
|
+
for (const record of records) {
|
|
3251
|
+
if (record?.taskId) latest.set(record.taskId, record);
|
|
3252
|
+
}
|
|
3253
|
+
const taskType = trimToNull(kwargs.taskType);
|
|
3254
|
+
const pendingOnly = toBool(kwargs.pendingOnly);
|
|
3255
|
+
const rows = [...latest.values()]
|
|
3256
|
+
.filter((record) => !taskType || normalizeFeedTaskType(record.taskType) === normalizeFeedTaskType(taskType))
|
|
3257
|
+
.filter((record) => !pendingOnly || !isTerminalTaskStatus(record.taskStatus))
|
|
3258
|
+
.sort((left, right) => String(right.recordedAt ?? '').localeCompare(String(left.recordedAt ?? '')))
|
|
3259
|
+
.slice(0, Math.max(1, toInt(kwargs.limit, 100)));
|
|
3260
|
+
return { filePath, count: rows.length, records: rows };
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
export async function taskRecordPoll(kwargs = {}) {
|
|
3264
|
+
const source = await taskRecords({ ...kwargs, pendingOnly: kwargs.pendingOnly ?? true });
|
|
3265
|
+
const waitSeconds = Math.max(0, toInt(kwargs.waitSeconds, 0));
|
|
3266
|
+
const deadline = Date.now() + waitSeconds * 1000;
|
|
3267
|
+
const results = new Map();
|
|
3268
|
+
do {
|
|
3269
|
+
for (const record of source.records) {
|
|
3270
|
+
if (results.get(record.taskId)?.isTerminal) continue;
|
|
3271
|
+
const status = await taskStatus({
|
|
3272
|
+
taskId: record.taskId,
|
|
3273
|
+
taskType: record.taskType,
|
|
3274
|
+
projectGroupNo: record.projectGroupNo,
|
|
3275
|
+
}).catch((error) => ({
|
|
3276
|
+
taskId: record.taskId,
|
|
3277
|
+
taskType: record.taskType,
|
|
3278
|
+
isTerminal: false,
|
|
3279
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3280
|
+
}));
|
|
3281
|
+
results.set(record.taskId, { ...record, ...status });
|
|
3282
|
+
}
|
|
3283
|
+
if ([...results.values()].every((item) => item.isTerminal)) break;
|
|
3284
|
+
if (Date.now() > deadline) break;
|
|
3285
|
+
await sleep(Math.max(500, toInt(kwargs.pollIntervalMs, 5000)));
|
|
3286
|
+
} while (waitSeconds > 0);
|
|
3287
|
+
return {
|
|
3288
|
+
filePath: source.filePath,
|
|
3289
|
+
count: source.records.length,
|
|
3290
|
+
timedOut: ![...results.values()].every((item) => item.isTerminal),
|
|
3291
|
+
records: [...results.values()],
|
|
3292
|
+
};
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
function normalizeCosAssetPath(value) {
|
|
3296
|
+
const text = trimToNull(value);
|
|
3297
|
+
if (!text) return null;
|
|
3298
|
+
if (/^https?:\/\//i.test(text)) {
|
|
3299
|
+
try {
|
|
3300
|
+
return decodeURIComponent(new URL(text).pathname.replace(/^\/+/, ''));
|
|
3301
|
+
} catch {
|
|
3302
|
+
return text.replace(/^\/+/, '');
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
return text.replace(/^\/+/, '');
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
function normalizeAssetGroup(item = {}) {
|
|
3309
|
+
return {
|
|
3310
|
+
id: item?.id ?? item?.groupId ?? item?.assetGroupsId ?? null,
|
|
3311
|
+
groupId: item?.id ?? item?.groupId ?? item?.assetGroupsId ?? null,
|
|
3312
|
+
name: item?.name ?? item?.groupName ?? null,
|
|
3313
|
+
description: item?.description ?? null,
|
|
3314
|
+
projectName: item?.projectName ?? null,
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
function extractAssetGroupRows(payload) {
|
|
3319
|
+
return normalizeRows(payload).map((item) => normalizeAssetGroup(item));
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
function parseJsonString(value) {
|
|
3323
|
+
if (value == null || value === '') return null;
|
|
3324
|
+
if (typeof value !== 'string') return value;
|
|
3325
|
+
try {
|
|
3326
|
+
return JSON.parse(value);
|
|
3327
|
+
} catch {
|
|
3328
|
+
return value;
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
function stringifyJsonObjectArg(value, fallback = '{}') {
|
|
3333
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
3334
|
+
const parsed = parseJsonArg(value, {});
|
|
3335
|
+
return typeof parsed === 'string' ? parsed : JSON.stringify(parsed);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
function normalizeAssetMatchRecord(item = {}, fallbackPartType = null) {
|
|
3339
|
+
return {
|
|
3340
|
+
assetId: item?.materialId ?? item?.assetId ?? item?.id ?? null,
|
|
3341
|
+
partType: item?.partType ?? fallbackPartType,
|
|
3342
|
+
imageUrl: item?.imageUrl ?? item?.image ?? item?.url ?? null,
|
|
3343
|
+
description: item?.description ?? null,
|
|
3344
|
+
score: toNumberOrNull(item?.matchScore ?? item?.score ?? item?.similarity),
|
|
3345
|
+
tags: parseJsonString(item?.tagInfo ?? item?.tags),
|
|
3346
|
+
};
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
function normalizeAssetMatchRows(payload) {
|
|
3350
|
+
if (Array.isArray(payload)) {
|
|
3351
|
+
return payload
|
|
3352
|
+
.map((item) => normalizeAssetMatchRecord(item))
|
|
3353
|
+
.sort((left, right) => (right.score ?? -Infinity) - (left.score ?? -Infinity));
|
|
3354
|
+
}
|
|
3355
|
+
if (!payload || typeof payload !== 'object') return [];
|
|
3356
|
+
|
|
3357
|
+
const groupSpecs = [
|
|
3358
|
+
['head', 'HEAD'],
|
|
3359
|
+
['body', 'BODY'],
|
|
3360
|
+
['realPersonHead', 'REAL_PERSON_HEAD'],
|
|
3361
|
+
];
|
|
3362
|
+
const rows = [];
|
|
3363
|
+
for (const [key, partType] of groupSpecs) {
|
|
3364
|
+
for (const item of Array.isArray(payload[key]) ? payload[key] : []) {
|
|
3365
|
+
rows.push(normalizeAssetMatchRecord(item, partType));
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
if (rows.length) {
|
|
3369
|
+
return rows.sort((left, right) => (right.score ?? -Infinity) - (left.score ?? -Infinity));
|
|
3370
|
+
}
|
|
3371
|
+
return normalizeRows(payload)
|
|
3372
|
+
.map((item) => normalizeAssetMatchRecord(item))
|
|
3373
|
+
.sort((left, right) => (right.score ?? -Infinity) - (left.score ?? -Infinity));
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
function normalizeSubjectResourceUrls(list, key) {
|
|
3377
|
+
if (!Array.isArray(list)) return [];
|
|
3378
|
+
const upperKey = key.charAt(0).toUpperCase() + key.slice(1);
|
|
3379
|
+
return uniqueNonEmpty(list.map((item) => (typeof item === 'string' ? item : item?.[key] ?? item?.[upperKey])));
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
function normalizeSubjectTagIds(tagList) {
|
|
3383
|
+
if (!Array.isArray(tagList)) return [];
|
|
3384
|
+
return uniqueNonEmpty(tagList.map((item) => (typeof item === 'string' ? item : item?.tagId ?? item?.id ?? item?.tagCode)));
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
function normalizeSubjectRecord(item = {}) {
|
|
3388
|
+
const referList = Array.isArray(item?.elementReferList) ? item.elementReferList : [];
|
|
3389
|
+
const videoList = Array.isArray(item?.elementVideoList) ? item.elementVideoList : [];
|
|
3390
|
+
const elementId = item?.id ?? item?.subjectId ?? item?.elementId ?? null;
|
|
3391
|
+
const externalId = item?.externalId ?? item?.external_id ?? null;
|
|
3392
|
+
const name = item?.elementName ?? item?.name ?? item?.subjectName ?? null;
|
|
3393
|
+
return {
|
|
3394
|
+
subjectId: externalId ?? elementId,
|
|
3395
|
+
elementId,
|
|
3396
|
+
externalId,
|
|
3397
|
+
name,
|
|
3398
|
+
description: item?.elementDescription ?? item?.description ?? item?.subjectDesc ?? null,
|
|
3399
|
+
referenceType: item?.referenceType ?? null,
|
|
3400
|
+
modelCode: item?.modelCode ?? null,
|
|
3401
|
+
imageUrls: uniqueNonEmpty([
|
|
3402
|
+
item?.elementFrontalImage,
|
|
3403
|
+
item?.imageUrl,
|
|
3404
|
+
item?.coverUrl,
|
|
3405
|
+
...normalizeSubjectResourceUrls(referList, 'imageUrl'),
|
|
3406
|
+
]),
|
|
3407
|
+
videoUrls: uniqueNonEmpty([
|
|
3408
|
+
item?.videoUrl,
|
|
3409
|
+
...normalizeSubjectResourceUrls(videoList, 'videoUrl'),
|
|
3410
|
+
]),
|
|
3411
|
+
audioUrls: normalizeSubjectResourceUrls(referList, 'audioUrl'),
|
|
3412
|
+
tagIds: normalizeSubjectTagIds(item?.tagList),
|
|
3413
|
+
...(trimToNull(item?.elementVoiceId) ? { voiceId: trimToNull(item.elementVoiceId) } : {}),
|
|
3414
|
+
...(externalId && name ? { nextRefSubject: `${name}=${externalId}` } : {}),
|
|
3415
|
+
createdAt: item?.gmtCreate ?? item?.createdAt ?? null,
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
function paginateRows(rows, pageNumber, pageSize) {
|
|
3420
|
+
const actualPageNumber = Math.max(1, toInt(pageNumber, 1));
|
|
3421
|
+
const actualPageSize = Math.max(1, toInt(pageSize, 20));
|
|
3422
|
+
const start = (actualPageNumber - 1) * actualPageSize;
|
|
3423
|
+
return rows.slice(start, start + actualPageSize);
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
export async function assetMatchActor(kwargs = {}) {
|
|
3427
|
+
const description = requireValue(kwargs, 'description');
|
|
3428
|
+
const tagJson = stringifyJsonObjectArg(kwargs.tagsJson ?? kwargs.tagJson);
|
|
3429
|
+
const payload = await awbApi.matchAssetMaterial({
|
|
3430
|
+
description,
|
|
3431
|
+
tagJson,
|
|
3432
|
+
tagWeight: toNumberOrNull(kwargs.tagWeight) ?? 0.5,
|
|
3433
|
+
descWeight: toNumberOrNull(kwargs.descWeight) ?? 0.5,
|
|
3434
|
+
});
|
|
3435
|
+
return {
|
|
3436
|
+
matches: normalizeAssetMatchRows(payload),
|
|
3437
|
+
...(toBool(kwargs.includeRaw) ? { raw: payload } : {}),
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
export async function assetGroupList(kwargs = {}) {
|
|
3442
|
+
const payload = await awbApi.listAssetGroups({
|
|
3443
|
+
name: kwargs.name ?? '',
|
|
3444
|
+
pageNumber: toInt(kwargs.pageNumber, 1),
|
|
3445
|
+
pageSize: toInt(kwargs.pageSize, 20),
|
|
3446
|
+
...(parseListArg(kwargs.groupIds).length ? { groupIds: parseListArg(kwargs.groupIds) } : {}),
|
|
3447
|
+
});
|
|
3448
|
+
return { groups: extractAssetGroupRows(payload), raw: toBool(kwargs.includeRaw) ? payload : undefined };
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
export async function assetGroupGet(kwargs = {}) {
|
|
3452
|
+
const groupId = requireValue(kwargs, 'groupId', 'group-id');
|
|
3453
|
+
const payload = await awbApi.getAssetGroup(groupId);
|
|
3454
|
+
return payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
3455
|
+
? normalizeAssetGroup(payload)
|
|
3456
|
+
: extractAssetGroupRows(payload)[0] ?? { groupId };
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
export async function assetGroupCreate(kwargs = {}) {
|
|
3460
|
+
const name = requireValue(kwargs, 'name');
|
|
3461
|
+
const body = {
|
|
3462
|
+
name,
|
|
3463
|
+
description: kwargs.description ?? '',
|
|
3464
|
+
projectName: kwargs.projectName ?? 'default',
|
|
3465
|
+
};
|
|
3466
|
+
if (toBool(kwargs.dryRun)) return { dryRun: true, action: 'asset group-create', request: body };
|
|
3467
|
+
ensureConfirmed(kwargs, '创建素材组是云端写入动作,需要确认', { action: 'asset group-create', body });
|
|
3468
|
+
const payload = await awbApi.createAssetGroup(body);
|
|
3469
|
+
return { created: true, groupId: payload?.id ?? payload?.groupId ?? payload ?? null, name, projectName: body.projectName };
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
export async function assetGroupUpdate(kwargs = {}) {
|
|
3473
|
+
const groupId = requireValue(kwargs, 'groupId', 'group-id');
|
|
3474
|
+
const body = {};
|
|
3475
|
+
if (kwargs.name != null) body.name = kwargs.name;
|
|
3476
|
+
if (kwargs.description != null) body.description = kwargs.description;
|
|
3477
|
+
if (kwargs.projectName != null) body.projectName = kwargs.projectName;
|
|
3478
|
+
if (!Object.keys(body).length) throw argumentError('缺少素材组更新字段', '至少传 --name、--description 或 --project-name。');
|
|
3479
|
+
if (toBool(kwargs.dryRun)) return { dryRun: true, action: 'asset group-update', groupId, request: body };
|
|
3480
|
+
ensureConfirmed(kwargs, '更新素材组是云端写入动作,需要确认', { action: 'asset group-update', groupId, body });
|
|
3481
|
+
await awbApi.updateAssetGroup(groupId, body);
|
|
3482
|
+
return { updated: true, groupId, ...body };
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
function extractAssetId(payload) {
|
|
3486
|
+
if (typeof payload === 'string') return payload;
|
|
3487
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
3488
|
+
return payload.id ?? payload.assetId ?? payload.thirdAssetId ?? payload.resourceNo ?? payload.data?.id ?? null;
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
export async function assetRegister(kwargs = {}) {
|
|
3492
|
+
const groupId = requireValue(kwargs, 'groupId', 'group-id');
|
|
3493
|
+
const name = requireValue(kwargs, 'name');
|
|
3494
|
+
const localFile = trimToNull(kwargs.file);
|
|
3495
|
+
const assetPath = trimToNull(kwargs.backendPath) ?? normalizeCosAssetPath(kwargs.url);
|
|
3496
|
+
if (!localFile && !assetPath) throw argumentError('缺少素材路径', '传 --file、--backend-path 或 --url。');
|
|
3497
|
+
if (toBool(kwargs.dryRun)) {
|
|
3498
|
+
return {
|
|
3499
|
+
dryRun: true,
|
|
3500
|
+
action: 'asset register',
|
|
3501
|
+
request: {
|
|
3502
|
+
assetGroupsId: groupId,
|
|
3503
|
+
url: localFile ? normalizeCosAssetPath(dryRunBackendPath(localFile, TASK_UPLOAD_SCENE.SUBJECT)) : assetPath,
|
|
3504
|
+
name,
|
|
3505
|
+
...(kwargs.platform ? { platform: kwargs.platform } : {}),
|
|
3506
|
+
},
|
|
3507
|
+
localFile: localFile ? await inspectLocalFile(localFile) : null,
|
|
3508
|
+
};
|
|
3509
|
+
}
|
|
3510
|
+
ensureConfirmed(kwargs, '注册素材是云端写入动作,需要确认', { action: 'asset register', groupId, name });
|
|
3511
|
+
const uploaded = localFile ? await uploadLocalFile(localFile, { sceneType: TASK_UPLOAD_SCENE.SUBJECT }) : null;
|
|
3512
|
+
const body = {
|
|
3513
|
+
assetGroupsId: groupId,
|
|
3514
|
+
url: normalizeCosAssetPath(uploaded?.backendPath ?? assetPath),
|
|
3515
|
+
name,
|
|
3516
|
+
...(kwargs.platform ? { platform: kwargs.platform } : {}),
|
|
3517
|
+
};
|
|
3518
|
+
const payload = await awbApi.registerAsset(body);
|
|
3519
|
+
return {
|
|
3520
|
+
registered: true,
|
|
3521
|
+
assetId: extractAssetId(payload),
|
|
3522
|
+
groupId,
|
|
3523
|
+
name,
|
|
3524
|
+
assetPath: body.url,
|
|
3525
|
+
...(uploaded ? { upload: uploaded } : {}),
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
export async function subjectList(kwargs = {}) {
|
|
3530
|
+
const keyword = trimToNull(kwargs.keyword ?? kwargs.name);
|
|
3531
|
+
const payload = keyword
|
|
3532
|
+
? await awbApi.listElementsByName(keyword)
|
|
3533
|
+
: await awbApi.listElements();
|
|
3534
|
+
const subjects = paginateRows(
|
|
3535
|
+
normalizeRows(payload).map((item) => normalizeSubjectRecord(item)),
|
|
3536
|
+
kwargs.pageNumber,
|
|
3537
|
+
kwargs.pageSize,
|
|
3538
|
+
);
|
|
3539
|
+
return {
|
|
3540
|
+
subjects,
|
|
3541
|
+
...(toBool(kwargs.includeRaw) ? { raw: payload } : {}),
|
|
3542
|
+
};
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
const SUBJECT_SLOT_ALIASES = {
|
|
3546
|
+
primary: 'primary',
|
|
3547
|
+
'three-view': 'three-view',
|
|
3548
|
+
three_view: 'three-view',
|
|
3549
|
+
threeview: 'three-view',
|
|
3550
|
+
face: 'face',
|
|
3551
|
+
side: 'side',
|
|
3552
|
+
back: 'back',
|
|
3553
|
+
};
|
|
3554
|
+
|
|
3555
|
+
const SUBJECT_SLOT_META = {
|
|
3556
|
+
primary: { idField: 'subjectId' },
|
|
3557
|
+
'three-view': { idField: 'subjectId' },
|
|
3558
|
+
face: { idField: 'faceViewId' },
|
|
3559
|
+
side: { idField: 'sideViewId' },
|
|
3560
|
+
back: { idField: 'backViewId' },
|
|
3561
|
+
};
|
|
3562
|
+
|
|
3563
|
+
function parseSubjectResourceItem(raw, index) {
|
|
3564
|
+
const text = String(raw ?? '').trim();
|
|
3565
|
+
if (!text) {
|
|
3566
|
+
throw argumentError(`resource[${index}] 为空`, '格式:--resource <slot>:<file|url>,slot ∈ primary | three-view | face | side | back。');
|
|
3567
|
+
}
|
|
3568
|
+
const colonIndex = text.indexOf(':');
|
|
3569
|
+
if (colonIndex <= 0) {
|
|
3570
|
+
throw argumentError(
|
|
3571
|
+
`resource[${index}] 缺少 slot`,
|
|
3572
|
+
'格式:--resource <slot>:<file|url>,例如 --resource primary:./three-view.png。',
|
|
3573
|
+
);
|
|
3574
|
+
}
|
|
3575
|
+
const slotRaw = text.slice(0, colonIndex).trim().toLowerCase();
|
|
3576
|
+
const slot = SUBJECT_SLOT_ALIASES[slotRaw];
|
|
3577
|
+
if (!slot) {
|
|
3578
|
+
throw argumentError(`resource[${index}] slot 不支持:${slotRaw}`, 'slot ∈ primary | three-view | face | side | back。');
|
|
3579
|
+
}
|
|
3580
|
+
const value = text.slice(colonIndex + 1).trim();
|
|
3581
|
+
if (!value) {
|
|
3582
|
+
throw argumentError(`resource[${index}] value 不能为空`);
|
|
3583
|
+
}
|
|
3584
|
+
return { slot, value };
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
function subjectAssetSpecs(kwargs = {}) {
|
|
3588
|
+
const bySlot = new Map();
|
|
3589
|
+
const items = parseListArg(kwargs.resource);
|
|
3590
|
+
for (const [index, raw] of items.entries()) {
|
|
3591
|
+
const { slot, value } = parseSubjectResourceItem(raw, index);
|
|
3592
|
+
if (bySlot.has(slot)) {
|
|
3593
|
+
throw argumentError(`resource[${index}] slot 重复:${slot}`, '每个 slot 只能传一次。');
|
|
3594
|
+
}
|
|
3595
|
+
const isUrl = isRemoteOrBackendPath(value);
|
|
3596
|
+
bySlot.set(slot, { label: slot, idField: SUBJECT_SLOT_META[slot].idField, file: isUrl ? null : value, url: isUrl ? value : null });
|
|
3597
|
+
}
|
|
3598
|
+
const primary = bySlot.get('primary') ?? bySlot.get('three-view');
|
|
3599
|
+
if (!primary) return [];
|
|
3600
|
+
const ordered = [{ ...primary, label: 'primary', isPrimary: true }];
|
|
3601
|
+
for (const slot of ['face', 'side', 'back']) {
|
|
3602
|
+
const item = bySlot.get(slot);
|
|
3603
|
+
if (item) ordered.push(item);
|
|
3604
|
+
}
|
|
3605
|
+
return ordered;
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
function subjectAssetPathForDryRun(item) {
|
|
3609
|
+
return item.file
|
|
3610
|
+
? normalizeCosAssetPath(dryRunBackendPath(item.file, TASK_UPLOAD_SCENE.SUBJECT))
|
|
3611
|
+
: normalizeCosAssetPath(item.url);
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
function tagListFromArg(value) {
|
|
3615
|
+
const parsed = parseJsonArg(value, []);
|
|
3616
|
+
if (!Array.isArray(parsed)) return [];
|
|
3617
|
+
return parsed
|
|
3618
|
+
.map((item) => (typeof item === 'string' ? { tagId: item } : { tagId: trimToNull(item?.tagId ?? item?.id ?? item?.tagCode) }))
|
|
3619
|
+
.filter((item) => item.tagId);
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
function buildSubjectCreateBody(kwargs, specs, assets) {
|
|
3623
|
+
const name = requireValue(kwargs, 'name');
|
|
3624
|
+
const primary = assets.find((item) => item.isPrimary);
|
|
3625
|
+
const referAssets = assets.filter((item) => item.assetPath);
|
|
3626
|
+
return compactRecord({
|
|
3627
|
+
reqTaskId: trimToNull(kwargs.reqTaskId),
|
|
3628
|
+
modelCode: trimToNull(kwargs.modelCode),
|
|
3629
|
+
elementName: name,
|
|
3630
|
+
elementDescription: trimToNull(kwargs.description ?? kwargs.elementDescription),
|
|
3631
|
+
elementFrontalImage: primary?.assetPath ?? null,
|
|
3632
|
+
elementReferList: referAssets.map((item) => ({
|
|
3633
|
+
imageUrl: item.assetPath,
|
|
3634
|
+
audioUrl: null,
|
|
3635
|
+
})),
|
|
3636
|
+
referenceType: trimToNull(kwargs.referenceType) ?? 'image_refer',
|
|
3637
|
+
elementVoiceId: trimToNull(kwargs.voiceId ?? kwargs.elementVoiceId),
|
|
3638
|
+
tagList: tagListFromArg(kwargs.tagsJson ?? kwargs.tagJson),
|
|
3639
|
+
});
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
function extractCreatedElementId(payload) {
|
|
3643
|
+
if (typeof payload === 'string') return payload;
|
|
3644
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
3645
|
+
return payload.id ?? payload.elementId ?? payload.subjectId ?? payload.reqTaskId ?? payload.data?.id ?? payload.data?.elementId ?? null;
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
async function fetchSubjectByReqTaskId(reqTaskId) {
|
|
3649
|
+
if (!reqTaskId) return null;
|
|
3650
|
+
const payload = await awbApi.getElementByReqTaskId(reqTaskId).catch(() => null);
|
|
3651
|
+
return payload && typeof payload === 'object' ? normalizeSubjectRecord(payload) : null;
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
async function waitForSubjectExternalId(reqTaskId, options = {}) {
|
|
3655
|
+
const waitSeconds = Math.max(0, toInt(options.waitSeconds, 0));
|
|
3656
|
+
const pollIntervalMs = Math.max(500, toInt(options.pollIntervalMs, 2000));
|
|
3657
|
+
const deadline = Date.now() + waitSeconds * 1000;
|
|
3658
|
+
let latest = await fetchSubjectByReqTaskId(reqTaskId);
|
|
3659
|
+
while (waitSeconds > 0 && !latest?.externalId && Date.now() < deadline) {
|
|
3660
|
+
await sleep(pollIntervalMs);
|
|
3661
|
+
latest = await fetchSubjectByReqTaskId(reqTaskId);
|
|
3662
|
+
}
|
|
3663
|
+
return latest;
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
function subjectWaitPayload(elementId, subject) {
|
|
3667
|
+
const externalId = subject?.externalId ?? null;
|
|
3668
|
+
return {
|
|
3669
|
+
elementId,
|
|
3670
|
+
subjectId: externalId ?? elementId,
|
|
3671
|
+
externalId,
|
|
3672
|
+
nextRefSubject: externalId && subject?.name ? `${subject.name}=${externalId}` : null,
|
|
3673
|
+
status: externalId ? 'ready' : 'pending_external_id',
|
|
3674
|
+
subject,
|
|
3675
|
+
};
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
export async function subjectPublish(kwargs = {}) {
|
|
3679
|
+
const name = requireValue(kwargs, 'name');
|
|
3680
|
+
const specs = subjectAssetSpecs(kwargs);
|
|
3681
|
+
if (!specs.some((item) => item.isPrimary)) {
|
|
3682
|
+
throw argumentError('缺少主体主参考图', '传 --resource primary:<path|url>(或 --resource three-view:<path|url> 自动升为主图)。');
|
|
3683
|
+
}
|
|
3684
|
+
if (toBool(kwargs.dryRun)) {
|
|
3685
|
+
const assets = specs.map((item) => ({
|
|
3686
|
+
label: item.label,
|
|
3687
|
+
file: item.file ?? null,
|
|
3688
|
+
url: item.url ?? null,
|
|
3689
|
+
assetPath: subjectAssetPathForDryRun(item),
|
|
3690
|
+
isPrimary: Boolean(item.isPrimary),
|
|
3691
|
+
dryRun: true,
|
|
3692
|
+
}));
|
|
3693
|
+
return {
|
|
3694
|
+
dryRun: true,
|
|
3695
|
+
action: 'subject publish',
|
|
3696
|
+
name,
|
|
3697
|
+
request: buildSubjectCreateBody(kwargs, specs, assets),
|
|
3698
|
+
assets,
|
|
3699
|
+
localFiles: await inspectLocalFiles(specs.map((item) => item.file).filter(Boolean)),
|
|
3700
|
+
nextRefSubject: `${name}=<externalId>`,
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
ensureConfirmed(kwargs, '创建平台主体 element 是云端写入动作,需要确认', { action: 'subject publish', name });
|
|
3704
|
+
const assets = [];
|
|
3705
|
+
for (const spec of specs) {
|
|
3706
|
+
const uploaded = spec.file ? await uploadLocalFile(spec.file, { sceneType: TASK_UPLOAD_SCENE.SUBJECT }) : null;
|
|
3707
|
+
const assetPath = normalizeCosAssetPath(uploaded?.backendPath ?? spec.url);
|
|
3708
|
+
assets.push({
|
|
3709
|
+
label: spec.label,
|
|
3710
|
+
file: spec.file ?? null,
|
|
3711
|
+
url: spec.url ?? null,
|
|
3712
|
+
assetPath,
|
|
3713
|
+
isPrimary: Boolean(spec.isPrimary),
|
|
3714
|
+
...(uploaded ? { upload: uploaded } : {}),
|
|
3715
|
+
});
|
|
3716
|
+
}
|
|
3717
|
+
const body = buildSubjectCreateBody(kwargs, specs, assets);
|
|
3718
|
+
const payload = await awbApi.createElement(body);
|
|
3719
|
+
const elementId = extractCreatedElementId(payload);
|
|
3720
|
+
if (!elementId) throw new LingjingAwbCliError('主体创建完成但未拿到 elementId', { type: 'api_error', exitCode: 1, details: payload });
|
|
3721
|
+
const subject = await fetchSubjectByReqTaskId(elementId);
|
|
3722
|
+
const waitPayload = subjectWaitPayload(elementId, subject);
|
|
3723
|
+
return {
|
|
3724
|
+
created: true,
|
|
3725
|
+
name,
|
|
3726
|
+
elementId,
|
|
3727
|
+
subjectId: waitPayload.subjectId,
|
|
3728
|
+
externalId: waitPayload.externalId,
|
|
3729
|
+
nextRefSubject: waitPayload.nextRefSubject,
|
|
3730
|
+
status: waitPayload.status,
|
|
3731
|
+
request: body,
|
|
3732
|
+
assets,
|
|
3733
|
+
subject,
|
|
3734
|
+
};
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
export async function subjectWait(kwargs = {}) {
|
|
3738
|
+
const elementId = requireValue(kwargs, 'elementId', 'element-id');
|
|
3739
|
+
const subject = await waitForSubjectExternalId(elementId, kwargs);
|
|
3740
|
+
const payload = subjectWaitPayload(elementId, subject);
|
|
3741
|
+
if (!payload.externalId) {
|
|
3742
|
+
throw new LingjingAwbCliError('主体 externalId 尚未回填', {
|
|
3743
|
+
type: 'subject_still_pending',
|
|
3744
|
+
exitCode: 20,
|
|
3745
|
+
hint: '稍后重试 subject wait,或用 subject list --name 查询。',
|
|
3746
|
+
details: payload,
|
|
3747
|
+
});
|
|
3748
|
+
}
|
|
3749
|
+
return payload;
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
export async function subjectPublishBatch(kwargs = {}) {
|
|
3753
|
+
const items = await loadBatchItems(kwargs.inputFile, 'subject');
|
|
3754
|
+
if (toBool(kwargs.dryRun)) {
|
|
3755
|
+
const results = [];
|
|
3756
|
+
for (const [index, item] of items.entries()) {
|
|
3757
|
+
results.push({ inputIndex: index, ...(await subjectPublish({ ...kwargs, ...item, dryRun: true })) });
|
|
3758
|
+
}
|
|
3759
|
+
return { dryRun: true, count: items.length, results };
|
|
3760
|
+
}
|
|
3761
|
+
ensureConfirmed(kwargs, '批量发布主体资产是云端写入动作,需要确认', { action: 'subject publish-batch', count: items.length });
|
|
3762
|
+
const results = await runConcurrent(items, kwargs.concurrency ?? 1, async (item, index) => ({
|
|
3763
|
+
inputIndex: index,
|
|
3764
|
+
status: 'success',
|
|
3765
|
+
...(await subjectPublish({ ...kwargs, ...item, yes: true, dryRun: false })),
|
|
3766
|
+
}));
|
|
3767
|
+
return { count: items.length, results };
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
export async function subtitleRemove(kwargs = {}) {
|
|
3771
|
+
const videoUrl = requireValue(kwargs, 'videoUrl', 'video-url');
|
|
3772
|
+
if (toBool(kwargs.dryRun)) {
|
|
3773
|
+
return { dryRun: true, action: 'video subtitle-remove', request: { input_type: 'url', video_url: videoUrl } };
|
|
3774
|
+
}
|
|
3775
|
+
ensureConfirmed(kwargs, '提交视频去字幕任务是云端写入动作,需要确认', { action: 'video subtitle-remove', videoUrl });
|
|
3776
|
+
const payload = await awbApi.createSubtitleRemoveTask({
|
|
3777
|
+
input_type: 'url',
|
|
3778
|
+
video_url: videoUrl,
|
|
3779
|
+
...(kwargs.name ? { name: kwargs.name } : {}),
|
|
3780
|
+
...(kwargs.taskId ? { task_id: kwargs.taskId } : {}),
|
|
3781
|
+
...(kwargs.callbackUrl ? { callback_url: kwargs.callbackUrl } : {}),
|
|
3782
|
+
});
|
|
3783
|
+
return {
|
|
3784
|
+
submitted: true,
|
|
3785
|
+
remoteTaskId: payload?.remote_task_id ?? payload?.remoteTaskId ?? null,
|
|
3786
|
+
publicId: payload?.public_id ?? payload?.publicId ?? payload?.id ?? null,
|
|
3787
|
+
};
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
export async function subtitleStatus(kwargs = {}) {
|
|
3791
|
+
const publicId = trimToNull(kwargs.publicId);
|
|
3792
|
+
const remoteTaskId = trimToNull(kwargs.remoteTaskId);
|
|
3793
|
+
if (!publicId && !remoteTaskId) throw argumentError('缺少去字幕任务 ID', '传 --public-id 或 --remote-task-id。');
|
|
3794
|
+
const payload = publicId
|
|
3795
|
+
? await awbApi.fetchSubtitleTaskByPublicId(publicId)
|
|
3796
|
+
: await awbApi.fetchSubtitleTaskByRemoteId(remoteTaskId);
|
|
3797
|
+
const resultUrls = uniqueNonEmpty([
|
|
3798
|
+
...parseListArg(payload?.result_url ?? payload?.resultUrl),
|
|
3799
|
+
...parseListArg(payload?.output_url ?? payload?.outputUrl),
|
|
3800
|
+
...parseListArg(payload?.url),
|
|
3801
|
+
]);
|
|
3802
|
+
return compactRecord({
|
|
3803
|
+
publicId: publicId ?? payload?.public_id ?? payload?.publicId ?? payload?.id ?? null,
|
|
3804
|
+
remoteTaskId: remoteTaskId ?? payload?.remote_task_id ?? payload?.remoteTaskId ?? null,
|
|
3805
|
+
taskStatus: payload?.status ?? payload?.taskStatus ?? payload?.state ?? null,
|
|
3806
|
+
isTerminal: isTerminalTaskStatus(payload?.status ?? payload?.taskStatus ?? payload?.state),
|
|
3807
|
+
resultCount: resultUrls.length,
|
|
3808
|
+
...(resultUrls.length ? { resultUrls } : {}),
|
|
3809
|
+
errorMessage: payload?.error_message ?? payload?.errorMessage ?? payload?.message ?? null,
|
|
3810
|
+
});
|
|
3811
|
+
}
|