|
@@ -5019,15 +5019,7 @@ class AnimeService
|
|
|
// 音效同出(默认使用)
|
|
// 音效同出(默认使用)
|
|
|
$current_generate_audio = $generate_audio ? 1 : 0;
|
|
$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');
|
|
$model = getProp($data, 'model');
|
|
|
if (!$model) {
|
|
if (!$model) {
|
|
|
$model = getProp($episode, 'video_model');
|
|
$model = getProp($episode, 'video_model');
|
|
@@ -5041,6 +5033,17 @@ class AnimeService
|
|
|
if (!in_array($model, $valid_models)) {
|
|
if (!in_array($model, $valid_models)) {
|
|
|
$model = 'seed-2';
|
|
$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表(仅在专用方法中更新模型)
|
|
// 保存到episode表(仅在专用方法中更新模型)
|
|
|
// DB::table('mp_anime_episodes')->where('id', $episode_id)->update([
|
|
// DB::table('mp_anime_episodes')->where('id', $episode_id)->update([
|
|
|
// 'video_model' => $model,
|
|
// 'video_model' => $model,
|
|
@@ -5203,6 +5206,16 @@ class AnimeService
|
|
|
$parameters['video_mode'] = 'multi_image';
|
|
$parameters['video_mode'] = 'multi_image';
|
|
|
$parameters['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;
|
|
$unifiedParams['parameters'] = $parameters;
|
|
|
|
|
|
|
@@ -5235,6 +5248,17 @@ class AnimeService
|
|
|
$videoParams['reference_image_assets'] = $reference_image_assets;
|
|
$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(火山豆包格式)
|
|
// 快快AI走聚合网关 /v1/video/generations(火山豆包格式)
|
|
|
if ($isKuaikuai) {
|
|
if ($isKuaikuai) {
|
|
|
$videoParams['gateway_base_url'] = env('KUAIKUAI_AI_BASE_URL', 'https://ai-api.kkidc.com');
|
|
$videoParams['gateway_base_url'] = env('KUAIKUAI_AI_BASE_URL', 'https://ai-api.kkidc.com');
|
|
@@ -8300,6 +8324,7 @@ class AnimeService
|
|
|
'url' => $sourceProduct->url,
|
|
'url' => $sourceProduct->url,
|
|
|
'pic_prompt' => $sourceProduct->pic_prompt ?? '',
|
|
'pic_prompt' => $sourceProduct->pic_prompt ?? '',
|
|
|
'three_view_image_url' => $sourceProduct->three_view_image_url ?? '',
|
|
'three_view_image_url' => $sourceProduct->three_view_image_url ?? '',
|
|
|
|
|
+ 'timbre_url' => $sourceProduct->timbre_url ?? '',
|
|
|
'product' => $finalProductType,
|
|
'product' => $finalProductType,
|
|
|
'versions' => $sourceProduct->versions ?? '',
|
|
'versions' => $sourceProduct->versions ?? '',
|
|
|
'sort_order' => time(),
|
|
'sort_order' => time(),
|
|
@@ -11185,11 +11210,21 @@ class AnimeService
|
|
|
* @param array $products 产品数组(包含product_name和url)
|
|
* @param array $products 产品数组(包含product_name和url)
|
|
|
* @return array ['content' => 处理后的内容, 'reference_images' => 参考图片数组]
|
|
* @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)) {
|
|
if (empty($actContent) || empty($products)) {
|
|
|
return [
|
|
return [
|
|
|
'content' => $actContent,
|
|
'content' => $actContent,
|
|
|
- 'reference_images' => []
|
|
|
|
|
|
|
+ 'reference_images' => [],
|
|
|
|
|
+ 'reference_audios' => [],
|
|
|
|
|
+ 'audio_voice_fallbacks' => []
|
|
|
];
|
|
];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -11201,7 +11236,10 @@ class AnimeService
|
|
|
$product_dict[$product_name] = [
|
|
$product_dict[$product_name] = [
|
|
|
// 优先使用三视图图片作为素材/参考图,否则使用原图 url
|
|
// 优先使用三视图图片作为素材/参考图,否则使用原图 url
|
|
|
'url' => getProp($product, 'three_view_image_url') ?: getProp($product, '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 = [];
|
|
$reference_images = [];
|
|
|
$product_index_map = []; // 产品名称 => 图片序号的映射
|
|
$product_index_map = []; // 产品名称 => 图片序号的映射
|
|
|
|
|
+ $reference_audios = [];
|
|
|
|
|
+ $product_audio_index_map = []; // 产品名称 => 音频序号的映射(与图片序号互相独立)
|
|
|
|
|
+ $audio_voice_fallbacks = []; // 产品名称 => voice_prompt(参考音频素材不可用时兜底使用)
|
|
|
$voice_prompts = []; // 存储需要添加到开头的voice_prompt信息
|
|
$voice_prompts = []; // 存储需要添加到开头的voice_prompt信息
|
|
|
$narrator_voice_prompt = ''; // 单独存储旁白的voice_prompt
|
|
$narrator_voice_prompt = ''; // 单独存储旁白的voice_prompt
|
|
|
$current_index = 1;
|
|
$current_index = 1;
|
|
|
|
|
+ $current_audio_index = 1;
|
|
|
|
|
|
|
|
if (!empty($matches[1])) {
|
|
if (!empty($matches[1])) {
|
|
|
// 按照在内容中第一次出现的顺序,为每个产品分配序号
|
|
// 按照在内容中第一次出现的顺序,为每个产品分配序号
|
|
@@ -11231,9 +11273,25 @@ class AnimeService
|
|
|
if (isset($product_dict[$product_name])) {
|
|
if (isset($product_dict[$product_name])) {
|
|
|
$url = $product_dict[$product_name]['url'];
|
|
$url = $product_dict[$product_name]['url'];
|
|
|
$voice_prompt = $product_dict[$product_name]['voice_prompt'];
|
|
$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";
|
|
$voice_prompts[] = $product_name . ':' . $voice_prompt . "\n";
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -11266,12 +11324,18 @@ class AnimeService
|
|
|
$escaped_name = preg_quote($product_name, '/');
|
|
$escaped_name = preg_quote($product_name, '/');
|
|
|
|
|
|
|
|
if ($index > 0) {
|
|
if ($index > 0) {
|
|
|
- // 有图片,替换为:产品名称(图X)
|
|
|
|
|
|
|
+ // 有图片,替换为:产品名称<图片X>
|
|
|
$replacement = $product_name . '<图片' . $index . '>';
|
|
$replacement = $product_name . '<图片' . $index . '>';
|
|
|
} else {
|
|
} else {
|
|
|
// 无图片,只保留产品名称
|
|
// 无图片,只保留产品名称
|
|
|
$replacement = $product_name;
|
|
$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);
|
|
$processedContent = preg_replace('/\{' . $escaped_name . '\}/u', $replacement, $processedContent);
|
|
@@ -11282,14 +11346,21 @@ class AnimeService
|
|
|
$product_name = getProp($product, 'product_name');
|
|
$product_name = getProp($product, 'product_name');
|
|
|
$voice_prompt = getProp($product, 'voice_prompt');
|
|
$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";
|
|
$narrator_voice_prompt = $product_name . ':' . $voice_prompt . "\n";
|
|
|
break; // 找到旁白后退出循环
|
|
break; // 找到旁白后退出循环
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 第四步:将voice_prompt信息添加到内容最前面
|
|
|
|
|
|
|
+ // 第四步:将参考音频音色信息和voice_prompt信息添加到内容最前面
|
|
|
$voice_prompt_prefix = '';
|
|
$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)) {
|
|
if (!empty($voice_prompts)) {
|
|
|
$voice_prompt_prefix .= implode('', $voice_prompts);
|
|
$voice_prompt_prefix .= implode('', $voice_prompts);
|
|
|
}
|
|
}
|
|
@@ -11305,8 +11376,264 @@ class AnimeService
|
|
|
|
|
|
|
|
return [
|
|
return [
|
|
|
'content' => $processedContent,
|
|
'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) {
|
|
public function saveActVideoGenerateParams($data) {
|
|
@@ -11466,6 +11793,7 @@ class AnimeService
|
|
|
'product.pic_prompt',
|
|
'product.pic_prompt',
|
|
|
'product.url',
|
|
'product.url',
|
|
|
'product.three_view_image_url',
|
|
'product.three_view_image_url',
|
|
|
|
|
+ 'product.timbre_url',
|
|
|
)
|
|
)
|
|
|
->get();
|
|
->get();
|
|
|
|
|
|
|
@@ -11474,7 +11802,7 @@ class AnimeService
|
|
|
$normalizedName = $normalizeName($mappingItem->product_name);
|
|
$normalizedName = $normalizeName($mappingItem->product_name);
|
|
|
if (!$normalizedName) continue;
|
|
if (!$normalizedName) continue;
|
|
|
|
|
|
|
|
- $mappingByName[$normalizedName] = [
|
|
|
|
|
|
|
+ $mappingProduct = [
|
|
|
'product_id' => (int)$mappingItem->product_id,
|
|
'product_id' => (int)$mappingItem->product_id,
|
|
|
'product_name' => $mappingItem->product_name,
|
|
'product_name' => $mappingItem->product_name,
|
|
|
'product' => (int)$mappingItem->product,
|
|
'product' => (int)$mappingItem->product,
|
|
@@ -11482,6 +11810,13 @@ class AnimeService
|
|
|
'url' => $mappingItem->url ?? '',
|
|
'url' => $mappingItem->url ?? '',
|
|
|
'three_view_image_url' => $mappingItem->three_view_image_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) {
|
|
foreach ($products as $key => &$product) {
|
|
@@ -11493,11 +11828,14 @@ class AnimeService
|
|
|
unset($product);
|
|
unset($product);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 兼容返回 three_view_image_url 字段(旧数据可能没有该键,统一补空值)
|
|
|
|
|
|
|
+ // 兼容返回 three_view_image_url / timbre_url 字段(旧数据可能没有该键,统一补空值)
|
|
|
foreach ($products as &$product) {
|
|
foreach ($products as &$product) {
|
|
|
if (!isset($product['three_view_image_url'])) {
|
|
if (!isset($product['three_view_image_url'])) {
|
|
|
$product['three_view_image_url'] = '';
|
|
$product['three_view_image_url'] = '';
|
|
|
}
|
|
}
|
|
|
|
|
+ if (!isset($product['timbre_url'])) {
|
|
|
|
|
+ $product['timbre_url'] = '';
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
unset($product);
|
|
unset($product);
|
|
|
|
|
|