aiVideoGenerationService = $aiVideoGenerationService; } /** * 创建视频生成任务(即梦AI) * * @param Request $request * @return JsonResponse */ public function createJimengTask(Request $request): JsonResponse { $data = $request->all(); // 首先验证基本参数 $baseRules = [ 'prompt' => 'required|string|max:1000', 'seed' => 'required|integer', 'video_duration' => 'required|in:5,10', 'video_resolution' => 'required|in:720P,1080P' ]; $baseMessages = [ 'prompt.required' => '提示词不能为空', 'prompt.max' => '提示词不能超过1000个字符', 'seed.required' => '随机种子不能为空', 'seed.integer' => '随机种子必须是整数', 'video_duration.required' => '请选择视频时长', 'video_duration.in' => '视频时长只能是5s或10s', 'video_resolution.required' => '请选择视频分辨率', 'video_resolution.in' => '视频分辨率只能是720P或1080P', ]; // 动态构建验证规则 $validationRules = $baseRules; $validationMessages = $baseMessages; // 检查是否有首帧URL if (isset($data['first_frame_url'])) { $validationRules['first_frame_url'] = 'url'; $validationMessages['first_frame_url.url'] = '首帧图片URL格式不正确'; } // 检查是否有尾帧URL if (isset($data['tail_frame_url'])) { $validationRules['tail_frame_url'] = 'url'; $validationMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确'; } // 检查是否有首帧文件 if ($request->hasFile('first_frame_file')) { $validationRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800'; $validationMessages = array_merge($validationMessages, [ 'first_frame_file.image' => '首帧必须是图片文件', 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式', 'first_frame_file.max' => '首帧图片大小不能超过4.7MB', ]); } // 检查是否有尾帧文件 if ($request->hasFile('tail_frame_file')) { $validationRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800'; $validationMessages = array_merge($validationMessages, [ 'tail_frame_file.image' => '尾帧必须是图片文件', 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式', 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB', ]); } if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) { Utils::throwError('1002:提供尾帧URL时必须提供首帧URL'); } if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) { Utils::throwError('1002:提供尾帧文件时必须提供首帧文件'); } $validator = Validator::make($data, $validationRules, $validationMessages); if ($validator->fails()) { Utils::throwError('1002:'.$validator->errors()->first()); } $firstFrame = null; $tailFrame = null; // 处理首帧图片(文件上传方式) if ($request->hasFile('first_frame_file')) { $firstFrameFile = $request->file('first_frame_file'); $firstFrame = $this->processFrameFile($firstFrameFile, 'first'); $data['first_frame_url'] = $firstFrame['url']; } // 处理首帧图片(URL方式) elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) { $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true); if (!$firstFrame) { Utils::throwError('1003:首帧图片获取失败'); } } // 处理尾帧图片(文件上传方式) if ($request->hasFile('tail_frame_file')) { $tailFrameFile = $request->file('tail_frame_file'); $tailFrame = $this->processFrameFile($tailFrameFile, 'tail'); $data['tail_frame_url'] = $tailFrame['url']; } // 处理尾帧图片(URL方式) elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) { $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true); if (!$tailFrame) { Utils::throwError('1003:尾帧图片获取失败'); } } // 验证首帧图片规格(如果存在) if ($firstFrame) { $this->validateFrameSpecs($firstFrame, '首帧'); } // 验证尾帧图片规格(如果存在) if ($tailFrame) { $this->validateFrameSpecs($tailFrame, '尾帧'); } // 如果首尾帧都存在,验证比例是否相同 if ($firstFrame && $tailFrame) { $firstRatio = round($firstFrame['width'] / $firstFrame['height'], 2); $tailRatio = round($tailFrame['width'] / $tailFrame['height'], 2); if ($firstRatio != $tailRatio) { Utils::throwError('1003:尾帧图片与首帧图片比例必须相同'); } } $task = $this->aiVideoGenerationService->createJimengTask($data); return $this->success(['task_id' => $task->task_id, 'status' => $task->status]); } /** * 查询视频任务状态 * * @param string $taskId * @return JsonResponse */ public function taskStatus(string $taskId): JsonResponse { $task = \App\Models\MpGenerateVideoTask::where('task_id', $taskId)->first(); if (!$task) { return response()->json([ 'success' => false, 'message' => '任务不存在' ], 404); } return response()->json([ 'success' => true, 'data' => [ 'task_id' => $task->task_id, 'status' => $task->status, 'result_url' => $task->result_url, 'error_message' => $task->error_message, 'created_at' => $task->created_at, 'started_at' => $task->started_at, 'completed_at' => $task->completed_at ] ]); } /** * 处理帧图片文件 * * @param \Illuminate\Http\UploadedFile $file * @param string $frameType 帧类型(first/tail) * @return array */ private function processFrameFile($file, string $frameType): array { // 验证图片信息 $frameInfo = @getimagesize($file->getPathname()); if (!$frameInfo) { Utils::throwError('1003:' . ($frameType === 'first' ? '首帧' : '尾帧') . '图片格式无效'); } $frameData = [ 'width' => $frameInfo[0], 'height' => $frameInfo[1], 'size' => $file->getSize(), ]; // 上传图片到服务器 try { $frameData['url'] = uploadFileByTos('image', $file); } catch (\Exception $e) { Utils::throwError('1003:' . ($frameType === 'first' ? '首帧' : '尾帧') . '图片上传失败:' . $e->getMessage()); } return $frameData; } /** * 验证帧图片规格 * * @param array $frame * @param string $frameName * @return void */ private function validateFrameSpecs(array $frame, string $frameName): void { // 1.图片文件最大4.7MB if ($frame['size'] > 4.7 * 1024 * 1024) { Utils::throwError('1003:' . $frameName . '图片大小不能超过4.7MB'); } // 2.图片分辨率最大: 4096*4096, 最短边不低于320 $minDimension = min($frame['width'], $frame['height']); $maxDimension = max($frame['width'], $frame['height']); if ($maxDimension > 4096 || $minDimension < 320) { Utils::throwError('1003:' . $frameName . '图片分辨率不能超过4096*4096,且最短边不能低于320'); } // 3.图片长边与短边比例在3以内 if ($maxDimension / $minDimension > 3) { Utils::throwError('1003:' . $frameName . '图片长边与短边比例不能超过3'); } } /** * 创建 Seedance 1.5 Pro 视频生成任务 * * @param Request $request * @return JsonResponse */ public function createSeedanceTask(Request $request): JsonResponse { $data = $request->all(); // 验证基本参数 $baseRules = [ 'model' => 'required|string|in:doubao-seedance-1-5-pro-251215,doubao-seedance-1-0-pro-250528,doubao-seedance-1-0-pro-fast-251015,doubao-seedance-1-0-lite-t2v-250428,doubao-seedance-1-0-lite-i2v-250428', 'prompt' => 'required|max:1000', // 'content' => 'required|array|min:1', ]; $baseMessages = [ 'model.required' => '模型参数不能为空', 'model.in' => '模型只支持 doubao-seedance-1-5-pro-251215、doubao-seedance-1-0-pro-250528、doubao-seedance-1-0-pro-fast-251015、doubao-seedance-1-0-lite-t2v-250428、doubao-seedance-1-0-lite-i2v-250428', 'prompt.required' => '提示词不能为空', 'prompt.max' => '提示词不能超过1000个字符', // 'content.required' => '内容参数不能为空', // 'content.array' => '内容参数必须是数组', // 'content.min' => '内容参数至少包含一个元素', ]; // 可选参数验证 $optionalRules = [ 'callback_url' => 'nullable|url', 'return_last_frame' => 'nullable|boolean', 'service_tier' => 'nullable|in:default,flex', 'execution_expires_after' => 'nullable|integer|min:3600|max:259200', 'generate_audio' => 'nullable|boolean', 'draft' => 'nullable|boolean', 'video_resolution' => 'required|in:480P,720P,1080P', 'ratio' => 'nullable|in:16:9,4:3,1:1,3:4,9:16,21:9,adaptive', 'video_duration' => 'required|integer|min:2|max:12', 'frames' => 'nullable|integer|min:29|max:289', 'seed' => 'nullable|integer|min:-1|max:4294967295', 'camera_fixed' => 'nullable|boolean', 'watermark' => 'nullable|boolean', ]; $optionalMessages = [ 'callback_url.url' => '回调地址格式不正确', 'service_tier.in' => '服务等级只能是 default 或 flex', 'execution_expires_after.integer' => '任务超时时间必须是整数', 'execution_expires_after.min' => '任务超时时间不能少于3600秒', 'execution_expires_after.max' => '任务超时时间不能超过259200秒', 'video_resolution.required' => '请选择视频分辨率', 'video_resolution.in' => '分辨率只能是 480P、720P 或 1080P', 'ratio.in' => '宽高比参数不正确', 'video_duration.required' => '请填写视频时长', 'video_duration.integer' => '视频时长必须是整数', 'video_duration.min' => '视频时长不能少于2秒', 'video_duration.max' => '视频时长不能超过12秒', 'frames.integer' => '帧数必须是整数', 'frames.min' => '帧数不能少于29', 'frames.max' => '帧数不能超过289', 'seed.integer' => '随机种子必须是整数', 'seed.min' => '随机种子不能小于-1', 'seed.max' => '随机种子不能超过4294967295', ]; $validationRules = array_merge($baseRules, $optionalRules); $validationMessages = array_merge($baseMessages, $optionalMessages); // 检查是否有首帧URL if (isset($data['first_frame_url'])) { $optionalRules['first_frame_url'] = 'url'; $optionalMessages['first_frame_url.url'] = '首帧图片URL格式不正确'; } // 检查是否有尾帧URL if (isset($data['tail_frame_url'])) { $optionalRules['tail_frame_url'] = 'url'; $optionalMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确'; } // 检查是否有首帧文件 if ($request->hasFile('first_frame_file')) { $optionalRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800'; $optionalMessages = array_merge($optionalMessages, [ 'first_frame_file.image' => '首帧必须是图片文件', 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式', 'first_frame_file.max' => '首帧图片大小不能超过4.7MB', ]); } // 检查是否有尾帧文件 if ($request->hasFile('tail_frame_file')) { $optionalRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800'; $optionalMessages = array_merge($optionalMessages, [ 'tail_frame_file.image' => '尾帧必须是图片文件', 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式', 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB', ]); } if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) { Utils::throwError('1002:提供尾帧URL时必须提供首帧URL'); } if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) { Utils::throwError('1002:提供尾帧文件时必须提供首帧文件'); } $validator = Validator::make($data, $validationRules, $validationMessages); if ($validator->fails()) { Utils::throwError('1002:'.$validator->errors()->first()); } $data['content'] = [ [ 'type' => 'text', 'text' => $data['prompt'], ], ]; $firstFrame = null; $tailFrame = null; // 以下首尾帧做模型兼容: // doubao-seedance-1-0-lite-t2v-250428只支持文生视频 // doubao-seedance-1-0-pro-fast-251015只支持文生视频、图生视频-首帧 // 处理首帧图片(文件上传方式) if ($request->hasFile('first_frame_file')) { $firstFrameFile = $request->file('first_frame_file'); $firstFrame = $this->processFrameFile($firstFrameFile, 'first'); if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428'])) { $data['first_frame_url'] = $firstFrame['url']; $data['content'][] = [ 'type' => 'image_url', 'image_url' => [ 'url' => $data['first_frame_url'], ], 'role' => 'first_frame', ]; } } // 处理首帧图片(URL方式) elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) { $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true); if (!$firstFrame) { Utils::throwError('1003:首帧图片获取失败'); } if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428'])) { $data['content'][] = [ 'type' => 'image_url', 'image_url' => [ 'url' => $data['first_frame_url'], ], 'role' => 'first_frame', ]; }else { $data['first_frame_url'] = ''; } } // 处理尾帧图片(文件上传方式) if ($request->hasFile('tail_frame_file')) { $tailFrameFile = $request->file('tail_frame_file'); $tailFrame = $this->processFrameFile($tailFrameFile, 'tail'); if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428', 'doubao-seedance-1-0-pro-fast-251015'])) { $data['tail_frame_url'] = $tailFrame['url']; $data['content'][] = [ 'type' => 'image_url', 'image_url' => [ 'url' => $data['tail_frame_url'], ], 'role' => 'last_frame', ]; } } // 处理尾帧图片(URL方式) elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) { $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true); if (!$tailFrame) { Utils::throwError('1003:尾帧图片获取失败'); } if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428', 'doubao-seedance-1-0-pro-fast-251015'])) { $data['content'][] = [ 'type' => 'image_url', 'image_url' => [ 'url' => $data['tail_frame_url'], ], 'role' => 'last_frame', ]; }else { $data['tail_frame_url'] = ''; } } // 验证模型参数 if (!in_array($data['model'], ['doubao-seedance-1-5-pro-251215'])) { $data['generate_audio'] = false; $data['draft'] = false; } if (in_array($data['model'], ['doubao-seedance-1-5-pro-251215'])) { if ($data['video_duration'] < 4) $data['video_duration'] = 4; if (!isset($data['ratio'])) $data['ratio'] = 'adaptive'; } // 验证 content 数组内容 $this->validateSeedanceContent($data['content']); $task = $this->aiVideoGenerationService->createSeedanceTask($data); return $this->success(['task_id' => $task->task_id, 'status' => $task->status]); } /** * 查询 Seedance 1.5 Pro 任务状态 * * @param string $taskId * @return JsonResponse */ public function getSeedanceTaskStatus(string $taskId): JsonResponse { try { $task = \App\Models\MpGenerateVideoTask::where('task_id', $taskId)->first(); if (!$task) { return response()->json([ 'success' => false, 'message' => '任务不存在' ], 404); } $result = $this->aiVideoGenerationService->querySeedanceTaskStatus($task); return $this->success($result); } catch (\Exception $e) { Utils::throwError('1001:'.$e->getMessage()); } } /** * 验证 Seedance content 内容 * * @param array $content * @return void */ private function validateSeedanceContent(array $content): void { $hasText = false; $hasFirstFrame = false; $hasLastFrame = false; $hasReferenceImage = false; $hasDraftTask = false; foreach ($content as $index => $item) { if (!isset($item['type'])) { Utils::throwError('1002:content[' . $index . '] 缺少 type 字段'); } switch ($item['type']) { case 'text': if (!isset($item['text']) || empty($item['text'])) { Utils::throwError('1002:text 类型的 content 必须包含非空的 text 字段'); } if (mb_strlen($item['text']) > 1000) { Utils::throwError('1002:文本提示词不能超过1000个字符'); } $hasText = true; break; case 'image_url': if (!isset($item['image_url']['url']) || empty($item['image_url']['url'])) { Utils::throwError('1002:image_url 类型的 content 必须包含 image_url.url 字段'); } $role = $item['role'] ?? null; if ($role === 'first_frame') { $hasFirstFrame = true; } elseif ($role === 'last_frame') { $hasLastFrame = true; } elseif ($role === 'reference_image') { $hasReferenceImage = true; } elseif (empty($role)) { // 默认为首帧 $hasFirstFrame = true; } break; case 'draft_task': if (!isset($item['draft_task']['id']) || empty($item['draft_task']['id'])) { Utils::throwError('1002:draft_task 类型的 content 必须包含 draft_task.id 字段'); } $hasDraftTask = true; break; default: Utils::throwError('1002:不支持的 content type: ' . $item['type']); } } // 验证组合规则 if ($hasLastFrame && !$hasFirstFrame) { Utils::throwError('1002:使用尾帧时必须同时提供首帧'); } if ($hasReferenceImage && ($hasFirstFrame || $hasLastFrame)) { Utils::throwError('1002:参考图模式不能与首帧/尾帧模式混用'); } if ($hasDraftTask && ($hasText || $hasFirstFrame || $hasLastFrame || $hasReferenceImage)) { Utils::throwError('1002:使用样片任务时不能同时提供其他内容'); } } /** * 创建可灵AI Omni视频生成任务 * * @param Request $request * @return JsonResponse */ public function createKelingTask(Request $request): JsonResponse { $data = $request->all(); // 验证基本参数 $baseRules = [ 'model' => 'required|string|in:kling-video-o1,kling-v3-omni', 'prompt' => 'required|max:2500', 'mode' => 'nullable|in:pro,std', 'aspect_ratio' => 'nullable|in:16:9,4:3,1:1,3:4,9:16,21:9', 'video_duration' => 'required|integer|min:3|max:15', ]; $baseMessages = [ 'model.required' => '模型参数不能为空', 'model.in' => '模型只支持 kling-video-o1、kling-v3-omni', 'prompt.required' => '提示词不能为空', 'prompt.max' => '提示词不能超过2500个字符', 'mode.in' => '模式只能是 pro 或 std', 'aspect_ratio.in' => '宽高比只能是 16:9、4:3、1:1、3:4、9:16、21:9', 'video_duration.required' => '请填写视频时长', 'video_duration.integer' => '视频时长必须是整数', 'video_duration.min' => '视频时长不能少于3秒', 'video_duration.max' => '视频时长不能超过15秒', ]; // 可选参数验证 $optionalRules = [ 'multi_prompt' => 'nullable|array', 'multi_prompt.*' => 'string|max:2500', 'shot_type' => 'nullable|in:customize,single_shot,multi_shot', 'generate_audio' => 'nullable|boolean', 'webhook_url' => 'nullable|url', 'seed' => 'nullable|integer|min:-1|max:4294967295', 'video_resolution' => 'nullable|in:480P,720P,1080P', ]; $optionalMessages = [ 'multi_prompt.array' => '多段提示词必须是数组', 'multi_prompt.*.string' => '每段提示词必须是字符串', 'multi_prompt.*.max' => '每段提示词不能超过2500个字符', 'shot_type.in' => '镜头类型只能是 customize、single_shot、multi_shot', 'webhook_url.url' => '回调地址格式不正确', 'seed.integer' => '随机种子必须是整数', 'seed.min' => '随机种子不能小于-1', 'seed.max' => '随机种子不能超过4294967295', 'video_resolution.in' => '分辨率只能是 480P、720P 或 1080P', ]; // 检查是否有首帧URL if (isset($data['first_frame_url'])) { $optionalRules['first_frame_url'] = 'url'; $optionalMessages['first_frame_url.url'] = '首帧图片URL格式不正确'; } // 检查是否有尾帧URL if (isset($data['tail_frame_url'])) { $optionalRules['tail_frame_url'] = 'url'; $optionalMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确'; } // 检查是否有首帧文件 if ($request->hasFile('first_frame_file')) { $optionalRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800'; $optionalMessages = array_merge($optionalMessages, [ 'first_frame_file.image' => '首帧必须是图片文件', 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式', 'first_frame_file.max' => '首帧图片大小不能超过4.7MB', ]); } // 检查是否有尾帧文件 if ($request->hasFile('tail_frame_file')) { $optionalRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800'; $optionalMessages = array_merge($optionalMessages, [ 'tail_frame_file.image' => '尾帧必须是图片文件', 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式', 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB', ]); } if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) { Utils::throwError('1002:提供尾帧URL时必须提供首帧URL'); } if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) { Utils::throwError('1002:提供尾帧文件时必须提供首帧文件'); } $validationRules = array_merge($baseRules, $optionalRules); $validationMessages = array_merge($baseMessages, $optionalMessages); // 处理图片相关参数 $validator = Validator::make($data, $validationRules, $validationMessages); if ($validator->fails()) { Utils::throwError('1002:'.$validator->errors()->first()); } $firstFrame = null; $tailFrame = null; $data['image_list'] = []; // 处理首帧图片(文件上传方式) if ($request->hasFile('first_frame_file')) { $firstFrameFile = $request->file('first_frame_file'); $firstFrame = $this->processFrameFile($firstFrameFile, 'first'); $data['first_frame_url'] = $firstFrame['url']; $data['image_list'][] = [ 'image_url' => $data['first_frame_url'], 'type' => 'first_frame', ]; } // 处理首帧图片(URL方式) elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) { $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true); if (!$firstFrame) { Utils::throwError('1003:首帧图片获取失败'); } $data['image_list'][] = [ 'image_url' => $data['first_frame_url'], 'type' => 'first_frame', ]; } // 处理尾帧图片(文件上传方式) if ($request->hasFile('tail_frame_file')) { $tailFrameFile = $request->file('tail_frame_file'); $tailFrame = $this->processFrameFile($tailFrameFile, 'tail'); $data['tail_frame_url'] = $tailFrame['url']; $data['image_list'][] = [ 'image_url' => $data['tail_frame_url'], 'type' => 'end_frame', ]; } // 处理尾帧图片(URL方式) elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) { $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true); if (!$tailFrame) { Utils::throwError('1003:尾帧图片获取失败'); } $data['image_list'][] = [ 'image_url' => $data['tail_frame_url'], 'type' => 'end_frame', ]; } $task = $this->aiVideoGenerationService->createKelingOmniTask($data); return $this->success(['task_id' => $task->task_id, 'status' => $task->status]); } /** * 查询可灵AI Omni任务状态 * * @param string $taskId * @return JsonResponse */ public function getKelingTaskStatus(string $taskId): JsonResponse { try { $task = \App\Models\MpGenerateVideoTask::where('task_id', $taskId)->first(); if (!$task) { return response()->json([ 'success' => false, 'message' => '任务不存在' ], 404); } $result = $this->aiVideoGenerationService->queryKelingOmniTaskStatus($task); return $this->success($result); } catch (\Exception $e) { Utils::throwError('1001:'.$e->getMessage()); } } /** * 获取视频任务列表 * * @param Request $request * @return mixed */ public function taskList(Request $request) { $data = $request->all(); $result = $this->aiVideoGenerationService->getTaskList($data); return $this->success($result, [new AIGenerationTransformer(), 'newBuildTaskList']); } }