@lingjingai/lj-awb-cli-pre 0.3.18 → 0.4.5
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 +57 -8
- package/build/_shared.mjs +54 -5
- package/build/prod.mjs +12 -3
- package/package.json +6 -2
- package/packages/awb-cli/package.json +2 -2
- package/packages/awb-core/package.json +6 -2
- package/packages/awb-core/src/api.js +22 -0
- package/packages/awb-core/src/commands.js +112 -39
- package/packages/awb-core/src/common.js +8 -0
- package/packages/awb-core/src/output.js +2030 -8
- package/packages/awb-core/src/services.js +1835 -205
- package/packages/awb-core/src/standalone.js +472 -136
- package/packages/awb-core/src/update.js +327 -0
- package/skills/lj-awb/SKILL.md +35 -12
- package/skills/lj-awb/VERSION +1 -1
- package/skills/lj-awb/compat.json +3 -3
- package/skills/lj-awb/modules/artifact/asset.md +1 -1
- package/skills/lj-awb/modules/artifact/clip.md +1 -1
- package/skills/lj-awb/modules/artifact/script.md +1 -1
- package/skills/lj-awb/modules/artifact/video.md +1 -1
- package/skills/lj-awb/modules/asset.md +10 -1
- package/skills/lj-awb/modules/auth.md +9 -1
- package/skills/lj-awb/modules/create-contract.md +5 -2
- package/skills/lj-awb/modules/create.md +4 -2
- package/skills/lj-awb/modules/driver.md +12 -6
- package/skills/lj-awb/modules/image.md +3 -1
- package/skills/lj-awb/modules/model.md +12 -9
- package/skills/lj-awb/modules/task.md +4 -1
- package/skills/lj-awb/modules/upload.md +1 -1
- package/skills/lj-awb/modules/video.md +11 -2
- package/skills/lj-awb/modules/workflows.md +3 -1
- package/skills/lj-awb/references/error-codes.md +24 -0
- package/skills/lj-awb/references/model-options-read.md +16 -10
- package/skills/lj-awb/references/output-fields.md +10 -7
- package/skills/lj-awb/scripts/resolve-lj-awb-cmd.sh +106 -4
|
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
7
|
import { promisify } from 'node:util';
|
|
7
8
|
import {
|
|
8
9
|
API_ORIGIN,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
isSuccessTaskStatus,
|
|
23
24
|
isTerminalTaskStatus,
|
|
24
25
|
loadState,
|
|
26
|
+
maskSecret,
|
|
25
27
|
normalizeFeedTaskType,
|
|
26
28
|
nowIso,
|
|
27
29
|
parseJsonArg,
|
|
@@ -35,10 +37,11 @@ import {
|
|
|
35
37
|
toBool,
|
|
36
38
|
toInt,
|
|
37
39
|
toNumberOrNull,
|
|
40
|
+
trimSecret,
|
|
38
41
|
trimToNull,
|
|
39
42
|
uniqueNonEmpty,
|
|
40
43
|
} from './common.js';
|
|
41
|
-
import { loadAuth, resolveAuthContext, summarizeAuth } from './auth.js';
|
|
44
|
+
import { clearAuth, loadAuth, resolveAuthContext, saveAccessKey, summarizeAuth } from './auth.js';
|
|
42
45
|
import * as awbApi from './api.js';
|
|
43
46
|
|
|
44
47
|
const SITE = 'lj-awb';
|
|
@@ -46,6 +49,17 @@ const REQUEST_SOURCE_CLI = 'LINGJING_AWB_CLI';
|
|
|
46
49
|
const DEFAULT_TASK_RECORD_FILE_ENV = process.env.LINGJING_AWB_TASK_RECORD_FILE || process.env.AWB_TASK_RECORD_FILE;
|
|
47
50
|
const execFileAsync = promisify(execFile);
|
|
48
51
|
const COMMON_IMAGE_FORMATS = new Set(['jpg', 'jpeg', 'jfif', 'png', 'webp']);
|
|
52
|
+
const REMOTE_IMAGE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
53
|
+
const REMOTE_IMAGE_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
|
54
|
+
const REMOTE_IMAGE_MIME_TYPES = new Set([
|
|
55
|
+
'image/bmp',
|
|
56
|
+
'image/gif',
|
|
57
|
+
'image/jpeg',
|
|
58
|
+
'image/jpg',
|
|
59
|
+
'image/png',
|
|
60
|
+
'image/webp',
|
|
61
|
+
]);
|
|
62
|
+
const REMOTE_IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jfif', '.jpg', '.jpeg', '.png', '.webp']);
|
|
49
63
|
const REMOTE_VOICE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
50
64
|
const REMOTE_VOICE_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
|
51
65
|
const REMOTE_VOICE_MIME_TYPES = new Set([
|
|
@@ -61,6 +75,68 @@ const REMOTE_VOICE_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg',
|
|
|
61
75
|
const ASSET_PLATFORM_CODES = ['JIMENG', 'BYTEPLUS'];
|
|
62
76
|
const ASSET_PLATFORM_CODE_SET = new Set(ASSET_PLATFORM_CODES);
|
|
63
77
|
const ASSET_PLATFORM_HINT = ASSET_PLATFORM_CODES.join('|');
|
|
78
|
+
const ASSET_TYPE_CODES = Object.freeze({
|
|
79
|
+
Image: 'Image',
|
|
80
|
+
Video: 'Video',
|
|
81
|
+
Audio: 'Audio',
|
|
82
|
+
});
|
|
83
|
+
const ASSET_IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.heic', '.heif', '.jpg', '.jpeg', '.png', '.tif', '.tiff', '.webp']);
|
|
84
|
+
const ASSET_VIDEO_EXTENSIONS = new Set(['.mp4', '.mov']);
|
|
85
|
+
const ASSET_AUDIO_EXTENSIONS = new Set(['.mp3', '.wav']);
|
|
86
|
+
const ASSET_IMAGE_MAX_BYTES = 30 * 1024 * 1024;
|
|
87
|
+
const ASSET_VIDEO_MAX_BYTES = 50 * 1024 * 1024;
|
|
88
|
+
const ASSET_MEDIA_MIN_ASPECT = 0.4;
|
|
89
|
+
const ASSET_MEDIA_MAX_ASPECT = 2.5;
|
|
90
|
+
const ASSET_MEDIA_MIN_DIMENSION = 300;
|
|
91
|
+
const ASSET_MEDIA_MAX_DIMENSION = 6000;
|
|
92
|
+
const ASSET_VIDEO_MIN_SECONDS = 2;
|
|
93
|
+
const ASSET_VIDEO_MAX_SECONDS = 15;
|
|
94
|
+
const ASSET_VIDEO_MIN_FPS = 24;
|
|
95
|
+
const ASSET_VIDEO_MAX_FPS = 60;
|
|
96
|
+
const ASSET_VIDEO_MIN_PIXELS = 640 * 640;
|
|
97
|
+
const ASSET_VIDEO_MAX_PIXELS = 834 * 1112;
|
|
98
|
+
const ASSET_AUDIO_MIN_SECONDS = 2;
|
|
99
|
+
const ASSET_AUDIO_MAX_SECONDS = 15;
|
|
100
|
+
let ffprobeCommandCache = null;
|
|
101
|
+
let ffmpegCommandCache = null;
|
|
102
|
+
const PLATFORM_BACKEND_PATH_PREFIXES = [
|
|
103
|
+
'material/',
|
|
104
|
+
'material-image-draw/',
|
|
105
|
+
'material-image-edit/',
|
|
106
|
+
'material-video-create/',
|
|
107
|
+
'default/workbench/',
|
|
108
|
+
'asset-review/',
|
|
109
|
+
];
|
|
110
|
+
const CREATE_STANDARD_KWARG_KEYS = new Set([
|
|
111
|
+
'modelGroupCode',
|
|
112
|
+
'projectGroupNo',
|
|
113
|
+
'customBizId',
|
|
114
|
+
'prompt',
|
|
115
|
+
'ratio',
|
|
116
|
+
'quality',
|
|
117
|
+
'generateNum',
|
|
118
|
+
'generate_num',
|
|
119
|
+
'duration',
|
|
120
|
+
'needAudio',
|
|
121
|
+
'need_audio',
|
|
122
|
+
'resource',
|
|
123
|
+
'resources',
|
|
124
|
+
'resourcesJson',
|
|
125
|
+
'resources_json',
|
|
126
|
+
'modelParam',
|
|
127
|
+
'model_param',
|
|
128
|
+
'modelParams',
|
|
129
|
+
'model_params',
|
|
130
|
+
'modelParamsJson',
|
|
131
|
+
'model_params_json',
|
|
132
|
+
'taskRecordFile',
|
|
133
|
+
'waitSeconds',
|
|
134
|
+
'pollIntervalMs',
|
|
135
|
+
'inputFile',
|
|
136
|
+
'concurrency',
|
|
137
|
+
'dryRun',
|
|
138
|
+
'yes',
|
|
139
|
+
]);
|
|
64
140
|
|
|
65
141
|
export function normalizeUserInfo(payload) {
|
|
66
142
|
const data = payload && typeof payload === 'object' ? payload : {};
|
|
@@ -116,6 +192,139 @@ export function ensureConfirmed(kwargs, message, details = {}) {
|
|
|
116
192
|
});
|
|
117
193
|
}
|
|
118
194
|
|
|
195
|
+
const LOGIN_FLOW_STATUS = Object.freeze({ PENDING: 0, SUCCESS: 1, EXPIRED: 2, CANCELED: 3 });
|
|
196
|
+
|
|
197
|
+
function renderLoginFlowPrompt({ verifyUrl, flowId, resumed }) {
|
|
198
|
+
const lines = [''];
|
|
199
|
+
if (resumed) {
|
|
200
|
+
lines.push(`继续轮询已有登录任务(flow_id=${flowId})...`);
|
|
201
|
+
} else {
|
|
202
|
+
lines.push('在浏览器中打开以下链接完成登录授权:', '', verifyUrl || `(flow_id=${flowId})`, '');
|
|
203
|
+
lines.push('[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成登录授权。请确保 runner 的 timeout >= 600s。');
|
|
204
|
+
lines.push('若当前工具无法实时展示命令输出,请使用 "lj-awb auth login --no-wait --json" 获取 flow_id 和 verify_url。');
|
|
205
|
+
lines.push('用户完成授权后,再执行 "lj-awb auth login --flow-id <flow_id>" 继续轮询。');
|
|
206
|
+
lines.push('不要重复执行新的 login 命令,否则会生成新的 flow_id,导致旧授权链接失效。');
|
|
207
|
+
}
|
|
208
|
+
lines.push('', '等待用户授权...', '');
|
|
209
|
+
process.stderr.write(lines.join('\n'));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function saveVerifiedAccessKey(accessKey, { skipVerify = false, loginMethod } = {}) {
|
|
213
|
+
let user = null;
|
|
214
|
+
if (!skipVerify) {
|
|
215
|
+
process.env.LINGJING_AWB_ACCESS_KEY = accessKey;
|
|
216
|
+
user = normalizeUserInfo(await awbApi.fetchUserInfo());
|
|
217
|
+
}
|
|
218
|
+
const { auth } = await saveAccessKey(accessKey, { verified: !skipVerify });
|
|
219
|
+
return {
|
|
220
|
+
loginMethod,
|
|
221
|
+
status: 'success',
|
|
222
|
+
saved: true,
|
|
223
|
+
verified: !skipVerify,
|
|
224
|
+
auth: summarizeAuth(auth, { accessKey, source: 'saved', sourceName: 'auth' }),
|
|
225
|
+
accessKey: maskSecret(accessKey),
|
|
226
|
+
user,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function authLogin(kwargs = {}) {
|
|
231
|
+
const skipVerify = toBool(kwargs.skipVerify);
|
|
232
|
+
const directAccessKey = trimSecret(kwargs.accessKey || process.env.LINGJING_AWB_ACCESS_KEY || '');
|
|
233
|
+
|
|
234
|
+
// 路径一:显式 --access-key 或环境变量 —— 保持原有「直接校验并保存」逻辑
|
|
235
|
+
if (directAccessKey) {
|
|
236
|
+
if (toBool(kwargs.dryRun)) {
|
|
237
|
+
return { dryRun: true, loginMethod: 'access_key', saved: false, verified: false, accessKey: maskSecret(directAccessKey) };
|
|
238
|
+
}
|
|
239
|
+
return await saveVerifiedAccessKey(directAccessKey, { skipVerify, loginMethod: 'access_key' });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 路径二/三:浏览器授权登录流程
|
|
243
|
+
if (toBool(kwargs.dryRun)) {
|
|
244
|
+
return { dryRun: true, loginMethod: 'flow', saved: false, verified: false };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const explicitFlowId = trimToNull(kwargs.flowId);
|
|
248
|
+
let flowId = explicitFlowId;
|
|
249
|
+
let verifyUrl = null;
|
|
250
|
+
if (!flowId) {
|
|
251
|
+
const created = await awbApi.createLoginFlow();
|
|
252
|
+
flowId = trimToNull(created?.flowId);
|
|
253
|
+
verifyUrl = trimToNull(created?.verifyUrl);
|
|
254
|
+
if (!flowId) {
|
|
255
|
+
throw new LingjingAwbCliError('创建登录任务失败:未返回 flowId', {
|
|
256
|
+
type: 'auth_flow_failed',
|
|
257
|
+
exitCode: 3,
|
|
258
|
+
hint: '稍后重试 lj-awb auth login;若持续失败请联系平台。',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (toBool(kwargs.noWait)) {
|
|
264
|
+
return {
|
|
265
|
+
loginMethod: 'flow',
|
|
266
|
+
status: 'pending',
|
|
267
|
+
saved: false,
|
|
268
|
+
verified: false,
|
|
269
|
+
flowId,
|
|
270
|
+
verifyUrl,
|
|
271
|
+
waited: false,
|
|
272
|
+
nextCommand: `lj-awb auth login --flow-id ${flowId}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const waitSeconds = Math.max(0, toInt(kwargs.waitSeconds, 600));
|
|
277
|
+
const pollIntervalMs = Math.max(1000, toInt(kwargs.pollIntervalMs, 3000));
|
|
278
|
+
renderLoginFlowPrompt({ verifyUrl, flowId, resumed: Boolean(explicitFlowId) });
|
|
279
|
+
|
|
280
|
+
const deadline = Date.now() + waitSeconds * 1000;
|
|
281
|
+
while (true) {
|
|
282
|
+
const statusData = await awbApi.queryLoginFlowStatus(flowId);
|
|
283
|
+
const status = toInt(statusData?.status, LOGIN_FLOW_STATUS.PENDING);
|
|
284
|
+
if (status === LOGIN_FLOW_STATUS.SUCCESS) {
|
|
285
|
+
const accessKey = trimSecret(statusData?.accessKey);
|
|
286
|
+
if (!accessKey) {
|
|
287
|
+
throw new LingjingAwbCliError('授权成功但未返回 accessKey', {
|
|
288
|
+
type: 'auth_flow_failed',
|
|
289
|
+
exitCode: 3,
|
|
290
|
+
details: { flowId },
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return { ...(await saveVerifiedAccessKey(accessKey, { skipVerify, loginMethod: 'flow' })), flowId };
|
|
294
|
+
}
|
|
295
|
+
if (status === LOGIN_FLOW_STATUS.EXPIRED) {
|
|
296
|
+
throw new LingjingAwbCliError('登录授权链接已过期', {
|
|
297
|
+
type: 'auth_flow_expired',
|
|
298
|
+
exitCode: 3,
|
|
299
|
+
hint: '重新运行 lj-awb auth login 获取新的授权链接。',
|
|
300
|
+
details: { flowId },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (status === LOGIN_FLOW_STATUS.CANCELED) {
|
|
304
|
+
throw new LingjingAwbCliError('登录授权已被取消', {
|
|
305
|
+
type: 'auth_flow_canceled',
|
|
306
|
+
exitCode: 3,
|
|
307
|
+
hint: '重新运行 lj-awb auth login 重新发起登录授权。',
|
|
308
|
+
details: { flowId },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (Date.now() >= deadline || waitSeconds === 0) break;
|
|
312
|
+
await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
throw new LingjingAwbCliError('等待登录授权超时,本轮等待窗口已结束', {
|
|
316
|
+
type: 'auth_flow_pending',
|
|
317
|
+
exitCode: 20,
|
|
318
|
+
hint: `用户在浏览器完成授权后,运行 lj-awb auth login --flow-id ${flowId} 继续轮询;不要重新发起新的 login。`,
|
|
319
|
+
details: { flowId, verifyUrl, waitSeconds },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function authLogout() {
|
|
324
|
+
await clearAuth();
|
|
325
|
+
return { loggedOut: true, authPath: AUTH_PATH };
|
|
326
|
+
}
|
|
327
|
+
|
|
119
328
|
function envProjectGroupNo() {
|
|
120
329
|
return trimToNull(
|
|
121
330
|
process.env.LINGJING_AWB_PROJECT_GROUP_NO
|
|
@@ -680,6 +889,31 @@ function modelListMetadata(kind, paramKeys = [], rulesByKey = new Map()) {
|
|
|
680
889
|
};
|
|
681
890
|
}
|
|
682
891
|
|
|
892
|
+
function normalizeRateValue(value) {
|
|
893
|
+
if (value === undefined || value === null || value === '') return null;
|
|
894
|
+
const text = String(value).trim();
|
|
895
|
+
const isPercent = text.endsWith('%');
|
|
896
|
+
const parsed = Number(isPercent ? text.slice(0, -1) : text);
|
|
897
|
+
if (!Number.isFinite(parsed)) return null;
|
|
898
|
+
const rate = isPercent || parsed > 1 ? parsed / 100 : parsed;
|
|
899
|
+
if (rate < 0 || rate > 1) return null;
|
|
900
|
+
return rate;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function modelSuccessRate(item = {}) {
|
|
904
|
+
const ext = item?.modelExtInfo || {};
|
|
905
|
+
return normalizeRateValue(
|
|
906
|
+
item?.successRate
|
|
907
|
+
?? item?.success_rate
|
|
908
|
+
?? item?.successRatio
|
|
909
|
+
?? item?.success_ratio
|
|
910
|
+
?? ext?.successRate
|
|
911
|
+
?? ext?.success_rate
|
|
912
|
+
?? ext?.successRatio
|
|
913
|
+
?? ext?.success_ratio,
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
683
917
|
function normalizeModelRows(payload, kind, options = {}) {
|
|
684
918
|
const includeRaw = Boolean(options.includeRaw);
|
|
685
919
|
const includeInternal = Boolean(options.includeInternal);
|
|
@@ -702,6 +936,7 @@ function normalizeModelRows(payload, kind, options = {}) {
|
|
|
702
936
|
enabled: item?.enabled ?? item?.available ?? item?.status ?? null,
|
|
703
937
|
modelStatus: item?.modelStatus ?? null,
|
|
704
938
|
taskQueueNum: item?.taskQueueNum ?? null,
|
|
939
|
+
successRate: modelSuccessRate(item),
|
|
705
940
|
feeCalcType: item?.feeCalcType ?? item?.feeType ?? null,
|
|
706
941
|
inputModes: metadata.inputModes,
|
|
707
942
|
params: metadata.controls,
|
|
@@ -917,6 +1152,68 @@ function normalizeModelConstraints(constraintSchema = [], taskKind = 'image') {
|
|
|
917
1152
|
}).filter((constraint) => constraint.target || constraint.targetConfigCode);
|
|
918
1153
|
}
|
|
919
1154
|
|
|
1155
|
+
function resourceConstraintTarget(mediaType, usage = 'reference') {
|
|
1156
|
+
const media = String(mediaType || '').toLowerCase();
|
|
1157
|
+
const normalizedUsage = String(usage || 'reference');
|
|
1158
|
+
return media ? `resource.${media}.${normalizedUsage}` : '';
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function countRangeValue(minCount, maxCount) {
|
|
1162
|
+
if (minCount != null && maxCount != null && minCount === maxCount) return String(minCount);
|
|
1163
|
+
if (minCount != null && maxCount != null) return `${minCount}..${maxCount}`;
|
|
1164
|
+
if (minCount != null) return `>=${minCount}`;
|
|
1165
|
+
if (maxCount != null) return `<=${maxCount}`;
|
|
1166
|
+
return undefined;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function normalizeLimitProfileCondition(condition = {}) {
|
|
1170
|
+
const mediaType = String(condition?.mediaType || '').toUpperCase();
|
|
1171
|
+
const usage = condition?.usage || 'reference';
|
|
1172
|
+
const minCount = ruleNumber(condition?.minCount);
|
|
1173
|
+
const maxCount = ruleNumber(condition?.maxCount);
|
|
1174
|
+
const target = resourceConstraintTarget(mediaType, usage);
|
|
1175
|
+
return compactRecord({
|
|
1176
|
+
key: target ? `${target}.count` : undefined,
|
|
1177
|
+
mediaType,
|
|
1178
|
+
usage,
|
|
1179
|
+
minCount,
|
|
1180
|
+
maxCount,
|
|
1181
|
+
value: countRangeValue(minCount, maxCount),
|
|
1182
|
+
meaning: 'count',
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function normalizeResourceLimitProfileConstraints(options = []) {
|
|
1187
|
+
const constraints = [];
|
|
1188
|
+
for (const option of Array.isArray(options) ? options : []) {
|
|
1189
|
+
const profiles = Array.isArray(option?.rules?.limitProfiles) ? option.rules.limitProfiles : [];
|
|
1190
|
+
for (const [profileIndex, profile] of profiles.entries()) {
|
|
1191
|
+
const condition = normalizeLimitProfileCondition(profile?.condition);
|
|
1192
|
+
const overrides = Array.isArray(profile?.overrides) ? profile.overrides : [];
|
|
1193
|
+
for (const [overrideIndex, override] of overrides.entries()) {
|
|
1194
|
+
const mediaType = String(override?.mediaType || '').toUpperCase();
|
|
1195
|
+
if (!mediaType) continue;
|
|
1196
|
+
const usage = override?.usage || condition.usage || 'reference';
|
|
1197
|
+
const limits = compactRecord({
|
|
1198
|
+
usage,
|
|
1199
|
+
...normalizeMediaRule({ ...override, mediaType }),
|
|
1200
|
+
});
|
|
1201
|
+
constraints.push(compactRecord({
|
|
1202
|
+
id: `${option.paramKey || 'resource'}.limitProfiles[${profileIndex}].overrides[${overrideIndex}]`,
|
|
1203
|
+
name: '素材联动限制',
|
|
1204
|
+
target: resourceConstraintTarget(mediaType, usage),
|
|
1205
|
+
targetConfigCode: option.paramKey,
|
|
1206
|
+
effect: 'resource_limit_overrides',
|
|
1207
|
+
priority: option.rank,
|
|
1208
|
+
conditions: condition.key ? [condition] : [],
|
|
1209
|
+
limits,
|
|
1210
|
+
}));
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return constraints;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
920
1217
|
function resourceUsageList(usage) {
|
|
921
1218
|
return (Array.isArray(usage) ? usage : [usage])
|
|
922
1219
|
.map((item) => trimToNull(item))
|
|
@@ -959,6 +1256,8 @@ function sourceAllowedByRule(rule = {}, sourceKind) {
|
|
|
959
1256
|
function resourceFormatForValidation(detail = {}) {
|
|
960
1257
|
if (detail.localFile?.format) return String(detail.localFile.format).toLowerCase();
|
|
961
1258
|
const value = detail.resource?.source?.value;
|
|
1259
|
+
const transformedFormat = imageTransformFormat(value);
|
|
1260
|
+
if (transformedFormat) return transformedFormat;
|
|
962
1261
|
const pathname = (() => {
|
|
963
1262
|
try {
|
|
964
1263
|
return new URL(String(value)).pathname;
|
|
@@ -970,6 +1269,34 @@ function resourceFormatForValidation(detail = {}) {
|
|
|
970
1269
|
return ext || null;
|
|
971
1270
|
}
|
|
972
1271
|
|
|
1272
|
+
function imageTransformFormat(value) {
|
|
1273
|
+
const text = String(value || '');
|
|
1274
|
+
if (!text.includes('?') && !text.includes('%2F') && !text.includes('/format/')) return null;
|
|
1275
|
+
const queryText = (() => {
|
|
1276
|
+
try {
|
|
1277
|
+
const url = new URL(text);
|
|
1278
|
+
return url.search || text;
|
|
1279
|
+
} catch {
|
|
1280
|
+
const queryIndex = text.indexOf('?');
|
|
1281
|
+
return queryIndex >= 0 ? text.slice(queryIndex) : text;
|
|
1282
|
+
}
|
|
1283
|
+
})();
|
|
1284
|
+
let decoded = queryText;
|
|
1285
|
+
try {
|
|
1286
|
+
decoded = decodeURIComponent(queryText);
|
|
1287
|
+
} catch {}
|
|
1288
|
+
for (const pattern of [
|
|
1289
|
+
/image(?:mogr2|view2)[^&#?]*?\/format\/([A-Za-z0-9]+)/i,
|
|
1290
|
+
/x-oss-process=image[^&#?]*?format,([A-Za-z0-9]+)/i,
|
|
1291
|
+
/(?:^|[?&#])format=([A-Za-z0-9]+)/i,
|
|
1292
|
+
]) {
|
|
1293
|
+
const match = decoded.match(pattern);
|
|
1294
|
+
const format = normalizeFileFormat(match?.[1]);
|
|
1295
|
+
if (format) return format;
|
|
1296
|
+
}
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
973
1300
|
function normalizeFileFormat(value) {
|
|
974
1301
|
const format = trimToNull(value)?.toLowerCase();
|
|
975
1302
|
if (!format) return null;
|
|
@@ -1034,6 +1361,15 @@ function isConvertibleLocalImage(detail = {}) {
|
|
|
1034
1361
|
return Boolean(detail.localFile?.exists && String(detail.localFile?.mimeType || '').startsWith('image/'));
|
|
1035
1362
|
}
|
|
1036
1363
|
|
|
1364
|
+
function isDownloadableImageSource(detail = {}) {
|
|
1365
|
+
const sourceKind = resourceSourceKindForValidation(detail);
|
|
1366
|
+
return detail.resource?.source?.kind === 'url' && ['http_url', 'backendPath'].includes(sourceKind);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function isConvertibleImageResource(detail = {}) {
|
|
1370
|
+
return isConvertibleLocalImage(detail) || isDownloadableImageSource(detail);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1037
1373
|
function resourceFormatConversion(rule = {}, detail = {}) {
|
|
1038
1374
|
if (!isImageRule(rule)) return null;
|
|
1039
1375
|
const format = normalizeFileFormat(resourceFormatForValidation(detail));
|
|
@@ -1045,7 +1381,7 @@ function resourceFormatConversion(rule = {}, detail = {}) {
|
|
|
1045
1381
|
fromFormat: format,
|
|
1046
1382
|
toFormat: policy.autoConvertTo,
|
|
1047
1383
|
reason: 'image_webp_not_supported',
|
|
1048
|
-
possible:
|
|
1384
|
+
possible: isConvertibleImageResource(detail),
|
|
1049
1385
|
};
|
|
1050
1386
|
}
|
|
1051
1387
|
if (policy.kind === 'normal_plus_webp') return null;
|
|
@@ -1054,7 +1390,7 @@ function resourceFormatConversion(rule = {}, detail = {}) {
|
|
|
1054
1390
|
fromFormat: format,
|
|
1055
1391
|
toFormat: policy.autoConvertTo,
|
|
1056
1392
|
reason: 'image_strict_format_mismatch',
|
|
1057
|
-
possible:
|
|
1393
|
+
possible: isConvertibleImageResource(detail),
|
|
1058
1394
|
allowed: policy.allowed,
|
|
1059
1395
|
};
|
|
1060
1396
|
}
|
|
@@ -1094,7 +1430,7 @@ function assertResourceShapeAgainstModel(resourceDetails = [], resourceRules = [
|
|
|
1094
1430
|
if (conversion && !conversion.possible) {
|
|
1095
1431
|
throw argumentError(
|
|
1096
1432
|
`素材格式需要转换但无法自动处理:${resourceText(resource)}`,
|
|
1097
|
-
`当前格式 ${conversion.fromFormat},目标格式 ${conversion.toFormat}
|
|
1433
|
+
`当前格式 ${conversion.fromFormat},目标格式 ${conversion.toFormat};请使用本地文件、http(s) URL 或平台 backendPath,asset 资源需先转换后再传。`,
|
|
1098
1434
|
);
|
|
1099
1435
|
}
|
|
1100
1436
|
if (!conversion && !fileTypeAllowedByRule(rule, format)) {
|
|
@@ -1161,6 +1497,58 @@ function assertResourceCountsAgainstModel(resourceDetails = [], resourceRules =
|
|
|
1161
1497
|
}
|
|
1162
1498
|
}
|
|
1163
1499
|
|
|
1500
|
+
function resourceCountForCondition(resourceDetails = [], condition = {}) {
|
|
1501
|
+
return resourceDetails.filter((detail) => {
|
|
1502
|
+
const resource = detail.resource || {};
|
|
1503
|
+
if (condition.mediaType && resourceMediaType(resource) !== String(condition.mediaType).toUpperCase()) return false;
|
|
1504
|
+
if (condition.usage && resource.usage !== condition.usage) return false;
|
|
1505
|
+
return true;
|
|
1506
|
+
}).length;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function resourceLimitConditionMatches(resourceDetails = [], condition = {}) {
|
|
1510
|
+
if (condition.meaning !== 'count') return false;
|
|
1511
|
+
const count = resourceCountForCondition(resourceDetails, condition);
|
|
1512
|
+
if (condition.minCount != null && count < condition.minCount) return false;
|
|
1513
|
+
if (condition.maxCount != null && count > condition.maxCount) return false;
|
|
1514
|
+
return true;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function validationResourceLimitOverrides(limits = {}) {
|
|
1518
|
+
const validatesResourceDuration = resourceUsageList(limits.usage).includes('keyframe');
|
|
1519
|
+
return compactRecord({
|
|
1520
|
+
mediaType: limits.mediaType,
|
|
1521
|
+
usage: limits.usage,
|
|
1522
|
+
fileTypes: limits.fileTypes,
|
|
1523
|
+
minFiles: limits.minFiles,
|
|
1524
|
+
maxFiles: limits.maxFiles,
|
|
1525
|
+
maxSizeKB: limits.maxSizeKB,
|
|
1526
|
+
minItems: limits.minItems,
|
|
1527
|
+
maxItems: limits.maxItems,
|
|
1528
|
+
supportLastFrameOnly: limits.supportLastFrameOnly,
|
|
1529
|
+
minDurationMs: validatesResourceDuration ? limits.minDurationMs : undefined,
|
|
1530
|
+
maxDurationMs: validatesResourceDuration ? limits.maxDurationMs : undefined,
|
|
1531
|
+
maxPromptLength: validatesResourceDuration ? limits.maxPromptLength : undefined,
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function applyResourceLimitProfileConstraints(resourceRules = [], constraints = [], resourceDetails = []) {
|
|
1536
|
+
const effective = resourceRules.map((rule) => ({ ...rule }));
|
|
1537
|
+
const profiles = constraints.filter((constraint) => constraint.effect === 'resource_limit_overrides' && constraint.limits);
|
|
1538
|
+
for (const profile of profiles) {
|
|
1539
|
+
const conditions = Array.isArray(profile.conditions) ? profile.conditions : [];
|
|
1540
|
+
if (conditions.length && !conditions.every((condition) => resourceLimitConditionMatches(resourceDetails, condition))) continue;
|
|
1541
|
+
const validationLimits = validationResourceLimitOverrides(profile.limits);
|
|
1542
|
+
if (!Object.keys(validationLimits).some((key) => !['mediaType', 'usage'].includes(key))) continue;
|
|
1543
|
+
const index = effective.findIndex((rule) => (
|
|
1544
|
+
String(rule.mediaType || '').toUpperCase() === String(validationLimits.mediaType || '').toUpperCase()
|
|
1545
|
+
&& resourceUsageList(rule.usage).includes(validationLimits.usage || 'reference')
|
|
1546
|
+
));
|
|
1547
|
+
if (index >= 0) effective[index] = compactRecord({ ...effective[index], ...validationLimits });
|
|
1548
|
+
}
|
|
1549
|
+
return effective;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1164
1552
|
function promptParamValue(promptParams = {}, key) {
|
|
1165
1553
|
if (key === 'duration') return promptParams.duration;
|
|
1166
1554
|
if (key === 'generateNum') return promptParams.generate_num;
|
|
@@ -1169,12 +1557,16 @@ function promptParamValue(promptParams = {}, key) {
|
|
|
1169
1557
|
}
|
|
1170
1558
|
|
|
1171
1559
|
function providedPromptParamKeys(promptParams = {}) {
|
|
1172
|
-
const keys =
|
|
1560
|
+
const keys = new Set();
|
|
1173
1561
|
for (const key of ['prompt', 'ratio', 'quality', 'duration', 'generateNum', 'needAudio']) {
|
|
1174
1562
|
const value = promptParamValue(promptParams, key);
|
|
1175
|
-
if (value !== undefined && value !== null && value !== '') keys.
|
|
1563
|
+
if (value !== undefined && value !== null && value !== '') keys.add(key);
|
|
1176
1564
|
}
|
|
1177
|
-
|
|
1565
|
+
for (const [key, value] of Object.entries(promptParams || {})) {
|
|
1566
|
+
if (key === 'resources' || key === 'generate_num' || key === 'need_audio') continue;
|
|
1567
|
+
if (value !== undefined && value !== null && value !== '') keys.add(key);
|
|
1568
|
+
}
|
|
1569
|
+
return [...keys];
|
|
1178
1570
|
}
|
|
1179
1571
|
|
|
1180
1572
|
function inferGeneratedMode(resources = []) {
|
|
@@ -1239,9 +1631,10 @@ async function validateCreateRequestAgainstModel(kind, modelGroupCode, promptPar
|
|
|
1239
1631
|
}
|
|
1240
1632
|
const params = modelOptionParams(context.options, context.taskKind);
|
|
1241
1633
|
const resources = modelOptionsResources(context.inputModes);
|
|
1634
|
+
const effectiveResources = applyResourceLimitProfileConstraints(resources, context.constraints, resourceDetails);
|
|
1242
1635
|
assertParamsAgainstModel(promptParams, params, context.constraints);
|
|
1243
|
-
assertResourceShapeAgainstModel(resourceDetails,
|
|
1244
|
-
assertResourceCountsAgainstModel(resourceDetails,
|
|
1636
|
+
assertResourceShapeAgainstModel(resourceDetails, effectiveResources);
|
|
1637
|
+
assertResourceCountsAgainstModel(resourceDetails, effectiveResources);
|
|
1245
1638
|
if (kind === 'video') {
|
|
1246
1639
|
const supportedIntents = createSpecSupportedIntents(context.inputModes, context.taskKind);
|
|
1247
1640
|
const inputRequirement = createSpecInputRequirement(context.taskKind, context.inputModes, supportedIntents);
|
|
@@ -1274,12 +1667,16 @@ async function loadModelOptionContext(modelGroupCode, options = {}) {
|
|
|
1274
1667
|
const constraintSchema = options.includeConstraintSchema
|
|
1275
1668
|
? await fetchModelConstraintSchema(rawModel, modelGroupCode)
|
|
1276
1669
|
: [];
|
|
1670
|
+
const constraints = [
|
|
1671
|
+
...normalizeModelConstraints(constraintSchema, taskKind),
|
|
1672
|
+
...normalizeResourceLimitProfileConstraints(mergedOptions),
|
|
1673
|
+
];
|
|
1277
1674
|
return {
|
|
1278
1675
|
rawModel,
|
|
1279
1676
|
model: modelSummaryFromRaw(rawModel, modelGroupCode, taskKind),
|
|
1280
1677
|
options: mergedOptions,
|
|
1281
1678
|
constraintSchema,
|
|
1282
|
-
constraints
|
|
1679
|
+
constraints,
|
|
1283
1680
|
optionKeys,
|
|
1284
1681
|
rulesByKey,
|
|
1285
1682
|
taskKind,
|
|
@@ -1367,7 +1764,7 @@ function mergeResourceLimits(base = {}, overrides = {}) {
|
|
|
1367
1764
|
function mergeModelParamRules(options = [], rawModel = null) {
|
|
1368
1765
|
const rawParams = Array.isArray(rawModel?.modelParams) ? rawModel.modelParams : [];
|
|
1369
1766
|
const rawByKey = new Map(rawParams.map((item) => [item?.paramKey, item]).filter(([key]) => key));
|
|
1370
|
-
|
|
1767
|
+
const merged = options.map((option) => {
|
|
1371
1768
|
const raw = rawByKey.get(option.paramKey);
|
|
1372
1769
|
return {
|
|
1373
1770
|
...option,
|
|
@@ -1375,6 +1772,62 @@ function mergeModelParamRules(options = [], rawModel = null) {
|
|
|
1375
1772
|
rules: option.rules ?? raw?.rules ?? null,
|
|
1376
1773
|
};
|
|
1377
1774
|
});
|
|
1775
|
+
const mergedKeys = new Set(merged.map((item) => item.paramKey).filter(Boolean));
|
|
1776
|
+
for (const raw of rawParams) {
|
|
1777
|
+
if (!raw?.paramKey || mergedKeys.has(raw.paramKey)) continue;
|
|
1778
|
+
merged.push(raw);
|
|
1779
|
+
}
|
|
1780
|
+
return merged;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function modelParamDefaultValue(option = {}) {
|
|
1784
|
+
return option.rules?.defaultValue
|
|
1785
|
+
?? option.defaultValue
|
|
1786
|
+
?? null;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function modelParamDefaultName(option = {}) {
|
|
1790
|
+
return option.rules?.defaultName
|
|
1791
|
+
?? option.defaultName
|
|
1792
|
+
?? null;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
function modelParamValueSource(option = {}) {
|
|
1796
|
+
const allowedValues = optionAllowedValues(option);
|
|
1797
|
+
if (allowedValues.length) return '只能从 allowedValues 选择。';
|
|
1798
|
+
if (modelParamDefaultValue(option) != null) return '模型配置提供 defaultValue;这是候选默认值,不代表可以静默代选。';
|
|
1799
|
+
return '来自实时 model options 的通用模型配置参数;用户明确选择后通过 --model-param key=value 或 --model-params-json 传入。';
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function isGenericModelConfigParam(option = {}) {
|
|
1803
|
+
const key = trimToNull(option.paramKey);
|
|
1804
|
+
if (!key) return false;
|
|
1805
|
+
const internalResourceKeys = new Set([
|
|
1806
|
+
'generated_mode',
|
|
1807
|
+
'resources',
|
|
1808
|
+
'iref',
|
|
1809
|
+
'cref',
|
|
1810
|
+
'sref',
|
|
1811
|
+
'frames',
|
|
1812
|
+
'multi_param',
|
|
1813
|
+
'multi_prompt',
|
|
1814
|
+
'subject',
|
|
1815
|
+
'subject_reference',
|
|
1816
|
+
'reference_image',
|
|
1817
|
+
'reference_audio',
|
|
1818
|
+
'reference_video',
|
|
1819
|
+
'first_frame',
|
|
1820
|
+
'first_last_frame',
|
|
1821
|
+
'last_frame_only',
|
|
1822
|
+
'storyboard',
|
|
1823
|
+
]);
|
|
1824
|
+
if (internalResourceKeys.has(key)) return false;
|
|
1825
|
+
const paramType = String(option.paramType || '');
|
|
1826
|
+
return Boolean(
|
|
1827
|
+
optionAllowedValues(option).length
|
|
1828
|
+
|| modelParamDefaultValue(option) != null
|
|
1829
|
+
|| /Enum|Boolean|Bool|Number|Integer|Float|Double|String|Text|Prompt/i.test(paramType),
|
|
1830
|
+
);
|
|
1378
1831
|
}
|
|
1379
1832
|
|
|
1380
1833
|
function createParamForModelOption(option = {}, taskKind = 'image') {
|
|
@@ -1451,7 +1904,7 @@ function createParamForModelOption(option = {}, taskKind = 'image') {
|
|
|
1451
1904
|
requestPath: 'promptParams.resources[]',
|
|
1452
1905
|
materialLegacyKey: 'iref',
|
|
1453
1906
|
meaning: '图片任务的参考图输入。',
|
|
1454
|
-
valueSource: '本地文件、http(s) URL
|
|
1907
|
+
valueSource: '本地文件、http(s) URL 或平台 backendPath。',
|
|
1455
1908
|
resourceSyntax: ['image:reference=./ref.png'],
|
|
1456
1909
|
},
|
|
1457
1910
|
resources: {
|
|
@@ -1460,7 +1913,7 @@ function createParamForModelOption(option = {}, taskKind = 'image') {
|
|
|
1460
1913
|
requestPath: 'promptParams.resources[]',
|
|
1461
1914
|
materialLegacyKey: 'resources',
|
|
1462
1915
|
meaning: '模型声明的素材输入;CLI 统一用 resources 表达。',
|
|
1463
|
-
valueSource: '本地文件、http(s) URL
|
|
1916
|
+
valueSource: '本地文件、http(s) URL 或平台 backendPath。',
|
|
1464
1917
|
resourceSyntax: taskKind === 'image' ? ['image:reference=./ref.png'] : ['image:first_frame=./first.png'],
|
|
1465
1918
|
},
|
|
1466
1919
|
frames: {
|
|
@@ -1469,7 +1922,7 @@ function createParamForModelOption(option = {}, taskKind = 'image') {
|
|
|
1469
1922
|
requestPath: 'promptParams.resources[]',
|
|
1470
1923
|
materialLegacyKey: 'frames',
|
|
1471
1924
|
meaning: '视频首帧 / 尾帧 / 关键帧输入。',
|
|
1472
|
-
valueSource: '首帧/尾帧可用本地文件、http(s) URL
|
|
1925
|
+
valueSource: '首帧/尾帧可用本地文件、http(s) URL、平台 backendPath 或 asset:<assetId>;keyframe 仅用文件/URL/backendPath。',
|
|
1473
1926
|
resourceSyntax: ['image:first_frame=./first.png', 'image:first_frame=asset:<assetId>', 'image:last_frame=asset:<assetId>', 'image:keyframe#1=./key1.png'],
|
|
1474
1927
|
},
|
|
1475
1928
|
multi_param: {
|
|
@@ -1510,15 +1963,26 @@ function createParamForModelOption(option = {}, taskKind = 'image') {
|
|
|
1510
1963
|
},
|
|
1511
1964
|
};
|
|
1512
1965
|
return {
|
|
1513
|
-
...(definitions[key]
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1966
|
+
...(definitions[key]
|
|
1967
|
+
?? (isGenericModelConfigParam(option)
|
|
1968
|
+
? {
|
|
1969
|
+
key,
|
|
1970
|
+
cliArg: '--model-param / --model-params-json',
|
|
1971
|
+
requestPath: `promptParams.${key}`,
|
|
1972
|
+
materialLegacyKey: key,
|
|
1973
|
+
meaning: option.paramName ? `模型配置参数:${option.paramName}` : '模型配置参数。',
|
|
1974
|
+
valueSource: modelParamValueSource(option),
|
|
1975
|
+
genericModelParam: true,
|
|
1976
|
+
}
|
|
1977
|
+
: {
|
|
1978
|
+
key,
|
|
1979
|
+
cliArg: null,
|
|
1980
|
+
requestPath: null,
|
|
1981
|
+
materialLegacyKey: key,
|
|
1982
|
+
meaning: '模型配置存在该参数,但当前 CLI 未将其识别为可安全透传的创建参数。',
|
|
1983
|
+
valueSource: '如确实需要,先确认该字段不是资源或旧 handler 内部字段,再扩展通用参数判定。',
|
|
1984
|
+
notExposedByCli: true,
|
|
1985
|
+
})),
|
|
1522
1986
|
...common,
|
|
1523
1987
|
};
|
|
1524
1988
|
}
|
|
@@ -1887,6 +2351,7 @@ function createSpecParameterControls(createParams, inputModes = []) {
|
|
|
1887
2351
|
valueSource: item.valueSource,
|
|
1888
2352
|
controlKind: item.controlKind,
|
|
1889
2353
|
allowedValues: item.allowedValues,
|
|
2354
|
+
genericModelParam: item.genericModelParam,
|
|
1890
2355
|
}));
|
|
1891
2356
|
const resourceParams = createParams
|
|
1892
2357
|
.filter((item) => item.key === 'resources')
|
|
@@ -2008,7 +2473,8 @@ function cliValueType(option = {}, allowedValues = []) {
|
|
|
2008
2473
|
if (allowedValues.length) return 'enum';
|
|
2009
2474
|
if (option.paramType === 'Prompt') return 'text';
|
|
2010
2475
|
if (option.paramType === 'BooleanType') return 'boolean';
|
|
2011
|
-
if (/
|
|
2476
|
+
if (/Enum/i.test(String(option.paramType || ''))) return 'enum';
|
|
2477
|
+
if (/Number|Integer|Float|Double/i.test(String(option.paramType || ''))) return 'number';
|
|
2012
2478
|
return option.paramType || undefined;
|
|
2013
2479
|
}
|
|
2014
2480
|
|
|
@@ -2018,6 +2484,7 @@ function modelOptionParams(options = [], taskKind = 'image') {
|
|
|
2018
2484
|
.filter(({ createParam }) => createParam.cliArg && createParam.key !== 'resources' && !createParam.internalOnly)
|
|
2019
2485
|
.map(({ option, createParam }) => {
|
|
2020
2486
|
const rules = option.rules || {};
|
|
2487
|
+
const defaultValue = modelParamDefaultValue(option);
|
|
2021
2488
|
const allowedValues = optionAllowedValues(option);
|
|
2022
2489
|
const required = option.required === true || rules.required === true ? true : undefined;
|
|
2023
2490
|
const maxLength = option.paramType === 'Prompt'
|
|
@@ -2028,10 +2495,15 @@ function modelOptionParams(options = [], taskKind = 'image') {
|
|
|
2028
2495
|
label: option.paramName,
|
|
2029
2496
|
valueType: cliValueType(option, allowedValues),
|
|
2030
2497
|
values: allowedValues.length ? allowedValues : undefined,
|
|
2031
|
-
defaultValue
|
|
2032
|
-
defaultName:
|
|
2498
|
+
defaultValue,
|
|
2499
|
+
defaultName: modelParamDefaultName(option),
|
|
2033
2500
|
maxLength,
|
|
2034
2501
|
required,
|
|
2502
|
+
cliArg: createParam.cliArg,
|
|
2503
|
+
genericModelParam: createParam.genericModelParam || undefined,
|
|
2504
|
+
modelParamKey: createParam.modelParamKey,
|
|
2505
|
+
modelParamName: createParam.modelParamName,
|
|
2506
|
+
modelParamType: createParam.modelParamType,
|
|
2035
2507
|
});
|
|
2036
2508
|
});
|
|
2037
2509
|
}
|
|
@@ -2073,6 +2545,7 @@ function modelOptionsResources(inputModes = []) {
|
|
|
2073
2545
|
mediaType: 'IMAGE',
|
|
2074
2546
|
usage: supportedFrameMode.optionResourceUsages,
|
|
2075
2547
|
valueShapes: supportedFrameMode.sourceKinds,
|
|
2548
|
+
sources: supportedFrameMode.sourceKinds,
|
|
2076
2549
|
...supportedFrameMode.optionResourceLimits,
|
|
2077
2550
|
formatPolicy: framePolicy.summary,
|
|
2078
2551
|
webpSupported: framePolicy.webpSupported,
|
|
@@ -2090,6 +2563,7 @@ function modelOptionsResources(inputModes = []) {
|
|
|
2090
2563
|
mediaType,
|
|
2091
2564
|
usage: optionResourceUsage(sourceMode),
|
|
2092
2565
|
valueShapes: item.valueShapes,
|
|
2566
|
+
sources: item.valueShapes,
|
|
2093
2567
|
fileTypes: sourceMode === 'subject_reference' ? undefined : item.fileTypes,
|
|
2094
2568
|
formatPolicy: imagePolicy?.summary,
|
|
2095
2569
|
webpSupported: imagePolicy?.webpSupported,
|
|
@@ -2127,7 +2601,7 @@ export function modelInputGuide() {
|
|
|
2127
2601
|
{ field: 'resources[].type', values: ['image', 'video', 'audio', 'subject'], description: '资源本体类型;subject 表示已创建的主体对象。' },
|
|
2128
2602
|
{ field: 'resources[].usage', values: ['first_frame', 'last_frame', 'reference', 'keyframe'], description: '素材用途。' },
|
|
2129
2603
|
{ field: 'resources[].reference_key', values: ['custom string'], description: '仅视频 reference 资源需要占位绑定时使用;图片生图 image:reference 不使用 reference_key。subject reference 必须传。' },
|
|
2130
|
-
{ field: 'resources[].source.kind', values: ['url', 'asset_id'], description: '只接受 url、asset_id 两个枚举值。本地文件、http(s) URL
|
|
2604
|
+
{ field: 'resources[].source.kind', values: ['url', 'asset_id'], description: '只接受 url、asset_id 两个枚举值。本地文件、http(s) URL、平台 backendPath 一律传 kind=url,CLI 按 value 自动识别;asset_id 表示平台资产或主体对象 ID。注意:模型 resources[].valueShapes 列出的 local_file / http_url / backendPath 是 value 形状分类,不是 kind 枚举。' },
|
|
2131
2605
|
{ field: 'resources[].source.value', description: '资源值,必填。url 传素材地址;asset_id 传资源 ID。' },
|
|
2132
2606
|
{ field: 'resources[].order', description: '仅 usage=keyframe 时需要,且同一请求内不能重复。' },
|
|
2133
2607
|
{ field: 'resources[].duration', description: '仅 keyframe 场景下用于表达该帧持续时长,可传小数秒。' },
|
|
@@ -2194,6 +2668,7 @@ export async function modelCreateSpec(kwargs = {}) {
|
|
|
2194
2668
|
inputRequirement,
|
|
2195
2669
|
supportedIntents,
|
|
2196
2670
|
validationRules: createSpecValidationRules(context.taskKind),
|
|
2671
|
+
parameterControls: createSpecParameterControls(context.createParams, context.inputModes),
|
|
2197
2672
|
agentGuidance: createSpecAgentGuidance(context.taskKind, inputRequirement),
|
|
2198
2673
|
preflight: createSpecPreflight(context.taskKind),
|
|
2199
2674
|
examples: createSpecExamples(context.taskKind, supportedIntents),
|
|
@@ -2265,139 +2740,1062 @@ function dryRunBackendPath(filePath, sceneType) {
|
|
|
2265
2740
|
return `/${sceneType}/__dry_run__/${safeFileName(filePath)}`;
|
|
2266
2741
|
}
|
|
2267
2742
|
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
url: null,
|
|
2281
|
-
dryRun: true,
|
|
2282
|
-
}));
|
|
2743
|
+
function mb(bytes) {
|
|
2744
|
+
return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')}MB`;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
function sourceExtension(value) {
|
|
2748
|
+
const text = trimToNull(value);
|
|
2749
|
+
if (!text) return '';
|
|
2750
|
+
if (/^https?:\/\//i.test(text)) {
|
|
2751
|
+
try {
|
|
2752
|
+
return path.extname(decodeURIComponent(new URL(text).pathname)).toLowerCase();
|
|
2753
|
+
} catch {
|
|
2754
|
+
return path.extname(text.split(/[?#]/)[0] || '').toLowerCase();
|
|
2283
2755
|
}
|
|
2284
|
-
return { dryRun: true, files };
|
|
2285
|
-
}
|
|
2286
|
-
const files = [];
|
|
2287
|
-
for (const spec of specs) {
|
|
2288
|
-
files.push(await uploadLocalFile(spec.file, spec));
|
|
2289
2756
|
}
|
|
2290
|
-
return
|
|
2757
|
+
return path.extname(text.split(/[?#]/)[0] || '').toLowerCase();
|
|
2291
2758
|
}
|
|
2292
2759
|
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
if (
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
const
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
authorization,
|
|
2323
|
-
'content-length': String(buffer.length),
|
|
2324
|
-
'content-type': inspected.mimeType || guessMimeType(inspected.filePath),
|
|
2325
|
-
host,
|
|
2326
|
-
'x-cos-security-token': credentials.sessionToken,
|
|
2327
|
-
},
|
|
2328
|
-
body: buffer,
|
|
2329
|
-
});
|
|
2330
|
-
if (!response.ok) {
|
|
2331
|
-
throw new LingjingAwbCliError(`上传 COS 失败:${response.status} ${response.statusText}`, {
|
|
2332
|
-
type: 'upload_failed',
|
|
2333
|
-
exitCode: 30,
|
|
2334
|
-
details: { filePath: inspected.filePath, objectName },
|
|
2335
|
-
});
|
|
2760
|
+
function inferAssetTypeFromExtension(ext) {
|
|
2761
|
+
if (ASSET_IMAGE_EXTENSIONS.has(ext)) return ASSET_TYPE_CODES.Image;
|
|
2762
|
+
if (ASSET_VIDEO_EXTENSIONS.has(ext)) return ASSET_TYPE_CODES.Video;
|
|
2763
|
+
if (ASSET_AUDIO_EXTENSIONS.has(ext)) return ASSET_TYPE_CODES.Audio;
|
|
2764
|
+
return null;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
function inferAssetTypeFromSource(value) {
|
|
2768
|
+
const ext = sourceExtension(value);
|
|
2769
|
+
const assetType = inferAssetTypeFromExtension(ext);
|
|
2770
|
+
if (assetType) return assetType;
|
|
2771
|
+
const suffix = ext || '无扩展名';
|
|
2772
|
+
throw argumentError(
|
|
2773
|
+
`无法判断素材类型:${suffix}`,
|
|
2774
|
+
'素材加白支持图片 jpg/jpeg/png/webp/bmp/tiff/gif/heic/heif,视频 mp4/mov,音频 wav/mp3;请传带正确扩展名的 --file、--url 或 --backend-path。',
|
|
2775
|
+
);
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
function parseFfprobeNumber(value) {
|
|
2779
|
+
const number = Number(value);
|
|
2780
|
+
return Number.isFinite(number) ? number : null;
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
function parseFps(value) {
|
|
2784
|
+
const text = String(value || '').trim();
|
|
2785
|
+
if (!text || text === '0/0') return null;
|
|
2786
|
+
if (text.includes('/')) {
|
|
2787
|
+
const [left, right] = text.split('/').map(Number);
|
|
2788
|
+
return Number.isFinite(left) && Number.isFinite(right) && right !== 0 ? left / right : null;
|
|
2336
2789
|
}
|
|
2337
|
-
return
|
|
2338
|
-
...inspected,
|
|
2339
|
-
sceneType,
|
|
2340
|
-
projectNo: options.projectNo ?? '',
|
|
2341
|
-
backendPath: `/${objectName}`,
|
|
2342
|
-
url: `https://${host}/${encodeObjectNamePath(objectName)}`,
|
|
2343
|
-
});
|
|
2790
|
+
return parseFfprobeNumber(text);
|
|
2344
2791
|
}
|
|
2345
2792
|
|
|
2346
|
-
function
|
|
2347
|
-
|
|
2793
|
+
function roundNumber(value, digits = 3) {
|
|
2794
|
+
if (!Number.isFinite(Number(value))) return value;
|
|
2795
|
+
const fixed = Number(value).toFixed(digits);
|
|
2796
|
+
return fixed.replace(/\.?0+$/, '');
|
|
2348
2797
|
}
|
|
2349
2798
|
|
|
2350
|
-
function
|
|
2799
|
+
function normalizeDisplayFormat(value) {
|
|
2351
2800
|
const text = trimToNull(value);
|
|
2352
|
-
if (!text) return
|
|
2353
|
-
|
|
2354
|
-
if (!isHttpUrl(text)) return false;
|
|
2355
|
-
try {
|
|
2356
|
-
const url = new URL(text);
|
|
2357
|
-
return url.hostname.endsWith('.myqcloud.com');
|
|
2358
|
-
} catch {
|
|
2359
|
-
return false;
|
|
2360
|
-
}
|
|
2801
|
+
if (!text) return null;
|
|
2802
|
+
return normalizeFileFormat(text.replace(/^\./, ''));
|
|
2361
2803
|
}
|
|
2362
2804
|
|
|
2363
|
-
function
|
|
2364
|
-
|
|
2365
|
-
const pathname = decodeURIComponent(new URL(remoteUrl).pathname);
|
|
2366
|
-
const baseName = safeFileName(path.basename(pathname));
|
|
2367
|
-
if (baseName && baseName !== '.' && baseName !== '/') return baseName;
|
|
2368
|
-
} catch {}
|
|
2369
|
-
return `${fallback}.mp3`;
|
|
2805
|
+
function clampNumber(value, min, max) {
|
|
2806
|
+
return Math.min(max, Math.max(min, value));
|
|
2370
2807
|
}
|
|
2371
2808
|
|
|
2372
|
-
function
|
|
2373
|
-
const
|
|
2374
|
-
|
|
2375
|
-
if (normalized === 'audio/wav' || normalized === 'audio/x-wav') return '.wav';
|
|
2376
|
-
if (normalized === 'audio/mp4') return '.m4a';
|
|
2377
|
-
if (normalized === 'audio/aac') return '.aac';
|
|
2378
|
-
if (normalized === 'audio/ogg') return '.ogg';
|
|
2379
|
-
if (normalized === 'video/mp4') return '.mp4';
|
|
2380
|
-
return '';
|
|
2809
|
+
function evenNumber(value, fallback = 2) {
|
|
2810
|
+
const rounded = Math.max(2, Math.round(value / 2) * 2);
|
|
2811
|
+
return Number.isFinite(rounded) ? rounded : fallback;
|
|
2381
2812
|
}
|
|
2382
2813
|
|
|
2383
|
-
function
|
|
2384
|
-
|
|
2385
|
-
const extension = path.extname(fileName).toLowerCase();
|
|
2386
|
-
if (REMOTE_VOICE_MIME_TYPES.has(normalized)) return true;
|
|
2387
|
-
if ((!normalized || normalized === 'application/octet-stream') && REMOTE_VOICE_EXTENSIONS.has(extension)) return true;
|
|
2388
|
-
return false;
|
|
2814
|
+
function roundToStep(value, step = 1) {
|
|
2815
|
+
return Math.round(value / step) * step;
|
|
2389
2816
|
}
|
|
2390
2817
|
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2818
|
+
function alignUp(value, step = 1) {
|
|
2819
|
+
return Math.ceil(value / step) * step;
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
function alignDown(value, step = 1) {
|
|
2823
|
+
return Math.floor(value / step) * step;
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
function isLegalMediaBox(width, height, options = {}) {
|
|
2827
|
+
const minAspect = options.minAspect ?? ASSET_MEDIA_MIN_ASPECT;
|
|
2828
|
+
const maxAspect = options.maxAspect ?? ASSET_MEDIA_MAX_ASPECT;
|
|
2829
|
+
const minDimension = options.minDimension ?? ASSET_MEDIA_MIN_DIMENSION;
|
|
2830
|
+
const maxDimension = options.maxDimension ?? ASSET_MEDIA_MAX_DIMENSION;
|
|
2831
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) return false;
|
|
2832
|
+
if (width < minDimension || height < minDimension || width > maxDimension || height > maxDimension) return false;
|
|
2833
|
+
const aspect = width / height;
|
|
2834
|
+
if (aspect < minAspect || aspect > maxAspect) return false;
|
|
2835
|
+
const pixels = width * height;
|
|
2836
|
+
if (options.minPixels != null && pixels < options.minPixels) return false;
|
|
2837
|
+
if (options.maxPixels != null && pixels > options.maxPixels) return false;
|
|
2838
|
+
return true;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function legalMediaBoxOrThrow(target, label, options = {}) {
|
|
2842
|
+
if (isLegalMediaBox(target.width, target.height, options)) return target;
|
|
2843
|
+
throw argumentError(
|
|
2844
|
+
`无法计算合法${label}转码尺寸`,
|
|
2845
|
+
`${label}目标尺寸计算结果为 ${target.width}x${target.height},仍不满足平台规格;请手动转换素材后重试。`,
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
function chooseClosestLegalBox(width, height, options = {}) {
|
|
2850
|
+
const minAspect = options.minAspect ?? ASSET_MEDIA_MIN_ASPECT;
|
|
2851
|
+
const maxAspect = options.maxAspect ?? ASSET_MEDIA_MAX_ASPECT;
|
|
2852
|
+
const minDimension = options.minDimension ?? ASSET_MEDIA_MIN_DIMENSION;
|
|
2853
|
+
const maxDimension = options.maxDimension ?? ASSET_MEDIA_MAX_DIMENSION;
|
|
2854
|
+
const step = options.even ? 2 : 1;
|
|
2855
|
+
const sourceWidth = Math.max(1, Number(width) || minDimension);
|
|
2856
|
+
const sourceHeight = Math.max(1, Number(height) || minDimension);
|
|
2857
|
+
const targetAspect = clampNumber(sourceWidth / sourceHeight, minAspect, maxAspect);
|
|
2858
|
+
const minPixels = options.minPixels;
|
|
2859
|
+
const maxPixels = options.maxPixels;
|
|
2860
|
+
const minArea = minPixels ?? minDimension * minDimension;
|
|
2861
|
+
const maxArea = maxPixels ?? maxDimension * maxDimension;
|
|
2862
|
+
const targetArea = clampNumber(sourceWidth * sourceHeight, minArea, maxArea);
|
|
2863
|
+
let best = null;
|
|
2864
|
+
|
|
2865
|
+
const minHeight = alignUp(minDimension, step);
|
|
2866
|
+
const maxHeight = alignDown(maxDimension, step);
|
|
2867
|
+
for (let candidateHeight = minHeight; candidateHeight <= maxHeight; candidateHeight += step) {
|
|
2868
|
+
const minWidthForAspect = Math.ceil(candidateHeight * minAspect);
|
|
2869
|
+
const maxWidthForAspect = Math.floor(candidateHeight * maxAspect);
|
|
2870
|
+
const minWidthForPixels = minPixels == null ? minDimension : Math.ceil(minPixels / candidateHeight);
|
|
2871
|
+
const maxWidthForPixels = maxPixels == null ? maxDimension : Math.floor(maxPixels / candidateHeight);
|
|
2872
|
+
const minWidth = alignUp(Math.max(minDimension, minWidthForAspect, minWidthForPixels), step);
|
|
2873
|
+
const maxWidth = alignDown(Math.min(maxDimension, maxWidthForAspect, maxWidthForPixels), step);
|
|
2874
|
+
if (minWidth > maxWidth) continue;
|
|
2875
|
+
|
|
2876
|
+
const preferredByAspect = roundToStep(targetAspect * candidateHeight, step);
|
|
2877
|
+
const preferredByArea = roundToStep(targetArea / candidateHeight, step);
|
|
2878
|
+
const candidates = uniqueNonEmpty([
|
|
2879
|
+
preferredByAspect,
|
|
2880
|
+
preferredByArea,
|
|
2881
|
+
minWidth,
|
|
2882
|
+
maxWidth,
|
|
2883
|
+
preferredByAspect - step,
|
|
2884
|
+
preferredByAspect + step,
|
|
2885
|
+
preferredByArea - step,
|
|
2886
|
+
preferredByArea + step,
|
|
2887
|
+
])
|
|
2888
|
+
.map((item) => Number(item))
|
|
2889
|
+
.filter((item) => Number.isFinite(item))
|
|
2890
|
+
.map((item) => clampNumber(item, minWidth, maxWidth));
|
|
2891
|
+
|
|
2892
|
+
for (const candidateWidth of candidates) {
|
|
2893
|
+
if (!isLegalMediaBox(candidateWidth, candidateHeight, options)) continue;
|
|
2894
|
+
const aspect = candidateWidth / candidateHeight;
|
|
2895
|
+
const area = candidateWidth * candidateHeight;
|
|
2896
|
+
const aspectScore = Math.abs(Math.log(aspect / targetAspect));
|
|
2897
|
+
const areaScore = Math.abs(Math.log(area / targetArea));
|
|
2898
|
+
const sourceScaleScore = Math.abs(Math.log(candidateWidth / sourceWidth)) + Math.abs(Math.log(candidateHeight / sourceHeight));
|
|
2899
|
+
const score = aspectScore * 10 + areaScore + sourceScaleScore * 0.05;
|
|
2900
|
+
if (!best || score < best.score) {
|
|
2901
|
+
best = { width: candidateWidth, height: candidateHeight, pixels: area, score };
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
if (!best) {
|
|
2907
|
+
const fallback = options.even ? { width: 640, height: 640, pixels: 640 * 640 } : { width: 300, height: 300, pixels: 300 * 300 };
|
|
2908
|
+
return fallback;
|
|
2909
|
+
}
|
|
2910
|
+
return {
|
|
2911
|
+
width: best.width,
|
|
2912
|
+
height: best.height,
|
|
2913
|
+
pixels: best.pixels,
|
|
2914
|
+
};
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
function mediaSummary(media = {}) {
|
|
2918
|
+
return compactRecord({
|
|
2919
|
+
width: media.width,
|
|
2920
|
+
height: media.height,
|
|
2921
|
+
pixels: media.width && media.height ? media.width * media.height : undefined,
|
|
2922
|
+
aspectRatio: media.width && media.height ? Number(roundNumber(media.width / media.height, 4)) : undefined,
|
|
2923
|
+
duration: media.duration == null ? undefined : Number(roundNumber(media.duration, 3)),
|
|
2924
|
+
fps: media.fps == null ? undefined : Number(roundNumber(media.fps, 3)),
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
function addAssetViolation(violations, code, message, hint = '') {
|
|
2929
|
+
violations.push(compactRecord({ code, message, hint }));
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
function validateMediaGeometry(violations, media, label, options = {}) {
|
|
2933
|
+
const width = Number(media?.width);
|
|
2934
|
+
const height = Number(media?.height);
|
|
2935
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
2936
|
+
addAssetViolation(violations, `${label}_dimensions_unknown`, `${label}宽高无法识别`, `${label}宽高必须在 [300,6000] px,宽高比必须在 [0.4,2.5]。`);
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
const aspectRatio = width / height;
|
|
2940
|
+
if (aspectRatio < ASSET_MEDIA_MIN_ASPECT || aspectRatio > ASSET_MEDIA_MAX_ASPECT) {
|
|
2941
|
+
addAssetViolation(
|
|
2942
|
+
violations,
|
|
2943
|
+
`${label}_aspect_ratio`,
|
|
2944
|
+
`${label}宽高比不支持:${roundNumber(aspectRatio, 4)}`,
|
|
2945
|
+
`${label}宽高比(宽/高)必须在 [0.4,2.5]。`,
|
|
2946
|
+
);
|
|
2947
|
+
}
|
|
2948
|
+
if (
|
|
2949
|
+
width < ASSET_MEDIA_MIN_DIMENSION
|
|
2950
|
+
|| height < ASSET_MEDIA_MIN_DIMENSION
|
|
2951
|
+
|| width > ASSET_MEDIA_MAX_DIMENSION
|
|
2952
|
+
|| height > ASSET_MEDIA_MAX_DIMENSION
|
|
2953
|
+
) {
|
|
2954
|
+
addAssetViolation(
|
|
2955
|
+
violations,
|
|
2956
|
+
`${label}_dimensions`,
|
|
2957
|
+
`${label}宽高不支持:${width}x${height}`,
|
|
2958
|
+
`${label}宽和高都必须在 [300,6000] px。`,
|
|
2959
|
+
);
|
|
2960
|
+
}
|
|
2961
|
+
if (options.videoPixels) {
|
|
2962
|
+
const pixels = width * height;
|
|
2963
|
+
if (pixels < ASSET_VIDEO_MIN_PIXELS || pixels > ASSET_VIDEO_MAX_PIXELS) {
|
|
2964
|
+
addAssetViolation(
|
|
2965
|
+
violations,
|
|
2966
|
+
'video_pixels',
|
|
2967
|
+
`视频总像素数不支持:${pixels}`,
|
|
2968
|
+
`视频宽高乘积必须在 [${ASSET_VIDEO_MIN_PIXELS},${ASSET_VIDEO_MAX_PIXELS}]。`,
|
|
2969
|
+
);
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
function validateDurationRange(violations, media, label, minSeconds, maxSeconds) {
|
|
2975
|
+
const duration = Number(media?.duration);
|
|
2976
|
+
if (!Number.isFinite(duration) || duration < minSeconds || duration > maxSeconds) {
|
|
2977
|
+
addAssetViolation(
|
|
2978
|
+
violations,
|
|
2979
|
+
`${label}_duration`,
|
|
2980
|
+
`${label}时长不支持:${Number.isFinite(duration) ? `${roundNumber(duration)}s` : 'unknown'}`,
|
|
2981
|
+
`${label}时长必须在 [${minSeconds},${maxSeconds}] 秒。`,
|
|
2982
|
+
);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
async function commandSucceeds(command, args = ['--version']) {
|
|
2987
|
+
try {
|
|
2988
|
+
await execFileAsync(command, args, { encoding: 'utf8', timeout: 10_000, maxBuffer: 256 * 1024 });
|
|
2989
|
+
return true;
|
|
2990
|
+
} catch {
|
|
2991
|
+
return false;
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
function commandCandidates(command) {
|
|
2996
|
+
if (path.isAbsolute(command)) return [command];
|
|
2997
|
+
if (command === 'brew') return ['brew', '/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
|
|
2998
|
+
if (command === 'ffprobe') return ['ffprobe', '/opt/homebrew/bin/ffprobe', '/usr/local/bin/ffprobe'];
|
|
2999
|
+
if (command === 'ffmpeg') return ['ffmpeg', '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'];
|
|
3000
|
+
return [command];
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
async function findCommand(command, args = ['--version']) {
|
|
3004
|
+
for (const candidate of commandCandidates(command)) {
|
|
3005
|
+
if (await commandSucceeds(candidate, args)) return candidate;
|
|
3006
|
+
}
|
|
3007
|
+
return null;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
function autoInstallFfprobeEnabled() {
|
|
3011
|
+
const value = String(process.env.LINGJING_AWB_AUTO_INSTALL_FFPROBE ?? '1').trim().toLowerCase();
|
|
3012
|
+
return !['0', 'false', 'no', 'off'].includes(value);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
async function ensureFfprobeAvailable() {
|
|
3016
|
+
if (ffprobeCommandCache && await commandSucceeds(ffprobeCommandCache, ['-version'])) return ffprobeCommandCache;
|
|
3017
|
+
const existing = await findCommand('ffprobe', ['-version']);
|
|
3018
|
+
if (existing) {
|
|
3019
|
+
ffprobeCommandCache = existing;
|
|
3020
|
+
return existing;
|
|
3021
|
+
}
|
|
3022
|
+
const brewCommand = autoInstallFfprobeEnabled() && process.platform === 'darwin'
|
|
3023
|
+
? await findCommand('brew', ['--version'])
|
|
3024
|
+
: null;
|
|
3025
|
+
if (brewCommand) {
|
|
3026
|
+
try {
|
|
3027
|
+
await execFileAsync(brewCommand, ['install', 'ffmpeg'], {
|
|
3028
|
+
encoding: 'utf8',
|
|
3029
|
+
timeout: 10 * 60_000,
|
|
3030
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
3031
|
+
});
|
|
3032
|
+
const installed = await findCommand('ffprobe', ['-version']);
|
|
3033
|
+
if (installed) {
|
|
3034
|
+
ffprobeCommandCache = installed;
|
|
3035
|
+
return installed;
|
|
3036
|
+
}
|
|
3037
|
+
} catch (error) {
|
|
3038
|
+
throw argumentError(
|
|
3039
|
+
'缺少 ffprobe,且自动安装 ffmpeg 失败',
|
|
3040
|
+
`CLI 已尝试运行 brew install ffmpeg。请手动安装后重试;如需关闭自动安装,设置 LINGJING_AWB_AUTO_INSTALL_FFPROBE=0。原始错误:${error.message}`,
|
|
3041
|
+
);
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
throw argumentError(
|
|
3045
|
+
'缺少 ffprobe',
|
|
3046
|
+
'本地图片 / 音频 / 视频素材加白需要 ffprobe 校验尺寸、时长和视频 FPS。macOS 可运行 brew install ffmpeg;Ubuntu/Debian 可运行 sudo apt-get install ffmpeg。',
|
|
3047
|
+
);
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
async function ensureFfmpegAvailable() {
|
|
3051
|
+
if (ffmpegCommandCache && await commandSucceeds(ffmpegCommandCache, ['-version'])) return ffmpegCommandCache;
|
|
3052
|
+
const existing = await findCommand('ffmpeg', ['-version']);
|
|
3053
|
+
if (existing) {
|
|
3054
|
+
ffmpegCommandCache = existing;
|
|
3055
|
+
return existing;
|
|
3056
|
+
}
|
|
3057
|
+
const brewCommand = autoInstallFfprobeEnabled() && process.platform === 'darwin'
|
|
3058
|
+
? await findCommand('brew', ['--version'])
|
|
3059
|
+
: null;
|
|
3060
|
+
if (brewCommand) {
|
|
3061
|
+
try {
|
|
3062
|
+
await execFileAsync(brewCommand, ['install', 'ffmpeg'], {
|
|
3063
|
+
encoding: 'utf8',
|
|
3064
|
+
timeout: 10 * 60_000,
|
|
3065
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
3066
|
+
});
|
|
3067
|
+
const installed = await findCommand('ffmpeg', ['-version']);
|
|
3068
|
+
if (installed) {
|
|
3069
|
+
ffmpegCommandCache = installed;
|
|
3070
|
+
return installed;
|
|
3071
|
+
}
|
|
3072
|
+
} catch (error) {
|
|
3073
|
+
throw argumentError(
|
|
3074
|
+
'缺少 ffmpeg,且自动安装 ffmpeg 失败',
|
|
3075
|
+
`CLI 已尝试运行 brew install ffmpeg。请手动安装后重试;如需关闭自动安装,设置 LINGJING_AWB_AUTO_INSTALL_FFPROBE=0。原始错误:${error.message}`,
|
|
3076
|
+
);
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
throw argumentError(
|
|
3080
|
+
'缺少 ffmpeg',
|
|
3081
|
+
'素材自动转码需要 ffmpeg。macOS 可运行 brew install ffmpeg;Ubuntu/Debian 可运行 sudo apt-get install ffmpeg。',
|
|
3082
|
+
);
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
async function readMediaMetadata(filePath, assetType) {
|
|
3086
|
+
const ffprobeCommand = await ensureFfprobeAvailable();
|
|
3087
|
+
try {
|
|
3088
|
+
const { stdout } = await execFileAsync(ffprobeCommand, [
|
|
3089
|
+
'-v',
|
|
3090
|
+
'error',
|
|
3091
|
+
'-print_format',
|
|
3092
|
+
'json',
|
|
3093
|
+
'-show_format',
|
|
3094
|
+
'-show_streams',
|
|
3095
|
+
filePath,
|
|
3096
|
+
], { encoding: 'utf8', timeout: 15_000, maxBuffer: 2 * 1024 * 1024 });
|
|
3097
|
+
const data = JSON.parse(stdout);
|
|
3098
|
+
const streams = Array.isArray(data.streams) ? data.streams : [];
|
|
3099
|
+
const wantedStreamType = assetType === ASSET_TYPE_CODES.Audio ? 'audio' : 'video';
|
|
3100
|
+
const stream = streams.find((item) => item.codec_type === wantedStreamType);
|
|
3101
|
+
if (!stream) {
|
|
3102
|
+
throw new Error(`缺少 ${wantedStreamType} stream`);
|
|
3103
|
+
}
|
|
3104
|
+
const duration = parseFfprobeNumber(stream.duration) ?? parseFfprobeNumber(data.format?.duration);
|
|
3105
|
+
return compactRecord({
|
|
3106
|
+
duration,
|
|
3107
|
+
width: parseFfprobeNumber(stream.width),
|
|
3108
|
+
height: parseFfprobeNumber(stream.height),
|
|
3109
|
+
fps: parseFps(stream.avg_frame_rate) ?? parseFps(stream.r_frame_rate),
|
|
3110
|
+
});
|
|
3111
|
+
} catch (error) {
|
|
3112
|
+
const mediaLabel = assetType === ASSET_TYPE_CODES.Audio ? '音频' : (assetType === ASSET_TYPE_CODES.Video ? '视频' : '图片');
|
|
3113
|
+
throw argumentError(
|
|
3114
|
+
`无法读取${mediaLabel}素材信息`,
|
|
3115
|
+
`请确认文件可被 ffprobe 解析且本机已安装 ffprobe。原始错误:${error.message}`,
|
|
3116
|
+
);
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
async function readImageMetadata(filePath) {
|
|
3121
|
+
const inspected = await inspectLocalFile(filePath);
|
|
3122
|
+
if (Number.isFinite(inspected.width) && Number.isFinite(inspected.height)) {
|
|
3123
|
+
return compactRecord({
|
|
3124
|
+
width: inspected.width,
|
|
3125
|
+
height: inspected.height,
|
|
3126
|
+
format: inspected.format ?? null,
|
|
3127
|
+
});
|
|
3128
|
+
}
|
|
3129
|
+
const media = await readMediaMetadata(filePath, ASSET_TYPE_CODES.Image);
|
|
3130
|
+
return compactRecord({
|
|
3131
|
+
width: media.width,
|
|
3132
|
+
height: media.height,
|
|
3133
|
+
format: inspected.format ?? null,
|
|
3134
|
+
});
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
function chooseLegalMediaBox(width, height, options = {}) {
|
|
3138
|
+
return legalMediaBoxOrThrow(chooseClosestLegalBox(width, height, options), '图片', options);
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function chooseLegalVideoBox(width, height) {
|
|
3142
|
+
const options = {
|
|
3143
|
+
even: true,
|
|
3144
|
+
minPixels: ASSET_VIDEO_MIN_PIXELS,
|
|
3145
|
+
maxPixels: ASSET_VIDEO_MAX_PIXELS,
|
|
3146
|
+
};
|
|
3147
|
+
return legalMediaBoxOrThrow(chooseClosestLegalBox(width, height, options), '视频', options);
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
function validateAssetViolations(assetType, inspected, media = null) {
|
|
3151
|
+
const violations = [];
|
|
3152
|
+
const format = normalizeFileFormat(inspected.format ?? path.extname(inspected.filePath));
|
|
3153
|
+
if (assetType === ASSET_TYPE_CODES.Image) {
|
|
3154
|
+
if (inspected.size >= ASSET_IMAGE_MAX_BYTES) {
|
|
3155
|
+
addAssetViolation(violations, 'image_size', `图片素材过大:${mb(inspected.size)}`, '单张图片必须小于 30MB。');
|
|
3156
|
+
}
|
|
3157
|
+
if (!media?.width || !media?.height) {
|
|
3158
|
+
addAssetViolation(violations, 'image_dimensions_unknown', '图片宽高无法识别', '请确认文件可被 ffprobe 或本地图片解析器解析。');
|
|
3159
|
+
} else {
|
|
3160
|
+
validateMediaGeometry(violations, media, '图片');
|
|
3161
|
+
}
|
|
3162
|
+
} else if (assetType === ASSET_TYPE_CODES.Video) {
|
|
3163
|
+
if (inspected.size > ASSET_VIDEO_MAX_BYTES) {
|
|
3164
|
+
addAssetViolation(violations, 'video_size', `视频素材过大:${mb(inspected.size)}`, '单个视频不能超过 50MB。');
|
|
3165
|
+
}
|
|
3166
|
+
if (!media?.width || !media?.height) {
|
|
3167
|
+
addAssetViolation(violations, 'video_dimensions_unknown', '视频宽高无法识别', '请确认文件可被 ffprobe 解析。');
|
|
3168
|
+
} else {
|
|
3169
|
+
validateMediaGeometry(violations, media, '视频', { videoPixels: true });
|
|
3170
|
+
}
|
|
3171
|
+
if (media?.fps == null || media.fps + 0.05 < ASSET_VIDEO_MIN_FPS || media.fps - 0.05 > ASSET_VIDEO_MAX_FPS) {
|
|
3172
|
+
addAssetViolation(
|
|
3173
|
+
violations,
|
|
3174
|
+
'video_fps',
|
|
3175
|
+
`视频帧率不支持:${media?.fps == null ? 'unknown' : `${roundNumber(media.fps)}fps`}`,
|
|
3176
|
+
'视频 FPS 必须在 [24,60]。',
|
|
3177
|
+
);
|
|
3178
|
+
}
|
|
3179
|
+
if (media?.duration == null || media.duration < ASSET_VIDEO_MIN_SECONDS || media.duration > ASSET_VIDEO_MAX_SECONDS) {
|
|
3180
|
+
addAssetViolation(
|
|
3181
|
+
violations,
|
|
3182
|
+
'video_duration',
|
|
3183
|
+
`视频时长不支持:${media?.duration == null ? 'unknown' : `${roundNumber(media.duration)}s`}`,
|
|
3184
|
+
'单个视频时长必须在 [2,15] 秒。',
|
|
3185
|
+
);
|
|
3186
|
+
}
|
|
3187
|
+
} else if (assetType === ASSET_TYPE_CODES.Audio) {
|
|
3188
|
+
if (media?.duration == null || media.duration < ASSET_AUDIO_MIN_SECONDS || media.duration > ASSET_AUDIO_MAX_SECONDS) {
|
|
3189
|
+
addAssetViolation(
|
|
3190
|
+
violations,
|
|
3191
|
+
'audio_duration',
|
|
3192
|
+
`音频时长不支持:${media?.duration == null ? 'unknown' : `${roundNumber(media.duration)}s`}`,
|
|
3193
|
+
'单个音频时长必须在 [2,15] 秒。',
|
|
3194
|
+
);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
return {
|
|
3198
|
+
legal: violations.length === 0,
|
|
3199
|
+
assetType,
|
|
3200
|
+
format,
|
|
3201
|
+
inspected,
|
|
3202
|
+
media: media ? compactRecord(media) : null,
|
|
3203
|
+
violations,
|
|
3204
|
+
};
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function buildAssetConversionPlan(validation) {
|
|
3208
|
+
const { assetType, media, inspected } = validation;
|
|
3209
|
+
if (!media?.width && assetType !== ASSET_TYPE_CODES.Audio) return null;
|
|
3210
|
+
if (assetType === ASSET_TYPE_CODES.Image) {
|
|
3211
|
+
const target = chooseLegalMediaBox(media.width, media.height);
|
|
3212
|
+
return compactRecord({
|
|
3213
|
+
assetType,
|
|
3214
|
+
fromFormat: normalizeDisplayFormat(inspected.format ?? path.extname(inspected.filePath)) || 'unknown',
|
|
3215
|
+
toFormat: 'jpg',
|
|
3216
|
+
targetWidth: target.width,
|
|
3217
|
+
targetHeight: target.height,
|
|
3218
|
+
sourceFile: inspected.filePath,
|
|
3219
|
+
reason: validation.violations.map((item) => item.message).join(';'),
|
|
3220
|
+
filters: `scale=${target.width}:${target.height}:force_original_aspect_ratio=decrease,pad=${target.width}:${target.height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1`,
|
|
3221
|
+
});
|
|
3222
|
+
}
|
|
3223
|
+
if (assetType === ASSET_TYPE_CODES.Video) {
|
|
3224
|
+
const target = chooseLegalVideoBox(media.width, media.height);
|
|
3225
|
+
const targetFps = clampNumber(Number(media.fps ?? ASSET_VIDEO_MIN_FPS), ASSET_VIDEO_MIN_FPS, ASSET_VIDEO_MAX_FPS);
|
|
3226
|
+
const targetDuration = clampNumber(Number(media.duration ?? ASSET_VIDEO_MIN_SECONDS), ASSET_VIDEO_MIN_SECONDS, ASSET_VIDEO_MAX_SECONDS);
|
|
3227
|
+
const filters = [
|
|
3228
|
+
`fps=${roundNumber(targetFps, 3)}`,
|
|
3229
|
+
`scale=${target.width}:${target.height}:force_original_aspect_ratio=decrease`,
|
|
3230
|
+
`pad=${target.width}:${target.height}:(ow-iw)/2:(oh-ih)/2:black`,
|
|
3231
|
+
'setsar=1',
|
|
3232
|
+
];
|
|
3233
|
+
if (media.duration != null && media.duration < ASSET_VIDEO_MIN_SECONDS) {
|
|
3234
|
+
filters.push(`tpad=stop_mode=clone:stop_duration=${roundNumber(ASSET_VIDEO_MIN_SECONDS - media.duration, 3)}`);
|
|
3235
|
+
}
|
|
3236
|
+
return compactRecord({
|
|
3237
|
+
assetType,
|
|
3238
|
+
fromFormat: normalizeDisplayFormat(inspected.format ?? path.extname(inspected.filePath)) || 'unknown',
|
|
3239
|
+
toFormat: 'mp4',
|
|
3240
|
+
sourceDuration: media.duration,
|
|
3241
|
+
targetWidth: target.width,
|
|
3242
|
+
targetHeight: target.height,
|
|
3243
|
+
targetPixels: target.width * target.height,
|
|
3244
|
+
targetFps: Number(roundNumber(targetFps, 3)),
|
|
3245
|
+
targetDuration,
|
|
3246
|
+
sourceFile: inspected.filePath,
|
|
3247
|
+
reason: validation.violations.map((item) => item.message).join(';'),
|
|
3248
|
+
filters: filters.join(','),
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
if (assetType === ASSET_TYPE_CODES.Audio) {
|
|
3252
|
+
return compactRecord({
|
|
3253
|
+
assetType,
|
|
3254
|
+
fromFormat: normalizeDisplayFormat(inspected.format ?? path.extname(inspected.filePath)) || 'unknown',
|
|
3255
|
+
toFormat: 'wav',
|
|
3256
|
+
sourceDuration: media?.duration,
|
|
3257
|
+
targetDuration: clampNumber(Number(media?.duration ?? ASSET_AUDIO_MIN_SECONDS), ASSET_AUDIO_MIN_SECONDS, ASSET_AUDIO_MAX_SECONDS),
|
|
3258
|
+
sourceFile: inspected.filePath,
|
|
3259
|
+
reason: validation.violations.map((item) => item.message).join(';'),
|
|
3260
|
+
});
|
|
3261
|
+
}
|
|
3262
|
+
return null;
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
function dryRunAssetFileName(source) {
|
|
3266
|
+
const plan = source.conversionPlan;
|
|
3267
|
+
if (!plan?.toFormat) return source.localFile?.filePath;
|
|
3268
|
+
return forceFileExtension(source.localFile?.fileName || source.localFile?.filePath || 'asset', `.${conversionOutputExtension(plan.toFormat)}`);
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
async function convertLocalVideoFile(filePath, plan) {
|
|
3272
|
+
const ffmpegCommand = await ensureFfmpegAvailable();
|
|
3273
|
+
const outputPath = path.join(tmpdir(), `lj-awb-${crypto.randomUUID()}.mp4`);
|
|
3274
|
+
const args = [
|
|
3275
|
+
'-y',
|
|
3276
|
+
'-hide_banner',
|
|
3277
|
+
'-loglevel',
|
|
3278
|
+
'error',
|
|
3279
|
+
'-i',
|
|
3280
|
+
filePath,
|
|
3281
|
+
'-vf',
|
|
3282
|
+
plan.filters,
|
|
3283
|
+
'-map',
|
|
3284
|
+
'0:v:0',
|
|
3285
|
+
'-map',
|
|
3286
|
+
'0:a?',
|
|
3287
|
+
'-c:v',
|
|
3288
|
+
'libx264',
|
|
3289
|
+
'-pix_fmt',
|
|
3290
|
+
'yuv420p',
|
|
3291
|
+
'-preset',
|
|
3292
|
+
'medium',
|
|
3293
|
+
'-crf',
|
|
3294
|
+
'20',
|
|
3295
|
+
'-movflags',
|
|
3296
|
+
'+faststart',
|
|
3297
|
+
];
|
|
3298
|
+
if (Number.isFinite(Number(plan.sourceDuration)) && plan.sourceDuration > ASSET_VIDEO_MAX_SECONDS) {
|
|
3299
|
+
args.push('-t', String(ASSET_VIDEO_MAX_SECONDS));
|
|
3300
|
+
} else if (Number.isFinite(Number(plan.sourceDuration)) && plan.sourceDuration < ASSET_VIDEO_MIN_SECONDS) {
|
|
3301
|
+
args.push('-t', String(ASSET_VIDEO_MIN_SECONDS));
|
|
3302
|
+
}
|
|
3303
|
+
args.push('-c:a', 'aac', '-b:a', '128k', outputPath);
|
|
3304
|
+
try {
|
|
3305
|
+
await execFileAsync(ffmpegCommand, args, { timeout: 15 * 60_000, maxBuffer: 8 * 1024 * 1024 });
|
|
3306
|
+
} catch (error) {
|
|
3307
|
+
await fs.rm(outputPath, { force: true }).catch(() => {});
|
|
3308
|
+
throw argumentError(
|
|
3309
|
+
`视频格式转换失败:${path.basename(filePath)}`,
|
|
3310
|
+
`请确认本机可用 ffmpeg,或手动转换后重试。${error?.message ? ` 原因:${error.message}` : ''}`,
|
|
3311
|
+
);
|
|
3312
|
+
}
|
|
3313
|
+
const inspected = await inspectLocalFile(outputPath);
|
|
3314
|
+
if (!inspected.exists) {
|
|
3315
|
+
throw argumentError(`视频格式转换失败:${path.basename(filePath)}`, '未生成目标视频文件。');
|
|
3316
|
+
}
|
|
3317
|
+
return inspected;
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
async function convertLocalAudioFile(filePath, plan) {
|
|
3321
|
+
const ffmpegCommand = await ensureFfmpegAvailable();
|
|
3322
|
+
const outputPath = path.join(tmpdir(), `lj-awb-${crypto.randomUUID()}.wav`);
|
|
3323
|
+
const args = [
|
|
3324
|
+
'-y',
|
|
3325
|
+
'-hide_banner',
|
|
3326
|
+
'-loglevel',
|
|
3327
|
+
'error',
|
|
3328
|
+
'-i',
|
|
3329
|
+
filePath,
|
|
3330
|
+
];
|
|
3331
|
+
if (Number.isFinite(Number(plan.sourceDuration)) && plan.sourceDuration < ASSET_AUDIO_MIN_SECONDS) {
|
|
3332
|
+
args.push('-af', 'apad');
|
|
3333
|
+
args.push('-t', String(ASSET_AUDIO_MIN_SECONDS));
|
|
3334
|
+
} else if (Number.isFinite(Number(plan.sourceDuration)) && plan.sourceDuration > ASSET_AUDIO_MAX_SECONDS) {
|
|
3335
|
+
args.push('-t', String(ASSET_AUDIO_MAX_SECONDS));
|
|
3336
|
+
}
|
|
3337
|
+
args.push('-c:a', 'pcm_s16le', outputPath);
|
|
3338
|
+
try {
|
|
3339
|
+
await execFileAsync(ffmpegCommand, args, { timeout: 15 * 60_000, maxBuffer: 8 * 1024 * 1024 });
|
|
3340
|
+
} catch (error) {
|
|
3341
|
+
await fs.rm(outputPath, { force: true }).catch(() => {});
|
|
3342
|
+
throw argumentError(
|
|
3343
|
+
`音频格式转换失败:${path.basename(filePath)}`,
|
|
3344
|
+
`请确认本机可用 ffmpeg,或手动转换后重试。${error?.message ? ` 原因:${error.message}` : ''}`,
|
|
3345
|
+
);
|
|
3346
|
+
}
|
|
3347
|
+
const inspected = await inspectLocalFile(outputPath);
|
|
3348
|
+
if (!inspected.exists) {
|
|
3349
|
+
throw argumentError(`音频格式转换失败:${path.basename(filePath)}`, '未生成目标音频文件。');
|
|
3350
|
+
}
|
|
3351
|
+
return inspected;
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
async function convertLocalImageFile(filePath, targetFormat, options = {}) {
|
|
3355
|
+
const ffmpegCommand = await ensureFfmpegAvailable();
|
|
3356
|
+
const target = normalizeFileFormat(targetFormat) || 'jpg';
|
|
3357
|
+
const outputPath = options.outputPath || path.join(tmpdir(), `lj-awb-${crypto.randomUUID()}.${conversionOutputExtension(target)}`);
|
|
3358
|
+
const codecArgs = target === 'jpg' ? ['-q:v', String(options.quality ?? 2)] : [];
|
|
3359
|
+
const filterArgs = options.filters ? ['-vf', options.filters] : [];
|
|
3360
|
+
try {
|
|
3361
|
+
await execFileAsync(ffmpegCommand, [
|
|
3362
|
+
'-y',
|
|
3363
|
+
'-hide_banner',
|
|
3364
|
+
'-loglevel',
|
|
3365
|
+
'error',
|
|
3366
|
+
'-i',
|
|
3367
|
+
filePath,
|
|
3368
|
+
...filterArgs,
|
|
3369
|
+
'-frames:v',
|
|
3370
|
+
'1',
|
|
3371
|
+
...codecArgs,
|
|
3372
|
+
outputPath,
|
|
3373
|
+
], { timeout: 10 * 60_000, maxBuffer: 8 * 1024 * 1024 });
|
|
3374
|
+
} catch (error) {
|
|
3375
|
+
await fs.rm(outputPath, { force: true }).catch(() => {});
|
|
3376
|
+
throw argumentError(
|
|
3377
|
+
`图片格式转换失败:${path.basename(filePath)}`,
|
|
3378
|
+
`需要把该图片转换为 ${target} 后提交。请确认本机可用 ffmpeg,或手动转换后重试。${error?.message ? ` 原因:${error.message}` : ''}`,
|
|
3379
|
+
);
|
|
3380
|
+
}
|
|
3381
|
+
const inspected = await inspectLocalFile(outputPath);
|
|
3382
|
+
if (!inspected.exists) {
|
|
3383
|
+
throw argumentError(`图片格式转换失败:${path.basename(filePath)}`, `未生成 ${target} 文件。`);
|
|
3384
|
+
}
|
|
3385
|
+
return inspected;
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
async function inspectAssetLocalFile(localFile) {
|
|
3389
|
+
const inspected = await inspectLocalFile(localFile);
|
|
3390
|
+
if (!inspected.exists) throw argumentError(`文件不存在:${localFile}`);
|
|
3391
|
+
const ext = sourceExtension(inspected.filePath);
|
|
3392
|
+
const assetType = inferAssetTypeFromSource(inspected.filePath);
|
|
3393
|
+
if (assetType === ASSET_TYPE_CODES.Image) {
|
|
3394
|
+
const media = await readImageMetadata(inspected.filePath);
|
|
3395
|
+
const validation = validateAssetViolations(assetType, inspected, media);
|
|
3396
|
+
return {
|
|
3397
|
+
...validation,
|
|
3398
|
+
localFile: inspected,
|
|
3399
|
+
media,
|
|
3400
|
+
conversionPlan: validation.legal ? null : buildAssetConversionPlan(validation),
|
|
3401
|
+
};
|
|
3402
|
+
}
|
|
3403
|
+
if (assetType === ASSET_TYPE_CODES.Video) {
|
|
3404
|
+
const media = await readMediaMetadata(inspected.filePath, assetType);
|
|
3405
|
+
const validation = validateAssetViolations(assetType, inspected, media);
|
|
3406
|
+
return {
|
|
3407
|
+
...validation,
|
|
3408
|
+
localFile: inspected,
|
|
3409
|
+
media,
|
|
3410
|
+
conversionPlan: validation.legal ? null : buildAssetConversionPlan(validation),
|
|
3411
|
+
};
|
|
3412
|
+
}
|
|
3413
|
+
if (assetType === ASSET_TYPE_CODES.Audio) {
|
|
3414
|
+
const media = await readMediaMetadata(inspected.filePath, assetType);
|
|
3415
|
+
const validation = validateAssetViolations(assetType, inspected, media);
|
|
3416
|
+
return {
|
|
3417
|
+
...validation,
|
|
3418
|
+
localFile: inspected,
|
|
3419
|
+
media,
|
|
3420
|
+
conversionPlan: validation.legal ? null : buildAssetConversionPlan(validation),
|
|
3421
|
+
};
|
|
3422
|
+
}
|
|
3423
|
+
throw argumentError(`不支持的素材格式:${ext}`);
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
function assetValidationSummary(validation) {
|
|
3427
|
+
return validation.violations.map((item) => item.message).join(';');
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
function assetConversionUnavailableError(validation) {
|
|
3431
|
+
const summary = assetValidationSummary(validation);
|
|
3432
|
+
return argumentError(
|
|
3433
|
+
`素材不符合 JIMENG 加白规格:${summary || '规格不合法'}`,
|
|
3434
|
+
'CLI 无法自动生成转码计划,请手动转换为合法图片 / 视频 / 音频规格后重试。',
|
|
3435
|
+
);
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
async function confirmAssetConversion(validation) {
|
|
3439
|
+
if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
|
|
3440
|
+
throw argumentError(
|
|
3441
|
+
`素材不符合 JIMENG 加白规格:${assetValidationSummary(validation)}`,
|
|
3442
|
+
'非交互终端不会自动询问。请在终端中重试并确认转码,或追加 --auto-convert 自动转换后继续。',
|
|
3443
|
+
);
|
|
3444
|
+
}
|
|
3445
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3446
|
+
try {
|
|
3447
|
+
const answer = await rl.question(`素材不符合 JIMENG 加白规格:${assetValidationSummary(validation)}\n是否自动转码为合法规格后继续?[y/N] `);
|
|
3448
|
+
return ['y', 'yes'].includes(String(answer || '').trim().toLowerCase());
|
|
3449
|
+
} finally {
|
|
3450
|
+
rl.close();
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
async function convertAssetLocalFile(validation) {
|
|
3455
|
+
const plan = validation.conversionPlan;
|
|
3456
|
+
if (!plan) throw assetConversionUnavailableError(validation);
|
|
3457
|
+
let converted;
|
|
3458
|
+
if (validation.assetType === ASSET_TYPE_CODES.Image) {
|
|
3459
|
+
converted = await convertLocalImageFile(validation.localFile.filePath, plan.toFormat, { filters: plan.filters });
|
|
3460
|
+
} else if (validation.assetType === ASSET_TYPE_CODES.Video) {
|
|
3461
|
+
converted = await convertLocalVideoFile(validation.localFile.filePath, plan);
|
|
3462
|
+
} else if (validation.assetType === ASSET_TYPE_CODES.Audio) {
|
|
3463
|
+
converted = await convertLocalAudioFile(validation.localFile.filePath, plan);
|
|
3464
|
+
} else {
|
|
3465
|
+
throw assetConversionUnavailableError(validation);
|
|
3466
|
+
}
|
|
3467
|
+
const nextValidation = await inspectAssetLocalFile(converted.filePath);
|
|
3468
|
+
if (!nextValidation.legal) {
|
|
3469
|
+
await fs.rm(converted.filePath, { force: true }).catch(() => {});
|
|
3470
|
+
throw argumentError(
|
|
3471
|
+
`素材自动转码后仍不符合加白规格:${assetValidationSummary(nextValidation)}`,
|
|
3472
|
+
'请手动转换为合法规格后重试,或检查原始素材是否损坏。',
|
|
3473
|
+
);
|
|
3474
|
+
}
|
|
3475
|
+
return {
|
|
3476
|
+
...nextValidation,
|
|
3477
|
+
originalLocalFile: validation.localFile,
|
|
3478
|
+
conversion: compactRecord({
|
|
3479
|
+
...plan,
|
|
3480
|
+
converted: true,
|
|
3481
|
+
convertedFile: converted.filePath,
|
|
3482
|
+
convertedSize: converted.size,
|
|
3483
|
+
originalSize: validation.localFile.size,
|
|
3484
|
+
}),
|
|
3485
|
+
};
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
async function resolveInvalidAssetLocalFile(validation, kwargs = {}) {
|
|
3489
|
+
if (validation.legal) return validation;
|
|
3490
|
+
if (!validation.conversionPlan) throw assetConversionUnavailableError(validation);
|
|
3491
|
+
const shouldConvert = toBool(kwargs.autoConvert) || await confirmAssetConversion(validation);
|
|
3492
|
+
if (!shouldConvert) {
|
|
3493
|
+
throw argumentError(
|
|
3494
|
+
`素材不符合 JIMENG 加白规格:${assetValidationSummary(validation)}`,
|
|
3495
|
+
'已取消自动转码。请手动转换素材,或追加 --auto-convert 自动转换后继续。',
|
|
3496
|
+
);
|
|
3497
|
+
}
|
|
3498
|
+
return await convertAssetLocalFile(validation);
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
function validateAssetRemoteSource(assetPath) {
|
|
3502
|
+
return { assetType: inferAssetTypeFromSource(assetPath) };
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
export async function uploadFilesCommand(kwargs = {}) {
|
|
3506
|
+
const specs = await collectFileSpecs(kwargs, trimToNull(kwargs.sceneType));
|
|
3507
|
+
if (!specs.length) throw argumentError('缺少上传文件', '传 --file <path> 或 --files a.png,b.mp4。');
|
|
3508
|
+
if (toBool(kwargs.dryRun)) {
|
|
3509
|
+
const files = [];
|
|
3510
|
+
for (const spec of specs) {
|
|
3511
|
+
const inspected = await inspectLocalFile(spec.file);
|
|
3512
|
+
files.push(compactRecord({
|
|
3513
|
+
...inspected,
|
|
3514
|
+
sceneType: spec.sceneType,
|
|
3515
|
+
projectNo: spec.projectNo,
|
|
3516
|
+
backendPath: dryRunBackendPath(spec.file, spec.sceneType),
|
|
3517
|
+
url: null,
|
|
3518
|
+
dryRun: true,
|
|
3519
|
+
}));
|
|
3520
|
+
}
|
|
3521
|
+
return { dryRun: true, files };
|
|
3522
|
+
}
|
|
3523
|
+
const files = [];
|
|
3524
|
+
for (const spec of specs) {
|
|
3525
|
+
files.push(await uploadLocalFile(spec.file, spec));
|
|
3526
|
+
}
|
|
3527
|
+
return { files };
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
export async function uploadLocalFile(filePath, options = {}) {
|
|
3531
|
+
const inspected = await inspectLocalFile(filePath);
|
|
3532
|
+
if (!inspected.exists) {
|
|
3533
|
+
throw argumentError(`文件不存在:${filePath}`);
|
|
3534
|
+
}
|
|
3535
|
+
const buffer = await fs.readFile(inspected.filePath);
|
|
3536
|
+
const sceneType = options.sceneType ?? defaultUploadSceneForFile(inspected.filePath, inspected.mimeType);
|
|
3537
|
+
const groupId = crypto.randomUUID().replaceAll('-', '');
|
|
3538
|
+
const secret = await awbApi.fetchUploadSecret({
|
|
3539
|
+
sceneType,
|
|
3540
|
+
groupId,
|
|
3541
|
+
projectNo: options.projectNo ?? '',
|
|
3542
|
+
});
|
|
3543
|
+
const credentials = secret.credentials ?? secret;
|
|
3544
|
+
const objectName = `${secret.path ?? ''}${secret.prefix ?? ''}${Date.now()}-${safeFileName(inspected.filePath)}`.replace(/^\/+/, '');
|
|
3545
|
+
const host = `${secret.bucket}.cos.${secret.region}.myqcloud.com`;
|
|
3546
|
+
const authorization = buildCosAuthorization({
|
|
3547
|
+
secretKey: credentials.tmpSecretKey,
|
|
3548
|
+
secretId: credentials.tmpSecretId,
|
|
3549
|
+
method: 'PUT',
|
|
3550
|
+
objectName,
|
|
3551
|
+
contentLength: buffer.length,
|
|
3552
|
+
host,
|
|
3553
|
+
startTime: secret.startTime,
|
|
3554
|
+
expiredTime: secret.expiredTime,
|
|
3555
|
+
});
|
|
3556
|
+
const response = await fetch(`https://${host}/${encodeObjectNamePath(objectName)}`, {
|
|
3557
|
+
method: 'PUT',
|
|
3558
|
+
headers: {
|
|
3559
|
+
authorization,
|
|
3560
|
+
'content-length': String(buffer.length),
|
|
3561
|
+
'content-type': inspected.mimeType || guessMimeType(inspected.filePath),
|
|
3562
|
+
host,
|
|
3563
|
+
'x-cos-security-token': credentials.sessionToken,
|
|
3564
|
+
},
|
|
3565
|
+
body: buffer,
|
|
3566
|
+
});
|
|
3567
|
+
if (!response.ok) {
|
|
3568
|
+
throw new LingjingAwbCliError(`上传 COS 失败:${response.status} ${response.statusText}`, {
|
|
3569
|
+
type: 'upload_failed',
|
|
3570
|
+
exitCode: 30,
|
|
3571
|
+
details: { filePath: inspected.filePath, objectName },
|
|
3572
|
+
});
|
|
3573
|
+
}
|
|
3574
|
+
return compactRecord({
|
|
3575
|
+
...inspected,
|
|
3576
|
+
sceneType,
|
|
3577
|
+
projectNo: options.projectNo ?? '',
|
|
3578
|
+
backendPath: `/${objectName}`,
|
|
3579
|
+
url: `https://${host}/${encodeObjectNamePath(objectName)}`,
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
function isHttpUrl(value) {
|
|
3584
|
+
return /^https?:\/\//i.test(String(value || '').trim());
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
function backendObjectName(value) {
|
|
3588
|
+
const text = trimToNull(value);
|
|
3589
|
+
if (!text) return null;
|
|
3590
|
+
return text.replace(/^\/+/, '');
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
function isPlatformBackendPath(value) {
|
|
3594
|
+
const objectName = backendObjectName(value);
|
|
3595
|
+
if (!objectName || /^[a-z][a-z0-9+.-]*:\/\//i.test(objectName)) return false;
|
|
3596
|
+
return PLATFORM_BACKEND_PATH_PREFIXES.some((prefix) => objectName.startsWith(prefix));
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
function isUploadedMaterialReference(value) {
|
|
3600
|
+
const text = trimToNull(value);
|
|
3601
|
+
if (!text) return false;
|
|
3602
|
+
if (isPlatformBackendPath(text)) return true;
|
|
3603
|
+
if (!isHttpUrl(text)) return false;
|
|
3604
|
+
try {
|
|
3605
|
+
const url = new URL(text);
|
|
3606
|
+
return url.hostname.endsWith('.myqcloud.com') && isPlatformBackendPath(decodeURIComponent(url.pathname));
|
|
3607
|
+
} catch {
|
|
3608
|
+
return false;
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
function remoteFileNameFromUrl(remoteUrl, fallback = 'voice-audio') {
|
|
3613
|
+
try {
|
|
3614
|
+
const pathname = decodeURIComponent(new URL(remoteUrl).pathname);
|
|
3615
|
+
const baseName = safeFileName(path.basename(pathname));
|
|
3616
|
+
if (baseName && baseName !== '.' && baseName !== '/') return baseName;
|
|
3617
|
+
} catch {}
|
|
3618
|
+
return `${fallback}.mp3`;
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
function extensionFromContentType(contentType = '') {
|
|
3622
|
+
const normalized = String(contentType || '').split(';')[0].trim().toLowerCase();
|
|
3623
|
+
if (normalized === 'image/bmp') return '.bmp';
|
|
3624
|
+
if (normalized === 'image/gif') return '.gif';
|
|
3625
|
+
if (normalized === 'image/jpeg' || normalized === 'image/jpg') return '.jpg';
|
|
3626
|
+
if (normalized === 'image/png') return '.png';
|
|
3627
|
+
if (normalized === 'image/webp') return '.webp';
|
|
3628
|
+
if (normalized === 'audio/mpeg') return '.mp3';
|
|
3629
|
+
if (normalized === 'audio/wav' || normalized === 'audio/x-wav') return '.wav';
|
|
3630
|
+
if (normalized === 'audio/mp4') return '.m4a';
|
|
3631
|
+
if (normalized === 'audio/aac') return '.aac';
|
|
3632
|
+
if (normalized === 'audio/ogg') return '.ogg';
|
|
3633
|
+
if (normalized === 'video/mp4') return '.mp4';
|
|
3634
|
+
return '';
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
function isSupportedRemoteVoiceDownload(contentType = '', fileName = '') {
|
|
3638
|
+
const normalized = String(contentType || '').split(';')[0].trim().toLowerCase();
|
|
3639
|
+
const extension = path.extname(fileName).toLowerCase();
|
|
3640
|
+
if (REMOTE_VOICE_MIME_TYPES.has(normalized)) return true;
|
|
3641
|
+
if ((!normalized || normalized === 'application/octet-stream') && REMOTE_VOICE_EXTENSIONS.has(extension)) return true;
|
|
3642
|
+
return false;
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
function remoteImageFileNameFromValue(value, fallback = 'resource-image') {
|
|
3646
|
+
const text = trimToNull(value);
|
|
3647
|
+
if (text) {
|
|
3648
|
+
try {
|
|
3649
|
+
const pathname = decodeURIComponent(new URL(text).pathname);
|
|
3650
|
+
const baseName = safeFileName(path.basename(pathname));
|
|
3651
|
+
if (baseName && baseName !== '.' && baseName !== '/') return baseName;
|
|
3652
|
+
} catch {
|
|
3653
|
+
const baseName = safeFileName(path.basename(text.split('?')[0].split('#')[0]));
|
|
3654
|
+
if (baseName && baseName !== '.' && baseName !== '/') return baseName;
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
return fallback;
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
function extensionForImageFormat(format) {
|
|
3661
|
+
const normalized = normalizeFileFormat(format);
|
|
3662
|
+
if (!normalized) return '';
|
|
3663
|
+
return normalized === 'jpg' ? '.jpg' : `.${normalized}`;
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
function forceFileExtension(fileName, extension) {
|
|
3667
|
+
const safeName = safeFileName(fileName || 'resource-image');
|
|
3668
|
+
if (!extension) return safeName;
|
|
3669
|
+
return `${path.basename(safeName, path.extname(safeName))}${extension}`;
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
function isSupportedRemoteImageDownload(contentType = '', fileName = '', expectedFormat = null) {
|
|
3673
|
+
const normalized = String(contentType || '').split(';')[0].trim().toLowerCase();
|
|
3674
|
+
const extension = path.extname(fileName).toLowerCase();
|
|
3675
|
+
if (REMOTE_IMAGE_MIME_TYPES.has(normalized)) return true;
|
|
3676
|
+
if ((!normalized || normalized === 'application/octet-stream') && REMOTE_IMAGE_EXTENSIONS.has(extension)) return true;
|
|
3677
|
+
if ((!normalized || normalized === 'application/octet-stream') && expectedFormat && COMMON_IMAGE_FORMATS.has(normalizeFileFormat(expectedFormat))) return true;
|
|
3678
|
+
return false;
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
function firstHttpUrl(value, depth = 0) {
|
|
3682
|
+
if (depth > 4 || value == null) return null;
|
|
3683
|
+
if (typeof value === 'string') return /^https?:\/\//i.test(value) ? value : null;
|
|
3684
|
+
if (Array.isArray(value)) {
|
|
3685
|
+
for (const item of value) {
|
|
3686
|
+
const found = firstHttpUrl(item, depth + 1);
|
|
3687
|
+
if (found) return found;
|
|
3688
|
+
}
|
|
3689
|
+
return null;
|
|
3690
|
+
}
|
|
3691
|
+
if (typeof value !== 'object') return null;
|
|
3692
|
+
for (const key of ['url', 'signedUrl', 'signUrl', 'downloadUrl', 'cosUrl', 'previewUrl', 'data']) {
|
|
3693
|
+
const found = firstHttpUrl(value[key], depth + 1);
|
|
3694
|
+
if (found) return found;
|
|
3695
|
+
}
|
|
3696
|
+
for (const nested of Object.values(value)) {
|
|
3697
|
+
const found = firstHttpUrl(nested, depth + 1);
|
|
3698
|
+
if (found) return found;
|
|
3699
|
+
}
|
|
3700
|
+
return null;
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
async function resolveRemoteResourceDownloadUrl(value) {
|
|
3704
|
+
const text = trimToNull(value);
|
|
3705
|
+
if (!text) throw argumentError('远程图片来源为空');
|
|
3706
|
+
if (/^https?:\/\//i.test(text)) return text;
|
|
3707
|
+
if (isPlatformBackendPath(text)) {
|
|
3708
|
+
const objectName = backendObjectName(text);
|
|
3709
|
+
const signed = await awbApi.fetchObjectSignUrl(objectName);
|
|
3710
|
+
const signedUrl = firstHttpUrl(signed);
|
|
3711
|
+
if (signedUrl) return signedUrl;
|
|
3712
|
+
throw new LingjingAwbCliError('获取素材签名 URL 失败', {
|
|
3713
|
+
type: 'api_error',
|
|
3714
|
+
exitCode: 30,
|
|
3715
|
+
details: { objectName, payload: signed },
|
|
3716
|
+
});
|
|
3717
|
+
}
|
|
3718
|
+
throw argumentError(
|
|
3719
|
+
`远程图片来源不支持自动转换:${text}`,
|
|
3720
|
+
'请传 http(s) URL、平台 backendPath,或先下载为本地文件后重试。',
|
|
3721
|
+
);
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
async function downloadRemoteImageFileToTemp(sourceValue, options = {}) {
|
|
3725
|
+
const timeoutMs = Math.max(1_000, toInt(options.timeoutMs, REMOTE_IMAGE_DOWNLOAD_TIMEOUT_MS));
|
|
3726
|
+
const maxBytes = Math.max(1, toInt(options.maxBytes, REMOTE_IMAGE_DOWNLOAD_MAX_BYTES));
|
|
3727
|
+
const remoteUrl = await resolveRemoteResourceDownloadUrl(sourceValue);
|
|
3728
|
+
const controller = new AbortController();
|
|
3729
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
3730
|
+
let dir = null;
|
|
3731
|
+
try {
|
|
3732
|
+
const response = await fetch(remoteUrl, { signal: controller.signal });
|
|
3733
|
+
if (!response.ok) {
|
|
3734
|
+
throw new LingjingAwbCliError(`下载远程图片失败:${response.status} ${response.statusText}`, {
|
|
3735
|
+
type: 'http_error',
|
|
3736
|
+
exitCode: 30,
|
|
3737
|
+
details: { url: sourceValue, status: response.status },
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
const contentType = response.headers.get('content-type') || '';
|
|
3741
|
+
const expectedExtension = extensionForImageFormat(options.expectedFormat);
|
|
3742
|
+
let fileName = remoteImageFileNameFromValue(sourceValue, options.fallbackName || 'resource-image');
|
|
3743
|
+
fileName = expectedExtension
|
|
3744
|
+
? forceFileExtension(fileName, expectedExtension)
|
|
3745
|
+
: (!path.extname(fileName) ? `${fileName}${extensionFromContentType(contentType) || '.img'}` : fileName);
|
|
3746
|
+
if (!isSupportedRemoteImageDownload(contentType, fileName, options.expectedFormat)) {
|
|
3747
|
+
throw argumentError(
|
|
3748
|
+
`远程图片类型不支持:${contentType || path.extname(fileName) || 'unknown'}`,
|
|
3749
|
+
'自动格式转换只支持常见图片输入,请先手动转换为 jpg/png/webp 后重试。',
|
|
3750
|
+
);
|
|
3751
|
+
}
|
|
3752
|
+
const contentLength = toInt(response.headers.get('content-length'), 0);
|
|
3753
|
+
if (contentLength > maxBytes) {
|
|
3754
|
+
throw argumentError(`远程图片过大:${contentLength} bytes`, `最大支持 ${maxBytes} bytes。`);
|
|
3755
|
+
}
|
|
3756
|
+
dir = await fs.mkdtemp(path.join(tmpdir(), 'lj-awb-image-'));
|
|
3757
|
+
const filePath = path.join(dir, fileName);
|
|
3758
|
+
const file = await fs.open(filePath, 'w');
|
|
3759
|
+
let size = 0;
|
|
3760
|
+
try {
|
|
3761
|
+
if (!response.body) throw new Error('empty response body');
|
|
3762
|
+
for await (const chunk of response.body) {
|
|
3763
|
+
const buffer = Buffer.from(chunk);
|
|
3764
|
+
size += buffer.length;
|
|
3765
|
+
if (size > maxBytes) {
|
|
3766
|
+
throw argumentError(`远程图片过大:超过 ${maxBytes} bytes`);
|
|
3767
|
+
}
|
|
3768
|
+
await file.write(buffer);
|
|
3769
|
+
}
|
|
3770
|
+
} finally {
|
|
3771
|
+
await file.close();
|
|
3772
|
+
}
|
|
3773
|
+
return { filePath, dir, contentType, size };
|
|
3774
|
+
} catch (error) {
|
|
3775
|
+
if (dir) await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
3776
|
+
if (error instanceof LingjingAwbCliError) throw error;
|
|
3777
|
+
const isAbort = error?.name === 'AbortError';
|
|
3778
|
+
throw new LingjingAwbCliError(`下载远程图片失败:${error.message}`, {
|
|
3779
|
+
type: 'network_error',
|
|
3780
|
+
exitCode: 30,
|
|
3781
|
+
hint: isAbort ? `远程下载超过 ${timeoutMs}ms,请改用本地文件或已上传平台 backendPath。` : '',
|
|
3782
|
+
details: { url: sourceValue, causeCode: error?.cause?.code },
|
|
3783
|
+
});
|
|
3784
|
+
} finally {
|
|
3785
|
+
clearTimeout(timeout);
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
async function downloadRemoteFileToTemp(remoteUrl, options = {}) {
|
|
3790
|
+
const timeoutMs = Math.max(1_000, toInt(options.timeoutMs, REMOTE_VOICE_DOWNLOAD_TIMEOUT_MS));
|
|
3791
|
+
const maxBytes = Math.max(1, toInt(options.maxBytes, REMOTE_VOICE_DOWNLOAD_MAX_BYTES));
|
|
3792
|
+
const controller = new AbortController();
|
|
3793
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
3794
|
+
let dir = null;
|
|
3795
|
+
let response;
|
|
3796
|
+
try {
|
|
3797
|
+
response = await fetch(remoteUrl, { signal: controller.signal });
|
|
3798
|
+
if (!response.ok) {
|
|
2401
3799
|
throw new LingjingAwbCliError(`下载远程音频失败:${response.status} ${response.statusText}`, {
|
|
2402
3800
|
type: 'http_error',
|
|
2403
3801
|
exitCode: 30,
|
|
@@ -2442,7 +3840,7 @@ async function downloadRemoteFileToTemp(remoteUrl, options = {}) {
|
|
|
2442
3840
|
throw new LingjingAwbCliError(`下载远程音频失败:${error.message}`, {
|
|
2443
3841
|
type: 'network_error',
|
|
2444
3842
|
exitCode: 30,
|
|
2445
|
-
hint: isAbort ? `远程下载超过 ${timeoutMs}ms,请改用本地 --file
|
|
3843
|
+
hint: isAbort ? `远程下载超过 ${timeoutMs}ms,请改用本地 --file 或已上传平台 backendPath。` : '',
|
|
2446
3844
|
details: { url: remoteUrl, causeCode: error?.cause?.code },
|
|
2447
3845
|
});
|
|
2448
3846
|
} finally {
|
|
@@ -2488,6 +3886,59 @@ function normalizeUnifiedPromptParams(promptParams) {
|
|
|
2488
3886
|
return promptParams;
|
|
2489
3887
|
}
|
|
2490
3888
|
|
|
3889
|
+
async function parseModelParamsJsonArg(value) {
|
|
3890
|
+
const parsed = await readJsonMaybeFile(value, null).catch((error) => {
|
|
3891
|
+
throw argumentError(`model-params-json 解析失败:${error.message}`);
|
|
3892
|
+
});
|
|
3893
|
+
if (parsed == null) return {};
|
|
3894
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
3895
|
+
throw argumentError('model-params-json 必须是 JSON 对象');
|
|
3896
|
+
}
|
|
3897
|
+
return parsed;
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
function parseModelParamAssignments(value) {
|
|
3901
|
+
const params = {};
|
|
3902
|
+
for (const item of parseListArg(value)) {
|
|
3903
|
+
const text = trimToNull(item);
|
|
3904
|
+
if (!text) continue;
|
|
3905
|
+
const eqIndex = text.indexOf('=');
|
|
3906
|
+
if (eqIndex <= 0) {
|
|
3907
|
+
throw argumentError(`model-param 格式错误:${text}`, '格式为 --model-param key=value,可重复传。');
|
|
3908
|
+
}
|
|
3909
|
+
const key = trimToNull(text.slice(0, eqIndex));
|
|
3910
|
+
if (!key) {
|
|
3911
|
+
throw argumentError(`model-param 缺少 key:${text}`);
|
|
3912
|
+
}
|
|
3913
|
+
params[key] = text.slice(eqIndex + 1);
|
|
3914
|
+
}
|
|
3915
|
+
return params;
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
async function collectGenericModelParams(kwargs = {}) {
|
|
3919
|
+
const fromJson = await parseModelParamsJsonArg(kwargs.modelParamsJson ?? kwargs.model_params_json);
|
|
3920
|
+
const fromObject = kwargs.modelParams && typeof kwargs.modelParams === 'object' && !Array.isArray(kwargs.modelParams)
|
|
3921
|
+
? kwargs.modelParams
|
|
3922
|
+
: {};
|
|
3923
|
+
const fromAssignments = parseModelParamAssignments(kwargs.modelParam);
|
|
3924
|
+
const fromDynamicFlags = {};
|
|
3925
|
+
for (const [key, value] of Object.entries(kwargs || {})) {
|
|
3926
|
+
if (CREATE_STANDARD_KWARG_KEYS.has(key)) continue;
|
|
3927
|
+
if (!key.includes('_') && !isLikelyDynamicModelParamKey(key)) continue;
|
|
3928
|
+
fromDynamicFlags[key] = value;
|
|
3929
|
+
}
|
|
3930
|
+
return normalizeUnifiedPromptParams({
|
|
3931
|
+
...fromJson,
|
|
3932
|
+
...fromObject,
|
|
3933
|
+
...fromDynamicFlags,
|
|
3934
|
+
...fromAssignments,
|
|
3935
|
+
});
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
function isLikelyDynamicModelParamKey(key) {
|
|
3939
|
+
return /^(generation|prompt|negative|seed|style|mode|cfg|guidance|aspect|image|output|response|media|safety|watermark|camera|motion|strength|steps)[A-Z]/.test(String(key || ''));
|
|
3940
|
+
}
|
|
3941
|
+
|
|
2491
3942
|
function normalizeResourceType(type, index) {
|
|
2492
3943
|
const normalized = String(type ?? '').trim().toLowerCase();
|
|
2493
3944
|
if (!RESOURCE_TYPES.has(normalized)) {
|
|
@@ -2523,7 +3974,7 @@ function normalizeResourceSource(source, type, index, options = {}) {
|
|
|
2523
3974
|
if (!['url', 'asset_id'].includes(kind)) {
|
|
2524
3975
|
throw argumentError(
|
|
2525
3976
|
`resource[${index}] source.kind 不支持:${explicitKind}`,
|
|
2526
|
-
'只支持 url、asset_id 两个值。本地文件 / http(s) URL /
|
|
3977
|
+
'只支持 url、asset_id 两个值。本地文件 / http(s) URL / 平台 backendPath 都统一传 kind=url(CLI 按 value 自动识别);平台资产或主体对象传 kind=asset_id。',
|
|
2527
3978
|
);
|
|
2528
3979
|
}
|
|
2529
3980
|
const value = kind === 'asset_id' ? assetValue : rawValue;
|
|
@@ -2684,59 +4135,39 @@ function isRemoteOrBackendPath(value) {
|
|
|
2684
4135
|
if (!text) return false;
|
|
2685
4136
|
return /^https?:\/\//i.test(text)
|
|
2686
4137
|
|| /^[a-z][a-z0-9+.-]*:\/\//i.test(text)
|
|
2687
|
-
|| text
|
|
2688
|
-
|| text.startsWith('/material/');
|
|
4138
|
+
|| isPlatformBackendPath(text);
|
|
2689
4139
|
}
|
|
2690
4140
|
|
|
2691
|
-
function
|
|
4141
|
+
function normalizeBackendPathUrlValue(value) {
|
|
2692
4142
|
const text = trimToNull(value);
|
|
2693
4143
|
if (!text) return text;
|
|
2694
4144
|
if (/^https?:\/\//i.test(text)) {
|
|
2695
4145
|
try {
|
|
2696
4146
|
const pathname = decodeURIComponent(new URL(text).pathname);
|
|
2697
|
-
if (pathname
|
|
4147
|
+
if (isPlatformBackendPath(pathname)) return pathname;
|
|
2698
4148
|
} catch {}
|
|
2699
4149
|
}
|
|
2700
4150
|
return text;
|
|
2701
4151
|
}
|
|
2702
4152
|
|
|
4153
|
+
function displayResourceSourceValue(value) {
|
|
4154
|
+
const text = trimToNull(value);
|
|
4155
|
+
if (!text || !/^https?:\/\//i.test(text)) return text;
|
|
4156
|
+
try {
|
|
4157
|
+
const url = new URL(text);
|
|
4158
|
+
const format = imageTransformFormat(text);
|
|
4159
|
+
return `${url.origin}${decodeURIComponent(url.pathname)}${format ? `?format=${format}` : ''}`;
|
|
4160
|
+
} catch {
|
|
4161
|
+
return text.split('?')[0];
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
|
|
2703
4165
|
function conversionOutputExtension(format) {
|
|
2704
4166
|
const normalized = normalizeFileFormat(format);
|
|
2705
4167
|
if (normalized === 'jpg') return 'jpg';
|
|
2706
4168
|
return normalized || 'jpg';
|
|
2707
4169
|
}
|
|
2708
4170
|
|
|
2709
|
-
async function convertLocalImageFile(filePath, targetFormat) {
|
|
2710
|
-
const target = normalizeFileFormat(targetFormat) || 'jpg';
|
|
2711
|
-
const outputPath = path.join(tmpdir(), `lj-awb-${crypto.randomUUID()}.${conversionOutputExtension(target)}`);
|
|
2712
|
-
const codecArgs = target === 'jpg' ? ['-q:v', '2'] : [];
|
|
2713
|
-
try {
|
|
2714
|
-
await execFileAsync('ffmpeg', [
|
|
2715
|
-
'-y',
|
|
2716
|
-
'-hide_banner',
|
|
2717
|
-
'-loglevel',
|
|
2718
|
-
'error',
|
|
2719
|
-
'-i',
|
|
2720
|
-
filePath,
|
|
2721
|
-
'-frames:v',
|
|
2722
|
-
'1',
|
|
2723
|
-
...codecArgs,
|
|
2724
|
-
outputPath,
|
|
2725
|
-
]);
|
|
2726
|
-
} catch (error) {
|
|
2727
|
-
await fs.rm(outputPath, { force: true }).catch(() => {});
|
|
2728
|
-
throw argumentError(
|
|
2729
|
-
`图片格式转换失败:${path.basename(filePath)}`,
|
|
2730
|
-
`需要把该帧图片转换为 ${target} 后提交。请确认本机可用 ffmpeg,或手动转换后重试。${error?.message ? ` 原因:${error.message}` : ''}`,
|
|
2731
|
-
);
|
|
2732
|
-
}
|
|
2733
|
-
const inspected = await inspectLocalFile(outputPath);
|
|
2734
|
-
if (!inspected.exists) {
|
|
2735
|
-
throw argumentError(`图片格式转换失败:${path.basename(filePath)}`, `未生成 ${target} 文件。`);
|
|
2736
|
-
}
|
|
2737
|
-
return inspected;
|
|
2738
|
-
}
|
|
2739
|
-
|
|
2740
4171
|
async function resolveResourceFileValue(resource, options = {}) {
|
|
2741
4172
|
if (resource.type === 'subject') {
|
|
2742
4173
|
if (resource._source.kind !== 'asset_id') throw argumentError('subject resource 必须使用 asset_id,例如 subject:reference:hero=asset:element_123');
|
|
@@ -2764,13 +4195,67 @@ async function resolveResourceFileValue(resource, options = {}) {
|
|
|
2764
4195
|
}
|
|
2765
4196
|
const value = resource._source.value;
|
|
2766
4197
|
if (isRemoteOrBackendPath(value)) {
|
|
2767
|
-
|
|
4198
|
+
const source = { kind: 'url', value };
|
|
4199
|
+
const conversion = options.resourceRules
|
|
4200
|
+
? resourceFormatConversion(
|
|
4201
|
+
options.resourceRules.find((item) => resourceRuleMatches(item, { ...base, source })),
|
|
4202
|
+
{ resource: { ...base, source } },
|
|
4203
|
+
)
|
|
4204
|
+
: null;
|
|
4205
|
+
if (conversion) {
|
|
4206
|
+
const conversionRecord = compactRecord({
|
|
4207
|
+
resource: resourceText(base),
|
|
4208
|
+
fromFormat: conversion.fromFormat,
|
|
4209
|
+
toFormat: conversion.toFormat,
|
|
4210
|
+
reason: conversion.reason,
|
|
4211
|
+
sourceValue: displayResourceSourceValue(value),
|
|
4212
|
+
dryRun: options.dryRun || undefined,
|
|
4213
|
+
});
|
|
4214
|
+
const convertedName = forceFileExtension(
|
|
4215
|
+
remoteImageFileNameFromValue(value, resourceText(base).replace(/[^0-9A-Za-z._-]+/g, '-')),
|
|
4216
|
+
extensionForImageFormat(conversion.toFormat),
|
|
4217
|
+
);
|
|
4218
|
+
if (options.dryRun) {
|
|
4219
|
+
return {
|
|
4220
|
+
resource: { ...base, source: { kind: 'url', value: dryRunBackendPath(convertedName, options.sceneType) } },
|
|
4221
|
+
upload: null,
|
|
4222
|
+
localFile: null,
|
|
4223
|
+
conversion: conversionRecord,
|
|
4224
|
+
};
|
|
4225
|
+
}
|
|
4226
|
+
const downloaded = await downloadRemoteImageFileToTemp(value, {
|
|
4227
|
+
expectedFormat: conversion.fromFormat,
|
|
4228
|
+
fallbackName: resourceText(base).replace(/[^0-9A-Za-z._-]+/g, '-'),
|
|
4229
|
+
});
|
|
4230
|
+
try {
|
|
4231
|
+
const converted = await convertLocalImageFile(downloaded.filePath, conversion.toFormat);
|
|
4232
|
+
try {
|
|
4233
|
+
const upload = await uploadLocalFile(converted.filePath, { sceneType: options.sceneType });
|
|
4234
|
+
return {
|
|
4235
|
+
resource: { ...base, source: { kind: 'url', value: upload.backendPath } },
|
|
4236
|
+
upload,
|
|
4237
|
+
localFile: null,
|
|
4238
|
+
conversion: {
|
|
4239
|
+
...conversionRecord,
|
|
4240
|
+
sourceSize: downloaded.size,
|
|
4241
|
+
convertedFile: converted.filePath,
|
|
4242
|
+
convertedSize: converted.size,
|
|
4243
|
+
},
|
|
4244
|
+
};
|
|
4245
|
+
} finally {
|
|
4246
|
+
await fs.rm(converted.filePath, { force: true }).catch(() => {});
|
|
4247
|
+
}
|
|
4248
|
+
} finally {
|
|
4249
|
+
await fs.rm(downloaded.dir, { recursive: true, force: true }).catch(() => {});
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
return { resource: { ...base, source: { kind: 'url', value: normalizeBackendPathUrlValue(value) } }, upload: null, localFile: null };
|
|
2768
4253
|
}
|
|
2769
4254
|
const inspected = await inspectLocalFile(value);
|
|
2770
4255
|
if (!inspected.exists) {
|
|
2771
4256
|
throw argumentError(
|
|
2772
4257
|
`资源文件不存在:${value}`,
|
|
2773
|
-
'source.kind=url 只接受完整 http(s) URL
|
|
4258
|
+
'source.kind=url 只接受完整 http(s) URL、平台 backendPath,或当前工作目录下存在的本地文件路径。',
|
|
2774
4259
|
);
|
|
2775
4260
|
}
|
|
2776
4261
|
const conversion = options.resourceRules
|
|
@@ -2893,7 +4378,9 @@ async function buildImageRequest(kwargs = {}, options = {}) {
|
|
|
2893
4378
|
kind: 'image',
|
|
2894
4379
|
sceneType: TASK_UPLOAD_SCENE.IMAGE_CREATE,
|
|
2895
4380
|
});
|
|
4381
|
+
const genericModelParams = await collectGenericModelParams(kwargs);
|
|
2896
4382
|
const defaultParams = {
|
|
4383
|
+
...genericModelParams,
|
|
2897
4384
|
prompt,
|
|
2898
4385
|
resources: validationResources.resources,
|
|
2899
4386
|
};
|
|
@@ -3071,7 +4558,9 @@ async function buildVideoRequest(kwargs = {}, options = {}) {
|
|
|
3071
4558
|
throw argumentError('缺少视频生成输入', '至少传 --prompt 或 --resource。');
|
|
3072
4559
|
}
|
|
3073
4560
|
assertVideoReferenceKeysUsed(prompt, validationResources.resources);
|
|
4561
|
+
const genericModelParams = await collectGenericModelParams(kwargs);
|
|
3074
4562
|
const defaultParams = {
|
|
4563
|
+
...genericModelParams,
|
|
3075
4564
|
prompt,
|
|
3076
4565
|
resources: validationResources.resources,
|
|
3077
4566
|
};
|
|
@@ -3202,6 +4691,11 @@ const IMAGE_BATCH_ITEM_KEYS = new Set([
|
|
|
3202
4691
|
'quality',
|
|
3203
4692
|
'generate_num',
|
|
3204
4693
|
'generateNum',
|
|
4694
|
+
'model_param',
|
|
4695
|
+
'modelParam',
|
|
4696
|
+
'model_params',
|
|
4697
|
+
'modelParams',
|
|
4698
|
+
'modelParamsJson',
|
|
3205
4699
|
'resource',
|
|
3206
4700
|
'resources',
|
|
3207
4701
|
'resourcesJson',
|
|
@@ -3214,6 +4708,11 @@ const VIDEO_BATCH_ITEM_KEYS = new Set([
|
|
|
3214
4708
|
'duration',
|
|
3215
4709
|
'need_audio',
|
|
3216
4710
|
'needAudio',
|
|
4711
|
+
'model_param',
|
|
4712
|
+
'modelParam',
|
|
4713
|
+
'model_params',
|
|
4714
|
+
'modelParams',
|
|
4715
|
+
'modelParamsJson',
|
|
3217
4716
|
'resource',
|
|
3218
4717
|
'resources',
|
|
3219
4718
|
'resourcesJson',
|
|
@@ -3225,18 +4724,21 @@ function normalizeTaskBatchItem(item, kind, index) {
|
|
|
3225
4724
|
throw argumentError(`批量输入第 ${index + 1} 项必须是 JSON 对象或纯文本 prompt`);
|
|
3226
4725
|
}
|
|
3227
4726
|
const allowedKeys = kind === 'image' ? IMAGE_BATCH_ITEM_KEYS : VIDEO_BATCH_ITEM_KEYS;
|
|
3228
|
-
const unknownKeys = Object.keys(item).filter((key) => !allowedKeys.has(key));
|
|
4727
|
+
const unknownKeys = Object.keys(item).filter((key) => !allowedKeys.has(key) && !key.includes('_'));
|
|
3229
4728
|
if (unknownKeys.length) {
|
|
3230
4729
|
throw argumentError(
|
|
3231
4730
|
`批量输入第 ${index + 1} 项存在未知字段:${unknownKeys.join(', ')}`,
|
|
3232
4731
|
kind === 'image'
|
|
3233
|
-
? '生图批量项只接受 prompt、ratio、quality、generate_num、resources、resource、customBizId
|
|
3234
|
-
: '生视频批量项只接受 prompt、ratio、quality、duration、need_audio、resources、resource、customBizId
|
|
4732
|
+
? '生图批量项只接受 prompt、ratio、quality、generate_num、resources、resource、model_params、customBizId,或 model options.params[] 中的下划线参数。'
|
|
4733
|
+
: '生视频批量项只接受 prompt、ratio、quality、duration、need_audio、resources、resource、model_params、customBizId,或 model options.params[] 中的下划线参数。',
|
|
3235
4734
|
);
|
|
3236
4735
|
}
|
|
3237
4736
|
const next = { ...item };
|
|
3238
4737
|
if (next.generate_num != null && next.generateNum == null) next.generateNum = next.generate_num;
|
|
3239
4738
|
if (next.need_audio != null && next.needAudio == null) next.needAudio = next.need_audio;
|
|
4739
|
+
if (next.model_param != null && next.modelParam == null) next.modelParam = next.model_param;
|
|
4740
|
+
if (next.model_params != null && next.modelParams == null) next.modelParams = next.model_params;
|
|
4741
|
+
if (next.model_params_json != null && next.modelParamsJson == null) next.modelParamsJson = next.model_params_json;
|
|
3240
4742
|
return next;
|
|
3241
4743
|
}
|
|
3242
4744
|
|
|
@@ -3491,7 +4993,8 @@ function normalizeCosAssetPath(value) {
|
|
|
3491
4993
|
if (!text) return null;
|
|
3492
4994
|
if (/^https?:\/\//i.test(text)) {
|
|
3493
4995
|
try {
|
|
3494
|
-
|
|
4996
|
+
const pathname = decodeURIComponent(new URL(text).pathname);
|
|
4997
|
+
return isPlatformBackendPath(pathname) ? pathname.replace(/^\/+/, '') : text;
|
|
3495
4998
|
} catch {
|
|
3496
4999
|
return text.replace(/^\/+/, '');
|
|
3497
5000
|
}
|
|
@@ -3779,37 +5282,68 @@ export async function assetRegister(kwargs = {}) {
|
|
|
3779
5282
|
const localFile = trimToNull(kwargs.file);
|
|
3780
5283
|
const assetPath = trimToNull(kwargs.backendPath) ?? normalizeCosAssetPath(kwargs.url);
|
|
3781
5284
|
if (!localFile && !assetPath) throw argumentError('缺少素材路径', '传 --file、--backend-path 或 --url。');
|
|
5285
|
+
let source = localFile ? await inspectAssetLocalFile(localFile) : validateAssetRemoteSource(assetPath);
|
|
3782
5286
|
if (toBool(kwargs.dryRun)) {
|
|
5287
|
+
const dryRunFileName = localFile ? dryRunAssetFileName(source) : null;
|
|
5288
|
+
const url = localFile ? normalizeCosAssetPath(dryRunBackendPath(dryRunFileName, TASK_UPLOAD_SCENE.ASSET_REVIEW)) : assetPath;
|
|
3783
5289
|
return {
|
|
3784
5290
|
dryRun: true,
|
|
3785
5291
|
action: 'create asset',
|
|
5292
|
+
groupId,
|
|
5293
|
+
name,
|
|
5294
|
+
platform,
|
|
5295
|
+
assetType: source.assetType,
|
|
5296
|
+
assetPath: url,
|
|
3786
5297
|
request: {
|
|
3787
5298
|
assetGroupsId: groupId,
|
|
3788
|
-
url
|
|
5299
|
+
url,
|
|
3789
5300
|
name,
|
|
3790
5301
|
platform,
|
|
5302
|
+
assetType: source.assetType,
|
|
3791
5303
|
},
|
|
3792
|
-
localFile: localFile
|
|
5304
|
+
localFile: source.localFile ?? null,
|
|
5305
|
+
originalLocalFile: source.originalLocalFile ?? null,
|
|
5306
|
+
media: source.media ?? null,
|
|
5307
|
+
validation: source.legal === false ? {
|
|
5308
|
+
legal: false,
|
|
5309
|
+
violations: source.violations,
|
|
5310
|
+
} : { legal: true },
|
|
5311
|
+
conversionPlan: source.conversionPlan ?? null,
|
|
3793
5312
|
};
|
|
3794
5313
|
}
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
5314
|
+
if (localFile && source.legal === false) {
|
|
5315
|
+
source = await resolveInvalidAssetLocalFile(source, kwargs);
|
|
5316
|
+
}
|
|
5317
|
+
ensureConfirmed(kwargs, '注册素材是云端写入动作,需要确认', { action: 'create asset', groupId, name, assetType: source.assetType });
|
|
5318
|
+
const uploadPath = source.localFile?.filePath ?? localFile;
|
|
5319
|
+
let uploaded = null;
|
|
5320
|
+
try {
|
|
5321
|
+
uploaded = localFile ? await uploadLocalFile(uploadPath, { sceneType: TASK_UPLOAD_SCENE.ASSET_REVIEW }) : null;
|
|
5322
|
+
const body = {
|
|
5323
|
+
assetGroupsId: groupId,
|
|
5324
|
+
url: normalizeCosAssetPath(uploaded?.backendPath ?? assetPath),
|
|
5325
|
+
name,
|
|
5326
|
+
platform,
|
|
5327
|
+
assetType: source.assetType,
|
|
5328
|
+
};
|
|
5329
|
+
const payload = await awbApi.registerAsset(body);
|
|
5330
|
+
return {
|
|
5331
|
+
registered: true,
|
|
5332
|
+
assetId: extractAssetId(payload),
|
|
5333
|
+
groupId,
|
|
5334
|
+
name,
|
|
5335
|
+
platform,
|
|
5336
|
+
assetType: source.assetType,
|
|
5337
|
+
assetPath: body.url,
|
|
5338
|
+
media: source.media ?? null,
|
|
5339
|
+
...(source.conversion ? { conversion: source.conversion } : {}),
|
|
5340
|
+
...(uploaded ? { upload: uploaded } : {}),
|
|
5341
|
+
};
|
|
5342
|
+
} finally {
|
|
5343
|
+
if (source.conversion?.convertedFile) {
|
|
5344
|
+
await fs.rm(source.conversion.convertedFile, { force: true }).catch(() => {});
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
3813
5347
|
}
|
|
3814
5348
|
|
|
3815
5349
|
export async function subjectList(kwargs = {}) {
|
|
@@ -4350,3 +5884,99 @@ export async function subtitleStatus(kwargs = {}) {
|
|
|
4350
5884
|
taskType: 'VIDEO_SUBTITLE_REMOVAL',
|
|
4351
5885
|
});
|
|
4352
5886
|
}
|
|
5887
|
+
|
|
5888
|
+
export async function videoSuperResolutionStatus(kwargs = {}) {
|
|
5889
|
+
const taskId = requireValue(kwargs, 'taskId', 'task-id');
|
|
5890
|
+
return taskStatus({
|
|
5891
|
+
...kwargs,
|
|
5892
|
+
taskId,
|
|
5893
|
+
taskType: 'VIDEO_SUPER_RESOLUTION',
|
|
5894
|
+
});
|
|
5895
|
+
}
|
|
5896
|
+
|
|
5897
|
+
async function buildVideoSuperResolutionRequest(kwargs = {}, options = {}) {
|
|
5898
|
+
const objectName = trimToNull(kwargs.objectName);
|
|
5899
|
+
if (!objectName) {
|
|
5900
|
+
throw argumentError('缺少视频对象路径', '传 --object-name <objectName>;通常是 material 的 backendPath 或 COS 对象路径。');
|
|
5901
|
+
}
|
|
5902
|
+
const dryRun = Boolean(options.dryRun);
|
|
5903
|
+
const projectGroupNo = await resolveProjectGroupNo(kwargs.projectGroupNo, {
|
|
5904
|
+
allowNull: true,
|
|
5905
|
+
noNetwork: dryRun,
|
|
5906
|
+
noSave: dryRun,
|
|
5907
|
+
}).catch((error) => {
|
|
5908
|
+
if (dryRun) return null;
|
|
5909
|
+
throw error;
|
|
5910
|
+
});
|
|
5911
|
+
return {
|
|
5912
|
+
objectName,
|
|
5913
|
+
projectGroupNo,
|
|
5914
|
+
request: compactRecord({
|
|
5915
|
+
objectName,
|
|
5916
|
+
projectGroupNo,
|
|
5917
|
+
}),
|
|
5918
|
+
};
|
|
5919
|
+
}
|
|
5920
|
+
|
|
5921
|
+
export async function videoSuperResolutionFee(kwargs = {}) {
|
|
5922
|
+
const built = await buildVideoSuperResolutionRequest(kwargs, { dryRun: toBool(kwargs.dryRun) });
|
|
5923
|
+
if (toBool(kwargs.dryRun)) {
|
|
5924
|
+
return {
|
|
5925
|
+
dryRun: true,
|
|
5926
|
+
action: 'create video-super-resolution-fee',
|
|
5927
|
+
objectName: built.objectName,
|
|
5928
|
+
projectGroupNo: built.projectGroupNo,
|
|
5929
|
+
request: built.request,
|
|
5930
|
+
materialEndpoint: '/api/material/creation/videoUpResolutionCal',
|
|
5931
|
+
taskType: 'VIDEO_SUPER_RESOLUTION',
|
|
5932
|
+
};
|
|
5933
|
+
}
|
|
5934
|
+
const payload = await awbApi.fetchVideoSuperResolutionFee(built.request);
|
|
5935
|
+
return {
|
|
5936
|
+
objectName: built.objectName,
|
|
5937
|
+
projectGroupNo: built.projectGroupNo,
|
|
5938
|
+
data: payload,
|
|
5939
|
+
...(await pointEstimate(payload, built.projectGroupNo)),
|
|
5940
|
+
};
|
|
5941
|
+
}
|
|
5942
|
+
|
|
5943
|
+
export async function videoSuperResolution(kwargs = {}) {
|
|
5944
|
+
const built = await buildVideoSuperResolutionRequest(kwargs, { dryRun: toBool(kwargs.dryRun) });
|
|
5945
|
+
if (toBool(kwargs.dryRun)) {
|
|
5946
|
+
return {
|
|
5947
|
+
dryRun: true,
|
|
5948
|
+
action: 'create video-super-resolution',
|
|
5949
|
+
objectName: built.objectName,
|
|
5950
|
+
projectGroupNo: built.projectGroupNo,
|
|
5951
|
+
request: built.request,
|
|
5952
|
+
materialEndpoint: '/api/material/creation/videoUpResolution',
|
|
5953
|
+
taskType: 'VIDEO_SUPER_RESOLUTION',
|
|
5954
|
+
};
|
|
5955
|
+
}
|
|
5956
|
+
ensureConfirmed(kwargs, '正式视频超分会通过 material 创建任务并消耗积分,需要确认', {
|
|
5957
|
+
action: 'create video-super-resolution',
|
|
5958
|
+
objectName: built.objectName,
|
|
5959
|
+
});
|
|
5960
|
+
const feePayload = await awbApi.fetchVideoSuperResolutionFee(built.request).catch(() => null);
|
|
5961
|
+
const estimate = await pointEstimate(feePayload, built.projectGroupNo).catch(() => ({}));
|
|
5962
|
+
const payload = await awbApi.createVideoSuperResolutionTask(built.request);
|
|
5963
|
+
const result = normalizeCreatedTask(payload, {
|
|
5964
|
+
...estimate,
|
|
5965
|
+
submitted: true,
|
|
5966
|
+
taskType: 'VIDEO_SUPER_RESOLUTION',
|
|
5967
|
+
objectName: built.objectName,
|
|
5968
|
+
projectGroupNo: built.projectGroupNo,
|
|
5969
|
+
});
|
|
5970
|
+
await appendTaskRecord(kwargs, {
|
|
5971
|
+
taskId: result.taskId,
|
|
5972
|
+
taskType: 'VIDEO_SUPER_RESOLUTION',
|
|
5973
|
+
projectGroupNo: built.projectGroupNo,
|
|
5974
|
+
promptSummary: `视频超分 objectName=${built.objectName}`,
|
|
5975
|
+
});
|
|
5976
|
+
return {
|
|
5977
|
+
...result,
|
|
5978
|
+
nextCommand: result.taskId
|
|
5979
|
+
? `lj-awb task video-super-resolution-status --task-id ${result.taskId}${built.projectGroupNo ? ` --project-group-no ${built.projectGroupNo}` : ''} -f json`
|
|
5980
|
+
: null,
|
|
5981
|
+
};
|
|
5982
|
+
}
|