volcEngineService = $volcEngineService; $this->pointsService = $pointsService; } /** * 视频生成成功计费(事务内调用,真实失败抛错触发整体回滚) * * @param MpGenerateVideoTask $task * @return void */ private function chargeVideoTaskInTransaction(MpGenerateVideoTask $task): void { $result = $this->pointsService->recordVideoTaskCharge($task); if (empty($result['charged']) && !in_array((string)($result['reason'] ?? ''), ['already_charged', 'no_charge_info'], true)) { throw new \Exception('视频计费失败: ' . (string)($result['reason'] ?? 'unknown')); } } /** * 构建视频任务计费信息 * * @param array $params 创建任务参数 * @param string $apiType API类型(seedance/jimeng/keling/zzengine) * @return array */ private function buildChargeInfo(array $params, string $apiType): array { $uid = 0; try { $uid = (int)Site::getUid(); } catch (\Throwable $e) { // 非请求上下文(如命令行)下可能无法获取用户ID,置为0 } // 兼容不同创建方法的字段名 $model = $params['model'] ?? $params['model_code'] ?? ''; $ratio = $params['ratio'] ?? $params['video_ratio'] ?? $params['aspect_ratio'] ?? ''; // zzengine(智帧)的 generate_audio 在 parameters 嵌套参数中,需兼容读取 $generateAudio = isset($params['generate_audio']) ? (bool)$params['generate_audio'] : (isset($params['parameters']['generate_audio']) ? (bool)$params['parameters']['generate_audio'] : false); // 带参考视频时按 video_to_video 定价(无该定价时积分引擎自动回退 video_generation) $hasReferenceVideo = !empty($params['reference_video_assets']) || !empty($params['reference_videos']); return [ 'user_id' => $uid, 'model' => $model, 'video_resolution' => strtolower($params['video_resolution'] ?? '720P'), 'video_duration' => $params['video_duration'] ?? -1, 'ratio' => $ratio, 'generate_audio' => $generateAudio, 'mode' => $hasReferenceVideo ? 'video_to_video' : 'video_generation', 'api_type' => $apiType, 'source' => !empty($params['alias_segment_id']) ? 'segment' : (!empty($params['alias_act_id']) ? 'act' : ''), 'alias_segment_id' => $params['alias_segment_id'] ?? '', 'alias_act_id' => $params['alias_act_id'] ?? '', 'created_at' => date('Y-m-d H:i:s'), ]; } /** * 异步创建视频生成任务 * * @param array $params * @return MpGenerateVideoTask */ public function createJimengTask(array $params): MpGenerateVideoTask { // 分辨率统一规范化为小写(480p=原始档;sr_720p=480p生成+本地超分到720p) $params['video_resolution'] = strtolower(trim((string)($params['video_resolution'] ?? '720p'))); // 生成唯一的任务ID $taskId = 'vid_gen_' . time() . '_' . uniqid(); // 创建任务记录 $task = MpGenerateVideoTask::create([ 'task_id' => $taskId, 'prompt' => $params['prompt'] ?? '', 'first_frame_url' => $params['first_frame_url'] ?? null, 'tail_frame_url' => $params['tail_frame_url'] ?? null, 'seed' => $params['seed'] ?? -1, 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : 121, 'video_resolution' => $params['video_resolution'] ?? '720P', 'status' => MpGenerateVideoTask::STATUS_PENDING, 'api_type' => 'jimeng', 'charge_info' => $this->buildChargeInfo($params, 'jimeng'), ]); // 发送异步请求到即梦AI API创建任务 $this->submitTaskToApi($task); return $task; } /** * 提交任务到即梦AI API * * @param MpGenerateVideoTask $task * @return void */ private function submitTaskToApi(MpGenerateVideoTask $task): void { try { // 验证环境变量配置 $accessKey = env('VOLC_AI_AK'); $secretKey = env('VOLC_AI_SK'); if (empty($accessKey) || empty($secretKey)) { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '火山引擎访问密钥未配置,请检查环境变量AK和SK' ]); return; } // 根据任务类型选择对应的req_key // sr_1080p(超分1080p)按720p生成,本地再超分到1080p $is720Like = in_array(strtolower((string)$task->video_resolution), ['720p', 'sr_1080p'], true); if ($task->first_frame_url && $task->tail_frame_url) { $req_key = $is720Like ? 'jimeng_i2v_first_tail_v30' : 'jimeng_i2v_first_tail_v30_1080'; }else if($task->first_frame_url){ $req_key = $is720Like ? 'jimeng_i2v_first_v30' : 'jimeng_i2v_first_v30_1080'; }else{ $req_key = $is720Like ? 'jimeng_t2v_v30' : 'jimeng_t2v_v30_1080p'; } // 构建即梦AI 3.0 视频生成API请求参数 $apiParams = [ 'req_key' => $req_key, 'prompt' => $task->prompt, 'seed' => (int)$task->seed, 'frames' => $task->frames, ]; // 添加图片URL参数 if ($task->first_frame_url) { $apiParams['image_urls'] = [ $task->first_frame_url ]; if ($task->tail_frame_url) $apiParams['image_urls'][] = $task->tail_frame_url; } dLog('generate')->info('图生视频参数: ', $apiParams); logDB('generate', 'info', '即梦AI视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]); // 调用即梦AI 3.0 视频生成API提交任务 $response = $this->volcEngineService->request( 'POST', 'visual.volcengineapi.com', // 即梦AI API域名 '/', // 使用根路径 [], // Query参数 json_encode($apiParams), $accessKey, $secretKey, 'cv', // 服务标识 'cn-north-1', // 区域 'CVSync2AsyncSubmitTask', '2022-08-31', 'application/json' ); $responseData = json_decode($response['body'], true); dLog('generate')->info('即梦AI 3.0 视频生成API提交任务响应: ', $responseData); logDB('generate', 'info', '即梦AI视频生成API响应', ['id' => $task->id, 'response' => $responseData]); if ($responseData['code'] !== 10000) { // API返回错误 logDB('generate', 'error', '即梦AI视频生成任务提交失败', ['id' => $task->id, 'error' => $responseData['message'] ?? 'API Error']); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $responseData['message'] ?? 'API Error', 'extra_params' => $responseData['data'] ?? [] ]); } else { // 任务提交成功,更新状态为处理中 $updateData = []; // 存储API返回的任务ID if (isset($responseData['data']['task_id'])) { $updateData['task_id'] = $responseData['data']['task_id']; $updateData['extra_params'] = $responseData['data'] ?? []; $updateData['extra_params']['req_key'] = $req_key; $updateData['extra_params']['request_id'] = isset($responseData['request_id']) ? $responseData['request_id'] : ''; } $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData); } } catch (\Exception $e) { // 记录错误 $error = $this->buildGatewayErrorPayload($e); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $error['error_message'], 'result_json' => $error['payload'], 'extra_params' => array_merge((array)$task->extra_params, ['api_response' => $error['payload']]) ]); Utils::throwError('1001:'.$e->getMessage()); } } /** * 查询任务状态(即梦AI) * * @param MpGenerateVideoTask $task * @return array */ public function queryJimengTaskStatus(MpGenerateVideoTask $task): array { try { // 获取API任务ID $apiTaskId = $task->task_id ?? null; if (!$apiTaskId) { return [ 'status' => 'failed', 'error_message' => 'API任务ID不存在', 'result_url' => null ]; } // 根据任务类型选择对应的req_key // sr_1080p(超分1080p)按720p生成,本地再超分到1080p $is720Like = in_array(strtolower((string)$task->video_resolution), ['720p', 'sr_1080p'], true); if ($task->first_frame_url && $task->tail_frame_url) { $req_key = $is720Like ? 'jimeng_i2v_first_tail_v30' : 'jimeng_i2v_first_tail_v30_1080'; }else if($task->first_frame_url){ $req_key = $is720Like ? 'jimeng_i2v_first_v30' : 'jimeng_i2v_first_v30_1080'; }else{ $req_key = $is720Like ? 'jimeng_t2v_v30' : 'jimeng_t2v_v30_1080p'; } // 构建查询参数 $apiParams = [ 'req_key' => $req_key, 'task_id' => $apiTaskId, // 'req_json' => '{"return_url":true}' // 返回URL格式 ]; // 调用即梦AI 3.0 视频生成API查询任务状态 $response = $this->volcEngineService->request( 'POST', 'visual.volcengineapi.com', '/', // 使用根路径 [], // Query参数 json_encode($apiParams), env('VOLC_AI_AK'), env('VOLC_AI_SK'), 'cv', 'cn-north-1', 'CVSync2AsyncGetResult', '2022-08-31', 'application/json' ); $responseData = json_decode($response['body'], true); dLog('generate')->info('即梦AI 3.0 视频生成API查询任务状态响应: ', $responseData); logDB('generate', 'info', '即梦AI视频任务状态查询', ['id' => $task->id, 'response' => $responseData]); if ($responseData['code'] !== 10000) { // API返回错误 logDB('generate', 'error', '即梦AI视频任务状态查询失败', ['id' => $task->id, 'error' => $responseData['message'] ?? 'API Error']); return [ 'status' => 'failed', 'error_message' => $responseData['message'] ?? 'API Error', 'result_url' => null, 'result_json' => $responseData ?? [] ]; } // 解析API响应 $result = $responseData['data'] ?? []; $taskStatus = $result['status'] ?? 'failed'; $returnData = [ 'status' => $taskStatus, 'error_message' => null, 'result_url' => null, 'result_json' => $responseData ?? [] ]; // 如果任务成功,获取结果URL if ($taskStatus === 'done' && isset($result['video_url'])) { if (isset($result['video_url'])) { // 下载视频内容 $videoContent = file_get_contents($result['video_url']); // 从任务中获取视频分辨率,按目标档位执行本地超分(sr_720p->720p;sr_1080p->1080p) if ($this->shouldUpgradeVideoResolution($task)) { $upgradeTarget = $this->getVideoUpgradeTarget($task); dLog('generate')->info('即梦检测到需超分视频,开始超分(' . strtolower((string)$task->video_resolution) . ' -> ' . $upgradeTarget . ')', [ 'original_url' => $result['video_url'], 'video_resolution' => $task->video_resolution, 'size' => round(strlen($videoContent) / 1024 / 1024, 2) . 'MB' ]); $url = upgrade480pTo720p($videoContent, 'video', true, $upgradeTarget); if (!$url) { // 超分失败,上传原视频 dLog('generate')->warning('即梦视频超分失败,使用原视频'); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($result['video_url']); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } } else { // 非超分档视频,直接保存原视频 dLog('generate')->info('即梦视频无需本地超分,直接保存', [ 'original_url' => $result['video_url'], 'video_resolution' => $task->video_resolution ]); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($result['video_url']); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } $returnData['result_url'] = $url; // 视频分辨率标准化(压缩前处理,避免压缩后重复上传) $targetResolution = $this->getTaskTargetResolution($task); if ($targetResolution) { $url = $this->ensureStandardVideoResolution($url, $targetResolution); $returnData['result_url'] = $url; } // 压缩视频 $compressed_video_url = compressVideo($url); $returnData['compressed_url'] = $compressed_video_url ?: $url; } // 计算视频时长 $video_duration = 0; if (isset($result['duration'])) { $video_duration = (int)$result['duration']; } elseif (isset($result['frames']) && isset($result['framespersecond'])) { $frames = (int)$result['frames']; $fps = (int)$result['framespersecond']; if ($fps > 0) { $video_duration = (int)floor($frames / $fps); } } // 如果没有计算出时长,使用任务表中的设定值 if ($video_duration <= 0 && !empty($task->extra_params)) { $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true); if (isset($extraParams['duration'])) { $video_duration = (int)$extraParams['duration']; } } // 只有当video_duration > 0时才添加到返回数据中 if ($video_duration > 0) { $returnData['video_duration'] = $video_duration; $returnData['video_time_point_start'] = 0; $returnData['video_time_point_end'] = $video_duration; } $returnData['status'] = 'success'; } elseif (in_array($taskStatus, ['not_found', 'expired'])) { $returnData['status'] = 'failed'; $returnData['error_message'] = '任务未找到或已过期'; } elseif ($taskStatus === 'failed') { $returnData['status'] = 'failed'; $returnData['error_message'] = '任务执行失败'; } // in_queue 或 generating状态保持原status return $returnData; } catch (\Exception $e) { return [ 'status' => 'failed', 'error_message' => $e->getMessage(), 'result_url' => null ]; } } /** * 更新所有待处理任务的状态 * * @return void */ public function updatePendingTasks(): void { // 获取所有处理中的任务 $tasks = MpGenerateVideoTask::where('status', MpGenerateVideoTask::STATUS_PROCESSING) ->get(); foreach ($tasks as $task) { $api_type = getProp($task, 'api_type'); switch($api_type) { case 'seedance': $this->updateSeedanceTask($task); break; case 'kuaikuai': $this->updateKuaikuaiTask($task); break; case 'jimeng': $this->updateJimengTask($task); break; case 'keling': $this->updateKelingOmniTask($task); break; case 'zzengine': $this->updateUnifiedApiTask($task); break; case 'video_enhance': $this->updateVideoEnhanceTask($task); break; default: break; } } } // 更新即梦AI视频任务状态 private function updateJimengTask($task) { $statusInfo = $this->queryJimengTaskStatus($task); if (!isset($statusInfo['status'])) return; if ($statusInfo['status'] === 'success') { logDB('generate', 'info', '即梦AI视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]); // 同步调整分镜视频状态和结果(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id && isset($statusInfo['result_url'])) { try { DB::beginTransaction(); // 原子抢占:只有仍为 processing 的任务能更新为 success,避免并发重复处理 if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('generate')->info('视频任务已被其他进程处理,跳过重复更新', [ 'id' => $task->id, 'result_url' => $statusInfo['result_url'] ?? null ]); return; } $now = date('Y-m-d H:i:s'); // 构建更新数据 $segmentUpdateData = [ 'origin_video_url' => $statusInfo['result_url'], 'video_task_status' => '已完成', 'current_type' => 2, 'last_frame_url' => $statusInfo['last_frame_url'] ?? '', 'updated_at' => $now ]; // 使用查询任务时已压缩的视频URL,避免重复压缩 $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url']; // 只有当video_duration存在且大于0时才更新 if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) { $segmentUpdateData['video_duration'] = $statusInfo['video_duration']; $segmentUpdateData['video_time_point_start'] = 0; $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null); $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0; } // 更新分镜表(兼容act模式) $updateSegmentResult = $this->updateSegmentTable($task, $segmentUpdateData); if ($updateSegmentResult === false || $updateSegmentResult === 0) { throw new \Exception('更新分镜表失败: 未找到对应的分镜记录'); } // 添加视频生成对话记录 $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']); // 视频生成成功,记录计费明细并扣减用户积分(与任务状态/分镜同事务) $this->chargeVideoTaskInTransaction($task); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('即梦视频任务处理失败', [ 'segment_id' => $segment_id, 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } else { // 没有segment_id的情况下,直接更新任务状态 // 原子抢占与计费同事务,避免并发重复处理 try { DB::beginTransaction(); if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('generate')->info('视频任务已被其他进程处理,跳过重复更新', [ 'id' => $task->id, 'result_url' => $statusInfo['result_url'] ?? null ]); return; } $this->chargeVideoTaskInTransaction($task); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('视频任务成功处理失败,已回滚', [ 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } } elseif ($statusInfo['status'] === 'failed') { logDB('generate', 'error', '即梦AI视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, $statusInfo); $this->addVideoGenerationFailedRecord($task, $statusInfo['error_message']); // 同步更新分镜表状态为失败(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id) { $this->updateSegmentTable($task, [ 'video_task_status' => '失败', 'updated_at' => date('Y-m-d H:i:s') ]); } } // 如果仍然是处理中状态,不做任何操作 // 处理:如果任务处理超过12小时,标记为失败 $processingTime = now()->diffInHours($task->created_at); if ($processingTime > 12) { logDB('generate', 'warning', '即梦AI视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '任务处理超时(超过12小时)' ]); $this->addVideoGenerationFailedRecord($task, '任务处理超时(超过12小时)'); // 同步更新分镜表状态为失败(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id) { $this->updateSegmentTable($task, [ 'video_task_status' => '失败', 'updated_at' => date('Y-m-d H:i:s') ]); } } } public function getTaskList($data) { $task_id = getProp($data, 'task_id'); $status = getProp($data, 'status'); $query = MpGenerateVideoTask::select('*'); if ($task_id) { $query->where('id', $task_id); } if ($status) { $query->where('status', $status); } return $query->orderBy('created_at', 'desc')->paginate(); } /** * 创建 Seedance 1.5 Pro 视频生成任务 * * @param array $params * @return MpGenerateVideoTask */ public function createSeedanceTask(array $params): MpGenerateVideoTask { // 分辨率统一规范化为小写(480p=原始档;sr_720p=480p生成+本地超分到720p) $params['video_resolution'] = strtolower(trim((string)($params['video_resolution'] ?? '720p'))); // 生成唯一的任务ID $taskId = 'seedance_' . time() . '_' . uniqid(); // 创建任务记录,复用现有表结构 $task = MpGenerateVideoTask::create([ 'task_id' => $taskId, 'alias_segment_id' => $params['alias_segment_id'] ?? '', 'alias_act_id' => $params['alias_act_id'] ?? '', 'prompt' => $params['prompt'] ?? '', 'ref_image_url' => $params['ref_image_url'] ?? json_encode([], 256), 'first_frame_url' => $params['first_frame_url'] ?? '', 'tail_frame_url' => $params['tail_frame_url'] ?? '', 'seed' => $params['seed'] ?? -1, 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null, 'video_resolution' => $params['video_resolution'] ?? '720P', 'status' => MpGenerateVideoTask::STATUS_PENDING, 'api_type' => 'seedance', // 标识为 Seedance 1.5 Pro API 'extra_params' => [ 'model' => $params['model'] ?? 'doubao-seedance-1-5-pro-251215', 'content' => $params['content'], 'callback_url' => $params['callback_url'] ?? '', 'return_last_frame' => $params['return_last_frame'] ?? true, 'service_tier' => $params['service_tier'] ?? 'default', 'execution_expires_after' => $params['execution_expires_after'] ?? 172800, 'video_resolution' => $params['video_resolution'] ?? '720P', 'generate_audio' => $params['generate_audio'] ?? false, 'draft' => $params['draft'] ?? false, 'ratio' => $params['ratio'] ?? '9:16', 'duration' => $params['video_duration'] ?? 5, 'camera_fixed' => $params['camera_fixed'] ?? false, 'watermark' => $params['watermark'] ?? false, ], 'charge_info' => $this->buildChargeInfo($params, 'seedance'), ]); // 提交任务到火山引擎 API $this->submitSeedanceTaskToApi($task); return $task; } /** * 创建 Seedance 2.0(百度AI网关)视频生成任务 * * @param array $params * @return MpGenerateVideoTask */ public function createSeedance20Task(array $params): MpGenerateVideoTask { // 分辨率统一规范化为小写(480p=原始档;sr_720p=480p生成+本地超分到720p) $params['video_resolution'] = strtolower(trim((string)($params['video_resolution'] ?? '720p'))); $modelName = (string)($params['model'] ?? 'doubao-seedance-2.0'); // 海外百度Seedance(dreamina-* 真人素材空间)使用独立AK;系统内统一 baidu- 前缀名 $isDreaminaModel = strpos($modelName, 'baidu-dreamina-') === 0; // 生成唯一的任务ID $taskId = 'seedance20_' . time() . '_' . uniqid(); // 创建任务记录,复用现有表结构 $task = MpGenerateVideoTask::create([ 'task_id' => $taskId, 'alias_segment_id' => $params['alias_segment_id'] ?? '', 'alias_act_id' => $params['alias_act_id'] ?? '', 'prompt' => $params['prompt'] ?? '', 'ref_image_url' => $params['ref_image_url'] ?? json_encode([], 256), 'first_frame_url' => $params['first_frame_url'] ?? '', 'tail_frame_url' => $params['tail_frame_url'] ?? '', 'seed' => $params['seed'] ?? -1, 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null, 'video_resolution' => $params['video_resolution'] ?? '720P', 'status' => MpGenerateVideoTask::STATUS_PENDING, 'api_type' => 'seedance', // 复用Seedance任务轮询流程(提交/查询接口按extra_params区分网关) 'extra_params' => [ 'model' => $modelName, 'content' => $params['content'], 'reference_images' => $params['reference_images'] ?? [], 'reference_image_assets' => $params['reference_image_assets'] ?? [], 'reference_video_assets' => $params['reference_video_assets'] ?? [], 'reference_audio_assets' => $params['reference_audio_assets'] ?? [], 'callback_url' => $params['callback_url'] ?? '', 'return_last_frame' => $params['return_last_frame'] ?? true, 'execution_expires_after' => $params['execution_expires_after'] ?? 172800, 'video_resolution' => $params['video_resolution'] ?? '720P', 'generate_audio' => $params['generate_audio'] ?? false, 'draft' => $params['draft'] ?? false, 'ratio' => $params['ratio'] ?? '9:16', 'duration' => $params['video_duration'] ?? 5, 'camera_fixed' => $params['camera_fixed'] ?? false, 'watermark' => $params['watermark'] ?? false, // 网关配置:默认百度AI网关;快快AI可传 https://ai-api.kkidc.com + gateway_path_prefix=/sd 'gateway_base_url' => $params['gateway_base_url'] ?? 'https://ai-gateway.baidubce.com', 'gateway_path_prefix' => $params['gateway_path_prefix'] ?? '', // 网关类型:baidu=国内百度、baidu_dreamina=海外百度(默认素材空间)、kuaikuai=快快AI 'gateway_kind' => $params['gateway_kind'] ?? ($isDreaminaModel ? 'baidu_dreamina' : 'baidu'), 'api_key_env' => $params['api_key_env'] ?? ($isDreaminaModel ? 'BAIDU_DREAMINA_AK' : 'BAIDU_SEEDANCE_20_AK'), ], 'charge_info' => $this->buildChargeInfo($params, 'seedance'), ]); // 提交任务到百度AI网关 $this->submitSeedanceTaskToApi($task); return $task; } /** * 提交 Seedance 任务到火山引擎 API * * @param MpGenerateVideoTask $task * @return void */ private function submitSeedanceTaskToApi(MpGenerateVideoTask $task): void { $client = new Client(['verify'=>false,'timeout'=>120]); try { $extraParams = $task->extra_params; // 网关配置:Seedance 2.0 走百度AI网关,其余默认走火山引擎 $gatewayBaseUrl = $extraParams['gateway_base_url'] ?? 'https://ark.cn-beijing.volces.com'; $apiKeyEnv = $extraParams['api_key_env'] ?? 'VOLC_AI_API_KEY'; $gatewayPathPrefix = $extraParams['gateway_path_prefix'] ?? ''; // 验证环境变量配置 $apiKey = env($apiKeyEnv); if (empty($apiKey)) { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $apiKeyEnv . ' 未配置,请检查环境变量API Key' ]); return; } $content = $extraParams['content'] ?? []; // Seedance 2.0 类网关需要先把图片上传为素材(国内百度AI网关/海外百度真人素材空间/快快AI) if (!empty($extraParams['gateway_base_url'])) { $gatewayKind = $extraParams['gateway_kind'] ?? ''; $isDreaminaGateway = $gatewayKind === 'baidu_dreamina' || strpos((string)($extraParams['model'] ?? ''), 'baidu-dreamina-') === 0; if (strpos($gatewayBaseUrl, 'ai-api.kkidc.com') !== false || $gatewayPathPrefix === '/sd') { $content = $this->convertKuaikuaiContentImagesToAssets($content); } elseif ($isDreaminaGateway) { // 海外百度素材走独立AK的默认素材空间(暂不使用 dreamina-seedance-livenessface 真人空间请求头) $content = $this->convertDreaminaContentImagesToAssets($content); } else { $content = $this->convertSeedance20ContentImagesToAssets($content); } } // 追加参考图素材(已由processReferenceImagesToSeedance20Assets上传并清理失败的图片标记) if (!empty($extraParams['gateway_base_url']) && !empty($extraParams['reference_image_assets'])) { foreach ($extraParams['reference_image_assets'] as $assetUrl) { if (empty($assetUrl)) { continue; } $content[] = [ 'type' => 'image_url', 'image_url' => [ 'url' => $assetUrl, ], 'role' => 'reference_image', ]; } } // 追加参考视频素材(已由processReferenceVideosToSeedance20Assets上传) if (!empty($extraParams['gateway_base_url']) && !empty($extraParams['reference_video_assets'])) { foreach ($extraParams['reference_video_assets'] as $assetUrl) { if (empty($assetUrl)) { continue; } $content[] = [ 'type' => 'video_url', 'video_url' => [ 'url' => $assetUrl, ], 'role' => 'reference_video', ]; } } // 追加参考音频素材(已由processReferenceAudiosToSeedance20Assets上传) if (!empty($extraParams['gateway_base_url']) && !empty($extraParams['reference_audio_assets'])) { foreach ($extraParams['reference_audio_assets'] as $assetUrl) { if (empty($assetUrl)) { continue; } $content[] = [ 'type' => 'audio_url', 'audio_url' => [ 'url' => $assetUrl, ], 'role' => 'reference_audio', ]; } } // 构建火山引擎 Seedance API 请求参数(百度模型先映射为网关原名) $requestModel = $this->baiduModelAliasMap((string)($extraParams['model'] ?? '')); $apiParams = [ 'model' => isset(\App\Consts\BaseConst::MODELS_MAP[$requestModel]) ? \App\Consts\BaseConst::MODELS_MAP[$requestModel] : $requestModel, 'content' => $content, ]; // 添加可选参数 if (!empty($extraParams['callback_url'])) { $apiParams['callback_url'] = $extraParams['callback_url']; } if (isset($extraParams['return_last_frame'])) { $apiParams['return_last_frame'] = $extraParams['return_last_frame']; } if (!empty($extraParams['service_tier']) && !in_array($requestModel, ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128', 'doubao-seedance-2.0'])) { $apiParams['service_tier'] = $extraParams['service_tier']; } if (!empty($extraParams['execution_expires_after'])) { $apiParams['execution_expires_after'] = $extraParams['execution_expires_after']; } if (isset($extraParams['generate_audio'])) { $apiParams['generate_audio'] = $extraParams['generate_audio']; } if (isset($extraParams['draft'])) { $apiParams['draft'] = $extraParams['draft']; } if (!empty($extraParams['video_resolution'])) { $apiParams['resolution'] = $this->apiRequestResolution($task->video_resolution); } if (!empty($extraParams['ratio'])) { $apiParams['ratio'] = $extraParams['ratio']; } if (!empty($extraParams['duration'])) { $apiParams['duration'] = (int)$extraParams['duration']; } // 百度 Seedance 2.5(含海外dreamina 2.5)带参考视频时,入参与快快 seed-2.5 一致:ratio=adaptive、duration=-1 if (in_array($requestModel, ['doubao-seedance-2-5-260628', 'dreamina-seedance-2-5-260628'], true) && !empty($extraParams['reference_video_assets'])) { $apiParams['ratio'] = 'adaptive'; $apiParams['duration'] = -1; } if (isset($task->frames)) { // $apiParams['frames'] = $task->frames; } if ($task->seed !== -1) { $apiParams['seed'] = (int)$task->seed; } if (isset($extraParams['camera_fixed'])) { $apiParams['camera_fixed'] = $extraParams['camera_fixed']; } if (isset($extraParams['watermark'])) { $apiParams['watermark'] = $extraParams['watermark']; } dLog('generate')->info('Seedance 视频生成参数: ', $apiParams); logDB('generate', 'info', 'Seedance视频生成任务提交', ['id' => $task->id, 'model' => $apiParams['model'], 'params' => $apiParams]); // 调用 Seedance API $response = $client->post($gatewayBaseUrl . $gatewayPathPrefix . '/api/v3/contents/generations/tasks', [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', ], 'json' => $apiParams ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('Seedance API 响应: ', $responseData); logDB('generate', 'info', 'Seedance视频生成API响应', ['id' => $task->id, 'response' => $responseData]); if (isset($responseData['error'])) { // API返回错误 logDB('generate', 'error', 'Seedance视频生成任务提交失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? 'API Error']); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $responseData['error']['message'] ?? 'API Error', 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]); } elseif (isset($responseData['id'])) { // 任务提交成功,更新状态为处理中 $updateData = [ 'task_id' => $responseData['id'], // 使用 API 返回的任务 ID 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]; $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData); } else { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '未知的 API 响应格式', 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]); } } catch (\Exception $e) { // 记录错误 $error = $this->buildGatewayErrorPayload($e); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $error['error_message'], 'result_json' => $error['payload'], 'extra_params' => array_merge($task->extra_params, ['api_response' => $error['payload']]) ]); Utils::throwError('1001:'.$e->getMessage()); } } /** * 提取网关请求异常的响应体,尽量完整记录到 result_json * * @param \Exception $e * @return array ['error_message' => string, 'payload' => array] */ private function buildGatewayErrorPayload(\Exception $e): array { $errorBody = ''; if ($e instanceof \GuzzleHttp\Exception\RequestException && $e->hasResponse()) { try { $errorBody = (string)$e->getResponse()->getBody(); } catch (\Throwable $ignored) { } } $payload = ['exception' => $e->getMessage()]; if ($errorBody !== '') { $decoded = json_decode($errorBody, true); $payload['response_body'] = (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) ? $decoded : $errorBody; } return [ 'error_message' => 'API请求失败: ' . $e->getMessage() . ($errorBody !== '' ? ' 响应: ' . mb_substr($errorBody, 0, 500) : ''), 'payload' => $payload, ]; } /** * 创建快快AI Seedance 2.0/2.5 视频生成任务 * 接口:POST {KUAIKUAI_AI_BASE_URL}/v1/video/generations(火山豆包格式) * 根据入参自动选择场景:多模态参考生视频 > 首尾帧 > 首帧 > 文生视频(API地址一致,仅入参兼容) * * @param array $params * @return MpGenerateVideoTask */ public function createKuaikuaiVideoTask(array $params): MpGenerateVideoTask { // 分辨率统一规范化为小写(480p=原始档;sr_720p=480p生成+本地超分到720p) $params['video_resolution'] = strtolower(trim((string)($params['video_resolution'] ?? '720p'))); // 生成唯一的任务ID $taskId = 'kuaikuai_' . time() . '_' . uniqid(); // 创建任务记录,复用现有表结构 $task = MpGenerateVideoTask::create([ 'task_id' => $taskId, 'alias_segment_id' => $params['alias_segment_id'] ?? '', 'alias_act_id' => $params['alias_act_id'] ?? '', 'prompt' => $params['prompt'] ?? '', 'ref_image_url' => $params['ref_image_url'] ?? json_encode([], 256), 'first_frame_url' => $params['first_frame_url'] ?? '', 'tail_frame_url' => $params['tail_frame_url'] ?? '', 'seed' => $params['seed'] ?? -1, 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null, 'video_resolution' => $params['video_resolution'] ?? '720P', 'status' => MpGenerateVideoTask::STATUS_PENDING, 'api_type' => 'kuaikuai', 'extra_params' => [ 'model' => $params['model'] ?? 'seed-2', 'prompt' => $params['prompt'] ?? '', 'first_frame_url' => $params['first_frame_url'] ?? '', 'tail_frame_url' => $params['tail_frame_url'] ?? '', 'reference_images' => $params['reference_images'] ?? [], 'reference_image_assets' => $params['reference_image_assets'] ?? [], 'reference_video_assets' => $params['reference_video_assets'] ?? [], 'reference_audio_assets' => $params['reference_audio_assets'] ?? [], 'video_resolution' => $params['video_resolution'] ?? '720P', 'generate_audio' => $params['generate_audio'] ?? false, 'ratio' => $params['ratio'] ?? '9:16', 'duration' => $params['video_duration'] ?? 5, 'watermark' => $params['watermark'] ?? false, 'gateway_base_url' => $params['gateway_base_url'] ?? env('KUAIKUAI_AI_BASE_URL', 'https://ai-api.kkidc.com'), // 视频生成按模型选择对应令牌;素材上传才使用KUAIKUAI_SEEDANCE_COMMON_AK 'api_key_env' => $this->getKuaikuaiApiKeyEnv($params['model'] ?? 'seed-2'), ], 'charge_info' => $this->buildChargeInfo($params, 'seedance'), ]); // 提交任务到快快AI网关 $this->submitKuaikuaiTaskToApi($task); return $task; } /** * 根据快快AI模型获取对应的API Key环境变量名 * 素材接口统一使用KUAIKUAI_SEEDANCE_COMMON_AK,视频生成按模型使用各自令牌 * * @param string $model * @return string */ private function getKuaikuaiApiKeyEnv(string $model): string { $map = [ 'seed-2' => 'KUAIKUAI_SEED_2_AK', 'seed-2-fast' => 'KUAIKUAI_SEED_2_FAST_AK', 'seed-2-mini' => 'KUAIKUAI_SEED_2_MINI_AK', 'seed-2.5' => 'KUAIKUAI_SEED_2_5_AK', ]; return $map[$model] ?? 'KUAIKUAI_SEED_2_AK'; } /** * 提交快快AI视频生成任务 * * @param MpGenerateVideoTask $task * @return void */ private function submitKuaikuaiTaskToApi(MpGenerateVideoTask $task): void { $client = new Client(['verify' => false, 'timeout' => 120]); try { $extraParams = $task->extra_params; $gatewayBaseUrl = rtrim((string)($extraParams['gateway_base_url'] ?? env('KUAIKUAI_AI_BASE_URL', 'https://ai-api.kkidc.com')), '/'); $apiKeyEnv = $extraParams['api_key_env'] ?? 'KUAIKUAI_SEEDANCE_COMMON_AK'; $apiKey = env($apiKeyEnv); if (empty($apiKey)) { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $apiKeyEnv . ' 未配置,请检查环境变量API Key' ]); return; } $apiParams = $this->buildKuaikuaiRequestBody($extraParams); dLog('generate')->info('快快AI视频生成参数: ', $apiParams); logDB('generate', 'info', '快快AI视频生成任务提交', ['id' => $task->id, 'model' => $apiParams['model'] ?? '', 'params' => $apiParams]); $response = $client->post($gatewayBaseUrl . '/v1/video/generations', [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', ], 'json' => $apiParams, ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('快快AI视频生成API响应: ', $responseData); logDB('generate', 'info', '快快AI视频生成API响应', ['id' => $task->id, 'response' => $responseData]); if (isset($responseData['error'])) { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $responseData['error']['message'] ?? (is_string($responseData['error']) ? $responseData['error'] : 'API Error'), 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]); return; } $apiTaskId = $responseData['id'] ?? $responseData['task_id'] ?? ''; if (!empty($apiTaskId)) { // 任务提交成功,更新状态为处理中,并保存API返回的任务ID $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, [ 'task_id' => $apiTaskId, 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]); } else { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '未知的 API 响应格式', 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]); } } catch (\Exception $e) { $error = $this->buildGatewayErrorPayload($e); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $error['error_message'], 'result_json' => $error['payload'], 'extra_params' => array_merge($task->extra_params, ['api_response' => $error['payload']]) ]); Utils::throwError('1001:' . $e->getMessage()); } } /** * 构建快快AI视频生成请求体(火山豆包格式,POST /v1/video/generations) * 根据入参自动选择场景: * 1. 有参考图/参考视频/参考音频 -> 多模态(参考图-图生视频同地址,仅入参兼容) * 2. 首帧+尾帧 -> 首尾帧-图生视频 * 3. 仅首帧 -> 首帧-图生视频 * 4. 否则 -> 文生视频 * * @param array $params * @return array */ private function buildKuaikuaiRequestBody(array $params): array { $model = $params['model'] ?? 'seed-2'; $prompt = trim((string)($params['prompt'] ?? '')); $ratio = $params['ratio'] ?? '9:16'; $duration = (int)($params['duration'] ?? $params['video_duration'] ?? 5); if ($duration < 4) $duration = 4; if ($duration > 15) $duration = 15; $watermark = !empty($params['watermark']); $generateAudio = isset($params['generate_audio']) ? (bool)$params['generate_audio'] : true; // 分辨率归一化:480p / 720p / 1080p / 4k(sr_1080p 请求时按 720p 生成,本地再超分) $resolution = $this->apiRequestResolution((string)($params['resolution'] ?? $params['video_resolution'] ?? '720p')); if (in_array($resolution, ['4k', '2160p', '2160'], true)) { $resolution = '4k'; } else { $resolution = rtrim($resolution, 'p') . 'p'; } if (!in_array($resolution, ['480p', '720p', '1080p', '4k'], true)) { $resolution = '720p'; } $referenceImages = array_values(array_filter($params['reference_image_assets'] ?? $params['reference_images'] ?? [])); $referenceVideos = array_values(array_filter($params['reference_video_assets'] ?? $params['reference_videos'] ?? [])); $referenceAudios = array_values(array_filter($params['reference_audio_assets'] ?? $params['reference_audios'] ?? [])); $firstFrame = $params['first_frame_url'] ?? ''; $tailFrame = $params['tail_frame_url'] ?? ''; // 请求接口model按 参考视频/分辨率 适配(如 seed-2 -> seed-2-vision-1080) // 系统内记录保持原模型不变,仅外部接口调用时转换 $hasReferenceVideo = !empty($referenceVideos); $model = $this->kuaikuaiRequestModel($model, $resolution, $hasReferenceVideo); // seed-2.5 带参考视频(seed-2.5-vision)时,视频编辑任务需将 ratio 设为 adaptive、duration 设为 -1 if ($hasReferenceVideo && ($params['model'] ?? '') === 'seed-2.5') { $ratio = 'adaptive'; $duration = -1; } // 多模态/参考图场景(API地址一致,仅入参兼容) if ($referenceImages || $referenceVideos || $referenceAudios) { // 有参考素材且存在首帧时,首帧并入参考图首位(文档多模态示例以图片1作为首帧) if ($firstFrame) { $referenceImages = array_values(array_unique(array_merge([$firstFrame], $referenceImages))); } $metadata = [ 'generate_audio' => $generateAudio, 'ratio' => $ratio, 'duration' => $duration, 'watermark' => $watermark, 'resolution' => $resolution, ]; if ($referenceImages) $metadata['reference_images'] = $referenceImages; if ($referenceVideos) $metadata['reference_videos'] = $referenceVideos; if ($referenceAudios) $metadata['reference_audios'] = $referenceAudios; return ['model' => $model, 'prompt' => $prompt, 'metadata' => $metadata]; } // 首尾帧-图生视频 if ($firstFrame && $tailFrame) { return [ 'model' => $model, 'prompt' => $prompt, 'metadata' => [ 'first_frame_image' => $firstFrame, 'last_frame_image' => $tailFrame, 'generate_audio' => $generateAudio, 'ratio' => $ratio, 'duration' => $duration, 'watermark' => $watermark, ], ]; } // 首帧-图生视频 if ($firstFrame) { return [ 'model' => $model, 'prompt' => $prompt, 'image' => $firstFrame, 'metadata' => [ 'generate_audio' => $generateAudio, 'ratio' => $ratio, 'duration' => $duration, 'watermark' => $watermark, ], ]; } // 文生视频 return [ 'model' => $model, 'prompt' => $prompt, 'metadata' => [ 'generate_audio' => $generateAudio, 'ratio' => $ratio, 'duration' => $duration, 'watermark' => $watermark, ], ]; } /** * 快快AI请求接口模型名适配(仅外部接口调用传参,系统记录保持原模型名) * 规则: * - seed-2 / seed-2.5:480p/720p -> 原模型;1080p -> -1080;4k -> -4k * 带参考视频时分别对应 -vision / -vision-1080 / -vision-4k * - seed-2-fast / seed-2-mini:仅支持 -vision(不带分辨率变体) * * @param string $model 系统内模型名 * @param string $resolution 归一化分辨率(480p/720p/1080p/4k) * @param bool $hasReferenceVideo 是否带参考视频 * @return string */ private function kuaikuaiRequestModel(string $model, string $resolution, bool $hasReferenceVideo): string { // seed-2 / seed-2.5 支持 1080p / 4k 分辨率变体(与参考视频组合 -vision) if (in_array($model, ['seed-2', 'seed-2.5'], true)) { $base = $model . ($hasReferenceVideo ? '-vision' : ''); if ($resolution === '1080p') { return $base . '-1080'; } if ($resolution === '4k') { return $base . '-4k'; } return $base; } // seed-2-fast / seed-2-mini 仅适配 -vision,不带分辨率 if ($hasReferenceVideo) { $map = [ 'seed-2-fast' => 'seed-2-fast-vision', 'seed-2-mini' => 'seed-2-mini-vision', ]; return $map[$model] ?? $model; } return $model; } /** * 百度AI网关模型名映射:系统内使用 baidu- 前缀名,请求网关时映射为 doubao- 原名 * * @param string $model * @return string */ private function baiduModelAliasMap(string $model): string { $map = [ 'baidu-doubao-seedance-2-0-260128' => 'doubao-seedance-2-0-260128', 'baidu-doubao-seedance-2-0-fast-260128' => 'doubao-seedance-2-0-fast-260128', 'baidu-doubao-seedance-2-0-mini-260615' => 'doubao-seedance-2-0-mini-260615', 'baidu-doubao-seedance-2-5-260628' => 'doubao-seedance-2-5-260628', // 海外百度Seedance(dreamina)真人素材模型:系统内 baidu- 前缀,请求网关时去掉前缀 'baidu-dreamina-seedance-2-0-260128' => 'dreamina-seedance-2-0-260128', 'baidu-dreamina-seedance-2-0-fast-260128' => 'dreamina-seedance-2-0-fast-260128', 'baidu-dreamina-seedance-2-0-mini-260615' => 'dreamina-seedance-2-0-mini-260615', 'baidu-dreamina-seedance-2-5-260628' => 'dreamina-seedance-2-5-260628', ]; return $map[$model] ?? $model; } /** * 请求外部API时的分辨率映射 * sr_720p(超分720p)模式:外部按480p生成,本地再超分到720p,因此请求分辨率统一改为480p * sr_1080p(超分1080p)模式:外部按720p生成,本地再超分到1080p,因此请求分辨率统一改为720p * * @param string $resolution * @return string */ private function apiRequestResolution(string $resolution): string { $resolution = strtolower(trim($resolution)); if (in_array($resolution, ['sr_720p', 'sr720p'], true)) { return '480p'; } if (in_array($resolution, ['sr_1080p', 'sr1080p'], true)) { return '720p'; } return $resolution; } /** * 解析任务分辨率的本地超分目标档位 * 档位语义: * - 480p:原始480p,生成480p并直接交付,不做本地超分 * - sr_720p:按480p生成,本地超分到720p * - sr_1080p:按720p生成,本地超分到1080p * - 720p / 1080p / 4k:直接按目标档位生成,不做本地超分 * * @param string $resolution * @return string '720p'|'1080p'|''(空表示无需本地超分) */ private function resolutionUpscaleTarget(string $resolution): string { $resolution = strtolower(trim($resolution)); if (in_array($resolution, ['sr_720p', 'sr720p'], true)) { return '720p'; } if (in_array($resolution, ['sr_1080p', 'sr1080p'], true)) { return '1080p'; } return ''; } /** * 判断任务是否需要本地超分(仅 sr_720p / sr_1080p 档触发) * * @param object $task * @return bool */ private function shouldUpgradeVideoResolution($task): bool { return $this->resolutionUpscaleTarget((string)getProp($task, 'video_resolution', '')) !== ''; } /** * 获取任务本地超分目标档位(显式传给 upgrade480pTo720p) * * @param object $task * @return string '720p'|'1080p'|''(空表示无需本地超分) */ private function getVideoUpgradeTarget($task): string { return $this->resolutionUpscaleTarget((string)getProp($task, 'video_resolution', '')); } /** * 将 Seedance 2.0 参考图上传为百度AI网关素材 * 失败处理与智帧AI的processReferenceImagesToAssets一致:删除参考图的同时清理提示词中的<图片N>标记 * * @param array $referenceImages 参考图片URL数组 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败图片的标记) * @return array 返回素材ID数组(asset://格式) */ public function processReferenceImagesToSeedance20Assets(array $referenceImages, string &$fullPrompt): array { $assetIds = []; $failedIndexes = []; // 记录失败的图片序号 foreach ($referenceImages as $index => $imageUrl) { $imageIndex = $index + 1; // 图片序号从1开始 try { dLog('generate')->info("开始处理Seedance 2.0参考图片 {$imageIndex}: {$imageUrl}"); // 上传素材(已存在则直接复用,不重复上传) $createResult = $this->createSeedance20Asset($imageUrl, "参考图片_{$imageIndex}_" . time()); // 网关素材异步处理为间歇性故障,失败后间隔重试,避免因网关临时故障误判 for ($retry = 1; $retry <= 5 && $createResult['code'] !== 0; $retry++) { sleep($retry <= 3 ? 10 : 30); dLog('generate')->warning("图片 {$imageIndex} Seedance 2.0素材第 {$retry} 次重试上传"); $createResult = $this->createSeedance20Asset($imageUrl, "参考图片_{$imageIndex}_" . time()); } if ($createResult['code'] !== 0) { $errorMessage = (string)($createResult['msg'] ?? '素材创建失败'); dLog('generate')->error("图片 {$imageIndex} 创建Seedance 2.0素材失败: " . $errorMessage); logDB('generate', 'error', 'Seedance2.0创建素材失败', [ 'image_url' => $imageUrl, 'index' => $imageIndex, 'error' => $errorMessage ]); // 素材不可用会直接影响视频效果,且任务会扣费,失败直接中断任务创建 Utils::throwError('20003:参考图片' . $imageIndex . '上传素材失败:' . $errorMessage); } $assetIds[] = $createResult['data']['asset_id']; dLog('generate')->info("图片 {$imageIndex} Seedance 2.0素材上传成功,asset_id: " . $createResult['data']['asset_id']); } catch (\Exception $e) { dLog('generate')->error("图片 {$imageIndex} Seedance 2.0素材处理异常: " . $e->getMessage()); logDB('generate', 'error', 'Seedance2.0素材处理异常', [ 'image_url' => $imageUrl, 'index' => $imageIndex, 'error' => $e->getMessage() ]); $failedIndexes[] = $imageIndex; } } // 处理失败的图片:从 fullPrompt 中删除对应的标记并调整序号 if (!empty($failedIndexes)) { dLog('generate')->info('开始处理Seedance 2.0失败图片的提示词调整,失败序号: ' . implode(',', $failedIndexes)); // 按序号从大到小排序,避免删除时影响后续序号 rsort($failedIndexes); foreach ($failedIndexes as $failedIndex) { // 删除失败图片的标记,例如 <图片1>、<图片2> 等 $pattern = "/<图片{$failedIndex}>/u"; $fullPrompt = preg_replace($pattern, '', $fullPrompt); dLog('generate')->info("已从提示词中删除 <图片{$failedIndex}>"); } // 重新调整剩余图片的序号 $needAdjust = false; foreach ($failedIndexes as $failedIndex) { $needAdjust = true; // 将所有大于失败序号的图片标记序号减1 for ($i = $failedIndex + 1; $i <= count($referenceImages); $i++) { $oldPattern = "/<图片{$i}>/u"; $newTag = "<图片" . ($i - 1) . ">"; $fullPrompt = preg_replace($oldPattern, $newTag, $fullPrompt); } } if ($needAdjust) { dLog('generate')->info('Seedance 2.0提示词图片序号调整完成'); } } dLog('generate')->info('Seedance 2.0参考图片转素材完成,成功数量: ' . count($assetIds) . ',失败数量: ' . count($failedIndexes)); // 格式化为可直接在API接口中使用的数组 foreach ($assetIds as &$assetId) { $assetId = "asset://{$assetId}"; } unset($assetId); return $assetIds; } /** * 将 Seedance 2.0 参考视频上传为百度AI网关素材 * 失败处理与参考图一致:删除参考视频的同时清理提示词中的<视频N>标记 * * @param array $referenceVideos 参考视频URL数组 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败视频的标记) * @return array 返回素材ID数组(asset://格式) */ public function processReferenceVideosToSeedance20Assets(array $referenceVideos, string &$fullPrompt): array { return $this->processReferenceMediaToSeedance20Assets($referenceVideos, MpAsset::TYPE_VIDEO, '参考视频', '视频', $fullPrompt); } /** * 将 Seedance 2.0 参考音频上传为百度AI网关素材 * 失败处理与参考图一致:删除参考音频的同时清理提示词中的<音频N>标记 * * @param array $referenceAudios 参考音频URL数组 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败音频的标记) * @return array 返回素材ID数组(asset://格式) */ public function processReferenceAudiosToSeedance20Assets(array $referenceAudios, string &$fullPrompt): array { return $this->processReferenceMediaToSeedance20Assets($referenceAudios, MpAsset::TYPE_AUDIO, '参考音频', '音频', $fullPrompt); } /** * 将 Seedance 2.0 参考视频/音频上传为百度AI网关素材(通用方法) * * @param array $urls 参考素材URL数组 * @param string $assetType 素材类型(Video/Audio) * @param string $label 素材中文名(用于日志和素材命名) * @param string $tagName 提示词标记名(视频/音频),用于 <{tagName}N> 标记的清理 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败素材的标记) * @return array 返回素材ID数组(asset://格式) */ private function processReferenceMediaToSeedance20Assets(array $urls, string $assetType, string $label, string $tagName, string &$fullPrompt): array { $assetIds = []; $failedIndexes = []; // 记录失败的素材序号 foreach ($urls as $index => $url) { $mediaIndex = $index + 1; // 序号从1开始 try { dLog('generate')->info("开始处理Seedance 2.0{$label} {$mediaIndex}: {$url}"); // 上传素材(已存在则直接复用,不重复上传) $createResult = $this->createSeedance20Asset($url, "{$label}_{$mediaIndex}_" . time(), $assetType); // 网关素材异步处理为间歇性故障,失败后间隔重试,避免因网关临时故障误判 for ($retry = 1; $retry <= 5 && $createResult['code'] !== 0; $retry++) { sleep($retry <= 3 ? 10 : 30); dLog('generate')->warning("{$label} {$mediaIndex} Seedance 2.0素材第 {$retry} 次重试上传"); $createResult = $this->createSeedance20Asset($url, "{$label}_{$mediaIndex}_" . time(), $assetType); } if ($createResult['code'] !== 0) { $errorMessage = (string)($createResult['msg'] ?? '素材创建失败'); dLog('generate')->error("{$label} {$mediaIndex} 创建Seedance 2.0素材失败: " . $errorMessage); logDB('generate', 'error', 'Seedance2.0创建素材失败', [ 'url' => $url, 'asset_type' => $assetType, 'index' => $mediaIndex, 'error' => $errorMessage ]); // 素材不可用会直接影响视频效果,且任务会扣费,失败直接中断任务创建 Utils::throwError('20003:' . $label . $mediaIndex . '上传素材失败:' . $errorMessage); } $assetIds[] = $createResult['data']['asset_id']; dLog('generate')->info("{$label} {$mediaIndex} Seedance 2.0素材上传成功,asset_id: " . $createResult['data']['asset_id']); } catch (\Exception $e) { dLog('generate')->error("{$label} {$mediaIndex} Seedance 2.0素材处理异常: " . $e->getMessage()); logDB('generate', 'error', 'Seedance2.0素材处理异常', [ 'url' => $url, 'asset_type' => $assetType, 'index' => $mediaIndex, 'error' => $e->getMessage() ]); $failedIndexes[] = $mediaIndex; } } // 处理失败的素材:从 fullPrompt 中删除对应的标记并调整序号(与参考图处理一致) if (!empty($failedIndexes)) { dLog('generate')->info("开始处理Seedance 2.0失败{$label}的提示词调整,失败序号: " . implode(',', $failedIndexes)); // 按序号从大到小排序,避免删除时影响后续序号 rsort($failedIndexes); foreach ($failedIndexes as $failedIndex) { // 删除失败素材的标记,例如 <视频1>、<音频2> 等 $pattern = "/<{$tagName}{$failedIndex}>/u"; $fullPrompt = preg_replace($pattern, '', $fullPrompt); dLog('generate')->info("已从提示词中删除 <{$tagName}{$failedIndex}>"); } // 重新调整剩余素材的序号 $needAdjust = false; foreach ($failedIndexes as $failedIndex) { $needAdjust = true; // 将所有大于失败序号的素材标记序号减1 for ($i = $failedIndex + 1; $i <= count($urls); $i++) { $oldPattern = "/<{$tagName}{$i}>/u"; $newTag = "<{$tagName}" . ($i - 1) . ">"; $fullPrompt = preg_replace($oldPattern, $newTag, $fullPrompt); } } if ($needAdjust) { dLog('generate')->info('Seedance 2.0提示词' . $label . '序号调整完成'); } } dLog('generate')->info("Seedance 2.0{$label}转素材完成,成功数量: " . count($assetIds) . ',失败数量: ' . count($failedIndexes)); // 格式化为可直接在API接口中使用的数组 foreach ($assetIds as &$assetId) { $assetId = "asset://{$assetId}"; } unset($assetId); return $assetIds; } /** * 将 Seedance 2.0 内容中的图片URL上传为百度AI网关素材 * 上传失败的图片直接从content中移除,不阻断整个任务 * * @param array $content * @return array 转换后的content(图片URL替换为 asset://{AssetId}) */ private function convertSeedance20ContentImagesToAssets(array $content): array { foreach ($content as $index => &$item) { if (($item['type'] ?? '') !== 'image_url') { continue; } $imageUrl = $item['image_url']['url'] ?? ''; if (empty($imageUrl) || strpos($imageUrl, 'asset://') === 0) { continue; } $name = 'seedance20_' . time() . '_' . uniqid(); $createResult = $this->createSeedance20Asset($imageUrl, $name); // 网关素材异步处理为间歇性故障,失败后间隔重试,避免因网关临时故障误判 for ($retry = 1; $retry <= 5 && $createResult['code'] !== 0; $retry++) { sleep($retry <= 3 ? 10 : 30); dLog('generate')->warning('Seedance 2.0 图片上传素材第 ' . $retry . ' 次重试: ' . $imageUrl); $createResult = $this->createSeedance20Asset($imageUrl, $name); } if ($createResult['code'] !== 0) { $errorMessage = (string)($createResult['msg'] ?? '素材创建失败'); dLog('generate')->error('Seedance 2.0 图片上传素材失败: ' . $imageUrl . ',原因: ' . $errorMessage); logDB('generate', 'error', 'Seedance2.0图片转素材失败', [ 'image_url' => $imageUrl, 'error' => $errorMessage ]); // 素材不可用会直接影响视频效果,且任务会扣费,失败直接中断任务创建 Utils::throwError('20003:图片上传素材失败:' . $errorMessage); } $item['image_url']['url'] = 'asset://' . $createResult['data']['asset_id']; dLog('generate')->info('Seedance 2.0 图片转素材成功: ' . $imageUrl . ' => asset://' . $createResult['data']['asset_id']); } unset($item); return array_values($content); } /** * 上传图片到百度AI网关素材库并等待素材可用 * 上传前先检查mp_assets中是否已存在该url(source_type=baidu),存在则直接复用素材ID,避免重复上传 * * @param string $url 素材公网地址 * @param string $name 素材名称 * @param string $assetType 素材类型(Image/Video/Audio) * @return array ['code'=>0, 'data'=>['asset_id'=>...]] 成功;['code'=>20003, 'msg'=>...] 失败 */ private function createSeedance20Asset(string $url, string $name, string $assetType = MpAsset::TYPE_IMAGE): array { try { if (empty($url)) { return [ 'code' => 1, 'msg' => '素材URL不能为空', 'data' => null ]; } // 先获取表中是否已有该url(仅限百度网关来源且状态可用的素材) // 只复用百度网关上传成功的素材,避免复用 Failed 记录或其它网关(ark/kuaikuai)的素材 ID $asset = DB::table('mp_assets') ->where('url', $url) ->where('source_type', 'baidu') ->where('status', MpAsset::STATUS_ACTIVE) ->orderBy('id', 'desc') ->first(); if ($asset) { $extraParams = is_array($asset->extra_params) ? $asset->extra_params : json_decode((string)$asset->extra_params, true); $isBaiduGatewayAsset = is_array($extraParams) && isset($extraParams['Result']); if ($isBaiduGatewayAsset) { dLog('generate')->info('Seedance 2.0 素材已存在,直接复用: ' . $url . ' => ' . $asset->asset_id); return [ 'code' => 0, 'msg' => '创建成功', 'data' => [ 'asset_id' => $asset->asset_id, 'group_id' => $asset->group_id, 'status' => 'ready', ] ]; } } $groupId = $this->ensureSeedance20AssetGroup(); $createAssetBody = [ 'ProjectName' => 'default', 'GroupId' => $groupId, 'AssetType' => $assetType, 'Name' => $name, 'URL' => $url, ]; try { $responseData = $this->requestSeedance20Gateway('CreateAsset', $createAssetBody); } catch (\GuzzleHttp\Exception\RequestException $e) { $errorBody = $e->hasResponse() ? (string)$e->getResponse()->getBody() : ''; // 素材组空间失效/不存在(资产空间迁移或素材组被清理),创建新素材组并重试一次 $groupInvalid = strpos($errorBody, 'asset_space_mismatch') !== false || strpos($errorBody, 'NotFound.group_id') !== false || (stripos($errorBody, 'not found') !== false && stripos($errorBody, 'asset_group') !== false); if (!$groupInvalid) { throw $e; } dLog('generate')->warning('Seedance 2.0 素材组空间失效或不存在,创建新素材组重试: ' . $groupId); logDB('generate', 'warning', 'Seedance2.0素材组空间失效或不存在', [ 'group_id' => $groupId, 'error' => $errorBody ]); $groupId = $this->createSeedance20AssetGroup(); $createAssetBody['GroupId'] = $groupId; $responseData = $this->requestSeedance20Gateway('CreateAsset', $createAssetBody); } $result = $responseData['Result'] ?? []; $assetId = $result['Id'] ?? $result['AssetId'] ?? ''; if (empty($assetId)) { logDB('generate', 'error', 'Seedance2.0创建素材失败', ['url' => $url, 'response' => $responseData]); return [ 'code' => 20003, 'msg' => '创建Seedance 2.0素材失败: ' . json_encode($responseData, JSON_UNESCAPED_UNICODE), 'data' => null ]; } // 保存素材记录到数据库 MpAsset::create([ 'asset_id' => $assetId, 'group_id' => $groupId, 'group_name' => 'default', 'name' => $name, 'url' => $url, 'asset_type' => $assetType, 'source_type' => 'baidu', 'status' => MpAsset::STATUS_PROCESSING, 'extra_params' => $responseData, ]); // 轮询查询素材状态(每3秒一次,最多100次即5分钟) $maxAttempts = 100; $assetStatus = null; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { sleep(3); $assetInfo = $this->getSeedance20Asset($assetId); $assetStatus = $assetInfo['Status'] ?? ''; dLog('generate')->info("Seedance 2.0 素材 {$assetId} 状态查询第 {$attempt} 次,状态: {$assetStatus}"); if ($assetStatus === 'Active') { return [ 'code' => 0, 'msg' => '创建成功', 'data' => [ 'asset_id' => $assetId, 'group_id' => $groupId, ] ]; } if ($assetStatus === 'Failed') { $errorMessage = $assetInfo['Error']['Message'] ?? json_encode($assetInfo, JSON_UNESCAPED_UNICODE); logDB('generate', 'error', 'Seedance2.0素材处理失败', [ 'asset_id' => $assetId, 'url' => $url, 'error' => $errorMessage ]); return [ 'code' => 20003, 'msg' => 'Seedance 2.0素材处理失败: ' . $errorMessage, 'data' => null ]; } } logDB('generate', 'error', 'Seedance2.0素材处理超时', [ 'asset_id' => $assetId, 'url' => $url, 'last_status' => $assetStatus ]); return [ 'code' => 20003, 'msg' => 'Seedance 2.0素材处理超时: ' . $assetId . ',最后状态: ' . ($assetStatus ?? 'unknown'), 'data' => null ]; } catch (\Exception $e) { dLog('generate')->error('Seedance 2.0创建素材异常: ' . $e->getMessage()); logDB('generate', 'error', 'Seedance2.0创建素材异常', ['url' => $url, 'error' => $e->getMessage()]); return [ 'code' => 20003, 'msg' => '创建Seedance 2.0素材失败: ' . $e->getMessage(), 'data' => null ]; } } /** * 查询百度AI网关素材状态(优先查本地mp_assets,处理中/缺失时请求网关并同步状态) * * @param string $assetId * @return array 素材信息(含Status字段) */ private function getSeedance20Asset(string $assetId): array { // 先查本地素材表(仅限百度网关来源的素材) $assetInfo = DB::table('mp_assets')->where('asset_id', $assetId)->where('source_type', 'baidu')->first(); if ($assetInfo) { $assetInfo = (array)$assetInfo; $status = getProp($assetInfo, 'status'); if (in_array($status, [MpAsset::STATUS_ACTIVE, MpAsset::STATUS_FAILED])) { $result = [ 'Id' => $assetInfo['asset_id'], 'Status' => $status, 'URL' => $assetInfo['url'] ?? '', ]; if ($status === MpAsset::STATUS_FAILED && !empty($assetInfo['error_message'])) { $result['Error'] = ['Message' => $assetInfo['error_message']]; } return $result; } } $responseData = $this->requestSeedance20Gateway('GetAsset', [ 'ProjectName' => 'default', 'Id' => $assetId, ]); $result = $responseData['Result'] ?? []; // 兼容不同返回结构 if (isset($result['Asset']) && is_array($result['Asset'])) { $result = $result['Asset']; } // 查询失败时快速结束,避免长时间轮询 if (empty($result) && isset($responseData['Error'])) { $result['Status'] = 'Failed'; $result['Error'] = $responseData['Error']; } // 同步更新本地素材状态 if (!empty($result['Status'])) { $updateData = ['status' => $result['Status']]; if (!empty($result['Error']['Message'])) { $updateData['error_message'] = $result['Error']['Message']; } DB::table('mp_assets') ->where('asset_id', $assetId) ->where('source_type', 'baidu') ->update($updateData); } return $result; } /** * 获取可用的Seedance 2.0素材组ID * 优先复用本地已有素材组,避免依赖网关ListAssetGroups接口(该接口近期返回400) * * @return string */ private function ensureSeedance20AssetGroup(): string { // 优先复用本地已有的百度素材组 $localGroup = DB::table('mp_assets') ->where('source_type', 'baidu') ->whereNotNull('group_id') ->where('group_id', '!=', '') ->where('asset_id', 'not like', 'group-%') ->orderBy('id', 'desc') ->select('group_id') ->first(); if ($localGroup && !empty($localGroup->group_id)) { dLog('generate')->info('Seedance 2.0 复用本地素材组: ' . $localGroup->group_id); return $localGroup->group_id; } // 本地无记录时再查询网关素材组 try { $groups = $this->listSeedance20AssetGroups(); if (!empty($groups)) { $groupId = $groups[0]['Id'] ?? $groups[0]['GroupId'] ?? ''; if (!empty($groupId)) { return $groupId; } } } catch (\Exception $e) { dLog('generate')->warning('Seedance 2.0 ListAssetGroups查询失败,尝试直接创建素材组: ' . $e->getMessage()); logDB('generate', 'warning', 'Seedance2.0 ListAssetGroups失败', ['error' => $e->getMessage()]); } return $this->createSeedance20AssetGroup(); } /** * 查询百度AI网关素材组列表 * * @return array */ private function listSeedance20AssetGroups(): array { $responseData = $this->requestSeedance20Gateway('ListAssetGroups', [ 'ProjectName' => 'default', ]); $result = $responseData['Result'] ?? []; $groups = $result['AssetGroups'] ?? $result['Groups'] ?? $result['Items'] ?? []; return is_array($groups) ? $groups : []; } /** * 创建Seedance 2.0默认素材组 * * @return string 素材组ID */ private function createSeedance20AssetGroup(): string { $responseData = $this->requestSeedance20Gateway('CreateAssetGroup', [ 'ProjectName' => 'default', 'Name' => 'default', 'Description' => 'Seedance 2.0 默认素材组', ]); $result = $responseData['Result'] ?? []; $groupId = $result['GroupId'] ?? $result['Id'] ?? ''; if (empty($groupId)) { throw new \Exception('创建Seedance 2.0素材组失败: ' . json_encode($responseData, JSON_UNESCAPED_UNICODE)); } // 持久化素材组记录(占位),避免后续每次都调用网关查询素材组 $groupRecordExists = DB::table('mp_assets') ->where('group_id', $groupId) ->where('source_type', 'baidu') ->where('asset_id', 'like', 'group-%') ->exists(); if (!$groupRecordExists) { MpAsset::create([ 'asset_id' => 'group-' . $groupId, 'group_id' => $groupId, 'group_name' => 'default', 'name' => $result['Name'] ?? 'default', 'url' => 'baidu_group://' . $groupId, 'asset_type' => MpAsset::TYPE_IMAGE, 'source_type' => 'baidu', 'status' => MpAsset::STATUS_ACTIVE, 'extra_params' => $responseData, ]); } return $groupId; } /** * 调用百度AI网关素材接口 * * @param string $action 接口Action(CreateAssetGroup/CreateAsset/GetAsset等) * @param array $body 请求体 * @return array 响应数组 */ private function requestSeedance20Gateway(string $action, array $body): array { $apiKey = env('BAIDU_SEEDANCE_20_AK'); if (empty($apiKey)) { throw new \Exception('百度Seedance 2.0 API KEY未配置,请检查环境变量BAIDU_SEEDANCE_20_AK'); } $client = new Client(['verify' => false, 'timeout' => 120]); $response = $client->post('https://ai-gateway.baidubce.com/?Action=' . $action . '&Version=2024-01-01', [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', ], 'json' => $body, ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('Seedance 2.0 网关 ' . $action . ' 响应: ', $responseData); return $responseData; } /** * 将海外百度Seedance(dreamina)参考图上传为海外真人素材空间素材 * 失败处理与国内Seedance 2.0一致:删除参考图的同时清理提示词中的<图片N>标记 * * @param array $referenceImages 参考图片URL数组 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败图片的标记) * @return array 返回素材ID数组(asset://格式) */ public function processReferenceImagesToBaiduDreaminaAssets(array $referenceImages, string &$fullPrompt): array { $assetIds = []; $failedIndexes = []; // 记录失败的图片序号 foreach ($referenceImages as $index => $imageUrl) { $imageIndex = $index + 1; // 图片序号从1开始 try { dLog('generate')->info("开始处理海外百度Seedance参考图片 {$imageIndex}: {$imageUrl}"); // 上传素材到海外真人素材空间(已存在则直接复用,不重复上传) $createResult = $this->createBaiduDreaminaAsset($imageUrl, "参考图片_{$imageIndex}_" . time()); // 网关素材异步处理为间歇性故障,失败后间隔重试,避免因网关临时故障误判 for ($retry = 1; $retry <= 5 && $createResult['code'] !== 0; $retry++) { sleep($retry <= 3 ? 10 : 30); dLog('generate')->warning("图片 {$imageIndex} 海外百度Seedance素材第 {$retry} 次重试上传"); $createResult = $this->createBaiduDreaminaAsset($imageUrl, "参考图片_{$imageIndex}_" . time()); } if ($createResult['code'] !== 0) { $errorMessage = (string)($createResult['msg'] ?? '素材创建失败'); dLog('generate')->error("图片 {$imageIndex} 创建海外百度Seedance素材失败: " . $errorMessage); logDB('generate', 'error', '海外百度Seedance创建素材失败', [ 'image_url' => $imageUrl, 'index' => $imageIndex, 'error' => $errorMessage ]); // 素材不可用会直接影响视频效果,且任务会扣费,失败直接中断任务创建 Utils::throwError('20003:参考图片' . $imageIndex . '上传素材失败:' . $errorMessage); } $assetIds[] = $createResult['data']['asset_id']; dLog('generate')->info("图片 {$imageIndex} 海外百度Seedance素材上传成功,asset_id: " . $createResult['data']['asset_id']); } catch (\Exception $e) { dLog('generate')->error("图片 {$imageIndex} 海外百度Seedance素材处理异常: " . $e->getMessage()); logDB('generate', 'error', '海外百度Seedance素材处理异常', [ 'image_url' => $imageUrl, 'index' => $imageIndex, 'error' => $e->getMessage() ]); $failedIndexes[] = $imageIndex; } } // 处理失败的图片:从 fullPrompt 中删除对应的标记并调整序号 if (!empty($failedIndexes)) { dLog('generate')->info('开始处理海外百度Seedance失败图片的提示词调整,失败序号: ' . implode(',', $failedIndexes)); // 按序号从大到小排序,避免删除时影响后续序号 rsort($failedIndexes); foreach ($failedIndexes as $failedIndex) { // 删除失败图片的标记,例如 <图片1>、<图片2> 等 $pattern = "/<图片{$failedIndex}>/u"; $fullPrompt = preg_replace($pattern, '', $fullPrompt); dLog('generate')->info("已从提示词中删除 <图片{$failedIndex}>"); } // 重新调整剩余图片的序号 $needAdjust = false; foreach ($failedIndexes as $failedIndex) { $needAdjust = true; // 将所有大于失败序号的图片标记序号减1 for ($i = $failedIndex + 1; $i <= count($referenceImages); $i++) { $oldPattern = "/<图片{$i}>/u"; $newTag = "<图片" . ($i - 1) . ">"; $fullPrompt = preg_replace($oldPattern, $newTag, $fullPrompt); } } if ($needAdjust) { dLog('generate')->info('海外百度Seedance提示词图片序号调整完成'); } } dLog('generate')->info('海外百度Seedance参考图片转素材完成,成功数量: ' . count($assetIds) . ',失败数量: ' . count($failedIndexes)); // 格式化为可直接在API接口中使用的数组 foreach ($assetIds as &$assetId) { $assetId = "asset://{$assetId}"; } unset($assetId); return $assetIds; } /** * 将海外百度Seedance(dreamina)参考视频上传为海外真人素材空间素材 * * @param array $referenceVideos 参考视频URL数组 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败视频的标记) * @return array 返回素材ID数组(asset://格式) */ public function processReferenceVideosToBaiduDreaminaAssets(array $referenceVideos, string &$fullPrompt): array { return $this->processReferenceMediaToBaiduDreaminaAssets($referenceVideos, MpAsset::TYPE_VIDEO, '参考视频', '视频', $fullPrompt); } /** * 将海外百度Seedance(dreamina)参考音频上传为海外真人素材空间素材 * * @param array $referenceAudios 参考音频URL数组 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败音频的标记) * @return array 返回素材ID数组(asset://格式) */ public function processReferenceAudiosToBaiduDreaminaAssets(array $referenceAudios, string &$fullPrompt): array { return $this->processReferenceMediaToBaiduDreaminaAssets($referenceAudios, MpAsset::TYPE_AUDIO, '参考音频', '音频', $fullPrompt); } /** * 将海外百度Seedance 参考视频/音频上传为海外真人素材空间素材(通用方法) * * @param array $urls 参考素材URL数组 * @param string $assetType 素材类型(Video/Audio) * @param string $label 素材中文名(用于日志和素材命名) * @param string $tagName 提示词标记名(视频/音频),用于 <{tagName}N> 标记的清理 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败素材的标记) * @return array 返回素材ID数组(asset://格式) */ private function processReferenceMediaToBaiduDreaminaAssets(array $urls, string $assetType, string $label, string $tagName, string &$fullPrompt): array { $assetIds = []; $failedIndexes = []; // 记录失败的素材序号 foreach ($urls as $index => $url) { $mediaIndex = $index + 1; // 序号从1开始 try { dLog('generate')->info("开始处理海外百度Seedance{$label} {$mediaIndex}: {$url}"); // 上传素材到海外真人素材空间(已存在则直接复用,不重复上传) $createResult = $this->createBaiduDreaminaAsset($url, "{$label}_{$mediaIndex}_" . time(), $assetType); // 网关素材异步处理为间歇性故障,失败后间隔重试,避免因网关临时故障误判 for ($retry = 1; $retry <= 5 && $createResult['code'] !== 0; $retry++) { sleep($retry <= 3 ? 10 : 30); dLog('generate')->warning("{$label} {$mediaIndex} 海外百度Seedance素材第 {$retry} 次重试上传"); $createResult = $this->createBaiduDreaminaAsset($url, "{$label}_{$mediaIndex}_" . time(), $assetType); } if ($createResult['code'] !== 0) { $errorMessage = (string)($createResult['msg'] ?? '素材创建失败'); dLog('generate')->error("{$label} {$mediaIndex} 创建海外百度Seedance素材失败: " . $errorMessage); logDB('generate', 'error', '海外百度Seedance创建素材失败', [ 'url' => $url, 'asset_type' => $assetType, 'index' => $mediaIndex, 'error' => $errorMessage ]); // 素材不可用会直接影响视频效果,且任务会扣费,失败直接中断任务创建 Utils::throwError('20003:' . $label . $mediaIndex . '上传素材失败:' . $errorMessage); } $assetIds[] = $createResult['data']['asset_id']; dLog('generate')->info("{$label} {$mediaIndex} 海外百度Seedance素材上传成功,asset_id: " . $createResult['data']['asset_id']); } catch (\Exception $e) { dLog('generate')->error("{$label} {$mediaIndex} 海外百度Seedance素材处理异常: " . $e->getMessage()); logDB('generate', 'error', '海外百度Seedance素材处理异常', [ 'url' => $url, 'asset_type' => $assetType, 'index' => $mediaIndex, 'error' => $e->getMessage() ]); $failedIndexes[] = $mediaIndex; } } // 处理失败的素材:从 fullPrompt 中删除对应的标记并调整序号(与参考图处理一致) if (!empty($failedIndexes)) { dLog('generate')->info("开始处理海外百度Seedance失败{$label}的提示词调整,失败序号: " . implode(',', $failedIndexes)); // 按序号从大到小排序,避免删除时影响后续序号 rsort($failedIndexes); foreach ($failedIndexes as $failedIndex) { // 删除失败素材的标记,例如 <视频1>、<音频2> 等 $pattern = "/<{$tagName}{$failedIndex}>/u"; $fullPrompt = preg_replace($pattern, '', $fullPrompt); dLog('generate')->info("已从提示词中删除 <{$tagName}{$failedIndex}>"); } // 重新调整剩余素材的序号 $needAdjust = false; foreach ($failedIndexes as $failedIndex) { $needAdjust = true; // 将所有大于失败序号的素材标记序号减1 for ($i = $failedIndex + 1; $i <= count($urls); $i++) { $oldPattern = "/<{$tagName}{$i}>/u"; $newTag = "<{$tagName}" . ($i - 1) . ">"; $fullPrompt = preg_replace($oldPattern, $newTag, $fullPrompt); } } if ($needAdjust) { dLog('generate')->info('海外百度Seedance提示词' . $label . '序号调整完成'); } } dLog('generate')->info("海外百度Seedance{$label}转素材完成,成功数量: " . count($assetIds) . ',失败数量: ' . count($failedIndexes)); // 格式化为可直接在API接口中使用的数组 foreach ($assetIds as &$assetId) { $assetId = "asset://{$assetId}"; } unset($assetId); return $assetIds; } /** * 将海外百度Seedance 内容中的图片URL上传为海外真人素材空间素材 * 上传失败的图片直接从content中移除,不阻断整个任务 * * @param array $content * @return array 转换后的content(图片URL替换为 asset://{AssetId}) */ private function convertDreaminaContentImagesToAssets(array $content): array { foreach ($content as $index => &$item) { if (($item['type'] ?? '') !== 'image_url') { continue; } $imageUrl = $item['image_url']['url'] ?? ''; if (empty($imageUrl) || strpos($imageUrl, 'asset://') === 0) { continue; } $name = 'dreamina_' . time() . '_' . uniqid(); $createResult = $this->createBaiduDreaminaAsset($imageUrl, $name); // 网关素材异步处理为间歇性故障,失败后间隔重试,避免因网关临时故障误判 for ($retry = 1; $retry <= 5 && $createResult['code'] !== 0; $retry++) { sleep($retry <= 3 ? 10 : 30); dLog('generate')->warning('海外百度Seedance 图片上传素材第 ' . $retry . ' 次重试: ' . $imageUrl); $createResult = $this->createBaiduDreaminaAsset($imageUrl, $name); } if ($createResult['code'] !== 0) { $errorMessage = (string)($createResult['msg'] ?? '素材创建失败'); dLog('generate')->error('海外百度Seedance 图片上传素材失败: ' . $imageUrl . ',原因: ' . $errorMessage); logDB('generate', 'error', '海外百度Seedance图片转素材失败', [ 'image_url' => $imageUrl, 'error' => $errorMessage ]); // 素材不可用会直接影响视频效果,且任务会扣费,失败直接中断任务创建 Utils::throwError('20003:图片上传素材失败:' . $errorMessage); } $item['image_url']['url'] = 'asset://' . $createResult['data']['asset_id']; dLog('generate')->info('海外百度Seedance 图片转素材成功: ' . $imageUrl . ' => asset://' . $createResult['data']['asset_id']); } unset($item); return array_values($content); } /** * 上传图片到海外百度AI网关真人素材空间并等待素材可用 * 上传前先检查mp_assets中是否已存在该url(source_type=baidu_dreamina),存在则直接复用素材ID,避免重复上传 * * @param string $url 素材公网地址 * @param string $name 素材名称 * @param string $assetType 素材类型(Image/Video/Audio) * @return array ['code'=>0, 'data'=>['asset_id'=>...]] 成功;['code'=>20003, 'msg'=>...] 失败 */ private function createBaiduDreaminaAsset(string $url, string $name, string $assetType = MpAsset::TYPE_IMAGE): array { try { if (empty($url)) { return [ 'code' => 1, 'msg' => '素材URL不能为空', 'data' => null ]; } // 先获取表中是否已有该url(仅限海外百度网关来源且状态可用的素材) // 只复用海外百度网关上传成功的素材,避免复用 Failed 记录或其它网关(ark/kuaikuai/国内baidu)的素材 ID $asset = DB::table('mp_assets') ->where('url', $url) ->where('source_type', 'baidu_dreamina') ->where('status', MpAsset::STATUS_ACTIVE) ->orderBy('id', 'desc') ->first(); if ($asset) { $extraParams = is_array($asset->extra_params) ? $asset->extra_params : json_decode((string)$asset->extra_params, true); $isBaiduGatewayAsset = is_array($extraParams) && isset($extraParams['Result']); if ($isBaiduGatewayAsset) { dLog('generate')->info('海外百度Seedance 素材已存在,直接复用: ' . $url . ' => ' . $asset->asset_id); return [ 'code' => 0, 'msg' => '创建成功', 'data' => [ 'asset_id' => $asset->asset_id, 'group_id' => $asset->group_id, 'status' => 'ready', ] ]; } } $groupId = $this->ensureBaiduDreaminaAssetGroup(); $createAssetBody = [ 'ProjectName' => 'default', 'GroupId' => $groupId, 'AssetType' => $assetType, 'Name' => $name, 'URL' => $url, ]; try { $responseData = $this->requestBaiduDreaminaGateway('CreateAsset', $createAssetBody); } catch (\GuzzleHttp\Exception\RequestException $e) { $errorBody = $e->hasResponse() ? (string)$e->getResponse()->getBody() : ''; // 素材组空间失效/不存在(资产空间迁移或素材组被清理),创建新素材组并重试一次 $groupInvalid = strpos($errorBody, 'asset_space_mismatch') !== false || strpos($errorBody, 'NotFound.group_id') !== false || (stripos($errorBody, 'not found') !== false && stripos($errorBody, 'asset_group') !== false); if (!$groupInvalid) { throw $e; } dLog('generate')->warning('海外百度Seedance 素材组空间失效或不存在,创建新素材组重试: ' . $groupId); logDB('generate', 'warning', '海外百度Seedance素材组空间失效或不存在', [ 'group_id' => $groupId, 'error' => $errorBody ]); $groupId = $this->createBaiduDreaminaAssetGroup(); $createAssetBody['GroupId'] = $groupId; $responseData = $this->requestBaiduDreaminaGateway('CreateAsset', $createAssetBody); } $result = $responseData['Result'] ?? []; $assetId = $result['Id'] ?? $result['AssetId'] ?? ''; if (empty($assetId)) { logDB('generate', 'error', '海外百度Seedance创建素材失败', ['url' => $url, 'response' => $responseData]); return [ 'code' => 20003, 'msg' => '创建海外百度Seedance素材失败: ' . json_encode($responseData, JSON_UNESCAPED_UNICODE), 'data' => null ]; } // 保存素材记录到数据库(海外百度素材类型:baidu_dreamina) MpAsset::create([ 'asset_id' => $assetId, 'group_id' => $groupId, 'group_name' => 'default', 'name' => $name, 'url' => $url, 'asset_type' => $assetType, 'source_type' => 'baidu_dreamina', 'status' => MpAsset::STATUS_PROCESSING, 'extra_params' => $responseData, ]); // 轮询查询素材状态(每3秒一次,最多100次即5分钟) $maxAttempts = 100; $assetStatus = null; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { sleep(3); $assetInfo = $this->getBaiduDreaminaAsset($assetId); $assetStatus = $assetInfo['Status'] ?? ''; dLog('generate')->info("海外百度Seedance 素材 {$assetId} 状态查询第 {$attempt} 次,状态: {$assetStatus}"); if ($assetStatus === 'Active') { return [ 'code' => 0, 'msg' => '创建成功', 'data' => [ 'asset_id' => $assetId, 'group_id' => $groupId, ] ]; } if ($assetStatus === 'Failed') { $errorMessage = $assetInfo['Error']['Message'] ?? json_encode($assetInfo, JSON_UNESCAPED_UNICODE); logDB('generate', 'error', '海外百度Seedance素材处理失败', [ 'asset_id' => $assetId, 'url' => $url, 'error' => $errorMessage ]); return [ 'code' => 20003, 'msg' => '海外百度Seedance素材处理失败: ' . $errorMessage, 'data' => null ]; } } logDB('generate', 'error', '海外百度Seedance素材处理超时', [ 'asset_id' => $assetId, 'url' => $url, 'last_status' => $assetStatus ]); return [ 'code' => 20003, 'msg' => '海外百度Seedance素材处理超时: ' . $assetId . ',最后状态: ' . ($assetStatus ?? 'unknown'), 'data' => null ]; } catch (\Exception $e) { dLog('generate')->error('海外百度Seedance创建素材异常: ' . $e->getMessage()); logDB('generate', 'error', '海外百度Seedance创建素材异常', ['url' => $url, 'error' => $e->getMessage()]); return [ 'code' => 20003, 'msg' => '创建海外百度Seedance素材失败: ' . $e->getMessage(), 'data' => null ]; } } /** * 查询海外百度AI网关素材状态(优先查本地mp_assets,处理中/缺失时请求网关并同步状态) * * @param string $assetId * @return array 素材信息(含Status字段) */ private function getBaiduDreaminaAsset(string $assetId): array { // 先查本地素材表(仅限海外百度网关来源的素材) $assetInfo = DB::table('mp_assets')->where('asset_id', $assetId)->where('source_type', 'baidu_dreamina')->first(); if ($assetInfo) { $assetInfo = (array)$assetInfo; $status = getProp($assetInfo, 'status'); if (in_array($status, [MpAsset::STATUS_ACTIVE, MpAsset::STATUS_FAILED])) { $result = [ 'Id' => $assetInfo['asset_id'], 'Status' => $status, 'URL' => $assetInfo['url'] ?? '', ]; if ($status === MpAsset::STATUS_FAILED && !empty($assetInfo['error_message'])) { $result['Error'] = ['Message' => $assetInfo['error_message']]; } return $result; } } $responseData = $this->requestBaiduDreaminaGateway('GetAsset', [ 'ProjectName' => 'default', 'Id' => $assetId, ]); $result = $responseData['Result'] ?? []; // 兼容不同返回结构 if (isset($result['Asset']) && is_array($result['Asset'])) { $result = $result['Asset']; } // 查询失败时快速结束,避免长时间轮询 if (empty($result) && isset($responseData['Error'])) { $result['Status'] = 'Failed'; $result['Error'] = $responseData['Error']; } // 同步更新本地素材状态 if (!empty($result['Status'])) { $updateData = ['status' => $result['Status']]; if (!empty($result['Error']['Message'])) { $updateData['error_message'] = $result['Error']['Message']; } DB::table('mp_assets') ->where('asset_id', $assetId) ->where('source_type', 'baidu_dreamina') ->update($updateData); } return $result; } /** * 获取可用的海外百度Seedance素材组ID * 优先复用本地已有素材组,避免依赖网关ListAssetGroups接口(该接口近期返回400) * * @return string */ private function ensureBaiduDreaminaAssetGroup(): string { // 优先复用本地已有的海外百度素材组 $localGroup = DB::table('mp_assets') ->where('source_type', 'baidu_dreamina') ->whereNotNull('group_id') ->where('group_id', '!=', '') ->where('asset_id', 'not like', 'group-%') ->orderBy('id', 'desc') ->select('group_id') ->first(); if ($localGroup && !empty($localGroup->group_id)) { dLog('generate')->info('海外百度Seedance 复用本地素材组: ' . $localGroup->group_id); return $localGroup->group_id; } // 本地无记录时再查询网关素材组 try { $groups = $this->listBaiduDreaminaAssetGroups(); if (!empty($groups)) { $groupId = $groups[0]['Id'] ?? $groups[0]['GroupId'] ?? ''; if (!empty($groupId)) { return $groupId; } } } catch (\Exception $e) { dLog('generate')->warning('海外百度Seedance ListAssetGroups查询失败,尝试直接创建素材组: ' . $e->getMessage()); logDB('generate', 'warning', '海外百度Seedance ListAssetGroups失败', ['error' => $e->getMessage()]); } return $this->createBaiduDreaminaAssetGroup(); } /** * 查询海外百度AI网关素材组列表 * * @return array */ private function listBaiduDreaminaAssetGroups(): array { $responseData = $this->requestBaiduDreaminaGateway('ListAssetGroups', [ 'ProjectName' => 'default', ]); $result = $responseData['Result'] ?? []; $groups = $result['AssetGroups'] ?? $result['Groups'] ?? $result['Items'] ?? []; return is_array($groups) ? $groups : []; } /** * 创建海外百度Seedance默认素材组 * * @return string 素材组ID */ private function createBaiduDreaminaAssetGroup(): string { $responseData = $this->requestBaiduDreaminaGateway('CreateAssetGroup', [ 'ProjectName' => 'default', 'Name' => 'default', 'Description' => '海外百度Seedance 默认素材组', ]); $result = $responseData['Result'] ?? []; $groupId = $result['GroupId'] ?? $result['Id'] ?? ''; if (empty($groupId)) { throw new \Exception('创建海外百度Seedance素材组失败: ' . json_encode($responseData, JSON_UNESCAPED_UNICODE)); } // 持久化素材组记录(占位),避免后续每次都调用网关查询素材组 $groupRecordExists = DB::table('mp_assets') ->where('group_id', $groupId) ->where('source_type', 'baidu_dreamina') ->where('asset_id', 'like', 'group-%') ->exists(); if (!$groupRecordExists) { MpAsset::create([ 'asset_id' => 'group-' . $groupId, 'group_id' => $groupId, 'group_name' => 'default', 'name' => $result['Name'] ?? 'default', 'url' => 'baidu_dreamina_group://' . $groupId, 'asset_type' => MpAsset::TYPE_IMAGE, 'source_type' => 'baidu_dreamina', 'status' => MpAsset::STATUS_ACTIVE, 'extra_params' => $responseData, ]); } return $groupId; } /** * 调用海外百度AI网关素材接口 * 与国内百度素材库的区别:使用独立AK(BAIDU_DREAMINA_AK),素材存放于海外账号默认素材空间 * * 注意:真人素材空间请求头(target-asset-space: dreamina-seedance-livenessface) * 当前确认暂不使用,先注释保留: * - 实测该真人空间不支持 CreateAssetGroup(HTTP 424 UNIFY_ROUTE_UNSUPPORTED), * 且 ListAssetGroups 查询不到素材组明细,代码无法自动建组; * - 不带该请求头时,海外AK的默认素材空间可正常 建组/创建素材/素材立即可用, * 国内版与海外版素材互不共享(账号与区域隔离),素材类型需按请求模型区分。 * * @param string $action 接口Action(CreateAssetGroup/CreateAsset/GetAsset等) * @param array $body 请求体 * @return array 响应数组 */ private function requestBaiduDreaminaGateway(string $action, array $body): array { $apiKey = env('BAIDU_DREAMINA_AK'); if (empty($apiKey)) { throw new \Exception('海外百度Seedance API KEY未配置,请检查环境变量BAIDU_DREAMINA_AK'); } $client = new Client(['verify' => false, 'timeout' => 120]); $response = $client->post('https://ai-gateway.baidubce.com/?Action=' . $action . '&Version=2024-01-01', [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', // 真人素材空间请求头暂不使用(见方法注释),保留以备后续真人空间流程启用 // 'target-asset-space' => 'dreamina-seedance-livenessface', ], 'json' => $body, ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('海外百度Seedance 网关 ' . $action . ' 响应: ', $responseData); return $responseData; } /** * 调用快快AI聚合网关素材接口(真人素材库) * 文档:https://ai.kkidc.com/api-docs/9207319m0 * * @param string $method HTTP方法(GET/POST/PUT/DELETE) * @param string $path 接口路径,如 /v1/assets/upload、/v1/assets/{asset_id} * @param array $body 请求体(POST/PUT) * @return array 统一响应结构 ['success'=>bool,'message'=>string,'data'=>...] */ private function requestKuaikuaiGateway(string $method, string $path, array $body = []): array { $baseUrl = rtrim((string)env('KUAIKUAI_AI_BASE_URL', 'https://ai-api.kkidc.com'), '/'); $apiKey = env('KUAIKUAI_SEEDANCE_COMMON_AK'); if (empty($apiKey)) { throw new \Exception('快快AI API KEY未配置,请检查环境变量KUAIKUAI_SEEDANCE_COMMON_AK'); } $client = new Client(['verify' => false, 'timeout' => 120]); $options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], ]; if (!empty($body)) { $options['json'] = $body; } $response = $client->request($method, $baseUrl . $path, $options); $responseData = json_decode($response->getBody(), true); if (!is_array($responseData)) { throw new \Exception('快快AI网关返回格式错误: ' . (string)$response->getBody()); } if (empty($responseData['success'])) { throw new \Exception('快快AI网关调用失败: ' . ($responseData['message'] ?? json_encode($responseData, JSON_UNESCAPED_UNICODE))); } dLog('generate')->info('快快AI网关 ' . $method . ' ' . $path . ' 响应: ', $responseData); return $responseData; } /** * 快快AI素材状态转本地状态 * pending->Processing、ready->Active、failed->Failed * * @param string $status 快快AI状态 * @return string */ private function kuaikuaiStatusToLocal(string $status): string { switch (strtolower(trim($status))) { case 'ready': case 'success': return MpAsset::STATUS_ACTIVE; case 'failed': case 'failure': return MpAsset::STATUS_FAILED; default: return MpAsset::STATUS_PROCESSING; } } /** * 本地素材类型转快快AI素材类型(image/video/audio) * * @param string $assetType * @return string */ private function kuaikuaiAssetType(string $assetType): string { switch ($assetType) { case MpAsset::TYPE_VIDEO: return 'video'; case MpAsset::TYPE_AUDIO: return 'audio'; default: return 'image'; } } /** * 获取可用的快快AI素材组ID * 优先复用本地已有的快快AI素材组;本地无记录时不传group_id,由网关自动创建并随响应返回 * * @return string 可能为空字符串(网关自动创建) */ private function ensureKuaikuaiAssetGroup(): string { $localGroup = DB::table('mp_assets') ->where('source_type', 'kuaikuai') ->whereNotNull('group_id') ->where('group_id', '!=', '') ->where('asset_id', 'not like', 'group-%') ->orderBy('id', 'desc') ->select('group_id') ->first(); if ($localGroup && !empty($localGroup->group_id)) { return $localGroup->group_id; } return ''; } /** * 查询快快AI单个素材状态(优先查本地mp_assets,处理中/缺失时请求网关并同步状态) * 返回字段使用快快AI语义:status 为 pending/ready/failed * * @param string $assetId 平台中立素材ID(mat_xxx) * @return array 素材信息(含status字段) */ private function getKuaikuaiAsset(string $assetId): array { // 先查本地素材表(仅限快快AI来源的素材) $assetInfo = DB::table('mp_assets')->where('asset_id', $assetId)->where('source_type', 'kuaikuai')->first(); if ($assetInfo) { $assetInfo = (array)$assetInfo; $localStatus = getProp($assetInfo, 'status'); if (in_array($localStatus, [MpAsset::STATUS_ACTIVE, MpAsset::STATUS_FAILED])) { $result = [ 'asset_id' => $assetInfo['asset_id'], 'group_id' => $assetInfo['group_id'] ?? '', 'type' => strtolower($assetInfo['asset_type'] ?? 'image'), 'purpose' => $assetInfo['name'] ?? '', 'original_url' => $assetInfo['url'] ?? '', 'status' => $localStatus === MpAsset::STATUS_ACTIVE ? 'ready' : 'failed', ]; if ($localStatus === MpAsset::STATUS_FAILED && !empty($assetInfo['error_message'])) { $result['failure_reason'] = $assetInfo['error_message']; } return $result; } } try { $responseData = $this->requestKuaikuaiGateway('GET', '/v1/assets/' . urlencode($assetId)); } catch (\Exception $e) { // 素材不存在或无权访问(404 asset not found)等,返回失败状态并同步本地 dLog('generate')->warning('快快AI素材查询失败: ' . $assetId . ',原因: ' . $e->getMessage()); if ($assetInfo) { DB::table('mp_assets') ->where('asset_id', $assetId) ->where('source_type', 'kuaikuai') ->update([ 'status' => MpAsset::STATUS_FAILED, 'error_message' => $e->getMessage(), ]); } return [ 'asset_id' => $assetId, 'status' => 'failed', 'failure_reason' => $e->getMessage(), ]; } $result = $responseData['data'] ?? []; if (!isset($result['asset_id'])) { $result['asset_id'] = $assetId; } // 状态归一化:ready/success 视为就绪,failed/failure 视为失败 $resultStatus = $result['status'] ?? ''; if (!empty($resultStatus)) { $normalizedStatus = in_array(strtolower((string)$resultStatus), ['ready', 'success'], true) ? 'ready' : (in_array(strtolower((string)$resultStatus), ['failed', 'failure'], true) ? 'failed' : 'pending'); $result['status'] = $normalizedStatus; // 同步更新本地素材状态(同时刷新extra_params为最新查询结果,避免保留上传时的pending快照) $updateData = [ 'status' => $this->kuaikuaiStatusToLocal($normalizedStatus), 'extra_params' => json_encode($responseData, JSON_UNESCAPED_UNICODE), 'updated_at' => date('Y-m-d H:i:s'), ]; if (!empty($result['failure_reason'])) { $updateData['error_message'] = $result['failure_reason']; } if (!empty($result['group_id'])) { $updateData['group_id'] = $result['group_id']; } DB::table('mp_assets') ->where('asset_id', $assetId) ->where('source_type', 'kuaikuai') ->update($updateData); } return $result; } /** * 查询快快AI素材是否可用于生成视频(对外接口) * * @param string $assetId 素材ID(mat_xxx) * @return array ['code'=>0,'data'=>['asset_id','status','usable','failure_reason']] */ public function queryKuaikuaiAssetUsable(string $assetId): array { try { if (empty($assetId)) { return ['code' => 1, 'msg' => '素材ID不能为空', 'data' => null]; } $info = $this->getKuaikuaiAsset($assetId); $status = $info['status'] ?? ''; return [ 'code' => 0, 'msg' => '查询成功', 'data' => [ 'asset_id' => $assetId, 'status' => $status, 'usable' => $status === 'ready', 'failure_reason' => $info['failure_reason'] ?? ($info['error'] ?? ''), ], ]; } catch (\Exception $e) { dLog('generate')->error('快快AI素材可用性查询异常: ' . $e->getMessage()); return ['code' => 20003, 'msg' => '查询素材状态失败: ' . $e->getMessage(), 'data' => null]; } } /** * 上传素材到快快AI素材库并落库mp_assets * 上传前先检查mp_assets中是否已存在该url(source_type=kuaikuai),存在则直接复用素材ID,避免重复上传 * 同一group_id上传同一URL为幂等操作 * * @param string $url 素材公网地址 * @param string $name 素材名称(作为purpose传入) * @param string $assetType 素材类型(Image/Video/Audio) * @param string $purpose 素材用途/描述,缺省用$name * @param bool $waitForReady 是否同步等待素材变为ready(最多约90秒) * @return array ['code'=>0,'data'=>['asset_id'=>...,'group_id'=>...,'status'=>...]];失败返回 ['code'=>20003,'msg'=>...] */ public function createKuaikuaiAsset(string $url, string $name, string $assetType = MpAsset::TYPE_IMAGE, string $purpose = '', bool $waitForReady = true): array { try { if (empty($url)) { return ['code' => 1, 'msg' => '素材URL不能为空', 'data' => null]; } // 先获取表中是否已有该url(仅限快快AI来源且状态可用的素材) // 只复用网关上传成功的素材,避免复用 Failed 记录或其它网关(ark)的素材 ID $asset = DB::table('mp_assets') ->where('url', $url) ->where('source_type', 'kuaikuai') ->where('status', MpAsset::STATUS_ACTIVE) ->orderBy('id', 'desc') ->first(); if ($asset) { $extraParams = is_array($asset->extra_params) ? $asset->extra_params : json_decode((string)$asset->extra_params, true); $isKuaikuaiGatewayAsset = is_array($extraParams) && isset($extraParams['success']); if ($isKuaikuaiGatewayAsset) { dLog('generate')->info('快快AI素材已存在,直接复用: ' . $url . ' => ' . $asset->asset_id); return [ 'code' => 0, 'msg' => '创建成功', 'data' => [ 'asset_id' => $asset->asset_id, 'group_id' => $asset->group_id, 'status' => 'ready', ], ]; } } $groupId = $this->ensureKuaikuaiAssetGroup(); $createAssetBody = [ 'url' => $url, 'type' => $this->kuaikuaiAssetType($assetType), 'purpose' => $purpose ?: $name, ]; if ($groupId) { $createAssetBody['group_id'] = $groupId; } $responseData = $this->requestKuaikuaiGateway('POST', '/v1/assets/upload', $createAssetBody); $data = $responseData['data'] ?? []; $assetId = $data['asset_id'] ?? ''; $status = $data['status'] ?? 'pending'; $returnGroupId = $data['group_id'] ?? $groupId; if (empty($assetId)) { logDB('generate', 'error', '快快AI创建素材失败', ['url' => $url, 'response' => $responseData]); return [ 'code' => 20003, 'msg' => '创建快快AI素材失败: ' . json_encode($responseData, JSON_UNESCAPED_UNICODE), 'data' => null, ]; } // 保存素材记录到数据库 $recordData = [ 'asset_id' => $assetId, 'group_id' => $returnGroupId, 'group_name' => 'default', 'name' => $name, 'url' => $url, 'asset_type' => $assetType, 'source_type' => 'kuaikuai', 'status' => $this->kuaikuaiStatusToLocal($status), 'extra_params' => $responseData, ]; if (!empty($data['failure_reason'])) { $recordData['error_message'] = $data['failure_reason']; } MpAsset::create($recordData); // 网关已就绪/失败,直接返回 if (in_array($status, ['ready', 'success'], true)) { return [ 'code' => 0, 'msg' => '创建成功', 'data' => ['asset_id' => $assetId, 'group_id' => $returnGroupId, 'status' => 'ready'], ]; } if ($status === 'failed') { $errorMessage = $data['failure_reason'] ?? json_encode($data, JSON_UNESCAPED_UNICODE); logDB('generate', 'error', '快快AI素材处理失败', ['asset_id' => $assetId, 'url' => $url, 'error' => $errorMessage]); return ['code' => 20003, 'msg' => '快快AI素材处理失败: ' . $errorMessage, 'data' => null]; } // pending 且不等待时直接返回 if (!$waitForReady) { return [ 'code' => 0, 'msg' => '创建成功,素材处理中', 'data' => ['asset_id' => $assetId, 'group_id' => $returnGroupId, 'status' => 'pending'], ]; } // 轮询查询素材状态(每3秒一次,最多30次即90秒) $maxAttempts = 30; $lastStatus = $status; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { sleep(3); $assetInfo = $this->getKuaikuaiAsset($assetId); $lastStatus = $assetInfo['status'] ?? ''; dLog('generate')->info("快快AI素材 {$assetId} 状态查询第 {$attempt} 次,状态: {$lastStatus}"); if (in_array($lastStatus, ['ready', 'success'], true)) { return [ 'code' => 0, 'msg' => '创建成功', 'data' => ['asset_id' => $assetId, 'group_id' => $returnGroupId, 'status' => 'ready'], ]; } if ($lastStatus === 'failed') { $errorMessage = $assetInfo['failure_reason'] ?? json_encode($assetInfo, JSON_UNESCAPED_UNICODE); logDB('generate', 'error', '快快AI素材处理失败', ['asset_id' => $assetId, 'url' => $url, 'error' => $errorMessage]); return ['code' => 20003, 'msg' => '快快AI素材处理失败: ' . $errorMessage, 'data' => null]; } } logDB('generate', 'error', '快快AI素材处理超时', ['asset_id' => $assetId, 'url' => $url, 'last_status' => $lastStatus]); return [ 'code' => 20003, 'msg' => '快快AI素材处理超时: ' . $assetId . ',最后状态: ' . ($lastStatus ?? 'unknown'), 'data' => null, ]; } catch (\Exception $e) { dLog('generate')->error('快快AI创建素材异常: ' . $e->getMessage()); logDB('generate', 'error', '快快AI创建素材异常', ['url' => $url, 'error' => $e->getMessage()]); return ['code' => 20003, 'msg' => '创建快快AI素材失败: ' . $e->getMessage(), 'data' => null]; } } /** * 批量上传素材到快快AI素材库(单次最多10条,超过自动分批) * 逐条返回结果,坏的一条只标错、不影响其余 * * @param array $urls 素材公网URL列表 * @param string $assetType 素材类型(Image/Video/Audio) * @param string $purpose 素材用途/描述 * @param string $namePrefix 素材名称前缀 * @return array ['code'=>0,'data'=>['assets'=>[['url','asset_id','status'|'error']...],'group_id'=>...]] */ public function createKuaikuaiAssetsBatch(array $urls, string $assetType = MpAsset::TYPE_IMAGE, string $purpose = '', string $namePrefix = 'kuaikuai'): array { $urls = array_values(array_unique(array_filter($urls))); if (empty($urls)) { return ['code' => 1, 'msg' => '素材URL列表不能为空', 'data' => null]; } try { $groupId = $this->ensureKuaikuaiAssetGroup(); $allAssets = []; $returnGroupId = $groupId; // 单次最多10条,分批处理 foreach (array_chunk($urls, 10) as $chunk) { $createAssetBody = [ 'urls' => array_values($chunk), 'type' => $this->kuaikuaiAssetType($assetType), 'purpose' => $purpose ?: $namePrefix, ]; if ($groupId) { $createAssetBody['group_id'] = $groupId; } $responseData = $this->requestKuaikuaiGateway('POST', '/v1/assets/upload', $createAssetBody); $data = $responseData['data'] ?? []; if (!empty($data['group_id'])) { $returnGroupId = $data['group_id']; } $assets = $data['assets'] ?? []; foreach ($assets as $item) { $url = $item['url'] ?? ''; if (!empty($item['error'])) { $allAssets[] = ['url' => $url, 'error' => $item['error']]; continue; } $assetId = $item['asset_id'] ?? ''; $status = $item['status'] ?? 'pending'; if (empty($assetId)) { $allAssets[] = ['url' => $url, 'error' => '素材ID为空']; continue; } // 落库 $recordData = [ 'asset_id' => $assetId, 'group_id' => $item['group_id'] ?? $returnGroupId, 'group_name' => 'default', 'name' => $namePrefix . '_' . time() . '_' . uniqid(), 'url' => $url, 'asset_type' => $assetType, 'source_type' => 'kuaikuai', 'status' => $this->kuaikuaiStatusToLocal($status), 'extra_params' => $responseData, ]; if (!empty($item['failure_reason'])) { $recordData['error_message'] = $item['failure_reason']; } MpAsset::create($recordData); $allAssets[] = ['url' => $url, 'asset_id' => $assetId, 'status' => $status]; } } return [ 'code' => 0, 'msg' => '批量上传完成', 'data' => ['assets' => $allAssets, 'group_id' => $returnGroupId], ]; } catch (\Exception $e) { dLog('generate')->error('快快AI批量创建素材异常: ' . $e->getMessage()); logDB('generate', 'error', '快快AI批量创建素材异常', ['urls' => $urls, 'error' => $e->getMessage()]); return ['code' => 20003, 'msg' => '批量创建快快AI素材失败: ' . $e->getMessage(), 'data' => null]; } } /** * 删除快快AI素材(同时清理本地记录) * * @param string $assetId 素材ID(mat_xxx) * @return array ['code'=>0,'msg'=>...] 或 ['code'=>20003,'msg'=>...] */ public function deleteKuaikuaiAsset(string $assetId): array { try { if (empty($assetId)) { return ['code' => 1, 'msg' => '素材ID不能为空', 'data' => null]; } $this->requestKuaikuaiGateway('DELETE', '/v1/assets/' . urlencode($assetId)); DB::table('mp_assets')->where('asset_id', $assetId)->where('source_type', 'kuaikuai')->delete(); dLog('generate')->info('快快AI素材删除成功: ' . $assetId); return ['code' => 0, 'msg' => '删除成功', 'data' => ['asset_id' => $assetId]]; } catch (\Exception $e) { dLog('generate')->error('快快AI素材删除异常: ' . $e->getMessage()); return ['code' => 20003, 'msg' => '删除快快AI素材失败: ' . $e->getMessage(), 'data' => null]; } } /** * 将 Seedance 2.0/2.5 内容中的图片URL上传为快快AI素材 * 上传失败的图片直接从content中移除,不阻断整个任务 * * @param array $content * @return array 转换后的content(图片URL替换为 asset://{AssetId}) */ private function convertKuaikuaiContentImagesToAssets(array $content): array { foreach ($content as $index => &$item) { if (($item['type'] ?? '') !== 'image_url') { continue; } $imageUrl = $item['image_url']['url'] ?? ''; if (empty($imageUrl) || strpos($imageUrl, 'asset://') === 0) { continue; } $name = 'kuaikuai_' . time() . '_' . uniqid(); $createResult = $this->createKuaikuaiAsset($imageUrl, $name, MpAsset::TYPE_IMAGE, '视频内容图片'); // 网关素材异步处理为间歇性故障,失败后间隔重试,避免因网关临时故障误判 for ($retry = 1; $retry <= 5 && ($createResult['code'] !== 0 || ($createResult['data']['status'] ?? '') !== 'ready'); $retry++) { sleep($retry <= 3 ? 10 : 30); dLog('generate')->warning('快快AI图片上传素材第 ' . $retry . ' 次重试: ' . $imageUrl); $createResult = $this->createKuaikuaiAsset($imageUrl, $name, MpAsset::TYPE_IMAGE, '视频内容图片'); } if ($createResult['code'] !== 0 || ($createResult['data']['status'] ?? '') !== 'ready') { $errorMessage = (string)($createResult['msg'] ?? '素材创建失败'); dLog('generate')->error('快快AI图片上传素材失败: ' . $imageUrl . ',原因: ' . $errorMessage); logDB('generate', 'error', '快快AI图片转素材失败', [ 'image_url' => $imageUrl, 'error' => $errorMessage, ]); // 素材不可用会直接影响视频效果,且任务会扣费,失败直接中断任务创建 Utils::throwError('20003:图片上传素材失败:' . $errorMessage); } $item['image_url']['url'] = 'asset://' . $createResult['data']['asset_id']; dLog('generate')->info('快快AI图片转素材成功: ' . $imageUrl . ' => asset://' . $createResult['data']['asset_id']); } unset($item); return array_values($content); } /** * 将 Seedance 2.0/2.5 参考图上传为快快AI素材 * 失败处理与百度Seedance2.0一致:删除参考图的同时清理提示词中的<图片N>标记 * * @param array $referenceImages 参考图片URL数组 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败图片的标记) * @return array 返回素材ID数组(asset://格式) */ public function processReferenceImagesToKuaikuaiAssets(array $referenceImages, string &$fullPrompt): array { return $this->processReferenceMediaToKuaikuaiAssets($referenceImages, MpAsset::TYPE_IMAGE, '参考图片', '图片', $fullPrompt); } /** * 将 Seedance 2.0/2.5 参考视频上传为快快AI素材 * * @param array $referenceVideos 参考视频URL数组 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败视频的标记) * @return array 返回素材ID数组(asset://格式) */ public function processReferenceVideosToKuaikuaiAssets(array $referenceVideos, string &$fullPrompt): array { return $this->processReferenceMediaToKuaikuaiAssets($referenceVideos, MpAsset::TYPE_VIDEO, '参考视频', '视频', $fullPrompt); } /** * 将 Seedance 2.0/2.5 参考音频上传为快快AI素材 * * @param array $referenceAudios 参考音频URL数组 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败音频的标记) * @return array 返回素材ID数组(asset://格式) */ public function processReferenceAudiosToKuaikuaiAssets(array $referenceAudios, string &$fullPrompt): array { return $this->processReferenceMediaToKuaikuaiAssets($referenceAudios, MpAsset::TYPE_AUDIO, '参考音频', '音频', $fullPrompt); } /** * 将 Seedance 2.0/2.5 参考图片/视频/音频上传为快快AI素材(通用方法) * * @param array $urls 参考素材URL数组 * @param string $assetType 素材类型(Image/Video/Audio) * @param string $label 素材中文名(用于日志和素材命名) * @param string $tagName 提示词标记名(图片/视频/音频),用于 <{tagName}N> 标记的清理 * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败素材的标记) * @return array 返回素材ID数组(asset://格式) */ private function processReferenceMediaToKuaikuaiAssets(array $urls, string $assetType, string $label, string $tagName, string &$fullPrompt): array { $assetIds = []; $failedIndexes = []; // 记录失败的素材序号 foreach ($urls as $index => $url) { $mediaIndex = $index + 1; // 序号从1开始 try { dLog('generate')->info("开始处理快快AI{$label} {$mediaIndex}: {$url}"); // 上传素材(已存在则直接复用,不重复上传) $createResult = $this->createKuaikuaiAsset($url, "{$label}_{$mediaIndex}_" . time(), $assetType, $label); // 网关素材异步处理为间歇性故障,失败后间隔重试,避免因网关临时故障误判 for ($retry = 1; $retry <= 5 && ($createResult['code'] !== 0 || ($createResult['data']['status'] ?? '') !== 'ready'); $retry++) { sleep($retry <= 3 ? 10 : 30); dLog('generate')->warning("{$label} {$mediaIndex} 快快AI素材第 {$retry} 次重试上传"); $createResult = $this->createKuaikuaiAsset($url, "{$label}_{$mediaIndex}_" . time(), $assetType, $label); } if ($createResult['code'] !== 0 || ($createResult['data']['status'] ?? '') !== 'ready') { $errorMessage = (string)($createResult['msg'] ?? '素材创建失败'); dLog('generate')->error("{$label} {$mediaIndex} 创建快快AI素材失败: " . $errorMessage); logDB('generate', 'error', '快快AI创建素材失败', [ 'url' => $url, 'asset_type' => $assetType, 'index' => $mediaIndex, 'error' => $errorMessage, ]); // 素材不可用会直接影响视频效果,且任务会扣费,失败直接中断任务创建 Utils::throwError('20003:' . $label . $mediaIndex . '上传素材失败:' . $errorMessage); } $assetIds[] = $createResult['data']['asset_id']; dLog('generate')->info("{$label} {$mediaIndex} 快快AI素材上传成功,asset_id: " . $createResult['data']['asset_id']); } catch (\Exception $e) { dLog('generate')->error("{$label} {$mediaIndex} 快快AI素材处理异常: " . $e->getMessage()); logDB('generate', 'error', '快快AI素材处理异常', [ 'url' => $url, 'asset_type' => $assetType, 'index' => $mediaIndex, 'error' => $e->getMessage(), ]); $failedIndexes[] = $mediaIndex; } } // 处理失败的素材:从 fullPrompt 中删除对应的标记并调整序号(与百度Seedance2.0处理一致) if (!empty($failedIndexes)) { dLog('generate')->info("开始处理快快AI失败{$label}的提示词调整,失败序号: " . implode(',', $failedIndexes)); // 按序号从大到小排序,避免删除时影响后续序号 rsort($failedIndexes); foreach ($failedIndexes as $failedIndex) { // 删除失败素材的标记,例如 <图片1>、<视频2> 等 $pattern = "/<{$tagName}{$failedIndex}>/u"; $fullPrompt = preg_replace($pattern, '', $fullPrompt); dLog('generate')->info("已从提示词中删除 <{$tagName}{$failedIndex}>"); } // 重新调整剩余素材的序号 $needAdjust = false; foreach ($failedIndexes as $failedIndex) { $needAdjust = true; // 将所有大于失败序号的素材标记序号减1 for ($i = $failedIndex + 1; $i <= count($urls); $i++) { $oldPattern = "/<{$tagName}{$i}>/u"; $newTag = "<{$tagName}" . ($i - 1) . ">"; $fullPrompt = preg_replace($oldPattern, $newTag, $fullPrompt); } } if ($needAdjust) { dLog('generate')->info('快快AI提示词' . $label . '序号调整完成'); } } dLog('generate')->info("快快AI{$label}转素材完成,成功数量: " . count($assetIds) . ',失败数量: ' . count($failedIndexes)); // 格式化为可直接在API接口中使用的数组 foreach ($assetIds as &$assetId) { $assetId = "asset://{$assetId}"; } unset($assetId); return $assetIds; } // 更新Seedance视频任务状态($statusQuery可选,用于复用处理逻辑查询其它网关状态) private function updateSeedanceTask($task, ?callable $statusQuery = null) { $statusInfo = $statusQuery ? $statusQuery($task) : $this->querySeedanceTaskStatus($task); if (!isset($statusInfo['status'])) return; if ($statusInfo['status'] === 'success') { logDB('generate', 'info', 'Seedance视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]); // 同步调整分镜视频状态和结果(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id && isset($statusInfo['result_url'])) { try { DB::beginTransaction(); // 原子抢占:只有仍为 processing 的任务能更新为 success,避免并发重复处理 if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('generate')->info('视频任务已被其他进程处理,跳过重复更新', [ 'id' => $task->id, 'result_url' => $statusInfo['result_url'] ?? null ]); return; } $now = date('Y-m-d H:i:s'); // 构建更新数据 $segmentUpdateData = [ 'origin_video_url' => $statusInfo['result_url'], 'video_task_status' => '已完成', 'current_type' => 2, 'last_frame_url' => $statusInfo['last_frame_url'] ?? '', 'updated_at' => $now ]; // 使用查询任务时已压缩的视频URL,避免重复压缩 $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url']; // 只有当video_duration存在且大于0时才更新 if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) { $segmentUpdateData['video_duration'] = $statusInfo['video_duration']; $segmentUpdateData['video_time_point_start'] = 0; // 获取video_time_point_end(兼容全能模式) $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null); $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0; } // 更新分镜表(兼容act模式) $updateSegmentResult = $this->updateSegmentTable($task, $segmentUpdateData); if ($updateSegmentResult === false || $updateSegmentResult === 0) { throw new \Exception('更新分镜表失败: 未找到对应的分镜记录'); } // 添加视频生成对话记录 $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']); // 视频生成成功,记录计费明细并扣减用户积分(与任务状态/分镜同事务) $this->chargeVideoTaskInTransaction($task); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('Seedance视频任务处理失败', [ 'segment_id' => $segment_id, 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } else { // 没有segment_id的情况下,直接更新任务状态 // 原子抢占与计费同事务,避免并发重复处理 try { DB::beginTransaction(); if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('generate')->info('视频任务已被其他进程处理,跳过重复更新', [ 'id' => $task->id, 'result_url' => $statusInfo['result_url'] ?? null ]); return; } $this->chargeVideoTaskInTransaction($task); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('视频任务成功处理失败,已回滚', [ 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } } elseif ($statusInfo['status'] === 'failed') { logDB('generate', 'error', 'Seedance视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $statusInfo['error_message'], 'result_json' => $statusInfo['result_json'] ?? [] ]); $this->addVideoGenerationFailedRecord($task, $statusInfo['error_message']); // 同步更新分镜表状态为失败(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id) { $this->updateSegmentTable($task, [ 'video_task_status' => '失败', 'updated_at' => date('Y-m-d H:i:s') ]); } } // 如果仍然是处理中状态,不做任何操作 // 处理:如果任务处理超过48小时,标记为失败 $processingTime = now()->diffInHours($task->created_at); if ($processingTime > 48) { logDB('generate', 'warning', 'Seedance视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '任务处理超时(超过48小时)' ]); $this->addVideoGenerationFailedRecord($task, '任务处理超时(超过48小时)'); // 同步更新分镜表状态为失败(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id) { $this->updateSegmentTable($task, [ 'video_task_status' => '失败', 'updated_at' => date('Y-m-d H:i:s') ]); } } } // 更新快快AI视频任务状态(复用Seedance任务处理逻辑) private function updateKuaikuaiTask($task) { $this->updateSeedanceTask($task, function ($t) { return $this->queryKuaikuaiTaskStatus($t); }); } /** * 查询 Seedance 任务状态 * * @param MpGenerateVideoTask $task * @return array */ public function querySeedanceTaskStatus(MpGenerateVideoTask $task): array { $client = new Client(['verify'=>false,'timeout'=>120]); try { // 如果任务已完成,直接返回结果 if (in_array($task->status, [MpGenerateVideoTask::STATUS_SUCCESS, MpGenerateVideoTask::STATUS_FAILED])) { return [ 'success' => true, 'data' => [ 'id' => $task->id, 'status' => $this->mapTaskStatus($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 ] ]; } // 调用 API 查询任务状态(Seedance 2.0走百度AI网关,其余走火山引擎) $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true); $gatewayBaseUrl = $extraParams['gateway_base_url'] ?? 'https://ark.cn-beijing.volces.com'; $apiKeyEnv = $extraParams['api_key_env'] ?? 'VOLC_AI_API_KEY'; $apiKey = env($apiKeyEnv); if (empty($apiKey)) { return [ 'status' => MpGenerateVideoTask::STATUS_FAILED, 'result_json' => [], 'error_message' => $apiKeyEnv . ' 未配置' ]; } // 构建查询 URL $url = $gatewayBaseUrl . '/api/v3/contents/generations/tasks/' . $task->task_id; $response = $client->get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', ] ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('Seedance 任务状态查询响应: ', $responseData); logDB('generate', 'info', 'Seedance任务状态查询', ['id' => $task->id, 'response' => $responseData]); if (isset($responseData['error'])) { logDB('generate', 'error', 'Seedance任务状态查询失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? 'API查询失败']); return [ 'status' => MpGenerateVideoTask::STATUS_FAILED, 'result_json' => $responseData, 'error_message' => $responseData['error']['message'] ?? 'API查询失败' ]; } // 解析任务状态 $apiStatus = $responseData['status'] ?? 'unknown'; $taskStatus = $this->mapApiStatusToTaskStatus($apiStatus); $updateData = ['result_json' => $responseData]; if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) { $updateData['status'] = MpGenerateVideoTask::STATUS_SUCCESS; // 任务成功,处理结果 if (isset($responseData['content']['video_url'])) { $video_url = $responseData['content']['video_url']; // 下载视频内容 $videoContent = file_get_contents($video_url); // 从任务中获取视频分辨率,按目标档位执行本地超分(sr_720p->720p;sr_1080p->1080p) if ($this->shouldUpgradeVideoResolution($task)) { $upgradeTarget = $this->getVideoUpgradeTarget($task); dLog('generate')->info('Seedance检测到需超分视频,开始超分(' . strtolower((string)$task->video_resolution) . ' -> ' . $upgradeTarget . ')', [ 'original_url' => $video_url, 'video_resolution' => $task->video_resolution, 'size' => round(strlen($videoContent) / 1024 / 1024, 2) . 'MB' ]); $url = upgrade480pTo720p($videoContent, 'video', true, $upgradeTarget); if (!$url) { // 超分失败,上传原视频 dLog('generate')->warning('Seedance视频超分失败,使用原视频'); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($video_url); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } } else { // 非超分档视频,直接保存原视频 dLog('generate')->info('Seedance视频无需本地超分,直接保存', [ 'original_url' => $video_url, 'video_resolution' => $task->video_resolution ]); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($video_url); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } $updateData['result_url'] = $url; // 视频分辨率标准化(压缩前处理,避免压缩后重复上传) $targetResolution = $this->getTaskTargetResolution($task); if ($targetResolution) { $url = $this->ensureStandardVideoResolution($url, $targetResolution); $updateData['result_url'] = $url; } // 压缩视频 $compressed_video_url = compressVideo($url); $updateData['compressed_url'] = $compressed_video_url ?: $url; } // 计算视频时长 $video_duration = 0; if (isset($responseData['duration'])) { // 如果直接返回duration,使用该值 $video_duration = (int)$responseData['duration']; } elseif (isset($responseData['frames']) && isset($responseData['framespersecond'])) { // 如果返回frames和framespersecond,计算时长 $frames = (int)$responseData['frames']; $fps = (int)$responseData['framespersecond']; if ($fps > 0) { $video_duration = (int)floor($frames / $fps); } } // 如果没有计算出时长,使用任务表中的设定值 if ($video_duration <= 0 && !empty($task->extra_params)) { $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true); if (isset($extraParams['duration'])) { $video_duration = (int)$extraParams['duration']; } } // 只有当video_duration > 0时才添加到更新数据中 if ($video_duration > 0) { $updateData['video_duration'] = $video_duration; $updateData['video_time_point_start'] = 0; $updateData['video_time_point_end'] = $video_duration; } // 处理尾帧图片(如果启用了 return_last_frame) if (isset($responseData['content']['last_frame_url']) && !empty($responseData['content']['last_frame_url'])) { $pic_url = $responseData['content']['last_frame_url']; $pic_name = 'ai_generation_' . time() . '_' . uniqid(); $pic_ext = getImgExtFromUrl($pic_url); $pic_name = $pic_name . $pic_ext; if ($pic_ext === '.png') { $comporessed_data = compressRemoteImageUrlToSize($pic_url); $url = uploadStreamByTos('image', $comporessed_data, $pic_name); }else { // 将图片另存到tos $url = uploadStreamByTos('image', file_get_contents($pic_url), $pic_name); } $updateData['last_frame_url'] = $url; } return $updateData; } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) { $updateData['status'] = MpGenerateVideoTask::STATUS_FAILED; $updateData['error_message'] = $responseData['error_message'] ?? '任务执行失败'; return $updateData; } // 其他状态保持不变 return [ 'success' => true, 'data' => [ 'id' => $task->id, 'status' => $this->mapTaskStatus($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, 'api_response' => $responseData ] ]; } catch (\Exception $e) { dLog('generate')->info('Seedance 任务状态查询异常: '.$e->getMessage()); return [ 'success' => false, 'message' => '查询失败: ' . $e->getMessage() ]; } } /** * 查询快快AI视频生成任务状态 * 接口:GET {KUAIKUAI_AI_BASE_URL}/v1/video/generations/{task_id} * * @param MpGenerateVideoTask $task * @return array 成功/失败/处理中状态信息(与querySeedanceTaskStatus返回结构兼容) */ public function queryKuaikuaiTaskStatus(MpGenerateVideoTask $task): array { $client = new Client(['verify' => false, 'timeout' => 120]); try { // 如果任务已完成,直接返回结果 if (in_array($task->status, [MpGenerateVideoTask::STATUS_SUCCESS, MpGenerateVideoTask::STATUS_FAILED])) { return [ 'success' => true, 'data' => [ 'id' => $task->id, 'status' => $this->mapTaskStatus($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 ] ]; } $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true); $gatewayBaseUrl = rtrim((string)($extraParams['gateway_base_url'] ?? env('KUAIKUAI_AI_BASE_URL', 'https://ai-api.kkidc.com')), '/'); $apiKeyEnv = $extraParams['api_key_env'] ?? 'KUAIKUAI_SEEDANCE_COMMON_AK'; $apiKey = env($apiKeyEnv); if (empty($apiKey)) { return [ 'status' => MpGenerateVideoTask::STATUS_FAILED, 'result_json' => [], 'error_message' => $apiKeyEnv . ' 未配置' ]; } // 构建查询 URL $url = $gatewayBaseUrl . '/v1/video/generations/' . $task->task_id; try { $response = $client->get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', ] ]); } catch (\GuzzleHttp\Exception\ClientException $e) { // 网关查询接口要求通用令牌(模型专用Key查询时返回403 no access to model) // 回退使用Seedance-common令牌重试一次 $statusCode = $e->getResponse() ? $e->getResponse()->getStatusCode() : 0; $errorBody = $e->getResponse() ? (string)$e->getResponse()->getBody() : ''; if ($statusCode !== 403 || strpos($errorBody, 'no access to model') === false) { throw $e; } $commonKey = env('KUAIKUAI_SEEDANCE_COMMON_AK'); if (empty($commonKey) || $commonKey === $apiKey) { throw $e; } dLog('generate')->warning('快快AI任务状态查询使用模型Key被拒(403),回退Seedance-common令牌重试', [ 'id' => $task->id, 'api_key_env' => $apiKeyEnv, ]); $response = $client->get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $commonKey, 'Content-Type' => 'application/json', ] ]); } $responseData = json_decode($response->getBody(), true); dLog('generate')->info('快快AI任务状态查询响应: ', $responseData); logDB('generate', 'info', '快快AI任务状态查询', ['id' => $task->id, 'response' => $responseData]); if (isset($responseData['error'])) { logDB('generate', 'error', '快快AI任务状态查询失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? 'API查询失败']); return [ 'status' => MpGenerateVideoTask::STATUS_FAILED, 'result_json' => $responseData, 'error_message' => $responseData['error']['message'] ?? 'API查询失败' ]; } // 快快AI响应结构:外层 data.status 为 SUCCESS/FAILURE/IN_PROGRESS 等,data.data.status 为 succeeded/failed/queued/running/expired $outer = is_array($responseData['data'] ?? null) ? $responseData['data'] : []; $inner = is_array($outer['data'] ?? null) ? $outer['data'] : []; $apiStatus = strtolower((string)($inner['status'] ?? $outer['status'] ?? 'unknown')); if (in_array($apiStatus, ['success', 'succeeded'], true)) { // 完整保留网关返回的所有字段(code/message/data 等),仅将项目依赖的字段提升到顶层做兼容 $resultJson = $responseData; $resultJson['status'] = 'succeeded'; $resultJson['content']['video_url'] = $inner['content']['video_url'] ?? ($outer['content']['video_url'] ?? ''); $resultJson['content']['last_frame_url'] = $inner['content']['last_frame_url'] ?? ($outer['content']['last_frame_url'] ?? ''); $resultJson['duration'] = $inner['duration'] ?? ($outer['duration'] ?? 0); $resultJson['framespersecond'] = $inner['framespersecond'] ?? 0; // 保留token用量(快快AI返回结构:data.data.usage),供积分/token统计使用 $resultJson['usage'] = $inner['usage'] ?? ($outer['usage'] ?? []); $resultJson['error_message'] = ''; return $this->processSeedanceSuccessResponse($task, $resultJson); } if (in_array($apiStatus, ['failure', 'failed', 'expired'], true)) { $error = $inner['error'] ?? $inner['fail_reason'] ?? $outer['fail_reason'] ?? $responseData['message'] ?? '任务执行失败'; if (is_array($error)) { $error = json_encode($error, JSON_UNESCAPED_UNICODE); } return [ 'status' => MpGenerateVideoTask::STATUS_FAILED, 'result_json' => $responseData, 'error_message' => (string)$error ]; } // 其他状态(NOT_START/SUBMITTED/QUEUED/IN_PROGRESS/queued/running/UNKNOWN)视为处理中 return [ 'status' => MpGenerateVideoTask::STATUS_PROCESSING, 'result_json' => $responseData, ]; } catch (\Exception $e) { dLog('generate')->info('快快AI任务状态查询异常: ' . $e->getMessage()); return [ 'status' => MpGenerateVideoTask::STATUS_PROCESSING, 'result_json' => [], 'error_message' => '查询异常: ' . $e->getMessage() ]; } } /** * 处理Seedance类任务成功响应(下载视频、480p超分、压缩、时长与尾帧处理) * 供火山官方格式与快快AI格式共用 * * @param MpGenerateVideoTask $task * @param array $responseData 规范化的响应数据(含status/content/duration等) * @return array 更新数据 */ private function processSeedanceSuccessResponse(MpGenerateVideoTask $task, array $responseData): array { $updateData = ['result_json' => $responseData]; $updateData['status'] = MpGenerateVideoTask::STATUS_SUCCESS; // 任务成功,处理结果 if (isset($responseData['content']['video_url'])) { $video_url = $responseData['content']['video_url']; // 下载视频内容 $videoContent = file_get_contents($video_url); // 从任务中获取视频分辨率,按目标档位执行本地超分(sr_720p->720p;sr_1080p->1080p) if ($this->shouldUpgradeVideoResolution($task)) { $upgradeTarget = $this->getVideoUpgradeTarget($task); dLog('generate')->info('Seedance检测到需超分视频,开始超分(' . strtolower((string)$task->video_resolution) . ' -> ' . $upgradeTarget . ')', [ 'original_url' => $video_url, 'video_resolution' => $task->video_resolution, 'size' => round(strlen($videoContent) / 1024 / 1024, 2) . 'MB' ]); $url = upgrade480pTo720p($videoContent, 'video', true, $upgradeTarget); if (!$url) { // 超分失败,上传原视频 dLog('generate')->warning('Seedance视频超分失败,使用原视频'); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($video_url); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } } else { // 非超分档视频,直接保存原视频 dLog('generate')->info('Seedance视频无需本地超分,直接保存', [ 'original_url' => $video_url, 'video_resolution' => $task->video_resolution ]); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($video_url); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } $updateData['result_url'] = $url; // 视频分辨率标准化(压缩前处理,避免压缩后重复上传) $targetResolution = $this->getTaskTargetResolution($task); if ($targetResolution) { $url = $this->ensureStandardVideoResolution($url, $targetResolution); $updateData['result_url'] = $url; } // 压缩视频 $compressed_video_url = compressVideo($url); $updateData['compressed_url'] = $compressed_video_url ?: $url; } // 计算视频时长 $video_duration = 0; if (isset($responseData['duration'])) { // 如果直接返回duration,使用该值 $video_duration = (int)$responseData['duration']; } elseif (isset($responseData['frames']) && isset($responseData['framespersecond'])) { // 如果返回frames和framespersecond,计算时长 $frames = (int)$responseData['frames']; $fps = (int)$responseData['framespersecond']; if ($fps > 0) { $video_duration = (int)floor($frames / $fps); } } // 如果没有计算出时长,使用任务表中的设定值 if ($video_duration <= 0 && !empty($task->extra_params)) { $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true); if (isset($extraParams['duration'])) { $video_duration = (int)$extraParams['duration']; } } // 只有当video_duration > 0时才添加到更新数据中 if ($video_duration > 0) { $updateData['video_duration'] = $video_duration; $updateData['video_time_point_start'] = 0; $updateData['video_time_point_end'] = $video_duration; } // 处理尾帧图片(如果启用了 return_last_frame) if (isset($responseData['content']['last_frame_url']) && !empty($responseData['content']['last_frame_url'])) { $pic_url = $responseData['content']['last_frame_url']; $pic_name = 'ai_generation_' . time() . '_' . uniqid(); $pic_ext = getImgExtFromUrl($pic_url); $pic_name = $pic_name . $pic_ext; if ($pic_ext === '.png') { $comporessed_data = compressRemoteImageUrlToSize($pic_url); $url = uploadStreamByTos('image', $comporessed_data, $pic_name); } else { // 将图片另存到tos $url = uploadStreamByTos('image', file_get_contents($pic_url), $pic_name); } $updateData['last_frame_url'] = $url; } return $updateData; } /** * 映射 API 状态到任务状态 * * @param string $apiStatus * @return string */ private function mapApiStatusToTaskStatus(string $apiStatus): string { switch ($apiStatus) { case 'queued': case 'running': return MpGenerateVideoTask::STATUS_PROCESSING; case 'succeeded': return MpGenerateVideoTask::STATUS_SUCCESS; case 'failed': case 'expired': return MpGenerateVideoTask::STATUS_FAILED; default: return MpGenerateVideoTask::STATUS_PROCESSING; } } public function seedanceCallback($data) { try { dLog('generate')->info('Seedance 回调数据: ', $data); logDB('generate', 'info', 'Seedance回调接收', ['task_id' => $data['id'] ?? null, 'status' => $data['status'] ?? null]); // 从回调数据中获取任务ID $taskId = $data['id'] ?? null; if (!$taskId) { dLog('generate')->error('Seedance 回调缺少任务ID'); logDB('generate', 'error', 'Seedance回调缺少任务ID', ['data' => $data]); return ['success' => false, 'message' => '缺少任务ID']; } // 查找对应的任务记录 $task = MpGenerateVideoTask::where('task_id', $taskId)->first(); if (!$task) { dLog('generate')->error('Seedance 回调找不到任务: ' . $taskId); logDB('generate', 'error', 'Seedance回调找不到任务', ['task_id' => $taskId]); return ['success' => false, 'message' => '任务不存在']; } // 解析任务状态 $apiStatus = $data['status'] ?? 'unknown'; $taskStatus = $this->mapApiStatusToTaskStatus($apiStatus); // 准备更新数据 $updateData = [ 'result_json' => $data, 'updated_at' => now() ]; if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) { $updateData['status'] = MpGenerateVideoTask::STATUS_SUCCESS; $updateData['completed_at'] = now(); // 处理视频结果 if (isset($data['content']['video_url'])) { $video_url = $data['content']['video_url']; try { // 下载视频内容 $videoContent = file_get_contents($video_url); // 从任务中获取视频分辨率,按目标档位执行本地超分(sr_720p->720p;sr_1080p->1080p) if ($this->shouldUpgradeVideoResolution($task)) { $upgradeTarget = $this->getVideoUpgradeTarget($task); dLog('generate')->info('Seedance回调检测到需超分视频,开始超分(' . strtolower((string)$task->video_resolution) . ' -> ' . $upgradeTarget . ')', [ 'original_url' => $video_url, 'video_resolution' => $task->video_resolution, 'size' => round(strlen($videoContent) / 1024 / 1024, 2) . 'MB' ]); $url = upgrade480pTo720p($videoContent, 'video', true, $upgradeTarget); if (!$url) { // 超分失败,上传原视频 dLog('generate')->warning('Seedance回调视频超分失败,使用原视频'); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($video_url); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } } else { // 非超分档视频,直接保存原视频 dLog('generate')->info('Seedance回调视频无需本地超分,直接保存', [ 'original_url' => $video_url, 'video_resolution' => $task->video_resolution ]); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($video_url); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } $updateData['result_url'] = $url; // 视频分辨率标准化(压缩前处理,避免压缩后重复上传) $targetResolution = $this->getTaskTargetResolution($task); if ($targetResolution) { $url = $this->ensureStandardVideoResolution($url, $targetResolution); $updateData['result_url'] = $url; } $compressed_video_url = compressVideo($url); $updateData['compressed_url'] = $compressed_video_url ?: $url; dLog('generate')->info('Seedance 视频保存成功: ' . $url); } catch (\Exception $e) { dLog('generate')->error('Seedance 视频保存失败: ' . $e->getMessage()); // 如果保存失败,直接使用原始URL $updateData['result_url'] = $video_url; } } // 处理尾帧图片(如果启用了 return_last_frame) if (isset($data['content']['last_frame_url']) && !empty($data['content']['last_frame_url'])) { $pic_url = $data['content']['last_frame_url']; try { $pic_name = 'ai_generation_' . time() . '_' . uniqid(); $pic_ext = getImgExtFromUrl($pic_url); $pic_name = $pic_name . $pic_ext; if ($pic_ext === '.png') { $compressed_data = compressRemoteImageUrlToSize($pic_url); $url = uploadStreamByTos('image', $compressed_data, $pic_name); } else { $url = uploadStreamByTos('image', file_get_contents($pic_url), $pic_name); } $updateData['last_frame_url'] = $url; dLog('generate')->info('Seedance 尾帧图片保存成功: ' . $url); } catch (\Exception $e) { dLog('generate')->error('Seedance 尾帧图片保存失败: ' . $e->getMessage()); // 如果保存失败,直接使用原始URL $updateData['last_frame_url'] = $pic_url; } } } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) { $updateData['status'] = MpGenerateVideoTask::STATUS_FAILED; $updateData['completed_at'] = now(); $updateData['error_message'] = $data['error_message'] ?? $data['failure_reason'] ?? '任务执行失败'; } else { // 处理中状态 $updateData['status'] = $taskStatus; if ($taskStatus === MpGenerateVideoTask::STATUS_PROCESSING && !$task->started_at) { $updateData['started_at'] = now(); } } // 成功任务的状态更新放到下方事务中做原子抢占,其他状态直接更新 if (!($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS && isset($updateData['result_url']))) { $task->update($updateData); } // 如果任务成功,更新分镜表 if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS && isset($updateData['result_url'])) { try { DB::beginTransaction(); // 原子抢占:只有仍为 processing 的任务能更新为 success,避免回调与轮询重复处理 if (!$this->markVideoTaskSuccess($task, $updateData)) { DB::rollBack(); dLog('generate')->info('Seedance 任务已被其他进程处理,跳过重复更新', [ 'id' => $task->id, 'result_url' => $updateData['result_url'] ?? null ]); return [ 'success' => true, 'message' => '任务已被其他进程处理', 'task_id' => $taskId, 'status' => $taskStatus ]; } $now = date('Y-m-d H:i:s'); // 获取分镜ID(兼容分镜模式和全能模式) $segment = DB::table('mp_episode_segments') ->where('video_task_id', $task->id) ->first(); if ($segment) { // 兼容两种模式 $recordId = $segment->id; $segmentId = $segment->segment_id ?: $segment->id; $anime_id = $segment->anime_id; $episode_number = $segment->episode_number; $tail_frame = $segment->tail_frame; $uid = $segment->uid ?? 0; // 更新分镜表 $segmentUpdateData = [ 'origin_video_url' => $updateData['result_url'], 'video_task_status' => '已完成', 'last_frame_url' => $updateData['last_frame_url'] ?? '', 'current_type' => 2, 'updated_at' => $now ]; $segmentUpdateData['video_url'] = $updateData['compressed_url'] ?? $updateData['result_url']; // 只有当video_duration存在且大于0时才更新 if (isset($data['content']['video_duration']) && $data['content']['video_duration'] > 0) { $segmentUpdateData['video_duration'] = $data['content']['video_duration']; $segmentUpdateData['video_time_point_start'] = 0; $audio_duration = $this->getAudioDuration($task, $segmentId, $segmentUpdateData['video_duration']); $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0; } // 使用主键id更新(兼容两种模式) $updateResult = DB::table('mp_episode_segments') ->where('id', $recordId) ->update($segmentUpdateData); if (!$updateResult) { throw new \Exception('更新分镜表失败'); } // 更新分镜视频成功后新增对话记录 $records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => $episode_number, 'role' => 'user', 'content' => '图片转视频', 'segment_id' => $segmentId, 'video_task_id' => getProp($task, 'id'), 'video_url' => '', 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => $episode_number, 'role' => 'assistant', 'content' => $tail_frame, 'segment_id' => $segmentId, 'video_task_id' => getProp($task, 'id'), 'video_url' => $updateData['result_url'], 'created_at' => $now, 'updated_at' => $now ] ]; DB::table('mp_anime_records')->insert($records); dLog('generate')->info('Seedance 分镜表更新成功: ' . $segmentId); } // 视频生成成功,记录计费明细并扣减用户积分(与任务状态/分镜同事务) $this->chargeVideoTaskInTransaction($task); DB::commit(); } catch (\Exception $e) { DB::rollBack(); $logData = [ 'task_id' => $task->id, 'error' => $e->getMessage() ]; dLog('generate')->error('Seedance 分镜表更新失败', $logData); logDB('generate', 'error', 'Seedance分镜表更新失败', $logData); } } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) { // 任务失败:更新任务记录与分镜表状态,并写入失败对话记录 $task->update($updateData); $this->addVideoGenerationFailedRecord($task, $updateData['error_message'] ?? '任务执行失败'); DB::table('mp_episode_segments') ->where('video_task_id', $task->id) ->update([ 'video_task_status' => '失败', 'updated_at' => date('Y-m-d H:i:s') ]); } elseif ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) { // 成功但无结果URL:任务状态与计费同事务 try { DB::beginTransaction(); $task->update($updateData); $this->chargeVideoTaskInTransaction($task); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('视频任务成功处理失败,已回滚', [ 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } else { // 处理中或其他状态:更新任务记录 $task->update($updateData); } dLog('generate')->info('Seedance 回调处理完成: ' . $taskId . ', 状态: ' . $taskStatus); logDB('generate', 'info', 'Seedance回调处理完成', ['task_id' => $taskId, 'status' => $taskStatus]); return [ 'success' => true, 'message' => '回调处理成功', 'task_id' => $taskId, 'status' => $taskStatus ]; } catch (\Exception $e) { dLog('generate')->error('Seedance 回调处理异常: ' . $e->getMessage()); logDB('generate', 'error', 'Seedance回调处理异常', ['error' => $e->getMessage()]); return [ 'success' => false, 'message' => '回调处理失败: ' . $e->getMessage() ]; } } /** * 更新分镜表的视频任务状态 * * @param MpGenerateVideoTask $task * @param string $taskStatus * @param array $updateData */ private function updateSegmentVideoStatus(MpGenerateVideoTask $task, string $taskStatus, array $updateData) { try { // 查找关联的分镜记录 $segment = DB::table('mp_episode_segments') ->where('video_task_id', $task->id) ->first(); if (!$segment) { dLog('generate')->info('未找到关联的分镜记录: ' . $task->id); return; } $segmentUpdateData = ['updated_at' => date('Y-m-d H:i:s')]; if (isset($updateData['last_frame_url']) && $updateData['last_frame_url']) { $segmentUpdateData['last_frame_url'] = $updateData['last_frame_url']; } if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) { $segmentUpdateData['video_task_status'] = '已完成'; if (isset($updateData['result_url'])) { $segmentUpdateData['origin_video_url'] = $updateData['result_url']; // 如果有压缩视频URL,使用压缩后的 if (isset($updateData['compressed_url'])) { $segmentUpdateData['video_url'] = $updateData['compressed_url']; } else { $segmentUpdateData['video_url'] = $updateData['result_url']; } } } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) { $segmentUpdateData['video_task_status'] = '失败'; } elseif ($taskStatus === MpGenerateVideoTask::STATUS_PROCESSING) { $segmentUpdateData['video_task_status'] = '生成中'; } // 更新分镜表 DB::table('mp_episode_segments') ->where('video_task_id', $task->id) ->update($segmentUpdateData); dLog('generate')->info('分镜视频状态更新成功: ' . $segment->segment_id . ', 状态: ' . ($segmentUpdateData['video_task_status'] ?? '未知')); } catch (\Exception $e) { dLog('generate')->error('更新分镜视频状态失败: ' . $e->getMessage()); } } /** * 生成可灵AI的JWT Token * * @return string */ private function generateKelingJwtToken(): string { $ak = env('KELING_AK'); $sk = env('KELING_SK'); if (empty($ak) || empty($sk)) { throw new \Exception('可灵AI访问密钥未配置,请检查环境变量KELING_AK和KELING_SK'); } // JWT头部 $header = [ 'alg' => 'HS256', 'typ' => 'JWT' ]; // JWT载荷 $payload = [ 'iss' => $ak, // 签发者,使用Access Key 'exp' => time() + 1800, // 过期时间,30分钟后过期 'nbf' => time() - 5 // 生效时间,5秒前开始生效 ]; // Base64URL编码 $headerEncoded = $this->base64UrlEncode(json_encode($header)); $payloadEncoded = $this->base64UrlEncode(json_encode($payload)); // 创建签名 $signature = hash_hmac('sha256', $headerEncoded . '.' . $payloadEncoded, $sk, true); $signatureEncoded = $this->base64UrlEncode($signature); // 返回完整的JWT token return $headerEncoded . '.' . $payloadEncoded . '.' . $signatureEncoded; } /** * Base64URL编码(JWT标准编码) * * @param string $data * @return string */ private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } /** * 创建可灵AI Omni视频生成任务 * * @param array $params * @return MpGenerateVideoTask */ public function createKelingOmniTask(array $params): MpGenerateVideoTask { // 分辨率统一规范化为小写(480p=原始档;sr_720p=480p生成+本地超分到720p) $params['video_resolution'] = strtolower(trim((string)($params['video_resolution'] ?? '720p'))); // 生成唯一的任务ID $taskId = 'keling_omni_' . time() . '_' . uniqid(); // 创建任务记录,复用现有表结构 $task = MpGenerateVideoTask::create([ 'task_id' => $taskId, 'prompt' => $params['prompt'] ?? '', 'first_frame_url' => $params['first_frame_url'] ?? '', 'tail_frame_url' => $params['tail_frame_url'] ?? '', 'seed' => $params['seed'] ?? -1, 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null, 'video_resolution' => $params['video_resolution'] ?? '720P', 'status' => MpGenerateVideoTask::STATUS_PENDING, 'api_type' => 'keling', // 标识为可灵AI Omni API 'extra_params' => [ 'api_type' => 'keling', 'model' => $params['model'] ?? 'kling-video-o1', 'prompt' => $params['prompt'] ?? '', 'multi_prompt' => $params['multi_prompt'] ?? [], 'shot_type' => $params['shot_type'] ?? 'customize', 'image_list' => $params['image_list'] ?? [], 'element_list' => $params['element_list'] ?? [], 'sound' => $params['sound'] ?? 'off', // 'aspect_ratio' => $params['aspect_ratio'] ?? '16:9', 'duration' => $params['video_duration'] ?? 5, 'mode' => $params['mode'] ?? 'std', 'callback_url' => $params['callback_url'] ?? '', ], 'charge_info' => $this->buildChargeInfo($params, 'keling'), ]); // 提交任务到可灵AI Omni API $this->submitKelingOmniTaskToApi($task); return $task; } /** * 提交可灵AI Omni任务到API * * @param MpGenerateVideoTask $task * @return void */ private function submitKelingOmniTaskToApi(MpGenerateVideoTask $task): void { $client = new Client(['verify'=>false,'timeout'=>120]); try { // 生成JWT token $jwtToken = $this->generateKelingJwtToken(); $extraParams = $task->extra_params; // 构建可灵AI Omni API请求参数 $apiParams = [ 'model_name' => $extraParams['model'], 'prompt' => $extraParams['prompt'], 'mode' => $extraParams['mode'], // 'aspect_ratio' => $extraParams['aspect_ratio'], 'duration' => (int)$extraParams['duration'], ]; // 添加可选参数 if (!empty($extraParams['multi_prompt'])) { $apiParams['multi_prompt'] = $extraParams['multi_prompt']; } if (!empty($extraParams['shot_type'])) { $apiParams['shot_type'] = $extraParams['shot_type']; } if (!empty($extraParams['image_list'])) { $apiParams['image_list'] = $extraParams['image_list']; } if (!empty($extraParams['element_list'])) { $apiParams['element_list'] = json_decode($extraParams['element_list'], true); } if (isset($extraParams['sound'])) { $apiParams['sound'] = $extraParams['sound']; } // if (!empty($extraParams['callback_url'])) { // $apiParams['callback_url'] = $extraParams['callback_url']; // } dLog('generate')->info('可灵AI Omni视频生成参数: ', $apiParams); logDB('generate', 'info', '可灵AI Omni视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]); // 调用可灵AI Omni API $response = $client->post('https://api-beijing.klingai.com/v1/videos/omni-video', [ 'headers' => [ 'Authorization' => 'Bearer ' . $jwtToken, 'Content-Type' => 'application/json', ], 'json' => $apiParams ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('可灵AI Omni API响应: ', $responseData); logDB('generate', 'info', '可灵AI Omni API响应', ['id' => $task->id, 'response' => $responseData]); if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) { // API返回错误 $errorMessage = $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error'; logDB('generate', 'error', '可灵AI Omni任务提交失败', ['id' => $task->id, 'error' => $errorMessage]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $errorMessage, 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]); } elseif (isset($responseData['data']['task_id'])) { // 任务提交成功,更新状态为处理中 $updateData = [ 'task_id' => $responseData['data']['task_id'], // 使用API返回的任务ID 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]; $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData); } else { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '未知的API响应格式', 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]); } } catch (\Exception $e) { // 记录错误 $error = $this->buildGatewayErrorPayload($e); logDB('generate', 'error', '可灵AI Omni任务异常', ['id' => $task->id, 'error' => $e->getMessage()]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $error['error_message'], 'result_json' => $error['payload'], 'extra_params' => array_merge($task->extra_params, ['api_response' => $error['payload']]) ]); dLog('generate')->error('可灵AI Omni API请求异常: ' . $e->getMessage()); } } /** * 查询可灵AI Omni任务状态 * * @param MpGenerateVideoTask $task * @return array */ public function queryKelingOmniTaskStatus(MpGenerateVideoTask $task): array { $client = new Client(['verify'=>false,'timeout'=>120]); try { // 如果任务已完成,直接返回结果 if (in_array($task->status, [MpGenerateVideoTask::STATUS_SUCCESS, MpGenerateVideoTask::STATUS_FAILED])) { return [ 'success' => true, 'data' => [ 'task_id' => $task->task_id, 'task_status' => $this->mapTaskStatusToKelingStatus($task->status), 'result_url' => $task->result_url, 'error_message' => $task->error_message, 'created_at' => $task->created_at, 'updated_at' => $task->updated_at ] ]; } // 生成JWT token $jwtToken = $this->generateKelingJwtToken(); // 构建查询URL $url = 'https://api-beijing.klingai.com/v1/videos/omni-video/' . $task->task_id; $response = $client->get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $jwtToken, 'Content-Type' => 'application/json', ] ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('可灵AI Omni任务状态查询响应: ', $responseData); logDB('generate', 'info', '可灵AI Omni任务状态查询', ['id' => $task->id, 'response' => $responseData]); if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) { logDB('generate', 'error', '可灵AI Omni任务状态查询失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败']); return [ 'status' => 'failed', 'error_message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败', 'result_json' => $responseData ]; } // 解析任务状态 $taskData = $responseData['data'] ?? []; $taskStatus = $taskData['task_status'] ?? 'unknown'; $updateData = ['result_json' => $responseData]; if ($taskStatus === 'succeed') { $updateData['status'] = 'success'; // 任务成功,处理结果 if (isset($taskData['task_result']['videos']) && !empty($taskData['task_result']['videos'])) { $video = $taskData['task_result']['videos'][0]; $video_url = $video['url']; // 下载视频内容 $videoContent = file_get_contents($video_url); // 从任务中获取视频分辨率,按目标档位执行本地超分(sr_720p->720p;sr_1080p->1080p) if ($this->shouldUpgradeVideoResolution($task)) { $upgradeTarget = $this->getVideoUpgradeTarget($task); dLog('generate')->info('可灵检测到需超分视频,开始超分(' . strtolower((string)$task->video_resolution) . ' -> ' . $upgradeTarget . ')', [ 'original_url' => $video_url, 'video_resolution' => $task->video_resolution, 'size' => round(strlen($videoContent) / 1024 / 1024, 2) . 'MB' ]); $url = upgrade480pTo720p($videoContent, 'video', true, $upgradeTarget); if (!$url) { // 超分失败,上传原视频 dLog('generate')->warning('可灵视频超分失败,使用原视频'); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($video_url); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } } else { // 非超分档视频,直接保存原视频 dLog('generate')->info('可灵视频无需本地超分,直接保存', [ 'original_url' => $video_url, 'video_resolution' => $task->video_resolution ]); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($video_url); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); } $updateData['result_url'] = $url; // 视频分辨率标准化(压缩前处理,避免压缩后重复上传) $targetResolution = $this->getTaskTargetResolution($task); if ($targetResolution) { $url = $this->ensureStandardVideoResolution($url, $targetResolution); $updateData['result_url'] = $url; } // 压缩视频 $compressed_video_url = compressVideo($url); $updateData['compressed_url'] = $compressed_video_url ?: $url; // 计算视频时长 $video_duration = 0; if (isset($video['duration'])) { $video_duration = (int)$video['duration']; } elseif (isset($taskData['duration'])) { $video_duration = (int)$taskData['duration']; } elseif (isset($video['frames']) && isset($video['framespersecond'])) { $frames = (int)$video['frames']; $fps = (int)$video['framespersecond']; if ($fps > 0) { $video_duration = (int)floor($frames / $fps); } } // 如果没有计算出时长,使用任务表中的设定值 if ($video_duration <= 0 && !empty($task->extra_params)) { $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true); if (isset($extraParams['duration'])) { $video_duration = (int)$extraParams['duration']; } } // 只有当video_duration > 0时才添加到更新数据中 if ($video_duration > 0) { $updateData['video_duration'] = $video_duration; $updateData['video_time_point_start'] = 0; $updateData['video_time_point_end'] = $video_duration; } } return $updateData; } elseif ($taskStatus === 'failed') { $updateData = [ 'status' => 'failed', 'error_message' => '任务执行失败', 'result_json' => $responseData ]; return $updateData; } // 其他状态(processing等)保持不变 return [ 'success' => true, 'data' => [ 'task_id' => $task->task_id, 'task_status' => $taskStatus, 'result_url' => $task->result_url, 'error_message' => $task->error_message, 'created_at' => $task->created_at, 'updated_at' => $task->updated_at, 'api_response' => $responseData ] ]; } catch (\Exception $e) { dLog('generate')->error('可灵AI Omni任务状态查询异常: ' . $e->getMessage()); return [ 'success' => false, 'message' => '查询失败: ' . $e->getMessage() ]; } } /** * 映射任务状态到可灵AI状态格式 * * @param string $taskStatus * @return string */ private function mapTaskStatusToKelingStatus(string $taskStatus): string { switch ($taskStatus) { case MpGenerateVideoTask::STATUS_PENDING: return 'submitted'; case MpGenerateVideoTask::STATUS_PROCESSING: return 'processing'; case MpGenerateVideoTask::STATUS_SUCCESS: return 'succeed'; case MpGenerateVideoTask::STATUS_FAILED: return 'failed'; default: return 'unknown'; } } /** * 更新可灵AI Omni视频任务状态 * * @param MpGenerateVideoTask $task * @return void */ private function updateKelingOmniTask($task): void { $statusInfo = $this->queryKelingOmniTaskStatus($task); if (!isset($statusInfo['status'])) return; if ($statusInfo['status'] === 'success') { logDB('generate', 'info', '可灵AI Omni视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]); // 同步调整分镜视频状态和结果(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id && isset($statusInfo['result_url'])) { try { DB::beginTransaction(); // 原子抢占:只有仍为 processing 的任务能更新为 success,避免并发重复处理 if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('generate')->info('视频任务已被其他进程处理,跳过重复更新', [ 'id' => $task->id, 'result_url' => $statusInfo['result_url'] ?? null ]); return; } $now = date('Y-m-d H:i:s'); // 构建更新数据 $segmentUpdateData = [ 'origin_video_url' => $statusInfo['result_url'], 'video_task_status' => '已完成', 'current_type' => 2, 'last_frame_url' => $statusInfo['last_frame_url'] ?? '', 'updated_at' => $now ]; // 使用查询任务时已压缩的视频URL,避免重复压缩 $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url']; // 只有当video_duration存在且大于0时才更新 if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) { $segmentUpdateData['video_duration'] = $statusInfo['video_duration']; $segmentUpdateData['video_time_point_start'] = 0; // 获取video_time_point_end(兼容全能模式) $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration']); $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0; } // 更新分镜表(兼容act模式) $updateSegmentResult = $this->updateSegmentTable($task, $segmentUpdateData); if ($updateSegmentResult === false || $updateSegmentResult === 0) { throw new \Exception('更新分镜表失败: 未找到对应的分镜记录'); } // 添加视频生成对话记录 $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']); // 视频生成成功,记录计费明细并扣减用户积分(与任务状态/分镜同事务) $this->chargeVideoTaskInTransaction($task); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('可灵视频任务处理失败', [ 'segment_id' => $segment_id, 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } else { // 没有segment_id的情况下,直接更新任务状态 // 原子抢占与计费同事务,避免并发重复处理 try { DB::beginTransaction(); if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('generate')->info('视频任务已被其他进程处理,跳过重复更新', [ 'id' => $task->id, 'result_url' => $statusInfo['result_url'] ?? null ]); return; } $this->chargeVideoTaskInTransaction($task); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('视频任务成功处理失败,已回滚', [ 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } } elseif ($statusInfo['status'] === 'failed') { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $statusInfo['error_message'], 'result_json' => $statusInfo['result_json'] ?? [] ]); $this->addVideoGenerationFailedRecord($task, $statusInfo['error_message']); // 同步更新分镜表状态为失败(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id) { $this->updateSegmentTable($task, [ 'video_task_status' => '失败', 'updated_at' => date('Y-m-d H:i:s') ]); } } // 如果仍然是处理中状态,不做任何操作 // 处理:如果任务处理超过24小时,标记为失败 $processingTime = now()->diffInHours($task->created_at); if ($processingTime > 24) { logDB('generate', 'warning', '可灵AI Omni视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '任务处理超时(超过24小时)' ]); $this->addVideoGenerationFailedRecord($task, '任务处理超时(超过24小时)'); // 同步更新分镜表状态为失败(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id) { $this->updateSegmentTable($task, [ 'video_task_status' => '失败', 'updated_at' => date('Y-m-d H:i:s') ]); } } } /** * 映射任务状态到前端显示状态 * * @param string $taskStatus * @return string */ private function mapTaskStatus(string $taskStatus): string { switch ($taskStatus) { case MpGenerateVideoTask::STATUS_PENDING: return 'queued'; case MpGenerateVideoTask::STATUS_PROCESSING: return 'running'; case MpGenerateVideoTask::STATUS_SUCCESS: return 'succeeded'; case MpGenerateVideoTask::STATUS_FAILED: return 'failed'; default: return 'unknown'; } } // ==================== 主体管理相关方法 ==================== /** * 创建主体 * * @param array $params * @return AIElement */ public function createElement(array $params): AIElement { // 生成唯一的任务ID $taskId = 'element_' . time() . '_' . uniqid(); // 创建主体记录 $element = AIElement::create([ 'task_id' => $taskId, 'external_task_id' => $params['external_task_id'] ?? null, 'element_name' => $params['element_name'], 'element_description' => $params['element_description'], 'reference_type' => $params['reference_type'], 'element_image_list' => $params['element_image_list'] ?? null, 'element_video_list' => $params['element_video_list'] ?? null, 'element_voice_id' => $params['element_voice_id'] ?? null, 'tag_list' => $params['tag_list'] ?? null, 'callback_url' => $params['callback_url'] ?? null, 'task_status' => AIElement::STATUS_SUBMITTED, ]); // 提交到可灵AI API $this->submitElementToKelingApi($element); return $element; } /** * 提交主体创建任务到可灵AI API * * @param AIElement $element * @return void */ private function submitElementToKelingApi(AIElement $element): void { $client = new Client(['verify' => false, 'timeout' => 1200]); try { // 生成JWT token $jwtToken = $this->generateKelingJwtToken(); // 构建API请求参数 $apiParams = [ 'element_name' => $element->element_name, 'element_description' => $element->element_description, 'reference_type' => $element->reference_type, ]; // 添加图片参考 if ($element->reference_type === AIElement::REFERENCE_TYPE_IMAGE && $element->element_image_list) { $apiParams['element_image_list'] = $element->element_image_list; } // 添加视频参考 if ($element->reference_type === AIElement::REFERENCE_TYPE_VIDEO && $element->element_video_list) { $apiParams['element_video_list'] = $element->element_video_list; } // 添加音色ID if ($element->element_voice_id) { $apiParams['element_voice_id'] = $element->element_voice_id; } // 添加标签 if ($element->tag_list) { $apiParams['tag_list'] = $element->tag_list; } // 添加回调URL if ($element->callback_url) { $apiParams['callback_url'] = $element->callback_url; } // 添加外部任务ID if ($element->external_task_id) { $apiParams['external_task_id'] = $element->external_task_id; } dLog('generate')->info('可灵AI创建主体参数: ', $apiParams); // 调用可灵AI创建主体API $response = $client->post('https://api-beijing.klingai.com/v1/general/advanced-custom-elements', [ 'headers' => [ 'Authorization' => 'Bearer ' . $jwtToken, 'Content-Type' => 'application/json', ], 'json' => $apiParams ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('可灵AI创建主体API响应: ', $responseData); if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) { // API返回错误 $errorMessage = $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error'; $element->update([ 'task_status' => AIElement::STATUS_FAILED, 'task_status_msg' => $errorMessage, ]); } elseif (isset($responseData['data']['task_id'])) { // 任务提交成功,更新状态为处理中 $element->update([ 'task_id' => $responseData['data']['task_id'], // 使用API返回的任务ID 'task_status' => AIElement::STATUS_PROCESSING, 'created_at' => isset($responseData['data']['created_at']) ? date('Y-m-d H:i:s', $responseData['data']['created_at'] / 1000) : now(), 'updated_at' => isset($responseData['data']['updated_at']) ? date('Y-m-d H:i:s', $responseData['data']['updated_at'] / 1000) : now(), ]); } else { $element->update([ 'task_status' => AIElement::STATUS_FAILED, 'task_status_msg' => '未知的API响应格式', ]); } } catch (\Exception $e) { // 记录错误 $element->update([ 'task_status' => AIElement::STATUS_FAILED, 'task_status_msg' => 'API请求失败: ' . $e->getMessage(), ]); dLog('generate')->error('可灵AI创建主体API请求异常: ' . $e->getMessage()); } } /** * 查询单个主体状态 * * @param string $taskId * @param string|null $externalTaskId * @return array */ public function queryElementStatus(string $taskId, string $externalTaskId = null): array { $client = new Client(['verify' => false, 'timeout' => 1200]); try { // 生成JWT token $jwtToken = $this->generateKelingJwtToken(); // 构建查询URL $url = 'https://api-beijing.klingai.com/v1/general/advanced-custom-elements/' . $taskId; $queryParams = []; if ($externalTaskId) { $queryParams['external_task_id'] = $externalTaskId; } if (!empty($queryParams)) { $url .= '?' . http_build_query($queryParams); } $response = $client->get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $jwtToken, 'Content-Type' => 'application/json', ] ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('可灵AI查询主体状态响应: ', $responseData); if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) { return [ 'success' => false, 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败', 'data' => null ]; } // 更新本地数据库记录 if (isset($responseData['data'])) { $this->updateLocalElementFromApi($responseData['data']); } return [ 'success' => true, 'data' => $responseData['data'] ]; } catch (\Exception $e) { dLog('generate')->error('可灵AI查询主体状态异常: ' . $e->getMessage()); return [ 'success' => false, 'message' => '查询失败: ' . $e->getMessage(), 'data' => null ]; } } /** * 根据API响应更新本地主体记录 * * @param array $apiData * @return void */ private function updateLocalElementFromApi(array $apiData): void { $element = AIElement::where('task_id', $apiData['task_id'])->first(); if (!$element) { return; } $updateData = [ 'task_status' => $apiData['task_status'], 'task_status_msg' => $apiData['task_status_msg'] ?? null, 'final_unit_deduction' => $apiData['final_unit_deduction'] ?? null, 'updated_at' => isset($apiData['updated_at']) ? date('Y-m-d H:i:s', $apiData['updated_at'] / 1000) : now(), ]; // 如果任务成功,更新主体信息 if ($apiData['task_status'] === AIElement::STATUS_SUCCEED && isset($apiData['task_result']['elements'])) { $elementData = $apiData['task_result']['elements'][0]; $updateData = array_merge($updateData, [ 'element_id' => $elementData['element_id'], 'element_voice_info' => $elementData['element_voice_info'] ?? null, 'owned_by' => $elementData['owned_by'] ?? 'custom', 'status' => $elementData['status'] ?? AIElement::ELEMENT_STATUS_SUCCEED, ]); } $element->update($updateData); } /** * 查询自定义主体列表 * * @param int $pageNum * @param int $pageSize * @return array */ public function queryCustomElementsList(int $pageNum = 1, int $pageSize = 30): array { $client = new Client(['verify' => false, 'timeout' => 1200]); try { // 生成JWT token $jwtToken = $this->generateKelingJwtToken(); // 构建查询URL $url = 'https://api-beijing.klingai.com/v1/general/advanced-custom-elements'; $queryParams = [ 'pageNum' => $pageNum, 'pageSize' => $pageSize, ]; $url .= '?' . http_build_query($queryParams); $response = $client->get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $jwtToken, 'Content-Type' => 'application/json', ] ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('可灵AI查询自定义主体列表响应: ', $responseData); if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) { return [ 'success' => false, 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败', 'data' => [] ]; } // 更新本地数据库记录 if (isset($responseData['data']) && is_array($responseData['data'])) { foreach ($responseData['data'] as $elementData) { $this->updateLocalElementFromApi($elementData); } } return [ 'success' => true, 'data' => $responseData['data'] ?? [] ]; } catch (\Exception $e) { dLog('generate')->error('可灵AI查询自定义主体列表异常: ' . $e->getMessage()); return [ 'success' => false, 'message' => '查询失败: ' . $e->getMessage(), 'data' => [] ]; } } /** * 查询官方主体列表 * * @param int $pageNum * @param int $pageSize * @return array */ public function queryOfficialElementsList(int $pageNum = 1, int $pageSize = 30): array { $client = new Client(['verify' => false, 'timeout' => 1200]); try { // 生成JWT token $jwtToken = $this->generateKelingJwtToken(); // 构建查询URL $url = 'https://api-beijing.klingai.com/v1/general/advanced-presets-elements'; $queryParams = [ 'pageNum' => $pageNum, 'pageSize' => $pageSize, ]; $url .= '?' . http_build_query($queryParams); $response = $client->get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $jwtToken, 'Content-Type' => 'application/json', ] ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('可灵AI查询官方主体列表响应: ', $responseData); if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) { return [ 'success' => false, 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败', 'data' => [] ]; } return [ 'success' => true, 'data' => $responseData['data'] ?? [] ]; } catch (\Exception $e) { dLog('generate')->error('可灵AI查询官方主体列表异常: ' . $e->getMessage()); return [ 'success' => false, 'message' => '查询失败: ' . $e->getMessage(), 'data' => [] ]; } } /** * 删除自定义主体 * * @param string $elementId * @return array */ public function deleteElement(string $elementId): array { $client = new Client(['verify' => false, 'timeout' => 1200]); try { // 生成JWT token $jwtToken = $this->generateKelingJwtToken(); // 构建API请求参数 $apiParams = [ 'element_id' => $elementId, ]; dLog('generate')->info('可灵AI删除主体参数: ', $apiParams); // 调用可灵AI删除主体API $response = $client->post('https://api-beijing.klingai.com/v1/general/delete-elements', [ 'headers' => [ 'Authorization' => 'Bearer ' . $jwtToken, 'Content-Type' => 'application/json', ], 'json' => $apiParams ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('可灵AI删除主体API响应: ', $responseData); if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) { // API返回错误 return [ 'success' => false, 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error' ]; } else { // 删除成功,更新本地记录状态 AIElement::where('element_id', $elementId)->update([ 'status' => AIElement::ELEMENT_STATUS_DELETED, 'updated_at' => now(), ]); return [ 'success' => true, 'message' => '主体删除成功', 'data' => $responseData['data'] ?? [] ]; } } catch (\Exception $e) { dLog('generate')->error('可灵AI删除主体API请求异常: ' . $e->getMessage()); return [ 'success' => false, 'message' => 'API请求失败: ' . $e->getMessage() ]; } } /** * 获取本地主体列表(包含自定义和官方) * * @param array $filters * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator */ public function getLocalElementsList(array $filters = []) { $query = AIElement::query(); // 筛选条件 if (isset($filters['owned_by'])) { $query->where('owned_by', $filters['owned_by']); } if (isset($filters['reference_type'])) { $query->where('reference_type', $filters['reference_type']); } if (isset($filters['status'])) { $query->where('status', $filters['status']); } if (isset($filters['task_status'])) { $query->where('task_status', $filters['task_status']); } // 搜索关键词 if (isset($filters['keyword']) && !empty($filters['keyword'])) { $query->where(function ($q) use ($filters) { $q->where('element_name', 'like', '%' . $filters['keyword'] . '%') ->orWhere('element_description', 'like', '%' . $filters['keyword'] . '%'); }); } return $query->orderBy('created_at', 'desc') ->paginate($filters['page_size'] ?? 20); } /** * 根据task获取segment_id或act_id(兼容act模式) * 分镜模式:返回segment_id用于查询 * 全能模式:返回act_id用于查询(因为segment_id为空) */ private function getSegmentIdFromTask($task) { $segment_id = getProp($task, 'alias_segment_id'); // 如果有segment_id,直接返回(分镜模式) if (!empty($segment_id)) { return $segment_id; } // 如果有alias_act_id,返回act_id(全能模式,segment_id为空) $act_id = getProp($task, 'alias_act_id'); if (!empty($act_id)) { return $act_id; // 直接返回act_id,不需要查segment_id } return null; } /** * 更新分镜表(兼容act模式) * 分镜模式:使用segment_id字段更新 * 全能模式:使用id字段更新 */ private function updateSegmentTable($task, $updateData) { $segment_id = getProp($task, 'alias_segment_id'); $act_id = getProp($task, 'alias_act_id'); if (!empty($segment_id)) { // 分镜模式:使用segment_id更新 return DB::table('mp_episode_segments')->where('segment_id', $segment_id)->update($updateData); } elseif (!empty($act_id)) { // 全能模式(全能模式):使用id更新 return DB::table('mp_episode_segments')->where('id', $act_id)->update($updateData); } return false; } /** * 获取audio_duration(兼容act模式) * 分镜模式:使用segment_id查询 * 全能模式:使用id查询 * @param array $task 任务数据 * @param string|int $segmentIdOrActId segment_id或act_id * @param float|null $video_duration 视频时长(如果传入,全能模式直接返回此值) * @return float */ private function getAudioDuration($task, $segmentIdOrActId, $video_duration = null) { $segment_id = getProp($task, 'alias_segment_id'); $act_id = getProp($task, 'alias_act_id'); if (!empty($segment_id)) { // 分镜模式:使用segment_id查询 return DB::table('mp_episode_segments')->where('segment_id', $segmentIdOrActId)->value('audio_duration'); } elseif (!empty($act_id)) { // 全能模式:如果传入了video_duration直接返回,否则查询audio_duration if ($video_duration !== null && $video_duration > 0) { return $video_duration; } return DB::table('mp_episode_segments')->where('id', $segmentIdOrActId)->value('audio_duration'); } return 0; } /** * 添加视频生成对话记录 * @param array $task 任务数据 * @param string|int $segmentIdOrActId segment_id(分镜模式)或 act_id(全能模式) * @param string $videoUrl 视频URL * @return void */ private function addVideoGenerationRecords($task, $segmentIdOrActId, $videoUrl) { try { $segment_id = getProp($task, 'alias_segment_id'); $act_id = getProp($task, 'alias_act_id'); $reference_images = getProp($task, 'ref_image_url'); if (!$reference_images) $reference_images = json_encode([], 256); if (!empty($segment_id)) { // 分镜模式:使用segment_id查询 $segment = DB::table('mp_episode_segments as a')->leftJoin('mp_animes as b', 'a.anime_id', 'b.id') ->where('a.segment_id', $segmentIdOrActId) ->select('b.user_id as uid', 'a.anime_id', 'a.episode_number', 'a.tail_frame', 'a.segment_id') ->first(); } elseif (!empty($act_id)) { // 全能模式:使用id查询 $segment = DB::table('mp_episode_segments as a')->leftJoin('mp_animes as b', 'a.anime_id', 'b.id') ->where('a.id', $segmentIdOrActId) ->select('b.user_id as uid', 'a.anime_id', 'a.episode_number', 'a.act_content', 'a.act_show_content', 'a.id as act_id') ->first(); } else { dLog('generate')->warning('无法确定查询模式,任务缺少segment_id和act_id', ['segment_id_or_act_id' => $segmentIdOrActId]); return; } if (!$segment) { dLog('generate')->warning('分镜信息不存在,无法添加对话记录', ['segment_id_or_act_id' => $segmentIdOrActId]); return; } $now = date('Y-m-d H:i:s'); // 根据模式确定保存的字段和值 if (!empty($segment_id)) { // 分镜模式:保存segment_id字段 $recordField = 'segment_id'; $recordValue = $segmentIdOrActId; $userContent = '图片转视频'; $assistantContent = $segment->tail_frame ?: ''; } else { // 全能模式:保存act_id字段 $recordField = 'act_id'; $recordValue = $segmentIdOrActId; $userContent = '片段转视频'; $assistantContent = $segment->act_show_content ? $segment->act_show_content : $segment->act_content; if (!$assistantContent) $assistantContent = ''; } // 同一视频任务只写一次对话记录(仅在有任务ID时按任务幂等,允许多个任务对应同一 act) $videoTaskId = getProp($task, 'id'); if (!empty($videoTaskId)) { $exists = DB::table('mp_anime_records') ->where('video_task_id', $videoTaskId) ->where('role', 'assistant') ->exists(); if ($exists) { dLog('generate')->info('该视频任务的对话记录已存在,跳过重复写入', [ 'video_task_id' => $videoTaskId, 'segment_id_or_act_id' => $segmentIdOrActId, 'video_url' => $videoUrl ]); return; } } // 保存对话记录 $records = [ [ 'uid' => $segment->uid, 'anime_id' => $segment->anime_id, 'sequence' => $segment->episode_number, 'role' => 'user', 'content' => $userContent, 'video_task_id' => $videoTaskId, $recordField => $recordValue, 'video_url' => '', 'reference_images' => json_encode([], 256), 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $segment->uid, 'anime_id' => $segment->anime_id, 'sequence' => $segment->episode_number, 'role' => 'assistant', 'content' => $assistantContent, 'video_task_id' => $videoTaskId, $recordField => $recordValue, 'video_url' => $videoUrl, 'reference_images' => $reference_images, 'created_at' => $now, 'updated_at' => $now ] ]; DB::table('mp_anime_records')->insert($records); dLog('generate')->info('视频生成对话记录添加成功', [ 'segment_id_or_act_id' => $segmentIdOrActId, 'video_url' => $videoUrl, 'uid' => $segment->uid, 'anime_id' => $segment->anime_id ]); } catch (\Exception $e) { dLog('generate')->error('添加视频生成对话记录失败: ' . $e->getMessage(), [ 'segment_id' => $segmentIdOrActId, 'video_url' => $videoUrl, 'error' => $e->getMessage() ]); } } /** * 视频任务失败时写入一条对话记录 * * @param MpGenerateVideoTask|array|object $task * @param string|null $errorMessage * @return void */ public function addVideoGenerationFailedRecord($task, $errorMessage = null) { $returnErrorMsg = mapErrorMessage($errorMessage); // 未知异常时保留原始错误信息;原始错误为空则保留默认提示 if ($returnErrorMsg === '未知异常,请重试' && $errorMessage !== null && $errorMessage !== '') { $returnErrorMsg = $errorMessage; } try { $videoTaskId = getProp($task, 'id'); $segment_id = getProp($task, 'alias_segment_id'); $act_id = getProp($task, 'alias_act_id'); if (!empty($segment_id)) { $segment = DB::table('mp_episode_segments as a')->leftJoin('mp_animes as b', 'a.anime_id', 'b.id') ->where('a.segment_id', $segment_id) ->select('b.user_id as uid', 'a.anime_id', 'a.episode_number', 'a.tail_frame', 'a.act_content', 'a.act_show_content', 'a.segment_id') ->first(); $recordField = 'segment_id'; $recordValue = $segment_id; } elseif (!empty($act_id)) { $segment = DB::table('mp_episode_segments as a')->leftJoin('mp_animes as b', 'a.anime_id', 'b.id') ->where('a.id', $act_id) ->select('b.user_id as uid', 'a.anime_id', 'a.episode_number', 'a.tail_frame', 'a.act_content', 'a.act_show_content', 'a.id as act_id') ->first(); $recordField = 'act_id'; $recordValue = $act_id; } else { dLog('generate')->warning('无法确定失败记录查询模式,任务缺少segment_id和act_id', [ 'video_task_id' => $videoTaskId ]); return; } if (!$segment) { dLog('generate')->warning('分镜信息不存在,无法添加失败对话记录', [ 'video_task_id' => $videoTaskId, 'segment_id_or_act_id' => $recordValue ]); return; } // 同一视频任务只写一次失败记录(无任务ID时不做幂等判断,兼容手动添加记录) if (!empty($videoTaskId)) { $exists = DB::table('mp_anime_records') ->where('video_task_id', $videoTaskId) ->where('role', 'assistant') ->where('content', 'LIKE', '视频生成失败%') ->exists(); if ($exists) { return; } } $now = date('Y-m-d H:i:s'); $userContent = !empty($segment_id) ? '图片转视频' : '片段转视频'; // 与成功路径保持一致:user + assistant 双记录,失败时 assistant 记录错误信息 $records = [ [ 'uid' => getProp($segment, 'uid'), 'anime_id' => getProp($segment, 'anime_id'), 'sequence' => getProp($segment, 'episode_number'), 'role' => 'user', 'content' => $userContent, $recordField => $recordValue, 'video_url' => '', 'video_task_id' => $videoTaskId ?: null, 'reference_images' => json_encode([], 256), 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => getProp($segment, 'uid'), 'anime_id' => getProp($segment, 'anime_id'), 'sequence' => getProp($segment, 'episode_number'), 'role' => 'assistant', 'content' => '视频生成失败,异常原因:' . (string)$returnErrorMsg, $recordField => $recordValue, 'video_url' => '', 'video_task_id' => $videoTaskId ?: null, 'reference_images' => json_encode([], 256), 'created_at' => $now, 'updated_at' => $now ] ]; DB::table('mp_anime_records')->insert($records); dLog('generate')->info('视频生成失败对话记录添加成功', [ 'video_task_id' => $videoTaskId, 'segment_id_or_act_id' => $recordValue, 'error_message' => $errorMessage ]); } catch (\Exception $e) { dLog('generate')->error('添加视频生成失败对话记录失败: ' . $e->getMessage(), [ 'video_task_id' => getProp($task, 'id'), 'error_message' => $errorMessage ]); } } /** * 创建统一API视频生成任务(智帧20等新模型) * * @param array $params * @return MpGenerateVideoTask */ public function createUnifiedApiTask(array $params): MpGenerateVideoTask { // 分辨率统一规范化为小写(480p=原始档;sr_720p=480p生成+本地超分到720p) $params['video_resolution'] = strtolower(trim((string)($params['video_resolution'] ?? '720p'))); // 生成唯一的任务ID $taskId = 'unified_' . time() . '_' . uniqid(); // 创建任务记录 $task = MpGenerateVideoTask::create([ 'task_id' => $taskId, 'alias_segment_id' => $params['alias_segment_id'] ?? '', 'alias_act_id' => $params['alias_act_id'] ?? '', 'prompt' => $params['prompt'] ?? '', 'first_frame_url' => $params['first_frame_url'] ?? '', 'tail_frame_url' => $params['last_frame_url'] ?? '', 'ref_image_url' => $params['ref_image_url'] ?? json_encode([], 256), 'seed' => $params['seed'] ?? -1, 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null, 'video_resolution' => $params['video_resolution'] ?? '720p', 'status' => MpGenerateVideoTask::STATUS_PENDING, 'api_type' => 'zzengine', 'extra_params' => [ 'task_type' => 'video', 'model_code' => $params['model_code'] ?? 'zhizhen-20', 'callback_url' => $params['callback_url'] ?? '', 'video_duration' => $params['video_duration'] ?? 5, 'video_ratio' => $params['video_ratio'] ?? '9:16', 'parameters' => $params['parameters'] ?? [], ], 'charge_info' => $this->buildChargeInfo($params, 'zzengine'), ]); // 提交任务到统一API $this->submitUnifiedApiTaskToApi($task); return $task; } /** * 提交任务到统一API * * @param MpGenerateVideoTask $task * @return void */ private function submitUnifiedApiTaskToApi(MpGenerateVideoTask $task): void { $client = new Client(['verify' => false, 'timeout' => 1200]); try { // 验证环境变量配置 $apiKey = env('ZHIZHEN_AI_SK'); $apiUrl = 'https://model-relay.zzengine.net/api/v1/task/submit'; if (empty($apiKey)) { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '统一视频API密钥未配置,请检查环境变量UNIFIED_VIDEO_API_KEY' ]); return; } $extraParams = $task->extra_params; // 构建统一API请求参数 $apiParams = [ 'task_type' => 'video', 'model_code' => $extraParams['model_code'] ?? 'zhizhen-20', 'prompt' => $task->prompt, 'video_duration' => $extraParams['video_duration'] ?? 5, 'video_resolution' => $this->apiRequestResolution((string)$task->video_resolution), 'video_ratio' => $extraParams['video_ratio'] ?? '9:16', ]; // 添加回调地址 if (!empty($extraParams['callback_url'])) { $apiParams['callback_url'] = $extraParams['callback_url']; } // 添加parameters参数 if (!empty($extraParams['parameters'])) { $parameters = $extraParams['parameters']; // sr_1080p 请求时按 720p 生成,本地再超分 if (isset($parameters['resolution'])) { $parameters['resolution'] = $this->apiRequestResolution((string)$parameters['resolution']); } $apiParams['parameters'] = $parameters; } dLog('generate')->info('统一API视频生成参数: ', $apiParams); logDB('generate', 'info', '统一API视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]); // 调用统一API $response = $client->post($apiUrl, [ 'headers' => [ 'X-Api-Key' => $apiKey, 'Content-Type' => 'application/json', ], 'json' => $apiParams ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('统一API响应: ', $responseData); logDB('generate', 'info', '统一API视频生成API响应', ['id' => $task->id, 'response' => $responseData]); if (isset($responseData['error']) || !isset($responseData['data']['task_uid'])) { // API返回错误 $errorMsg = $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error'; logDB('generate', 'error', '统一API视频生成任务提交失败', ['id' => $task->id, 'error' => $errorMsg]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $errorMsg, 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]); } elseif (isset($responseData['data']['task_uid'])) { // 任务提交成功,更新状态为处理中 $updateData = [ 'task_id' => $responseData['data']['task_uid'], // 使用API返回的task_uid 'extra_params' => array_merge($task->extra_params, [ 'task_uid' => $responseData['data']['task_uid'], 'estimated_amount' => $responseData['data']['estimated_amount'] ?? 0, 'account_uid' => $responseData['data']['account_uid'] ?? '', 'key_uid' => $responseData['data']['key_uid'] ?? '', 'api_response' => $responseData ]) ]; $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData); } else { $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '未知的API响应格式', 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData]) ]); } } catch (\GuzzleHttp\Exception\RequestException $e) { $error = $this->buildGatewayErrorPayload($e); logDB('generate', 'error', '统一API请求失败', ['id' => $task->id, 'error' => $error['payload']]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $error['error_message'], 'result_json' => $error['payload'], 'extra_params' => array_merge($task->extra_params, ['api_response' => $error['payload']]) ]); } catch (\Exception $e) { $error = $this->buildGatewayErrorPayload($e); logDB('generate', 'error', '统一API任务提交异常', ['id' => $task->id, 'error' => $e->getMessage()]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $error['error_message'], 'result_json' => $error['payload'], 'extra_params' => array_merge($task->extra_params, ['api_response' => $error['payload']]) ]); } } /** * 查询统一API任务状态 * * @param MpGenerateVideoTask $task * @return array */ public function queryUnifiedApiTaskStatus(MpGenerateVideoTask $task): array { $client = new Client(['verify' => false, 'timeout' => 1200]); try { // 获取API任务UID $extraParams = $task->extra_params; $taskUid = $extraParams['task_uid'] ?? $task->task_id; if (!$taskUid) { return [ 'status' => 'failed', 'error_message' => 'API任务UID不存在', 'result_url' => null ]; } // 验证环境变量配置 $apiKey = env('ZHIZHEN_AI_SK'); $apiUrl = 'https://model-relay.zzengine.net'; $queryUrl = rtrim($apiUrl, '/') . '/api/v1/task/query'; if (empty($apiKey)) { return [ 'status' => 'failed', 'error_message' => '统一视频API密钥未配置', 'result_url' => null ]; } dLog('generate')->info('查询统一API任务状态: ', ['task_uid' => $taskUid]); // 调用统一API查询接口(GET请求) $response = $client->get($queryUrl, [ 'headers' => [ 'X-Api-Key' => $apiKey, ], 'query' => [ 'task_uid' => $taskUid ] ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('统一API任务状态查询响应: ', $responseData); // 检查响应code if (!isset($responseData['code']) || $responseData['code'] !== 0) { $errorMsg = $responseData['msg'] ?? $responseData['message'] ?? 'API查询失败'; logDB('generate', 'error', '统一API任务状态查询失败', ['id' => $task->id, 'error' => $errorMsg]); return [ 'status' => 'failed', 'error_message' => $errorMsg, 'result_url' => null, 'result_json' => $responseData ?? [] ]; } // 解析API响应 $taskData = $responseData['data']['task'] ?? []; if (empty($taskData)) { return [ 'status' => 'failed', 'error_message' => '任务数据为空', 'result_url' => null, 'result_json' => $responseData ?? [] ]; } // 任务状态映射:0=排队中,1=执行中,2=成功,3=失败 $taskStatus = $taskData['status'] ?? 3; $failReason = $taskData['fail_reason'] ?? ''; $returnData = [ 'status' => 'processing', 'error_message' => null, 'result_url' => null, 'result_json' => $responseData ?? [] ]; // 根据status判断任务状态 if ($taskStatus === 2) { // 任务成功 $detail = $taskData['detail'] ?? []; $result = $detail['result'] ?? []; // 获取视频URL $videoUrl = $result['video'] ?? null; if (!$videoUrl && isset($result['videos']) && is_array($result['videos']) && count($result['videos']) > 0) { $videoUrl = $result['videos'][0]; } if ($videoUrl) { // 保存视频到TOS,超分档(sr_720p/sr_1080p)本地超分后保存,其余分辨率直接保存 try { // 下载视频内容 $videoContent = file_get_contents($videoUrl); // 从任务中获取视频分辨率,按目标档位执行本地超分(sr_720p->720p;sr_1080p->1080p) if ($this->shouldUpgradeVideoResolution($task)) { $upgradeTarget = $this->getVideoUpgradeTarget($task); dLog('generate')->info('检测到需超分视频,开始超分(' . strtolower((string)$task->video_resolution) . ' -> ' . $upgradeTarget . ')', [ 'original_url' => $videoUrl, 'video_resolution' => $task->video_resolution, 'size' => round(strlen($videoContent) / 1024 / 1024, 2) . 'MB' ]); $enhancedUrl = upgrade480pTo720p($videoContent, 'video', true, $upgradeTarget); if ($enhancedUrl) { dLog('generate')->info('视频超分成功', ['enhanced_url' => $enhancedUrl]); $url = $enhancedUrl; $returnData['result_url'] = $url; } else { // 超分失败,使用原视频 dLog('generate')->warning('视频超分失败,使用原视频'); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($videoUrl); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); $returnData['result_url'] = $url; } } else { // 非超分档视频,直接保存原视频 dLog('generate')->info('视频无需本地超分,直接保存', [ 'original_url' => $videoUrl, 'video_resolution' => $task->video_resolution ]); $video_name = 'ai_generation_' . time() . '_' . uniqid(); $video_ext = getVideoExtFromUrl($videoUrl); $video_name = $video_name . $video_ext; $url = uploadStreamByTos('video', $videoContent, $video_name); $returnData['result_url'] = $url; } // 视频分辨率标准化(压缩前处理,避免压缩后重复上传) $targetResolution = $this->getTaskTargetResolution($task); if ($targetResolution) { $url = $this->ensureStandardVideoResolution($url, $targetResolution); $returnData['result_url'] = $url; } // 压缩视频 $compressed_video_url = compressVideo($url); $returnData['compressed_url'] = $compressed_video_url ?: $url; } catch (\Exception $e) { dLog('generate')->error('保存视频到TOS失败', ['error' => $e->getMessage()]); $returnData['result_url'] = $videoUrl; // 如果保存失败,使用原URL } } else { $returnData['status'] = 'failed'; $returnData['error_message'] = '视频URL不存在'; return $returnData; } // 获取视频时长 $video_duration = $detail['duration'] ?? 0; if ($video_duration > 0) { $returnData['video_duration'] = (int)$video_duration; $returnData['video_time_point_start'] = 0; $returnData['video_time_point_end'] = (int)$video_duration; } // 获取尾帧 if (isset($result['last_frame_url'])) { $returnData['last_frame_url'] = $result['last_frame_url']; } $returnData['status'] = 'success'; } elseif ($taskStatus === 3) { // 任务失败 $returnData['status'] = 'failed'; $returnData['error_message'] = $failReason ?: '任务执行失败'; } elseif (in_array($taskStatus, [0, 1])) { // 0=排队中,1=执行中 $returnData['status'] = 'processing'; } else { $returnData['status'] = 'failed'; $returnData['error_message'] = '未知的任务状态: ' . $taskStatus; } return $returnData; } catch (\GuzzleHttp\Exception\RequestException $e) { $errorMsg = 'API查询失败'; if ($e->hasResponse()) { $errorResponse = json_decode($e->getResponse()->getBody()->getContents(), true); $errorMsg .= ': ' . ($errorResponse['msg'] ?? $errorResponse['message'] ?? $e->getMessage()); } else { $errorMsg .= ': ' . $e->getMessage(); } return [ 'status' => 'processing', 'error_message' => $errorMsg, 'result_url' => null ]; } catch (\Exception $e) { return [ 'status' => 'processing', 'error_message' => $e->getMessage(), 'result_url' => null ]; } } /** * 更新统一API视频任务状态 * * @param MpGenerateVideoTask $task * @return void */ private function updateUnifiedApiTask($task): void { $statusInfo = $this->queryUnifiedApiTaskStatus($task); if (!isset($statusInfo['status'])) return; if ($statusInfo['status'] === 'success') { logDB('generate', 'info', '统一API视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]); // // 判断是否为480p视频,如果是则触发超分逻辑 // if ($task->video_resolution === '480P') { // logDB('video_enhance', 'info', '检测到480p视频生成成功,准备自动触发超分', [ // 'task_id' => $task->id // ]); // // 先更新任务状态为成功 // $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo); // // 调用超分任务提交方法 // $enhanceResult = $this->submitVideoEnhanceTask($task->id); // if ($enhanceResult['success']) { // logDB('video_enhance', 'info', '480p视频超分任务已自动提交', [ // 'original_task_id' => $task->id, // 'enhance_task_id' => $enhanceResult['enhance_task_id'] // ]); // } else { // logDB('video_enhance', 'warning', '480p视频超分任务提交失败,保持原480p视频', [ // 'task_id' => $task->id, // 'error' => $enhanceResult['message'] // ]); // // 超分失败时,仍然更新分镜表为480p视频 // $this->update480pVideoToSegment($task, $statusInfo); // } // // 480p视频已触发超分,直接返回,不继续后续更新操作 // return; // } $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id && isset($statusInfo['result_url'])) { try { DB::beginTransaction(); // 原子抢占:只有仍为 processing 的任务才能更新成功,避免多个进程重复处理同一任务 if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('generate')->info('视频任务已被其他进程处理,跳过重复更新', [ 'id' => $task->id, 'result_url' => $statusInfo['result_url'] ?? null ]); return; } $now = date('Y-m-d H:i:s'); // 构建更新数据 $segmentUpdateData = [ 'origin_video_url' => $statusInfo['result_url'], 'video_task_status' => '已完成', 'current_type' => 2, 'last_frame_url' => $statusInfo['last_frame_url'] ?? '', 'updated_at' => $now ]; // 使用查询任务时已压缩的视频URL,避免重复压缩 $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url']; // 只有当video_duration存在且大于0时才更新 if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) { $segmentUpdateData['video_duration'] = $statusInfo['video_duration']; $segmentUpdateData['video_time_point_start'] = 0; // 获取audio_duration(兼容全能模式) $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null); $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0; } // 更新分镜表(兼容act模式) $updateSegmentResult = $this->updateSegmentTable($task, $segmentUpdateData); if ($updateSegmentResult === false || $updateSegmentResult === 0) { throw new \Exception('更新分镜表失败: 未找到对应的分镜记录'); } // 添加视频生成对话记录 $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']); // 如果是全能模式的首帧图,则提取视频第一帧并更新anime表 $act_id = getProp($task, 'alias_act_id'); if (!empty($act_id) && Redis::sismember('anime_act_first_frame_urls', $act_id)) { $anime_id = DB::table('mp_episode_segments')->where('id', $act_id)->value('anime_id'); if ($anime_id) { // 提取视频第一帧 $videoUrl = $segmentUpdateData['video_url']; $firstFrameUrl = getVideoFirstFrame($videoUrl, 'anime_first_frames', 'tos'); if ($firstFrameUrl) { // 更新anime表 DB::table('mp_animes')->where('id', $anime_id)->update([ 'first_frame_url' => $firstFrameUrl, 'updated_at' => date('Y-m-d H:i:s') ]); dLog('video_frame')->info('全能模式首帧图更新成功', [ 'act_id' => $act_id, 'anime_id' => $anime_id, 'first_frame_url' => $firstFrameUrl ]); } } } // 视频生成成功,记录计费明细并扣减用户积分(与任务状态/分镜同事务) $this->chargeVideoTaskInTransaction($task); DB::commit(); // 从Redis中移除 Redis::srem('anime_act_first_frame_urls', $act_id); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('统一API视频任务处理失败', [ 'segment_id' => $segment_id, 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } else { // 没有segment_id的情况下,直接更新任务状态 // 原子抢占与计费同事务,避免并发重复处理 try { DB::beginTransaction(); if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('generate')->info('视频任务已被其他进程处理,跳过重复更新', [ 'id' => $task->id, 'result_url' => $statusInfo['result_url'] ?? null ]); return; } $this->chargeVideoTaskInTransaction($task); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('视频任务成功处理失败,已回滚', [ 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } } elseif ($statusInfo['status'] === 'failed') { logDB('generate', 'error', '统一API视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $statusInfo['error_message'], 'result_json' => $statusInfo['result_json'] ?? [] ]); $this->addVideoGenerationFailedRecord($task, $statusInfo['error_message']); // 同步更新分镜表状态为失败(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id) { $this->updateSegmentTable($task, [ 'video_task_status' => '失败', 'updated_at' => date('Y-m-d H:i:s') ]); } } // 如果仍然是处理中状态,不做任何操作 // 处理:如果任务处理超过48小时,标记为失败 $processingTime = now()->diffInHours($task->created_at); if ($processingTime > 48) { logDB('generate', 'warning', '统一API视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '任务处理超时(超过48小时)' ]); $this->addVideoGenerationFailedRecord($task, '任务处理超时(超过48小时)'); // 同步更新分镜表状态为失败(兼容全能模式) $segment_id = $this->getSegmentIdFromTask($task); if ($segment_id) { $this->updateSegmentTable($task, [ 'video_task_status' => '失败', 'updated_at' => date('Y-m-d H:i:s') ]); } } } /** * 将任务从 processing 原子更新为 success,返回是否更新成功 * * 防止多个定时任务进程同时查询到同一 processing 任务后重复处理, * 导致对话记录重复写入、任务表 result_url 被后写入的结果覆盖。 * * @param MpGenerateVideoTask $task * @param array $statusInfo * @return bool */ private function markVideoTaskSuccess(MpGenerateVideoTask $task, array $statusInfo): bool { $updateData = [ 'status' => MpGenerateVideoTask::STATUS_SUCCESS, 'updated_at' => now(), ]; if (isset($statusInfo['result_json'])) { $updateData['result_json'] = is_array($statusInfo['result_json']) ? json_encode($statusInfo['result_json'], JSON_UNESCAPED_UNICODE) : $statusInfo['result_json']; } if (isset($statusInfo['result_url'])) { $updateData['result_url'] = $statusInfo['result_url']; } if (isset($statusInfo['compressed_url'])) { $updateData['compressed_url'] = $statusInfo['compressed_url']; } if (isset($statusInfo['last_frame_url'])) { $updateData['last_frame_url'] = $statusInfo['last_frame_url']; } if (isset($statusInfo['error_message'])) { $updateData['error_message'] = $statusInfo['error_message']; } if (isset($statusInfo['extra_params'])) { $updateData['extra_params'] = is_array($statusInfo['extra_params']) ? json_encode($statusInfo['extra_params'], JSON_UNESCAPED_UNICODE) : $statusInfo['extra_params']; } if (!$task->completed_at) { $updateData['completed_at'] = now(); } $updated = DB::table('mp_generate_video_tasks') ->where('id', $task->id) ->where('status', MpGenerateVideoTask::STATUS_PROCESSING) ->update($updateData); if ($updated) { $task->status = MpGenerateVideoTask::STATUS_SUCCESS; // 同步内存对象属性,保证后续逻辑(如计费时解析 result_json 中的 token) // 读到最新结果,而不是数据库已更新但内存仍是旧值 if (isset($statusInfo['result_json'])) { $task->result_json = is_array($statusInfo['result_json']) ? $statusInfo['result_json'] : json_decode($statusInfo['result_json'], true); } if (isset($statusInfo['result_url'])) { $task->result_url = $statusInfo['result_url']; } if (isset($statusInfo['compressed_url'])) { $task->compressed_url = $statusInfo['compressed_url']; } if (isset($statusInfo['last_frame_url'])) { $task->last_frame_url = $statusInfo['last_frame_url']; } if (isset($statusInfo['error_message'])) { $task->error_message = $statusInfo['error_message']; } if (isset($statusInfo['extra_params'])) { $task->extra_params = is_array($statusInfo['extra_params']) ? $statusInfo['extra_params'] : json_decode($statusInfo['extra_params'], true); } if (!$task->completed_at) { $task->completed_at = now(); } } return (bool)$updated; } /** * 确保视频为标准分辨率(720p -> 1280*720 / 720*1280,1080p -> 1920*1080 / 1080*1920) * * 下载视频并探测实际分辨率,若属于目标档位但不是标准尺寸,则强制转码为标准分辨率 * 并上传TOS返回新地址;已经是标准分辨率、非目标档位或处理失败时返回原地址。 * * @param string $videoUrl 视频URL * @param string $targetResolution 目标档位:'720p' 或 '1080p'(其他值不处理) * @return string 规范化后的视频URL(失败返回原URL) */ private function ensureStandardVideoResolution($videoUrl, $targetResolution = '720p') { if (empty($videoUrl) || !in_array($targetResolution, ['720p', '1080p', 'sr_1080p'])) { return $videoUrl; } $tempDir = storage_path('app/temp/video_standard'); $inputFile = ''; $outputFile = ''; try { if (!is_dir($tempDir)) { mkdir($tempDir, 0775, true); } // 1. 下载视频到临时目录 $uniqueId = uniqid('std_res_') . bin2hex(random_bytes(4)); $inputFile = $tempDir . '/' . $uniqueId . '_input' . getVideoExtFromUrl($videoUrl); $outputFile = $tempDir . '/' . $uniqueId . '_output.mp4'; $client = new Client(['timeout' => 600, 'verify' => false]); $response = $client->get($videoUrl); if (file_put_contents($inputFile, $response->getBody()->getContents()) === false) { dLog('video_standard')->error('标准分辨率处理:视频下载写入失败', ['url' => $videoUrl]); return $videoUrl; } if (!file_exists($inputFile) || filesize($inputFile) <= 0) { dLog('video_standard')->error('标准分辨率处理:视频下载文件无效', ['url' => $videoUrl]); return $videoUrl; } // 2. 探测视频实际分辨率 $ffprobePath = env('FFPROBE_PATH', 'ffprobe'); $probeCmd = '"' . $ffprobePath . '" -v quiet -print_format json -show_streams "' . $inputFile . '" 2>&1'; $probeOutput = shell_exec($probeCmd); $videoInfo = json_decode($probeOutput, true); $width = 0; $height = 0; if (isset($videoInfo['streams']) && is_array($videoInfo['streams'])) { foreach ($videoInfo['streams'] as $stream) { if (isset($stream['codec_type']) && $stream['codec_type'] === 'video') { $width = (int)($stream['width'] ?? 0); $height = (int)($stream['height'] ?? 0); break; } } } if ($width <= 0 || $height <= 0) { dLog('video_standard')->warning('标准分辨率处理:无法获取视频分辨率', [ 'url' => $videoUrl, 'probe' => $probeOutput ]); return $videoUrl; } // 3. 档位校验:仅处理实际属于目标档位的视频(短边接近目标值),其他分辨率不处理 $shortSide = min($width, $height); // 兜底标准化:仅处理已属于目标档位的视频(超分由 upgrade480pTo720p 负责,这里不做放大) $targetShortSide = $targetResolution === '720p' ? 720 : 1080; $isMatch = abs($shortSide - $targetShortSide) <= 40; if (!$isMatch) { dLog('video_standard')->info('视频不属于目标分辨率档位,跳过处理', [ 'url' => $videoUrl, 'source' => $width . 'x' . $height, 'target' => $targetResolution ]); return $videoUrl; } // 4. 确定目标标准分辨率 $isPortrait = $width < $height; if ($targetResolution === '720p') { $targetWidth = $isPortrait ? 720 : 1280; $targetHeight = $isPortrait ? 1280 : 720; } else { // 1080p / sr_1080p 统一目标:16:9 -> 1920x1080,9:16 -> 1080x1920 $targetWidth = $isPortrait ? 1080 : 1920; $targetHeight = $isPortrait ? 1920 : 1080; } // 5. 已经是标准分辨率则直接返回 if ($width === $targetWidth && $height === $targetHeight) { dLog('video_standard')->info('视频已是标准分辨率,无需转换', [ 'url' => $videoUrl, 'resolution' => $width . 'x' . $height ]); return $videoUrl; } // 6. 强制转码(scale保持纵横比 + pad补边到目标尺寸) $ffmpegPath = env('FFMPEG_PATH', 'ffmpeg'); $filter = 'scale=' . $targetWidth . ':' . $targetHeight . ':force_original_aspect_ratio=decrease,pad=' . $targetWidth . ':' . $targetHeight . ':(ow-iw)/2:(oh-ih)/2'; $ffmpegCmd = '"' . $ffmpegPath . '" -i "' . $inputFile . '" -vf "' . $filter . '" -c:v libx264 -preset medium -crf 23' . ' -c:a aac -b:a 128k -movflags +faststart -y "' . $outputFile . '" 2>&1'; dLog('video_standard')->info('开始视频分辨率强制转换', [ 'url' => $videoUrl, 'source' => $width . 'x' . $height, 'target' => $targetWidth . 'x' . $targetHeight, 'command' => $ffmpegCmd ]); $output = shell_exec($ffmpegCmd); if (!file_exists($outputFile) || filesize($outputFile) <= 0) { dLog('video_standard')->error('标准分辨率转码失败', [ 'url' => $videoUrl, 'output' => $output ]); return $videoUrl; } // 6. 上传TOS并返回新地址 $videoName = 'ai_generation_' . time() . '_' . uniqid() . '.mp4'; $newUrl = uploadStreamByTos('video', file_get_contents($outputFile), $videoName); dLog('video_standard')->info('视频分辨率转换完成', [ 'source' => $videoUrl, 'target' => $newUrl, 'resolution' => $targetWidth . 'x' . $targetHeight ]); return $newUrl ?: $videoUrl; } catch (\Exception $e) { dLog('video_standard')->error('标准分辨率处理异常: ' . $e->getMessage(), ['url' => $videoUrl]); return $videoUrl; } finally { if ($inputFile && file_exists($inputFile)) @unlink($inputFile); if ($outputFile && file_exists($outputFile)) @unlink($outputFile); } } /** * 根据任务的分辨率字段确定标准化目标档位 * * sr_720p(超分720p)目标为720p;sr_1080p(超分1080p)目标为sr_1080p; * 720p 目标为720p;1080p 目标为1080p;480p(原始档,不做超分与标准化)与其他档位返回空(不处理) * * @param MpGenerateVideoTask|object $task * @return string ''|'720p'|'1080p'|'sr_1080p' */ private function getTaskTargetResolution($task) { $resolution = strtolower(trim((string)getProp($task, 'video_resolution', ''))); $upscaleTarget = $this->resolutionUpscaleTarget($resolution); if ($upscaleTarget === '1080p') { return 'sr_1080p'; } if ($upscaleTarget === '720p') { return '720p'; } if ($resolution === '720p') { return '720p'; } if ($resolution === '1080p') { return '1080p'; } return ''; } /** * 更新480p视频到分镜表(超分失败时的降级处理) * * @param MpGenerateVideoTask $task * @param array $statusInfo * @return void */ private function update480pVideoToSegment($task, $statusInfo): void { $segment_id = $this->getSegmentIdFromTask($task); if (!$segment_id || !isset($statusInfo['result_url'])) { return; } try { DB::beginTransaction(); $now = date('Y-m-d H:i:s'); // 构建更新数据 $segmentUpdateData = [ 'origin_video_url' => $statusInfo['result_url'], 'video_task_status' => '已完成', 'current_type' => 2, 'last_frame_url' => $statusInfo['last_frame_url'] ?? '', 'updated_at' => $now ]; // 使用查询任务时已压缩的视频URL,避免重复压缩 $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url']; // 只有当video_duration存在且大于0时才更新 if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) { $segmentUpdateData['video_duration'] = $statusInfo['video_duration']; $segmentUpdateData['video_time_point_start'] = 0; // 获取audio_duration(兼容全能模式) $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null); $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0; } // 更新分镜表(兼容act模式) $this->updateSegmentTable($task, $segmentUpdateData); // 添加视频生成对话记录 $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('generate')->error('480p视频更新分镜表失败', [ 'segment_id' => $segment_id, 'task_id' => $task->id, 'error' => $e->getMessage() ]); } } /** * 提交视频超分任务(480p -> 720p) * * @param int $taskId mp_generate_video_tasks表的任务ID * @return array 包含成功状态和新任务ID或错误信息 */ public function submitVideoEnhanceTask(int $taskId): array { try { // 查询原任务 $originalTask = MpGenerateVideoTask::find($taskId); if (!$originalTask) { return [ 'success' => false, 'message' => '原任务不存在' ]; } // 检查任务是否已完成 if ($originalTask->status !== MpGenerateVideoTask::STATUS_SUCCESS) { return [ 'success' => false, 'message' => '只能对已成功完成的任务进行超分处理' ]; } // 检查分辨率是否为480p(不区分大小写;480p为原始档,可再手动提交超分到720p) if (strtolower(trim((string)$originalTask->video_resolution)) !== '480p') { return [ 'success' => false, 'message' => '只支持480p视频超分到720p' ]; } // 检查是否有视频URL if (empty($originalTask->result_url)) { return [ 'success' => false, 'message' => '原任务没有视频URL' ]; } // 视频分辨率标准化兜底(480p不在处理范围,720p/1080p强制标准分辨率;失败回退原URL) $sourceVideoUrl = $this->ensureStandardVideoResolution($originalTask->result_url, '720p'); // 获取API Key $apiKey = env('VOLC_AI_MEDIAKIT_KEY'); if (empty($apiKey)) { return [ 'success' => false, 'message' => '火山引擎 MediaKit API Key 未配置' ]; } // 构建请求参数 $requestParams = [ 'video_url' => $sourceVideoUrl, 'resolution' => '720p', 'bitrate_level' => 'medium' ]; // 调用超分API $client = new Client(['verify' => false, 'timeout' => 120]); $response = $client->post('https://mediakit.cn-beijing.volces.com/api/v1/tools/enhance-video-fast', [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', ], 'json' => $requestParams ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('视频超分任务提交响应', [ 'original_task_id' => $taskId, 'response' => $responseData ]); // 检查API响应 if (!isset($responseData['success']) || $responseData['success'] !== true) { $errorMsg = '超分任务提交失败'; if (isset($responseData['error'])) { $errorMsg .= ': ' . ($responseData['error']['message'] ?? json_encode($responseData['error'])); } logDB('video_enhance', 'error', $errorMsg, [ 'original_task_id' => $taskId, 'response' => $responseData ]); return [ 'success' => false, 'message' => $errorMsg ]; } // 使用事务创建新的超分任务记录并更新原任务 try { DB::beginTransaction(); // 创建新的超分任务记录 $enhanceTask = MpGenerateVideoTask::create([ 'task_id' => $responseData['task_id'], 'prompt' => $originalTask->prompt, 'video_resolution' => '720p', // 目标分辨率 'status' => MpGenerateVideoTask::STATUS_PROCESSING, 'api_type' => 'video_enhance', 'extra_params' => [ 'original_task_id' => $originalTask->id, 'original_video_url' => $originalTask->result_url, 'enhance_type' => '480p_to_720p', 'request_id' => $responseData['request_id'] ?? '', 'api_response' => $responseData ] ]); // 更新原任务的video_enhance_id字段 $originalTask->update([ 'video_enhance_id' => $enhanceTask->id ]); DB::commit(); logDB('video_enhance', 'info', '视频超分任务创建成功', [ 'original_task_id' => $taskId, 'enhance_task_id' => $enhanceTask->id, 'api_task_id' => $responseData['task_id'] ]); return [ 'success' => true, 'message' => '超分任务提交成功', 'enhance_task_id' => $enhanceTask->id, 'api_task_id' => $responseData['task_id'] ]; } catch (\Exception $e) { DB::rollBack(); logDB('video_enhance', 'error', '创建超分任务失败', [ 'original_task_id' => $taskId, 'error' => $e->getMessage() ]); return [ 'success' => false, 'message' => '创建超分任务失败: ' . $e->getMessage() ]; } } catch (\GuzzleHttp\Exception\RequestException $e) { $errorMsg = 'API请求失败'; if ($e->hasResponse()) { $errorResponse = json_decode($e->getResponse()->getBody()->getContents(), true); $errorMsg .= ': ' . ($errorResponse['msg'] ?? $errorResponse['message'] ?? $e->getMessage()); } else { $errorMsg .= ': ' . $e->getMessage(); } logDB('video_enhance', 'error', $errorMsg, ['task_id' => $taskId]); return [ 'success' => false, 'message' => $errorMsg ]; } catch (\Exception $e) { logDB('video_enhance', 'error', '超分任务提交异常', [ 'task_id' => $taskId, 'error' => $e->getMessage() ]); return [ 'success' => false, 'message' => '系统错误: ' . $e->getMessage() ]; } } /** * 查询视频超分任务状态 * * @param MpGenerateVideoTask $task * @return array */ public function queryVideoEnhanceTaskStatus(MpGenerateVideoTask $task): array { try { $apiKey = env('VOLC_AI_MEDIAKIT_KEY'); if (empty($apiKey)) { return [ 'status' => 'failed', 'error_message' => 'API Key 未配置' ]; } // 调用查询任务信息API $client = new Client(['verify' => false, 'timeout' => 120]); $response = $client->get('https://mediakit.cn-beijing.volces.com/api/v1/tasks/' . $task->task_id, [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, ] ]); $responseData = json_decode($response->getBody(), true); dLog('generate')->info('视频超分任务状态查询', [ 'task_id' => $task->id, 'api_task_id' => $task->task_id, 'response' => $responseData ]); // 检查响应 if (!isset($responseData['success']) || $responseData['success'] !== true) { return [ 'status' => 'failed', 'error_message' => isset($responseData['error']) ? ($responseData['error']['message'] ?? '查询失败') : '查询失败', 'result_json' => $responseData ?? [] ]; } $taskData = $responseData; $taskStatus = $taskData['status'] ?? ''; $returnData = [ 'status' => 'processing', 'error_message' => null, 'result_url' => null, 'result_json' => $responseData ]; // 解析任务状态 if ($taskStatus === 'completed' && isset($taskData['result']['video_url'])) { // 任务成功,保存视频 $videoUrl = $taskData['result']['video_url']; // 将视频保存到TOS $videoName = 'enhanced_video_' . time() . '_' . uniqid() . '.mp4'; $savedUrl = uploadStreamByTos('video', file_get_contents($videoUrl), $videoName); $returnData['status'] = 'success'; $returnData['result_url'] = $savedUrl; // 视频分辨率标准化(压缩前处理,避免压缩后重复上传;超分目标为720p) $targetResolution = $this->getTaskTargetResolution($task); if ($targetResolution) { $savedUrl = $this->ensureStandardVideoResolution($savedUrl, $targetResolution); $returnData['result_url'] = $savedUrl; } // 视频时长 if (isset($taskData['result']['duration'])) { $returnData['video_duration'] = (int)$taskData['result']['duration']; } // 压缩视频(可选) $compressedUrl = compressVideo($savedUrl); $returnData['compressed_url'] = $compressedUrl ?: $savedUrl; } elseif ($taskStatus === 'failed') { $returnData['status'] = 'failed'; $returnData['error_message'] = '视频超分失败'; } elseif (in_array($taskStatus, ['pending', 'processing', 'queued'])) { $returnData['status'] = 'processing'; } return $returnData; } catch (\GuzzleHttp\Exception\RequestException $e) { $errorMsg = 'API查询失败'; if ($e->hasResponse()) { $errorResponse = json_decode($e->getResponse()->getBody()->getContents(), true); $errorMsg .= ': ' . ($errorResponse['msg'] ?? $errorResponse['message'] ?? $e->getMessage()); } else { $errorMsg .= ': ' . $e->getMessage(); } return [ 'status' => 'processing', 'error_message' => $errorMsg ]; } catch (\Exception $e) { return [ 'status' => 'processing', 'error_message' => $e->getMessage() ]; } } /** * 更新视频超分任务状态 * * @param MpGenerateVideoTask $task * @return void */ private function updateVideoEnhanceTask($task): void { $statusInfo = $this->queryVideoEnhanceTaskStatus($task); if (!isset($statusInfo['status'])) return; if ($statusInfo['status'] === 'success') { logDB('video_enhance', 'info', '视频超分任务成功', [ 'id' => $task->id, 'result_url' => $statusInfo['result_url'] ]); // 获取原始480p任务 $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true); $originalTaskId = $extraParams['original_task_id'] ?? null; if (!$originalTaskId) { // 如果没有原始任务ID,只更新超分任务本身 $this->markVideoTaskSuccess($task, $statusInfo); return; } $originalTask = MpGenerateVideoTask::find($originalTaskId); if (!$originalTask) { logDB('video_enhance', 'warning', '原始480p任务不存在', [ 'enhance_task_id' => $task->id, 'original_task_id' => $originalTaskId ]); $this->markVideoTaskSuccess($task, $statusInfo); return; } // 使用与updateUnifiedApiTask完全一致的逻辑 $segment_id = $this->getSegmentIdFromTask($originalTask); if ($segment_id && isset($statusInfo['result_url'])) { try { DB::beginTransaction(); // 原子抢占:只有仍为 processing 的超分任务能更新为 success,避免并发重复处理 if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('video_enhance')->info('视频超分任务已被其他进程处理,跳过重复更新', [ 'enhance_task_id' => $task->id ]); return; } // 原始480p任务可能已被主流程处理为 success,这里仅尽力同步状态 $this->markVideoTaskSuccess($originalTask, $statusInfo); $now = date('Y-m-d H:i:s'); // 构建更新数据(使用超分后的720p视频) $segmentUpdateData = [ 'origin_video_url' => $statusInfo['result_url'], 'video_task_status' => '已完成', 'current_type' => 2, 'last_frame_url' => $statusInfo['last_frame_url'] ?? '', 'updated_at' => $now ]; // 使用查询任务时已压缩的视频URL,避免重复压缩 $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url']; // 只有当video_duration存在且大于0时才更新 if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) { $segmentUpdateData['video_duration'] = $statusInfo['video_duration']; $segmentUpdateData['video_time_point_start'] = 0; // 获取audio_duration(兼容全能模式) $audio_duration = $this->getAudioDuration($originalTask, $segment_id, $statusInfo['video_duration'] ?? null); $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0; } // 更新分镜表(兼容act模式) $this->updateSegmentTable($originalTask, $segmentUpdateData); // 添加视频生成对话记录 $this->addVideoGenerationRecords($originalTask, $segment_id, $statusInfo['result_url']); // 如果是全能模式的首帧图,则提取视频第一帧并更新anime表 $act_id = getProp($originalTask, 'alias_act_id'); if (!empty($act_id) && Redis::sismember('anime_act_first_frame_urls', $act_id)) { $anime_id = DB::table('mp_episode_segments')->where('id', $act_id)->value('anime_id'); if ($anime_id) { // 提取视频第一帧 $videoUrl = $segmentUpdateData['video_url']; $firstFrameUrl = getVideoFirstFrame($videoUrl, 'anime_first_frames', 'tos'); if ($firstFrameUrl) { // 更新anime表 DB::table('mp_animes')->where('id', $anime_id)->update([ 'first_frame_url' => $firstFrameUrl, 'updated_at' => date('Y-m-d H:i:s') ]); dLog('video_frame')->info('全能模式首帧图更新成功(超分任务)', [ 'act_id' => $act_id, 'anime_id' => $anime_id, 'first_frame_url' => $firstFrameUrl ]); } } } DB::commit(); // 从Redis中移除 Redis::srem('anime_act_first_frame_urls', $act_id); } catch (\Exception $e) { DB::rollBack(); dLog('video_enhance')->error('超分任务处理失败', [ 'segment_id' => $segment_id, 'enhance_task_id' => $task->id, 'original_task_id' => $originalTaskId, 'error' => $e->getMessage() ]); } } else { // 没有segment_id的情况下,只更新任务状态 try { DB::beginTransaction(); if (!$this->markVideoTaskSuccess($task, $statusInfo)) { DB::rollBack(); dLog('video_enhance')->info('视频超分任务已被其他进程处理,跳过重复更新', [ 'enhance_task_id' => $task->id ]); return; } $this->markVideoTaskSuccess($originalTask, $statusInfo); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('video_enhance')->error('超分任务状态更新失败', [ 'enhance_task_id' => $task->id, 'original_task_id' => $originalTaskId, 'error' => $e->getMessage() ]); } } } elseif ($statusInfo['status'] === 'failed') { logDB('video_enhance', 'error', '视频超分任务失败', [ 'id' => $task->id, 'error' => $statusInfo['error_message'] ]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => $statusInfo['error_message'], 'result_json' => $statusInfo['result_json'] ?? [] ]); $this->addVideoGenerationFailedRecord($task, $statusInfo['error_message']); // 不更新原始任务状态,保留480p任务的成功状态 } // 超时检查:48小时 $processingTime = now()->diffInHours($task->created_at); if ($processingTime > 48 && $task->status === MpGenerateVideoTask::STATUS_PROCESSING) { logDB('video_enhance', 'warning', '视频超分任务超时', [ 'id' => $task->id, 'processing_hours' => $processingTime ]); $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [ 'error_message' => '任务处理超时(超过48小时)' ]); $this->addVideoGenerationFailedRecord($task, '任务处理超时(超过48小时)'); } } }