xt8 commited on
Commit
124fc9e
·
verified ·
1 Parent(s): e48f0a4

Upload 50 files

Browse files
Dockerfile CHANGED
@@ -1,73 +1,73 @@
1
- # 构建阶段
2
- FROM node:18-alpine AS builder
3
-
4
- # 设置工作目录
5
- WORKDIR /app
6
-
7
- # 安装构建依赖(包括Python和make,某些npm包需要)
8
- RUN apk add --no-cache python3 make g++
9
-
10
- # 复制package文件以优化Docker层缓存
11
- COPY package.json package-lock.json ./
12
-
13
- # 安装所有依赖(包括devDependencies)
14
- RUN npm ci --registry https://registry.npmmirror.com/
15
-
16
- # 复制源代码
17
- COPY . .
18
-
19
- # 接收版本号参数并更新 package.json
20
- ARG VERSION
21
- RUN if [ -n "$VERSION" ]; then \
22
- echo "Updating package.json version to $VERSION"; \
23
- sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" package.json; \
24
- cat package.json | grep version; \
25
- fi
26
-
27
- # 构建应用
28
- RUN npm run build
29
-
30
- # 生产阶段
31
- FROM node:18-alpine AS production
32
-
33
- # 安装健康检查工具
34
- RUN apk add --no-cache wget
35
-
36
- # 创建非root用户
37
- RUN addgroup -g 1001 -S nodejs && \
38
- adduser -S jimeng -u 1001
39
-
40
- # 设置工作目录
41
- WORKDIR /app
42
-
43
- # 复制 package.json(使用构建阶段已更新版本)与 package-lock.json
44
- COPY --from=builder /app/package.json ./package.json
45
- COPY --from=builder /app/package-lock.json ./package-lock.json
46
-
47
- # 只安装生产依赖
48
- RUN npm ci --omit=dev --registry https://registry.npmmirror.com/ && \
49
- npm cache clean --force
50
-
51
- # 从构建阶段复制构建产物
52
- COPY --from=builder --chown=jimeng:nodejs /app/dist ./dist
53
- COPY --from=builder --chown=jimeng:nodejs /app/configs ./configs
54
-
55
- # 创建应用需要的目录并设置权限
56
- RUN mkdir -p /app/logs /app/tmp && \
57
- chown -R jimeng:nodejs /app/logs /app/tmp
58
-
59
- # 设置环境变量
60
- ENV SERVER_PORT=7860
61
-
62
- # 切换到非root用户
63
- USER jimeng
64
-
65
- # 暴露端口
66
- EXPOSE 7860
67
-
68
- # 健康检查
69
- HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
70
- CMD wget -q --spider http://localhost:7860/ping
71
-
72
- # 启动应用
73
- CMD ["npm", "start"]
 
1
+ # 构建阶段
2
+ FROM node:18-alpine AS builder
3
+
4
+ # 设置工作目录
5
+ WORKDIR /app
6
+
7
+ # 安装构建依赖(包括Python和make,某些npm包需要)
8
+ RUN apk add --no-cache python3 make g++
9
+
10
+ # 复制package文件以优化Docker层缓存
11
+ COPY package.json package-lock.json ./
12
+
13
+ # 安装所有依赖(包括devDependencies)
14
+ RUN npm ci --registry https://registry.npmmirror.com/
15
+
16
+ # 复制源代码
17
+ COPY . .
18
+
19
+ # 接收版本号参数并更新 package.json
20
+ ARG VERSION
21
+ RUN if [ -n "$VERSION" ]; then \
22
+ echo "Updating package.json version to $VERSION"; \
23
+ sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" package.json; \
24
+ cat package.json | grep version; \
25
+ fi
26
+
27
+ # 构建应用
28
+ RUN npm run build
29
+
30
+ # 生产阶段
31
+ FROM node:18-alpine AS production
32
+
33
+ # 安装健康检查工具
34
+ RUN apk add --no-cache wget
35
+
36
+ # 创建非root用户
37
+ RUN addgroup -g 1001 -S nodejs && \
38
+ adduser -S jimeng -u 1001
39
+
40
+ # 设置工作目录
41
+ WORKDIR /app
42
+
43
+ # 复制 package.json(使用构建阶段已更新版本)与 package-lock.json
44
+ COPY --from=builder /app/package.json ./package.json
45
+ COPY --from=builder /app/package-lock.json ./package-lock.json
46
+
47
+ # 只安装生产依赖
48
+ RUN npm ci --omit=dev --registry https://registry.npmmirror.com/ && \
49
+ npm cache clean --force
50
+
51
+ # 从构建阶段复制构建产物
52
+ COPY --from=builder --chown=jimeng:nodejs /app/dist ./dist
53
+ COPY --from=builder --chown=jimeng:nodejs /app/configs ./configs
54
+
55
+ # 创建应用需要的目录并设置权限
56
+ RUN mkdir -p /app/logs /app/tmp && \
57
+ chown -R jimeng:nodejs /app/logs /app/tmp
58
+
59
+ # 设置环境变量
60
+ ENV SERVER_PORT=5100
61
+
62
+ # 切换到非root用户
63
+ USER jimeng
64
+
65
+ # 暴露端口
66
+ EXPOSE 5100
67
+
68
+ # 健康检查
69
+ HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
70
+ CMD wget -q --spider http://localhost:5100/ping
71
+
72
+ # 启动应用
73
+ CMD ["npm", "start"]
jimeng-api/Skill.md CHANGED
@@ -107,7 +107,7 @@ python scripts/generate_image.py text \
107
  - `prompt` (required): Text description of the desired image
108
  - `--session-id`: Jimeng session ID (required)
109
  - `--model`: Model to use (default: `jimeng-4.0`)
110
- - Options: `jimeng-4.5`, `jimeng-4.1`, `jimeng-4.0`, `jimeng-3.1`, `jimeng-3.0`, `jimeng-2.1`, `jimeng-xl-pro`, `nanobanana` (international only)
111
  - `--ratio`: Aspect ratio (default: `1:1`)
112
  - Options: `1:1`, `4:3`, `3:4`, `16:9`, `9:16`, `3:2`, `2:3`, `21:9`
113
  - `--resolution`: Resolution level (default: `2k`)
@@ -268,7 +268,7 @@ Script executes:
268
 
269
  **"Model not supported"**
270
  - `nanobanana` only works with international sites (us-/hk-/jp-/sg- prefix)
271
- - `jimeng-3.1` and `jimeng-2.1` only work with domestic sites
272
 
273
  **"nanobanana resolution mismatch"**
274
  - **US site (us- prefix)**: nanobanana model only supports 1024×1024 @ 2k resolution; all `ratio` and `resolution` parameters are ignored
 
107
  - `prompt` (required): Text description of the desired image
108
  - `--session-id`: Jimeng session ID (required)
109
  - `--model`: Model to use (default: `jimeng-4.0`)
110
+ - Options: `jimeng-5.0`, `jimeng-4.6`, `jimeng-4.5`, `jimeng-4.1`, `jimeng-4.0`, `jimeng-3.1`, `jimeng-3.0`, `nanobanana` (international only)
111
  - `--ratio`: Aspect ratio (default: `1:1`)
112
  - Options: `1:1`, `4:3`, `3:4`, `16:9`, `9:16`, `3:2`, `2:3`, `21:9`
113
  - `--resolution`: Resolution level (default: `2k`)
 
268
 
269
  **"Model not supported"**
270
  - `nanobanana` only works with international sites (us-/hk-/jp-/sg- prefix)
271
+ - `jimeng-3.1` only works with domestic sites
272
 
273
  **"nanobanana resolution mismatch"**
274
  - **US site (us- prefix)**: nanobanana model only supports 1024×1024 @ 2k resolution; all `ratio` and `resolution` parameters are ignored
jimeng-api/scripts/generate_image.py CHANGED
@@ -352,7 +352,7 @@ def main():
352
  "--model",
353
  type=str,
354
  default="jimeng-4.5",
355
- choices=["jimeng-4.5", "jimeng-4.1", "jimeng-4.0", "jimeng-3.1", "jimeng-3.0", "jimeng-2.1", "jimeng-xl-pro", "nanobanana"],
356
  help="Model to use (default: jimeng-4.5)"
357
  )
358
  subparser.add_argument(
 
352
  "--model",
353
  type=str,
354
  default="jimeng-4.5",
355
+ choices=["jimeng-5.0", "jimeng-4.6", "jimeng-4.5", "jimeng-4.1", "jimeng-4.0", "jimeng-3.1", "jimeng-3.0", "nanobanana"],
356
  help="Model to use (default: jimeng-4.5)"
357
  )
358
  subparser.add_argument(
src/api/builders/payload-builder.ts CHANGED
@@ -112,10 +112,8 @@ export function resolveResolution(
112
 
113
  /**
114
  * benefitCount 规则
115
- * - CN: 全部不加
116
- * - US: 仅 jimeng-4.0 / jimeng-3.0
117
- * - HK/JP/SG: nanobanana 不加,其余(含 nanobananapro)加
118
- * - 多图模式: 所有站点都不加
119
  */
120
  export function getBenefitCount(
121
  userModel: string,
@@ -124,20 +122,7 @@ export function getBenefitCount(
124
  ): number | undefined {
125
  if (isMultiImage) return undefined;
126
 
127
- const regionKey = getRegionKey(regionInfo);
128
-
129
- if (regionKey === "CN") return undefined;
130
-
131
- if (regionKey === "US") {
132
- return ["jimeng-4.5", "jimeng-4.0", "jimeng-3.0"].includes(userModel) ? 4 : undefined;
133
- }
134
-
135
- if (regionKey === "HK" || regionKey === "JP" || regionKey === "SG") {
136
- if (userModel === "nanobanana") return undefined;
137
- return 4;
138
- }
139
-
140
- return undefined;
141
  }
142
 
143
  export type GenerateMode = "text2img" | "img2img";
@@ -175,8 +160,8 @@ export function buildCoreParam(options: BuildCoreParamOptions) {
175
  mode = "text2img",
176
  } = options;
177
 
178
- // ⚠️ intelligent_ratio 仅对 jimeng-4.0/jimeng-4.1/jimeng-4.5 模型有效
179
- const effectiveIntelligentRatio = ['jimeng-4.0', 'jimeng-4.1', 'jimeng-4.5'].includes(userModel) ? intelligentRatio : false;
180
 
181
  // 图生图时,prompt 前缀规则: 每张图片对应 2 个 #
182
  // 1张图 → ##, 2张图 → ####, 3张图 → ######
@@ -233,6 +218,7 @@ interface Ability {
233
 
234
  export interface BuildMetricsExtraOptions {
235
  userModel: string;
 
236
  regionInfo: RegionInfo;
237
  submitId: string;
238
  scene: SceneType;
@@ -246,6 +232,7 @@ export interface BuildMetricsExtraOptions {
246
  */
247
  export function buildMetricsExtra({
248
  userModel,
 
249
  regionInfo,
250
  submitId,
251
  scene,
@@ -258,13 +245,13 @@ export function buildMetricsExtra({
258
  const sceneOption: any = {
259
  type: "image",
260
  scene,
261
- modelReqKey: userModel,
262
  resolutionType,
263
  abilityList,
264
  reportParams: {
265
  enterSource: "generate",
266
  vipSource: "generate",
267
- extraVipFunctionKey: `${userModel}-${resolutionType}`,
268
  useVipFunctionDetailsReporterHoc: true,
269
  },
270
  };
 
112
 
113
  /**
114
  * benefitCount 规则
115
+ * - 生图模式统一返回 4
116
+ * - 多图模式:
 
 
117
  */
118
  export function getBenefitCount(
119
  userModel: string,
 
122
  ): number | undefined {
123
  if (isMultiImage) return undefined;
124
 
125
+ return 4;
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  }
127
 
128
  export type GenerateMode = "text2img" | "img2img";
 
160
  mode = "text2img",
161
  } = options;
162
 
163
+ // ⚠️ intelligent_ratio 仅对 jimeng-4.0/jimeng-4.1/jimeng-4.5/jimeng-4.6/jimeng-5.0 模型有效
164
+ const effectiveIntelligentRatio = ['jimeng-4.0', 'jimeng-4.1', 'jimeng-4.5', 'jimeng-4.6', 'jimeng-5.0'].includes(userModel) ? intelligentRatio : false;
165
 
166
  // 图生图时,prompt 前缀规则: 每张图片对应 2 个 #
167
  // 1张图 → ##, 2张图 → ####, 3张图 → ######
 
218
 
219
  export interface BuildMetricsExtraOptions {
220
  userModel: string;
221
+ model: string; // 映射后的内部模型名 (如 high_aes_general_v50)
222
  regionInfo: RegionInfo;
223
  submitId: string;
224
  scene: SceneType;
 
232
  */
233
  export function buildMetricsExtra({
234
  userModel,
235
+ model,
236
  regionInfo,
237
  submitId,
238
  scene,
 
245
  const sceneOption: any = {
246
  type: "image",
247
  scene,
248
+ modelReqKey: model,
249
  resolutionType,
250
  abilityList,
251
  reportParams: {
252
  enterSource: "generate",
253
  vipSource: "generate",
254
+ extraVipFunctionKey: `${model}-${resolutionType}`,
255
  useVipFunctionDetailsReporterHoc: true,
256
  },
257
  };
src/api/consts/common.ts CHANGED
@@ -37,19 +37,21 @@ export const DEFAULT_VIDEO_MODEL = "jimeng-video-3.5-pro";
37
  // 草稿版本
38
  export const DRAFT_VERSION = "3.3.8";
39
  export const DRAFT_MIN_VERSION = "3.0.2";
 
 
 
 
 
40
 
41
  // 图像模型映射
42
  export const IMAGE_MODEL_MAP = {
 
 
43
  "jimeng-4.5": "high_aes_general_v40l",
44
  "jimeng-4.1": "high_aes_general_v41",
45
  "jimeng-4.0": "high_aes_general_v40",
46
  "jimeng-3.1": "high_aes_general_v30l_art_fangzhou:general_v3.0_18b",
47
  "jimeng-3.0": "high_aes_general_v30l:general_v3.0_18b",
48
- "jimeng-2.1": "high_aes_general_v21_L:general_v2.1_L",
49
- "jimeng-2.0-pro": "high_aes_general_v20_L:general_v2.0_L",
50
- "jimeng-2.0": "high_aes_general_v20:general_v2.0",
51
- "jimeng-1.4": "high_aes_general_v14:general_v1.4",
52
- "jimeng-xl-pro": "text2img_xl_sft"
53
  };
54
 
55
  export const IMAGE_MODEL_MAP_US = {
@@ -61,10 +63,22 @@ export const IMAGE_MODEL_MAP_US = {
61
  "nanobananapro": "dreamina_image_lib_1",
62
  };
63
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  // 视频模型映射 - 国内站 (CN)
65
  export const VIDEO_MODEL_MAP = {
66
- "jimeng-video-4.0-pro": "dreamina_seedance_40_pro",
67
- "jimeng-video-4.0": "dreamina_seedance_40",
68
  "jimeng-video-3.5-pro": "dreamina_ic_generate_video_model_vgfm_3.5_pro",
69
  "jimeng-video-3.0-pro": "dreamina_ic_generate_video_model_vgfm_3.0_pro",
70
  "jimeng-video-3.0": "dreamina_ic_generate_video_model_vgfm_3.0",
 
37
  // 草稿版本
38
  export const DRAFT_VERSION = "3.3.8";
39
  export const DRAFT_MIN_VERSION = "3.0.2";
40
+ export const DRAFT_VERSION_OMNI = "3.3.9";
41
+
42
+ // omni_reference 模式专用 benefit_type
43
+ export const OMNI_BENEFIT_TYPE = "dreamina_video_seedance_20_video_add";
44
+ export const OMNI_BENEFIT_TYPE_FAST = "dreamina_seedance_20_fast_with_video";
45
 
46
  // 图像模型映射
47
  export const IMAGE_MODEL_MAP = {
48
+ "jimeng-5.0": "high_aes_general_v50",
49
+ "jimeng-4.6": "high_aes_general_v42",
50
  "jimeng-4.5": "high_aes_general_v40l",
51
  "jimeng-4.1": "high_aes_general_v41",
52
  "jimeng-4.0": "high_aes_general_v40",
53
  "jimeng-3.1": "high_aes_general_v30l_art_fangzhou:general_v3.0_18b",
54
  "jimeng-3.0": "high_aes_general_v30l:general_v3.0_18b",
 
 
 
 
 
55
  };
56
 
57
  export const IMAGE_MODEL_MAP_US = {
 
63
  "nanobananapro": "dreamina_image_lib_1",
64
  };
65
 
66
+ // 图像模型映射 - 亚洲国际站 (HK/JP/SG)
67
+ export const IMAGE_MODEL_MAP_ASIA = {
68
+ "jimeng-5.0": "high_aes_general_v50",
69
+ "jimeng-4.6": "high_aes_general_v42",
70
+ "jimeng-4.5": "high_aes_general_v40l",
71
+ "jimeng-4.1": "high_aes_general_v41",
72
+ "jimeng-4.0": "high_aes_general_v40",
73
+ "jimeng-3.0": "high_aes_general_v30l:general_v3.0_18b",
74
+ "nanobanana": "external_model_gemini_flash_image_v25",
75
+ "nanobananapro": "dreamina_image_lib_1",
76
+ };
77
+
78
  // 视频模型映射 - 国内站 (CN)
79
  export const VIDEO_MODEL_MAP = {
80
+ "jimeng-video-seedance-2.0": "dreamina_seedance_40_pro",
81
+ "jimeng-video-seedance-2.0-fast": "dreamina_seedance_40",
82
  "jimeng-video-3.5-pro": "dreamina_ic_generate_video_model_vgfm_3.5_pro",
83
  "jimeng-video-3.0-pro": "dreamina_ic_generate_video_model_vgfm_3.0_pro",
84
  "jimeng-video-3.0": "dreamina_ic_generate_video_model_vgfm_3.0",
src/api/controllers/core.ts CHANGED
@@ -38,7 +38,7 @@ const MODEL_NAME = "jimeng";
38
  const DEVICE_ID = Math.random() * 999999999999999999 + 7000000000000000000;
39
  // WebID
40
  const WEB_ID = Math.random() * 999999999999999999 + 7000000000000000000;
41
- // 用户ID
42
  const USER_ID = util.uuid(false);
43
  // 伪装headers
44
  const FAKE_HEADERS = {
@@ -165,24 +165,15 @@ export function generateCookie(refreshToken: string) {
165
  ? tokenWithRegion.substring(3)
166
  : tokenWithRegion;
167
 
168
- let storeRegion = 'cn-gd';
169
- if (isUS) storeRegion = 'us';
170
- else if (isHK) storeRegion = 'hk';
171
- else if (isJP) storeRegion = 'hk'; // JP uses HK store region
172
- else if (isSG) storeRegion = 'hk'; // SG uses HK store region
173
-
174
  return [
175
  `_tea_web_id=${WEB_ID}`,
176
  `is_staff_user=false`,
177
- `store-region=${storeRegion}`,
178
- `store-region-src=uid`,
179
  `sid_guard=${token}%7C${util.unixTimestamp()}%7C5184000%7CMon%2C+03-Feb-2025+08%3A17%3A09+GMT`,
180
  `uid_tt=${USER_ID}`,
181
  `uid_tt_ss=${USER_ID}`,
182
  `sid_tt=${token}`,
183
  `sessionid=${token}`,
184
  `sessionid_ss=${token}`,
185
- `sid_tt=${token}`
186
  ].join("; ");
187
  }
188
 
@@ -312,11 +303,15 @@ export async function request(
312
  ...FAKE_HEADERS,
313
  Origin: origin,
314
  Referer: origin,
 
315
  Appid: aid,
316
  Cookie: generateCookie(tokenWithRegion),
317
  "Device-Time": deviceTime,
 
 
318
  Sign: sign,
319
  "Sign-Ver": "1",
 
320
  ...(options.headers || {}),
321
  };
322
 
@@ -396,7 +391,8 @@ export async function request(
396
  error.message.includes('timeout') ||
397
  error.message.includes('network') ||
398
  error.message.includes('ECONNRESET') ||
399
- error.message.includes('socket hang up');
 
400
 
401
  if (isRetryableError && retries < maxRetries) {
402
  retries++;
@@ -424,6 +420,64 @@ export async function request(
424
  }
425
  }
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  /**
428
  * 预检查文件URL有效性
429
  *
 
38
  const DEVICE_ID = Math.random() * 999999999999999999 + 7000000000000000000;
39
  // WebID
40
  const WEB_ID = Math.random() * 999999999999999999 + 7000000000000000000;
41
+ // 用户ID(32位hex,无横线)
42
  const USER_ID = util.uuid(false);
43
  // 伪装headers
44
  const FAKE_HEADERS = {
 
165
  ? tokenWithRegion.substring(3)
166
  : tokenWithRegion;
167
 
 
 
 
 
 
 
168
  return [
169
  `_tea_web_id=${WEB_ID}`,
170
  `is_staff_user=false`,
 
 
171
  `sid_guard=${token}%7C${util.unixTimestamp()}%7C5184000%7CMon%2C+03-Feb-2025+08%3A17%3A09+GMT`,
172
  `uid_tt=${USER_ID}`,
173
  `uid_tt_ss=${USER_ID}`,
174
  `sid_tt=${token}`,
175
  `sessionid=${token}`,
176
  `sessionid_ss=${token}`,
 
177
  ].join("; ");
178
  }
179
 
 
303
  ...FAKE_HEADERS,
304
  Origin: origin,
305
  Referer: origin,
306
+ "App-Sdk-Version": "48.0.0",
307
  Appid: aid,
308
  Cookie: generateCookie(tokenWithRegion),
309
  "Device-Time": deviceTime,
310
+ Lan: isUS ? "en" : isJP ? "ja" : (isHK || isSG) ? "en" : "zh-Hans",
311
+ Loc: isUS ? "us" : isJP ? "jp" : isHK ? "hk" : isSG ? "sg" : "cn",
312
  Sign: sign,
313
  "Sign-Ver": "1",
314
+ Tdid: "",
315
  ...(options.headers || {}),
316
  };
317
 
 
391
  error.message.includes('timeout') ||
392
  error.message.includes('network') ||
393
  error.message.includes('ECONNRESET') ||
394
+ error.message.includes('socket hang up') ||
395
+ error.message.includes('Proxy connection');
396
 
397
  if (isRetryableError && retries < maxRetries) {
398
  retries++;
 
420
  }
421
  }
422
 
423
+ /**
424
+ * 检测上传图片内容合规性(仅国内站)
425
+ * 调用 algo_proxy 接口进行图片安全检测,不通过则抛出异常
426
+ *
427
+ * @param imageUri 已上传图片的 URI
428
+ * @param refreshToken 刷新令牌
429
+ * @param regionInfo 区域信息
430
+ */
431
+ export async function checkImageContent(
432
+ imageUri: string,
433
+ refreshToken: string,
434
+ regionInfo: RegionInfo
435
+ ): Promise<void> {
436
+ // 仅国内站需要内容检测
437
+ if (regionInfo.isInternational) return;
438
+
439
+ const babiParam = JSON.stringify({
440
+ scenario: "image_video_generation",
441
+ feature_key: "aigc_to_image",
442
+ feature_entrance: "to-generate",
443
+ feature_entrance_detail: "to-generate-algo_proxy",
444
+ });
445
+
446
+ logger.info(`开始图片内容安全检测: ${imageUri}`);
447
+
448
+ try {
449
+ await request("post", "/mweb/v1/algo_proxy", refreshToken, {
450
+ params: {
451
+ babi_param: babiParam,
452
+ },
453
+ data: {
454
+ scene: "image_face_ip",
455
+ options: { ip_check: true },
456
+ req_key: "benchmark_test_user_upload_image_input",
457
+ file_list: [{ file_uri: imageUri }],
458
+ req_params: {},
459
+ },
460
+ });
461
+ logger.info(`图片内容安全检测通过: ${imageUri}`);
462
+ } catch (error: any) {
463
+ // 区分内容违规(ret=2003等) vs 网络/服务异常
464
+ const isContentViolation = error.message && (
465
+ error.message.includes('2003') ||
466
+ error.message.includes('risk not pass') ||
467
+ error.message.includes('detected risk')
468
+ );
469
+ if (isContentViolation) {
470
+ logger.error(`图片内容安全检测未通过: ${imageUri}, ${error.message}`);
471
+ throw new APIException(
472
+ EX.API_REQUEST_FAILED,
473
+ `图片内容检测未通过,该图片可能包含违规内容`
474
+ );
475
+ }
476
+ // 网络/服务异常不阻塞,仅记录警告
477
+ logger.warn(`图片内容安全检测服务异常(不阻塞): ${imageUri}, ${error.message}`);
478
+ }
479
+ }
480
+
481
  /**
482
  * 预检查文件URL有效性
483
  *
src/api/controllers/images.ts CHANGED
@@ -3,10 +3,10 @@ import _ from "lodash";
3
  import APIException from "@/lib/exceptions/APIException.ts";
4
  import EX from "@/api/consts/exceptions.ts";
5
  import util from "@/lib/util.ts";
6
- import { getCredit, receiveCredit, request, parseRegionFromToken, getAssistantId, RegionInfo } from "./core.ts";
7
  import logger from "@/lib/logger.ts";
8
  import { SmartPoller, PollingStatus } from "@/lib/smart-poller.ts";
9
- import { DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_MODEL_US, IMAGE_MODEL_MAP, IMAGE_MODEL_MAP_US } from "@/api/consts/common.ts";
10
  import { uploadImageFromUrl, uploadImageBuffer } from "@/lib/image-uploader.ts";
11
  import { extractImageUrls } from "@/lib/image-utils.ts";
12
  import {
@@ -31,14 +31,22 @@ export interface ModelResult {
31
 
32
  /**
33
  * 获取模型映射
34
- * - 国际站不支持的模型会抛出错误
 
35
  * - 但如果传入的是国内站默认模型,国际站会自动回退到国际站默认模型
36
  */
37
- export function getModel(model: string, isInternational: boolean): ModelResult {
38
- const modelMap = isInternational ? IMAGE_MODEL_MAP_US : IMAGE_MODEL_MAP;
39
- const defaultModel = isInternational ? DEFAULT_MODEL_US : DEFAULT_MODEL;
 
 
 
 
 
 
 
40
 
41
- if (isInternational && !modelMap[model]) {
42
  // 如果传入的是国内站默认模型,回退到国际站默认模型
43
  if (model === DEFAULT_MODEL) {
44
  logger.info(`国际站不支持默认模型 "${model}",回退到 "${defaultModel}"`);
@@ -91,7 +99,7 @@ export async function generateImageComposition(
91
  refreshToken: string
92
  ) {
93
  const regionInfo = parseRegionFromToken(refreshToken);
94
- const { model, userModel } = getModel(_model, regionInfo.isInternational);
95
 
96
  // 使用 payload-builder 处理分辨率
97
  const resolutionResult = resolveResolution(userModel, regionInfo, resolution, ratio);
@@ -123,12 +131,13 @@ export async function generateImageComposition(
123
  let imageId: string;
124
  if (typeof image === 'string') {
125
  logger.info(`正在处理第 ${i + 1}/${imageCount} 张图片 (URL)...`);
126
- imageId = await uploadImageFromUrl(image, refreshToken, regionInfo);
127
  } else {
128
  logger.info(`正在处理第 ${i + 1}/${imageCount} 张图片 (Buffer)...`);
129
- imageId = await uploadImageBuffer(image, refreshToken, regionInfo);
130
  }
131
  uploadedImageIds.push(imageId);
 
132
  logger.info(`图片 ${i + 1}/${imageCount} 上传成功: ${imageId}`);
133
  } catch (error) {
134
  logger.error(`图片 ${i + 1}/${imageCount} 上传失败: ${error.message}`);
@@ -166,6 +175,7 @@ export async function generateImageComposition(
166
  // 使用 payload-builder 构建 metrics_extra
167
  const metricsExtra = buildMetricsExtra({
168
  userModel,
 
169
  regionInfo,
170
  submitId,
171
  scene: "ImageBasicGenerate",
@@ -201,11 +211,15 @@ export async function generateImageComposition(
201
  metricsExtra,
202
  });
203
 
 
 
 
 
204
  const { aigc_data } = await request(
205
  "post",
206
  "/mweb/v1/aigc_draft/generate",
207
  refreshToken,
208
- { data: requestData }
209
  );
210
 
211
  const historyId = aigc_data?.history_record_id;
@@ -217,9 +231,10 @@ export async function generateImageComposition(
217
  // 轮询结果
218
  const poller = new SmartPoller({
219
  maxPollCount: 900,
 
220
  expectedItemCount: 1,
221
  type: 'image',
222
- timeoutSeconds: 900 // 15 分钟超时
223
  });
224
 
225
  const { result: pollingResult, data: finalTaskInfo } = await poller.poll(async () => {
@@ -297,7 +312,7 @@ export async function generateImages(
297
  refreshToken: string
298
  ) {
299
  const regionInfo = parseRegionFromToken(refreshToken);
300
- const { model, userModel } = getModel(_model, regionInfo.isInternational);
301
  logger.info(`使用模型: ${userModel} 映射模型: ${model} 分辨率: ${resolution} 比例: ${ratio} 精细度: ${sampleStrength} 智能比例: ${intelligentRatio}`);
302
 
303
  return await generateImagesInternal(userModel, prompt, { ratio, resolution, sampleStrength, negativePrompt, intelligentRatio }, refreshToken);
@@ -325,7 +340,7 @@ async function generateImagesInternal(
325
  refreshToken: string
326
  ) {
327
  const regionInfo = parseRegionFromToken(refreshToken);
328
- const { model, userModel } = getModel(_model, regionInfo.isInternational);
329
 
330
  // 使用 payload-builder 处理分辨率
331
  const resolutionResult = resolveResolution(userModel, regionInfo, resolution, ratio);
@@ -378,6 +393,7 @@ async function generateImagesInternal(
378
  // 使用 payload-builder 构建 metrics_extra
379
  const metricsExtra = buildMetricsExtra({
380
  userModel,
 
381
  regionInfo,
382
  submitId,
383
  scene: "ImageBasicGenerate",
@@ -401,11 +417,15 @@ async function generateImagesInternal(
401
  metricsExtra,
402
  });
403
 
 
 
 
 
404
  const { aigc_data } = await request(
405
  "post",
406
  "/mweb/v1/aigc_draft/generate",
407
  refreshToken,
408
- { data: requestData }
409
  );
410
 
411
  const historyId = aigc_data?.history_record_id;
@@ -415,9 +435,10 @@ async function generateImagesInternal(
415
  // 轮询结果
416
  const poller = new SmartPoller({
417
  maxPollCount: 900,
 
418
  expectedItemCount: 4,
419
  type: 'image',
420
- timeoutSeconds: 900 // 15 分钟超时
421
  });
422
 
423
  const { result: pollingResult, data: finalTaskInfo } = await poller.poll(async () => {
@@ -498,7 +519,7 @@ async function generateJimeng4xMultiImages(
498
  refreshToken: string
499
  ) {
500
  const regionInfo = parseRegionFromToken(refreshToken);
501
- const { model, userModel } = getModel(_model, regionInfo.isInternational);
502
 
503
  // 使用 payload-builder 处理分辨率
504
  const resolutionResult = resolveResolution(userModel, regionInfo, resolution, ratio);
@@ -526,6 +547,7 @@ async function generateJimeng4xMultiImages(
526
  // 使用 payload-builder 构建 metrics_extra (多图模式)
527
  const metricsExtra = buildMetricsExtra({
528
  userModel,
 
529
  regionInfo,
530
  submitId,
531
  scene: "ImageMultiGenerate",
@@ -550,11 +572,15 @@ async function generateJimeng4xMultiImages(
550
  metricsExtra,
551
  });
552
 
 
 
 
 
553
  const { aigc_data } = await request(
554
  "post",
555
  "/mweb/v1/aigc_draft/generate",
556
  refreshToken,
557
- { data: requestData }
558
  );
559
 
560
  const historyId = aigc_data?.history_record_id;
@@ -566,9 +592,10 @@ async function generateJimeng4xMultiImages(
566
  // 轮询结果
567
  const poller = new SmartPoller({
568
  maxPollCount: 600,
 
569
  expectedItemCount: targetImageCount,
570
  type: 'image',
571
- timeoutSeconds: 900 // 15 分钟超时
572
  });
573
 
574
  const { result: pollingResult, data: finalTaskInfo } = await poller.poll(async () => {
 
3
  import APIException from "@/lib/exceptions/APIException.ts";
4
  import EX from "@/api/consts/exceptions.ts";
5
  import util from "@/lib/util.ts";
6
+ import { getCredit, receiveCredit, request, parseRegionFromToken, getAssistantId, checkImageContent, RegionInfo } from "./core.ts";
7
  import logger from "@/lib/logger.ts";
8
  import { SmartPoller, PollingStatus } from "@/lib/smart-poller.ts";
9
+ import { DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_MODEL_US, IMAGE_MODEL_MAP, IMAGE_MODEL_MAP_US, IMAGE_MODEL_MAP_ASIA } from "@/api/consts/common.ts";
10
  import { uploadImageFromUrl, uploadImageBuffer } from "@/lib/image-uploader.ts";
11
  import { extractImageUrls } from "@/lib/image-utils.ts";
12
  import {
 
31
 
32
  /**
33
  * 获取模型映射
34
+ * - 根据点选择的模型映射 (CN / US / ASIA)
35
+ * - 不支持的模型会抛出错误
36
  * - 但如果传入的是国内站默认模型,国际站会自动回退到国际站默认模型
37
  */
38
+ export function getModel(model: string, regionInfo: RegionInfo): ModelResult {
39
+ let modelMap: Record<string, string>;
40
+ if (regionInfo.isUS) {
41
+ modelMap = IMAGE_MODEL_MAP_US;
42
+ } else if (regionInfo.isHK || regionInfo.isJP || regionInfo.isSG) {
43
+ modelMap = IMAGE_MODEL_MAP_ASIA;
44
+ } else {
45
+ modelMap = IMAGE_MODEL_MAP;
46
+ }
47
+ const defaultModel = regionInfo.isInternational ? DEFAULT_MODEL_US : DEFAULT_MODEL;
48
 
49
+ if (regionInfo.isInternational && !modelMap[model]) {
50
  // 如果传入的是国内站默认模型,回退到国际站默认模型
51
  if (model === DEFAULT_MODEL) {
52
  logger.info(`国际站不支持默认模型 "${model}",回退到 "${defaultModel}"`);
 
99
  refreshToken: string
100
  ) {
101
  const regionInfo = parseRegionFromToken(refreshToken);
102
+ const { model, userModel } = getModel(_model, regionInfo);
103
 
104
  // 使用 payload-builder 处理分辨率
105
  const resolutionResult = resolveResolution(userModel, regionInfo, resolution, ratio);
 
131
  let imageId: string;
132
  if (typeof image === 'string') {
133
  logger.info(`正在处理第 ${i + 1}/${imageCount} 张图片 (URL)...`);
134
+ imageId = (await uploadImageFromUrl(image, refreshToken, regionInfo)).uri;
135
  } else {
136
  logger.info(`正在处理第 ${i + 1}/${imageCount} 张图片 (Buffer)...`);
137
+ imageId = (await uploadImageBuffer(image, refreshToken, regionInfo)).uri;
138
  }
139
  uploadedImageIds.push(imageId);
140
+ await checkImageContent(imageId, refreshToken, regionInfo);
141
  logger.info(`图片 ${i + 1}/${imageCount} 上传成功: ${imageId}`);
142
  } catch (error) {
143
  logger.error(`图片 ${i + 1}/${imageCount} 上传失败: ${error.message}`);
 
175
  // 使用 payload-builder 构建 metrics_extra
176
  const metricsExtra = buildMetricsExtra({
177
  userModel,
178
+ model,
179
  regionInfo,
180
  submitId,
181
  scene: "ImageBasicGenerate",
 
211
  metricsExtra,
212
  });
213
 
214
+ const imageReferer = regionInfo.isCN
215
+ ? "https://jimeng.jianying.com/ai-tool/generate?type=image"
216
+ : "https://dreamina.capcut.com/ai-tool/generate?type=image";
217
+
218
  const { aigc_data } = await request(
219
  "post",
220
  "/mweb/v1/aigc_draft/generate",
221
  refreshToken,
222
+ { data: requestData, headers: { Referer: imageReferer } }
223
  );
224
 
225
  const historyId = aigc_data?.history_record_id;
 
231
  // 轮询结果
232
  const poller = new SmartPoller({
233
  maxPollCount: 900,
234
+ pollInterval: 10000, // 10秒轮询间隔
235
  expectedItemCount: 1,
236
  type: 'image',
237
+ timeoutSeconds: 1800 // 30 分钟超时
238
  });
239
 
240
  const { result: pollingResult, data: finalTaskInfo } = await poller.poll(async () => {
 
312
  refreshToken: string
313
  ) {
314
  const regionInfo = parseRegionFromToken(refreshToken);
315
+ const { model, userModel } = getModel(_model, regionInfo);
316
  logger.info(`使用模型: ${userModel} 映射模型: ${model} 分辨率: ${resolution} 比例: ${ratio} 精细度: ${sampleStrength} 智能比例: ${intelligentRatio}`);
317
 
318
  return await generateImagesInternal(userModel, prompt, { ratio, resolution, sampleStrength, negativePrompt, intelligentRatio }, refreshToken);
 
340
  refreshToken: string
341
  ) {
342
  const regionInfo = parseRegionFromToken(refreshToken);
343
+ const { model, userModel } = getModel(_model, regionInfo);
344
 
345
  // 使用 payload-builder 处理分辨率
346
  const resolutionResult = resolveResolution(userModel, regionInfo, resolution, ratio);
 
393
  // 使用 payload-builder 构建 metrics_extra
394
  const metricsExtra = buildMetricsExtra({
395
  userModel,
396
+ model,
397
  regionInfo,
398
  submitId,
399
  scene: "ImageBasicGenerate",
 
417
  metricsExtra,
418
  });
419
 
420
+ const imageReferer = regionInfo.isCN
421
+ ? "https://jimeng.jianying.com/ai-tool/generate?type=image"
422
+ : "https://dreamina.capcut.com/ai-tool/generate?type=image";
423
+
424
  const { aigc_data } = await request(
425
  "post",
426
  "/mweb/v1/aigc_draft/generate",
427
  refreshToken,
428
+ { data: requestData, headers: { Referer: imageReferer } }
429
  );
430
 
431
  const historyId = aigc_data?.history_record_id;
 
435
  // 轮询结果
436
  const poller = new SmartPoller({
437
  maxPollCount: 900,
438
+ pollInterval: 10000, // 10秒轮询间隔
439
  expectedItemCount: 4,
440
  type: 'image',
441
+ timeoutSeconds: 1800 // 30 分钟超时
442
  });
443
 
444
  const { result: pollingResult, data: finalTaskInfo } = await poller.poll(async () => {
 
519
  refreshToken: string
520
  ) {
521
  const regionInfo = parseRegionFromToken(refreshToken);
522
+ const { model, userModel } = getModel(_model, regionInfo);
523
 
524
  // 使用 payload-builder 处理分辨率
525
  const resolutionResult = resolveResolution(userModel, regionInfo, resolution, ratio);
 
547
  // 使用 payload-builder 构建 metrics_extra (多图模式)
548
  const metricsExtra = buildMetricsExtra({
549
  userModel,
550
+ model,
551
  regionInfo,
552
  submitId,
553
  scene: "ImageMultiGenerate",
 
572
  metricsExtra,
573
  });
574
 
575
+ const imageReferer = regionInfo.isCN
576
+ ? "https://jimeng.jianying.com/ai-tool/generate?type=image"
577
+ : "https://dreamina.capcut.com/ai-tool/generate?type=image";
578
+
579
  const { aigc_data } = await request(
580
  "post",
581
  "/mweb/v1/aigc_draft/generate",
582
  refreshToken,
583
+ { data: requestData, headers: { Referer: imageReferer } }
584
  );
585
 
586
  const historyId = aigc_data?.history_record_id;
 
592
  // 轮询结果
593
  const poller = new SmartPoller({
594
  maxPollCount: 600,
595
+ pollInterval: 10000, // 10秒轮询间隔
596
  expectedItemCount: targetImageCount,
597
  type: 'image',
598
+ timeoutSeconds: 1800 // 30 分钟超时
599
  });
600
 
601
  const { result: pollingResult, data: finalTaskInfo } = await poller.poll(async () => {
src/api/controllers/videos.ts CHANGED
@@ -6,12 +6,14 @@ import APIException from "@/lib/exceptions/APIException.ts";
6
 
7
  import EX from "@/api/consts/exceptions.ts";
8
  import util from "@/lib/util.ts";
9
- import { getCredit, receiveCredit, request, parseRegionFromToken, getAssistantId, RegionInfo } from "./core.ts";
10
  import logger from "@/lib/logger.ts";
11
  import { SmartPoller, PollingStatus } from "@/lib/smart-poller.ts";
12
- import { DEFAULT_ASSISTANT_ID_CN, DEFAULT_ASSISTANT_ID_US, DEFAULT_ASSISTANT_ID_HK, DEFAULT_ASSISTANT_ID_JP, DEFAULT_ASSISTANT_ID_SG, DEFAULT_VIDEO_MODEL, DRAFT_VERSION, VIDEO_MODEL_MAP, VIDEO_MODEL_MAP_US, VIDEO_MODEL_MAP_ASIA } from "@/api/consts/common.ts";
13
- import { uploadImageBuffer } from "@/lib/image-uploader.ts";
14
- import { extractVideoUrl } from "@/lib/image-utils.ts";
 
 
15
 
16
  export const DEFAULT_MODEL = DEFAULT_VIDEO_MODEL;
17
 
@@ -45,7 +47,7 @@ function getVideoBenefitType(model: string): string {
45
  return "dreamina_video_seedance_20_pro";
46
  }
47
  if (model.includes("40")) {
48
- return "dreamina_video_seedance_20";
49
  }
50
  if (model.includes("3.5_pro")) {
51
  return "dreamina_video_seedance_15_pro";
@@ -57,7 +59,7 @@ function getVideoBenefitType(model: string): string {
57
  }
58
 
59
  // 处理本地上传的文件
60
- async function uploadImageFromFile(file: any, refreshToken: string, regionInfo: RegionInfo): Promise<string> {
61
  try {
62
  logger.info(`开始从本地文件上传视频图片: ${file.originalFilename} (路径: ${file.filepath})`);
63
  const imageBuffer = await fs.readFile(file.filepath);
@@ -69,11 +71,12 @@ async function uploadImageFromFile(file: any, refreshToken: string, regionInfo:
69
  }
70
 
71
  // 处理来自URL的图片
72
- async function uploadImageFromUrl(imageUrl: string, refreshToken: string, regionInfo: RegionInfo): Promise<string> {
73
  try {
74
  logger.info(`开始从URL下载并上传视频图片: ${imageUrl}`);
75
  const imageResponse = await axios.get(imageUrl, {
76
  responseType: 'arraybuffer',
 
77
  });
78
  if (imageResponse.status < 200 || imageResponse.status >= 300) {
79
  throw new Error(`下载图片失败: ${imageResponse.status}`);
@@ -86,6 +89,60 @@ async function uploadImageFromUrl(imageUrl: string, refreshToken: string, region
86
  }
87
  }
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  /**
91
  * 生成视频
@@ -105,12 +162,16 @@ export async function generateVideo(
105
  duration = 5,
106
  filePaths = [],
107
  files = {},
 
 
108
  }: {
109
  ratio?: string;
110
  resolution?: string;
111
  duration?: number;
112
  filePaths?: string[];
113
  files?: any;
 
 
114
  },
115
  refreshToken: string
116
  ) {
@@ -124,6 +185,8 @@ export async function generateVideo(
124
  const isVeo3 = model.includes("veo3");
125
  const isSora2 = model.includes("sora2");
126
  const is35Pro = model.includes("3.5_pro");
 
 
127
  // 只有 video-3.0 和 video-3.0-fast 支持 resolution 参数(3.0-pro 和 3.5-pro 不支持)
128
  const supportsResolution = (model.includes("vgfm_3.0") || model.includes("vgfm_3.0_fast")) && !model.includes("_pro");
129
 
@@ -131,6 +194,7 @@ export async function generateVideo(
131
  // veo3 模型固定 8 秒
132
  // sora2 模型支持 4秒、8秒、12秒,默认4秒
133
  // 3.5-pro 模型支持 5秒、10秒、12秒,默认5秒
 
134
  // 其他模型支持 5秒、10秒,默认5秒
135
  let durationMs: number;
136
  let actualDuration: number;
@@ -148,6 +212,10 @@ export async function generateVideo(
148
  durationMs = 4000;
149
  actualDuration = 4;
150
  }
 
 
 
 
151
  } else if (is35Pro) {
152
  if (duration === 12) {
153
  durationMs = 12000;
@@ -179,230 +247,611 @@ export async function generateVideo(
179
  }
180
  }
181
 
182
- // 处理首帧和尾帧图片
183
- let first_frame_image = undefined;
184
- let end_frame_image = undefined;
185
- let uploadIDs: string[] = [];
186
-
187
- // 优先处理本地上传的文件
188
- const uploadedFiles = _.values(files); // 将files对象转为数组
189
- if (uploadedFiles && uploadedFiles.length > 0) {
190
- logger.info(`检测到 ${uploadedFiles.length} 个本地上传文件,优先处理`);
191
- for (let i = 0; i < uploadedFiles.length; i++) {
192
- const file = uploadedFiles[i];
193
- if (!file) continue;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  try {
195
- logger.info(`开始上传 ${i + 1} 张本地图片: ${file.originalFilename}`);
196
- const imageUri = await uploadImageFromFile(file, refreshToken, regionInfo);
197
- if (imageUri) {
198
- uploadIDs.push(imageUri);
199
- logger.info(`第 ${i + 1} 张本地图片上传成功: ${imageUri}`);
200
- } else {
201
- logger.error(`第 ${i + 1} 张本地图片上传失败: 未获取到 image_uri`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  }
203
  } catch (error: any) {
204
- logger.error(`第 ${i + 1} 张本地图片上传失败: ${error.message}`);
205
- if (i === 0) {
206
- throw new APIException(EX.API_REQUEST_FAILED, `首帧图片上传失败: ${error.message}`);
207
- }
208
  }
209
  }
210
- }
211
- // 如果没有本地文件,再处理URL
212
- else if (filePaths && filePaths.length > 0) {
213
- logger.info(`未检测到本地上传文件,处理 ${filePaths.length} 个图片URL`);
214
- for (let i = 0; i < filePaths.length; i++) {
215
- const filePath = filePaths[i];
216
- if (!filePath) {
217
- logger.warn(`第 ${i + 1} 个图片URL为空,跳过`);
218
- continue;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
 
 
 
 
 
 
 
220
  try {
221
- logger.info(`开始上传 ${i + 1} 个URL图片: ${filePath}`);
222
- const imageUri = await uploadImageFromUrl(filePath, refreshToken, regionInfo);
223
- if (imageUri) {
224
- uploadIDs.push(imageUri);
225
- logger.info(`第 ${i + 1} 个URL图片上传成功: ${imageUri}`);
226
- } else {
227
- logger.error(`第 ${i + 1} 个URL图片上传失败: 未获取到 image_uri`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  }
229
  } catch (error: any) {
230
- logger.error(`第 ${i + 1} 个URL图片上传失败: ${error.message}`);
231
- if (i === 0) {
232
- throw new APIException(EX.API_REQUEST_FAILED, `首帧图片上传失败: ${error.message}`);
233
- }
234
  }
235
  }
236
- } else {
237
- logger.info(`未提供图片文件或URL,将进行纯文本视频生成`);
238
- }
239
 
240
- // 如果有图片上传(无论来源),构建对象
241
- if (uploadIDs.length > 0) {
242
- logger.info(`图片上传完成,共成功 ${uploadIDs.length} 张`);
243
- // 构建首帧图片对象
244
- if (uploadIDs[0]) {
245
- first_frame_image = {
246
- format: "",
247
- height: 0,
248
- id: util.uuid(),
249
- image_uri: uploadIDs[0],
250
- name: "",
251
- platform_type: 1,
252
- source_from: "upload",
253
- type: "image",
254
- uri: uploadIDs[0],
255
- width: 0,
256
- };
257
- logger.info(`设置首帧图片: ${uploadIDs[0]}`);
258
  }
259
 
260
- // 构建尾帧图片对象
261
- if (uploadIDs[1]) {
262
- end_frame_image = {
263
- format: "",
264
- height: 0,
265
- id: util.uuid(),
266
- image_uri: uploadIDs[1],
267
- name: "",
268
- platform_type: 1,
269
- source_from: "upload",
270
- type: "image",
271
- uri: uploadIDs[1],
272
- width: 0,
273
- };
274
- logger.info(`设置尾帧图片: ${uploadIDs[1]}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  }
276
- }
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
 
279
- const componentId = util.uuid();
280
- const originSubmitId = util.uuid();
281
-
282
- // 根据官方API的实际行为,所有模式都使用 "first_last_frames"
283
- // 通过 first_frame_image 和 end_frame_image 是否为 undefined 来区分模式
284
- const functionMode = "first_last_frames";
285
-
286
- const sceneOption = {
287
- type: "video",
288
- scene: "BasicVideoGenerateButton",
289
- ...(supportsResolution ? { resolution: resolution } : {}),
290
- modelReqKey: model,
291
- videoDuration: actualDuration,
292
- reportParams: {
293
- enterSource: "generate",
294
- vipSource: "generate",
295
- extraVipFunctionKey: supportsResolution ? `${model}-${resolution}` : model,
296
- useVipFunctionDetailsReporterHoc: true,
297
- },
298
- };
299
-
300
- const metricsExtra = JSON.stringify({
301
- promptSource: "custom",
302
- isDefaultSeed: 1,
303
- originSubmitId: originSubmitId,
304
- isRegenerate: false,
305
- enterFrom: "click",
306
- functionMode: functionMode,
307
- sceneOptions: JSON.stringify([sceneOption]),
308
- });
309
 
310
- // 当有图片输入时,ratio参数会被图片的实际比例覆盖
311
- const hasImageInput = uploadIDs.length > 0;
312
- if (hasImageInput && ratio !== "1:1") {
313
- logger.warn(`图生视频模式下,ratio参数将被忽略(由输入图片的实际比例决定),但resolution参数仍然有效`);
314
- }
315
 
316
- logger.info(`视频生成模式: ${uploadIDs.length}张图片 (首帧: ${!!first_frame_image}, 尾帧: ${!!end_frame_image}), resolution: ${resolution}`);
317
-
318
- // 构建请求参数
319
- const { aigc_data } = await request(
320
- "post",
321
- "/mweb/v1/aigc_draft/generate",
322
- refreshToken,
323
- {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  params: {
325
  aigc_features: "app_lip_sync",
326
  web_version: "7.5.0",
327
  da_version: DRAFT_VERSION,
328
  },
329
  data: {
330
- "extend": {
331
- "root_model": model,
332
- "m_video_commerce_info": {
333
  benefit_type: getVideoBenefitType(model),
334
  resource_id: "generate_video",
335
  resource_id_type: "str",
336
- resource_sub_type: "aigc"
337
  },
338
- "m_video_commerce_info_list": [{
339
  benefit_type: getVideoBenefitType(model),
340
  resource_id: "generate_video",
341
  resource_id_type: "str",
342
- resource_sub_type: "aigc"
343
- }]
344
  },
345
- "submit_id": util.uuid(),
346
- "metrics_extra": metricsExtra,
347
- "draft_content": JSON.stringify({
348
- "type": "draft",
349
- "id": util.uuid(),
350
- "min_version": "3.0.5",
351
- "min_features": [],
352
- "is_from_tsn": true,
353
- "version": DRAFT_VERSION,
354
- "main_component_id": componentId,
355
- "component_list": [{
356
- "type": "video_base_component",
357
- "id": componentId,
358
- "min_version": "1.0.0",
359
- "aigc_mode": "workbench",
360
- "metadata": {
361
- "type": "",
362
- "id": util.uuid(),
363
- "created_platform": 3,
364
- "created_platform_version": "",
365
- "created_time_in_ms": Date.now().toString(),
366
- "created_did": ""
367
  },
368
- "generate_type": "gen_video",
369
- "abilities": {
370
- "type": "",
371
- "id": util.uuid(),
372
- "gen_video": {
373
- "id": util.uuid(),
374
- "type": "",
375
- "text_to_video_params": {
376
- "type": "",
377
- "id": util.uuid(),
378
- "video_gen_inputs": [{
379
- "type": "",
380
- "id": util.uuid(),
381
- "min_version": "3.0.5",
382
- "prompt": prompt,
383
- "video_mode": 2,
384
- "fps": 24,
385
- "duration_ms": durationMs,
386
- ...(supportsResolution ? { "resolution": resolution } : {}),
387
- "first_frame_image": first_frame_image,
388
- "end_frame_image": end_frame_image,
389
- "idip_meta_list": []
390
  }],
391
- "video_aspect_ratio": ratio,
392
- "seed": Math.floor(Math.random() * 100000000) + 2500000000,
393
- "model_req_key": model,
394
- "priority": 0
395
  },
396
- "video_task_extra": metricsExtra,
397
- }
398
  },
399
- "process_type": 1
400
  }],
401
  }),
402
  http_common_info: {
403
- aid: getAssistantId(regionInfo)
404
  },
405
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  }
407
  );
408
 
@@ -421,10 +870,10 @@ export async function generateVideo(
421
 
422
  const poller = new SmartPoller({
423
  maxPollCount,
424
- pollInterval: 2000, // 2秒基础间隔
425
  expectedItemCount: 1,
426
  type: 'video',
427
- timeoutSeconds: 1200 // 20分钟超时
428
  });
429
 
430
  const { result: pollingResult, data: finalHistoryData } = await poller.poll(async () => {
@@ -437,33 +886,6 @@ export async function generateVideo(
437
  },
438
  });
439
 
440
- // 尝试直接从响应中提取视频URL
441
- const responseStr = JSON.stringify(result);
442
- const videoUrlMatch = responseStr.match(/https:\/\/v[0-9]+-artist\.vlabvod\.com\/[^"\s]+/);
443
- if (videoUrlMatch && videoUrlMatch[0]) {
444
- logger.info(`从API响应中直接提取到视频URL: ${videoUrlMatch[0]}`);
445
- // 构造成功状态并返回
446
- return {
447
- status: {
448
- status: 10,
449
- itemCount: 1,
450
- historyId
451
- } as PollingStatus,
452
- data: {
453
- status: 10,
454
- item_list: [{
455
- video: {
456
- transcoded_video: {
457
- origin: {
458
- video_url: videoUrlMatch[0]
459
- }
460
- }
461
- }
462
- }]
463
- }
464
- };
465
- }
466
-
467
  // 检查响应中是否有该 history_id 的数据
468
  // 由于 API 存在最终一致性,早期轮询可能暂时获取不到记录,返回处理中状态继续轮询
469
  if (!result[historyId]) {
@@ -510,15 +932,35 @@ export async function generateVideo(
510
 
511
  const item_list = finalHistoryData.item_list || [];
512
 
513
- // 取视频URL
514
- let videoUrl = item_list?.[0] ? extractVideoUrl(item_list[0]) : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
 
516
  // 如果无法获取视频URL,抛出异常
517
- if (!videoUrl) {
518
  logger.error(`未能获取视频URL,item_list: ${JSON.stringify(item_list)}`);
519
  throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "未能获取视频URL,请稍后查看");
520
  }
521
 
522
- logger.info(`视频生成成功,URL: ${videoUrl},总耗时: ${pollingResult.elapsedTime}秒`);
523
- return videoUrl;
524
  }
 
6
 
7
  import EX from "@/api/consts/exceptions.ts";
8
  import util from "@/lib/util.ts";
9
+ import { getCredit, receiveCredit, request, parseRegionFromToken, getAssistantId, checkImageContent, RegionInfo } from "./core.ts";
10
  import logger from "@/lib/logger.ts";
11
  import { SmartPoller, PollingStatus } from "@/lib/smart-poller.ts";
12
+ import { DEFAULT_ASSISTANT_ID_CN, DEFAULT_ASSISTANT_ID_US, DEFAULT_ASSISTANT_ID_HK, DEFAULT_ASSISTANT_ID_JP, DEFAULT_ASSISTANT_ID_SG, DEFAULT_VIDEO_MODEL, DRAFT_VERSION, DRAFT_VERSION_OMNI, OMNI_BENEFIT_TYPE, OMNI_BENEFIT_TYPE_FAST, VIDEO_MODEL_MAP, VIDEO_MODEL_MAP_US, VIDEO_MODEL_MAP_ASIA } from "@/api/consts/common.ts";
13
+ import { uploadImageBuffer, ImageUploadResult } from "@/lib/image-uploader.ts";
14
+ import { uploadVideoBuffer, VideoUploadResult } from "@/lib/video-uploader.ts";
15
+ import { extractVideoUrl, fetchHighQualityVideoUrl } from "@/lib/image-utils.ts";
16
+ import { uploadVideoFromUrl } from "@/lib/video-uploader.ts";
17
 
18
  export const DEFAULT_MODEL = DEFAULT_VIDEO_MODEL;
19
 
 
47
  return "dreamina_video_seedance_20_pro";
48
  }
49
  if (model.includes("40")) {
50
+ return "dreamina_video_seedance_20_fast";
51
  }
52
  if (model.includes("3.5_pro")) {
53
  return "dreamina_video_seedance_15_pro";
 
59
  }
60
 
61
  // 处理本地上传的文件
62
+ async function uploadImageFromFile(file: any, refreshToken: string, regionInfo: RegionInfo): Promise<ImageUploadResult> {
63
  try {
64
  logger.info(`开始从本地文件上传视频图片: ${file.originalFilename} (路径: ${file.filepath})`);
65
  const imageBuffer = await fs.readFile(file.filepath);
 
71
  }
72
 
73
  // 处理来自URL的图片
74
+ async function uploadImageFromUrl(imageUrl: string, refreshToken: string, regionInfo: RegionInfo): Promise<ImageUploadResult> {
75
  try {
76
  logger.info(`开始从URL下载并上传视频图片: ${imageUrl}`);
77
  const imageResponse = await axios.get(imageUrl, {
78
  responseType: 'arraybuffer',
79
+ proxy: false,
80
  });
81
  if (imageResponse.status < 200 || imageResponse.status >= 300) {
82
  throw new Error(`下载图片失败: ${imageResponse.status}`);
 
89
  }
90
  }
91
 
92
+ /**
93
+ * 解析 omni_reference 模式的 prompt,将 @引用 拆解为 meta_list
94
+ * 输入: "@image_file_1作为首帧,@image_file_2作为尾帧,运动动作模仿@video_file"
95
+ * 输出: 交替的 text + material_ref 段
96
+ */
97
+ function parseOmniPrompt(prompt: string, materialRegistry: Map<string, any>): any[] {
98
+ // 收集所有可识别的引用名(字段名 + 原始文件名),转义正则特殊字符
99
+ const refNames = [...materialRegistry.keys()]
100
+ .sort((a, b) => b.length - a.length) // 长名优先匹配
101
+ .map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
102
+
103
+ if (refNames.length === 0) {
104
+ return [{ meta_type: "text", text: prompt }];
105
+ }
106
+
107
+ const pattern = new RegExp(`@(${refNames.join('|')})`, 'g');
108
+ const meta_list: any[] = [];
109
+ let lastIndex = 0;
110
+ let match: RegExpExecArray | null;
111
+
112
+ while ((match = pattern.exec(prompt)) !== null) {
113
+ // 文本段
114
+ if (match.index > lastIndex) {
115
+ const textSegment = prompt.slice(lastIndex, match.index);
116
+ if (textSegment) {
117
+ meta_list.push({ meta_type: "text", text: textSegment });
118
+ }
119
+ }
120
+ // 引用段
121
+ const refName = match[1];
122
+ const entry = materialRegistry.get(refName);
123
+ if (entry) {
124
+ meta_list.push({
125
+ meta_type: entry.type,
126
+ text: "",
127
+ material_ref: { material_idx: entry.idx },
128
+ });
129
+ }
130
+ lastIndex = pattern.lastIndex;
131
+ }
132
+
133
+ // 尾部文本
134
+ if (lastIndex < prompt.length) {
135
+ meta_list.push({ meta_type: "text", text: prompt.slice(lastIndex) });
136
+ }
137
+
138
+ // 如果没有任何 @ 引用,把整个 prompt 作为文本段
139
+ if (meta_list.length === 0) {
140
+ meta_list.push({ meta_type: "text", text: prompt });
141
+ }
142
+
143
+ return meta_list;
144
+ }
145
+
146
 
147
  /**
148
  * 生成视频
 
162
  duration = 5,
163
  filePaths = [],
164
  files = {},
165
+ httpRequest,
166
+ functionMode = "first_last_frames",
167
  }: {
168
  ratio?: string;
169
  resolution?: string;
170
  duration?: number;
171
  filePaths?: string[];
172
  files?: any;
173
+ httpRequest?: any;
174
+ functionMode?: string;
175
  },
176
  refreshToken: string
177
  ) {
 
185
  const isVeo3 = model.includes("veo3");
186
  const isSora2 = model.includes("sora2");
187
  const is35Pro = model.includes("3.5_pro");
188
+ const is40Pro = model.includes("40_pro");
189
+ const is40 = model.includes("40") && !model.includes("40_pro");
190
  // 只有 video-3.0 和 video-3.0-fast 支持 resolution 参数(3.0-pro 和 3.5-pro 不支持)
191
  const supportsResolution = (model.includes("vgfm_3.0") || model.includes("vgfm_3.0_fast")) && !model.includes("_pro");
192
 
 
194
  // veo3 模型固定 8 秒
195
  // sora2 模型支持 4秒、8秒、12秒,默认4秒
196
  // 3.5-pro 模型支持 5秒、10秒、12秒,默认5秒
197
+ // 4.0-pro (seedance 2.0) 和 4.0 (seedance 2.0-fast) 模型支持 4~15秒,默认5秒
198
  // 其他模型支持 5秒、10秒,默认5秒
199
  let durationMs: number;
200
  let actualDuration: number;
 
212
  durationMs = 4000;
213
  actualDuration = 4;
214
  }
215
+ } else if (is40Pro || is40) {
216
+ // seedance 2.0 和 2.0-fast: 支持 4~15 秒,clamp 到有效范围,默认 5 秒
217
+ actualDuration = Math.max(4, Math.min(15, duration));
218
+ durationMs = actualDuration * 1000;
219
  } else if (is35Pro) {
220
  if (duration === 12) {
221
  durationMs = 12000;
 
247
  }
248
  }
249
 
250
+ const isOmniMode = functionMode === "omni_reference";
251
+
252
+ // omni_reference 仅支持 seedance 2.0 (40_pro) 和 2.0-fast (40) 模型
253
+ if (isOmniMode && !is40Pro && !is40) {
254
+ throw new APIException(EX.API_REQUEST_FAILED,
255
+ `omni_reference 模式仅支持 jimeng-video-seedance-2.0 和 jimeng-video-seedance-2.0-fast 模型`);
256
+ }
257
+
258
+ let requestData: any;
259
+
260
+ if (isOmniMode) {
261
+ // ========== omni_reference 分支 ==========
262
+ logger.info(`进入 omni_reference 全能模式`);
263
+
264
+ // 素材注册表: fieldName → { idx, type, uploadResult }
265
+ interface MaterialEntry {
266
+ idx: number;
267
+ type: "image" | "video";
268
+ fieldName: string;
269
+ originalFilename: string;
270
+ imageUri?: string;
271
+ imageWidth?: number;
272
+ imageHeight?: number;
273
+ imageFormat?: string;
274
+ videoResult?: VideoUploadResult;
275
+ }
276
+ const materialRegistry: Map<string, MaterialEntry> = new Map();
277
+ let materialIdx = 0;
278
+
279
+ // canonical key 集合,防止 originalFilename 覆盖
280
+ const canonicalKeys = new Set<string>();
281
+ canonicalKeys.add('image_file');
282
+ canonicalKeys.add('video_file');
283
+ for (let i = 1; i <= 9; i++) canonicalKeys.add(`image_file_${i}`);
284
+ for (let i = 1; i <= 3; i++) canonicalKeys.add(`video_file_${i}`);
285
+
286
+ // 安全注册别名:originalFilename 不与 canonical key 冲突时才注册
287
+ function registerAlias(filename: string, entry: MaterialEntry) {
288
+ if (!canonicalKeys.has(filename) && !materialRegistry.has(filename)) {
289
+ materialRegistry.set(filename, entry);
290
+ }
291
+ }
292
+
293
+ // 收集所有需要处理的图片和视频字段
294
+ const imageFields: string[] = [];
295
+ const videoFields: string[] = [];
296
+
297
+ // 检测上传的文件
298
+ if (files) {
299
+ for (const fieldName of Object.keys(files)) {
300
+ if (fieldName === 'image_file' || fieldName.startsWith('image_file_')) imageFields.push(fieldName);
301
+ else if (fieldName === 'video_file' || fieldName.startsWith('video_file_')) videoFields.push(fieldName);
302
+ }
303
+ }
304
+
305
+ // 检测URL字段
306
+ for (let i = 1; i <= 9; i++) {
307
+ const fieldName = `image_file_${i}`;
308
+ if (typeof httpRequest?.body?.[fieldName] === 'string' && httpRequest.body[fieldName].startsWith('http')) {
309
+ if (!imageFields.includes(fieldName)) imageFields.push(fieldName);
310
+ }
311
+ }
312
+ for (let i = 1; i <= 3; i++) {
313
+ const fieldName = `video_file_${i}`;
314
+ if (typeof httpRequest?.body?.[fieldName] === 'string' && httpRequest.body[fieldName].startsWith('http')) {
315
+ if (!videoFields.includes(fieldName)) videoFields.push(fieldName);
316
+ }
317
+ }
318
+ // 检测不带数字后缀的裸名 URL 字段
319
+ if (typeof httpRequest?.body?.image_file === 'string' && httpRequest.body.image_file.startsWith('http')) {
320
+ if (!imageFields.includes('image_file')) imageFields.push('image_file');
321
+ }
322
+ if (typeof httpRequest?.body?.video_file === 'string' && httpRequest.body.video_file.startsWith('http')) {
323
+ if (!videoFields.includes('video_file')) videoFields.push('video_file');
324
+ }
325
+
326
+ // 检查是否有素材
327
+ const hasFilePaths = filePaths && filePaths.length > 0;
328
+ if (imageFields.length === 0 && videoFields.length === 0 && !hasFilePaths) {
329
+ throw new APIException(EX.API_REQUEST_FAILED,
330
+ `omni_reference 模式需要至少上传一个素材文件 (image_file_*, video_file_*) 或提供素材URL`);
331
+ }
332
+
333
+ let totalVideoDuration = 0; // 累计视频时长
334
+
335
+ // 串行上传图片素材
336
+ for (const fieldName of imageFields) {
337
+ const imageFile = files?.[fieldName];
338
+ const imageUrlField = httpRequest?.body?.[fieldName];
339
+
340
  try {
341
+ logger.info(`[omni] 上传 ${fieldName}`);
342
+ let imgResult: ImageUploadResult;
343
+
344
+ if (imageFile) {
345
+ // 本地文件上传
346
+ const buf = await fs.readFile(imageFile.filepath);
347
+ imgResult = await uploadImageBuffer(buf, refreshToken, regionInfo);
348
+ await checkImageContent(imgResult.uri, refreshToken, regionInfo);
349
+ const entry: MaterialEntry = {
350
+ idx: materialIdx++,
351
+ type: "image",
352
+ fieldName,
353
+ originalFilename: imageFile.originalFilename,
354
+ imageUri: imgResult.uri,
355
+ imageWidth: imgResult.width,
356
+ imageHeight: imgResult.height,
357
+ imageFormat: imgResult.format,
358
+ };
359
+ materialRegistry.set(fieldName, entry);
360
+ registerAlias(imageFile.originalFilename, entry);
361
+ logger.info(`[omni] ${fieldName} 上传成功: ${imgResult.uri} (${imgResult.width}x${imgResult.height})`);
362
+ } else if (imageUrlField && typeof imageUrlField === 'string' && imageUrlField.startsWith('http')) {
363
+ // URL上传
364
+ imgResult = await uploadImageFromUrl(imageUrlField, refreshToken, regionInfo);
365
+ await checkImageContent(imgResult.uri, refreshToken, regionInfo);
366
+ const entry: MaterialEntry = {
367
+ idx: materialIdx++,
368
+ type: "image",
369
+ fieldName,
370
+ originalFilename: imageUrlField,
371
+ imageUri: imgResult.uri,
372
+ imageWidth: imgResult.width,
373
+ imageHeight: imgResult.height,
374
+ imageFormat: imgResult.format,
375
+ };
376
+ materialRegistry.set(fieldName, entry);
377
+ logger.info(`[omni] ${fieldName} URL上传成功: ${imgResult.uri} (${imgResult.width}x${imgResult.height})`);
378
  }
379
  } catch (error: any) {
380
+ throw new APIException(EX.API_REQUEST_FAILED, `${fieldName} 处理失败: ${error.message}`);
 
 
 
381
  }
382
  }
383
+
384
+ // 通过 filePaths 数组补充未被占用的图片槽位
385
+ if (filePaths && filePaths.length > 0) {
386
+ let slotIndex = 1;
387
+ for (const url of filePaths) {
388
+ // 找到第一个未被占用的槽位
389
+ while (slotIndex <= 9 && materialRegistry.has(`image_file_${slotIndex}`)) {
390
+ slotIndex++;
391
+ }
392
+ if (slotIndex > 9) break; // 已达到最大数量
393
+
394
+ const fieldName = `image_file_${slotIndex}`;
395
+ try {
396
+ logger.info(`[omni] 从URL上传 ${fieldName}: ${url}`);
397
+ const imgResult = await uploadImageFromUrl(url, refreshToken, regionInfo);
398
+ await checkImageContent(imgResult.uri, refreshToken, regionInfo);
399
+ const entry: MaterialEntry = {
400
+ idx: materialIdx++,
401
+ type: "image",
402
+ fieldName,
403
+ originalFilename: url,
404
+ imageUri: imgResult.uri,
405
+ imageWidth: imgResult.width,
406
+ imageHeight: imgResult.height,
407
+ imageFormat: imgResult.format,
408
+ };
409
+ materialRegistry.set(fieldName, entry);
410
+ logger.info(`[omni] ${fieldName} URL上传成功: ${imgResult.uri} (${imgResult.width}x${imgResult.height})`);
411
+ } catch (error: any) {
412
+ throw new APIException(EX.API_REQUEST_FAILED, `${fieldName} URL图片处理失败: ${error.message}`);
413
+ }
414
+ slotIndex++;
415
  }
416
+ }
417
+
418
+ // 串行上传视频素材
419
+ for (const fieldName of videoFields) {
420
+ const videoFile = files?.[fieldName];
421
+ const videoUrlField = httpRequest?.body?.[fieldName];
422
+
423
  try {
424
+ logger.info(`[omni] 上传 ${fieldName}`);
425
+ let vResult: VideoUploadResult;
426
+
427
+ if (videoFile) {
428
+ // 本地文件上传
429
+ const buf = await fs.readFile(videoFile.filepath);
430
+ vResult = await uploadVideoBuffer(buf, refreshToken, regionInfo);
431
+ totalVideoDuration += vResult.videoMeta.duration;
432
+ const entry: MaterialEntry = {
433
+ idx: materialIdx++,
434
+ type: "video",
435
+ fieldName,
436
+ originalFilename: videoFile.originalFilename,
437
+ videoResult: vResult
438
+ };
439
+ materialRegistry.set(fieldName, entry);
440
+ registerAlias(videoFile.originalFilename, entry);
441
+ logger.info(`[omni] ${fieldName} 上传成功: vid=${vResult.vid}, ${vResult.videoMeta.width}x${vResult.videoMeta.height}, ${vResult.videoMeta.duration}s`);
442
+ } else if (videoUrlField && typeof videoUrlField === 'string' && videoUrlField.startsWith('http')) {
443
+ // URL上传
444
+ vResult = await uploadVideoFromUrl(videoUrlField, refreshToken, regionInfo);
445
+ totalVideoDuration += vResult.videoMeta.duration;
446
+ const entry: MaterialEntry = {
447
+ idx: materialIdx++,
448
+ type: "video",
449
+ fieldName,
450
+ originalFilename: videoUrlField,
451
+ videoResult: vResult
452
+ };
453
+ materialRegistry.set(fieldName, entry);
454
+ logger.info(`[omni] ${fieldName} URL上传成功: vid=${vResult.vid}, ${vResult.videoMeta.width}x${vResult.videoMeta.height}, ${vResult.videoMeta.duration}s`);
455
  }
456
  } catch (error: any) {
457
+ throw new APIException(EX.API_REQUEST_FAILED, `${fieldName} 处理失败: ${error.message}`);
 
 
 
458
  }
459
  }
 
 
 
460
 
461
+ // 验证视频总时长
462
+ const MAX_TOTAL_VIDEO_DURATION = 15;
463
+ if (!Number.isFinite(totalVideoDuration)) {
464
+ throw new APIException(EX.API_REQUEST_FAILED,
465
+ `视频时长数据异常,请检查视频文件`);
466
+ }
467
+ if (totalVideoDuration > MAX_TOTAL_VIDEO_DURATION) {
468
+ throw new APIException(EX.API_REQUEST_FAILED,
469
+ `视频总时长 ${totalVideoDuration.toFixed(2)}s 超过限制 (最大 ${MAX_TOTAL_VIDEO_DURATION}s)`);
 
 
 
 
 
 
 
 
 
470
  }
471
 
472
+ logger.info(`[omni] 视频总时长: ${totalVideoDuration.toFixed(2)}s`);
473
+
474
+ // 构建 material_list(按注册顺序)
475
+ const orderedEntries = [...new Map([...materialRegistry].filter(([k, v]) => k === v.fieldName)).values()]
476
+ .sort((a, b) => a.idx - b.idx);
477
+
478
+ const material_list: any[] = [];
479
+ const materialTypes: number[] = [];
480
+
481
+ for (const entry of orderedEntries) {
482
+ if (entry.type === "image") {
483
+ material_list.push({
484
+ type: "",
485
+ id: util.uuid(),
486
+ material_type: "image",
487
+ image_info: {
488
+ type: "image",
489
+ id: util.uuid(),
490
+ source_from: "upload",
491
+ platform_type: 1,
492
+ name: "",
493
+ image_uri: entry.imageUri,
494
+ width: entry.imageWidth || 0,
495
+ height: entry.imageHeight || 0,
496
+ format: entry.imageFormat || "",
497
+ uri: entry.imageUri,
498
+ },
499
+ });
500
+ materialTypes.push(1);
501
+ } else {
502
+ const vm = entry.videoResult!;
503
+ material_list.push({
504
+ type: "",
505
+ id: util.uuid(),
506
+ material_type: "video",
507
+ video_info: {
508
+ type: "video",
509
+ id: util.uuid(),
510
+ source_from: "upload",
511
+ name: "",
512
+ vid: vm.vid,
513
+ fps: 0,
514
+ width: vm.videoMeta.width,
515
+ height: vm.videoMeta.height,
516
+ duration: Math.round(vm.videoMeta.duration * 1000),
517
+ },
518
+ });
519
+ materialTypes.push(2);
520
+ }
521
  }
 
522
 
523
+ // 解析 prompt → meta_list
524
+ const meta_list = parseOmniPrompt(prompt, materialRegistry);
525
+
526
+ logger.info(`[omni] material_list: ${material_list.length} 项, meta_list: ${meta_list.length} 项, materialTypes: [${materialTypes}]`);
527
+
528
+ // 构建 omni payload
529
+ const componentId = util.uuid();
530
+ const submitId = util.uuid();
531
+
532
+ const sceneOption = {
533
+ type: "video",
534
+ scene: "BasicVideoGenerateButton",
535
+ modelReqKey: model,
536
+ videoDuration: actualDuration,
537
+ materialTypes,
538
+ reportParams: {
539
+ enterSource: "generate",
540
+ vipSource: "generate",
541
+ extraVipFunctionKey: model,
542
+ useVipFunctionDetailsReporterHoc: true,
543
+ },
544
+ };
545
 
546
+ const metricsExtra = JSON.stringify({
547
+ position: "page_bottom_box",
548
+ isDefaultSeed: 1,
549
+ originSubmitId: submitId,
550
+ isRegenerate: false,
551
+ enterFrom: "click",
552
+ functionMode: "omni_reference",
553
+ sceneOptions: JSON.stringify([sceneOption]),
554
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
 
556
+ // 根据模型选择 benefit_type
557
+ const omniBenefitType = is40 ? OMNI_BENEFIT_TYPE_FAST : OMNI_BENEFIT_TYPE;
 
 
 
558
 
559
+ requestData = {
560
+ params: {
561
+ aigc_features: "app_lip_sync",
562
+ web_version: "7.5.0",
563
+ da_version: DRAFT_VERSION_OMNI,
564
+ },
565
+ data: {
566
+ extend: {
567
+ root_model: model,
568
+ m_video_commerce_info: {
569
+ benefit_type: omniBenefitType,
570
+ resource_id: "generate_video",
571
+ resource_id_type: "str",
572
+ resource_sub_type: "aigc",
573
+ },
574
+ m_video_commerce_info_list: [{
575
+ benefit_type: omniBenefitType,
576
+ resource_id: "generate_video",
577
+ resource_id_type: "str",
578
+ resource_sub_type: "aigc",
579
+ }],
580
+ },
581
+ submit_id: submitId,
582
+ metrics_extra: metricsExtra,
583
+ draft_content: JSON.stringify({
584
+ type: "draft",
585
+ id: util.uuid(),
586
+ min_version: DRAFT_VERSION_OMNI,
587
+ min_features: ["AIGC_Video_UnifiedEdit"],
588
+ is_from_tsn: true,
589
+ version: DRAFT_VERSION_OMNI,
590
+ main_component_id: componentId,
591
+ component_list: [{
592
+ type: "video_base_component",
593
+ id: componentId,
594
+ min_version: "1.0.0",
595
+ aigc_mode: "workbench",
596
+ metadata: {
597
+ type: "",
598
+ id: util.uuid(),
599
+ created_platform: 3,
600
+ created_platform_version: "",
601
+ created_time_in_ms: Date.now().toString(),
602
+ created_did: "",
603
+ },
604
+ generate_type: "gen_video",
605
+ abilities: {
606
+ type: "",
607
+ id: util.uuid(),
608
+ gen_video: {
609
+ id: util.uuid(),
610
+ type: "",
611
+ text_to_video_params: {
612
+ type: "",
613
+ id: util.uuid(),
614
+ video_gen_inputs: [{
615
+ type: "",
616
+ id: util.uuid(),
617
+ min_version: DRAFT_VERSION_OMNI,
618
+ prompt: "",
619
+ video_mode: 2,
620
+ fps: 24,
621
+ duration_ms: durationMs,
622
+ unified_edit_input: {
623
+ type: "",
624
+ id: util.uuid(),
625
+ material_list,
626
+ meta_list,
627
+ },
628
+ idip_meta_list: [],
629
+ }],
630
+ video_aspect_ratio: ratio,
631
+ seed: Math.floor(Math.random() * 4294967296),
632
+ model_req_key: model,
633
+ priority: 0,
634
+ },
635
+ video_task_extra: metricsExtra,
636
+ },
637
+ },
638
+ process_type: 1,
639
+ }],
640
+ }),
641
+ http_common_info: {
642
+ aid: getAssistantId(regionInfo),
643
+ },
644
+ },
645
+ };
646
+ } else {
647
+ // ========== first_last_frames 分支(原有逻辑) ==========
648
+ let first_frame_image = undefined;
649
+ let end_frame_image = undefined;
650
+ let uploadIDs: string[] = [];
651
+
652
+ // 优先处理本地上传的文件
653
+ const uploadedFiles = _.values(files);
654
+ if (uploadedFiles && uploadedFiles.length > 0) {
655
+ logger.info(`检测到 ${uploadedFiles.length} 个本地上传文件,优先处理`);
656
+ for (let i = 0; i < uploadedFiles.length; i++) {
657
+ const file = uploadedFiles[i];
658
+ if (!file) continue;
659
+ try {
660
+ logger.info(`开始上传第 ${i + 1} 张本地图片: ${file.originalFilename}`);
661
+ const imgResult = await uploadImageFromFile(file, refreshToken, regionInfo);
662
+ if (imgResult) {
663
+ await checkImageContent(imgResult.uri, refreshToken, regionInfo);
664
+ uploadIDs.push(imgResult.uri);
665
+ logger.info(`第 ${i + 1} 张本地图片上传成功: ${imgResult.uri}`);
666
+ } else {
667
+ logger.error(`第 ${i + 1} 张本地图片上传失败: 未获取到 image_uri`);
668
+ }
669
+ } catch (error: any) {
670
+ logger.error(`第 ${i + 1} 张本地图片上传失败: ${error.message}`);
671
+ if (i === 0) {
672
+ throw new APIException(EX.API_REQUEST_FAILED, `首帧图片上传失败: ${error.message}`);
673
+ }
674
+ }
675
+ }
676
+ } else if (filePaths && filePaths.length > 0) {
677
+ logger.info(`未检测到本地上传文件,处理 ${filePaths.length} 个图片URL`);
678
+ for (let i = 0; i < filePaths.length; i++) {
679
+ const filePath = filePaths[i];
680
+ if (!filePath) {
681
+ logger.warn(`第 ${i + 1} 个图片URL为空,跳过`);
682
+ continue;
683
+ }
684
+ try {
685
+ logger.info(`开始上传第 ${i + 1} 个URL图片: ${filePath}`);
686
+ const imgResult = await uploadImageFromUrl(filePath, refreshToken, regionInfo);
687
+ if (imgResult) {
688
+ await checkImageContent(imgResult.uri, refreshToken, regionInfo);
689
+ uploadIDs.push(imgResult.uri);
690
+ logger.info(`第 ${i + 1} 个URL图片上传成功: ${imgResult.uri}`);
691
+ } else {
692
+ logger.error(`第 ${i + 1} 个URL图片上传失败: 未获取到 image_uri`);
693
+ }
694
+ } catch (error: any) {
695
+ logger.error(`第 ${i + 1} 个URL图片上传失败: ${error.message}`);
696
+ if (i === 0) {
697
+ throw new APIException(EX.API_REQUEST_FAILED, `首帧图片上传失败: ${error.message}`);
698
+ }
699
+ }
700
+ }
701
+ } else {
702
+ logger.info(`未提供图片文件或URL,将进行纯文本视频生成`);
703
+ }
704
+
705
+ if (uploadIDs.length > 0) {
706
+ logger.info(`图片上传完成,共成功 ${uploadIDs.length} 张`);
707
+ if (uploadIDs[0]) {
708
+ first_frame_image = {
709
+ format: "", height: 0, id: util.uuid(), image_uri: uploadIDs[0],
710
+ name: "", platform_type: 1, source_from: "upload", type: "image", uri: uploadIDs[0], width: 0,
711
+ };
712
+ logger.info(`设置首帧图片: ${uploadIDs[0]}`);
713
+ }
714
+ if (uploadIDs[1]) {
715
+ end_frame_image = {
716
+ format: "", height: 0, id: util.uuid(), image_uri: uploadIDs[1],
717
+ name: "", platform_type: 1, source_from: "upload", type: "image", uri: uploadIDs[1], width: 0,
718
+ };
719
+ logger.info(`设置尾帧图片: ${uploadIDs[1]}`);
720
+ }
721
+ }
722
+
723
+ const componentId = util.uuid();
724
+ const originSubmitId = util.uuid();
725
+ const flFunctionMode = "first_last_frames";
726
+
727
+ const sceneOption = {
728
+ type: "video",
729
+ scene: "BasicVideoGenerateButton",
730
+ ...(supportsResolution ? { resolution } : {}),
731
+ modelReqKey: model,
732
+ videoDuration: actualDuration,
733
+ reportParams: {
734
+ enterSource: "generate",
735
+ vipSource: "generate",
736
+ extraVipFunctionKey: supportsResolution ? `${model}-${resolution}` : model,
737
+ useVipFunctionDetailsReporterHoc: true,
738
+ },
739
+ };
740
+
741
+ const metricsExtra = JSON.stringify({
742
+ promptSource: "custom",
743
+ isDefaultSeed: 1,
744
+ originSubmitId,
745
+ isRegenerate: false,
746
+ enterFrom: "click",
747
+ functionMode: flFunctionMode,
748
+ sceneOptions: JSON.stringify([sceneOption]),
749
+ });
750
+
751
+ const hasImageInput = uploadIDs.length > 0;
752
+ if (hasImageInput && ratio !== "1:1") {
753
+ logger.warn(`图生视频模式下,ratio参数将被忽略(由输入图片的实际比例决定),但resolution参数仍然有效`);
754
+ }
755
+
756
+ logger.info(`视频生成模式: ${uploadIDs.length}张图片 (首帧: ${!!first_frame_image}, 尾帧: ${!!end_frame_image}), resolution: ${resolution}`);
757
+
758
+ requestData = {
759
  params: {
760
  aigc_features: "app_lip_sync",
761
  web_version: "7.5.0",
762
  da_version: DRAFT_VERSION,
763
  },
764
  data: {
765
+ extend: {
766
+ root_model: model,
767
+ m_video_commerce_info: {
768
  benefit_type: getVideoBenefitType(model),
769
  resource_id: "generate_video",
770
  resource_id_type: "str",
771
+ resource_sub_type: "aigc",
772
  },
773
+ m_video_commerce_info_list: [{
774
  benefit_type: getVideoBenefitType(model),
775
  resource_id: "generate_video",
776
  resource_id_type: "str",
777
+ resource_sub_type: "aigc",
778
+ }],
779
  },
780
+ submit_id: util.uuid(),
781
+ metrics_extra: metricsExtra,
782
+ draft_content: JSON.stringify({
783
+ type: "draft",
784
+ id: util.uuid(),
785
+ min_version: "3.0.5",
786
+ min_features: [],
787
+ is_from_tsn: true,
788
+ version: DRAFT_VERSION,
789
+ main_component_id: componentId,
790
+ component_list: [{
791
+ type: "video_base_component",
792
+ id: componentId,
793
+ min_version: "1.0.0",
794
+ aigc_mode: "workbench",
795
+ metadata: {
796
+ type: "",
797
+ id: util.uuid(),
798
+ created_platform: 3,
799
+ created_platform_version: "",
800
+ created_time_in_ms: Date.now().toString(),
801
+ created_did: "",
802
  },
803
+ generate_type: "gen_video",
804
+ abilities: {
805
+ type: "",
806
+ id: util.uuid(),
807
+ gen_video: {
808
+ id: util.uuid(),
809
+ type: "",
810
+ text_to_video_params: {
811
+ type: "",
812
+ id: util.uuid(),
813
+ video_gen_inputs: [{
814
+ type: "",
815
+ id: util.uuid(),
816
+ min_version: "3.0.5",
817
+ prompt,
818
+ video_mode: 2,
819
+ fps: 24,
820
+ duration_ms: durationMs,
821
+ ...(supportsResolution ? { resolution } : {}),
822
+ first_frame_image,
823
+ end_frame_image,
824
+ idip_meta_list: [],
825
  }],
826
+ video_aspect_ratio: ratio,
827
+ seed: Math.floor(Math.random() * 4294967296),
828
+ model_req_key: model,
829
+ priority: 0,
830
  },
831
+ video_task_extra: metricsExtra,
832
+ },
833
  },
834
+ process_type: 1,
835
  }],
836
  }),
837
  http_common_info: {
838
+ aid: getAssistantId(regionInfo),
839
  },
840
  },
841
+ };
842
+ }
843
+
844
+ // 发送请求
845
+ const videoReferer = regionInfo.isCN
846
+ ? "https://jimeng.jianying.com/ai-tool/generate?type=video"
847
+ : "https://dreamina.capcut.com/ai-tool/generate?type=video";
848
+ const { aigc_data } = await request(
849
+ "post",
850
+ "/mweb/v1/aigc_draft/generate",
851
+ refreshToken,
852
+ {
853
+ ...requestData,
854
+ headers: { Referer: videoReferer },
855
  }
856
  );
857
 
 
870
 
871
  const poller = new SmartPoller({
872
  maxPollCount,
873
+ pollInterval: 20000, // 20秒基础间隔
874
  expectedItemCount: 1,
875
  type: 'video',
876
+ timeoutSeconds: 3600 // 60分钟超时
877
  });
878
 
879
  const { result: pollingResult, data: finalHistoryData } = await poller.poll(async () => {
 
886
  },
887
  });
888
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
  // 检查响应中是否有该 history_id 的数据
890
  // 由于 API 存在最终一致性,早期轮询可能暂时获取不到记录,返回处理中状态继续轮询
891
  if (!result[historyId]) {
 
932
 
933
  const item_list = finalHistoryData.item_list || [];
934
 
935
+ // 尝试通过 get_local_item_list 获高质量视频下载URL
936
+ const itemId = item_list?.[0]?.item_id
937
+ || item_list?.[0]?.id
938
+ || item_list?.[0]?.local_item_id
939
+ || item_list?.[0]?.common_attr?.id;
940
+
941
+ if (itemId) {
942
+ try {
943
+ const hqVideoUrl = await fetchHighQualityVideoUrl(String(itemId), refreshToken);
944
+ if (hqVideoUrl) {
945
+ logger.info(`视频生成成功(高质量),URL: ${hqVideoUrl},总耗时: ${pollingResult.elapsedTime}秒`);
946
+ return hqVideoUrl;
947
+ }
948
+ } catch (error) {
949
+ logger.warn(`获取高质量视频URL失败,将使用预览URL作为回退: ${error.message}`);
950
+ }
951
+ } else {
952
+ logger.warn(`未能从item_list中提取item_id,将使用预览URL。item_list[0]键: ${item_list?.[0] ? Object.keys(item_list[0]).join(', ') : '无'}`);
953
+ }
954
+
955
+ // 回退:提取预览视频URL
956
+ let fallbackVideoUrl = item_list?.[0] ? extractVideoUrl(item_list[0]) : null;
957
 
958
  // 如果无法获取视频URL,抛出异常
959
+ if (!fallbackVideoUrl) {
960
  logger.error(`未能获取视频URL,item_list: ${JSON.stringify(item_list)}`);
961
  throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "未能获取视频URL,请稍后查看");
962
  }
963
 
964
+ logger.info(`视频生成成功,URL: ${fallbackVideoUrl},总耗时: ${pollingResult.elapsedTime}秒`);
965
+ return fallbackVideoUrl;
966
  }
src/api/routes/videos.ts CHANGED
@@ -21,28 +21,119 @@ export default {
21
  .validate('body.prompt', _.isString)
22
  .validate('body.ratio', v => _.isUndefined(v) || _.isString(v))
23
  .validate('body.resolution', v => _.isUndefined(v) || _.isString(v))
24
- .validate('body.duration', v => {
25
- if (_.isUndefined(v)) return true;
26
- // 支持的时长: 4/8/12 (sora2)、5/10 (其他模型)、15 (4.0模型)
27
- const validDurations = [4, 5, 8, 10, 12, 15];
28
- // 对于 multipart/form-data,允许字符串类型的数字
29
- if (isMultiPart && typeof v === 'string') {
30
- const num = parseInt(v);
31
- return validDurations.includes(num);
32
- }
33
- // 对于 JSON,要求数字类型
34
- return _.isFinite(v) && validDurations.includes(v);
35
- })
36
- // 限制图片URL数量最多2个
37
- .validate('body.file_paths', v => _.isUndefined(v) || (_.isArray(v) && v.length <= 2))
38
- .validate('body.filePaths', v => _.isUndefined(v) || (_.isArray(v) && v.length <= 2))
39
  .validate('body.response_format', v => _.isUndefined(v) || _.isString(v))
40
  .validate('headers.authorization', _.isString);
41
 
42
- // 限制上传文件数量最多2个
43
- const uploadedFiles = request.files ? _.values(request.files) : [];
44
- if (uploadedFiles.length > 2) {
45
- throw new Error('最多只能上传2个图片文件');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  }
47
 
48
  // refresh_token切分
@@ -70,7 +161,7 @@ export default {
70
  const finalFilePaths = filePaths.length > 0 ? filePaths : file_paths;
71
 
72
  // 生成视频
73
- const videoUrl = await generateVideo(
74
  model,
75
  prompt,
76
  {
@@ -79,6 +170,8 @@ export default {
79
  duration: finalDuration,
80
  filePaths: finalFilePaths,
81
  files: request.files, // 传递上传的文件
 
 
82
  },
83
  token
84
  );
@@ -86,7 +179,7 @@ export default {
86
  // 根据response_format返回不同格式的结果
87
  if (response_format === "b64_json") {
88
  // 获取视频内容并转换为BASE64
89
- const videoBase64 = await util.fetchFileBASE64(videoUrl);
90
  return {
91
  created: util.unixTimestamp(),
92
  data: [{
@@ -99,7 +192,7 @@ export default {
99
  return {
100
  created: util.unixTimestamp(),
101
  data: [{
102
- url: videoUrl,
103
  revised_prompt: prompt
104
  }]
105
  };
 
21
  .validate('body.prompt', _.isString)
22
  .validate('body.ratio', v => _.isUndefined(v) || _.isString(v))
23
  .validate('body.resolution', v => _.isUndefined(v) || _.isString(v))
24
+ .validate('body.functionMode', v => _.isUndefined(v) || (_.isString(v) && ['first_last_frames', 'omni_reference'].includes(v)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  .validate('body.response_format', v => _.isUndefined(v) || _.isString(v))
26
  .validate('headers.authorization', _.isString);
27
 
28
+ const functionMode = request.body.functionMode || 'first_last_frames';
29
+ const isOmniMode = functionMode === 'omni_reference';
30
+
31
+ // 验证 duration(根据模型)
32
+ if (!_.isUndefined(request.body.duration)) {
33
+ const modelName = request.body.model || DEFAULT_MODEL;
34
+ let durationValue: number;
35
+ if (isMultiPart && typeof request.body.duration === 'string') {
36
+ durationValue = parseInt(request.body.duration, 10);
37
+ // 严格检查 parseInt 结果
38
+ if (!Number.isInteger(durationValue) || request.body.duration.trim() !== String(durationValue)) {
39
+ throw new Error(`duration 必须是整数,当前值: ${request.body.duration}`);
40
+ }
41
+ } else if (_.isFinite(request.body.duration)) {
42
+ durationValue = request.body.duration as number;
43
+ if (!Number.isInteger(durationValue)) {
44
+ throw new Error(`duration 必须是整数,当前值: ${durationValue}`);
45
+ }
46
+ } else {
47
+ throw new Error(`duration 参数格式错误`);
48
+ }
49
+
50
+ // 根据模型验证 duration 有效值
51
+ let validDurations: number[] = [];
52
+ let errorMessage = '';
53
+
54
+ if (modelName.includes('veo3.1') || modelName.includes('veo3')) {
55
+ validDurations = [8];
56
+ errorMessage = 'veo3 模型仅支持 8 秒时长';
57
+ } else if (modelName.includes('sora2')) {
58
+ validDurations = [4, 8, 12];
59
+ errorMessage = 'sora2 模型仅支持 4、8、12 秒时长';
60
+ } else if (modelName.includes('3.5-pro') || modelName.includes('3.5_pro')) {
61
+ validDurations = [5, 10, 12];
62
+ errorMessage = '3.5-pro 模型仅支持 5、10、12 秒时长';
63
+ } else if (modelName.includes('seedance-2.0') || modelName.includes('40_pro') || modelName.includes('40-pro') || modelName.includes('seedance-2.0-fast')) {
64
+ // seedance 2.0 和 2.0-fast 支持 4~15 秒任意整数
65
+ if (durationValue < 4 || durationValue > 15) {
66
+ throw new Error(`seedance 2.0/2.0-fast 模型支持 4~15 秒时长,当前值: ${durationValue}`);
67
+ }
68
+ } else {
69
+ // 其他模型支持 5 或 10 秒
70
+ validDurations = [5, 10];
71
+ errorMessage = '该模型仅支持 5、10 秒时长';
72
+ }
73
+
74
+ // 检查是否在有效值列表中
75
+ if (validDurations.length > 0 && !validDurations.includes(durationValue)) {
76
+ throw new Error(`${errorMessage},当前值: ${durationValue}`);
77
+ }
78
+ }
79
+
80
+ // 验证 file_paths 和 filePaths
81
+ request
82
+ .validate('body.file_paths', v => _.isUndefined(v) || (_.isArray(v) && v.length <= 2))
83
+ .validate('body.filePaths', v => _.isUndefined(v) || (_.isArray(v) && v.length <= 2));
84
+
85
+ if (isOmniMode) {
86
+ // 全能模式验证逻辑
87
+ const uploadedFiles = request.files || {};
88
+
89
+ // 统计各类型文件数量
90
+ let imageCount = 0;
91
+ let videoCount = 0;
92
+
93
+ // 统计上传的文件
94
+ for (const fieldName of Object.keys(uploadedFiles)) {
95
+ if (fieldName.startsWith('image_file_')) imageCount++;
96
+ else if (fieldName.startsWith('video_file_')) videoCount++;
97
+ }
98
+
99
+ // 统计URL字段
100
+ for (let i = 1; i <= 9; i++) {
101
+ const fieldName = `image_file_${i}`;
102
+ if (typeof request.body[fieldName] === 'string' && request.body[fieldName].startsWith('http')) {
103
+ imageCount++;
104
+ }
105
+ }
106
+ for (let i = 1; i <= 3; i++) {
107
+ const fieldName = `video_file_${i}`;
108
+ if (typeof request.body[fieldName] === 'string' && request.body[fieldName].startsWith('http')) {
109
+ videoCount++;
110
+ }
111
+ }
112
+
113
+ // 验证数量限制
114
+ if (imageCount > 9) {
115
+ throw new Error('全能模式最多上传9张图片');
116
+ }
117
+ if (videoCount > 3) {
118
+ throw new Error('全能模式最多上传3个视频');
119
+ }
120
+
121
+ const totalCount = imageCount + videoCount;
122
+ if (totalCount > 12) {
123
+ throw new Error('全能模式图片+视频总数不超过12个');
124
+ }
125
+ if (totalCount === 0) {
126
+ const hasFilePaths = (request.body.filePaths?.length > 0) || (request.body.file_paths?.length > 0);
127
+ if (!hasFilePaths) {
128
+ throw new Error('全能模式至少需要上传1个素材文件(图片或视频)');
129
+ }
130
+ }
131
+ } else {
132
+ // 普通模式验证逻辑(保持原有逻辑)
133
+ const uploadedFiles = request.files ? _.values(request.files) : [];
134
+ if (uploadedFiles.length > 2) {
135
+ throw new Error('最多只能上传2个图片文件');
136
+ }
137
  }
138
 
139
  // refresh_token切分
 
161
  const finalFilePaths = filePaths.length > 0 ? filePaths : file_paths;
162
 
163
  // 生成视频
164
+ const generatedVideoUrl = await generateVideo(
165
  model,
166
  prompt,
167
  {
 
170
  duration: finalDuration,
171
  filePaths: finalFilePaths,
172
  files: request.files, // 传递上传的文件
173
+ httpRequest: request, // 传递完整的 request 对象以访问动态字段
174
+ functionMode,
175
  },
176
  token
177
  );
 
179
  // 根据response_format返回不同格式的结果
180
  if (response_format === "b64_json") {
181
  // 获取视频内容并转换为BASE64
182
+ const videoBase64 = await util.fetchFileBASE64(generatedVideoUrl);
183
  return {
184
  created: util.unixTimestamp(),
185
  data: [{
 
192
  return {
193
  created: util.unixTimestamp(),
194
  data: [{
195
+ url: generatedVideoUrl,
196
  revised_prompt: prompt
197
  }]
198
  };
src/lib/aws-signature.ts CHANGED
@@ -12,7 +12,8 @@ export function createSignature(
12
  secretAccessKey: string,
13
  sessionToken?: string,
14
  payload: string = '',
15
- region: string = 'cn-north-1'
 
16
  ) {
17
  const urlObj = new URL(url);
18
  const pathname = urlObj.pathname || '/';
@@ -21,7 +22,6 @@ export function createSignature(
21
  // 创建规范请求
22
  const timestamp = headers['x-amz-date'];
23
  const date = timestamp.substr(0, 8);
24
- const service = 'imagex';
25
 
26
  // 规范化查询参数
27
  const queryParams: Array<[string, string]> = [];
 
12
  secretAccessKey: string,
13
  sessionToken?: string,
14
  payload: string = '',
15
+ region: string = 'cn-north-1',
16
+ service: string = 'imagex'
17
  ) {
18
  const urlObj = new URL(url);
19
  const pathname = urlObj.pathname || '/';
 
22
  // 创建规范请求
23
  const timestamp = headers['x-amz-date'];
24
  const date = timestamp.substr(0, 8);
 
25
 
26
  // 规范化查询参数
27
  const queryParams: Array<[string, string]> = [];
src/lib/image-uploader.ts CHANGED
@@ -11,18 +11,28 @@ import util from "@/lib/util.ts";
11
  * 整合了images.ts和videos.ts中重复的上传逻辑
12
  */
13
 
 
 
 
 
 
 
 
 
 
 
14
  /**
15
  * 上传图片Buffer到ImageX
16
  * @param imageBuffer 图片数据
17
  * @param refreshToken 刷新令牌
18
  * @param regionInfo 区域信息
19
- * @returns 图片URI
20
  */
21
  export async function uploadImageBuffer(
22
  imageBuffer: ArrayBuffer | Buffer,
23
  refreshToken: string,
24
  regionInfo: RegionInfo
25
- ): Promise<string> {
26
  try {
27
  logger.info(`开始上传图片Buffer... (isInternational: ${regionInfo.isInternational})`);
28
 
@@ -230,9 +240,16 @@ export async function uploadImageBuffer(
230
  }
231
 
232
  const fullImageUri = uploadResult.Uri;
233
- logger.info(`图片上传完成: ${fullImageUri}`);
234
 
235
- return fullImageUri;
 
 
 
 
 
 
 
 
236
  } catch (error: any) {
237
  logger.error(`图片Buffer上传失败: ${error.message}`);
238
  throw error;
@@ -244,13 +261,13 @@ export async function uploadImageBuffer(
244
  * @param imageUrl 图片URL
245
  * @param refreshToken 刷新令牌
246
  * @param regionInfo 区域信息
247
- * @returns 图片URI
248
  */
249
  export async function uploadImageFromUrl(
250
  imageUrl: string,
251
  refreshToken: string,
252
  regionInfo: RegionInfo
253
- ): Promise<string> {
254
  try {
255
  logger.info(`开始从URL下载并上传图片: ${imageUrl}`);
256
 
 
11
  * 整合了images.ts和videos.ts中重复的上传逻辑
12
  */
13
 
14
+ /**
15
+ * 图片上传结果
16
+ */
17
+ export interface ImageUploadResult {
18
+ uri: string;
19
+ width: number;
20
+ height: number;
21
+ format: string;
22
+ }
23
+
24
  /**
25
  * 上传图片Buffer到ImageX
26
  * @param imageBuffer 图片数据
27
  * @param refreshToken 刷新令牌
28
  * @param regionInfo 区域信息
29
+ * @returns 图片上传结果(URI + 元信息)
30
  */
31
  export async function uploadImageBuffer(
32
  imageBuffer: ArrayBuffer | Buffer,
33
  refreshToken: string,
34
  regionInfo: RegionInfo
35
+ ): Promise<ImageUploadResult> {
36
  try {
37
  logger.info(`开始上传图片Buffer... (isInternational: ${regionInfo.isInternational})`);
38
 
 
240
  }
241
 
242
  const fullImageUri = uploadResult.Uri;
 
243
 
244
+ // 从 PluginResult 提取图片元信息
245
+ const pluginResult = commitResult.Result?.PluginResult?.[0];
246
+ const width = pluginResult?.ImageWidth || 0;
247
+ const height = pluginResult?.ImageHeight || 0;
248
+ const format = pluginResult?.ImageFormat || "";
249
+
250
+ logger.info(`图片上传完成: ${fullImageUri} (${width}x${height}, ${format})`);
251
+
252
+ return { uri: fullImageUri, width, height, format };
253
  } catch (error: any) {
254
  logger.error(`图片Buffer上传失败: ${error.message}`);
255
  throw error;
 
261
  * @param imageUrl 图片URL
262
  * @param refreshToken 刷新令牌
263
  * @param regionInfo 区域信息
264
+ * @returns 图片上传结果(URI + 元信息)
265
  */
266
  export async function uploadImageFromUrl(
267
  imageUrl: string,
268
  refreshToken: string,
269
  regionInfo: RegionInfo
270
+ ): Promise<ImageUploadResult> {
271
  try {
272
  logger.info(`开始从URL下载并上传图片: ${imageUrl}`);
273
 
src/lib/image-utils.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import logger from "@/lib/logger.ts";
2
 
3
  /**
@@ -64,3 +65,74 @@ export function extractVideoUrl(item: any): string | null {
64
 
65
  return null;
66
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { request } from "@/api/controllers/core.ts";
2
  import logger from "@/lib/logger.ts";
3
 
4
  /**
 
65
 
66
  return null;
67
  }
68
+
69
+ /**
70
+ * 通过 get_local_item_list API 获取高质量视频下载URL
71
+ * 浏览器下载视频时使用此API获取高码率版本(~6297 vs 预览版 ~1152)
72
+ *
73
+ * @param itemId 视频项目ID
74
+ * @param refreshToken 刷新令牌
75
+ * @returns 高质量视频URL,失败时返回 null
76
+ */
77
+ export async function fetchHighQualityVideoUrl(itemId: string, refreshToken: string): Promise<string | null> {
78
+ try {
79
+ logger.info(`尝试获取高质量视频下载URL,item_id: ${itemId}`);
80
+
81
+ const result = await request("post", "/mweb/v1/get_local_item_list", refreshToken, {
82
+ data: {
83
+ item_id_list: [itemId],
84
+ pack_item_opt: {
85
+ scene: 1,
86
+ need_data_integrity: true,
87
+ },
88
+ is_for_video_download: true,
89
+ },
90
+ });
91
+
92
+ const responseStr = JSON.stringify(result);
93
+ logger.info(`get_local_item_list 响应大小: ${responseStr.length} 字符`);
94
+
95
+ // 策略1: 从结构化字段中提取视频URL
96
+ const itemList = result.item_list || result.local_item_list || [];
97
+ if (itemList.length > 0) {
98
+ const item = itemList[0];
99
+ const videoUrl =
100
+ item?.video?.transcoded_video?.origin?.video_url ||
101
+ item?.video?.download_url ||
102
+ item?.video?.play_url ||
103
+ item?.video?.url;
104
+
105
+ if (videoUrl) {
106
+ logger.info(`从get_local_item_list结构化字段获取到高清视频URL: ${videoUrl}`);
107
+ return videoUrl;
108
+ }
109
+ }
110
+
111
+ // 策略2: 正则匹配 dreamnia.jimeng.com 高质量URL
112
+ const hqUrlMatch = responseStr.match(/https:\/\/v[0-9]+-dreamnia\.jimeng\.com\/[^"\s\\]+/);
113
+ if (hqUrlMatch && hqUrlMatch[0]) {
114
+ logger.info(`正则提取到高质量视频URL (dreamnia): ${hqUrlMatch[0]}`);
115
+ return hqUrlMatch[0];
116
+ }
117
+
118
+ // 策略3: 匹配任何 jimeng.com 域名的视频URL
119
+ const jimengUrlMatch = responseStr.match(/https:\/\/v[0-9]+-[^"\\]*\.jimeng\.com\/[^"\s\\]+/);
120
+ if (jimengUrlMatch && jimengUrlMatch[0]) {
121
+ logger.info(`正则提取到jimeng视频URL: ${jimengUrlMatch[0]}`);
122
+ return jimengUrlMatch[0];
123
+ }
124
+
125
+ // 策略4: 匹配任何视频URL(兜底)
126
+ const anyVideoUrlMatch = responseStr.match(/https:\/\/v[0-9]+-[^"\\]*\.(vlabvod|jimeng)\.com\/[^"\s\\]+/);
127
+ if (anyVideoUrlMatch && anyVideoUrlMatch[0]) {
128
+ logger.info(`从get_local_item_list提取到视频URL: ${anyVideoUrlMatch[0]}`);
129
+ return anyVideoUrlMatch[0];
130
+ }
131
+
132
+ logger.warn(`未能从get_local_item_list响应中提取到视频URL`);
133
+ return null;
134
+ } catch (error) {
135
+ logger.warn(`获取高质量视频下载URL失败: ${error.message}`);
136
+ return null;
137
+ }
138
+ }
src/lib/smart-poller.ts CHANGED
@@ -227,7 +227,8 @@ export class SmartPoller {
227
  error.message?.includes('timeout') ||
228
  error.message?.includes('network') ||
229
  error.message?.includes('ECONNRESET') ||
230
- error.message?.includes('socket hang up');
 
231
 
232
  // 网络错误时进行轮询级别的重试,而不是直接中断整个流程
233
  if (isRetryableError && this.pollCount < this.options.maxPollCount) {
 
227
  error.message?.includes('timeout') ||
228
  error.message?.includes('network') ||
229
  error.message?.includes('ECONNRESET') ||
230
+ error.message?.includes('socket hang up') ||
231
+ error.message?.includes('Proxy connection');
232
 
233
  // 网络错误时进行轮询级别的重试,而不是直接中断整个流程
234
  if (isRetryableError && this.pollCount < this.options.maxPollCount) {
src/lib/util.ts CHANGED
@@ -7,7 +7,7 @@ import "colors";
7
  import mime from "mime";
8
  import axios from "axios";
9
  import fs from "fs-extra";
10
- import { v1 as uuid } from "uuid";
11
  import { format as dateFormat } from "date-fns";
12
  import CRC32 from "crc-32";
13
  import randomstring from "randomstring";
 
7
  import mime from "mime";
8
  import axios from "axios";
9
  import fs from "fs-extra";
10
+ import { v4 as uuid } from "uuid";
11
  import { format as dateFormat } from "date-fns";
12
  import CRC32 from "crc-32";
13
  import randomstring from "randomstring";
src/lib/video-uploader.ts ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from "crypto";
2
+ import axios from "axios";
3
+ import { RegionInfo, request } from "@/api/controllers/core.ts";
4
+ import { RegionUtils } from "@/lib/region-utils.ts";
5
+ import { createSignature } from "@/lib/aws-signature.ts";
6
+ import logger from "@/lib/logger.ts";
7
+ import util from "@/lib/util.ts";
8
+
9
+ /**
10
+ * 视频上传模块
11
+ * 通过 VOD (vod.bytedanceapi.com) 上传视频文件,获取 Vid
12
+ */
13
+
14
+ export interface VideoUploadResult {
15
+ vid: string;
16
+ uri: string;
17
+ videoMeta: {
18
+ width: number;
19
+ height: number;
20
+ duration: number;
21
+ bitrate: number;
22
+ format: string;
23
+ codec: string;
24
+ size: number;
25
+ md5: string;
26
+ };
27
+ }
28
+
29
+ /**
30
+ * 上传视频Buffer到VOD
31
+ * @param videoBuffer 视频二进制数据
32
+ * @param refreshToken 刷新令牌
33
+ * @param regionInfo 区域信息
34
+ * @returns 上传结果,包含 vid 和视频元信息
35
+ */
36
+ export async function uploadVideoBuffer(
37
+ videoBuffer: ArrayBuffer | Buffer,
38
+ refreshToken: string,
39
+ regionInfo: RegionInfo
40
+ ): Promise<VideoUploadResult> {
41
+ try {
42
+ const fileSize = videoBuffer.byteLength;
43
+ logger.info(`开始上传视频Buffer... (size=${fileSize}, isInternational=${regionInfo.isInternational})`);
44
+
45
+ // 第一步:获取上传令牌
46
+ const tokenResult = await request("post", "/mweb/v1/get_upload_token", refreshToken, {
47
+ data: {
48
+ scene: 1, // VOD 视频上传场景
49
+ },
50
+ });
51
+
52
+ const { access_key_id, secret_access_key, session_token, space_name } = tokenResult;
53
+
54
+ if (!access_key_id || !secret_access_key || !session_token) {
55
+ throw new Error("获取视频上传令牌失败");
56
+ }
57
+
58
+ const spaceName = space_name || "dreamina";
59
+ logger.info(`获取视频上传令牌成功: spaceName=${spaceName}`);
60
+
61
+ // 第二步:申请视频上传权限 (ApplyUploadInner)
62
+ const now = new Date();
63
+ const timestamp = now.toISOString().replace(/[:\-]/g, '').replace(/\.\d{3}Z$/, 'Z');
64
+ const randomStr = Math.random().toString(36).substring(2, 12);
65
+
66
+ const vodHost = "https://vod.bytedanceapi.com";
67
+ const applyUrl = `${vodHost}/?Action=ApplyUploadInner&Version=2020-11-19&SpaceName=${spaceName}&FileType=video&IsInner=1&FileSize=${fileSize}&s=${randomStr}`;
68
+
69
+ const awsRegion = RegionUtils.getAWSRegion(regionInfo);
70
+ const origin = RegionUtils.getOrigin(regionInfo);
71
+
72
+ const requestHeaders = {
73
+ 'x-amz-date': timestamp,
74
+ 'x-amz-security-token': session_token,
75
+ };
76
+
77
+ const authorization = createSignature(
78
+ 'GET', applyUrl, requestHeaders,
79
+ access_key_id, secret_access_key, session_token,
80
+ '', awsRegion, 'vod'
81
+ );
82
+
83
+ logger.info(`申请视频上传权限: ${applyUrl}`);
84
+
85
+ let applyResponse;
86
+ try {
87
+ applyResponse = await axios({
88
+ method: 'GET',
89
+ url: applyUrl,
90
+ headers: {
91
+ 'accept': '*/*',
92
+ 'accept-language': 'zh-CN,zh;q=0.9',
93
+ 'authorization': authorization,
94
+ 'origin': origin,
95
+ 'referer': RegionUtils.getRefererPath(regionInfo),
96
+ 'sec-ch-ua': '"Not A(Brand";v="8", "Chromium";v="132", "Google Chrome";v="132"',
97
+ 'sec-ch-ua-mobile': '?0',
98
+ 'sec-ch-ua-platform': '"Windows"',
99
+ 'sec-fetch-dest': 'empty',
100
+ 'sec-fetch-mode': 'cors',
101
+ 'sec-fetch-site': 'cross-site',
102
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
103
+ 'x-amz-date': timestamp,
104
+ 'x-amz-security-token': session_token,
105
+ },
106
+ validateStatus: () => true,
107
+ });
108
+ } catch (fetchError: any) {
109
+ logger.error(`ApplyUploadInner请求失败: ${fetchError.message}`);
110
+ throw new Error(`视频上传申请网络请求失败 (${vodHost}): ${fetchError.message}`);
111
+ }
112
+
113
+ if (applyResponse.status < 200 || applyResponse.status >= 300) {
114
+ const errorText = typeof applyResponse.data === 'string' ? applyResponse.data : JSON.stringify(applyResponse.data);
115
+ throw new Error(`申请视频上传权限失败: ${applyResponse.status} - ${errorText}`);
116
+ }
117
+
118
+ const applyResult = applyResponse.data;
119
+
120
+ if (applyResult?.ResponseMetadata?.Error) {
121
+ throw new Error(`申请视频上传权限失败: ${JSON.stringify(applyResult.ResponseMetadata.Error)}`);
122
+ }
123
+
124
+ // 解析上传节点(优先使用 Edge 节点)
125
+ const uploadNodes = applyResult?.Result?.InnerUploadAddress?.UploadNodes;
126
+ if (!uploadNodes || uploadNodes.length === 0) {
127
+ throw new Error(`获取视频上传节点失败: ${JSON.stringify(applyResult)}`);
128
+ }
129
+
130
+ const uploadNode = uploadNodes[0];
131
+ const storeInfo = uploadNode.StoreInfos?.[0];
132
+ if (!storeInfo) {
133
+ throw new Error(`获取视频上传存储信息失败: ${JSON.stringify(uploadNode)}`);
134
+ }
135
+
136
+ const uploadHost = uploadNode.UploadHost;
137
+ const storeUri = storeInfo.StoreUri;
138
+ const auth = storeInfo.Auth;
139
+ const sessionKey = uploadNode.SessionKey;
140
+ const vid = uploadNode.Vid;
141
+
142
+ logger.info(`获取视频上传节点成功: host=${uploadHost}, vid=${vid}, type=${uploadNode.Type}`);
143
+
144
+ // 第三步:上传视频二进制数据
145
+ const uploadUrl = `https://${uploadHost}/upload/v1/${storeUri}`;
146
+ const crc32 = util.calculateCRC32(videoBuffer);
147
+ logger.info(`开始上传视频文件: ${uploadUrl}, CRC32=${crc32}`);
148
+
149
+ let uploadResponse;
150
+ try {
151
+ uploadResponse = await axios({
152
+ method: 'POST',
153
+ url: uploadUrl,
154
+ headers: {
155
+ 'Accept': '*/*',
156
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
157
+ 'Authorization': auth,
158
+ 'Connection': 'keep-alive',
159
+ 'Content-CRC32': crc32,
160
+ 'Content-Type': 'application/octet-stream',
161
+ 'Origin': origin,
162
+ 'Referer': RegionUtils.getRefererPath(regionInfo),
163
+ 'Sec-Fetch-Dest': 'empty',
164
+ 'Sec-Fetch-Mode': 'cors',
165
+ 'Sec-Fetch-Site': 'cross-site',
166
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
167
+ },
168
+ data: videoBuffer,
169
+ maxContentLength: Infinity,
170
+ maxBodyLength: Infinity,
171
+ validateStatus: () => true,
172
+ });
173
+ } catch (fetchError: any) {
174
+ logger.error(`视频文件上传请求失败: ${fetchError.message}`);
175
+ throw new Error(`视频文件上传网络请求失败 (${uploadHost}): ${fetchError.message}`);
176
+ }
177
+
178
+ if (uploadResponse.status < 200 || uploadResponse.status >= 300) {
179
+ const errorText = typeof uploadResponse.data === 'string' ? uploadResponse.data : JSON.stringify(uploadResponse.data);
180
+ throw new Error(`视频文件上传失败: ${uploadResponse.status} - ${errorText}`);
181
+ }
182
+
183
+ const uploadData = uploadResponse.data;
184
+ if (uploadData?.code !== 2000) {
185
+ throw new Error(`视频文件上传失败: code=${uploadData?.code}, message=${uploadData?.message}`);
186
+ }
187
+
188
+ logger.info(`视频文件上传成功: crc32=${uploadData.data?.crc32}`);
189
+
190
+ // 第四步:提交上传确认 (CommitUploadInner)
191
+ const commitUrl = `${vodHost}/?Action=CommitUploadInner&Version=2020-11-19&SpaceName=${spaceName}`;
192
+ const commitTimestamp = new Date().toISOString().replace(/[:\-]/g, '').replace(/\.\d{3}Z$/, 'Z');
193
+ const commitPayload = JSON.stringify({
194
+ SessionKey: sessionKey,
195
+ Functions: [],
196
+ });
197
+
198
+ const payloadHash = crypto.createHash('sha256').update(commitPayload, 'utf8').digest('hex');
199
+
200
+ const commitRequestHeaders = {
201
+ 'x-amz-date': commitTimestamp,
202
+ 'x-amz-security-token': session_token,
203
+ 'x-amz-content-sha256': payloadHash,
204
+ };
205
+
206
+ const commitAuthorization = createSignature(
207
+ 'POST', commitUrl, commitRequestHeaders,
208
+ access_key_id, secret_access_key, session_token,
209
+ commitPayload, awsRegion, 'vod'
210
+ );
211
+
212
+ logger.info(`提交视频上传确认: ${commitUrl}`);
213
+
214
+ let commitResponse;
215
+ try {
216
+ commitResponse = await axios({
217
+ method: 'POST',
218
+ url: commitUrl,
219
+ headers: {
220
+ 'accept': '*/*',
221
+ 'accept-language': 'zh-CN,zh;q=0.9',
222
+ 'authorization': commitAuthorization,
223
+ 'content-type': 'application/json',
224
+ 'origin': origin,
225
+ 'referer': RegionUtils.getRefererPath(regionInfo),
226
+ 'sec-ch-ua': '"Not A(Brand";v="8", "Chromium";v="132", "Google Chrome";v="132"',
227
+ 'sec-ch-ua-mobile': '?0',
228
+ 'sec-ch-ua-platform': '"Windows"',
229
+ 'sec-fetch-dest': 'empty',
230
+ 'sec-fetch-mode': 'cors',
231
+ 'sec-fetch-site': 'cross-site',
232
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
233
+ 'x-amz-date': commitTimestamp,
234
+ 'x-amz-security-token': session_token,
235
+ 'x-amz-content-sha256': payloadHash,
236
+ },
237
+ data: commitPayload,
238
+ validateStatus: () => true,
239
+ });
240
+ } catch (fetchError: any) {
241
+ logger.error(`CommitUploadInner请求失败: ${fetchError.message}`);
242
+ throw new Error(`提交视频上传网络请求失败 (${vodHost}): ${fetchError.message}`);
243
+ }
244
+
245
+ if (commitResponse.status < 200 || commitResponse.status >= 300) {
246
+ const errorText = typeof commitResponse.data === 'string' ? commitResponse.data : JSON.stringify(commitResponse.data);
247
+ throw new Error(`提交视频上传失败: ${commitResponse.status} - ${errorText}`);
248
+ }
249
+
250
+ const commitResult = commitResponse.data;
251
+
252
+ if (commitResult?.ResponseMetadata?.Error) {
253
+ throw new Error(`提交视频上传失败: ${JSON.stringify(commitResult.ResponseMetadata.Error)}`);
254
+ }
255
+
256
+ if (!commitResult?.Result?.Results || commitResult.Result.Results.length === 0) {
257
+ throw new Error(`提交视频上传响应缺少结果: ${JSON.stringify(commitResult)}`);
258
+ }
259
+
260
+ const result = commitResult.Result.Results[0];
261
+ const videoMeta = result.VideoMeta;
262
+
263
+ if (!result.Vid) {
264
+ throw new Error(`提交视频上传响应缺少 Vid: ${JSON.stringify(result)}`);
265
+ }
266
+
267
+ // 校验视频时长,即梦限制不超过15秒
268
+ const MAX_VIDEO_DURATION = 15;
269
+ if (videoMeta?.Duration && videoMeta.Duration > MAX_VIDEO_DURATION) {
270
+ throw new Error(`视频时长 ${videoMeta.Duration.toFixed(2)}s 超过限制 (最大 ${MAX_VIDEO_DURATION}s)`);
271
+ }
272
+
273
+ logger.info(`视频上传完成: vid=${result.Vid}, ${videoMeta?.Width}x${videoMeta?.Height}, ${videoMeta?.Duration}s, ${videoMeta?.Format}/${videoMeta?.Codec}`);
274
+
275
+ return {
276
+ vid: result.Vid,
277
+ uri: videoMeta?.Uri || '',
278
+ videoMeta: {
279
+ width: videoMeta?.Width || 0,
280
+ height: videoMeta?.Height || 0,
281
+ duration: videoMeta?.Duration || 0,
282
+ bitrate: videoMeta?.Bitrate || 0,
283
+ format: videoMeta?.Format || '',
284
+ codec: videoMeta?.Codec || '',
285
+ size: videoMeta?.Size || 0,
286
+ md5: videoMeta?.Md5 || '',
287
+ },
288
+ };
289
+ } catch (error: any) {
290
+ logger.error(`视频Buffer上传失败: ${error.message}`);
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * 从URL下载并上传视频
297
+ * @param videoUrl 视频URL
298
+ * @param refreshToken 刷新令牌
299
+ * @param regionInfo 区域信息
300
+ * @returns 上传结果
301
+ */
302
+ export async function uploadVideoFromUrl(
303
+ videoUrl: string,
304
+ refreshToken: string,
305
+ regionInfo: RegionInfo
306
+ ): Promise<VideoUploadResult> {
307
+ try {
308
+ logger.info(`开始从URL下载并上传视频: ${videoUrl}`);
309
+
310
+ const videoResponse = await axios.get(videoUrl, {
311
+ responseType: 'arraybuffer',
312
+ maxContentLength: Infinity,
313
+ maxBodyLength: Infinity,
314
+ });
315
+ if (videoResponse.status < 200 || videoResponse.status >= 300) {
316
+ throw new Error(`下载视频失败: ${videoResponse.status}`);
317
+ }
318
+
319
+ const videoBuffer = videoResponse.data;
320
+ logger.info(`视频下载完成: ${videoBuffer.byteLength} 字节`);
321
+ return await uploadVideoBuffer(videoBuffer, refreshToken, regionInfo);
322
+ } catch (error: any) {
323
+ logger.error(`从URL上传视频失败: ${error.message}`);
324
+ throw error;
325
+ }
326
+ }