Forráskód Böngészése

Merge branch 'test' into check_points

lh 1 hónapja
szülő
commit
e1fe4d237e

+ 43 - 16
app/Libs/Helpers.php

@@ -3439,8 +3439,12 @@ function handleEpisodeContentForAce($originalContent) {
     }
     
     // 提取主体列表 - 兼容多种格式
-    // if (preg_match('/###\s*主体列表\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $charactersMatch)) {
-    if (preg_match('/###[ \t]*主体列表[ \t]*\r?\n(.*?)(?=###[^#\n]|\z)/s', $originalContent, $charactersMatch)) {
+    // 旧正则(2026-08-14 前):只允许 ### 与标题同行,初衷是防止板块为空时把后续"###分段剧本"当板块内容;
+    // 但不兼容模型偶发的"###\n主体列表"换行标题格式(如 mp_anime_records id=31915),导致 roles/scenes/props 解析为空。
+    // if (preg_match('/###[ \t]*主体列表[ \t]*\r?\n(.*?)(?=###[^#\n]|\z)/s', $originalContent, $charactersMatch)) {
+    // 新正则:开头 \s*? 非贪婪,兼容 ###主体列表、### 主体列表、###\n主体列表、###\n\n主体列表;
+    // 结束边界 (?=\n?\s*###[^#]|\z) 保证空板块(### 后直接跟下一章节)与换行标题格式都不会吞入后续内容。
+    if (preg_match('/###\s*?主体列表\s*?\r?\n(.*?)(?=\n?\s*###[^#]|\z)/s', $originalContent, $charactersMatch)) {
         $charactersText = trim($charactersMatch[1]);
         $characterLines = explode("\n", $charactersText);
         
@@ -3551,8 +3555,9 @@ function handleEpisodeContentForAce($originalContent) {
     }
     
     // 提取场景列表 - 兼容多种格式
-    // if (preg_match('/###\s*场景列表\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $scenesMatch)) {
-    if (preg_match('/###[ \t]*场景列表[ \t]*\r?\n(.*?)(?=###[^#\n]|\z)/s', $originalContent, $scenesMatch)) {
+    // 旧正则(2026-08-14 前):只允许 ### 与标题同行,原因同上方主体列表,不兼容"###\n场景列表"换行标题格式。
+    // if (preg_match('/###[ \t]*场景列表[ \t]*\r?\n(.*?)(?=###[^#\n]|\z)/s', $originalContent, $scenesMatch)) {
+    if (preg_match('/###\s*?场景列表\s*?\r?\n(.*?)(?=\n?\s*###[^#]|\z)/s', $originalContent, $scenesMatch)) {
         $scenesText = trim($scenesMatch[1]);
         $sceneLines = explode("\n", $scenesText);
         
@@ -3566,11 +3571,20 @@ function handleEpisodeContentForAce($originalContent) {
                 $description = trim($sceneMatch[2]);
                 $picPrompt = null;
                 
-                // 检查描述末尾是否有{场景图片提示词}格式
-                if (preg_match('/^(.*?)\{([^}]+)\}\s*$/u', $description, $promptMatch)) {
-                    // 匹配格式:场景描述{场景图片提示词}
-                    $description = trim($promptMatch[1]);
-                    $picPrompt = trim($promptMatch[2]);
+                // 检查描述末尾是否有{场景图片提示词}或{场景图片提示词}{{补充说明}}格式
+                // 格式1: 描述{pic_prompt}{{补充说明}}(如"{...}{{无人物}}")
+                if (preg_match('/^(.*?)\{(.+?)\}\s*\{\{(.+?)\}\}\s*$/u', $description, $fullMatch)) {
+                    $description = trim($fullMatch[1]);
+                    $picPrompt = trim($fullMatch[2]);
+                }
+                // 格式2: 描述{{补充说明}}(只有补充说明,无图片提示词)
+                elseif (preg_match('/^(.*?)\{\{(.+?)\}\}\s*$/u', $description, $voiceMatch)) {
+                    $description = trim($voiceMatch[1]);
+                }
+                // 格式3: 描述{pic_prompt}(只有图片提示词)
+                elseif (preg_match('/^(.*?)\{(.+?)\}\s*$/u', $description, $picMatch)) {
+                    $description = trim($picMatch[1]);
+                    $picPrompt = trim($picMatch[2]);
                 }
                 
                 $sceneData = [
@@ -3589,7 +3603,10 @@ function handleEpisodeContentForAce($originalContent) {
     }
     
     // 提取道具列表 - 兼容多种格式
-    if (preg_match('/###[ \t]*道具列表[ \t]*\r?\n(.*?)(?=###[^#\n]|\z)/s', $originalContent, $propsMatch)) {
+    // 旧正则(2026-08-14 前):只允许 ### 与标题同行,原因同上方主体列表,不兼容"###\n道具列表"换行标题格式;
+    // 同时旧代码仅在提取到 pic_prompt 时才保存道具,用于兜底防止把分段剧本误当道具,代价是纯描述道具被丢弃。
+    // if (preg_match('/###[ \t]*道具列表[ \t]*\r?\n(.*?)(?=###[^#\n]|\z)/s', $originalContent, $propsMatch)) {
+    if (preg_match('/###\s*?道具列表\s*?\r?\n(.*?)(?=\n?\s*###[^#]|\z)/s', $originalContent, $propsMatch)) {
         $propsText = trim($propsMatch[1]);
         $propLines = explode("\n", $propsText);
         
@@ -3603,11 +3620,20 @@ function handleEpisodeContentForAce($originalContent) {
                 $description = trim($propMatch[2]);
                 $picPrompt = null;
                 
-                // 检查描述末尾是否有{道具图片提示词}格式
-                if (preg_match('/^(.*?)\{([^}]+)\}\s*$/u', $description, $promptMatch)) {
-                    // 匹配格式:道具描述{道具图片提示词}
-                    $description = trim($promptMatch[1]);
-                    $picPrompt = trim($promptMatch[2]);
+                // 检查描述末尾是否有{道具图片提示词}或{道具图片提示词}{{音效提示词}}格式(与主体列表保持一致)
+                // 格式1: 描述{pic_prompt}{{voice_prompt}}(图片提示词+音效提示词)
+                if (preg_match('/^(.*?)\{(.+?)\}\s*\{\{(.+?)\}\}\s*$/u', $description, $fullMatch)) {
+                    $description = trim($fullMatch[1]);
+                    $picPrompt = trim($fullMatch[2]);
+                }
+                // 格式2: 描述{{voice_prompt}}(只有音效提示词)
+                elseif (preg_match('/^(.*?)\{\{(.+?)\}\}\s*$/u', $description, $voiceMatch)) {
+                    $description = trim($voiceMatch[1]);
+                }
+                // 格式3: 描述{pic_prompt}(只有图片提示词)
+                elseif (preg_match('/^(.*?)\{(.+?)\}\s*$/u', $description, $picMatch)) {
+                    $description = trim($picMatch[1]);
+                    $picPrompt = trim($picMatch[2]);
                 }
                 
                 $propData = [
@@ -3615,10 +3641,11 @@ function handleEpisodeContentForAce($originalContent) {
                     'description' => $description
                 ];
                 
+                // 新正则已精确切分道具板块,不再依赖"必须有提示词"兜底,纯描述道具也保留
                 if ($picPrompt) {
                     $propData['pic_prompt'] = $picPrompt;
-                    $result['props'][] = $propData;
                 }
+                $result['props'][] = $propData;
             }
         }
     }

+ 101 - 56
app/Services/AIGeneration/AIImageGenerationService.php

@@ -1250,7 +1250,7 @@ class AIImageGenerationService
                 ];
             }
 
-            // 先获取表中是否已有该url(仅限智AI来源的素材)
+            // 先获取表中是否已有该url(仅限智AI来源的素材)
             $asset = DB::table('mp_assets')->where('url', $params['url'])->where('source_type', 'zhizhen')->first();
             if ($asset) {
                 return [
@@ -1272,7 +1272,7 @@ class AIImageGenerationService
             if (empty($apiKey)) {
                 return [
                     'code' => 1002,
-                    'msg' => '智AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
+                    'msg' => '智AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
                     'data' => null
                 ];
             }
@@ -1291,9 +1291,9 @@ class AIImageGenerationService
                 $requestData['group_id'] = $params['group_id'];
             }
 
-            dLog('generate')->info('智AI创建素材请求参数: ', $requestData);
+            dLog('generate')->info('智AI创建素材请求参数: ', $requestData);
 
-            // 调用智AI API
+            // 调用智AI API
             $response = $this->httpClient->post($apiUrl . '/api/v1/asset/create', [
                 'headers' => [
                     'X-Api-Key' => $apiKey,
@@ -1303,8 +1303,8 @@ class AIImageGenerationService
             ]);
 
             $responseData = json_decode($response->getBody()->getContents(), true);
-            dLog('generate')->info('智AI创建素材响应: ', $responseData);
-            logDB('generate', 'info', '智AI创建素材', ['params' => $requestData, 'response' => $responseData]);
+            dLog('generate')->info('智AI创建素材响应: ', $responseData);
+            logDB('generate', 'info', '智AI创建素材', ['params' => $requestData, 'response' => $responseData]);
 
             if ($responseData['code'] !== 0) {
                 return [
@@ -1369,7 +1369,7 @@ class AIImageGenerationService
                 ];
             }
 
-            // 先获取表中是否已有该素材(仅限智AI来源的素材)
+            // 先获取表中是否已有该素材(仅限智AI来源的素材)
             $assetInfo = DB::table('mp_assets')->where('asset_id', $assetId)->where('source_type', 'zhizhen')->first();
             if ($assetInfo) {
                 $assetInfo = (array)$assetInfo;
@@ -1393,14 +1393,14 @@ class AIImageGenerationService
             if (empty($apiKey)) {
                 return [
                     'code' => 1002,
-                    'msg' => '智AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
+                    'msg' => '智AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
                     'data' => null
                 ];
             }
 
-            dLog('generate')->info('智AI查询素材: ' . $assetId);
+            dLog('generate')->info('智AI查询素材: ' . $assetId);
 
-            // 调用智AI API
+            // 调用智AI API
             $response = $this->httpClient->post($apiUrl . '/api/v1/asset/get', [
                 'headers' => [
                     'X-Api-Key' => $apiKey,
@@ -1412,8 +1412,8 @@ class AIImageGenerationService
             ]);
 
             $responseData = json_decode($response->getBody()->getContents(), true);
-            dLog('generate')->info('智AI查询素材响应: ', $responseData);
-            logDB('generate', 'info', '智AI查询素材', ['asset_id' => $assetId, 'response' => $responseData]);
+            dLog('generate')->info('智AI查询素材响应: ', $responseData);
+            logDB('generate', 'info', '智AI查询素材', ['asset_id' => $assetId, 'response' => $responseData]);
 
             if ($responseData['code'] !== 0) {
                 return [
@@ -1577,7 +1577,7 @@ class AIImageGenerationService
     }
 
     /**
-     * 处理智AI的参考图片转素材
+     * 处理智AI的参考图片转素材
      *
      * @param array $referenceImages 参考图片URL数组
      * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败图片的标记)
@@ -1585,35 +1585,75 @@ class AIImageGenerationService
      */
     public function processReferenceImagesToAssets(array $referenceImages, string &$fullPrompt): array
     {
+        return $this->processReferenceAssetsToZhizhenAssets($referenceImages, MpAsset::TYPE_IMAGE, '图片', '图片', $fullPrompt);
+    }
+
+    /**
+     * 处理智帧AI的参考视频转素材
+     *
+     * @param array $referenceVideos 参考视频URL数组
+     * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败视频的标记)
+     * @return array 返回素材ID数组
+     */
+    public function processReferenceVideosToAssets(array $referenceVideos, string &$fullPrompt): array
+    {
+        return $this->processReferenceAssetsToZhizhenAssets($referenceVideos, MpAsset::TYPE_VIDEO, '视频', '视频', $fullPrompt);
+    }
+
+    /**
+     * 处理智帧AI的参考音频转素材
+     *
+     * @param array $referenceAudios 参考音频URL数组
+     * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败音频的标记)
+     * @return array 返回素材ID数组
+     */
+    public function processReferenceAudiosToAssets(array $referenceAudios, string &$fullPrompt): array
+    {
+        return $this->processReferenceAssetsToZhizhenAssets($referenceAudios, MpAsset::TYPE_AUDIO, '音频', '音频', $fullPrompt);
+    }
+
+    /**
+     * 处理智帧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 processReferenceAssetsToZhizhenAssets(array $urls, string $assetType, string $label, string $tagName, string &$fullPrompt): array
+    {
         $assetIds = [];
-        $failedIndexes = []; // 记录失败的图片序号
+        $failedIndexes = []; // 记录失败的素材序号
         
-        foreach ($referenceImages as $index => $imageUrl) {
-            $imageIndex = $index + 1; // 图片序号从1开始
+        foreach ($urls as $index => $url) {
+            $mediaIndex = $index + 1; // 素材序号从1开始
             
             try {
-                dLog('generate')->info("开始处理参考图片 {$imageIndex}: {$imageUrl}");
+                dLog('generate')->info("开始处理参考{$label} {$mediaIndex}: {$url}");
                 
                 // 1. 创建素材
                 $createResult = $this->createAsset([
-                    'url' => $imageUrl,
-                    'asset_type' => MpAsset::TYPE_IMAGE,
-                    'name' => "参考图片_{$imageIndex}_" . time(),
+                    'url' => $url,
+                    'asset_type' => $assetType,
+                    'name' => "参考{$label}_{$mediaIndex}_" . time(),
                 ]);
                 
                 if ($createResult['code'] !== 0) {
-                    dLog('generate')->warning("图片 {$imageIndex} 创建素材失败: " . $createResult['msg']);
+                    dLog('generate')->warning("{$label} {$mediaIndex} 创建素材失败: " . $createResult['msg']);
                     logDB('generate', 'warning', '创建素材失败', [
-                        'image_url' => $imageUrl,
-                        'index' => $imageIndex,
+                        'url' => $url,
+                        'asset_type' => $assetType,
+                        'index' => $mediaIndex,
                         'error' => $createResult['msg']
                     ]);
-                    $failedIndexes[] = $imageIndex;
+                    $failedIndexes[] = $mediaIndex;
                     continue;
                 }
                 
                 $assetId = $createResult['data']['asset_id'];
-                dLog('generate')->info("图片 {$imageIndex} 素材创建成功,asset_id: {$assetId},开始轮询查询状态");
+                dLog('generate')->info("{$label} {$mediaIndex} 素材创建成功,asset_id: {$assetId},开始轮询查询状态");
                 
                 // 2. 轮询查询素材状态(每3秒查询一次,最多查询100次即5分钟)
                 $maxAttempts = 100;
@@ -1627,32 +1667,34 @@ class AIImageGenerationService
                     $getResult = $this->getAsset($assetId);
                     
                     if ($getResult['code'] !== 0) {
-                        dLog('generate')->warning("图片 {$imageIndex} 查询素材状态失败: " . $getResult['msg']);
+                        dLog('generate')->warning("{$label} {$mediaIndex} 查询素材状态失败: " . $getResult['msg']);
                         continue;
                     }
                     
                     $assetStatus = $getResult['data']['asset']['status'] ?? null;
-                    dLog('generate')->info("图片 {$imageIndex} 素材状态查询第 {$attempt} 次,状态: {$assetStatus}");
+                    dLog('generate')->info("{$label} {$mediaIndex} 素材状态查询第 {$attempt} 次,状态: {$assetStatus}");
                     
                     if ($assetStatus === 'Active') {
                         // 素材可用
                         $assetIds[] = $assetId;
-                        dLog('generate')->info("图片 {$imageIndex} 素材处理成功,asset_id: {$assetId}");
+                        dLog('generate')->info("{$label} {$mediaIndex} 素材处理成功,asset_id: {$assetId}");
                         logDB('generate', 'info', '素材转换成功', [
-                            'image_url' => $imageUrl,
-                            'index' => $imageIndex,
+                            'url' => $url,
+                            'asset_type' => $assetType,
+                            'index' => $mediaIndex,
                             'asset_id' => $assetId
                         ]);
                         break;
                     } elseif ($assetStatus === 'Failed') {
                         // 素材处理失败
-                        dLog('generate')->warning("图片 {$imageIndex} 素材处理失败");
+                        dLog('generate')->warning("{$label} {$mediaIndex} 素材处理失败");
                         logDB('generate', 'warning', '素材处理失败', [
-                            'image_url' => $imageUrl,
-                            'index' => $imageIndex,
+                            'url' => $url,
+                            'asset_type' => $assetType,
+                            'index' => $mediaIndex,
                             'asset_id' => $assetId
                         ]);
-                        $failedIndexes[] = $imageIndex;
+                        $failedIndexes[] = $mediaIndex;
                         break;
                     }
                     // 其他状态(Processing等)继续等待
@@ -1660,56 +1702,58 @@ class AIImageGenerationService
                 
                 // 如果超时还未成功
                 if ($attempt >= $maxAttempts && $assetStatus !== 'Active') {
-                    dLog('generate')->warning("图片 {$imageIndex} 素材处理超时");
+                    dLog('generate')->warning("{$label} {$mediaIndex} 素材处理超时");
                     logDB('generate', 'warning', '素材处理超时', [
-                        'image_url' => $imageUrl,
-                        'index' => $imageIndex,
+                        'url' => $url,
+                        'asset_type' => $assetType,
+                        'index' => $mediaIndex,
                         'asset_id' => $assetId,
                         'last_status' => $assetStatus
                     ]);
-                    $failedIndexes[] = $imageIndex;
+                    $failedIndexes[] = $mediaIndex;
                 }
                 
             } catch (\Exception $e) {
-                dLog('generate')->error("图片 {$imageIndex} 处理异常: " . $e->getMessage());
+                dLog('generate')->error("{$label} {$mediaIndex} 处理异常: " . $e->getMessage());
                 logDB('generate', 'error', '素材转换异常', [
-                    'image_url' => $imageUrl,
-                    'index' => $imageIndex,
+                    'url' => $url,
+                    'asset_type' => $assetType,
+                    'index' => $mediaIndex,
                     'error' => $e->getMessage()
                 ]);
-                $failedIndexes[] = $imageIndex;
+                $failedIndexes[] = $mediaIndex;
             }
         }
         
-        // 处理失败的图片:从 fullPrompt 中删除对应的标记并调整序号
+        // 处理失败的素材:从 fullPrompt 中删除对应的标记并调整序号
         if (!empty($failedIndexes)) {
-            dLog('generate')->info('开始处理失败图片的提示词调整,失败序号: ' . implode(',', $failedIndexes));
+            dLog('generate')->info("开始处理失败{$label}的提示词调整,失败序号: " . implode(',', $failedIndexes));
             
             // 按序号从大到小排序,避免删除时影响后续序号
             rsort($failedIndexes);
             
             foreach ($failedIndexes as $failedIndex) {
-                // 删除失败图片的标记,例如 <图片1>、<图片2> 等
-                $pattern = "/<图片{$failedIndex}>/u";
+                // 删除失败素材的标记,例如 <图片1>、<视频2>、<音频3> 等
+                $pattern = "/<{$tagName}{$failedIndex}>/u";
                 $fullPrompt = preg_replace($pattern, '', $fullPrompt);
-                dLog('generate')->info("已从提示词中删除 <图片{$failedIndex}>");
+                dLog('generate')->info("已从提示词中删除 <{$tagName}{$failedIndex}>");
             }
             
-            // 重新调整剩余图片的序号
-            // 例如:如果图片2失败,图片3、图片4需要改为图片2、图片3
+            // 重新调整剩余素材的序号
+            // 例如:如果素材2失败,素材3、素材4需要改为素材2、素材3
             $needAdjust = false;
             foreach ($failedIndexes as $failedIndex) {
                 $needAdjust = true;
-                // 将所有大于失败序号的图片标记序号减1
-                for ($i = $failedIndex + 1; $i <= count($referenceImages); $i++) {
-                    $oldPattern = "/<图片{$i}>/u";
-                    $newTag = "<图片" . ($i - 1) . ">";
+                // 将所有大于失败序号的素材标记序号减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('提示词图片序号调整完成');
+                dLog('generate')->info("提示词{$label}序号调整完成");
                 logDB('generate', 'info', '提示词调整', [
                     'failed_indexes' => $failedIndexes,
                     'adjusted_prompt' => $fullPrompt
@@ -1717,12 +1761,13 @@ class AIImageGenerationService
             }
         }
         
-        dLog('generate')->info('参考图片转素材完成,成功数量: ' . count($assetIds) . ',失败数量: ' . count($failedIndexes));
+        dLog('generate')->info("参考{$label}转素材完成,成功数量: " . count($assetIds) . ',失败数量: ' . count($failedIndexes));
 
         // 格式化为可直接在API接口中使用的数组
-        foreach($assetIds as &$assetId) {
+        foreach ($assetIds as &$assetId) {
             $assetId = "asset://{$assetId}";
         }
+        unset($assetId);
         
         return $assetIds;
     }

+ 233 - 13
app/Services/AIGeneration/AIVideoGenerationService.php

@@ -579,6 +579,7 @@ class AIVideoGenerationService
             '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,
@@ -627,6 +628,7 @@ class AIVideoGenerationService
             '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,
@@ -639,6 +641,8 @@ class AIVideoGenerationService
                 '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,
@@ -710,6 +714,38 @@ class AIVideoGenerationService
                     ];
                 }
             }
+
+            // 追加参考视频素材(已由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 请求参数
             $apiParams = [
@@ -806,7 +842,7 @@ class AIVideoGenerationService
 
     /**
      * 将 Seedance 2.0 参考图上传为百度AI网关素材
-     * 失败处理与智AI的processReferenceImagesToAssets一致:删除参考图的同时清理提示词中的<图片N>标记
+     * 失败处理与智AI的processReferenceImagesToAssets一致:删除参考图的同时清理提示词中的<图片N>标记
      *
      * @param array $referenceImages 参考图片URL数组
      * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败图片的标记)
@@ -892,6 +928,130 @@ class AIVideoGenerationService
         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);
+
+                if ($createResult['code'] !== 0) {
+                    dLog('generate')->warning("{$label} {$mediaIndex} 创建Seedance 2.0素材失败: " . $createResult['msg']);
+                    logDB('generate', 'warning', 'Seedance2.0创建素材失败', [
+                        'url' => $url,
+                        'asset_type' => $assetType,
+                        'index' => $mediaIndex,
+                        'error' => $createResult['msg']
+                    ]);
+                    $failedIndexes[] = $mediaIndex;
+                    continue;
+                }
+
+                $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 . '序号调整完成');
+                logDB('generate', 'info', 'Seedance2.0提示词调整', [
+                    'failed_indexes' => $failedIndexes,
+                    'adjusted_prompt' => $fullPrompt
+                ]);
+            }
+        }
+
+        dLog('generate')->info("Seedance 2.0{$label}转素材完成,成功数量: " . count($assetIds) . ',失败数量: ' . count($failedIndexes));
+
+        // 格式化为可直接在API接口中使用的数组
+        foreach ($assetIds as &$assetId) {
+            $assetId = "asset://{$assetId}";
+        }
+        unset($assetId);
 
         return $assetIds;
     }
@@ -941,11 +1101,12 @@ class AIVideoGenerationService
      * 上传图片到百度AI网关素材库并等待素材可用
      * 上传前先检查mp_assets中是否已存在该url(source_type=baidu),存在则直接复用素材ID,避免重复上传
      *
-     * @param string $url 图片公网地址
+     * @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): array
+    private function createSeedance20Asset(string $url, string $name, string $assetType = MpAsset::TYPE_IMAGE): array
     {
         try {
             if (empty($url)) {
@@ -972,13 +1133,32 @@ class AIVideoGenerationService
 
             $groupId = $this->ensureSeedance20AssetGroup();
 
-            $responseData = $this->requestSeedance20Gateway('CreateAsset', [
+            $createAssetBody = [
                 'ProjectName' => 'default',
                 'GroupId' => $groupId,
-                'AssetType' => 'Image',
+                'AssetType' => $assetType,
                 'Name' => $name,
                 'URL' => $url,
-            ]);
+            ];
+
+            try {
+                $responseData = $this->requestSeedance20Gateway('CreateAsset', $createAssetBody);
+            } catch (\GuzzleHttp\Exception\RequestException $e) {
+                $errorBody = $e->hasResponse() ? (string)$e->getResponse()->getBody() : '';
+                if (strpos($errorBody, 'asset_space_mismatch') === false) {
+                    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'] ?? '';
@@ -998,7 +1178,7 @@ class AIVideoGenerationService
                 'group_name' => 'default',
                 'name' => $name,
                 'url' => $url,
-                'asset_type' => MpAsset::TYPE_IMAGE,
+                'asset_type' => $assetType,
                 'source_type' => 'baidu',
                 'status' => MpAsset::STATUS_PROCESSING,
                 'extra_params' => $responseData,
@@ -1124,18 +1304,38 @@ class AIVideoGenerationService
     }
 
     /**
-     * 获取可用的Seedance 2.0素材组ID(不存在则创建默认素材组)
+     * 获取可用的Seedance 2.0素材组ID
+     * 优先复用本地已有素材组,避免依赖网关ListAssetGroups接口(该接口近期返回400)
      *
      * @return string
      */
     private function ensureSeedance20AssetGroup(): string
     {
-        $groups = $this->listSeedance20AssetGroups();
-        if (!empty($groups)) {
-            $groupId = $groups[0]['Id'] ?? $groups[0]['GroupId'] ?? '';
-            if (!empty($groupId)) {
-                return $groupId;
+        // 优先复用本地已有的百度素材组
+        $localGroup = DB::table('mp_assets')
+            ->where('source_type', 'baidu')
+            ->whereNotNull('group_id')
+            ->where('group_id', '!=', '')
+            ->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();
@@ -1177,6 +1377,26 @@ class AIVideoGenerationService
             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,
+            ]);
+        }
+
         logDB('generate', 'info', 'Seedance2.0素材组创建成功', ['group_id' => $groupId]);
         return $groupId;
     }

+ 98 - 3
app/Services/Anime/AnimeService.php

@@ -5170,6 +5170,34 @@ class AnimeService
         $reference_images = array_values(array_unique(array_filter($reference_images)));
         $reference_images = array_slice($reference_images, 0, 9);
 
+        // 参考视频(支持数组或JSON字符串,最多3个)
+        $reference_videos = getProp($data, 'reference_videos', []);
+        if (is_string($reference_videos)) {
+            $reference_videos = json_decode($reference_videos, true) ?: [];
+        }
+        if (!is_array($reference_videos)) {
+            Utils::throwError('20003:参考视频格式不正确');
+        }
+        $reference_videos = array_values(array_unique(array_filter($reference_videos)));
+        if (count($reference_videos) > 3) {
+            Utils::throwError('20003:参考视频最多选择3个');
+        }
+        $reference_videos = array_slice($reference_videos, 0, 3);
+
+        // 参考音频(支持数组或JSON字符串,最多3个)
+        $reference_audios = getProp($data, 'reference_audios', []);
+        if (is_string($reference_audios)) {
+            $reference_audios = json_decode($reference_audios, true) ?: [];
+        }
+        if (!is_array($reference_audios)) {
+            Utils::throwError('20003:参考音频格式不正确');
+        }
+        $reference_audios = array_values(array_unique(array_filter($reference_audios)));
+        if (count($reference_audios) > 3) {
+            Utils::throwError('20003:参考音频最多选择3个');
+        }
+        $reference_audios = array_slice($reference_audios, 0, 3);
+
         // 构建视频生成参数(用户参数直接透传,提示词不做处理)
         $videoParams = [
             'model' => $model,
@@ -5229,32 +5257,99 @@ class AnimeService
             if ($first_frame_url) $parameters['first_frame_url'] = $first_frame_url;
             if ($tail_frame_url) $parameters['last_frame_url'] = $tail_frame_url;
 
+            // 维护ref_image_url字段(参考图+参考视频+参考音频URL,JSON数组,与参考图处理方式一致)
+            $refImageUrls = array_merge($reference_images, $reference_videos, $reference_audios);
+            if ($refImageUrls) {
+                $unifiedParams['ref_image_url'] = json_encode(array_values(array_filter($refImageUrls)), 256);
+            }
+
             // 参考图处理
             if ($reference_images) {
-                $unifiedParams['ref_image_url'] = json_encode($reference_images, 256);
                 $reference_images = $this->aiImageGenerationService->processReferenceImagesToAssets($reference_images, $unifiedParams['prompt']);
                 $parameters['reference_images'] = $reference_images;
                 $parameters['video_mode'] = 'multi_image';
                 $parameters['mode'] = 'multi_image';
             }
 
+            // 参考视频和参考音频(与参考图一致,先上传为智帧素材)
+            if ($reference_videos) {
+                $reference_videos = $this->aiImageGenerationService->processReferenceVideosToAssets($reference_videos, $unifiedParams['prompt']);
+                $parameters['reference_videos'] = $reference_videos;
+            }
+            if ($reference_audios) {
+                $reference_audios = $this->aiImageGenerationService->processReferenceAudiosToAssets($reference_audios, $unifiedParams['prompt']);
+                $parameters['reference_audios'] = $reference_audios;
+            }
+
             $unifiedParams['parameters'] = $parameters;
 
             // 调用统一API创建任务
             $task = $this->aiVideoGenerationService->createUnifiedApiTask($unifiedParams);
         } elseif (strpos($model, 'doubao-seedance-2.0') !== false) {
-            // 豆包Seedance 2.0(百度AI网关):图片需要先上传为素材
+            // 豆包Seedance 2.0(百度AI网关):图片/视频/音频需要先上传为素材
             $videoParams['reference_images'] = $reference_images;
 
+            // 维护ref_image_url字段(参考图+参考视频+参考音频URL,JSON数组)
+            $refImageUrls = array_merge($reference_images, $reference_videos, $reference_audios);
+            if ($refImageUrls) {
+                $videoParams['ref_image_url'] = json_encode(array_values(array_filter($refImageUrls)), 256);
+            }
+
             if ($reference_images) {
                 // 参考图上传为百度网关素材
                 $reference_image_assets = $this->aiVideoGenerationService->processReferenceImagesToSeedance20Assets($reference_images, $videoParams['prompt']);
-                $videoParams['content'][0]['text'] = $videoParams['prompt'];
                 $videoParams['reference_image_assets'] = $reference_image_assets;
             }
 
+            // 参考视频上传为百度网关素材
+            if ($reference_videos) {
+                $reference_video_assets = $this->aiVideoGenerationService->processReferenceVideosToSeedance20Assets($reference_videos, $videoParams['prompt']);
+                $videoParams['reference_video_assets'] = $reference_video_assets;
+            }
+
+            // 参考音频上传为百度网关素材
+            if ($reference_audios) {
+                $reference_audio_assets = $this->aiVideoGenerationService->processReferenceAudiosToSeedance20Assets($reference_audios, $videoParams['prompt']);
+                $videoParams['reference_audio_assets'] = $reference_audio_assets;
+            }
+
+            // 参考素材上传失败时可能清理提示词中的素材标记,需要同步回content文本
+            if ($reference_images || $reference_videos || $reference_audios) {
+                $videoParams['content'][0]['text'] = $videoParams['prompt'];
+            }
+
             $task = $this->aiVideoGenerationService->createSeedance20Task($videoParams);
         } else {
+            // 官方Seedance模型:兼容参考视频和参考音频(content直接透传公网URL)
+
+            // 维护ref_image_url字段(参考图+参考视频+参考音频URL,JSON数组)
+            $refImageUrls = array_merge($reference_images, $reference_videos, $reference_audios);
+            if ($refImageUrls) {
+                $videoParams['ref_image_url'] = json_encode(array_values(array_filter($refImageUrls)), 256);
+            }
+
+            if ($reference_videos) {
+                foreach ($reference_videos as $videoUrl) {
+                    $videoParams['content'][] = [
+                        'type' => 'video_url',
+                        'video_url' => [
+                            'url' => $videoUrl,
+                        ],
+                        'role' => 'reference_video',
+                    ];
+                }
+            }
+            if ($reference_audios) {
+                foreach ($reference_audios as $audioUrl) {
+                    $videoParams['content'][] = [
+                        'type' => 'audio_url',
+                        'audio_url' => [
+                            'url' => $audioUrl,
+                        ],
+                        'role' => 'reference_audio',
+                    ];
+                }
+            }
             $task = $this->aiVideoGenerationService->createSeedanceTask($videoParams);
         }