Преглед изворни кода

分段剧本制作(包括批量制作)增加音色参考音频

lh пре 3 дана
родитељ
комит
3ad24c0662
2 измењених фајлова са 414 додато и 91 уклоњено
  1. 55 70
      app/Http/Controllers/Anime/AnimeController.php
  2. 359 21
      app/Services/Anime/AnimeService.php

+ 55 - 70
app/Http/Controllers/Anime/AnimeController.php

@@ -3029,46 +3029,12 @@ class AnimeController extends BaseController
             );
         }
 
-        // 暂不确定是否启用,先保留
-        // // 最高优先级:当前集对应的mp_script_product_mappings(关联mp_products获取资产库最新数据)
-        // $script_id = (int)DB::table('mp_animes')->where('id', $animeId)->value('script_id');
-        // if ($script_id) {
-        //     $mapping_items = DB::table('mp_script_product_mappings as mapping')
-        //         ->join('mp_products as product', 'mapping.product_id', '=', 'product.id')
-        //         ->where('mapping.script_id', $script_id)
-        //         ->where('mapping.episode_number', $episodeNumber)
-        //         ->where('product.is_deleted', 0)
-        //         ->select(
-        //             'mapping.product_id',
-        //             'product.product_name',
-        //             'product.product',
-        //             'product.pic_prompt',
-        //             'product.url',
-        //             'product.three_view_image_url',
-        //         )
-        //         ->get();
-
-        //     foreach ($mapping_items as $mapping_item) {
-        //         $mapping_name = $normalizeProductName($mapping_item->product_name);
-        //         if ($mapping_name === '') continue;
-
-        //         $mapping_product = [
-        //             'product_id'           => (int)$mapping_item->product_id,
-        //             'product_name'         => $mapping_item->product_name,
-        //             'product'              => (int)$mapping_item->product,
-        //             'pic_prompt'           => $mapping_item->pic_prompt ?? '',
-        //             'url'                  => $mapping_item->url ?? '',
-        //             'three_view_image_url' => $mapping_item->three_view_image_url ?? '',
-        //         ];
-
-        //         $products[$mapping_name] = array_merge(
-        //             isset($products[$mapping_name]) ? $products[$mapping_name] : [],
-        //             $mapping_product
-        //         );
-        //     }
-        // }
-
         $products = array_values($products);
+
+        // 资产(图片/三视图/提示词/音色等)优先级:剧本资产关联(mappings) > extra_products > 剧集单独设置
+        // products 中 extra_products 已覆盖剧集设置,这里再用资产库数据覆盖,即 mappings 优先级最高
+        $animeScriptId = (int)DB::table('mp_animes')->where('id', $animeId)->value('script_id');
+        $products = $this->AnimeService->fillProductsFromMapping($products, $animeScriptId, true);
         
         // 处理视频模型(在循环外统一处理)
         // $model = getProp($data, 'model');
@@ -3179,9 +3145,14 @@ class AnimeController extends BaseController
                 // 获取分镜内容
                 $actContent = $act->act_show_content ?: '';
                 // 构建完整的提示词
-                $processResult = $this->AnimeService->processActContentWithProducts($actContent, $products);
+                $supportAudioReference = $this->AnimeService->isActReferenceAudioSupported($model);
+                $processResult = $this->AnimeService->processActContentWithProducts($actContent, $products, $supportAudioReference);
                 $fullPrompt = $processResult['content'];
                 $reference_images = $processResult['reference_images'];
+                // 角色音色参考音频(顺序与提示词中的<音频N>标记一一对应)
+                $reference_audios = $supportAudioReference ? ($processResult['reference_audios'] ?? []) : [];
+                // 参考音频素材不可用时用于兜底的角色音色提示词
+                $audioVoiceFallbacks = $supportAudioReference ? ($processResult['audio_voice_fallbacks'] ?? []) : [];
                 $videoDuration = $act->act_duration;                
                 
                 // 构建视频生成参数
@@ -3297,6 +3268,16 @@ class AnimeController extends BaseController
                         $parameters['video_mode'] = 'multi_image';
                         $parameters['mode'] = 'multi_image';
                     }
+
+                    // 角色音色参考音频:上传为素材并传入统一API(上传失败时清理提示词中的<音频N>标记)
+                    if ($reference_audios) {
+                        $unifiedParams['ref_audio_url'] = json_encode($reference_audios, 256);
+                        $reference_audio_assets = $this->AnimeService->processActReferenceAudiosToAssets($reference_audios, $model, $unifiedParams['prompt']);
+                        $parameters['reference_audios'] = $reference_audio_assets;
+                        // 音频素材上传失败的角色回退到音色提示词
+                        $unifiedParams['prompt'] = $this->AnimeService->applyAudioVoiceFallbacks($unifiedParams['prompt'], $audioVoiceFallbacks);
+                        $unifiedParams['prompt'] = trim($unifiedParams['prompt']);
+                    }
                     
                     $unifiedParams['parameters'] = $parameters;
                     
@@ -3329,25 +3310,29 @@ class AnimeController extends BaseController
                         $videoParams['reference_image_assets'] = $reference_image_assets;
                     }
 
-                    // 配音音频:快快AI上传为素材;国内百度/海外百度/doubao走content audio_url
-                    if ($canUseAudio && $audioUrl) {
-                        if ($isKuaikuai) {
-                            $reference_audio_assets = $this->AIVideoGenerationService->processReferenceAudiosToKuaikuaiAssets([$audioUrl], $videoParams['prompt']);
-                            $videoParams['reference_audio_assets'] = $reference_audio_assets;
-                        } else {
-                            $videoParams['content'][] = [
-                                'type' => 'audio_url',
-                                'audio_url' => [
-                                    'url' => $audioUrl,
-                                ],
-                                'role' => 'reference_audio',
-                            ];
+                    // 整段配音音频:与角色音色音频合并(角色音色在前、整段配音在后),提示词由公共方法统一生成
+                    $segmentAudioUrl = ($canUseAudio && $audioUrl) ? $audioUrl : '';
+                    $segmentAudio = $this->AnimeService->buildSegmentAudioReference($videoParams['prompt'], $reference_audios, $segmentAudioUrl);
+                    $videoParams['prompt'] = $segmentAudio['prompt'];
+                    $videoParams['content'][0]['text'] = $segmentAudio['prompt'];
+                    // 整段配音使用<音频N>标记时,素材上传失败需要移除对应提示语句
+                    $hasSegmentAudioMark = $segmentAudio['has_audio_mark'];
+                    $allReferenceAudios = $segmentAudio['reference_audios'];
+                    if ($allReferenceAudios) {
+                        $videoParams['ref_audio_url'] = json_encode($allReferenceAudios, 256);
+                        $reference_audio_assets = $this->AnimeService->processActReferenceAudiosToAssets($allReferenceAudios, $model, $videoParams['prompt']);
+                        $videoParams['reference_audio_assets'] = $reference_audio_assets;
+
+                        // 音频素材上传失败的角色回退到音色提示词
+                        $videoParams['prompt'] = $this->AnimeService->applyAudioVoiceFallbacks($videoParams['prompt'], $audioVoiceFallbacks);
+                        // 整段配音素材上传失败时移除对应提示语句,避免出现残句
+                        if ($hasSegmentAudioMark) {
+                            $videoParams['prompt'] = $this->AnimeService->removeUnavailableSegmentAudioPrompt($videoParams['prompt']);
                         }
 
-                        // 优化提示词,增加音频相关描述
-                        $audioPrompt = "全程使用音频1作为视频配音。" . $videoParams['prompt'];
-                        $videoParams['prompt'] = $audioPrompt;
-                        $videoParams['content'][0]['text'] = $audioPrompt;
+                        // 素材上传失败后提示词可能被清理,同步更新视频参数中的提示词
+                        $videoParams['prompt'] = trim($videoParams['prompt']);
+                        $videoParams['content'][0]['text'] = trim($videoParams['prompt']);
                     } elseif ($audioDuration) {
                         // 有音频时长则需将视频时长设置为比音频时长更长的整数
                         $videoParams['video_duration'] = max(4, min(12, ceil($audioDuration)));
@@ -3364,19 +3349,19 @@ class AnimeController extends BaseController
                     // Seedance模型(默认)
                     // 检查是否为 Seedance 2.0 模型,如果是则需要处理配音音频
                     if ($canUseAudio && in_array($model, ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128', 'baidu-doubao-seedance-2-5-260628', 'baidu-doubao-seedance-2-0-260128', 'baidu-doubao-seedance-2-0-fast-260128', 'baidu-doubao-seedance-2-0-mini-260615', 'baidu-dreamina-seedance-2-0-260128', 'baidu-dreamina-seedance-2-0-fast-260128', 'baidu-dreamina-seedance-2-0-mini-260615', 'baidu-dreamina-seedance-2-5-260628'])) {
-                        // 添加配音音频到content中
-                        $videoParams['content'][] = [
-                            'type' => 'audio_url',
-                            'audio_url' => [
-                                'url' => $audioUrl,
-                            ],
-                            'role' => 'reference_audio',
-                        ];
-                        
-                        // 优化提示词,增加音频相关描述
-                        $audioPrompt = "全程使用音频1作为视频配音。" . $fullPrompt;
-                        $videoParams['prompt'] = $audioPrompt;
-                        $videoParams['content'][0]['text'] = $audioPrompt;
+                        // 整段配音音频直接作为参考音频传入content(该分支模型不走素材上传),提示词由公共方法统一生成
+                        $segmentAudio = $this->AnimeService->buildSegmentAudioReference($fullPrompt, $reference_audios, $audioUrl);
+                        $videoParams['prompt'] = $segmentAudio['prompt'];
+                        $videoParams['content'][0]['text'] = $segmentAudio['prompt'];
+                        foreach ($segmentAudio['reference_audios'] as $referenceAudioUrl) {
+                            $videoParams['content'][] = [
+                                'type' => 'audio_url',
+                                'audio_url' => [
+                                    'url' => $referenceAudioUrl,
+                                ],
+                                'role' => 'reference_audio',
+                            ];
+                        }
                     } else if ($videoDuration) {
                         // 有音频时长则需将视频时长设置为比音频时长更长的整数
                         $videoParams['video_duration'] = max(4, min(12, ceil($videoDuration)));   // seedance支持2-12s

+ 359 - 21
app/Services/Anime/AnimeService.php

@@ -5019,15 +5019,7 @@ class AnimeService
         // 音效同出(默认使用)
         $current_generate_audio = $generate_audio ? 1 : 0;
 
-        // 构建完整的提示词
-        $processResult = $this->processActContentWithProducts($actContent, $products);
-        $fullPrompt = $processResult['content'];
-        // if ($art_style_type == '3D真人风格') $fullPrompt = "美术风格: $art_style\n\n".$fullPrompt;
-        $reference_images = array_merge($processResult['reference_images'], $reference_images);
-        
-        $videoDuration = $video_duration ? $video_duration : getProp($act, 'act_duration');
-        
-        // 处理视频模型
+        // 处理视频模型(需在构建提示词之前解析:决定角色音色是否使用参考音频)
         $model = getProp($data, 'model');
         if (!$model) {
             $model = getProp($episode, 'video_model');
@@ -5041,6 +5033,17 @@ class AnimeService
         if (!in_array($model, $valid_models)) {
             $model = 'seed-2';
         }
+
+        // 构建完整的提示词
+        $supportAudioReference = $this->isActReferenceAudioSupported($model);
+        $processResult = $this->processActContentWithProducts($actContent, $products, $supportAudioReference);
+        $fullPrompt = $processResult['content'];
+        // if ($art_style_type == '3D真人风格') $fullPrompt = "美术风格: $art_style\n\n".$fullPrompt;
+        $reference_images = array_merge($processResult['reference_images'], $reference_images);
+        // 角色音色参考音频(顺序与提示词中的<音频N>标记一一对应)
+        $reference_audios = $supportAudioReference ? ($processResult['reference_audios'] ?? []) : [];
+        
+        $videoDuration = $video_duration ? $video_duration : getProp($act, 'act_duration');
         // 保存到episode表(仅在专用方法中更新模型)
         // DB::table('mp_anime_episodes')->where('id', $episode_id)->update([
         //     'video_model' => $model,
@@ -5203,6 +5206,16 @@ class AnimeService
                     $parameters['video_mode'] = 'multi_image';
                     $parameters['mode'] = 'multi_image';
                 }
+
+                // 角色音色参考音频:上传为素材并传入统一API(上传失败时清理提示词中的<音频N>标记)
+                if ($reference_audios) {
+                    $unifiedParams['ref_audio_url'] = json_encode($reference_audios, 256);
+                    $reference_audio_assets = $this->processActReferenceAudiosToAssets($reference_audios, $model, $unifiedParams['prompt']);
+                    $parameters['reference_audios'] = $reference_audio_assets;
+                    // 音频素材上传失败的角色回退到音色提示词
+                    $unifiedParams['prompt'] = $this->applyAudioVoiceFallbacks($unifiedParams['prompt'], $processResult['audio_voice_fallbacks'] ?? []);
+                    $unifiedParams['prompt'] = trim($unifiedParams['prompt']);
+                }
                 
                 $unifiedParams['parameters'] = $parameters;
                 
@@ -5235,6 +5248,17 @@ class AnimeService
                     $videoParams['reference_image_assets'] = $reference_image_assets;
                 }
 
+                // 角色音色参考音频:上传为素材并传入网关(上传失败时清理提示词中的<音频N>标记)
+                if ($reference_audios) {
+                    $videoParams['ref_audio_url'] = json_encode($reference_audios, 256);
+                    $reference_audio_assets = $this->processActReferenceAudiosToAssets($reference_audios, $model, $fullPrompt);
+                    // 音频素材上传失败的角色回退到音色提示词
+                    $fullPrompt = $this->applyAudioVoiceFallbacks($fullPrompt, $processResult['audio_voice_fallbacks'] ?? []);
+                    $videoParams['prompt'] = trim($fullPrompt);
+                    $videoParams['content'][0]['text'] = trim($fullPrompt);
+                    $videoParams['reference_audio_assets'] = $reference_audio_assets;
+                }
+
                 // 快快AI走聚合网关 /v1/video/generations(火山豆包格式)
                 if ($isKuaikuai) {
                     $videoParams['gateway_base_url'] = env('KUAIKUAI_AI_BASE_URL', 'https://ai-api.kkidc.com');
@@ -8300,6 +8324,7 @@ class AnimeService
             'url' => $sourceProduct->url,
             'pic_prompt' => $sourceProduct->pic_prompt ?? '',
             'three_view_image_url' => $sourceProduct->three_view_image_url ?? '',
+            'timbre_url' => $sourceProduct->timbre_url ?? '',
             'product' => $finalProductType,
             'versions' => $sourceProduct->versions ?? '',
             'sort_order' => time(),
@@ -11185,11 +11210,21 @@ class AnimeService
      * @param array $products 产品数组(包含product_name和url)
      * @return array ['content' => 处理后的内容, 'reference_images' => 参考图片数组]
      */
-    public function processActContentWithProducts($actContent, $products) {
+    /**
+     * 根据片段内容与资产构建提示词、参考图、参考音频
+     *
+     * @param string $actContent 片段内容
+     * @param array $products 资产数组(角色/场景/道具)
+     * @param bool $supportAudioReference 模型是否支持参考音频;支持时角色音色优先使用 timbre_url
+     * @return array
+     */
+    public function processActContentWithProducts($actContent, $products, $supportAudioReference = false) {
         if (empty($actContent) || empty($products)) {
             return [
                 'content' => $actContent,
-                'reference_images' => []
+                'reference_images' => [],
+                'reference_audios' => [],
+                'audio_voice_fallbacks' => []
             ];
         }
         
@@ -11201,7 +11236,10 @@ class AnimeService
                 $product_dict[$product_name] = [
                     // 优先使用三视图图片作为素材/参考图,否则使用原图 url
                     'url' => getProp($product, 'three_view_image_url') ?: getProp($product, 'url'),
-                    'voice_prompt' => getProp($product, 'voice_prompt')
+                    'voice_prompt' => getProp($product, 'voice_prompt'),
+                    // 角色音色参考音频(选填),有值时优先于 voice_prompt
+                    'timbre_url' => trim((string)getProp($product, 'timbre_url', '')),
+                    'product' => (int)getProp($product, 'product', 1)
                 ];
             }
         }
@@ -11211,9 +11249,13 @@ class AnimeService
         
         $reference_images = [];
         $product_index_map = []; // 产品名称 => 图片序号的映射
+        $reference_audios = [];
+        $product_audio_index_map = []; // 产品名称 => 音频序号的映射(与图片序号互相独立)
+        $audio_voice_fallbacks = []; // 产品名称 => voice_prompt(参考音频素材不可用时兜底使用)
         $voice_prompts = []; // 存储需要添加到开头的voice_prompt信息
         $narrator_voice_prompt = ''; // 单独存储旁白的voice_prompt
         $current_index = 1;
+        $current_audio_index = 1;
         
         if (!empty($matches[1])) {
             // 按照在内容中第一次出现的顺序,为每个产品分配序号
@@ -11231,9 +11273,25 @@ class AnimeService
                 if (isset($product_dict[$product_name])) {
                     $url = $product_dict[$product_name]['url'];
                     $voice_prompt = $product_dict[$product_name]['voice_prompt'];
-                    
-                    // 处理voice_prompt(旁白不在这里处理,旁白是公共的在后面单独处理)
-                    if (!empty($voice_prompt) && $product_name !== '旁白') {
+                    $timbre_url = $product_dict[$product_name]['timbre_url'];
+
+                    // 分配音频序号(与图片序号互相独立;相同音频URL复用同一序号,保证角色与音频一一对应)
+                    if ($supportAudioReference && !empty($timbre_url)) {
+                        if (!in_array($timbre_url, $reference_audios)) {
+                            $reference_audios[] = $timbre_url;
+                            $product_audio_index_map[$product_name] = $current_audio_index;
+                            $current_audio_index++;
+                        } else {
+                            $existing_audio_index = array_search($timbre_url, $reference_audios) + 1;
+                            $product_audio_index_map[$product_name] = $existing_audio_index;
+                        }
+
+                        // 记录该角色的音色提示词:参考音频素材不可用时用它兜底(无提示词时记录空串,用于清理残留说明行)
+                        $audio_voice_fallbacks[$product_name] = $voice_prompt ?? '';
+                    }
+
+                    // 处理voice_prompt:模型支持参考音频且角色有音频时优先使用音频,不再输出音色提示词(旁白是公共的在后面单独处理)
+                    if ((!$supportAudioReference || empty($timbre_url)) && !empty($voice_prompt) && $product_name !== '旁白') {
                         $voice_prompts[] = $product_name . ':' . $voice_prompt . "\n";
                     }
                     
@@ -11266,12 +11324,18 @@ class AnimeService
             $escaped_name = preg_quote($product_name, '/');
             
             if ($index > 0) {
-                // 有图片,替换为:产品名称(图X)
+                // 有图片,替换为:产品名称<图片X>
                 $replacement = $product_name . '<图片' . $index . '>';
             } else {
                 // 无图片,只保留产品名称
                 $replacement = $product_name;
             }
+
+            // 有参考音频的角色追加音频标记(如 角色A<图片1><音频1>),保证角色与音频一一对应
+            $audio_index = $product_audio_index_map[$product_name] ?? 0;
+            if ($audio_index > 0) {
+                $replacement .= '<音频' . $audio_index . '>';
+            }
             
             // 替换所有 {产品名称} 为处理后的格式
             $processedContent = preg_replace('/\{' . $escaped_name . '\}/u', $replacement, $processedContent);
@@ -11282,14 +11346,21 @@ class AnimeService
             $product_name = getProp($product, 'product_name');
             $voice_prompt = getProp($product, 'voice_prompt');
             
-            if ($product_name === '旁白' && !empty($voice_prompt)) {
+            if ($product_name === '旁白' && !empty($voice_prompt) && empty($product_audio_index_map['旁白'])) {
                 $narrator_voice_prompt = $product_name . ':' . $voice_prompt . "\n";
                 break; // 找到旁白后退出循环
             }
         }
         
-        // 第四步:将voice_prompt信息添加到内容最前面
+        // 第四步:将参考音频音色信息和voice_prompt信息添加到内容最前面
         $voice_prompt_prefix = '';
+
+        // 有参考音频的角色:声明"角色名 对应 音频N",统一使用<音频N>标记,
+        // 素材上传失败时与正文标记一起被清理/重排,保证角色与音频始终对应
+        foreach ($product_audio_index_map as $product_name => $audio_index) {
+            $voice_prompt_prefix .= $product_name . ':全程使用<音频' . $audio_index . '>的音色进行配音' . "\n";
+        }
+
         if (!empty($voice_prompts)) {
             $voice_prompt_prefix .= implode('', $voice_prompts);
         }
@@ -11305,8 +11376,264 @@ class AnimeService
         
         return [
             'content' => $processedContent,
-            'reference_images' => $reference_images
+            'reference_images' => $reference_images,
+            'reference_audios' => $reference_audios,
+            'audio_voice_fallbacks' => $audio_voice_fallbacks
+        ];
+    }
+
+    /**
+     * 用剧本资产关联(mappings)的数据补齐资产数组
+     * 批量转视频时资产取值的优先级:剧本资产关联(mappings) > extra_products > 剧集单独设置
+     * 资产库对应字段为空时不覆盖,避免把分集里的有效数据清空
+     *
+     * @param array $products 资产数组
+     * @param int $script_id 剧本ID
+     * @param bool $overwrite 资产库有值时是否覆盖数组中的已有值(true 表示 mappings 优先级最高)
+     * @return array
+     */
+    public function fillProductsFromMapping($products, $script_id, $overwrite = false) {
+        if (empty($products) || !$script_id) {
+            return $products;
+        }
+
+        $mappingItems = DB::table('mp_script_product_mappings as mapping')
+            ->join('mp_products as product', 'mapping.product_id', '=', 'product.id')
+            ->where('mapping.script_id', $script_id)
+            ->where('product.is_deleted', 0)
+            ->select(
+                'mapping.product_id',
+                'product.product_name',
+                'product.product',
+                'product.pic_prompt',
+                'product.url',
+                'product.three_view_image_url',
+                'product.timbre_url'
+            )
+            ->get();
+
+        if ($mappingItems->isEmpty()) {
+            return $products;
+        }
+
+        $mappingMap = [];
+        foreach ($mappingItems as $item) {
+            $normalized_name = $this->normalizeProductName($item->product_name);
+            if ($normalized_name === '' || isset($mappingMap[$normalized_name])) {
+                continue;
+            }
+
+            // 资产库字段有值才纳入覆盖范围(三视图、音色等为选填,可能为空)
+            $mappingProduct = [];
+            if (!empty($item->product_id)) {
+                $mappingProduct['product_id'] = (int)$item->product_id;
+            }
+            if (!empty($item->product)) {
+                $mappingProduct['product'] = (int)$item->product;
+            }
+            if (!empty($item->pic_prompt)) {
+                $mappingProduct['pic_prompt'] = $item->pic_prompt;
+            }
+            if (!empty($item->url)) {
+                $mappingProduct['url'] = $item->url;
+            }
+            if (!empty($item->three_view_image_url)) {
+                $mappingProduct['three_view_image_url'] = $item->three_view_image_url;
+            }
+            if (!empty($item->timbre_url)) {
+                $mappingProduct['timbre_url'] = $item->timbre_url;
+            }
+
+            if (!empty($mappingProduct)) {
+                $mappingMap[$normalized_name] = $mappingProduct;
+            }
+        }
+
+        if (empty($mappingMap)) {
+            return $products;
+        }
+
+        foreach ($products as &$product) {
+            $normalized_name = $this->normalizeProductName(getProp($product, 'product_name'));
+            if ($normalized_name === '' || empty($mappingMap[$normalized_name])) {
+                continue;
+            }
+
+            foreach ($mappingMap[$normalized_name] as $key => $value) {
+                // 不覆盖模式:只补齐数组中缺失的字段
+                if (!$overwrite && !empty(getProp($product, $key))) {
+                    continue;
+                }
+                $product[$key] = $value;
+            }
+        }
+        unset($product);
+
+        return $products;
+    }
+
+    /**
+     * 资产名称归一化(去掉首尾空白、方括号、花括号等符号)
+     *
+     * @param string $name
+     * @return string
+     */
+    private function normalizeProductName($name) {
+        return trim(preg_replace(
+            '/^[\s*\[\]【】[]{}{}\x{3000}]+|[\s*\[\]【】[]{}{}\x{3000}]+$/u',
+            '',
+            trim((string)$name)
+        ));
+    }
+
+    /**
+     * 判断视频模型是否支持角色参考音频(音色)
+     * 目前支持:智帧统一API、Seedance 2.0 系列(国内百度/海外百度/快快AI)
+     *
+     * @param string $model
+     * @return bool
+     */
+    public function isActReferenceAudioSupported($model) {
+        if (!$model) {
+            return false;
+        }
+
+        return strpos($model, 'zhizhen') !== false || $this->isSeedance20SeriesModel($model);
+    }
+
+    /**
+     * 将角色音色参考音频按模型分支上传为素材
+     * 上传失败时对应素材的<音频N>标记会从 $prompt 中清理(引用传递),与参考图处理保持一致
+     *
+     * @param array $referenceAudios 参考音频URL数组(顺序与<音频N>标记一一对应)
+     * @param string $model 视频模型
+     * @param string $prompt 完整提示词(引用传递)
+     * @return array 素材数组(asset://格式),非支持模型返回空数组
+     */
+    public function processActReferenceAudiosToAssets(array $referenceAudios, $model, &$prompt) {
+        if (empty($referenceAudios)) {
+            return [];
+        }
+
+        if (strpos($model, 'zhizhen') !== false) {
+            return $this->aiImageGenerationService->processReferenceAudiosToAssets($referenceAudios, $prompt);
+        }
+
+        if ($this->isSeedance20SeriesModel($model)) {
+            if ($this->isKuaikuaiSeedanceModel($model)) {
+                return $this->aiVideoGenerationService->processReferenceAudiosToKuaikuaiAssets($referenceAudios, $prompt);
+            }
+            if ($this->isOverseasBaiduSeedanceModel($model)) {
+                return $this->aiVideoGenerationService->processReferenceAudiosToBaiduDreaminaAssets($referenceAudios, $prompt);
+            }
+
+            return $this->aiVideoGenerationService->processReferenceAudiosToSeedance20Assets($referenceAudios, $prompt);
+        }
+
+        return [];
+    }
+
+    /**
+     * 参考音频素材上传失败时,用该角色原本的音色提示词兜底
+     * 说明行中的<音频N>标记被清理即代表该角色的音频素材不可用
+     *
+     * @param string $prompt 完整提示词
+     * @param array $audioVoiceFallbacks 角色名 => voice_prompt(仅包含配置了参考音频的角色)
+     * @return string
+     */
+    public function applyAudioVoiceFallbacks($prompt, array $audioVoiceFallbacks) {
+        if (empty($prompt) || empty($audioVoiceFallbacks)) {
+            return $prompt;
+        }
+
+        foreach ($audioVoiceFallbacks as $product_name => $voice_prompt) {
+            $voice_prompt = trim((string)$voice_prompt);
+            if ($product_name === '') {
+                continue;
+            }
+
+            $escaped_name = preg_quote($product_name, '/');
+
+            // 说明行中仍有<音频N>标记,说明该角色的音频素材可用,无需兜底
+            if (preg_match('/^' . $escaped_name . ':全程使用<音频\d+>的音色进行配音[^\n]*$/mu', $prompt)) {
+                continue;
+            }
+
+            $linePattern = '/^' . $escaped_name . ':全程使用[^\n]*的音色进行配音[^\n]*$/mu';
+            if (!preg_match($linePattern, $prompt)) {
+                continue;
+            }
+
+            if ($voice_prompt === '') {
+                // 该角色没有音色提示词,素材不可用时直接移除残留的说明行
+                $prompt = preg_replace('/^' . $escaped_name . ':全程使用[^\n]*的音色进行配音[^\n]*\n?/mu', '', $prompt, 1);
+            } else {
+                // 素材不可用:把说明行替换为该角色原本的音色提示词
+                $prompt = preg_replace_callback($linePattern, function () use ($product_name, $voice_prompt) {
+                    return $product_name . ':' . $voice_prompt;
+                }, $prompt, 1);
+            }
+
+            dLog('anime')->info('参考音频素材不可用,使用音色提示词兜底', [
+                'product_name' => $product_name,
+                'has_voice_prompt' => $voice_prompt === '' ? 0 : 1,
+            ]);
+        }
+
+        return $prompt;
+    }
+
+    /**
+     * 整段配音音频素材不可用时,移除对应的提示语句,避免出现残句
+     *
+     * @param string $prompt 完整提示词
+     * @return string
+     */
+    public function removeUnavailableSegmentAudioPrompt($prompt) {
+        if (empty($prompt) || strpos($prompt, '作为视频配音') === false) {
+            return $prompt;
+        }
+
+        return preg_replace_callback('/^全程使用[^\n]*?作为视频配音。\n?/mu', function ($matches) {
+            return strpos($matches[0], '<音频') === false ? '' : $matches[0];
+        }, $prompt);
+    }
+
+    /**
+     * 生成整段配音音频的提示词引用,并返回合并后的参考音频数组
+     * 角色音色音频排在前面(音频1..N,与提示词中的<音频N>一一对应),整段配音音频排在其后
+     *
+     * @param string $prompt 当前提示词
+     * @param array $referenceAudios 角色音色参考音频URL数组
+     * @param string $segmentAudioUrl 整段配音音频URL(为空表示无整段配音)
+     * @return array ['prompt' => 新提示词, 'has_audio_mark' => 是否使用<音频N>标记, 'reference_audios' => 合并后的音频数组]
+     */
+    public function buildSegmentAudioReference($prompt, array $referenceAudios, $segmentAudioUrl) {
+        $result = [
+            'prompt' => $prompt,
+            'has_audio_mark' => false,
+            'reference_audios' => $referenceAudios,
         ];
+
+        if (empty($segmentAudioUrl)) {
+            return $result;
+        }
+
+        // 有角色音色时整段配音排在角色音色之后,使用<音频N>标记交由素材上传统一做失败清理与序号重排;
+        // 没有角色音色时保持原有文案
+        $audioReference = $referenceAudios
+            ? "使用<音频" . (count($referenceAudios) + 1) . ">"
+            : "使用音频1";
+        $hasAudioMark = strpos($audioReference, '<音频') !== false;
+
+        // 有角色音色时整段配音独占一行,便于失败时按行清理
+        $result['prompt'] = $hasAudioMark
+            ? "全程" . $audioReference . "作为视频配音。\n" . $prompt
+            : "全程" . $audioReference . "作为视频配音。" . $prompt;
+        $result['has_audio_mark'] = $hasAudioMark;
+        $result['reference_audios'] = array_merge($referenceAudios, [$segmentAudioUrl]);
+
+        return $result;
     }
 
     public function saveActVideoGenerateParams($data) {
@@ -11466,6 +11793,7 @@ class AnimeService
                     'product.pic_prompt',
                     'product.url',
                     'product.three_view_image_url',
+                    'product.timbre_url',
                 )
                 ->get();
 
@@ -11474,7 +11802,7 @@ class AnimeService
                 $normalizedName = $normalizeName($mappingItem->product_name);
                 if (!$normalizedName) continue;
 
-                $mappingByName[$normalizedName] = [
+                $mappingProduct = [
                     'product_id'            => (int)$mappingItem->product_id,
                     'product_name'          => $mappingItem->product_name,
                     'product'               => (int)$mappingItem->product,
@@ -11482,6 +11810,13 @@ class AnimeService
                     'url'                   => $mappingItem->url ?? '',
                     'three_view_image_url'  => $mappingItem->three_view_image_url ?? '',
                 ];
+
+                // 音色音频:资产库有值时才覆盖分集自带的值,避免空值把已有音色清掉
+                if (!empty($mappingItem->timbre_url)) {
+                    $mappingProduct['timbre_url'] = $mappingItem->timbre_url;
+                }
+
+                $mappingByName[$normalizedName] = $mappingProduct;
             }
 
             foreach ($products as $key => &$product) {
@@ -11493,11 +11828,14 @@ class AnimeService
             unset($product);
         }
 
-        // 兼容返回 three_view_image_url 字段(旧数据可能没有该键,统一补空值)
+        // 兼容返回 three_view_image_url / timbre_url 字段(旧数据可能没有该键,统一补空值)
         foreach ($products as &$product) {
             if (!isset($product['three_view_image_url'])) {
                 $product['three_view_image_url'] = '';
             }
+            if (!isset($product['timbre_url'])) {
+                $product['timbre_url'] = '';
+            }
         }
         unset($product);