Quellcode durchsuchen

Merge branch 'test' into check_points

lh vor 1 Monat
Ursprung
Commit
2c6e0bbe1a

+ 1 - 0
app/Models/MpAsset.php

@@ -19,6 +19,7 @@ class MpAsset extends Model
         'url',
         'cover_url',
         'asset_type',
+        'source_type',
         'status',
         'error_message',
         'extra_params',

+ 5 - 4
app/Services/AIGeneration/AIImageGenerationService.php

@@ -1250,8 +1250,8 @@ class AIImageGenerationService
                 ];
             }
 
-            // 先获取表中是否已有该url
-            $asset = DB::table('mp_assets')->where('url', $params['url'])->first();
+            // 先获取表中是否已有该url(仅限智真AI来源的素材)
+            $asset = DB::table('mp_assets')->where('url', $params['url'])->where('source_type', 'zhizhen')->first();
             if ($asset) {
                 return [
                     'code' => 0,
@@ -1323,6 +1323,7 @@ class AIImageGenerationService
                 'name' => $params['name'] ?? null,
                 'url' => $params['url'],
                 'asset_type' => $params['asset_type'] ?? MpAsset::TYPE_IMAGE,
+                'source_type' => 'zhizhen',
                 'status' => MpAsset::STATUS_PROCESSING,
                 'extra_params' => $responseData,
             ]);
@@ -1368,8 +1369,8 @@ class AIImageGenerationService
                 ];
             }
 
-            // 先获取表中是否已有该url
-            $assetInfo = DB::table('mp_assets')->where('asset_id', $assetId)->first();
+            // 先获取表中是否已有该素材(仅限智真AI来源的素材)
+            $assetInfo = DB::table('mp_assets')->where('asset_id', $assetId)->where('source_type', 'zhizhen')->first();
             if ($assetInfo) {
                 $assetInfo = (array)$assetInfo;
                 $status = getProp($assetInfo, 'status');

+ 506 - 16
app/Services/AIGeneration/AIVideoGenerationService.php

@@ -4,6 +4,7 @@ namespace App\Services\AIGeneration;
 
 use App\Facade\Site;
 use App\Libs\Utils;
+use App\Models\MpAsset;
 use App\Models\MpGenerateVideoTask;
 use App\Models\AIElement;
 use App\Services\PointsService;
@@ -560,6 +561,57 @@ class AIVideoGenerationService
     }
 
     /**
+     * 创建 Seedance 2.0(百度AI网关)视频生成任务
+     *
+     * @param array $params
+     * @return MpGenerateVideoTask
+     */
+    public function createSeedance20Task(array $params): MpGenerateVideoTask
+    {
+        // 生成唯一的任务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'] ?? '',
+            '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' => $params['model'] ?? 'doubao-seedance-2.0',
+                'content' => $params['content'],
+                'reference_images' => $params['reference_images'] ?? [],
+                'reference_image_assets' => $params['reference_image_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网关配置
+                'gateway_base_url' => 'https://ai-gateway.baidubce.com',
+                'api_key_env' => 'BAIDU_SEEDANCE_20_AK',
+            ]
+        ]);
+
+        // 提交任务到百度AI网关
+        $this->submitSeedanceTaskToApi($task);
+
+        return $task;
+    }
+
+    /**
      * 提交 Seedance 任务到火山引擎 API
      *
      * @param MpGenerateVideoTask $task
@@ -569,22 +621,49 @@ class AIVideoGenerationService
     {
         $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';
+
             // 验证环境变量配置
-            $apiKey = env('VOLC_AI_API_KEY');
-                
+            $apiKey = env($apiKeyEnv);
+
             if (empty($apiKey)) {
                 $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
-                    'error_message' => '火山引擎访问密钥未配置,请检查环境变量API Key'
+                    'error_message' => $apiKeyEnv . ' 未配置,请检查环境变量API Key'
                 ]);
                 return;
             }
 
-            $extraParams = $task->extra_params;
+            $content = $extraParams['content'] ?? [];
+
+            // Seedance 2.0(百度AI网关)需要先把图片上传为素材
+            if (!empty($extraParams['gateway_base_url'])) {
+                $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',
+                    ];
+                }
+            }
             
             // 构建火山引擎 Seedance API 请求参数
             $apiParams = [
                 'model' => isset(\App\Consts\BaseConst::MODELS_MAP[$extraParams['model']]) ? \App\Consts\BaseConst::MODELS_MAP[$extraParams['model']] : $extraParams['model'],
-                'content' => $extraParams['content'],
+                'content' => $content,
             ];
 
             // 添加可选参数
@@ -594,7 +673,7 @@ class AIVideoGenerationService
             if (isset($extraParams['return_last_frame'])) {
                 $apiParams['return_last_frame'] = $extraParams['return_last_frame'];
             }
-            if (!empty($extraParams['service_tier']) && !in_array($extraParams['model'], ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'])) {
+            if (!empty($extraParams['service_tier']) && !in_array($extraParams['model'], ['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'])) {
@@ -628,11 +707,11 @@ class AIVideoGenerationService
                 $apiParams['watermark'] = $extraParams['watermark'];
             }
             
-            dLog('generate')->info('Seedance 1.5 Pro 视频生成参数: ', $apiParams);
-            logDB('generate', 'info', 'Seedance视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]);
+            dLog('generate')->info('Seedance 视频生成参数: ', $apiParams);
+            logDB('generate', 'info', 'Seedance视频生成任务提交', ['id' => $task->id, 'model' => $apiParams['model'], 'params' => $apiParams]);
             
-            // 调用火山引擎 Seedance API
-            $response = $client->post('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', [
+            // 调用 Seedance API
+            $response = $client->post($gatewayBaseUrl . '/api/v3/contents/generations/tasks', [
                 'headers' => [
                     'Authorization' => 'Bearer ' . $apiKey,
                     'Content-Type' => 'application/json',
@@ -642,7 +721,7 @@ class AIVideoGenerationService
 
 
             $responseData = json_decode($response->getBody(), true);
-            dLog('generate')->info('Seedance 1.5 Pro API 响应: ', $responseData);
+            dLog('generate')->info('Seedance API 响应: ', $responseData);
             logDB('generate', 'info', 'Seedance视频生成API响应', ['id' => $task->id, 'response' => $responseData]);
                 
             if (isset($responseData['error'])) {
@@ -674,6 +753,413 @@ class AIVideoGenerationService
         }
     }
 
+    /**
+     * 将 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());
+
+                if ($createResult['code'] !== 0) {
+                    dLog('generate')->warning("图片 {$imageIndex} 创建Seedance 2.0素材失败: " . $createResult['msg']);
+                    logDB('generate', 'warning', 'Seedance2.0创建素材失败', [
+                        'image_url' => $imageUrl,
+                        'index' => $imageIndex,
+                        'error' => $createResult['msg']
+                    ]);
+                    $failedIndexes[] = $imageIndex;
+                    continue;
+                }
+
+                $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提示词图片序号调整完成');
+                logDB('generate', 'info', 'Seedance2.0提示词调整', [
+                    'failed_indexes' => $failedIndexes,
+                    'adjusted_prompt' => $fullPrompt
+                ]);
+            }
+        }
+
+        dLog('generate')->info('Seedance 2.0参考图片转素材完成,成功数量: ' . count($assetIds) . ',失败数量: ' . count($failedIndexes));
+
+        // 格式化为可直接在API接口中使用的数组
+        foreach ($assetIds as &$assetId) {
+            $assetId = "asset://{$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);
+
+            if ($createResult['code'] !== 0) {
+                // 上传失败:移除该图片,继续处理其他图片
+                dLog('generate')->warning('Seedance 2.0 图片上传素材失败,已从content中移除: ' . $imageUrl . ',原因: ' . $createResult['msg']);
+                logDB('generate', 'warning', 'Seedance2.0图片转素材失败', [
+                    'image_url' => $imageUrl,
+                    'error' => $createResult['msg']
+                ]);
+                unset($content[$index]);
+                continue;
+            }
+
+            $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 素材名称
+     * @return array ['code'=>0, 'data'=>['asset_id'=>...]] 成功;['code'=>20003, 'msg'=>...] 失败
+     */
+    private function createSeedance20Asset(string $url, string $name): array
+    {
+        try {
+            if (empty($url)) {
+                return [
+                    'code' => 1,
+                    'msg' => '素材URL不能为空',
+                    'data' => null
+                ];
+            }
+
+            // 先获取表中是否已有该url(仅限百度网关来源的素材)
+            $asset = DB::table('mp_assets')->where('url', $url)->where('source_type', 'baidu')->first();
+            if ($asset) {
+                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,
+                    ]
+                ];
+            }
+
+            $groupId = $this->ensureSeedance20AssetGroup();
+
+            $responseData = $this->requestSeedance20Gateway('CreateAsset', [
+                'ProjectName' => 'default',
+                'GroupId' => $groupId,
+                'AssetType' => 'Image',
+                'Name' => $name,
+                'URL' => $url,
+            ]);
+
+            $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' => MpAsset::TYPE_IMAGE,
+                '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') {
+                    logDB('generate', 'info', 'Seedance2.0素材处理成功', [
+                        'asset_id' => $assetId,
+                        'url' => $url
+                    ]);
+                    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(不存在则创建默认素材组)
+     *
+     * @return string
+     */
+    private function ensureSeedance20AssetGroup(): string
+    {
+        $groups = $this->listSeedance20AssetGroups();
+        if (!empty($groups)) {
+            $groupId = $groups[0]['Id'] ?? $groups[0]['GroupId'] ?? '';
+            if (!empty($groupId)) {
+                return $groupId;
+            }
+        }
+
+        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));
+        }
+
+        logDB('generate', 'info', 'Seedance2.0素材组创建成功', ['group_id' => $groupId]);
+        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);
+        logDB('generate', 'info', 'Seedance2.0网关' . $action, ['params' => $body, 'response' => $responseData]);
+
+        return $responseData;
+    }
+
     // 更新Seedance视频任务状态
     private function updateSeedanceTask($task) {
         $statusInfo = $this->querySeedanceTaskStatus($task);
@@ -816,19 +1302,23 @@ class AIVideoGenerationService
                 ];
             }
 
-            // 调用火山引擎 API 查询任务状态
-            $apiKey = env('VOLC_AI_API_KEY');
-                
+            // 调用 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' => '火山引擎访问密钥未配置'
+                    'error_message' => $apiKeyEnv . ' 未配置'
                 ];
             }
 
             // 构建查询 URL
-            $url = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/' . $task->task_id;
+            $url = $gatewayBaseUrl . '/api/v3/contents/generations/tasks/' . $task->task_id;
             
             $response = $client->get($url, [
                 'headers' => [

+ 20 - 1
app/Services/Anime/AnimeService.php

@@ -4943,7 +4943,7 @@ class AnimeService
         
         // 如果有首帧图片,添加到content中
         if ($hasImage) {
-            if (in_array($model, ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'])) {
+            if (in_array($model, ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128', 'doubao-seedance-2.0'])) {
                 $videoParams['content'][] = [
                     'type' => 'image_url',
                     'image_url' => [
@@ -5048,6 +5048,25 @@ class AnimeService
             
             // 调用统一API创建任务
             $task = $this->aiVideoGenerationService->createUnifiedApiTask($unifiedParams);
+        } elseif (strpos($model, 'doubao-seedance-2.0') !== false) {
+            // 豆包Seedance 2.0(百度AI网关):图片需要先上传为素材
+            // 保存原始参考图用于任务记录
+            $videoParams['reference_images'] = $reference_images;
+
+            if ($reference_images) {
+                // 限制只传入9张参考图
+                $reference_images = array_slice($reference_images, 0, 9);
+
+                // 参考图上传为百度网关素材,上传失败时同步清理提示词中的<图片N>标记
+                $reference_image_assets = $this->aiVideoGenerationService->processReferenceImagesToSeedance20Assets($reference_images, $fullPrompt);
+
+                // 素材上传失败后提示词可能被清理,同步更新视频参数中的提示词
+                $videoParams['prompt'] = trim($fullPrompt);
+                $videoParams['content'][0]['text'] = trim($fullPrompt);
+                $videoParams['reference_image_assets'] = $reference_image_assets;
+            }
+
+            $task = $this->aiVideoGenerationService->createSeedance20Task($videoParams);
         } else {
             $task = $this->aiVideoGenerationService->createSeedanceTask($videoParams);
         }

+ 240 - 96
app/Services/DeepSeek/DeepSeekService.php

@@ -1344,6 +1344,97 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
     }
 
     /**
+     * GPT 重试次数(env 可配置,默认 5 次)
+     */
+    private function gptRetryTimes()
+    {
+        return (int)env('GPT_RETRY_TIMES', 5);
+    }
+
+    /**
+     * GPT 重试间隔秒数(env 可配置,默认 10 秒)
+     */
+    private function gptRetryInterval()
+    {
+        return (int)env('GPT_RETRY_INTERVAL', 10);
+    }
+
+    /**
+     * 判断 GPT 请求异常是否可重试
+     * 仅连接失败/超时等传输层异常、5xx、429 可重试;其余 4xx(401/400 等)不重试
+     */
+    private function isGptRetryableException(\Exception $e)
+    {
+        if (!($e instanceof \GuzzleHttp\Exception\GuzzleException)) {
+            return false;
+        }
+
+        if ($e instanceof \GuzzleHttp\Exception\BadResponseException && $e->getResponse()) {
+            $status = $e->getResponse()->getStatusCode();
+            return $status >= 500 || $status == 429;
+        }
+
+        return true;
+    }
+
+    /**
+     * GPT 中转站探活:GET /v1/models,5 秒短超时
+     * 连接成功(含 401/403/404)视为可用;连接失败/超时/5xx/429 视为不可用
+     */
+    private function checkGptServiceAvailable()
+    {
+        $apiKey = env('GPT_54_API_KEY');
+        if (empty($apiKey)) {
+            return false;
+        }
+
+        try {
+            $checkClient = new Client(['timeout' => 5, 'verify' => false, 'http_errors' => false]);
+            $response = $checkClient->get('https://ai-api.kkidc.com/v1/models', [
+                'headers' => [
+                    'Authorization' => 'Bearer ' . $apiKey,
+                    'Content-Type'  => 'application/json'
+                ]
+            ]);
+            $status = $response->getStatusCode();
+            return $status < 500 && $status != 429;
+        } catch (\Exception $e) {
+            return false;
+        }
+    }
+
+    /**
+     * 探活并重试:最多重试 gptRetryTimes() 次,间隔 gptRetryInterval() 秒
+     * 全部失败抛出友好错误
+     */
+    private function ensureGptServiceAvailable()
+    {
+        $maxRetries = $this->gptRetryTimes();
+        $interval = $this->gptRetryInterval();
+
+        for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
+            if ($this->checkGptServiceAvailable()) {
+                return;
+            }
+
+            if ($attempt < $maxRetries) {
+                dLog('deepseek')->warning('GPT 中转站探活失败,准备重试', [
+                    'retry'       => $attempt + 1,
+                    'max_retries' => $maxRetries
+                ]);
+                sleep($interval);
+            }
+        }
+
+        dLog('deepseek')->error('GPT 中转站探活失败,重试次数已用完');
+        logDB('deepseek', 'error', 'GPT 中转站服务不可用', [
+            'max_retries' => $maxRetries,
+            'interval'    => $interval
+        ]);
+        Utils::throwError('20003:GPT 中转站服务暂时不可用,请稍后重试');
+    }
+
+    /**
      * GPT 流式输出处理方法
      * 
      * @param array $post_data GPT API请求参数
@@ -1356,6 +1447,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             Utils::throwError('20003:GPT API Key未配置');
         }
 
+        // 先探活,服务不可用时自动重试
+        $this->ensureGptServiceAvailable();
+
         $client = new Client(['timeout' => 1800, 'verify' => false]);
         $headers = [
             'Authorization' => 'Bearer ' . $apiKey,
@@ -1373,96 +1467,121 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         // 流式请求显式声明返回 usage(在最后一个 chunk 中返回)
         $post_data['stream_options'] = ['include_usage' => true];
 
-        // 备用中转站地址1: https://token.ithinkai.cn/v1/chat/completions
-        // 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
-        $response = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
-            'json' => $post_data, 
-            'headers' => $headers,
-            'stream' => true  // 启用流式响应
-        ]);
-        
-        $body = $response->getBody();
-        $fullContent = '';
-        $usage = [];
-        $buffer = '';
+        $maxRetries = $this->gptRetryTimes();
+        $interval = $this->gptRetryInterval();
+        $attempt = 0;
 
-        dLog('deepseek')->info('开始读取 GPT 流式响应');
+        while (true) {
+            $fullContent = '';
+            $usage = [];
+            $buffer = '';
 
-        // 逐行读取流式响应
-        while (!$body->eof()) {
-            $chunk = $body->read(1024); // 每次读取 1KB
-            $buffer .= $chunk;
-            
-            // 按行分割
-            $lines = explode("\n", $buffer);
-            // 保留最后一个不完整的行
-            $buffer = array_pop($lines);
-            
-            foreach ($lines as $line) {
-                $line = trim($line);
-                
-                // 跳过空行
-                if (empty($line)) {
-                    continue;
-                }
+            try {
+                // 备用中转站地址1: https://token.ithinkai.cn/v1/chat/completions
+                // 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
+                $response = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
+                    'json' => $post_data, 
+                    'headers' => $headers,
+                    'stream' => true  // 启用流式响应
+                ]);
                 
-                // 检查是否是 SSE 数据行
-                if (strpos($line, 'data: ') !== 0) {
-                    dLog('deepseek')->warning('非 SSE 数据行', ['line' => $line]);
-                    continue;
-                }
+                $body = $response->getBody();
 
-                $data = substr($line, 6); // 移除 "data: " 前缀
-                
-                if ($data === '[DONE]') {
-                    dLog('deepseek')->info('收到结束标记');
-                    break 2; // 跳出两层循环
-                }
+                dLog('deepseek')->info('开始读取 GPT 流式响应');
 
-                try {
-                    $json = json_decode($data, true);
+                // 逐行读取流式响应
+                while (!$body->eof()) {
+                    $chunk = $body->read(1024); // 每次读取 1KB
+                    $buffer .= $chunk;
                     
-                    if (json_last_error() !== JSON_ERROR_NONE) {
-                        dLog('deepseek')->warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
-                        continue;
-                    }
+                    // 按行分割
+                    $lines = explode("\n", $buffer);
+                    // 保留最后一个不完整的行
+                    $buffer = array_pop($lines);
                     
-                    if (isset($json['choices'][0]['delta'])) {
-                        $delta = $json['choices'][0]['delta'];
+                    foreach ($lines as $line) {
+                        $line = trim($line);
                         
-                        // 处理回答内容
-                        if (isset($delta['content'])) {
-                            $fullContent .= $delta['content'];
-                            yield [
-                                'type' => 'content',
-                                'content' => $delta['content'],
-                            ];
+                        // 跳过空行
+                        if (empty($line)) {
+                            continue;
+                        }
+                        
+                        // 检查是否是 SSE 数据行
+                        if (strpos($line, 'data: ') !== 0) {
+                            dLog('deepseek')->warning('非 SSE 数据行', ['line' => $line]);
+                            continue;
+                        }
+
+                        $data = substr($line, 6); // 移除 "data: " 前缀
+                        
+                        if ($data === '[DONE]') {
+                            dLog('deepseek')->info('收到结束标记');
+                            break 2; // 跳出 foreach 和流读取循环
+                        }
+
+                        try {
+                            $json = json_decode($data, true);
+                            
+                            if (json_last_error() !== JSON_ERROR_NONE) {
+                                dLog('deepseek')->warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
+                                continue;
+                            }
+                            
+                            if (isset($json['choices'][0]['delta'])) {
+                                $delta = $json['choices'][0]['delta'];
+                                
+                                // 处理回答内容
+                                if (isset($delta['content'])) {
+                                    $fullContent .= $delta['content'];
+                                    yield [
+                                        'type' => 'content',
+                                        'content' => $delta['content'],
+                                    ];
+                                }
+                            }
+                            
+                            // 获取使用统计信息
+                            if (isset($json['usage'])) {
+                                $usage = $json['usage'];
+                                dLog('deepseek')->info('收到使用统计', ['usage' => $usage]);
+                            }
+                            
+                        } catch (\Exception $e) {
+                            dLog('deepseek')->error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
+                            continue;
                         }
                     }
-                    
-                    // 获取使用统计信息
-                    if (isset($json['usage'])) {
-                        $usage = $json['usage'];
-                        dLog('deepseek')->info('收到使用统计', ['usage' => $usage]);
-                    }
-                    
-                } catch (\Exception $e) {
-                    dLog('deepseek')->error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
-                    continue;
                 }
-            }
-        }
 
-        dLog('deepseek')->info('流式读取完成', [
-            'content_length' => strlen($fullContent)
-        ]);
+                dLog('deepseek')->info('流式读取完成', [
+                    'content_length' => strlen($fullContent)
+                ]);
 
-        yield [
-            'type' => 'done',
-            'full_content' => $fullContent,
-            'full_reasoning' => '',
-            'usage' => $usage
-        ];
+                yield [
+                    'type' => 'done',
+                    'full_content' => $fullContent,
+                    'full_reasoning' => '',
+                    'usage' => $usage
+                ];
+                return;
+            } catch (\Exception $e) {
+                // 已输出内容不重试(避免重复);非可重试异常不重试;重试次数用完则抛出
+                if (!empty($fullContent) || !$this->isGptRetryableException($e) || $attempt >= $maxRetries) {
+                    dLog('deepseek')->error('GPT 流式请求失败: ' . $e->getMessage());
+                    logDB('deepseek', 'error', 'GPT 流式请求失败', ['error' => $e->getMessage()]);
+                    throw $e;
+                }
+
+                dLog('deepseek')->warning('GPT 流式请求失败,准备重试', [
+                    'retry'       => $attempt + 1,
+                    'max_retries' => $maxRetries,
+                    'error'       => $e->getMessage()
+                ]);
+                sleep($interval);
+                $attempt++;
+            }
+        }
     }
 
     // 与推理模型对话
@@ -11866,6 +11985,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             Utils::throwError('20003:GPT-5.4 API Key未配置');
         }
 
+        // 先探活,服务不可用时自动重试
+        $this->ensureGptServiceAvailable();
+
         $client = new Client(['timeout' => 1800, 'verify' => false]);
         $headers = [
             'Authorization' => 'Bearer ' . $apiKey,
@@ -11884,29 +12006,51 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         // 确保 stream 为 false
         $post_data['stream'] = false;
 
-        // 备用中转站地址: https://token.ithinkai.cn/v1/chat/completions
-        // 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
-        $result = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
-            'json' => $post_data, 
-            'headers' => $headers
-        ]);
+        $maxRetries = $this->gptRetryTimes();
+        $interval = $this->gptRetryInterval();
+        $attempt = 0;
 
-        $response = $result->getBody()->getContents();
-        $response_arr = json_decode($response, true);
-        dLog('deepseek')->info('GPT-5.4请求完成', ['response' => $response_arr]);
+        while (true) {
+            try {
+                // 备用中转站地址: https://token.ithinkai.cn/v1/chat/completions
+                // 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
+                $result = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
+                    'json' => $post_data, 
+                    'headers' => $headers
+                ]);
 
-        $fullContent = '';
-        $usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
+                $response = $result->getBody()->getContents();
+                $response_arr = json_decode($response, true);
+                dLog('deepseek')->info('GPT-5.4请求完成', ['response' => $response_arr]);
 
-        if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
-            $fullContent = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
-        }
+                $fullContent = '';
+                $usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
 
-        return [
-            'fullContent' => $fullContent,
-            'fullReasoningContent' => '', // GPT-5.4 不支持思考链
-            'usage' => $usage
-        ];
+                if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
+                    $fullContent = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
+                }
+
+                return [
+                    'fullContent' => $fullContent,
+                    'fullReasoningContent' => '', // GPT-5.4 不支持思考链
+                    'usage' => $usage
+                ];
+            } catch (\Exception $e) {
+                if (!$this->isGptRetryableException($e) || $attempt >= $maxRetries) {
+                    dLog('deepseek')->error('GPT-5.4 请求失败: ' . $e->getMessage());
+                    logDB('deepseek', 'error', 'GPT-5.4 请求失败', ['error' => $e->getMessage()]);
+                    throw $e;
+                }
+
+                dLog('deepseek')->warning('GPT-5.4 请求失败,准备重试', [
+                    'retry'       => $attempt + 1,
+                    'max_retries' => $maxRetries,
+                    'error'       => $e->getMessage()
+                ]);
+                sleep($interval);
+                $attempt++;
+            }
+        }
     }
 
     private function splitContent($content) {

+ 32 - 0
database/migrations/2026_08_05_000001_add_source_type_to_mp_assets_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddSourceTypeToMpAssetsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('mp_assets', function (Blueprint $table) {
+            $table->string('source_type', 32)->default('zhizhen')->comment('素材来源:zhizhen-智真AI, baidu-百度AI网关')->index()->after('asset_type');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('mp_assets', function (Blueprint $table) {
+            $table->dropColumn('source_type');
+        });
+    }
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 1
resources/views/animeIndex.blade.php