Upload 50 files
Browse files- Dockerfile +73 -73
- jimeng-api/Skill.md +2 -2
- jimeng-api/scripts/generate_image.py +1 -1
- src/api/builders/payload-builder.ts +9 -22
- src/api/consts/common.ts +21 -7
- src/api/controllers/core.ts +65 -11
- src/api/controllers/images.ts +46 -19
- src/api/controllers/videos.ts +665 -223
- src/api/routes/videos.ts +115 -22
- src/lib/aws-signature.ts +2 -2
- src/lib/image-uploader.ts +23 -6
- src/lib/image-utils.ts +72 -0
- src/lib/smart-poller.ts +2 -1
- src/lib/util.ts +1 -1
- src/lib/video-uploader.ts +326 -0
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=
|
| 61 |
-
|
| 62 |
-
# 切换到非root用户
|
| 63 |
-
USER jimeng
|
| 64 |
-
|
| 65 |
-
# 暴露端口
|
| 66 |
-
EXPOSE
|
| 67 |
-
|
| 68 |
-
# 健康检查
|
| 69 |
-
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
|
| 70 |
-
CMD wget -q --spider http://localhost:
|
| 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-
|
| 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`
|
| 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-
|
| 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 |
-
* -
|
| 116 |
-
* -
|
| 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 |
-
|
| 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:
|
| 262 |
resolutionType,
|
| 263 |
abilityList,
|
| 264 |
reportParams: {
|
| 265 |
enterSource: "generate",
|
| 266 |
vipSource: "generate",
|
| 267 |
-
extraVipFunctionKey: `${
|
| 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-
|
| 67 |
-
"jimeng-video-
|
| 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,
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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:
|
| 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
|
| 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
|
| 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:
|
| 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
|
| 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:
|
| 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 {
|
|
|
|
|
|
|
| 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 "
|
| 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<
|
| 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<
|
| 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 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
try {
|
| 195 |
-
logger.info(`
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
}
|
| 203 |
} catch (error: any) {
|
| 204 |
-
|
| 205 |
-
if (i === 0) {
|
| 206 |
-
throw new APIException(EX.API_REQUEST_FAILED, `首帧图片上传失败: ${error.message}`);
|
| 207 |
-
}
|
| 208 |
}
|
| 209 |
}
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
try {
|
| 221 |
-
logger.info(`
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
}
|
| 229 |
} catch (error: any) {
|
| 230 |
-
|
| 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 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 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 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
}
|
| 276 |
-
}
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 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 |
-
|
| 311 |
-
|
| 312 |
-
if (hasImageInput && ratio !== "1:1") {
|
| 313 |
-
logger.warn(`图生视频模式下,ratio参数将被忽略(由输入图片的实际比例决定),但resolution参数仍然有效`);
|
| 314 |
-
}
|
| 315 |
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
params: {
|
| 325 |
aigc_features: "app_lip_sync",
|
| 326 |
web_version: "7.5.0",
|
| 327 |
da_version: DRAFT_VERSION,
|
| 328 |
},
|
| 329 |
data: {
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
benefit_type: getVideoBenefitType(model),
|
| 334 |
resource_id: "generate_video",
|
| 335 |
resource_id_type: "str",
|
| 336 |
-
resource_sub_type: "aigc"
|
| 337 |
},
|
| 338 |
-
|
| 339 |
benefit_type: getVideoBenefitType(model),
|
| 340 |
resource_id: "generate_video",
|
| 341 |
resource_id_type: "str",
|
| 342 |
-
resource_sub_type: "aigc"
|
| 343 |
-
}]
|
| 344 |
},
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
},
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
...(supportsResolution ? {
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
}],
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
},
|
| 396 |
-
|
| 397 |
-
}
|
| 398 |
},
|
| 399 |
-
|
| 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:
|
| 425 |
expectedItemCount: 1,
|
| 426 |
type: 'video',
|
| 427 |
-
timeoutSeconds:
|
| 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 |
-
//
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
|
| 516 |
// 如果无法获取视频URL,抛出异常
|
| 517 |
-
if (!
|
| 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: ${
|
| 523 |
-
return
|
| 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.
|
| 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 |
-
|
| 43 |
-
const
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
// refresh_token切分
|
|
@@ -70,7 +161,7 @@ export default {
|
|
| 70 |
const finalFilePaths = filePaths.length > 0 ? filePaths : file_paths;
|
| 71 |
|
| 72 |
// 生成视频
|
| 73 |
-
const
|
| 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(
|
| 90 |
return {
|
| 91 |
created: util.unixTimestamp(),
|
| 92 |
data: [{
|
|
@@ -99,7 +192,7 @@ export default {
|
|
| 99 |
return {
|
| 100 |
created: util.unixTimestamp(),
|
| 101 |
data: [{
|
| 102 |
-
url:
|
| 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<
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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<
|
| 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 {
|
| 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 |
+
}
|