@lingjingai/lj-awb-cli-pre 0.4.0 → 0.4.6

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.
@@ -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,10 +23,12 @@ import {
22
23
  isSuccessTaskStatus,
23
24
  isTerminalTaskStatus,
24
25
  loadState,
26
+ maskSecret,
25
27
  normalizeFeedTaskType,
26
28
  nowIso,
27
29
  parseJsonArg,
28
30
  parseListArg,
31
+ resolvePurchaseUrl,
29
32
  safeFileName,
30
33
  saveState,
31
34
  sleep,
@@ -35,10 +38,11 @@ import {
35
38
  toBool,
36
39
  toInt,
37
40
  toNumberOrNull,
41
+ trimSecret,
38
42
  trimToNull,
39
43
  uniqueNonEmpty,
40
44
  } from './common.js';
41
- import { loadAuth, resolveAuthContext, summarizeAuth } from './auth.js';
45
+ import { clearAuth, loadAuth, resolveAuthContext, saveAccessKey, summarizeAuth } from './auth.js';
42
46
  import * as awbApi from './api.js';
43
47
 
44
48
  const SITE = 'lj-awb';
@@ -46,6 +50,17 @@ const REQUEST_SOURCE_CLI = 'LINGJING_AWB_CLI';
46
50
  const DEFAULT_TASK_RECORD_FILE_ENV = process.env.LINGJING_AWB_TASK_RECORD_FILE || process.env.AWB_TASK_RECORD_FILE;
47
51
  const execFileAsync = promisify(execFile);
48
52
  const COMMON_IMAGE_FORMATS = new Set(['jpg', 'jpeg', 'jfif', 'png', 'webp']);
53
+ const REMOTE_IMAGE_DOWNLOAD_TIMEOUT_MS = 30_000;
54
+ const REMOTE_IMAGE_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024;
55
+ const REMOTE_IMAGE_MIME_TYPES = new Set([
56
+ 'image/bmp',
57
+ 'image/gif',
58
+ 'image/jpeg',
59
+ 'image/jpg',
60
+ 'image/png',
61
+ 'image/webp',
62
+ ]);
63
+ const REMOTE_IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jfif', '.jpg', '.jpeg', '.png', '.webp']);
49
64
  const REMOTE_VOICE_DOWNLOAD_TIMEOUT_MS = 30_000;
50
65
  const REMOTE_VOICE_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024;
51
66
  const REMOTE_VOICE_MIME_TYPES = new Set([
@@ -61,6 +76,68 @@ const REMOTE_VOICE_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg',
61
76
  const ASSET_PLATFORM_CODES = ['JIMENG', 'BYTEPLUS'];
62
77
  const ASSET_PLATFORM_CODE_SET = new Set(ASSET_PLATFORM_CODES);
63
78
  const ASSET_PLATFORM_HINT = ASSET_PLATFORM_CODES.join('|');
79
+ const ASSET_TYPE_CODES = Object.freeze({
80
+ Image: 'Image',
81
+ Video: 'Video',
82
+ Audio: 'Audio',
83
+ });
84
+ const ASSET_IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.heic', '.heif', '.jpg', '.jpeg', '.png', '.tif', '.tiff', '.webp']);
85
+ const ASSET_VIDEO_EXTENSIONS = new Set(['.mp4', '.mov']);
86
+ const ASSET_AUDIO_EXTENSIONS = new Set(['.mp3', '.wav']);
87
+ const ASSET_IMAGE_MAX_BYTES = 30 * 1024 * 1024;
88
+ const ASSET_VIDEO_MAX_BYTES = 50 * 1024 * 1024;
89
+ const ASSET_MEDIA_MIN_ASPECT = 0.4;
90
+ const ASSET_MEDIA_MAX_ASPECT = 2.5;
91
+ const ASSET_MEDIA_MIN_DIMENSION = 300;
92
+ const ASSET_MEDIA_MAX_DIMENSION = 6000;
93
+ const ASSET_VIDEO_MIN_SECONDS = 2;
94
+ const ASSET_VIDEO_MAX_SECONDS = 15;
95
+ const ASSET_VIDEO_MIN_FPS = 24;
96
+ const ASSET_VIDEO_MAX_FPS = 60;
97
+ const ASSET_VIDEO_MIN_PIXELS = 640 * 640;
98
+ const ASSET_VIDEO_MAX_PIXELS = 834 * 1112;
99
+ const ASSET_AUDIO_MIN_SECONDS = 2;
100
+ const ASSET_AUDIO_MAX_SECONDS = 15;
101
+ let ffprobeCommandCache = null;
102
+ let ffmpegCommandCache = null;
103
+ const PLATFORM_BACKEND_PATH_PREFIXES = [
104
+ 'material/',
105
+ 'material-image-draw/',
106
+ 'material-image-edit/',
107
+ 'material-video-create/',
108
+ 'default/workbench/',
109
+ 'asset-review/',
110
+ ];
111
+ const CREATE_STANDARD_KWARG_KEYS = new Set([
112
+ 'modelGroupCode',
113
+ 'projectGroupNo',
114
+ 'customBizId',
115
+ 'prompt',
116
+ 'ratio',
117
+ 'quality',
118
+ 'generateNum',
119
+ 'generate_num',
120
+ 'duration',
121
+ 'needAudio',
122
+ 'need_audio',
123
+ 'resource',
124
+ 'resources',
125
+ 'resourcesJson',
126
+ 'resources_json',
127
+ 'modelParam',
128
+ 'model_param',
129
+ 'modelParams',
130
+ 'model_params',
131
+ 'modelParamsJson',
132
+ 'model_params_json',
133
+ 'taskRecordFile',
134
+ 'waitSeconds',
135
+ 'pollIntervalMs',
136
+ 'inputFile',
137
+ 'concurrency',
138
+ 'dryRun',
139
+ 'yes',
140
+ ]);
64
141
 
65
142
  export function normalizeUserInfo(payload) {
66
143
  const data = payload && typeof payload === 'object' ? payload : {};
@@ -116,6 +193,139 @@ export function ensureConfirmed(kwargs, message, details = {}) {
116
193
  });
117
194
  }
118
195
 
196
+ const LOGIN_FLOW_STATUS = Object.freeze({ PENDING: 0, SUCCESS: 1, EXPIRED: 2, CANCELED: 3 });
197
+
198
+ function renderLoginFlowPrompt({ verifyUrl, flowId, resumed }) {
199
+ const lines = [''];
200
+ if (resumed) {
201
+ lines.push(`继续轮询已有登录任务(flow_id=${flowId})...`);
202
+ } else {
203
+ lines.push('在浏览器中打开以下链接完成登录授权:', '', verifyUrl || `(flow_id=${flowId})`, '');
204
+ lines.push('[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成登录授权。请确保 runner 的 timeout >= 600s。');
205
+ lines.push('若当前工具无法实时展示命令输出,请使用 "lj-awb auth login --no-wait --json" 获取 flow_id 和 verify_url。');
206
+ lines.push('用户完成授权后,再执行 "lj-awb auth login --flow-id <flow_id>" 继续轮询。');
207
+ lines.push('不要重复执行新的 login 命令,否则会生成新的 flow_id,导致旧授权链接失效。');
208
+ }
209
+ lines.push('', '等待用户授权...', '');
210
+ process.stderr.write(lines.join('\n'));
211
+ }
212
+
213
+ async function saveVerifiedAccessKey(accessKey, { skipVerify = false, loginMethod } = {}) {
214
+ let user = null;
215
+ if (!skipVerify) {
216
+ process.env.LINGJING_AWB_ACCESS_KEY = accessKey;
217
+ user = normalizeUserInfo(await awbApi.fetchUserInfo());
218
+ }
219
+ const { auth } = await saveAccessKey(accessKey, { verified: !skipVerify });
220
+ return {
221
+ loginMethod,
222
+ status: 'success',
223
+ saved: true,
224
+ verified: !skipVerify,
225
+ auth: summarizeAuth(auth, { accessKey, source: 'saved', sourceName: 'auth' }),
226
+ accessKey: maskSecret(accessKey),
227
+ user,
228
+ };
229
+ }
230
+
231
+ export async function authLogin(kwargs = {}) {
232
+ const skipVerify = toBool(kwargs.skipVerify);
233
+ const directAccessKey = trimSecret(kwargs.accessKey || process.env.LINGJING_AWB_ACCESS_KEY || '');
234
+
235
+ // 路径一:显式 --access-key 或环境变量 —— 保持原有「直接校验并保存」逻辑
236
+ if (directAccessKey) {
237
+ if (toBool(kwargs.dryRun)) {
238
+ return { dryRun: true, loginMethod: 'access_key', saved: false, verified: false, accessKey: maskSecret(directAccessKey) };
239
+ }
240
+ return await saveVerifiedAccessKey(directAccessKey, { skipVerify, loginMethod: 'access_key' });
241
+ }
242
+
243
+ // 路径二/三:浏览器授权登录流程
244
+ if (toBool(kwargs.dryRun)) {
245
+ return { dryRun: true, loginMethod: 'flow', saved: false, verified: false };
246
+ }
247
+
248
+ const explicitFlowId = trimToNull(kwargs.flowId);
249
+ let flowId = explicitFlowId;
250
+ let verifyUrl = null;
251
+ if (!flowId) {
252
+ const created = await awbApi.createLoginFlow();
253
+ flowId = trimToNull(created?.flowId);
254
+ verifyUrl = trimToNull(created?.verifyUrl);
255
+ if (!flowId) {
256
+ throw new LingjingAwbCliError('创建登录任务失败:未返回 flowId', {
257
+ type: 'auth_flow_failed',
258
+ exitCode: 3,
259
+ hint: '稍后重试 lj-awb auth login;若持续失败请联系平台。',
260
+ });
261
+ }
262
+ }
263
+
264
+ if (toBool(kwargs.noWait)) {
265
+ return {
266
+ loginMethod: 'flow',
267
+ status: 'pending',
268
+ saved: false,
269
+ verified: false,
270
+ flowId,
271
+ verifyUrl,
272
+ waited: false,
273
+ nextCommand: `lj-awb auth login --flow-id ${flowId}`,
274
+ };
275
+ }
276
+
277
+ const waitSeconds = Math.max(0, toInt(kwargs.waitSeconds, 600));
278
+ const pollIntervalMs = Math.max(1000, toInt(kwargs.pollIntervalMs, 3000));
279
+ renderLoginFlowPrompt({ verifyUrl, flowId, resumed: Boolean(explicitFlowId) });
280
+
281
+ const deadline = Date.now() + waitSeconds * 1000;
282
+ while (true) {
283
+ const statusData = await awbApi.queryLoginFlowStatus(flowId);
284
+ const status = toInt(statusData?.status, LOGIN_FLOW_STATUS.PENDING);
285
+ if (status === LOGIN_FLOW_STATUS.SUCCESS) {
286
+ const accessKey = trimSecret(statusData?.accessKey);
287
+ if (!accessKey) {
288
+ throw new LingjingAwbCliError('授权成功但未返回 accessKey', {
289
+ type: 'auth_flow_failed',
290
+ exitCode: 3,
291
+ details: { flowId },
292
+ });
293
+ }
294
+ return { ...(await saveVerifiedAccessKey(accessKey, { skipVerify, loginMethod: 'flow' })), flowId };
295
+ }
296
+ if (status === LOGIN_FLOW_STATUS.EXPIRED) {
297
+ throw new LingjingAwbCliError('登录授权链接已过期', {
298
+ type: 'auth_flow_expired',
299
+ exitCode: 3,
300
+ hint: '重新运行 lj-awb auth login 获取新的授权链接。',
301
+ details: { flowId },
302
+ });
303
+ }
304
+ if (status === LOGIN_FLOW_STATUS.CANCELED) {
305
+ throw new LingjingAwbCliError('登录授权已被取消', {
306
+ type: 'auth_flow_canceled',
307
+ exitCode: 3,
308
+ hint: '重新运行 lj-awb auth login 重新发起登录授权。',
309
+ details: { flowId },
310
+ });
311
+ }
312
+ if (Date.now() >= deadline || waitSeconds === 0) break;
313
+ await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
314
+ }
315
+
316
+ throw new LingjingAwbCliError('等待登录授权超时,本轮等待窗口已结束', {
317
+ type: 'auth_flow_pending',
318
+ exitCode: 20,
319
+ hint: `用户在浏览器完成授权后,运行 lj-awb auth login --flow-id ${flowId} 继续轮询;不要重新发起新的 login。`,
320
+ details: { flowId, verifyUrl, waitSeconds },
321
+ });
322
+ }
323
+
324
+ export async function authLogout() {
325
+ await clearAuth();
326
+ return { loggedOut: true, authPath: AUTH_PATH };
327
+ }
328
+
119
329
  function envProjectGroupNo() {
120
330
  return trimToNull(
121
331
  process.env.LINGJING_AWB_PROJECT_GROUP_NO
@@ -262,6 +472,91 @@ export async function accountInfo() {
262
472
  };
263
473
  }
264
474
 
475
+ const REDEEM_CODE_WAIT_SECONDS_DEFAULT = 600;
476
+
477
+ // 去掉用户粘贴时可能带上的空格 / 制表符 / 换行,避免污染兑换码。
478
+ function sanitizeRedeemCode(value) {
479
+ return String(value ?? '').replace(/\s+/g, '');
480
+ }
481
+
482
+ export async function creditsBuy() {
483
+ const { purchaseUrl } = resolvePurchaseUrl();
484
+ return {
485
+ action: 'credits buy',
486
+ purchaseUrl,
487
+ };
488
+ }
489
+
490
+ async function promptRedeemCode(waitSeconds) {
491
+ if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
492
+ throw argumentError(
493
+ '未提供兑换码',
494
+ '非交互终端不会等待输入,请通过 --code 传入兑换码,例如 lj-awb credits redeem --code <code>。',
495
+ );
496
+ }
497
+ const controller = new AbortController();
498
+ const timer = setTimeout(() => controller.abort(), Math.max(1, waitSeconds) * 1000);
499
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
500
+ try {
501
+ const answer = await rl.question('请输入兑换码后回车确认:', { signal: controller.signal });
502
+ return sanitizeRedeemCode(answer);
503
+ } catch (error) {
504
+ if (controller.signal.aborted) {
505
+ throw new LingjingAwbCliError('等待输入兑换码超时', {
506
+ type: 'redeem_timeout',
507
+ exitCode: 20,
508
+ hint: '重新运行 lj-awb credits redeem 并在限定时间内输入兑换码,或直接用 --code 传入。',
509
+ });
510
+ }
511
+ throw error;
512
+ } finally {
513
+ clearTimeout(timer);
514
+ rl.close();
515
+ }
516
+ }
517
+
518
+ export async function creditsRedeem(kwargs = {}) {
519
+ const waitSeconds = Math.max(0, toInt(kwargs.waitSeconds, REDEEM_CODE_WAIT_SECONDS_DEFAULT));
520
+ let code = sanitizeRedeemCode(kwargs.code);
521
+ if (!code) {
522
+ code = await promptRedeemCode(waitSeconds);
523
+ }
524
+ if (!code) {
525
+ throw argumentError('兑换码不能为空', '请提供有效兑换码,或通过 --code <code> 传入。');
526
+ }
527
+ if (toBool(kwargs.dryRun)) {
528
+ return { dryRun: true, action: 'credits redeem', redemptionCode: code };
529
+ }
530
+
531
+ try {
532
+ await awbApi.redeemCode(code);
533
+ } catch (error) {
534
+ // apiFetch 在平台返回 code != 200 时抛 api_error,并带上平台 msg;统一转成更友好的兑换失败提示。
535
+ if (error instanceof LingjingAwbCliError && (error.type === 'api_error' || error.type === 'http_error')) {
536
+ throw new LingjingAwbCliError(error.message || '兑换失败', {
537
+ type: 'redeem_failed',
538
+ exitCode: 1,
539
+ hint: '兑换处理失败,请确认兑换码是否正确或已被使用;若持续失败请联系平台处理。',
540
+ details: error.details,
541
+ });
542
+ }
543
+ throw error;
544
+ }
545
+
546
+ // 兑换成功后顺带拉取账号概览,展示最新积分余额(等同于 lj-awb account info)。
547
+ const account = await accountInfo().catch(() => null);
548
+ return {
549
+ redeemed: true,
550
+ action: 'credits redeem',
551
+ redemptionCode: code,
552
+ billingPointBalance: account?.billingPointBalance ?? null,
553
+ currentProjectGroupNo: account?.currentProjectGroupNo ?? null,
554
+ currentProjectGroupName: account?.currentProjectGroupName ?? null,
555
+ projectBudgetBalance: account?.projectBudgetBalance ?? null,
556
+ projectBudgetMax: account?.projectBudgetMax ?? null,
557
+ };
558
+ }
559
+
265
560
  async function pathExists(filePath) {
266
561
  if (!filePath) return false;
267
562
  try {
@@ -1047,6 +1342,8 @@ function sourceAllowedByRule(rule = {}, sourceKind) {
1047
1342
  function resourceFormatForValidation(detail = {}) {
1048
1343
  if (detail.localFile?.format) return String(detail.localFile.format).toLowerCase();
1049
1344
  const value = detail.resource?.source?.value;
1345
+ const transformedFormat = imageTransformFormat(value);
1346
+ if (transformedFormat) return transformedFormat;
1050
1347
  const pathname = (() => {
1051
1348
  try {
1052
1349
  return new URL(String(value)).pathname;
@@ -1058,6 +1355,34 @@ function resourceFormatForValidation(detail = {}) {
1058
1355
  return ext || null;
1059
1356
  }
1060
1357
 
1358
+ function imageTransformFormat(value) {
1359
+ const text = String(value || '');
1360
+ if (!text.includes('?') && !text.includes('%2F') && !text.includes('/format/')) return null;
1361
+ const queryText = (() => {
1362
+ try {
1363
+ const url = new URL(text);
1364
+ return url.search || text;
1365
+ } catch {
1366
+ const queryIndex = text.indexOf('?');
1367
+ return queryIndex >= 0 ? text.slice(queryIndex) : text;
1368
+ }
1369
+ })();
1370
+ let decoded = queryText;
1371
+ try {
1372
+ decoded = decodeURIComponent(queryText);
1373
+ } catch {}
1374
+ for (const pattern of [
1375
+ /image(?:mogr2|view2)[^&#?]*?\/format\/([A-Za-z0-9]+)/i,
1376
+ /x-oss-process=image[^&#?]*?format,([A-Za-z0-9]+)/i,
1377
+ /(?:^|[?&#])format=([A-Za-z0-9]+)/i,
1378
+ ]) {
1379
+ const match = decoded.match(pattern);
1380
+ const format = normalizeFileFormat(match?.[1]);
1381
+ if (format) return format;
1382
+ }
1383
+ return null;
1384
+ }
1385
+
1061
1386
  function normalizeFileFormat(value) {
1062
1387
  const format = trimToNull(value)?.toLowerCase();
1063
1388
  if (!format) return null;
@@ -1122,6 +1447,15 @@ function isConvertibleLocalImage(detail = {}) {
1122
1447
  return Boolean(detail.localFile?.exists && String(detail.localFile?.mimeType || '').startsWith('image/'));
1123
1448
  }
1124
1449
 
1450
+ function isDownloadableImageSource(detail = {}) {
1451
+ const sourceKind = resourceSourceKindForValidation(detail);
1452
+ return detail.resource?.source?.kind === 'url' && ['http_url', 'backendPath'].includes(sourceKind);
1453
+ }
1454
+
1455
+ function isConvertibleImageResource(detail = {}) {
1456
+ return isConvertibleLocalImage(detail) || isDownloadableImageSource(detail);
1457
+ }
1458
+
1125
1459
  function resourceFormatConversion(rule = {}, detail = {}) {
1126
1460
  if (!isImageRule(rule)) return null;
1127
1461
  const format = normalizeFileFormat(resourceFormatForValidation(detail));
@@ -1133,7 +1467,7 @@ function resourceFormatConversion(rule = {}, detail = {}) {
1133
1467
  fromFormat: format,
1134
1468
  toFormat: policy.autoConvertTo,
1135
1469
  reason: 'image_webp_not_supported',
1136
- possible: isConvertibleLocalImage(detail),
1470
+ possible: isConvertibleImageResource(detail),
1137
1471
  };
1138
1472
  }
1139
1473
  if (policy.kind === 'normal_plus_webp') return null;
@@ -1142,7 +1476,7 @@ function resourceFormatConversion(rule = {}, detail = {}) {
1142
1476
  fromFormat: format,
1143
1477
  toFormat: policy.autoConvertTo,
1144
1478
  reason: 'image_strict_format_mismatch',
1145
- possible: isConvertibleLocalImage(detail),
1479
+ possible: isConvertibleImageResource(detail),
1146
1480
  allowed: policy.allowed,
1147
1481
  };
1148
1482
  }
@@ -1182,7 +1516,7 @@ function assertResourceShapeAgainstModel(resourceDetails = [], resourceRules = [
1182
1516
  if (conversion && !conversion.possible) {
1183
1517
  throw argumentError(
1184
1518
  `素材格式需要转换但无法自动处理:${resourceText(resource)}`,
1185
- `当前格式 ${conversion.fromFormat},目标格式 ${conversion.toFormat};请使用本地文件,或先转换后再传远程 URL / asset。`,
1519
+ `当前格式 ${conversion.fromFormat},目标格式 ${conversion.toFormat};请使用本地文件、http(s) URL 或平台 backendPath,asset 资源需先转换后再传。`,
1186
1520
  );
1187
1521
  }
1188
1522
  if (!conversion && !fileTypeAllowedByRule(rule, format)) {
@@ -1309,12 +1643,16 @@ function promptParamValue(promptParams = {}, key) {
1309
1643
  }
1310
1644
 
1311
1645
  function providedPromptParamKeys(promptParams = {}) {
1312
- const keys = [];
1646
+ const keys = new Set();
1313
1647
  for (const key of ['prompt', 'ratio', 'quality', 'duration', 'generateNum', 'needAudio']) {
1314
1648
  const value = promptParamValue(promptParams, key);
1315
- if (value !== undefined && value !== null && value !== '') keys.push(key);
1649
+ if (value !== undefined && value !== null && value !== '') keys.add(key);
1316
1650
  }
1317
- return keys;
1651
+ for (const [key, value] of Object.entries(promptParams || {})) {
1652
+ if (key === 'resources' || key === 'generate_num' || key === 'need_audio') continue;
1653
+ if (value !== undefined && value !== null && value !== '') keys.add(key);
1654
+ }
1655
+ return [...keys];
1318
1656
  }
1319
1657
 
1320
1658
  function inferGeneratedMode(resources = []) {
@@ -1512,7 +1850,7 @@ function mergeResourceLimits(base = {}, overrides = {}) {
1512
1850
  function mergeModelParamRules(options = [], rawModel = null) {
1513
1851
  const rawParams = Array.isArray(rawModel?.modelParams) ? rawModel.modelParams : [];
1514
1852
  const rawByKey = new Map(rawParams.map((item) => [item?.paramKey, item]).filter(([key]) => key));
1515
- return options.map((option) => {
1853
+ const merged = options.map((option) => {
1516
1854
  const raw = rawByKey.get(option.paramKey);
1517
1855
  return {
1518
1856
  ...option,
@@ -1520,6 +1858,62 @@ function mergeModelParamRules(options = [], rawModel = null) {
1520
1858
  rules: option.rules ?? raw?.rules ?? null,
1521
1859
  };
1522
1860
  });
1861
+ const mergedKeys = new Set(merged.map((item) => item.paramKey).filter(Boolean));
1862
+ for (const raw of rawParams) {
1863
+ if (!raw?.paramKey || mergedKeys.has(raw.paramKey)) continue;
1864
+ merged.push(raw);
1865
+ }
1866
+ return merged;
1867
+ }
1868
+
1869
+ function modelParamDefaultValue(option = {}) {
1870
+ return option.rules?.defaultValue
1871
+ ?? option.defaultValue
1872
+ ?? null;
1873
+ }
1874
+
1875
+ function modelParamDefaultName(option = {}) {
1876
+ return option.rules?.defaultName
1877
+ ?? option.defaultName
1878
+ ?? null;
1879
+ }
1880
+
1881
+ function modelParamValueSource(option = {}) {
1882
+ const allowedValues = optionAllowedValues(option);
1883
+ if (allowedValues.length) return '只能从 allowedValues 选择。';
1884
+ if (modelParamDefaultValue(option) != null) return '模型配置提供 defaultValue;这是候选默认值,不代表可以静默代选。';
1885
+ return '来自实时 model options 的通用模型配置参数;用户明确选择后通过 --model-param key=value 或 --model-params-json 传入。';
1886
+ }
1887
+
1888
+ function isGenericModelConfigParam(option = {}) {
1889
+ const key = trimToNull(option.paramKey);
1890
+ if (!key) return false;
1891
+ const internalResourceKeys = new Set([
1892
+ 'generated_mode',
1893
+ 'resources',
1894
+ 'iref',
1895
+ 'cref',
1896
+ 'sref',
1897
+ 'frames',
1898
+ 'multi_param',
1899
+ 'multi_prompt',
1900
+ 'subject',
1901
+ 'subject_reference',
1902
+ 'reference_image',
1903
+ 'reference_audio',
1904
+ 'reference_video',
1905
+ 'first_frame',
1906
+ 'first_last_frame',
1907
+ 'last_frame_only',
1908
+ 'storyboard',
1909
+ ]);
1910
+ if (internalResourceKeys.has(key)) return false;
1911
+ const paramType = String(option.paramType || '');
1912
+ return Boolean(
1913
+ optionAllowedValues(option).length
1914
+ || modelParamDefaultValue(option) != null
1915
+ || /Enum|Boolean|Bool|Number|Integer|Float|Double|String|Text|Prompt/i.test(paramType),
1916
+ );
1523
1917
  }
1524
1918
 
1525
1919
  function createParamForModelOption(option = {}, taskKind = 'image') {
@@ -1596,7 +1990,7 @@ function createParamForModelOption(option = {}, taskKind = 'image') {
1596
1990
  requestPath: 'promptParams.resources[]',
1597
1991
  materialLegacyKey: 'iref',
1598
1992
  meaning: '图片任务的参考图输入。',
1599
- valueSource: '本地文件、http(s) URL material backendPath。',
1993
+ valueSource: '本地文件、http(s) URL 或平台 backendPath。',
1600
1994
  resourceSyntax: ['image:reference=./ref.png'],
1601
1995
  },
1602
1996
  resources: {
@@ -1605,7 +1999,7 @@ function createParamForModelOption(option = {}, taskKind = 'image') {
1605
1999
  requestPath: 'promptParams.resources[]',
1606
2000
  materialLegacyKey: 'resources',
1607
2001
  meaning: '模型声明的素材输入;CLI 统一用 resources 表达。',
1608
- valueSource: '本地文件、http(s) URL material backendPath。',
2002
+ valueSource: '本地文件、http(s) URL 或平台 backendPath。',
1609
2003
  resourceSyntax: taskKind === 'image' ? ['image:reference=./ref.png'] : ['image:first_frame=./first.png'],
1610
2004
  },
1611
2005
  frames: {
@@ -1614,7 +2008,7 @@ function createParamForModelOption(option = {}, taskKind = 'image') {
1614
2008
  requestPath: 'promptParams.resources[]',
1615
2009
  materialLegacyKey: 'frames',
1616
2010
  meaning: '视频首帧 / 尾帧 / 关键帧输入。',
1617
- valueSource: '首帧/尾帧可用本地文件、http(s) URL、material backendPath 或 asset:<assetId>;keyframe 仅用文件/URL/backendPath。',
2011
+ valueSource: '首帧/尾帧可用本地文件、http(s) URL、平台 backendPath 或 asset:<assetId>;keyframe 仅用文件/URL/backendPath。',
1618
2012
  resourceSyntax: ['image:first_frame=./first.png', 'image:first_frame=asset:<assetId>', 'image:last_frame=asset:<assetId>', 'image:keyframe#1=./key1.png'],
1619
2013
  },
1620
2014
  multi_param: {
@@ -1655,15 +2049,26 @@ function createParamForModelOption(option = {}, taskKind = 'image') {
1655
2049
  },
1656
2050
  };
1657
2051
  return {
1658
- ...(definitions[key] ?? {
1659
- key,
1660
- cliArg: null,
1661
- requestPath: null,
1662
- materialLegacyKey: key,
1663
- meaning: '模型配置存在该参数,但当前 CLI 没有暴露为稳定创建参数;不要为了调用而硬塞旧字段。',
1664
- valueSource: '如确实需要,先扩展 CLI 显式参数和 Material adapter。',
1665
- notExposedByCli: true,
1666
- }),
2052
+ ...(definitions[key]
2053
+ ?? (isGenericModelConfigParam(option)
2054
+ ? {
2055
+ key,
2056
+ cliArg: '--model-param / --model-params-json',
2057
+ requestPath: `promptParams.${key}`,
2058
+ materialLegacyKey: key,
2059
+ meaning: option.paramName ? `模型配置参数:${option.paramName}` : '模型配置参数。',
2060
+ valueSource: modelParamValueSource(option),
2061
+ genericModelParam: true,
2062
+ }
2063
+ : {
2064
+ key,
2065
+ cliArg: null,
2066
+ requestPath: null,
2067
+ materialLegacyKey: key,
2068
+ meaning: '模型配置存在该参数,但当前 CLI 未将其识别为可安全透传的创建参数。',
2069
+ valueSource: '如确实需要,先确认该字段不是资源或旧 handler 内部字段,再扩展通用参数判定。',
2070
+ notExposedByCli: true,
2071
+ })),
1667
2072
  ...common,
1668
2073
  };
1669
2074
  }
@@ -2032,6 +2437,7 @@ function createSpecParameterControls(createParams, inputModes = []) {
2032
2437
  valueSource: item.valueSource,
2033
2438
  controlKind: item.controlKind,
2034
2439
  allowedValues: item.allowedValues,
2440
+ genericModelParam: item.genericModelParam,
2035
2441
  }));
2036
2442
  const resourceParams = createParams
2037
2443
  .filter((item) => item.key === 'resources')
@@ -2153,7 +2559,8 @@ function cliValueType(option = {}, allowedValues = []) {
2153
2559
  if (allowedValues.length) return 'enum';
2154
2560
  if (option.paramType === 'Prompt') return 'text';
2155
2561
  if (option.paramType === 'BooleanType') return 'boolean';
2156
- if (/Number|Integer|Float/i.test(String(option.paramType || ''))) return 'number';
2562
+ if (/Enum/i.test(String(option.paramType || ''))) return 'enum';
2563
+ if (/Number|Integer|Float|Double/i.test(String(option.paramType || ''))) return 'number';
2157
2564
  return option.paramType || undefined;
2158
2565
  }
2159
2566
 
@@ -2163,6 +2570,7 @@ function modelOptionParams(options = [], taskKind = 'image') {
2163
2570
  .filter(({ createParam }) => createParam.cliArg && createParam.key !== 'resources' && !createParam.internalOnly)
2164
2571
  .map(({ option, createParam }) => {
2165
2572
  const rules = option.rules || {};
2573
+ const defaultValue = modelParamDefaultValue(option);
2166
2574
  const allowedValues = optionAllowedValues(option);
2167
2575
  const required = option.required === true || rules.required === true ? true : undefined;
2168
2576
  const maxLength = option.paramType === 'Prompt'
@@ -2173,10 +2581,15 @@ function modelOptionParams(options = [], taskKind = 'image') {
2173
2581
  label: option.paramName,
2174
2582
  valueType: cliValueType(option, allowedValues),
2175
2583
  values: allowedValues.length ? allowedValues : undefined,
2176
- defaultValue: rules.defaultValue,
2177
- defaultName: rules.defaultName,
2584
+ defaultValue,
2585
+ defaultName: modelParamDefaultName(option),
2178
2586
  maxLength,
2179
2587
  required,
2588
+ cliArg: createParam.cliArg,
2589
+ genericModelParam: createParam.genericModelParam || undefined,
2590
+ modelParamKey: createParam.modelParamKey,
2591
+ modelParamName: createParam.modelParamName,
2592
+ modelParamType: createParam.modelParamType,
2180
2593
  });
2181
2594
  });
2182
2595
  }
@@ -2274,7 +2687,7 @@ export function modelInputGuide() {
2274
2687
  { field: 'resources[].type', values: ['image', 'video', 'audio', 'subject'], description: '资源本体类型;subject 表示已创建的主体对象。' },
2275
2688
  { field: 'resources[].usage', values: ['first_frame', 'last_frame', 'reference', 'keyframe'], description: '素材用途。' },
2276
2689
  { field: 'resources[].reference_key', values: ['custom string'], description: '仅视频 reference 资源需要占位绑定时使用;图片生图 image:reference 不使用 reference_key。subject reference 必须传。' },
2277
- { field: 'resources[].source.kind', values: ['url', 'asset_id'], description: '只接受 url、asset_id 两个枚举值。本地文件、http(s) URL、material backendPath 一律传 kind=url,CLI 按 value 自动识别;asset_id 表示平台资产或主体对象 ID。注意:模型 resources[].valueShapes 列出的 local_file / http_url / backendPath 是 value 形状分类,不是 kind 枚举。' },
2690
+ { 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 枚举。' },
2278
2691
  { field: 'resources[].source.value', description: '资源值,必填。url 传素材地址;asset_id 传资源 ID。' },
2279
2692
  { field: 'resources[].order', description: '仅 usage=keyframe 时需要,且同一请求内不能重复。' },
2280
2693
  { field: 'resources[].duration', description: '仅 keyframe 场景下用于表达该帧持续时长,可传小数秒。' },
@@ -2341,6 +2754,7 @@ export async function modelCreateSpec(kwargs = {}) {
2341
2754
  inputRequirement,
2342
2755
  supportedIntents,
2343
2756
  validationRules: createSpecValidationRules(context.taskKind),
2757
+ parameterControls: createSpecParameterControls(context.createParams, context.inputModes),
2344
2758
  agentGuidance: createSpecAgentGuidance(context.taskKind, inputRequirement),
2345
2759
  preflight: createSpecPreflight(context.taskKind),
2346
2760
  examples: createSpecExamples(context.taskKind, supportedIntents),
@@ -2412,135 +2826,1058 @@ function dryRunBackendPath(filePath, sceneType) {
2412
2826
  return `/${sceneType}/__dry_run__/${safeFileName(filePath)}`;
2413
2827
  }
2414
2828
 
2415
- export async function uploadFilesCommand(kwargs = {}) {
2416
- const specs = await collectFileSpecs(kwargs, trimToNull(kwargs.sceneType));
2417
- if (!specs.length) throw argumentError('缺少上传文件', '传 --file <path> 或 --files a.png,b.mp4。');
2418
- if (toBool(kwargs.dryRun)) {
2419
- const files = [];
2420
- for (const spec of specs) {
2421
- const inspected = await inspectLocalFile(spec.file);
2422
- files.push(compactRecord({
2423
- ...inspected,
2424
- sceneType: spec.sceneType,
2425
- projectNo: spec.projectNo,
2426
- backendPath: dryRunBackendPath(spec.file, spec.sceneType),
2427
- url: null,
2428
- dryRun: true,
2429
- }));
2829
+ function mb(bytes) {
2830
+ return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')}MB`;
2831
+ }
2832
+
2833
+ function sourceExtension(value) {
2834
+ const text = trimToNull(value);
2835
+ if (!text) return '';
2836
+ if (/^https?:\/\//i.test(text)) {
2837
+ try {
2838
+ return path.extname(decodeURIComponent(new URL(text).pathname)).toLowerCase();
2839
+ } catch {
2840
+ return path.extname(text.split(/[?#]/)[0] || '').toLowerCase();
2430
2841
  }
2431
- return { dryRun: true, files };
2432
2842
  }
2433
- const files = [];
2434
- for (const spec of specs) {
2435
- files.push(await uploadLocalFile(spec.file, spec));
2436
- }
2437
- return { files };
2843
+ return path.extname(text.split(/[?#]/)[0] || '').toLowerCase();
2438
2844
  }
2439
2845
 
2440
- export async function uploadLocalFile(filePath, options = {}) {
2441
- const inspected = await inspectLocalFile(filePath);
2442
- if (!inspected.exists) {
2443
- throw argumentError(`文件不存在:${filePath}`);
2444
- }
2445
- const buffer = await fs.readFile(inspected.filePath);
2446
- const sceneType = options.sceneType ?? defaultUploadSceneForFile(inspected.filePath, inspected.mimeType);
2447
- const groupId = crypto.randomUUID().replaceAll('-', '');
2448
- const secret = await awbApi.fetchUploadSecret({
2449
- sceneType,
2450
- groupId,
2451
- projectNo: options.projectNo ?? '',
2452
- });
2453
- const credentials = secret.credentials ?? secret;
2454
- const objectName = `${secret.path ?? ''}${secret.prefix ?? ''}${Date.now()}-${safeFileName(inspected.filePath)}`.replace(/^\/+/, '');
2455
- const host = `${secret.bucket}.cos.${secret.region}.myqcloud.com`;
2456
- const authorization = buildCosAuthorization({
2457
- secretKey: credentials.tmpSecretKey,
2458
- secretId: credentials.tmpSecretId,
2459
- method: 'PUT',
2460
- objectName,
2461
- contentLength: buffer.length,
2462
- host,
2463
- startTime: secret.startTime,
2464
- expiredTime: secret.expiredTime,
2465
- });
2466
- const response = await fetch(`https://${host}/${encodeObjectNamePath(objectName)}`, {
2467
- method: 'PUT',
2468
- headers: {
2469
- authorization,
2470
- 'content-length': String(buffer.length),
2471
- 'content-type': inspected.mimeType || guessMimeType(inspected.filePath),
2472
- host,
2473
- 'x-cos-security-token': credentials.sessionToken,
2474
- },
2475
- body: buffer,
2476
- });
2477
- if (!response.ok) {
2478
- throw new LingjingAwbCliError(`上传 COS 失败:${response.status} ${response.statusText}`, {
2479
- type: 'upload_failed',
2480
- exitCode: 30,
2481
- details: { filePath: inspected.filePath, objectName },
2482
- });
2846
+ function inferAssetTypeFromExtension(ext) {
2847
+ if (ASSET_IMAGE_EXTENSIONS.has(ext)) return ASSET_TYPE_CODES.Image;
2848
+ if (ASSET_VIDEO_EXTENSIONS.has(ext)) return ASSET_TYPE_CODES.Video;
2849
+ if (ASSET_AUDIO_EXTENSIONS.has(ext)) return ASSET_TYPE_CODES.Audio;
2850
+ return null;
2851
+ }
2852
+
2853
+ function inferAssetTypeFromSource(value) {
2854
+ const ext = sourceExtension(value);
2855
+ const assetType = inferAssetTypeFromExtension(ext);
2856
+ if (assetType) return assetType;
2857
+ const suffix = ext || '无扩展名';
2858
+ throw argumentError(
2859
+ `无法判断素材类型:${suffix}`,
2860
+ '素材加白支持图片 jpg/jpeg/png/webp/bmp/tiff/gif/heic/heif,视频 mp4/mov,音频 wav/mp3;请传带正确扩展名的 --file、--url --backend-path。',
2861
+ );
2862
+ }
2863
+
2864
+ function parseFfprobeNumber(value) {
2865
+ const number = Number(value);
2866
+ return Number.isFinite(number) ? number : null;
2867
+ }
2868
+
2869
+ function parseFps(value) {
2870
+ const text = String(value || '').trim();
2871
+ if (!text || text === '0/0') return null;
2872
+ if (text.includes('/')) {
2873
+ const [left, right] = text.split('/').map(Number);
2874
+ return Number.isFinite(left) && Number.isFinite(right) && right !== 0 ? left / right : null;
2483
2875
  }
2484
- return compactRecord({
2485
- ...inspected,
2486
- sceneType,
2487
- projectNo: options.projectNo ?? '',
2488
- backendPath: `/${objectName}`,
2489
- url: `https://${host}/${encodeObjectNamePath(objectName)}`,
2490
- });
2876
+ return parseFfprobeNumber(text);
2491
2877
  }
2492
2878
 
2493
- function isHttpUrl(value) {
2494
- return /^https?:\/\//i.test(String(value || '').trim());
2879
+ function roundNumber(value, digits = 3) {
2880
+ if (!Number.isFinite(Number(value))) return value;
2881
+ const fixed = Number(value).toFixed(digits);
2882
+ return fixed.replace(/\.?0+$/, '');
2495
2883
  }
2496
2884
 
2497
- function isUploadedMaterialReference(value) {
2885
+ function normalizeDisplayFormat(value) {
2498
2886
  const text = trimToNull(value);
2499
- if (!text) return false;
2500
- if (text.startsWith('material/') || text.startsWith('/material/')) return true;
2501
- if (!isHttpUrl(text)) return false;
2502
- try {
2503
- const url = new URL(text);
2504
- return url.hostname.endsWith('.myqcloud.com');
2505
- } catch {
2506
- return false;
2507
- }
2887
+ if (!text) return null;
2888
+ return normalizeFileFormat(text.replace(/^\./, ''));
2508
2889
  }
2509
2890
 
2510
- function remoteFileNameFromUrl(remoteUrl, fallback = 'voice-audio') {
2511
- try {
2512
- const pathname = decodeURIComponent(new URL(remoteUrl).pathname);
2513
- const baseName = safeFileName(path.basename(pathname));
2514
- if (baseName && baseName !== '.' && baseName !== '/') return baseName;
2515
- } catch {}
2516
- return `${fallback}.mp3`;
2891
+ function clampNumber(value, min, max) {
2892
+ return Math.min(max, Math.max(min, value));
2517
2893
  }
2518
2894
 
2519
- function extensionFromContentType(contentType = '') {
2520
- const normalized = String(contentType || '').split(';')[0].trim().toLowerCase();
2521
- if (normalized === 'audio/mpeg') return '.mp3';
2522
- if (normalized === 'audio/wav' || normalized === 'audio/x-wav') return '.wav';
2523
- if (normalized === 'audio/mp4') return '.m4a';
2524
- if (normalized === 'audio/aac') return '.aac';
2525
- if (normalized === 'audio/ogg') return '.ogg';
2526
- if (normalized === 'video/mp4') return '.mp4';
2527
- return '';
2895
+ function evenNumber(value, fallback = 2) {
2896
+ const rounded = Math.max(2, Math.round(value / 2) * 2);
2897
+ return Number.isFinite(rounded) ? rounded : fallback;
2528
2898
  }
2529
2899
 
2530
- function isSupportedRemoteVoiceDownload(contentType = '', fileName = '') {
2531
- const normalized = String(contentType || '').split(';')[0].trim().toLowerCase();
2532
- const extension = path.extname(fileName).toLowerCase();
2533
- if (REMOTE_VOICE_MIME_TYPES.has(normalized)) return true;
2534
- if ((!normalized || normalized === 'application/octet-stream') && REMOTE_VOICE_EXTENSIONS.has(extension)) return true;
2535
- return false;
2900
+ function roundToStep(value, step = 1) {
2901
+ return Math.round(value / step) * step;
2536
2902
  }
2537
2903
 
2538
- async function downloadRemoteFileToTemp(remoteUrl, options = {}) {
2539
- const timeoutMs = Math.max(1_000, toInt(options.timeoutMs, REMOTE_VOICE_DOWNLOAD_TIMEOUT_MS));
2540
- const maxBytes = Math.max(1, toInt(options.maxBytes, REMOTE_VOICE_DOWNLOAD_MAX_BYTES));
2541
- const controller = new AbortController();
2542
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
2543
- let dir = null;
2904
+ function alignUp(value, step = 1) {
2905
+ return Math.ceil(value / step) * step;
2906
+ }
2907
+
2908
+ function alignDown(value, step = 1) {
2909
+ return Math.floor(value / step) * step;
2910
+ }
2911
+
2912
+ function isLegalMediaBox(width, height, options = {}) {
2913
+ const minAspect = options.minAspect ?? ASSET_MEDIA_MIN_ASPECT;
2914
+ const maxAspect = options.maxAspect ?? ASSET_MEDIA_MAX_ASPECT;
2915
+ const minDimension = options.minDimension ?? ASSET_MEDIA_MIN_DIMENSION;
2916
+ const maxDimension = options.maxDimension ?? ASSET_MEDIA_MAX_DIMENSION;
2917
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return false;
2918
+ if (width < minDimension || height < minDimension || width > maxDimension || height > maxDimension) return false;
2919
+ const aspect = width / height;
2920
+ if (aspect < minAspect || aspect > maxAspect) return false;
2921
+ const pixels = width * height;
2922
+ if (options.minPixels != null && pixels < options.minPixels) return false;
2923
+ if (options.maxPixels != null && pixels > options.maxPixels) return false;
2924
+ return true;
2925
+ }
2926
+
2927
+ function legalMediaBoxOrThrow(target, label, options = {}) {
2928
+ if (isLegalMediaBox(target.width, target.height, options)) return target;
2929
+ throw argumentError(
2930
+ `无法计算合法${label}转码尺寸`,
2931
+ `${label}目标尺寸计算结果为 ${target.width}x${target.height},仍不满足平台规格;请手动转换素材后重试。`,
2932
+ );
2933
+ }
2934
+
2935
+ function chooseClosestLegalBox(width, height, options = {}) {
2936
+ const minAspect = options.minAspect ?? ASSET_MEDIA_MIN_ASPECT;
2937
+ const maxAspect = options.maxAspect ?? ASSET_MEDIA_MAX_ASPECT;
2938
+ const minDimension = options.minDimension ?? ASSET_MEDIA_MIN_DIMENSION;
2939
+ const maxDimension = options.maxDimension ?? ASSET_MEDIA_MAX_DIMENSION;
2940
+ const step = options.even ? 2 : 1;
2941
+ const sourceWidth = Math.max(1, Number(width) || minDimension);
2942
+ const sourceHeight = Math.max(1, Number(height) || minDimension);
2943
+ const targetAspect = clampNumber(sourceWidth / sourceHeight, minAspect, maxAspect);
2944
+ const minPixels = options.minPixels;
2945
+ const maxPixels = options.maxPixels;
2946
+ const minArea = minPixels ?? minDimension * minDimension;
2947
+ const maxArea = maxPixels ?? maxDimension * maxDimension;
2948
+ const targetArea = clampNumber(sourceWidth * sourceHeight, minArea, maxArea);
2949
+ let best = null;
2950
+
2951
+ const minHeight = alignUp(minDimension, step);
2952
+ const maxHeight = alignDown(maxDimension, step);
2953
+ for (let candidateHeight = minHeight; candidateHeight <= maxHeight; candidateHeight += step) {
2954
+ const minWidthForAspect = Math.ceil(candidateHeight * minAspect);
2955
+ const maxWidthForAspect = Math.floor(candidateHeight * maxAspect);
2956
+ const minWidthForPixels = minPixels == null ? minDimension : Math.ceil(minPixels / candidateHeight);
2957
+ const maxWidthForPixels = maxPixels == null ? maxDimension : Math.floor(maxPixels / candidateHeight);
2958
+ const minWidth = alignUp(Math.max(minDimension, minWidthForAspect, minWidthForPixels), step);
2959
+ const maxWidth = alignDown(Math.min(maxDimension, maxWidthForAspect, maxWidthForPixels), step);
2960
+ if (minWidth > maxWidth) continue;
2961
+
2962
+ const preferredByAspect = roundToStep(targetAspect * candidateHeight, step);
2963
+ const preferredByArea = roundToStep(targetArea / candidateHeight, step);
2964
+ const candidates = uniqueNonEmpty([
2965
+ preferredByAspect,
2966
+ preferredByArea,
2967
+ minWidth,
2968
+ maxWidth,
2969
+ preferredByAspect - step,
2970
+ preferredByAspect + step,
2971
+ preferredByArea - step,
2972
+ preferredByArea + step,
2973
+ ])
2974
+ .map((item) => Number(item))
2975
+ .filter((item) => Number.isFinite(item))
2976
+ .map((item) => clampNumber(item, minWidth, maxWidth));
2977
+
2978
+ for (const candidateWidth of candidates) {
2979
+ if (!isLegalMediaBox(candidateWidth, candidateHeight, options)) continue;
2980
+ const aspect = candidateWidth / candidateHeight;
2981
+ const area = candidateWidth * candidateHeight;
2982
+ const aspectScore = Math.abs(Math.log(aspect / targetAspect));
2983
+ const areaScore = Math.abs(Math.log(area / targetArea));
2984
+ const sourceScaleScore = Math.abs(Math.log(candidateWidth / sourceWidth)) + Math.abs(Math.log(candidateHeight / sourceHeight));
2985
+ const score = aspectScore * 10 + areaScore + sourceScaleScore * 0.05;
2986
+ if (!best || score < best.score) {
2987
+ best = { width: candidateWidth, height: candidateHeight, pixels: area, score };
2988
+ }
2989
+ }
2990
+ }
2991
+
2992
+ if (!best) {
2993
+ const fallback = options.even ? { width: 640, height: 640, pixels: 640 * 640 } : { width: 300, height: 300, pixels: 300 * 300 };
2994
+ return fallback;
2995
+ }
2996
+ return {
2997
+ width: best.width,
2998
+ height: best.height,
2999
+ pixels: best.pixels,
3000
+ };
3001
+ }
3002
+
3003
+ function mediaSummary(media = {}) {
3004
+ return compactRecord({
3005
+ width: media.width,
3006
+ height: media.height,
3007
+ pixels: media.width && media.height ? media.width * media.height : undefined,
3008
+ aspectRatio: media.width && media.height ? Number(roundNumber(media.width / media.height, 4)) : undefined,
3009
+ duration: media.duration == null ? undefined : Number(roundNumber(media.duration, 3)),
3010
+ fps: media.fps == null ? undefined : Number(roundNumber(media.fps, 3)),
3011
+ });
3012
+ }
3013
+
3014
+ function addAssetViolation(violations, code, message, hint = '') {
3015
+ violations.push(compactRecord({ code, message, hint }));
3016
+ }
3017
+
3018
+ function validateMediaGeometry(violations, media, label, options = {}) {
3019
+ const width = Number(media?.width);
3020
+ const height = Number(media?.height);
3021
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
3022
+ addAssetViolation(violations, `${label}_dimensions_unknown`, `${label}宽高无法识别`, `${label}宽高必须在 [300,6000] px,宽高比必须在 [0.4,2.5]。`);
3023
+ return;
3024
+ }
3025
+ const aspectRatio = width / height;
3026
+ if (aspectRatio < ASSET_MEDIA_MIN_ASPECT || aspectRatio > ASSET_MEDIA_MAX_ASPECT) {
3027
+ addAssetViolation(
3028
+ violations,
3029
+ `${label}_aspect_ratio`,
3030
+ `${label}宽高比不支持:${roundNumber(aspectRatio, 4)}`,
3031
+ `${label}宽高比(宽/高)必须在 [0.4,2.5]。`,
3032
+ );
3033
+ }
3034
+ if (
3035
+ width < ASSET_MEDIA_MIN_DIMENSION
3036
+ || height < ASSET_MEDIA_MIN_DIMENSION
3037
+ || width > ASSET_MEDIA_MAX_DIMENSION
3038
+ || height > ASSET_MEDIA_MAX_DIMENSION
3039
+ ) {
3040
+ addAssetViolation(
3041
+ violations,
3042
+ `${label}_dimensions`,
3043
+ `${label}宽高不支持:${width}x${height}`,
3044
+ `${label}宽和高都必须在 [300,6000] px。`,
3045
+ );
3046
+ }
3047
+ if (options.videoPixels) {
3048
+ const pixels = width * height;
3049
+ if (pixels < ASSET_VIDEO_MIN_PIXELS || pixels > ASSET_VIDEO_MAX_PIXELS) {
3050
+ addAssetViolation(
3051
+ violations,
3052
+ 'video_pixels',
3053
+ `视频总像素数不支持:${pixels}`,
3054
+ `视频宽高乘积必须在 [${ASSET_VIDEO_MIN_PIXELS},${ASSET_VIDEO_MAX_PIXELS}]。`,
3055
+ );
3056
+ }
3057
+ }
3058
+ }
3059
+
3060
+ function validateDurationRange(violations, media, label, minSeconds, maxSeconds) {
3061
+ const duration = Number(media?.duration);
3062
+ if (!Number.isFinite(duration) || duration < minSeconds || duration > maxSeconds) {
3063
+ addAssetViolation(
3064
+ violations,
3065
+ `${label}_duration`,
3066
+ `${label}时长不支持:${Number.isFinite(duration) ? `${roundNumber(duration)}s` : 'unknown'}`,
3067
+ `${label}时长必须在 [${minSeconds},${maxSeconds}] 秒。`,
3068
+ );
3069
+ }
3070
+ }
3071
+
3072
+ async function commandSucceeds(command, args = ['--version']) {
3073
+ try {
3074
+ await execFileAsync(command, args, { encoding: 'utf8', timeout: 10_000, maxBuffer: 256 * 1024 });
3075
+ return true;
3076
+ } catch {
3077
+ return false;
3078
+ }
3079
+ }
3080
+
3081
+ function commandCandidates(command) {
3082
+ if (path.isAbsolute(command)) return [command];
3083
+ if (command === 'brew') return ['brew', '/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
3084
+ if (command === 'ffprobe') return ['ffprobe', '/opt/homebrew/bin/ffprobe', '/usr/local/bin/ffprobe'];
3085
+ if (command === 'ffmpeg') return ['ffmpeg', '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'];
3086
+ return [command];
3087
+ }
3088
+
3089
+ async function findCommand(command, args = ['--version']) {
3090
+ for (const candidate of commandCandidates(command)) {
3091
+ if (await commandSucceeds(candidate, args)) return candidate;
3092
+ }
3093
+ return null;
3094
+ }
3095
+
3096
+ function autoInstallFfprobeEnabled() {
3097
+ const value = String(process.env.LINGJING_AWB_AUTO_INSTALL_FFPROBE ?? '1').trim().toLowerCase();
3098
+ return !['0', 'false', 'no', 'off'].includes(value);
3099
+ }
3100
+
3101
+ async function ensureFfprobeAvailable() {
3102
+ if (ffprobeCommandCache && await commandSucceeds(ffprobeCommandCache, ['-version'])) return ffprobeCommandCache;
3103
+ const existing = await findCommand('ffprobe', ['-version']);
3104
+ if (existing) {
3105
+ ffprobeCommandCache = existing;
3106
+ return existing;
3107
+ }
3108
+ const brewCommand = autoInstallFfprobeEnabled() && process.platform === 'darwin'
3109
+ ? await findCommand('brew', ['--version'])
3110
+ : null;
3111
+ if (brewCommand) {
3112
+ try {
3113
+ await execFileAsync(brewCommand, ['install', 'ffmpeg'], {
3114
+ encoding: 'utf8',
3115
+ timeout: 10 * 60_000,
3116
+ maxBuffer: 8 * 1024 * 1024,
3117
+ });
3118
+ const installed = await findCommand('ffprobe', ['-version']);
3119
+ if (installed) {
3120
+ ffprobeCommandCache = installed;
3121
+ return installed;
3122
+ }
3123
+ } catch (error) {
3124
+ throw argumentError(
3125
+ '缺少 ffprobe,且自动安装 ffmpeg 失败',
3126
+ `CLI 已尝试运行 brew install ffmpeg。请手动安装后重试;如需关闭自动安装,设置 LINGJING_AWB_AUTO_INSTALL_FFPROBE=0。原始错误:${error.message}`,
3127
+ );
3128
+ }
3129
+ }
3130
+ throw argumentError(
3131
+ '缺少 ffprobe',
3132
+ '本地图片 / 音频 / 视频素材加白需要 ffprobe 校验尺寸、时长和视频 FPS。macOS 可运行 brew install ffmpeg;Ubuntu/Debian 可运行 sudo apt-get install ffmpeg。',
3133
+ );
3134
+ }
3135
+
3136
+ async function ensureFfmpegAvailable() {
3137
+ if (ffmpegCommandCache && await commandSucceeds(ffmpegCommandCache, ['-version'])) return ffmpegCommandCache;
3138
+ const existing = await findCommand('ffmpeg', ['-version']);
3139
+ if (existing) {
3140
+ ffmpegCommandCache = existing;
3141
+ return existing;
3142
+ }
3143
+ const brewCommand = autoInstallFfprobeEnabled() && process.platform === 'darwin'
3144
+ ? await findCommand('brew', ['--version'])
3145
+ : null;
3146
+ if (brewCommand) {
3147
+ try {
3148
+ await execFileAsync(brewCommand, ['install', 'ffmpeg'], {
3149
+ encoding: 'utf8',
3150
+ timeout: 10 * 60_000,
3151
+ maxBuffer: 8 * 1024 * 1024,
3152
+ });
3153
+ const installed = await findCommand('ffmpeg', ['-version']);
3154
+ if (installed) {
3155
+ ffmpegCommandCache = installed;
3156
+ return installed;
3157
+ }
3158
+ } catch (error) {
3159
+ throw argumentError(
3160
+ '缺少 ffmpeg,且自动安装 ffmpeg 失败',
3161
+ `CLI 已尝试运行 brew install ffmpeg。请手动安装后重试;如需关闭自动安装,设置 LINGJING_AWB_AUTO_INSTALL_FFPROBE=0。原始错误:${error.message}`,
3162
+ );
3163
+ }
3164
+ }
3165
+ throw argumentError(
3166
+ '缺少 ffmpeg',
3167
+ '素材自动转码需要 ffmpeg。macOS 可运行 brew install ffmpeg;Ubuntu/Debian 可运行 sudo apt-get install ffmpeg。',
3168
+ );
3169
+ }
3170
+
3171
+ async function readMediaMetadata(filePath, assetType) {
3172
+ const ffprobeCommand = await ensureFfprobeAvailable();
3173
+ try {
3174
+ const { stdout } = await execFileAsync(ffprobeCommand, [
3175
+ '-v',
3176
+ 'error',
3177
+ '-print_format',
3178
+ 'json',
3179
+ '-show_format',
3180
+ '-show_streams',
3181
+ filePath,
3182
+ ], { encoding: 'utf8', timeout: 15_000, maxBuffer: 2 * 1024 * 1024 });
3183
+ const data = JSON.parse(stdout);
3184
+ const streams = Array.isArray(data.streams) ? data.streams : [];
3185
+ const wantedStreamType = assetType === ASSET_TYPE_CODES.Audio ? 'audio' : 'video';
3186
+ const stream = streams.find((item) => item.codec_type === wantedStreamType);
3187
+ if (!stream) {
3188
+ throw new Error(`缺少 ${wantedStreamType} stream`);
3189
+ }
3190
+ const duration = parseFfprobeNumber(stream.duration) ?? parseFfprobeNumber(data.format?.duration);
3191
+ return compactRecord({
3192
+ duration,
3193
+ width: parseFfprobeNumber(stream.width),
3194
+ height: parseFfprobeNumber(stream.height),
3195
+ fps: parseFps(stream.avg_frame_rate) ?? parseFps(stream.r_frame_rate),
3196
+ });
3197
+ } catch (error) {
3198
+ const mediaLabel = assetType === ASSET_TYPE_CODES.Audio ? '音频' : (assetType === ASSET_TYPE_CODES.Video ? '视频' : '图片');
3199
+ throw argumentError(
3200
+ `无法读取${mediaLabel}素材信息`,
3201
+ `请确认文件可被 ffprobe 解析且本机已安装 ffprobe。原始错误:${error.message}`,
3202
+ );
3203
+ }
3204
+ }
3205
+
3206
+ async function readImageMetadata(filePath) {
3207
+ const inspected = await inspectLocalFile(filePath);
3208
+ if (Number.isFinite(inspected.width) && Number.isFinite(inspected.height)) {
3209
+ return compactRecord({
3210
+ width: inspected.width,
3211
+ height: inspected.height,
3212
+ format: inspected.format ?? null,
3213
+ });
3214
+ }
3215
+ const media = await readMediaMetadata(filePath, ASSET_TYPE_CODES.Image);
3216
+ return compactRecord({
3217
+ width: media.width,
3218
+ height: media.height,
3219
+ format: inspected.format ?? null,
3220
+ });
3221
+ }
3222
+
3223
+ function chooseLegalMediaBox(width, height, options = {}) {
3224
+ return legalMediaBoxOrThrow(chooseClosestLegalBox(width, height, options), '图片', options);
3225
+ }
3226
+
3227
+ function chooseLegalVideoBox(width, height) {
3228
+ const options = {
3229
+ even: true,
3230
+ minPixels: ASSET_VIDEO_MIN_PIXELS,
3231
+ maxPixels: ASSET_VIDEO_MAX_PIXELS,
3232
+ };
3233
+ return legalMediaBoxOrThrow(chooseClosestLegalBox(width, height, options), '视频', options);
3234
+ }
3235
+
3236
+ function validateAssetViolations(assetType, inspected, media = null) {
3237
+ const violations = [];
3238
+ const format = normalizeFileFormat(inspected.format ?? path.extname(inspected.filePath));
3239
+ if (assetType === ASSET_TYPE_CODES.Image) {
3240
+ if (inspected.size >= ASSET_IMAGE_MAX_BYTES) {
3241
+ addAssetViolation(violations, 'image_size', `图片素材过大:${mb(inspected.size)}`, '单张图片必须小于 30MB。');
3242
+ }
3243
+ if (!media?.width || !media?.height) {
3244
+ addAssetViolation(violations, 'image_dimensions_unknown', '图片宽高无法识别', '请确认文件可被 ffprobe 或本地图片解析器解析。');
3245
+ } else {
3246
+ validateMediaGeometry(violations, media, '图片');
3247
+ }
3248
+ } else if (assetType === ASSET_TYPE_CODES.Video) {
3249
+ if (inspected.size > ASSET_VIDEO_MAX_BYTES) {
3250
+ addAssetViolation(violations, 'video_size', `视频素材过大:${mb(inspected.size)}`, '单个视频不能超过 50MB。');
3251
+ }
3252
+ if (!media?.width || !media?.height) {
3253
+ addAssetViolation(violations, 'video_dimensions_unknown', '视频宽高无法识别', '请确认文件可被 ffprobe 解析。');
3254
+ } else {
3255
+ validateMediaGeometry(violations, media, '视频', { videoPixels: true });
3256
+ }
3257
+ if (media?.fps == null || media.fps + 0.05 < ASSET_VIDEO_MIN_FPS || media.fps - 0.05 > ASSET_VIDEO_MAX_FPS) {
3258
+ addAssetViolation(
3259
+ violations,
3260
+ 'video_fps',
3261
+ `视频帧率不支持:${media?.fps == null ? 'unknown' : `${roundNumber(media.fps)}fps`}`,
3262
+ '视频 FPS 必须在 [24,60]。',
3263
+ );
3264
+ }
3265
+ if (media?.duration == null || media.duration < ASSET_VIDEO_MIN_SECONDS || media.duration > ASSET_VIDEO_MAX_SECONDS) {
3266
+ addAssetViolation(
3267
+ violations,
3268
+ 'video_duration',
3269
+ `视频时长不支持:${media?.duration == null ? 'unknown' : `${roundNumber(media.duration)}s`}`,
3270
+ '单个视频时长必须在 [2,15] 秒。',
3271
+ );
3272
+ }
3273
+ } else if (assetType === ASSET_TYPE_CODES.Audio) {
3274
+ if (media?.duration == null || media.duration < ASSET_AUDIO_MIN_SECONDS || media.duration > ASSET_AUDIO_MAX_SECONDS) {
3275
+ addAssetViolation(
3276
+ violations,
3277
+ 'audio_duration',
3278
+ `音频时长不支持:${media?.duration == null ? 'unknown' : `${roundNumber(media.duration)}s`}`,
3279
+ '单个音频时长必须在 [2,15] 秒。',
3280
+ );
3281
+ }
3282
+ }
3283
+ return {
3284
+ legal: violations.length === 0,
3285
+ assetType,
3286
+ format,
3287
+ inspected,
3288
+ media: media ? compactRecord(media) : null,
3289
+ violations,
3290
+ };
3291
+ }
3292
+
3293
+ function buildAssetConversionPlan(validation) {
3294
+ const { assetType, media, inspected } = validation;
3295
+ if (!media?.width && assetType !== ASSET_TYPE_CODES.Audio) return null;
3296
+ if (assetType === ASSET_TYPE_CODES.Image) {
3297
+ const target = chooseLegalMediaBox(media.width, media.height);
3298
+ return compactRecord({
3299
+ assetType,
3300
+ fromFormat: normalizeDisplayFormat(inspected.format ?? path.extname(inspected.filePath)) || 'unknown',
3301
+ toFormat: 'jpg',
3302
+ targetWidth: target.width,
3303
+ targetHeight: target.height,
3304
+ sourceFile: inspected.filePath,
3305
+ reason: validation.violations.map((item) => item.message).join(';'),
3306
+ 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`,
3307
+ });
3308
+ }
3309
+ if (assetType === ASSET_TYPE_CODES.Video) {
3310
+ const target = chooseLegalVideoBox(media.width, media.height);
3311
+ const targetFps = clampNumber(Number(media.fps ?? ASSET_VIDEO_MIN_FPS), ASSET_VIDEO_MIN_FPS, ASSET_VIDEO_MAX_FPS);
3312
+ const targetDuration = clampNumber(Number(media.duration ?? ASSET_VIDEO_MIN_SECONDS), ASSET_VIDEO_MIN_SECONDS, ASSET_VIDEO_MAX_SECONDS);
3313
+ const filters = [
3314
+ `fps=${roundNumber(targetFps, 3)}`,
3315
+ `scale=${target.width}:${target.height}:force_original_aspect_ratio=decrease`,
3316
+ `pad=${target.width}:${target.height}:(ow-iw)/2:(oh-ih)/2:black`,
3317
+ 'setsar=1',
3318
+ ];
3319
+ if (media.duration != null && media.duration < ASSET_VIDEO_MIN_SECONDS) {
3320
+ filters.push(`tpad=stop_mode=clone:stop_duration=${roundNumber(ASSET_VIDEO_MIN_SECONDS - media.duration, 3)}`);
3321
+ }
3322
+ return compactRecord({
3323
+ assetType,
3324
+ fromFormat: normalizeDisplayFormat(inspected.format ?? path.extname(inspected.filePath)) || 'unknown',
3325
+ toFormat: 'mp4',
3326
+ sourceDuration: media.duration,
3327
+ targetWidth: target.width,
3328
+ targetHeight: target.height,
3329
+ targetPixels: target.width * target.height,
3330
+ targetFps: Number(roundNumber(targetFps, 3)),
3331
+ targetDuration,
3332
+ sourceFile: inspected.filePath,
3333
+ reason: validation.violations.map((item) => item.message).join(';'),
3334
+ filters: filters.join(','),
3335
+ });
3336
+ }
3337
+ if (assetType === ASSET_TYPE_CODES.Audio) {
3338
+ return compactRecord({
3339
+ assetType,
3340
+ fromFormat: normalizeDisplayFormat(inspected.format ?? path.extname(inspected.filePath)) || 'unknown',
3341
+ toFormat: 'wav',
3342
+ sourceDuration: media?.duration,
3343
+ targetDuration: clampNumber(Number(media?.duration ?? ASSET_AUDIO_MIN_SECONDS), ASSET_AUDIO_MIN_SECONDS, ASSET_AUDIO_MAX_SECONDS),
3344
+ sourceFile: inspected.filePath,
3345
+ reason: validation.violations.map((item) => item.message).join(';'),
3346
+ });
3347
+ }
3348
+ return null;
3349
+ }
3350
+
3351
+ function dryRunAssetFileName(source) {
3352
+ const plan = source.conversionPlan;
3353
+ if (!plan?.toFormat) return source.localFile?.filePath;
3354
+ return forceFileExtension(source.localFile?.fileName || source.localFile?.filePath || 'asset', `.${conversionOutputExtension(plan.toFormat)}`);
3355
+ }
3356
+
3357
+ async function convertLocalVideoFile(filePath, plan) {
3358
+ const ffmpegCommand = await ensureFfmpegAvailable();
3359
+ const outputPath = path.join(tmpdir(), `lj-awb-${crypto.randomUUID()}.mp4`);
3360
+ const args = [
3361
+ '-y',
3362
+ '-hide_banner',
3363
+ '-loglevel',
3364
+ 'error',
3365
+ '-i',
3366
+ filePath,
3367
+ '-vf',
3368
+ plan.filters,
3369
+ '-map',
3370
+ '0:v:0',
3371
+ '-map',
3372
+ '0:a?',
3373
+ '-c:v',
3374
+ 'libx264',
3375
+ '-pix_fmt',
3376
+ 'yuv420p',
3377
+ '-preset',
3378
+ 'medium',
3379
+ '-crf',
3380
+ '20',
3381
+ '-movflags',
3382
+ '+faststart',
3383
+ ];
3384
+ if (Number.isFinite(Number(plan.sourceDuration)) && plan.sourceDuration > ASSET_VIDEO_MAX_SECONDS) {
3385
+ args.push('-t', String(ASSET_VIDEO_MAX_SECONDS));
3386
+ } else if (Number.isFinite(Number(plan.sourceDuration)) && plan.sourceDuration < ASSET_VIDEO_MIN_SECONDS) {
3387
+ args.push('-t', String(ASSET_VIDEO_MIN_SECONDS));
3388
+ }
3389
+ args.push('-c:a', 'aac', '-b:a', '128k', outputPath);
3390
+ try {
3391
+ await execFileAsync(ffmpegCommand, args, { timeout: 15 * 60_000, maxBuffer: 8 * 1024 * 1024 });
3392
+ } catch (error) {
3393
+ await fs.rm(outputPath, { force: true }).catch(() => {});
3394
+ throw argumentError(
3395
+ `视频格式转换失败:${path.basename(filePath)}`,
3396
+ `请确认本机可用 ffmpeg,或手动转换后重试。${error?.message ? ` 原因:${error.message}` : ''}`,
3397
+ );
3398
+ }
3399
+ const inspected = await inspectLocalFile(outputPath);
3400
+ if (!inspected.exists) {
3401
+ throw argumentError(`视频格式转换失败:${path.basename(filePath)}`, '未生成目标视频文件。');
3402
+ }
3403
+ return inspected;
3404
+ }
3405
+
3406
+ async function convertLocalAudioFile(filePath, plan) {
3407
+ const ffmpegCommand = await ensureFfmpegAvailable();
3408
+ const outputPath = path.join(tmpdir(), `lj-awb-${crypto.randomUUID()}.wav`);
3409
+ const args = [
3410
+ '-y',
3411
+ '-hide_banner',
3412
+ '-loglevel',
3413
+ 'error',
3414
+ '-i',
3415
+ filePath,
3416
+ ];
3417
+ if (Number.isFinite(Number(plan.sourceDuration)) && plan.sourceDuration < ASSET_AUDIO_MIN_SECONDS) {
3418
+ args.push('-af', 'apad');
3419
+ args.push('-t', String(ASSET_AUDIO_MIN_SECONDS));
3420
+ } else if (Number.isFinite(Number(plan.sourceDuration)) && plan.sourceDuration > ASSET_AUDIO_MAX_SECONDS) {
3421
+ args.push('-t', String(ASSET_AUDIO_MAX_SECONDS));
3422
+ }
3423
+ args.push('-c:a', 'pcm_s16le', outputPath);
3424
+ try {
3425
+ await execFileAsync(ffmpegCommand, args, { timeout: 15 * 60_000, maxBuffer: 8 * 1024 * 1024 });
3426
+ } catch (error) {
3427
+ await fs.rm(outputPath, { force: true }).catch(() => {});
3428
+ throw argumentError(
3429
+ `音频格式转换失败:${path.basename(filePath)}`,
3430
+ `请确认本机可用 ffmpeg,或手动转换后重试。${error?.message ? ` 原因:${error.message}` : ''}`,
3431
+ );
3432
+ }
3433
+ const inspected = await inspectLocalFile(outputPath);
3434
+ if (!inspected.exists) {
3435
+ throw argumentError(`音频格式转换失败:${path.basename(filePath)}`, '未生成目标音频文件。');
3436
+ }
3437
+ return inspected;
3438
+ }
3439
+
3440
+ async function convertLocalImageFile(filePath, targetFormat, options = {}) {
3441
+ const ffmpegCommand = await ensureFfmpegAvailable();
3442
+ const target = normalizeFileFormat(targetFormat) || 'jpg';
3443
+ const outputPath = options.outputPath || path.join(tmpdir(), `lj-awb-${crypto.randomUUID()}.${conversionOutputExtension(target)}`);
3444
+ const codecArgs = target === 'jpg' ? ['-q:v', String(options.quality ?? 2)] : [];
3445
+ const filterArgs = options.filters ? ['-vf', options.filters] : [];
3446
+ try {
3447
+ await execFileAsync(ffmpegCommand, [
3448
+ '-y',
3449
+ '-hide_banner',
3450
+ '-loglevel',
3451
+ 'error',
3452
+ '-i',
3453
+ filePath,
3454
+ ...filterArgs,
3455
+ '-frames:v',
3456
+ '1',
3457
+ ...codecArgs,
3458
+ outputPath,
3459
+ ], { timeout: 10 * 60_000, maxBuffer: 8 * 1024 * 1024 });
3460
+ } catch (error) {
3461
+ await fs.rm(outputPath, { force: true }).catch(() => {});
3462
+ throw argumentError(
3463
+ `图片格式转换失败:${path.basename(filePath)}`,
3464
+ `需要把该图片转换为 ${target} 后提交。请确认本机可用 ffmpeg,或手动转换后重试。${error?.message ? ` 原因:${error.message}` : ''}`,
3465
+ );
3466
+ }
3467
+ const inspected = await inspectLocalFile(outputPath);
3468
+ if (!inspected.exists) {
3469
+ throw argumentError(`图片格式转换失败:${path.basename(filePath)}`, `未生成 ${target} 文件。`);
3470
+ }
3471
+ return inspected;
3472
+ }
3473
+
3474
+ async function inspectAssetLocalFile(localFile) {
3475
+ const inspected = await inspectLocalFile(localFile);
3476
+ if (!inspected.exists) throw argumentError(`文件不存在:${localFile}`);
3477
+ const ext = sourceExtension(inspected.filePath);
3478
+ const assetType = inferAssetTypeFromSource(inspected.filePath);
3479
+ if (assetType === ASSET_TYPE_CODES.Image) {
3480
+ const media = await readImageMetadata(inspected.filePath);
3481
+ const validation = validateAssetViolations(assetType, inspected, media);
3482
+ return {
3483
+ ...validation,
3484
+ localFile: inspected,
3485
+ media,
3486
+ conversionPlan: validation.legal ? null : buildAssetConversionPlan(validation),
3487
+ };
3488
+ }
3489
+ if (assetType === ASSET_TYPE_CODES.Video) {
3490
+ const media = await readMediaMetadata(inspected.filePath, assetType);
3491
+ const validation = validateAssetViolations(assetType, inspected, media);
3492
+ return {
3493
+ ...validation,
3494
+ localFile: inspected,
3495
+ media,
3496
+ conversionPlan: validation.legal ? null : buildAssetConversionPlan(validation),
3497
+ };
3498
+ }
3499
+ if (assetType === ASSET_TYPE_CODES.Audio) {
3500
+ const media = await readMediaMetadata(inspected.filePath, assetType);
3501
+ const validation = validateAssetViolations(assetType, inspected, media);
3502
+ return {
3503
+ ...validation,
3504
+ localFile: inspected,
3505
+ media,
3506
+ conversionPlan: validation.legal ? null : buildAssetConversionPlan(validation),
3507
+ };
3508
+ }
3509
+ throw argumentError(`不支持的素材格式:${ext}`);
3510
+ }
3511
+
3512
+ function assetValidationSummary(validation) {
3513
+ return validation.violations.map((item) => item.message).join(';');
3514
+ }
3515
+
3516
+ function assetConversionUnavailableError(validation) {
3517
+ const summary = assetValidationSummary(validation);
3518
+ return argumentError(
3519
+ `素材不符合 JIMENG 加白规格:${summary || '规格不合法'}`,
3520
+ 'CLI 无法自动生成转码计划,请手动转换为合法图片 / 视频 / 音频规格后重试。',
3521
+ );
3522
+ }
3523
+
3524
+ async function confirmAssetConversion(validation) {
3525
+ if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
3526
+ throw argumentError(
3527
+ `素材不符合 JIMENG 加白规格:${assetValidationSummary(validation)}`,
3528
+ '非交互终端不会自动询问。请在终端中重试并确认转码,或追加 --auto-convert 自动转换后继续。',
3529
+ );
3530
+ }
3531
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
3532
+ try {
3533
+ const answer = await rl.question(`素材不符合 JIMENG 加白规格:${assetValidationSummary(validation)}\n是否自动转码为合法规格后继续?[y/N] `);
3534
+ return ['y', 'yes'].includes(String(answer || '').trim().toLowerCase());
3535
+ } finally {
3536
+ rl.close();
3537
+ }
3538
+ }
3539
+
3540
+ async function convertAssetLocalFile(validation) {
3541
+ const plan = validation.conversionPlan;
3542
+ if (!plan) throw assetConversionUnavailableError(validation);
3543
+ let converted;
3544
+ if (validation.assetType === ASSET_TYPE_CODES.Image) {
3545
+ converted = await convertLocalImageFile(validation.localFile.filePath, plan.toFormat, { filters: plan.filters });
3546
+ } else if (validation.assetType === ASSET_TYPE_CODES.Video) {
3547
+ converted = await convertLocalVideoFile(validation.localFile.filePath, plan);
3548
+ } else if (validation.assetType === ASSET_TYPE_CODES.Audio) {
3549
+ converted = await convertLocalAudioFile(validation.localFile.filePath, plan);
3550
+ } else {
3551
+ throw assetConversionUnavailableError(validation);
3552
+ }
3553
+ const nextValidation = await inspectAssetLocalFile(converted.filePath);
3554
+ if (!nextValidation.legal) {
3555
+ await fs.rm(converted.filePath, { force: true }).catch(() => {});
3556
+ throw argumentError(
3557
+ `素材自动转码后仍不符合加白规格:${assetValidationSummary(nextValidation)}`,
3558
+ '请手动转换为合法规格后重试,或检查原始素材是否损坏。',
3559
+ );
3560
+ }
3561
+ return {
3562
+ ...nextValidation,
3563
+ originalLocalFile: validation.localFile,
3564
+ conversion: compactRecord({
3565
+ ...plan,
3566
+ converted: true,
3567
+ convertedFile: converted.filePath,
3568
+ convertedSize: converted.size,
3569
+ originalSize: validation.localFile.size,
3570
+ }),
3571
+ };
3572
+ }
3573
+
3574
+ async function resolveInvalidAssetLocalFile(validation, kwargs = {}) {
3575
+ if (validation.legal) return validation;
3576
+ if (!validation.conversionPlan) throw assetConversionUnavailableError(validation);
3577
+ const shouldConvert = toBool(kwargs.autoConvert) || await confirmAssetConversion(validation);
3578
+ if (!shouldConvert) {
3579
+ throw argumentError(
3580
+ `素材不符合 JIMENG 加白规格:${assetValidationSummary(validation)}`,
3581
+ '已取消自动转码。请手动转换素材,或追加 --auto-convert 自动转换后继续。',
3582
+ );
3583
+ }
3584
+ return await convertAssetLocalFile(validation);
3585
+ }
3586
+
3587
+ function validateAssetRemoteSource(assetPath) {
3588
+ return { assetType: inferAssetTypeFromSource(assetPath) };
3589
+ }
3590
+
3591
+ export async function uploadFilesCommand(kwargs = {}) {
3592
+ const specs = await collectFileSpecs(kwargs, trimToNull(kwargs.sceneType));
3593
+ if (!specs.length) throw argumentError('缺少上传文件', '传 --file <path> 或 --files a.png,b.mp4。');
3594
+ if (toBool(kwargs.dryRun)) {
3595
+ const files = [];
3596
+ for (const spec of specs) {
3597
+ const inspected = await inspectLocalFile(spec.file);
3598
+ files.push(compactRecord({
3599
+ ...inspected,
3600
+ sceneType: spec.sceneType,
3601
+ projectNo: spec.projectNo,
3602
+ backendPath: dryRunBackendPath(spec.file, spec.sceneType),
3603
+ url: null,
3604
+ dryRun: true,
3605
+ }));
3606
+ }
3607
+ return { dryRun: true, files };
3608
+ }
3609
+ const files = [];
3610
+ for (const spec of specs) {
3611
+ files.push(await uploadLocalFile(spec.file, spec));
3612
+ }
3613
+ return { files };
3614
+ }
3615
+
3616
+ export async function uploadLocalFile(filePath, options = {}) {
3617
+ const inspected = await inspectLocalFile(filePath);
3618
+ if (!inspected.exists) {
3619
+ throw argumentError(`文件不存在:${filePath}`);
3620
+ }
3621
+ const buffer = await fs.readFile(inspected.filePath);
3622
+ const sceneType = options.sceneType ?? defaultUploadSceneForFile(inspected.filePath, inspected.mimeType);
3623
+ const groupId = crypto.randomUUID().replaceAll('-', '');
3624
+ const secret = await awbApi.fetchUploadSecret({
3625
+ sceneType,
3626
+ groupId,
3627
+ projectNo: options.projectNo ?? '',
3628
+ });
3629
+ const credentials = secret.credentials ?? secret;
3630
+ const objectName = `${secret.path ?? ''}${secret.prefix ?? ''}${Date.now()}-${safeFileName(inspected.filePath)}`.replace(/^\/+/, '');
3631
+ const host = `${secret.bucket}.cos.${secret.region}.myqcloud.com`;
3632
+ const authorization = buildCosAuthorization({
3633
+ secretKey: credentials.tmpSecretKey,
3634
+ secretId: credentials.tmpSecretId,
3635
+ method: 'PUT',
3636
+ objectName,
3637
+ contentLength: buffer.length,
3638
+ host,
3639
+ startTime: secret.startTime,
3640
+ expiredTime: secret.expiredTime,
3641
+ });
3642
+ const response = await fetch(`https://${host}/${encodeObjectNamePath(objectName)}`, {
3643
+ method: 'PUT',
3644
+ headers: {
3645
+ authorization,
3646
+ 'content-length': String(buffer.length),
3647
+ 'content-type': inspected.mimeType || guessMimeType(inspected.filePath),
3648
+ host,
3649
+ 'x-cos-security-token': credentials.sessionToken,
3650
+ },
3651
+ body: buffer,
3652
+ });
3653
+ if (!response.ok) {
3654
+ throw new LingjingAwbCliError(`上传 COS 失败:${response.status} ${response.statusText}`, {
3655
+ type: 'upload_failed',
3656
+ exitCode: 30,
3657
+ details: { filePath: inspected.filePath, objectName },
3658
+ });
3659
+ }
3660
+ return compactRecord({
3661
+ ...inspected,
3662
+ sceneType,
3663
+ projectNo: options.projectNo ?? '',
3664
+ backendPath: `/${objectName}`,
3665
+ url: `https://${host}/${encodeObjectNamePath(objectName)}`,
3666
+ });
3667
+ }
3668
+
3669
+ function isHttpUrl(value) {
3670
+ return /^https?:\/\//i.test(String(value || '').trim());
3671
+ }
3672
+
3673
+ function backendObjectName(value) {
3674
+ const text = trimToNull(value);
3675
+ if (!text) return null;
3676
+ return text.replace(/^\/+/, '');
3677
+ }
3678
+
3679
+ function isPlatformBackendPath(value) {
3680
+ const objectName = backendObjectName(value);
3681
+ if (!objectName || /^[a-z][a-z0-9+.-]*:\/\//i.test(objectName)) return false;
3682
+ return PLATFORM_BACKEND_PATH_PREFIXES.some((prefix) => objectName.startsWith(prefix));
3683
+ }
3684
+
3685
+ function isUploadedMaterialReference(value) {
3686
+ const text = trimToNull(value);
3687
+ if (!text) return false;
3688
+ if (isPlatformBackendPath(text)) return true;
3689
+ if (!isHttpUrl(text)) return false;
3690
+ try {
3691
+ const url = new URL(text);
3692
+ return url.hostname.endsWith('.myqcloud.com') && isPlatformBackendPath(decodeURIComponent(url.pathname));
3693
+ } catch {
3694
+ return false;
3695
+ }
3696
+ }
3697
+
3698
+ function remoteFileNameFromUrl(remoteUrl, fallback = 'voice-audio') {
3699
+ try {
3700
+ const pathname = decodeURIComponent(new URL(remoteUrl).pathname);
3701
+ const baseName = safeFileName(path.basename(pathname));
3702
+ if (baseName && baseName !== '.' && baseName !== '/') return baseName;
3703
+ } catch {}
3704
+ return `${fallback}.mp3`;
3705
+ }
3706
+
3707
+ function extensionFromContentType(contentType = '') {
3708
+ const normalized = String(contentType || '').split(';')[0].trim().toLowerCase();
3709
+ if (normalized === 'image/bmp') return '.bmp';
3710
+ if (normalized === 'image/gif') return '.gif';
3711
+ if (normalized === 'image/jpeg' || normalized === 'image/jpg') return '.jpg';
3712
+ if (normalized === 'image/png') return '.png';
3713
+ if (normalized === 'image/webp') return '.webp';
3714
+ if (normalized === 'audio/mpeg') return '.mp3';
3715
+ if (normalized === 'audio/wav' || normalized === 'audio/x-wav') return '.wav';
3716
+ if (normalized === 'audio/mp4') return '.m4a';
3717
+ if (normalized === 'audio/aac') return '.aac';
3718
+ if (normalized === 'audio/ogg') return '.ogg';
3719
+ if (normalized === 'video/mp4') return '.mp4';
3720
+ return '';
3721
+ }
3722
+
3723
+ function isSupportedRemoteVoiceDownload(contentType = '', fileName = '') {
3724
+ const normalized = String(contentType || '').split(';')[0].trim().toLowerCase();
3725
+ const extension = path.extname(fileName).toLowerCase();
3726
+ if (REMOTE_VOICE_MIME_TYPES.has(normalized)) return true;
3727
+ if ((!normalized || normalized === 'application/octet-stream') && REMOTE_VOICE_EXTENSIONS.has(extension)) return true;
3728
+ return false;
3729
+ }
3730
+
3731
+ function remoteImageFileNameFromValue(value, fallback = 'resource-image') {
3732
+ const text = trimToNull(value);
3733
+ if (text) {
3734
+ try {
3735
+ const pathname = decodeURIComponent(new URL(text).pathname);
3736
+ const baseName = safeFileName(path.basename(pathname));
3737
+ if (baseName && baseName !== '.' && baseName !== '/') return baseName;
3738
+ } catch {
3739
+ const baseName = safeFileName(path.basename(text.split('?')[0].split('#')[0]));
3740
+ if (baseName && baseName !== '.' && baseName !== '/') return baseName;
3741
+ }
3742
+ }
3743
+ return fallback;
3744
+ }
3745
+
3746
+ function extensionForImageFormat(format) {
3747
+ const normalized = normalizeFileFormat(format);
3748
+ if (!normalized) return '';
3749
+ return normalized === 'jpg' ? '.jpg' : `.${normalized}`;
3750
+ }
3751
+
3752
+ function forceFileExtension(fileName, extension) {
3753
+ const safeName = safeFileName(fileName || 'resource-image');
3754
+ if (!extension) return safeName;
3755
+ return `${path.basename(safeName, path.extname(safeName))}${extension}`;
3756
+ }
3757
+
3758
+ function isSupportedRemoteImageDownload(contentType = '', fileName = '', expectedFormat = null) {
3759
+ const normalized = String(contentType || '').split(';')[0].trim().toLowerCase();
3760
+ const extension = path.extname(fileName).toLowerCase();
3761
+ if (REMOTE_IMAGE_MIME_TYPES.has(normalized)) return true;
3762
+ if ((!normalized || normalized === 'application/octet-stream') && REMOTE_IMAGE_EXTENSIONS.has(extension)) return true;
3763
+ if ((!normalized || normalized === 'application/octet-stream') && expectedFormat && COMMON_IMAGE_FORMATS.has(normalizeFileFormat(expectedFormat))) return true;
3764
+ return false;
3765
+ }
3766
+
3767
+ function firstHttpUrl(value, depth = 0) {
3768
+ if (depth > 4 || value == null) return null;
3769
+ if (typeof value === 'string') return /^https?:\/\//i.test(value) ? value : null;
3770
+ if (Array.isArray(value)) {
3771
+ for (const item of value) {
3772
+ const found = firstHttpUrl(item, depth + 1);
3773
+ if (found) return found;
3774
+ }
3775
+ return null;
3776
+ }
3777
+ if (typeof value !== 'object') return null;
3778
+ for (const key of ['url', 'signedUrl', 'signUrl', 'downloadUrl', 'cosUrl', 'previewUrl', 'data']) {
3779
+ const found = firstHttpUrl(value[key], depth + 1);
3780
+ if (found) return found;
3781
+ }
3782
+ for (const nested of Object.values(value)) {
3783
+ const found = firstHttpUrl(nested, depth + 1);
3784
+ if (found) return found;
3785
+ }
3786
+ return null;
3787
+ }
3788
+
3789
+ async function resolveRemoteResourceDownloadUrl(value) {
3790
+ const text = trimToNull(value);
3791
+ if (!text) throw argumentError('远程图片来源为空');
3792
+ if (/^https?:\/\//i.test(text)) return text;
3793
+ if (isPlatformBackendPath(text)) {
3794
+ const objectName = backendObjectName(text);
3795
+ const signed = await awbApi.fetchObjectSignUrl(objectName);
3796
+ const signedUrl = firstHttpUrl(signed);
3797
+ if (signedUrl) return signedUrl;
3798
+ throw new LingjingAwbCliError('获取素材签名 URL 失败', {
3799
+ type: 'api_error',
3800
+ exitCode: 30,
3801
+ details: { objectName, payload: signed },
3802
+ });
3803
+ }
3804
+ throw argumentError(
3805
+ `远程图片来源不支持自动转换:${text}`,
3806
+ '请传 http(s) URL、平台 backendPath,或先下载为本地文件后重试。',
3807
+ );
3808
+ }
3809
+
3810
+ async function downloadRemoteImageFileToTemp(sourceValue, options = {}) {
3811
+ const timeoutMs = Math.max(1_000, toInt(options.timeoutMs, REMOTE_IMAGE_DOWNLOAD_TIMEOUT_MS));
3812
+ const maxBytes = Math.max(1, toInt(options.maxBytes, REMOTE_IMAGE_DOWNLOAD_MAX_BYTES));
3813
+ const remoteUrl = await resolveRemoteResourceDownloadUrl(sourceValue);
3814
+ const controller = new AbortController();
3815
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
3816
+ let dir = null;
3817
+ try {
3818
+ const response = await fetch(remoteUrl, { signal: controller.signal });
3819
+ if (!response.ok) {
3820
+ throw new LingjingAwbCliError(`下载远程图片失败:${response.status} ${response.statusText}`, {
3821
+ type: 'http_error',
3822
+ exitCode: 30,
3823
+ details: { url: sourceValue, status: response.status },
3824
+ });
3825
+ }
3826
+ const contentType = response.headers.get('content-type') || '';
3827
+ const expectedExtension = extensionForImageFormat(options.expectedFormat);
3828
+ let fileName = remoteImageFileNameFromValue(sourceValue, options.fallbackName || 'resource-image');
3829
+ fileName = expectedExtension
3830
+ ? forceFileExtension(fileName, expectedExtension)
3831
+ : (!path.extname(fileName) ? `${fileName}${extensionFromContentType(contentType) || '.img'}` : fileName);
3832
+ if (!isSupportedRemoteImageDownload(contentType, fileName, options.expectedFormat)) {
3833
+ throw argumentError(
3834
+ `远程图片类型不支持:${contentType || path.extname(fileName) || 'unknown'}`,
3835
+ '自动格式转换只支持常见图片输入,请先手动转换为 jpg/png/webp 后重试。',
3836
+ );
3837
+ }
3838
+ const contentLength = toInt(response.headers.get('content-length'), 0);
3839
+ if (contentLength > maxBytes) {
3840
+ throw argumentError(`远程图片过大:${contentLength} bytes`, `最大支持 ${maxBytes} bytes。`);
3841
+ }
3842
+ dir = await fs.mkdtemp(path.join(tmpdir(), 'lj-awb-image-'));
3843
+ const filePath = path.join(dir, fileName);
3844
+ const file = await fs.open(filePath, 'w');
3845
+ let size = 0;
3846
+ try {
3847
+ if (!response.body) throw new Error('empty response body');
3848
+ for await (const chunk of response.body) {
3849
+ const buffer = Buffer.from(chunk);
3850
+ size += buffer.length;
3851
+ if (size > maxBytes) {
3852
+ throw argumentError(`远程图片过大:超过 ${maxBytes} bytes`);
3853
+ }
3854
+ await file.write(buffer);
3855
+ }
3856
+ } finally {
3857
+ await file.close();
3858
+ }
3859
+ return { filePath, dir, contentType, size };
3860
+ } catch (error) {
3861
+ if (dir) await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
3862
+ if (error instanceof LingjingAwbCliError) throw error;
3863
+ const isAbort = error?.name === 'AbortError';
3864
+ throw new LingjingAwbCliError(`下载远程图片失败:${error.message}`, {
3865
+ type: 'network_error',
3866
+ exitCode: 30,
3867
+ hint: isAbort ? `远程下载超过 ${timeoutMs}ms,请改用本地文件或已上传平台 backendPath。` : '',
3868
+ details: { url: sourceValue, causeCode: error?.cause?.code },
3869
+ });
3870
+ } finally {
3871
+ clearTimeout(timeout);
3872
+ }
3873
+ }
3874
+
3875
+ async function downloadRemoteFileToTemp(remoteUrl, options = {}) {
3876
+ const timeoutMs = Math.max(1_000, toInt(options.timeoutMs, REMOTE_VOICE_DOWNLOAD_TIMEOUT_MS));
3877
+ const maxBytes = Math.max(1, toInt(options.maxBytes, REMOTE_VOICE_DOWNLOAD_MAX_BYTES));
3878
+ const controller = new AbortController();
3879
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
3880
+ let dir = null;
2544
3881
  let response;
2545
3882
  try {
2546
3883
  response = await fetch(remoteUrl, { signal: controller.signal });
@@ -2589,7 +3926,7 @@ async function downloadRemoteFileToTemp(remoteUrl, options = {}) {
2589
3926
  throw new LingjingAwbCliError(`下载远程音频失败:${error.message}`, {
2590
3927
  type: 'network_error',
2591
3928
  exitCode: 30,
2592
- hint: isAbort ? `远程下载超过 ${timeoutMs}ms,请改用本地 --file 或已上传 material 路径。` : '',
3929
+ hint: isAbort ? `远程下载超过 ${timeoutMs}ms,请改用本地 --file 或已上传平台 backendPath。` : '',
2593
3930
  details: { url: remoteUrl, causeCode: error?.cause?.code },
2594
3931
  });
2595
3932
  } finally {
@@ -2635,6 +3972,59 @@ function normalizeUnifiedPromptParams(promptParams) {
2635
3972
  return promptParams;
2636
3973
  }
2637
3974
 
3975
+ async function parseModelParamsJsonArg(value) {
3976
+ const parsed = await readJsonMaybeFile(value, null).catch((error) => {
3977
+ throw argumentError(`model-params-json 解析失败:${error.message}`);
3978
+ });
3979
+ if (parsed == null) return {};
3980
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
3981
+ throw argumentError('model-params-json 必须是 JSON 对象');
3982
+ }
3983
+ return parsed;
3984
+ }
3985
+
3986
+ function parseModelParamAssignments(value) {
3987
+ const params = {};
3988
+ for (const item of parseListArg(value)) {
3989
+ const text = trimToNull(item);
3990
+ if (!text) continue;
3991
+ const eqIndex = text.indexOf('=');
3992
+ if (eqIndex <= 0) {
3993
+ throw argumentError(`model-param 格式错误:${text}`, '格式为 --model-param key=value,可重复传。');
3994
+ }
3995
+ const key = trimToNull(text.slice(0, eqIndex));
3996
+ if (!key) {
3997
+ throw argumentError(`model-param 缺少 key:${text}`);
3998
+ }
3999
+ params[key] = text.slice(eqIndex + 1);
4000
+ }
4001
+ return params;
4002
+ }
4003
+
4004
+ async function collectGenericModelParams(kwargs = {}) {
4005
+ const fromJson = await parseModelParamsJsonArg(kwargs.modelParamsJson ?? kwargs.model_params_json);
4006
+ const fromObject = kwargs.modelParams && typeof kwargs.modelParams === 'object' && !Array.isArray(kwargs.modelParams)
4007
+ ? kwargs.modelParams
4008
+ : {};
4009
+ const fromAssignments = parseModelParamAssignments(kwargs.modelParam);
4010
+ const fromDynamicFlags = {};
4011
+ for (const [key, value] of Object.entries(kwargs || {})) {
4012
+ if (CREATE_STANDARD_KWARG_KEYS.has(key)) continue;
4013
+ if (!key.includes('_') && !isLikelyDynamicModelParamKey(key)) continue;
4014
+ fromDynamicFlags[key] = value;
4015
+ }
4016
+ return normalizeUnifiedPromptParams({
4017
+ ...fromJson,
4018
+ ...fromObject,
4019
+ ...fromDynamicFlags,
4020
+ ...fromAssignments,
4021
+ });
4022
+ }
4023
+
4024
+ function isLikelyDynamicModelParamKey(key) {
4025
+ 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 || ''));
4026
+ }
4027
+
2638
4028
  function normalizeResourceType(type, index) {
2639
4029
  const normalized = String(type ?? '').trim().toLowerCase();
2640
4030
  if (!RESOURCE_TYPES.has(normalized)) {
@@ -2670,7 +4060,7 @@ function normalizeResourceSource(source, type, index, options = {}) {
2670
4060
  if (!['url', 'asset_id'].includes(kind)) {
2671
4061
  throw argumentError(
2672
4062
  `resource[${index}] source.kind 不支持:${explicitKind}`,
2673
- '只支持 url、asset_id 两个值。本地文件 / http(s) URL / material backendPath 都统一传 kind=url(CLI 按 value 自动识别);平台资产或主体对象传 kind=asset_id。',
4063
+ '只支持 url、asset_id 两个值。本地文件 / http(s) URL / 平台 backendPath 都统一传 kind=url(CLI 按 value 自动识别);平台资产或主体对象传 kind=asset_id。',
2674
4064
  );
2675
4065
  }
2676
4066
  const value = kind === 'asset_id' ? assetValue : rawValue;
@@ -2831,59 +4221,39 @@ function isRemoteOrBackendPath(value) {
2831
4221
  if (!text) return false;
2832
4222
  return /^https?:\/\//i.test(text)
2833
4223
  || /^[a-z][a-z0-9+.-]*:\/\//i.test(text)
2834
- || text.startsWith('material/')
2835
- || text.startsWith('/material/');
4224
+ || isPlatformBackendPath(text);
2836
4225
  }
2837
4226
 
2838
- function normalizeMaterialUrlValue(value) {
4227
+ function normalizeBackendPathUrlValue(value) {
2839
4228
  const text = trimToNull(value);
2840
4229
  if (!text) return text;
2841
4230
  if (/^https?:\/\//i.test(text)) {
2842
4231
  try {
2843
4232
  const pathname = decodeURIComponent(new URL(text).pathname);
2844
- if (pathname.startsWith('/material/')) return pathname;
4233
+ if (isPlatformBackendPath(pathname)) return pathname;
2845
4234
  } catch {}
2846
4235
  }
2847
4236
  return text;
2848
4237
  }
2849
4238
 
4239
+ function displayResourceSourceValue(value) {
4240
+ const text = trimToNull(value);
4241
+ if (!text || !/^https?:\/\//i.test(text)) return text;
4242
+ try {
4243
+ const url = new URL(text);
4244
+ const format = imageTransformFormat(text);
4245
+ return `${url.origin}${decodeURIComponent(url.pathname)}${format ? `?format=${format}` : ''}`;
4246
+ } catch {
4247
+ return text.split('?')[0];
4248
+ }
4249
+ }
4250
+
2850
4251
  function conversionOutputExtension(format) {
2851
4252
  const normalized = normalizeFileFormat(format);
2852
4253
  if (normalized === 'jpg') return 'jpg';
2853
4254
  return normalized || 'jpg';
2854
4255
  }
2855
4256
 
2856
- async function convertLocalImageFile(filePath, targetFormat) {
2857
- const target = normalizeFileFormat(targetFormat) || 'jpg';
2858
- const outputPath = path.join(tmpdir(), `lj-awb-${crypto.randomUUID()}.${conversionOutputExtension(target)}`);
2859
- const codecArgs = target === 'jpg' ? ['-q:v', '2'] : [];
2860
- try {
2861
- await execFileAsync('ffmpeg', [
2862
- '-y',
2863
- '-hide_banner',
2864
- '-loglevel',
2865
- 'error',
2866
- '-i',
2867
- filePath,
2868
- '-frames:v',
2869
- '1',
2870
- ...codecArgs,
2871
- outputPath,
2872
- ]);
2873
- } catch (error) {
2874
- await fs.rm(outputPath, { force: true }).catch(() => {});
2875
- throw argumentError(
2876
- `图片格式转换失败:${path.basename(filePath)}`,
2877
- `需要把该帧图片转换为 ${target} 后提交。请确认本机可用 ffmpeg,或手动转换后重试。${error?.message ? ` 原因:${error.message}` : ''}`,
2878
- );
2879
- }
2880
- const inspected = await inspectLocalFile(outputPath);
2881
- if (!inspected.exists) {
2882
- throw argumentError(`图片格式转换失败:${path.basename(filePath)}`, `未生成 ${target} 文件。`);
2883
- }
2884
- return inspected;
2885
- }
2886
-
2887
4257
  async function resolveResourceFileValue(resource, options = {}) {
2888
4258
  if (resource.type === 'subject') {
2889
4259
  if (resource._source.kind !== 'asset_id') throw argumentError('subject resource 必须使用 asset_id,例如 subject:reference:hero=asset:element_123');
@@ -2911,13 +4281,67 @@ async function resolveResourceFileValue(resource, options = {}) {
2911
4281
  }
2912
4282
  const value = resource._source.value;
2913
4283
  if (isRemoteOrBackendPath(value)) {
2914
- return { resource: { ...base, source: { kind: 'url', value: normalizeMaterialUrlValue(value) } }, upload: null, localFile: null };
4284
+ const source = { kind: 'url', value };
4285
+ const conversion = options.resourceRules
4286
+ ? resourceFormatConversion(
4287
+ options.resourceRules.find((item) => resourceRuleMatches(item, { ...base, source })),
4288
+ { resource: { ...base, source } },
4289
+ )
4290
+ : null;
4291
+ if (conversion) {
4292
+ const conversionRecord = compactRecord({
4293
+ resource: resourceText(base),
4294
+ fromFormat: conversion.fromFormat,
4295
+ toFormat: conversion.toFormat,
4296
+ reason: conversion.reason,
4297
+ sourceValue: displayResourceSourceValue(value),
4298
+ dryRun: options.dryRun || undefined,
4299
+ });
4300
+ const convertedName = forceFileExtension(
4301
+ remoteImageFileNameFromValue(value, resourceText(base).replace(/[^0-9A-Za-z._-]+/g, '-')),
4302
+ extensionForImageFormat(conversion.toFormat),
4303
+ );
4304
+ if (options.dryRun) {
4305
+ return {
4306
+ resource: { ...base, source: { kind: 'url', value: dryRunBackendPath(convertedName, options.sceneType) } },
4307
+ upload: null,
4308
+ localFile: null,
4309
+ conversion: conversionRecord,
4310
+ };
4311
+ }
4312
+ const downloaded = await downloadRemoteImageFileToTemp(value, {
4313
+ expectedFormat: conversion.fromFormat,
4314
+ fallbackName: resourceText(base).replace(/[^0-9A-Za-z._-]+/g, '-'),
4315
+ });
4316
+ try {
4317
+ const converted = await convertLocalImageFile(downloaded.filePath, conversion.toFormat);
4318
+ try {
4319
+ const upload = await uploadLocalFile(converted.filePath, { sceneType: options.sceneType });
4320
+ return {
4321
+ resource: { ...base, source: { kind: 'url', value: upload.backendPath } },
4322
+ upload,
4323
+ localFile: null,
4324
+ conversion: {
4325
+ ...conversionRecord,
4326
+ sourceSize: downloaded.size,
4327
+ convertedFile: converted.filePath,
4328
+ convertedSize: converted.size,
4329
+ },
4330
+ };
4331
+ } finally {
4332
+ await fs.rm(converted.filePath, { force: true }).catch(() => {});
4333
+ }
4334
+ } finally {
4335
+ await fs.rm(downloaded.dir, { recursive: true, force: true }).catch(() => {});
4336
+ }
4337
+ }
4338
+ return { resource: { ...base, source: { kind: 'url', value: normalizeBackendPathUrlValue(value) } }, upload: null, localFile: null };
2915
4339
  }
2916
4340
  const inspected = await inspectLocalFile(value);
2917
4341
  if (!inspected.exists) {
2918
4342
  throw argumentError(
2919
4343
  `资源文件不存在:${value}`,
2920
- 'source.kind=url 只接受完整 http(s) URL、material backendPath,或当前工作目录下存在的本地文件路径。',
4344
+ 'source.kind=url 只接受完整 http(s) URL、平台 backendPath,或当前工作目录下存在的本地文件路径。',
2921
4345
  );
2922
4346
  }
2923
4347
  const conversion = options.resourceRules
@@ -3040,7 +4464,9 @@ async function buildImageRequest(kwargs = {}, options = {}) {
3040
4464
  kind: 'image',
3041
4465
  sceneType: TASK_UPLOAD_SCENE.IMAGE_CREATE,
3042
4466
  });
4467
+ const genericModelParams = await collectGenericModelParams(kwargs);
3043
4468
  const defaultParams = {
4469
+ ...genericModelParams,
3044
4470
  prompt,
3045
4471
  resources: validationResources.resources,
3046
4472
  };
@@ -3218,7 +4644,9 @@ async function buildVideoRequest(kwargs = {}, options = {}) {
3218
4644
  throw argumentError('缺少视频生成输入', '至少传 --prompt 或 --resource。');
3219
4645
  }
3220
4646
  assertVideoReferenceKeysUsed(prompt, validationResources.resources);
4647
+ const genericModelParams = await collectGenericModelParams(kwargs);
3221
4648
  const defaultParams = {
4649
+ ...genericModelParams,
3222
4650
  prompt,
3223
4651
  resources: validationResources.resources,
3224
4652
  };
@@ -3349,6 +4777,11 @@ const IMAGE_BATCH_ITEM_KEYS = new Set([
3349
4777
  'quality',
3350
4778
  'generate_num',
3351
4779
  'generateNum',
4780
+ 'model_param',
4781
+ 'modelParam',
4782
+ 'model_params',
4783
+ 'modelParams',
4784
+ 'modelParamsJson',
3352
4785
  'resource',
3353
4786
  'resources',
3354
4787
  'resourcesJson',
@@ -3361,6 +4794,11 @@ const VIDEO_BATCH_ITEM_KEYS = new Set([
3361
4794
  'duration',
3362
4795
  'need_audio',
3363
4796
  'needAudio',
4797
+ 'model_param',
4798
+ 'modelParam',
4799
+ 'model_params',
4800
+ 'modelParams',
4801
+ 'modelParamsJson',
3364
4802
  'resource',
3365
4803
  'resources',
3366
4804
  'resourcesJson',
@@ -3372,18 +4810,21 @@ function normalizeTaskBatchItem(item, kind, index) {
3372
4810
  throw argumentError(`批量输入第 ${index + 1} 项必须是 JSON 对象或纯文本 prompt`);
3373
4811
  }
3374
4812
  const allowedKeys = kind === 'image' ? IMAGE_BATCH_ITEM_KEYS : VIDEO_BATCH_ITEM_KEYS;
3375
- const unknownKeys = Object.keys(item).filter((key) => !allowedKeys.has(key));
4813
+ const unknownKeys = Object.keys(item).filter((key) => !allowedKeys.has(key) && !key.includes('_'));
3376
4814
  if (unknownKeys.length) {
3377
4815
  throw argumentError(
3378
4816
  `批量输入第 ${index + 1} 项存在未知字段:${unknownKeys.join(', ')}`,
3379
4817
  kind === 'image'
3380
- ? '生图批量项只接受 prompt、ratio、quality、generate_num、resources、resource、customBizId'
3381
- : '生视频批量项只接受 prompt、ratio、quality、duration、need_audio、resources、resource、customBizId',
4818
+ ? '生图批量项只接受 prompt、ratio、quality、generate_num、resources、resource、model_params、customBizId,或 model options.params[] 中的下划线参数。'
4819
+ : '生视频批量项只接受 prompt、ratio、quality、duration、need_audio、resources、resource、model_params、customBizId,或 model options.params[] 中的下划线参数。',
3382
4820
  );
3383
4821
  }
3384
4822
  const next = { ...item };
3385
4823
  if (next.generate_num != null && next.generateNum == null) next.generateNum = next.generate_num;
3386
4824
  if (next.need_audio != null && next.needAudio == null) next.needAudio = next.need_audio;
4825
+ if (next.model_param != null && next.modelParam == null) next.modelParam = next.model_param;
4826
+ if (next.model_params != null && next.modelParams == null) next.modelParams = next.model_params;
4827
+ if (next.model_params_json != null && next.modelParamsJson == null) next.modelParamsJson = next.model_params_json;
3387
4828
  return next;
3388
4829
  }
3389
4830
 
@@ -3638,7 +5079,8 @@ function normalizeCosAssetPath(value) {
3638
5079
  if (!text) return null;
3639
5080
  if (/^https?:\/\//i.test(text)) {
3640
5081
  try {
3641
- return decodeURIComponent(new URL(text).pathname.replace(/^\/+/, ''));
5082
+ const pathname = decodeURIComponent(new URL(text).pathname);
5083
+ return isPlatformBackendPath(pathname) ? pathname.replace(/^\/+/, '') : text;
3642
5084
  } catch {
3643
5085
  return text.replace(/^\/+/, '');
3644
5086
  }
@@ -3926,37 +5368,68 @@ export async function assetRegister(kwargs = {}) {
3926
5368
  const localFile = trimToNull(kwargs.file);
3927
5369
  const assetPath = trimToNull(kwargs.backendPath) ?? normalizeCosAssetPath(kwargs.url);
3928
5370
  if (!localFile && !assetPath) throw argumentError('缺少素材路径', '传 --file、--backend-path 或 --url。');
5371
+ let source = localFile ? await inspectAssetLocalFile(localFile) : validateAssetRemoteSource(assetPath);
3929
5372
  if (toBool(kwargs.dryRun)) {
5373
+ const dryRunFileName = localFile ? dryRunAssetFileName(source) : null;
5374
+ const url = localFile ? normalizeCosAssetPath(dryRunBackendPath(dryRunFileName, TASK_UPLOAD_SCENE.ASSET_REVIEW)) : assetPath;
3930
5375
  return {
3931
5376
  dryRun: true,
3932
5377
  action: 'create asset',
5378
+ groupId,
5379
+ name,
5380
+ platform,
5381
+ assetType: source.assetType,
5382
+ assetPath: url,
3933
5383
  request: {
3934
5384
  assetGroupsId: groupId,
3935
- url: localFile ? normalizeCosAssetPath(dryRunBackendPath(localFile, TASK_UPLOAD_SCENE.ASSET_REVIEW)) : assetPath,
5385
+ url,
3936
5386
  name,
3937
5387
  platform,
5388
+ assetType: source.assetType,
3938
5389
  },
3939
- localFile: localFile ? await inspectLocalFile(localFile) : null,
5390
+ localFile: source.localFile ?? null,
5391
+ originalLocalFile: source.originalLocalFile ?? null,
5392
+ media: source.media ?? null,
5393
+ validation: source.legal === false ? {
5394
+ legal: false,
5395
+ violations: source.violations,
5396
+ } : { legal: true },
5397
+ conversionPlan: source.conversionPlan ?? null,
3940
5398
  };
3941
5399
  }
3942
- ensureConfirmed(kwargs, '注册素材是云端写入动作,需要确认', { action: 'create asset', groupId, name });
3943
- const uploaded = localFile ? await uploadLocalFile(localFile, { sceneType: TASK_UPLOAD_SCENE.ASSET_REVIEW }) : null;
3944
- const body = {
3945
- assetGroupsId: groupId,
3946
- url: normalizeCosAssetPath(uploaded?.backendPath ?? assetPath),
3947
- name,
3948
- platform,
3949
- };
3950
- const payload = await awbApi.registerAsset(body);
3951
- return {
3952
- registered: true,
3953
- assetId: extractAssetId(payload),
3954
- groupId,
3955
- name,
3956
- platform,
3957
- assetPath: body.url,
3958
- ...(uploaded ? { upload: uploaded } : {}),
3959
- };
5400
+ if (localFile && source.legal === false) {
5401
+ source = await resolveInvalidAssetLocalFile(source, kwargs);
5402
+ }
5403
+ ensureConfirmed(kwargs, '注册素材是云端写入动作,需要确认', { action: 'create asset', groupId, name, assetType: source.assetType });
5404
+ const uploadPath = source.localFile?.filePath ?? localFile;
5405
+ let uploaded = null;
5406
+ try {
5407
+ uploaded = localFile ? await uploadLocalFile(uploadPath, { sceneType: TASK_UPLOAD_SCENE.ASSET_REVIEW }) : null;
5408
+ const body = {
5409
+ assetGroupsId: groupId,
5410
+ url: normalizeCosAssetPath(uploaded?.backendPath ?? assetPath),
5411
+ name,
5412
+ platform,
5413
+ assetType: source.assetType,
5414
+ };
5415
+ const payload = await awbApi.registerAsset(body);
5416
+ return {
5417
+ registered: true,
5418
+ assetId: extractAssetId(payload),
5419
+ groupId,
5420
+ name,
5421
+ platform,
5422
+ assetType: source.assetType,
5423
+ assetPath: body.url,
5424
+ media: source.media ?? null,
5425
+ ...(source.conversion ? { conversion: source.conversion } : {}),
5426
+ ...(uploaded ? { upload: uploaded } : {}),
5427
+ };
5428
+ } finally {
5429
+ if (source.conversion?.convertedFile) {
5430
+ await fs.rm(source.conversion.convertedFile, { force: true }).catch(() => {});
5431
+ }
5432
+ }
3960
5433
  }
3961
5434
 
3962
5435
  export async function subjectList(kwargs = {}) {
@@ -4497,3 +5970,99 @@ export async function subtitleStatus(kwargs = {}) {
4497
5970
  taskType: 'VIDEO_SUBTITLE_REMOVAL',
4498
5971
  });
4499
5972
  }
5973
+
5974
+ export async function videoSuperResolutionStatus(kwargs = {}) {
5975
+ const taskId = requireValue(kwargs, 'taskId', 'task-id');
5976
+ return taskStatus({
5977
+ ...kwargs,
5978
+ taskId,
5979
+ taskType: 'VIDEO_SUPER_RESOLUTION',
5980
+ });
5981
+ }
5982
+
5983
+ async function buildVideoSuperResolutionRequest(kwargs = {}, options = {}) {
5984
+ const objectName = trimToNull(kwargs.objectName);
5985
+ if (!objectName) {
5986
+ throw argumentError('缺少视频对象路径', '传 --object-name <objectName>;通常是 material 的 backendPath 或 COS 对象路径。');
5987
+ }
5988
+ const dryRun = Boolean(options.dryRun);
5989
+ const projectGroupNo = await resolveProjectGroupNo(kwargs.projectGroupNo, {
5990
+ allowNull: true,
5991
+ noNetwork: dryRun,
5992
+ noSave: dryRun,
5993
+ }).catch((error) => {
5994
+ if (dryRun) return null;
5995
+ throw error;
5996
+ });
5997
+ return {
5998
+ objectName,
5999
+ projectGroupNo,
6000
+ request: compactRecord({
6001
+ objectName,
6002
+ projectGroupNo,
6003
+ }),
6004
+ };
6005
+ }
6006
+
6007
+ export async function videoSuperResolutionFee(kwargs = {}) {
6008
+ const built = await buildVideoSuperResolutionRequest(kwargs, { dryRun: toBool(kwargs.dryRun) });
6009
+ if (toBool(kwargs.dryRun)) {
6010
+ return {
6011
+ dryRun: true,
6012
+ action: 'create video-super-resolution-fee',
6013
+ objectName: built.objectName,
6014
+ projectGroupNo: built.projectGroupNo,
6015
+ request: built.request,
6016
+ materialEndpoint: '/api/material/creation/videoUpResolutionCal',
6017
+ taskType: 'VIDEO_SUPER_RESOLUTION',
6018
+ };
6019
+ }
6020
+ const payload = await awbApi.fetchVideoSuperResolutionFee(built.request);
6021
+ return {
6022
+ objectName: built.objectName,
6023
+ projectGroupNo: built.projectGroupNo,
6024
+ data: payload,
6025
+ ...(await pointEstimate(payload, built.projectGroupNo)),
6026
+ };
6027
+ }
6028
+
6029
+ export async function videoSuperResolution(kwargs = {}) {
6030
+ const built = await buildVideoSuperResolutionRequest(kwargs, { dryRun: toBool(kwargs.dryRun) });
6031
+ if (toBool(kwargs.dryRun)) {
6032
+ return {
6033
+ dryRun: true,
6034
+ action: 'create video-super-resolution',
6035
+ objectName: built.objectName,
6036
+ projectGroupNo: built.projectGroupNo,
6037
+ request: built.request,
6038
+ materialEndpoint: '/api/material/creation/videoUpResolution',
6039
+ taskType: 'VIDEO_SUPER_RESOLUTION',
6040
+ };
6041
+ }
6042
+ ensureConfirmed(kwargs, '正式视频超分会通过 material 创建任务并消耗积分,需要确认', {
6043
+ action: 'create video-super-resolution',
6044
+ objectName: built.objectName,
6045
+ });
6046
+ const feePayload = await awbApi.fetchVideoSuperResolutionFee(built.request).catch(() => null);
6047
+ const estimate = await pointEstimate(feePayload, built.projectGroupNo).catch(() => ({}));
6048
+ const payload = await awbApi.createVideoSuperResolutionTask(built.request);
6049
+ const result = normalizeCreatedTask(payload, {
6050
+ ...estimate,
6051
+ submitted: true,
6052
+ taskType: 'VIDEO_SUPER_RESOLUTION',
6053
+ objectName: built.objectName,
6054
+ projectGroupNo: built.projectGroupNo,
6055
+ });
6056
+ await appendTaskRecord(kwargs, {
6057
+ taskId: result.taskId,
6058
+ taskType: 'VIDEO_SUPER_RESOLUTION',
6059
+ projectGroupNo: built.projectGroupNo,
6060
+ promptSummary: `视频超分 objectName=${built.objectName}`,
6061
+ });
6062
+ return {
6063
+ ...result,
6064
+ nextCommand: result.taskId
6065
+ ? `lj-awb task video-super-resolution-status --task-id ${result.taskId}${built.projectGroupNo ? ` --project-group-no ${built.projectGroupNo}` : ''} -f json`
6066
+ : null,
6067
+ };
6068
+ }