Pārlūkot izejas kodu

1.视频任务新增参考音频总开
2.参考音频增加数量和时长验证以保证通过模型要求
3.优化音频提示词取用方式

lh 1 dienu atpakaļ
vecāks
revīzija
82695a5429

+ 7 - 2
app/Http/Controllers/Anime/AnimeController.php

@@ -3145,8 +3145,13 @@ class AnimeController extends BaseController
                 // 获取分镜内容
                 $actContent = $act->act_show_content ?: '';
                 // 构建完整的提示词
-                $supportAudioReference = $this->AnimeService->isActReferenceAudioSupported($model);
-                $processResult = $this->AnimeService->processActContentWithProducts($actContent, $products, $supportAudioReference);
+                // 参考音频开关:调用方传 enable_reference_audio 优先,未传则读 ENABLE_REFERENCE_AUDIO(默认开启)
+                $supportAudioReference = $this->AnimeService->isReferenceAudioEnabled($data) && $this->AnimeService->isActReferenceAudioSupported($model);
+                // 先在提示词转换之前完成参考音频校验与截断(超出模型段数/时长上限的角色回退使用音色提示词)
+                $audioPlan = $supportAudioReference
+                    ? $this->AnimeService->resolveActReferenceAudios($actContent, $products, $model)
+                    : ['allowed' => [], 'excluded' => [], 'total_duration' => 0];
+                $processResult = $this->AnimeService->processActContentWithProducts($actContent, $products, $supportAudioReference, $audioPlan['allowed']);
                 $fullPrompt = $processResult['content'];
                 $reference_images = $processResult['reference_images'];
                 // 角色音色参考音频(顺序与提示词中的<音频N>标记一一对应)

+ 338 - 11
app/Services/Anime/AnimeService.php

@@ -5097,8 +5097,13 @@ class AnimeService
         }
 
         // 构建完整的提示词
-        $supportAudioReference = $this->isActReferenceAudioSupported($model);
-        $processResult = $this->processActContentWithProducts($actContent, $products, $supportAudioReference);
+        // 参考音频开关:调用方传 enable_reference_audio 优先,未传则读 ENABLE_REFERENCE_AUDIO(默认开启)
+        $supportAudioReference = $this->isReferenceAudioEnabled($data) && $this->isActReferenceAudioSupported($model);
+        // 先在提示词转换之前完成参考音频校验与截断(超出模型段数/时长上限的角色回退使用音色提示词)
+        $audioPlan = $supportAudioReference
+            ? $this->resolveActReferenceAudios($actContent, $products, $model)
+            : ['allowed' => [], 'excluded' => [], 'total_duration' => 0];
+        $processResult = $this->processActContentWithProducts($actContent, $products, $supportAudioReference, $audioPlan['allowed']);
         $fullPrompt = $processResult['content'];
         // if ($art_style_type == '3D真人风格') $fullPrompt = "美术风格: $art_style\n\n".$fullPrompt;
         // 资产参考图与调用方传入的参考图合并后统一去重(保留资产参考图在前,不影响提示词中的<图片N>编号)
@@ -11309,7 +11314,7 @@ class AnimeService
      * @param bool $supportAudioReference 模型是否支持参考音频;支持时角色音色优先使用 timbre_url
      * @return array
      */
-    public function processActContentWithProducts($actContent, $products, $supportAudioReference = false) {
+    public function processActContentWithProducts($actContent, $products, $supportAudioReference = false, $allowedAudioProducts = null) {
         if (empty($actContent) || empty($products)) {
             return [
                 'content' => $actContent,
@@ -11366,8 +11371,15 @@ class AnimeService
                     $voice_prompt = $product_dict[$product_name]['voice_prompt'];
                     $timbre_url = $product_dict[$product_name]['timbre_url'];
 
+                    // 判断该角色的参考音频是否可用(模型支持 + 配置了音色 + 未超出模型段数/时长限制)
+                    $audio_allowed = $supportAudioReference && !empty($timbre_url);
+                    if ($audio_allowed && is_array($allowedAudioProducts)) {
+                        $audio_allowed = isset($allowedAudioProducts[$product_name])
+                            && $allowedAudioProducts[$product_name] === $timbre_url;
+                    }
+
                     // 分配音频序号(与图片序号互相独立;相同音频URL复用同一序号,保证角色与音频一一对应)
-                    if ($supportAudioReference && !empty($timbre_url)) {
+                    if ($audio_allowed) {
                         if (!in_array($timbre_url, $reference_audios)) {
                             $reference_audios[] = $timbre_url;
                             $product_audio_index_map[$product_name] = $current_audio_index;
@@ -11381,8 +11393,8 @@ class AnimeService
                         $audio_voice_fallbacks[$product_name] = $voice_prompt ?? '';
                     }
 
-                    // 处理voice_prompt:模型支持参考音频且角色有音频时优先使用音频,不再输出音色提示词(旁白是公共的在后面单独处理)
-                    if ((!$supportAudioReference || empty($timbre_url)) && !empty($voice_prompt) && $product_name !== '旁白') {
+                    // 处理voice_prompt:有可用参考音频的角色优先使用音频,不再输出音色提示词(旁白是公共的在后面单独处理)
+                    if (!$audio_allowed && !empty($voice_prompt) && $product_name !== '旁白') {
                         $voice_prompts[] = $product_name . ':' . $voice_prompt . "\n";
                     }
                     
@@ -11446,10 +11458,10 @@ class AnimeService
         // 第四步:将参考音频音色信息和voice_prompt信息添加到内容最前面
         $voice_prompt_prefix = '';
 
-        // 有参考音频的角色:声明"角色名 对应 音频N",统一使用<音频N>标记
+        // 有参考音频的角色:按官方推荐句式"参考<音频N>中的音色"声明对应关系
         // 素材上传失败时与正文标记一起被清理/重排,保证角色与音频始终对应
         foreach ($product_audio_index_map as $product_name => $audio_index) {
-            $voice_prompt_prefix .= $product_name . ':全程使用<音频' . $audio_index . '>的音色进行配音' . "\n";
+            $voice_prompt_prefix .= '参考<音频' . $audio_index . '>中的音色,为' . $product_name . '配音' . "\n";
         }
 
         if (!empty($voice_prompts)) {
@@ -11578,6 +11590,33 @@ class AnimeService
     }
 
     /**
+     * 是否启用参考音频(角色音色)
+     * 优先取调用方传入的 enable_reference_audio,未传时读取环境变量 ENABLE_REFERENCE_AUDIO,默认开启
+     *
+     * @param array $data 调用方入参
+     * @return bool
+     */
+    public function isReferenceAudioEnabled($data) {
+        // 环境变量兜底:兼容 true/false/1/0/on/off/yes/no 等写法,无法识别时按默认开启
+        $envValue = env('ENABLE_REFERENCE_AUDIO', true);
+        $envDefault = is_bool($envValue)
+            ? $envValue
+            : filter_var($envValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+        if ($envDefault === null) {
+            $envDefault = true;
+        }
+
+        $value = getProp($data, 'enable_reference_audio', null);
+        if ($value === null || $value === '') {
+            return $envDefault;
+        }
+
+        $bool = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+
+        return $bool === null ? $envDefault : $bool;
+    }
+
+    /**
      * 判断视频模型是否支持角色参考音频(音色)
      * 目前支持:智帧统一API、Seedance 2.0 系列(国内百度/海外百度/快快AI)
      *
@@ -11593,6 +11632,294 @@ class AnimeService
     }
 
     /**
+     * 获取模型对参考音频的限制
+     * Seedance 2.0(含 2.0 lite / 2.0 fast):最多3段,单个[2,15]s,总时长不超过15s
+     * Seedance 2.5:最多10段,单个[2,30]s,总时长不超过30s
+     *
+     * @param string $model
+     * @return array|null 非参考音频模型返回 null
+     */
+    public function getActAudioLimits($model) {
+        if (!$this->isActReferenceAudioSupported($model)) {
+            return null;
+        }
+
+        if (strpos($model, '2.5') !== false || strpos($model, '2-5') !== false) {
+            return ['max_count' => 10, 'max_single' => 30, 'max_total' => 30];
+        }
+
+        return ['max_count' => 3, 'max_single' => 15, 'max_total' => 15];
+    }
+
+    /**
+     * 生成提示词之前解析可用的参考音频
+     * 按正文中{资产名}首次出现的顺序,逐个校验单个音频时长、段数与总时长,超出限制的部分直接截断;
+     * 被截断(或单个时长超限)的角色不会出现在提示词的<音频N>引用中,会自动回退使用音色提示词
+     *
+     * @param string $actContent 片段内容
+     * @param array $products 资产数组
+     * @param string $model 视频模型
+     * @return array ['allowed' => [产品名 => 音频URL], 'excluded' => [产品名 => 原因], 'total_duration' => 秒]
+     */
+    public function resolveActReferenceAudios($actContent, $products, $model) {
+        $result = ['allowed' => [], 'excluded' => [], 'total_duration' => 0];
+
+        $limits = $this->getActAudioLimits($model);
+        if (!$limits || empty($actContent) || empty($products)) {
+            return $result;
+        }
+
+        // 产品名 => 音色音频
+        $timbreMap = [];
+        foreach ($products as $product) {
+            $name = getProp($product, 'product_name');
+            if (!$name) {
+                continue;
+            }
+            $timbre = trim((string)getProp($product, 'timbre_url', ''));
+            if ($timbre !== '') {
+                $timbreMap[$name] = $timbre;
+            }
+        }
+        if (empty($timbreMap)) {
+            return $result;
+        }
+
+        // 按正文中{资产名}首次出现的顺序确定音频编号顺序(与提示词生成保持一致)
+        preg_match_all('/\{([^}]+)\}/u', $actContent, $matches);
+        $orderedNames = [];
+        foreach (($matches[1] ?? []) as $name) {
+            if (!isset($orderedNames[$name])) {
+                $orderedNames[$name] = true;
+            }
+        }
+
+        $usedUrls = [];
+        $totalDuration = 0;
+        foreach (array_keys($orderedNames) as $name) {
+            $timbre = $timbreMap[$name] ?? '';
+            if ($timbre === '') {
+                continue;
+            }
+
+            // 相同音频复用同一段素材,不重复计数
+            if (isset($usedUrls[$timbre])) {
+                $result['allowed'][$name] = $timbre;
+                continue;
+            }
+
+            $duration = $this->getRemoteAudioDurationSeconds($timbre);
+
+            if ($duration > 0 && $duration > $limits['max_single']) {
+                $result['excluded'][$name] = '单个音频时长超限(' . $duration . 's > ' . $limits['max_single'] . 's)';
+                continue;
+            }
+            if (count($usedUrls) >= $limits['max_count']) {
+                $result['excluded'][$name] = '超过参考音频段数上限(' . $limits['max_count'] . '段)';
+                continue;
+            }
+            if ($duration > 0 && ($totalDuration + $duration) > $limits['max_total']) {
+                $result['excluded'][$name] = '超过参考音频总时长上限(' . $limits['max_total'] . 's)';
+                continue;
+            }
+
+            $usedUrls[$timbre] = true;
+            $totalDuration += max(0, $duration);
+            $result['allowed'][$name] = $timbre;
+        }
+
+        $result['total_duration'] = round($totalDuration, 2);
+
+        if (!empty($result['excluded'])) {
+            dLog('anime')->info('参考音频超出模型限制,已截断', [
+                'model' => $model,
+                'limits' => $limits,
+                'allowed' => array_keys($result['allowed']),
+                'excluded' => $result['excluded'],
+                'total_duration' => $result['total_duration'],
+            ]);
+        }
+
+        return $result;
+    }
+
+    /**
+     * 探测远程音频时长(wav 精确解析;mp3 按首帧码率估算)
+     *
+     * @param string $url 音频URL
+     * @return float 秒;无法探测时返回 0
+     */
+    public function getRemoteAudioDurationSeconds($url) {
+        try {
+            // 使用 Guzzle(不依赖 allow_url_fopen,LNMP 环境下同样可用)
+            $client = new \GuzzleHttp\Client(['verify' => false, 'timeout' => 20, 'connect_timeout' => 10]);
+            $response = $client->get($url, [
+                'headers' => ['Range' => 'bytes=0-1048575'],
+                'http_errors' => false,
+            ]);
+
+            $data = (string)$response->getBody();
+            if (strlen($data) < 64) {
+                return 0;
+            }
+
+            // 文件总大小:206 时取 Content-Range 的分母,否则用 Content-Length
+            $totalSize = 0;
+            $contentRange = $response->getHeaderLine('Content-Range');
+            if ($contentRange !== '' && preg_match('#/(\d+)#', $contentRange, $m)) {
+                $totalSize = (int)$m[1];
+            } else {
+                $contentLength = $response->getHeaderLine('Content-Length');
+                if ($contentLength !== '') {
+                    $totalSize = (int)$contentLength;
+                }
+            }
+
+            // WAV:解析 fmt/data chunk 精确计算
+            if (substr($data, 0, 4) === 'RIFF' && substr($data, 8, 4) === 'WAVE') {
+                $offset = 12;
+                $byteRate = 0;
+                $dataSize = 0;
+                while ($offset + 8 <= strlen($data)) {
+                    $chunkId = substr($data, $offset, 4);
+                    $chunkSize = unpack('V', substr($data, $offset + 4, 4))[1];
+                    if ($chunkId === 'fmt ' && $offset + 8 + 12 <= strlen($data)) {
+                        $byteRate = unpack('V', substr($data, $offset + 16, 4))[1];
+                    } elseif ($chunkId === 'data') {
+                        $dataSize = $chunkSize;
+                        break;
+                    }
+                    $offset += 8 + $chunkSize + ($chunkSize % 2);
+                }
+                if ($byteRate > 0 && $dataSize > 0) {
+                    return round($dataSize / $byteRate, 2);
+                }
+                return 0;
+            }
+
+            // MP3:优先 Xing/Info 帧数,其次逐帧统计,最后按码率估算
+            $scanStart = 0;
+            if (strlen($data) >= 10 && substr($data, 0, 3) === 'ID3') {
+                // 跳过 ID3v2 标签,避免命中标签数据中的伪同步字
+                $tagSize = ((ord($data[6]) & 0x7F) << 21) | ((ord($data[7]) & 0x7F) << 14)
+                    | ((ord($data[8]) & 0x7F) << 7) | (ord($data[9]) & 0x7F);
+                $scanStart = 10 + $tagSize;
+            }
+
+            $mpeg1Layer3 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
+            $mpeg2Layer3 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
+            $sampleRatesV1 = [44100, 48000, 32000, 0];
+            $sampleRatesV2 = [22050, 24000, 16000, 0];
+
+            $parseFrame = function ($buffer, $offset) use ($mpeg1Layer3, $mpeg2Layer3, $sampleRatesV1, $sampleRatesV2) {
+                if ($offset < 0 || $offset + 4 > strlen($buffer)) {
+                    return null;
+                }
+                if (ord($buffer[$offset]) !== 0xFF) {
+                    return null;
+                }
+                $b1 = ord($buffer[$offset + 1]);
+                if (($b1 & 0xE0) !== 0xE0) {
+                    return null;
+                }
+                $b2 = ord($buffer[$offset + 2]);
+                $b3 = ord($buffer[$offset + 3]);
+                $versionBits = ($b1 >> 3) & 0x03;   // 3=MPEG1, 2=MPEG2, 0=MPEG2.5
+                $layerBits = ($b1 >> 1) & 0x03;     // 1=Layer3
+                $bitrateIndex = ($b2 >> 4) & 0x0F;
+                $sampleIndex = ($b2 >> 2) & 0x03;
+                $channelMode = ($b3 >> 6) & 0x03;   // 3=单声道
+                if ($versionBits === 1 || $layerBits !== 1 || $bitrateIndex === 0 || $bitrateIndex === 15 || $sampleIndex === 3) {
+                    return null;
+                }
+                $bitrate = $versionBits === 3 ? $mpeg1Layer3[$bitrateIndex] : $mpeg2Layer3[$bitrateIndex];
+                $rate = $versionBits === 3 ? $sampleRatesV1[$sampleIndex] : $sampleRatesV2[$sampleIndex];
+                if ($bitrate <= 0 || $rate <= 0) {
+                    return null;
+                }
+                $samplesPerFrame = $versionBits === 3 ? 1152 : 576;
+                $frameLen = (int)(($samplesPerFrame / 8 * $bitrate * 1000) / $rate);
+                if ($frameLen <= 4) {
+                    return null;
+                }
+                return [
+                    'version' => $versionBits,
+                    'bitrate' => $bitrate,
+                    'rate' => $rate,
+                    'samples' => $samplesPerFrame,
+                    'length' => $frameLen,
+                    'channel_mode' => $channelMode,
+                ];
+            };
+
+            // 定位首个有效帧,并校验下一帧也合法(避免命中伪同步字)
+            $firstFrame = null;
+            $frameOffset = -1;
+            for ($i = $scanStart; $i + 4 <= strlen($data); $i++) {
+                $frame = $parseFrame($data, $i);
+                if (!$frame) {
+                    continue;
+                }
+                $nextOffset = $i + $frame['length'];
+                if ($nextOffset + 4 <= strlen($data) && !$parseFrame($data, $nextOffset)) {
+                    continue;
+                }
+                $firstFrame = $frame;
+                $frameOffset = $i;
+                break;
+            }
+            if (!$firstFrame) {
+                return 0;
+            }
+
+            // 1) Xing/Info 头包含总帧数,可直接精确计算
+            $sideInfoSize = $firstFrame['version'] === 3
+                ? ($firstFrame['channel_mode'] === 3 ? 17 : 32)
+                : ($firstFrame['channel_mode'] === 3 ? 9 : 17);
+            $xingOffset = $frameOffset + 4 + $sideInfoSize;
+            $xingTag = substr($data, $xingOffset, 4);
+            if (($xingTag === 'Xing' || $xingTag === 'Info') && strlen($data) >= $xingOffset + 12) {
+                $flags = unpack('N', substr($data, $xingOffset + 4, 4))[1];
+                if ($flags & 0x01) {
+                    $frameCount = unpack('N', substr($data, $xingOffset + 8, 4))[1];
+                    if ($frameCount > 0) {
+                        return round($frameCount * $firstFrame['samples'] / $firstFrame['rate'], 2);
+                    }
+                }
+            }
+
+            // 2) 文件不大时下载完整内容逐帧统计(精确)
+            $fullData = $data;
+            if ($totalSize > 0 && strlen($data) < $totalSize && $totalSize <= 10485760) {
+                $fullData = (string)$client->get($url, ['http_errors' => false])->getBody();
+            }
+            if ($totalSize > 0 && strlen($fullData) >= $totalSize) {
+                $samples = 0;
+                $i = $frameOffset;
+                $bufferLength = strlen($fullData);
+                while ($i + 4 <= $bufferLength) {
+                    $frame = $parseFrame($fullData, $i);
+                    if (!$frame) {
+                        $i++;
+                        continue;
+                    }
+                    $samples += $frame['samples'];
+                    $i += $frame['length'];
+                }
+                if ($samples > 0) {
+                    return round($samples / $firstFrame['rate'], 2);
+                }
+            }
+
+            // 3) 回退:按首帧码率估算(仅用于无法读取完整文件的极端情况)
+            return round(($totalSize * 8) / ($firstFrame['bitrate'] * 1000), 2);
+        } catch (\Throwable $e) {
+            dLog('anime')->warning('参考音频时长探测失败', ['url' => $url, 'error' => $e->getMessage()]);
+            return 0;
+        }
+    }
+
+    /**
      * 将角色音色参考音频按模型分支上传为素材
      * 上传失败时对应素材的<音频N>标记会从 $prompt 中清理(引用传递),与参考图处理保持一致
      *
@@ -11646,18 +11973,18 @@ class AnimeService
             $escaped_name = preg_quote($product_name, '/');
 
             // 说明行中仍有<音频N>标记,说明该角色的音频素材可用,无需兜底
-            if (preg_match('/^' . $escaped_name . ':全程使用<音频\d+>的音色进行配音[^\n]*$/mu', $prompt)) {
+            if (preg_match('/^参考<音频\d+>中的音色,为' . $escaped_name . '配音[^\n]*$/mu', $prompt)) {
                 continue;
             }
 
-            $linePattern = '/^' . $escaped_name . ':全程使用[^\n]*的音色进行配音[^\n]*$/mu';
+            $linePattern = '/^参考<音频\d+>中的音色,为' . $escaped_name . '配音[^\n]*$/mu';
             if (!preg_match($linePattern, $prompt)) {
                 continue;
             }
 
             if ($voice_prompt === '') {
                 // 该角色没有音色提示词,素材不可用时直接移除残留的说明行
-                $prompt = preg_replace('/^' . $escaped_name . ':全程使用[^\n]*的音色进行配音[^\n]*\n?/mu', '', $prompt, 1);
+                $prompt = preg_replace('/^参考<音频\d+>中的音色,为' . $escaped_name . '配音[^\n]*\n?/mu', '', $prompt, 1);
             } else {
                 // 素材不可用:把说明行替换为该角色原本的音色提示词
                 $prompt = preg_replace_callback($linePattern, function () use ($product_name, $voice_prompt) {