aiImageGenerationService = $aiImageGenerationService; $this->url = 'https://api.deepseek.com/chat/completions'; // DeepSeek API请求地址 $this->api_key = env('DEEPSEEK_API_KEY'); $this->headers = [ 'Authorization' => 'Bearer '.$this->api_key, 'Content-Type' => 'application/json; charset=UTF-8' ]; // 火山引擎模型列表 $this->valid_text_models = DB::table('mp_text_models')->where('is_enabled', 1)->pluck('model')->toArray(); $this->sys_message = [ 'role' => 'system', 'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集剧本>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n 强制要求:\n 1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出 2.强制按照示例格式返回内容,不要更改格式或随意增加符号\n 普通要求:\n 1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n 2.<分集剧本>要求包含“分集序号”、“分集标题”以及分镜信息,分镜信息需包含“场景描述”、“运镜”、“出场角色”和“台词内容”这几个部分,其中“出场角色”和“场景描述”必须在前面的板块<主体列表>和<场景列表>中选取,不得自定义。\n\n 示例如下:\n ###剧本名:西昆仑 ###故事梗概 内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###剧本亮点 亮点1:绝境奇药,生死一线 画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。 亮点2:结拜兄妹,化解尴尬 阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。 亮点3:纤发夺命,情愫暗涌 拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。 ###人物关系 阿雪与梁萧之间存在兄妹之情。 ###核心矛盾 阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###主体列表 阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。 阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。 梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。 阴阳球:天地异宝,能化生精气,救人于危难。 问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。 ###美术风格 基础画风风格词:厚涂古风 整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。 ###场景列表 林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。 ###分集剧本 ##分集01 分集名: 第一集 分镜01-1 场景描述:地点、时间、场景 运镜:固定近距离 出场角色:男主、女主 台词内容: 女主: 女主对话 男主:男主对话 女主: 女主对话 男主: 男主对话 分镜01-2 场景描述:地点、时间、场景 运镜:从近到远 出场角色:男主、女主 台词内容: 女主: 女主对话 男主:男主对话 女主: 女主对话 男主: 男主对话 分镜01-3 场景描述:地点、时间、场景 运镜:快速拉近 出场角色:男主、女主 台词内容: 女主: 女主对话 男主:男主对话 女主: 女主对话 男主: 男主对话 ##分集02 分集名: 第二集 分镜02-1 场景描述:地点、时间、场景 运镜:从近到远 出场角色:男主、女主 台词内容: 女主: 女主对话 男主:男主对话 女主: 女主对话 男主: 男主对话 分镜02-2 场景描述:地点、时间、场景 运镜:从近到远 出场角色:男主、女主 台词内容: 女主: 女主对话 男主:男主对话 女主: 女主对话 男主: 男主对话" ]; } /** * 通用文生文方法 - 支持多模型流式输出和图片输入 * * @param array $data 请求参数 * @return \Generator 返回生成器,用于流式输出 */ public function generateText($data) { // 获取请求参数 $model = getProp($data, 'model', 'deepseek-v3-2-251201'); // 默认使用deepseek-v3 $messages = getProp($data, 'messages', []); $systemPrompt = getProp($data, 'system_prompt', ''); $prompt = getProp($data, 'prompt', ''); $images = getProp($data, 'images', []); // 支持多张图片 $imageUrls = getProp($data, 'image_urls', []); // 支持图片URL // 如果没有提供messages,则根据system_prompt、content和images构建 if (empty($messages)) { $messages = []; // 添加系统提示词 if (!empty($systemPrompt)) { $messages[] = [ 'role' => 'system', 'content' => $systemPrompt ]; } // 构建用户消息内容(支持文本+图片) if (!empty($prompt) || !empty($images) || !empty($imageUrls)) { $userMessage = [ 'role' => 'user', 'content' => [] ]; // 添加文本内容 if (!empty($prompt)) { $userMessage['content'][] = [ 'type' => 'text', 'text' => $prompt ]; } // 处理上传的图片文件 if (!empty($images)) { if (!is_array($images)) { $images = [$images]; } foreach ($images as $image) { $imageBase64 = $this->processImageToBase64($image); if ($imageBase64) { $userMessage['content'][] = [ 'type' => 'image_url', 'image_url' => [ 'url' => "data:image/jpeg;base64,{$imageBase64}" ] ]; } } } // 处理图片URL if (!empty($imageUrls)) { if (!is_array($imageUrls)) { $imageUrls = [$imageUrls]; } foreach ($imageUrls as $url) { if (!empty($url)) { $userMessage['content'][] = [ 'type' => 'image_url', 'image_url' => [ 'url' => $url ] ]; } } } // 如果只有文本内容,简化为字符串格式 if (count($userMessage['content']) === 1 && $userMessage['content'][0]['type'] === 'text') { $userMessage['content'] = $userMessage['content'][0]['text']; } $messages[] = $userMessage; } } // 验证messages不为空 if (empty($messages)) { Utils::throwError('20003:请提供有效的对话内容'); } // 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash $thinkingMode = getProp($data, 'thinking', 'disabled'); // 默认非思考模式 $reasoningEffort = getProp($data, 'reasoning_effort', 'high'); // 默认思考强度 if ($model === 'deepseek-chat') { // deepseek-chat 映射到 deepseek-v4-flash,非思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'disabled'; } elseif ($model === 'deepseek-reasoner') { // deepseek-reasoner 映射到 deepseek-v4-flash,思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'enabled'; } // 构建请求参数 $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => getProp($data, 'max_tokens', 8192), 'temperature' => getProp($data, 'temperature', 1), 'frequency_penalty' => getProp($data, 'frequency_penalty', 0), 'presence_penalty' => getProp($data, 'presence_penalty', 0), 'thinking' => ['type' => $thinkingMode], 'stream' => true // 启用流式输出 ]; if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort; // 添加可选参数 if (isset($data['top_p'])) { $post_data['top_p'] = $data['top_p']; } if (isset($data['response_format'])) { $post_data['response_format'] = $data['response_format']; } dLog('deepseek')->info('通用文生文请求参数', ['model' => $model, 'thinking' => $thinkingMode, 'messages_count' => count($messages)]); // 根据模型类型选择调用方法 if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // DeepSeek 官方模型使用 DeepSeek API return $this->deepSeekStreamResponse($post_data); } else if (in_array($model, $this->valid_text_models)) { // 火山引擎支持的模型使用火山引擎 API return $this->volcEngineChatCompletion($post_data); } else { Utils::throwError('20003:不支持的模型: ' . $model); } } /** * 处理图片转换为base64 * * @param mixed $image 图片文件对象或文件路径 * @return string|null base64编码的图片数据 */ private function processImageToBase64($image) { try { // 获取文件路径 if (is_string($image)) { $filePath = $image; } else { // Laravel UploadedFile 对象 $filePath = $image->getRealPath(); } if (!file_exists($filePath)) { dLog('deepseek')->warning('图片文件不存在', ['path' => $filePath]); return null; } // 验证是否为图片文件 $imageInfo = @getimagesize($filePath); if ($imageInfo === false) { dLog('deepseek')->warning('无效的图片文件', ['path' => $filePath]); return null; } // 读取图片并转换为base64 $imageData = file_get_contents($filePath); $base64Image = base64_encode($imageData); dLog('deepseek')->info('图片转换成功', [ 'size' => strlen($imageData), 'type' => $imageInfo['mime'] ]); return $base64Image; } catch (\Exception $e) { dLog('deepseek')->error('图片处理失败: ' . $e->getMessage()); return null; } } private function getArtStylePromptMappings(): array { return [ '日系动漫风格' => [ 'aliases' => ['日系动漫风格', '日系动漫', '日系', '动漫', '日漫'], 'prompt' => "日系动漫风格,线条干净流畅,色彩明亮柔和,人物精致,光影细腻,画面统一稳定" ], '国漫风格' => [ 'aliases' => ['国漫风格', '国漫'], 'prompt' => "国漫风格,线条硬朗,人物成熟大气,色彩浓郁,光影对比强烈,整体画风统一,适合漫剧叙事" ], 'Q版卡通风格' => [ 'aliases' => ['Q版卡通风格', 'Q版卡通', 'Q版', '卡通'], 'prompt' => "Q版卡通风格,头大身小造型可爱,线条简洁,色彩明快,无复杂阴影,整体萌系干净统一" ], '简约扁平风格' => [ 'aliases' => ['简约扁平风格', '简约扁平', '扁平', '简约'], 'prompt' => "简约扁平风格,大块纯色无渐变,线条简洁现代,画面干净清爽,不出现复杂纹理与光影" ], '古风仙侠风格' => [ 'aliases' => ['古风仙侠', '古风', '仙侠'], 'prompt' => "古风仙侠风格,汉服飘逸,云雾缭绕仙气空灵,色调清雅,光影柔和,国风氛围统一" ], '武侠风格' => [ 'aliases' => ['武侠风格', '武侠'], 'prompt' => "武侠风格,中式古装,线条干练硬朗,江湖氛围浓厚,动态自然,色调沉稳大气" ], '新中式水墨风格' => [ 'aliases' => ['新中式水墨风格', '新中式', '水墨'], 'prompt' => "新中式水墨风格,淡墨渲染笔触自然,留白有意境,色调清雅,国风质感统一不杂乱" ], '写实插画风格' => [ 'aliases' => ['写实插画风格', '写实插画', '写实', '插画'], 'prompt' => "写实插画风格,真实人体比例,光影自然细腻,画面质感真实,不卡通不崩坏" ], '3D卡通风格' => [ 'aliases' => ['3D卡通风格', '3D卡通'], 'prompt' => "3D卡通风格,立体建模质感柔和,造型圆润光滑,光影干净,整体风格统一不穿模" ], '条漫风格' => [ 'aliases' => ['条漫风格', '条漫'], 'prompt' => "条漫风格,纵向分镜简洁,色彩清淡,适合上下滑动阅读,画面轻量化且整体统一" ], '赛博朋克风格' => [ 'aliases' => ['赛博朋克风格', '赛博朋克'], 'prompt' => "赛博朋克风格,霓虹灯光未来都市,暗色调科技感强,高对比光影,风格统一强烈" ], '暗黑悬疑风格' => [ 'aliases' => ['暗黑悬疑风格', '暗黑悬疑'], 'prompt' => "暗黑悬疑风格,低饱和冷色调,强阴影光线昏暗,氛围神秘压抑,整体色调统一" ], '治愈清新风格' => [ 'aliases' => ['治愈清新风格', '治愈清新'], 'prompt' => "治愈清新风格,马卡龙柔和色系,柔光暖调,画面干净舒适,氛围温暖不杂乱" ], ]; } public function getArtStylePromptByInput($inputArtStyle): string { $normalizedInput = $this->normalizeArtStyleInput($inputArtStyle); if ($normalizedInput === '') { return ''; } foreach ($this->getArtStylePromptMappings() as $mapping) { foreach ($mapping['aliases'] as $alias) { $normalizedAlias = $this->normalizeArtStyleInput($alias); if ($normalizedAlias === '') { continue; } if (strpos($normalizedInput, $normalizedAlias) !== false || strpos($normalizedAlias, $normalizedInput) !== false) { return $mapping['prompt']; } } } return ''; } private function normalizeArtStyleInput($value): string { $value = trim((string)$value); if ($value === '') { return ''; } $value = str_replace(["\r", "\n", "\t", ' ', ' ', '-', '_', ':', ':', ',', ',', '。', '.'], '', $value); return mb_strtolower($value, 'UTF-8'); } private function replaceArtStyleSection(string $content, string $artStyle): string { if ($content === '' || $artStyle === '') { return $content; } $pattern = '/###美术风格\s*\R(.*?)(?=\R###|$)/su'; if (!preg_match($pattern, $content)) { return $content; } return preg_replace($pattern, "###美术风格\n" . trim($artStyle), $content, 1); } /** * DeepSeek流式输出处理方法 * * @param array $post_data DeepSeek API请求参数 * @return \Generator 返回生成器,用于流式输出 */ private function deepSeekStreamResponse($post_data) { $client = new Client(['timeout' => 1200, 'verify' => false]); $response = $client->post($this->url, [ 'json' => $post_data, 'headers' => $this->headers, 'stream' => true // 启用流式响应 ]); $body = $response->getBody(); $fullContent = ''; $fullReasoningContent = ''; $usage = []; $buffer = ''; dLog('deepseek')->info('开始读取 DeepSeek 流式响应'); // 逐行读取流式响应 while (!$body->eof()) { $chunk = $body->read(1024); // 每次读取 1KB $buffer .= $chunk; // 按行分割 $lines = explode("\n", $buffer); // 保留最后一个不完整的行 $buffer = array_pop($lines); foreach ($lines as $line) { $line = trim($line); // 跳过空行 if (empty($line)) { continue; } // 检查是否是 SSE 数据行 if (strpos($line, 'data: ') !== 0) { dLog('deepseek')->warning('非 SSE 数据行', ['line' => $line]); continue; } $data = substr($line, 6); // 移除 "data: " 前缀 if ($data === '[DONE]') { dLog('deepseek')->info('收到结束标记'); break 2; // 跳出两层循环 } try { $json = json_decode($data, true); if (json_last_error() !== JSON_ERROR_NONE) { dLog('deepseek')->warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]); continue; } if (isset($json['choices'][0]['delta'])) { $delta = $json['choices'][0]['delta']; // 处理思维链内容(仅 deepseek-reasoner 模型) if (isset($delta['reasoning_content'])) { $fullReasoningContent .= $delta['reasoning_content']; yield [ 'type' => 'reasoning', 'content' => $delta['reasoning_content'], 'full_reasoning' => $fullReasoningContent ]; } // 处理最终回答内容 if (isset($delta['content'])) { $fullContent .= $delta['content']; yield [ 'type' => 'content', 'content' => $delta['content'], ]; } } // 获取使用统计信息 if (isset($json['usage'])) { $usage = $json['usage']; dLog('deepseek')->info('收到使用统计', ['usage' => $usage]); } } catch (\Exception $e) { dLog('deepseek')->error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]); continue; } } } dLog('deepseek')->info('流式读取完成', [ 'content_length' => strlen($fullContent), 'reasoning_length' => strlen($fullReasoningContent) ]); yield [ 'type' => 'done', 'full_content' => $fullContent, 'full_reasoning' => $fullReasoningContent, 'usage' => $usage ]; } // 与推理模型对话 public function chatWithReasoner($data) { $content = getProp($data, 'content'); $model = getProp($data, 'model', 'r1'); // 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash $thinkingMode = getProp($data, 'thinking', 'disabled'); // 默认非思考模式 $reasoningEffort = getProp($data, 'reasoning_effort', 'high'); // 默认思考强度 if ($model == 'r1') { $model = 'deepseek-v4-flash'; // r1 映射到 deepseek-v4-flash 思考模式 $thinkingMode = 'disabled'; } else { $model = 'deepseek-v4-flash'; // 默认映射到 deepseek-v4-flash 非思考模式 $thinkingMode = 'disabled'; } // 获取可选情感(根据音色可支持情感选) $timbre_emotion = DB::table('mp_timbres')->where('is_enabled', 1)->pluck('emotion')->toArray(); $emotion_list = []; foreach ($timbre_emotion as $emotion) { $tmp = explode(',', $emotion); $emotion_list = array_merge($emotion_list, $tmp); } $emotion_list = array_unique($emotion_list); $emotion_str = implode('、', $emotion_list); // // 获取可选情感 // $emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->select('emotion_name')->orderBy('id')->get()->pluck('emotion_name')->toArray(); // $emotion_str = implode('、', $emotion_list); // 是否启用情感 $enable_emotion = getProp($data, 'enable_emotion', 0); if ($enable_emotion) { $sys_content = "下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男、女、中性):台词{情感}”输出,需要注意以下几点要求: 1.角色名后不要加入任何其他词语,只能加性别,在男、女或中性中选 2.非对话部分请全部用旁白角色代替,旁白的情感统一选择中性 3.情感必须在【{$emotion_str}】中选一个,不得使用其他词语"; }else { $sys_content = "下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男、女、中性):台词”输出,需要注意以下几点要求: 1.角色名后不要加入任何其他词语,只能加性别,在男、女或中性中选 2.非对话部分请全部用旁白角色代替,旁白的情感统一选择中性"; } $messages = [ [ 'role' => 'system', 'content' => $sys_content ], [ 'role' => 'user', 'content' => $content ] ]; $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => 8192, 'temperature' => 1, 'frequency_penalty' => 0, 'presence_penalty' => 0, 'thinking' => ['type' => $thinkingMode], 'response_format' => [ 'type' => 'text' ], 'stream' => false ]; if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort; // 根据模型类型选择调用方法 $fullContent = ''; $usage = []; if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // DeepSeek 官方模型使用 DeepSeek API $chatResult = $this->chatOnly($post_data); } else { // 其他模型使用火山引擎API $chatResult = $this->volcEngineChatCompletion($post_data); } if (is_array($chatResult)) { $fullContent = $chatResult['fullContent']; $usage = $chatResult['usage']; } // 处理获取到的剧本数据 $script_content = handleScriptWords($fullContent, $enable_emotion); $result = [ 'origin_content' => $fullContent, 'roles' => getProp($script_content, 'roles'), 'words' => getProp($script_content, 'words'), ]; return $result; } public function resetParagraphAudio($data) { $bid = getProp($data, 'bid'); $cid = getProp($data, 'cid'); $version_id = getProp($data, 'version_id'); if (!DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->value('id')) return true; return DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->update([ 'generate_status' => '待制作', 'updated_at' => date('Y-m-d H:i:s') ]); } public function saveParagraphAudio($data) { $bid = getProp($data, 'bid'); $cid = getProp($data, 'cid'); $version_id = getProp($data, 'version_id'); $sequence = getProp($data, 'sequence'); $id = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->where('sequence', $sequence)->value('id'); // 获取所有情感 $emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray(); $emotion_list = array_flip($emotion_list); // 获取音色支持情感 $timbre_emotion = DB::table('mp_timbres')->where('timbre_type', getProp($data, 'voice_type'))->value('emotion'); $timbre_emotion = explode(',', $timbre_emotion); $emotion = getProp($data, 'emotion'); if (!in_array($emotion, $timbre_emotion)) $emotion = '中性'; $emotion_type = isset($emotion_list[$emotion]) ? $emotion_list[$emotion] : 'neutral'; $list = [ 'bid' => $bid, 'cid' => $cid, 'version_id' => $version_id, 'sequence' => $sequence, 'role' => getProp($data, 'role'), 'gender' => getProp($data, 'gender'), 'text' => trim(getProp($data, 'text')), 'emotion' => $emotion, 'emotion_type' => $emotion_type, 'voice_type' => getProp($data, 'voice_type'), 'voice_name' => getProp($data, 'voice_name'), 'speed_ratio' => getProp($data, 'speed_ratio', 0), 'loudness_ratio'=> getProp($data, 'loudness_ratio', 0), 'emotion_scale' => getProp($data, 'emotion_scale', 4), 'pitch' => getProp($data, 'pitch', 0), // 'paragraph_audio_url' => '', 'generate_status' => '制作中', 'error_msg' => '', 'updated_at' => date('Y-m-d H:i:s') ]; $continue = false; // 判断是否含有中文、数字或英文,如果没有则跳过合成 if (!preg_match('/[\x{4e00}-\x{9fa5}]+/u', $list['text']) && !preg_match('/[0-9]+/', $list['text']) && !preg_match('/[a-zA-Z]+/', $list['text'])) { $list['generate_status'] = '制作成功'; $list['paragraph_audio_url'] = 'https://zw-audiobook.tos-cn-beijing.volces.com/effects/ellipses_2s.mp3'; $list['subtitle_info'] = '{"duration": 2.0, "words": []}'; $continue = true; } // if (getProp($data, 'paragraph_audio_url')) $list['paragraph_audio_url'] = getProp($data, 'paragraph_audio_url'); if ($id) { $boolen = DB::table('mp_chapter_paragraph_audios')->where('id', $id)->update($list); }else { $list['created_at'] = date('Y-m-d H:i:s'); $id = DB::table('mp_chapter_paragraph_audios')->insertGetId($list); $boolen = $id ? true : false; } // 如果更新成功则加入查询队列 if ($boolen) { $redis_key = "select-{$bid}-{$version_id}-{$cid}"; Redis::sadd($redis_key, $id); } if ($boolen && !$continue) { $boolen = false; $client = new Client(['timeout' => 300, 'verify' => false]); // 根据ID通过API通知合成音频 // $result = $client->get("http://47.240.171.155:5000/api/previewTask?taskId={$id}"); $result = $client->get("http://122.9.129.83:5000/api/previewTask?taskId={$id}"); $response = $result->getBody()->getContents(); $response_arr = json_decode($response, true); if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) { $error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误'; Log::info('通知火山生成段落音频失败: '.$error_msg); Utils::throwError('20003:通知火山生成段落音频失败'); } $boolen = true; } return $boolen; } public function insertAudioEffect($data) { $audio_id = getProp($data, 'audio_id'); $chapter_audio = DB::table('mp_chapter_audios')->where('id', $audio_id)->first(); $bid = getProp($chapter_audio, 'bid'); $version_id = getProp($chapter_audio, 'version_id'); $cid = getProp($chapter_audio, 'cid'); if (getProp($chapter_audio, 'generate_status') != '制作成功') Utils::throwError('20003:请先完成有声制作'); $audio_effect_json = getProp($data, 'audio_effect_json'); if (!is_string($audio_effect_json)) $audio_effect_json = json_encode($audio_effect_json, 256); try { DB::beginTransaction(); $count = DB::table('mp_audio_tasks')->where('audio_id', $audio_id)->count('id'); if (!$count) { $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】'; }else { $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')'; } $boolen1 = DB::table('mp_chapter_audios')->where('id', $audio_id)->update([ 'audio_effect_status' => '添加中', 'audio_effect_json' => $audio_effect_json, 'updated_at' => date('Y-m-d H:i:s') ]); if (!$boolen1) { DB::rollBack(); Utils::throwError('20003:更新生成数据失败'); } $id = DB::table('mp_audio_tasks')->insertGetId([ 'audio_id' => $audio_id, 'generate_status' => '执行中', 'generate_json' => json_encode([], 256), 'bid' => $bid, 'book_name' => getProp($chapter_audio, 'book_name'), 'version_id' => $version_id, 'version_name' => getProp($chapter_audio, 'version_name'), 'cid' => $cid, 'chapter_name' => getProp($chapter_audio, 'chapter_name'), 'task_name' => $task_name, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); if (!$id) { DB::rollBack(); Utils::throwError('20003:创建任务失败'); } }catch (\Exception $e) { DB::rollBack(); Utils::throwError('20003:'.$e->getMessage()); } DB::commit(); $client = new Client(['timeout' => 300, 'verify' => false]); // 根据ID通过API通知合成音频 // $result = $client->get("http://47.240.171.155:5000/api/postTask?taskId={$id}"); $result = $client->get("http://122.9.129.83:5000/api/postTask?taskId={$id}"); $response = $result->getBody()->getContents(); $response_arr = json_decode($response, true); if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) { $error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误'; Log::info('通知火山插入音效失败: '.$error_msg); Utils::throwError('20003:通知火山插入音效失败'); } return true; } public function insertBgm($data) { $audio_id = getProp($data, 'audio_id'); $chapter_audio = DB::table('mp_chapter_audios')->where('id', $audio_id)->first(); $bid = getProp($chapter_audio, 'bid'); $version_id = getProp($chapter_audio, 'version_id'); $cid = getProp($chapter_audio, 'cid'); if (getProp($chapter_audio, 'generate_status') != '制作成功') Utils::throwError('20003:请先完成有声制作'); $bgm_json = getProp($data, 'bgm_json'); if (!is_string($bgm_json)) $bgm_json = json_encode($bgm_json, 256); if (!$bgm_json) { $bgm_json = getProp($data, 'audio_effect_json'); if (!is_string($bgm_json)) $bgm_json = json_encode($bgm_json, 256); } try { DB::beginTransaction(); $count = DB::table('mp_audio_tasks')->where('audio_id', $audio_id)->count('id'); if (!$count) { $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】'; }else { $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')'; } $boolen1 = DB::table('mp_chapter_audios')->where('id', $audio_id)->update([ 'bgm_status' => '添加中', 'bgm_json' => $bgm_json, 'updated_at' => date('Y-m-d H:i:s') ]); if (!$boolen1) { DB::rollBack(); Utils::throwError('20003:更新生成数据失败'); } $id = DB::table('mp_audio_tasks')->insertGetId([ 'audio_id' => $audio_id, 'generate_status' => '执行中', 'generate_json' => json_encode([], 256), 'bid' => $bid, 'book_name' => getProp($chapter_audio, 'book_name'), 'version_id' => $version_id, 'version_name' => getProp($chapter_audio, 'version_name'), 'cid' => $cid, 'chapter_name' => getProp($chapter_audio, 'chapter_name'), 'task_name' => $task_name, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); if (!$id) { DB::rollBack(); Utils::throwError('20003:创建任务失败'); } }catch (\Exception $e) { DB::rollBack(); Utils::throwError('20003:'.$e->getMessage()); } DB::commit(); $client = new Client(['timeout' => 300, 'verify' => false]); // 根据ID通过API通知合成音频 // $result = $client->get("http://47.240.171.155:5000/api/postTask?taskId={$id}"); $result = $client->get("http://122.9.129.83:5000/api/postTask?taskId={$id}"); $response = $result->getBody()->getContents(); $response_arr = json_decode($response, true); if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) { $error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误'; Log::info('通知火山插入bgm失败: '.$error_msg); Utils::throwError('20003:通知火山插入bgm失败'); } return true; } public function getParagraphAudios($data) { $bid = getProp($data, 'bid'); $cid = getProp($data, 'cid'); $version_id = getProp($data, 'version_id'); $sequence = getProp($data, 'sequence'); // 获取已生成的音频 $query = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id); if ($sequence) $query->where('sequence', $sequence); $paragraph_audios = $query->orderBy('sequence')->get(); $result = []; foreach($paragraph_audios as $item) { $result[] = [ 'sequence' => getProp($item, 'sequence'), 'role' => getProp($item, 'role'), 'gender' => getProp($item, 'gender'), 'text' => trim(getProp($item, 'text')), 'emotion' => getProp($item, 'emotion'), 'emotion_type' => getProp($item, 'emotion_type'), 'voice_type' => getProp($item, 'voice_type'), 'voice_name' => getProp($item, 'voice_name'), 'speed_ratio' => getProp($item, 'speed_ratio'), 'loudness_ratio' => getProp($item, 'loudness_ratio'), 'emotion_scale' => getProp($item, 'emotion_scale'), 'paragraph_audio_url' => getProp($item, 'paragraph_audio_url'), 'subtitle_info' => json_decode(getProp($item, 'subtitle_info'), true), ]; } return $result; } // 新增合成任务 public function addGenerateTask($data) { $bid = getProp($data, 'bid'); $cid = getProp($data, 'cid'); $version_id = getProp($data, 'version_id'); $generate_json = getProp($data, 'generate_json'); // 获取已生成的音频 $paragraph_audios = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->get(); $paragraph_list = []; foreach($paragraph_audios as $item) { $paragraph_list[getProp($item, 'sequence')] = [ 'role' => getProp($item, 'role'), 'gender' => getProp($item, 'gender'), 'text' => trim(getProp($item, 'text')), 'emotion' => getProp($item, 'emotion'), 'emotion_type' => getProp($item, 'emotion_type'), 'voice_type' => getProp($item, 'voice_type'), 'voice_name' => getProp($item, 'voice_name'), 'speed_ratio' => getProp($item, 'speed_ratio'), 'loudness_ratio' => getProp($item, 'loudness_ratio'), 'emotion_scale' => getProp($item, 'emotion_scale'), 'paragraph_audio_url' => getProp($item, 'paragraph_audio_url'), ]; } // 更新角色-音色信息 $existed_role_info = DB::table('mp_book_version')->where('bid', $bid)->where('id', $version_id)->value('role_info'); $existed_role_info = json_decode($existed_role_info, true); if (!$existed_role_info) $existed_role_info = []; // 获取情感信息 $emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray(); $emotion_list = array_flip($emotion_list); // 获取音色对应情感组 $timbre_emotions = DB::table('mp_timbres')->where('is_enabled', 1)->select('timbre_type', 'emotion')->get(); $timbre_emotion_list = []; foreach($timbre_emotions as $item) { $timbre_emotion_list[getProp($item, 'timbre_type')] = explode(',', getProp($item, 'emotion')); } // 构造生成音频的json $words = json_decode($generate_json, true); // 最终合成前的参数组 $mp_chapter_paragraph_audios = []; $sequence = 1; $complete_paragraph_sequences = []; foreach($words as &$word) { if (!isset($word['text']) || !isset($word['emotion']) || !isset($word['voice_type']) || !isset($word['voice_name']) || !isset($word['speed_ratio']) || !isset($word['loudness_ratio']) || !isset($word['emotion_scale'])) Utils::throwError('20003:参数格式有误'); if (!($word['text']) || !($word['voice_type']) || !($word['voice_name'])) Utils::throwError('20003:参数不得为空'); $role = getProp($word, 'role'); $word['gender'] = (int)$word['gender']; // 判断音色对应情感是否支持,不支持则调整为中性 $access_emotion = isset($timbre_emotion_list[$word['voice_type']]) ? $timbre_emotion_list[$word['voice_type']] : []; if (!in_array($word['emotion'], $access_emotion)) $word['emotion'] = '中性'; // 如果有对应情感则赋值,没有则默认为中性(neutral) if (isset($emotion_list[getProp($word, 'emotion')])) { $word['emotion_type'] = $emotion_list[getProp($word, 'emotion')]; }else { $word['emotion'] = '中性'; $word['emotion_type'] = 'neutral'; } $existed_role_info[$role] = [ 'timbre_type' => $word['voice_type'], 'timbre_name' => $word['voice_name'], 'emotion' => $word['emotion'], 'emotion_type' => $word['emotion_type'], 'speed_ratio' => $word['speed_ratio'], 'loudness_ratio'=> $word['loudness_ratio'], 'emotion_scale' => $word['emotion_scale'], 'pitch' => $word['pitch'] ]; $word['paragraph_audio_url'] = ''; // 判断生成参数是否相同,相同则直接使用已生成的音频 $paragraph = isset($paragraph_list[$sequence]) ? $paragraph_list[$sequence] : []; // 如果音频存在并且参数都未改变则使用已生成的音频 if (getProp($paragraph, 'paragraph_audio_url')) { // if (getProp($paragraph, 'role') == getProp($word, 'role') && getProp($paragraph, 'text') == getProp($word, 'text') && // getProp($paragraph, 'voice_type') == getProp($word, 'voice_type') && // getProp($paragraph, 'speed_ratio') == getProp($word, 'speed_ratio') && getProp($paragraph, 'loudness_ratio') == getProp($word, 'loudness_ratio') && // getProp($paragraph, 'emotion_scale') == getProp($word, 'emotion_scale')) // { // $word['paragraph_audio_url'] = getProp($paragraph, 'paragraph_audio_url'); // } $word['paragraph_audio_url'] = getProp($paragraph, 'paragraph_audio_url'); } $tmp = $word; // 组装章节分句音频数据 // $tmp['sequence'] = getProp($word, 'sequence'); // $tmp['text'] = getProp($word, 'text'); // $tmp['emotion'] = getProp($word, 'emotion'); // $tmp['emotion_type'] = getProp($word, 'emotion_type'); // $tmp['voice_name'] = getProp($word, 'voice_name'); // $tmp['voice_type'] = getProp($word, 'voice_type'); // $tmp['speed_ratio'] = getProp($word, 'speed_ratio'); // $tmp['loudness_ratio'] = getProp($word, 'loudness_ratio'); // $tmp['emotion_scale'] = getProp($word, 'emotion_scale'); $tmp['bid'] = $bid; $tmp['version_id'] = $version_id; $tmp['cid'] = $cid; $tmp['sequence'] = $sequence; $tmp['created_at'] = date('Y-m-d H:i:s'); $tmp['updated_at'] = date('Y-m-d H:i:s'); if (!getProp($tmp, 'paragraph_audio_url')) Utils::throwError('20003:段落未生成音频,请等待完成后提交'); $mp_chapter_paragraph_audios[] = $tmp; $complete_paragraph_sequences[] = $sequence; $sequence++; } $generate_json = json_encode($words, 256); // 判断是否有未生成字幕的段落 $no_subtitle_sequences = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->whereIn('sequence', $complete_paragraph_sequences)->whereNull('subtitle_info')->pluck('sequence')->toArray(); if ($no_subtitle_sequences) { Utils::throwError('20003:请先将以下段落序号生成字幕('.implode('、', $no_subtitle_sequences).')'); } try { DB::beginTransaction(); $role_info = json_encode($existed_role_info, 256); $boolen = DB::table('mp_book_version')->where('bid', $bid)->where('id', $version_id)->update(['role_info' => $role_info, 'updated_at' => date('Y-m-d H:i:s')]); if (!$boolen) { DB::rollBack(); Utils::throwError('20003:更新角色信息失败'); } $count = DB::table('mp_audio_tasks')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->count('id'); $chapter_audio = DB::table('mp_chapter_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->first(); if (!$count) { $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】'; }else { $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')'; } $boolen1 = DB::table('mp_chapter_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->update(['generate_status'=>'制作中', 'generate_json' => $generate_json, 'updated_at' => date('Y-m-d H:i:s')]); if (!$boolen1) { DB::rollBack(); Utils::throwError('20003:更新生成数据失败'); } $id = DB::table('mp_audio_tasks')->insertGetId([ 'audio_id' => getProp($chapter_audio, 'id'), 'generate_status' => '执行中', 'generate_json' => $generate_json, 'bid' => $bid, 'book_name' => getProp($chapter_audio, 'book_name'), 'version_id' => $version_id, 'version_name' => getProp($chapter_audio, 'version_name'), 'cid' => $cid, 'chapter_name' => getProp($chapter_audio, 'chapter_name'), 'task_name' => $task_name, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); if (!$id) { DB::rollBack(); Utils::throwError('20003:创建任务失败'); } // 删除不在段落序号范围内的其他数据 if ($complete_paragraph_sequences) { DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->whereNotIn('sequence', $complete_paragraph_sequences)->delete(); } // // 删除章节分句音频数据并重新插入 // DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->delete(); // $boolen3 = DB::table('mp_chapter_paragraph_audios')->insert($mp_chapter_paragraph_audios); // if (!$boolen3) { // DB::rollBack(); // Utils::throwError('20003:更新章节分句音频失败'); // } } catch (\Exception $e) { DB::rollBack(); Utils::throwError('20003:'.$e->getMessage()); } DB::commit(); // 通知火山生成音频 $client = new Client(['timeout' => 300, 'verify' => false]); // 根据ID通过API通知合成音频 // $result = $client->get("http://47.240.171.155:5000/api/chapterTask?taskId={$id}"); $result = $client->get("http://122.9.129.83:5000/api/chapterTask?taskId={$id}"); $response = $result->getBody()->getContents(); $response_arr = json_decode($response, true); if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) { $error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误'; Log::info('通知火山生成音频失败: '.$error_msg); Utils::throwError('20003:通知火山生成音频失败'); } return true; } public function timbreList($data) { $gender = getProp($data, 'gender'); $timbre_name = getProp($data, 'voice_name'); $category_id = getProp($data, 'category_id'); $query = DB::table('mp_timbres')->where('is_enabled', 1)->select('timbre_name as voice_name', 'timbre_type as voice_type', 'gender', 'language', 'emotion', 'label', 'first_category_id', 'first_category_name', 'second_category_id', 'second_category_name', 'third_category_id', 'third_category_name', 'audio_url'); if ($gender) { $query->where('gender', $gender); } if ($timbre_name) { $query->where('timbre_name', 'like', "%{$timbre_name}%"); } if ($category_id) { $query->where(function ($query) use ($category_id) { $query->where('first_category_id', $category_id)->orWhere('second_category_id', $category_id)->orWhere('third_category_id', $category_id); }); } $list = $query->get()->map(function ($value) { $value = (array)$value; $value['voice_name'] = str_replace('(多情感)', '', $value['voice_name']); return $value; })->toArray(); return $list; } // 生成火山临时token public function setStsToken() { // ************* 配置参数 ************* $method = 'GET'; $service = 'sts'; $host = 'open.volcengineapi.com'; $region = env('VOLC_REGION'); $endpoint = 'https://open.volcengineapi.com'; // $endpoint = 'https://tos-cn-beijing.volces.com'; $access_key = env('VOLC_AK'); $secret_key = env('VOLC_SK'); // 获取缓存中的token,如果没有则请求接口 $token = Redis::get('volc_sts_token'); if (!$token) { // 查询参数 $query_parameters = [ 'Action' => 'AssumeRole', 'RoleSessionName' => 'user@zw', 'RoleTrn' => 'trn:iam::2102575520:role/tos_role', 'Version' => '2018-01-01' ]; // 生成URL编码的查询字符串 $request_parameters = http_build_query($query_parameters, '', '&', PHP_QUERY_RFC3986); // 获取签名头信息 $headers = $this->getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key); // 构建完整URL $request_url = $endpoint . '?' . $request_parameters; $client = new Client(['verify' => false]); $response = $client->get($request_url, ['headers' => $headers]); $response_arr = json_decode($response->getBody()->getContents(), true); $result = [ 'SessionToken' => isset($response_arr['Result']['Credentials']['SessionToken']) ? $response_arr['Result']['Credentials']['SessionToken'] : '', 'AccessKeyId' => isset($response_arr['Result']['Credentials']['AccessKeyId']) ? $response_arr['Result']['Credentials']['AccessKeyId'] : '', 'SecretAccessKey' => isset($response_arr['Result']['Credentials']['SecretAccessKey']) ? $response_arr['Result']['Credentials']['SecretAccessKey'] : '', 'Region' => env('VOLC_REGION'), 'Endpoint' => env('VOLC_END_POINT'), 'Bucket' => env('VOLC_BUCKET'), ]; // 缓存token Redis::setex('volc_sts_token', 3000, json_encode($result)); return $result; } else { return json_decode($token, true); } // $response = $response['Response']; // $access_key = $response['Credentials']['AccessKeyId']; // $secret_key = $response['Credentials']['AccessKeySecret']; // $security_token = $response['Credentials']['SecurityToken']; // $expiration = $response['Credentials']['Expiration']; // dd($response_arr); } public function emotionGroups($data) { $id = getProp($data, 'group_id'); $group_name = getProp($data, 'group_name'); $voice_type = getProp($data, 'voice_type'); $query = DB::table('mp_emotion_groups')->where('is_enabled', 1)->select('id as group_id', 'group_name', 'emotion', 'emotion_type', 'voice_name', 'voice_type', 'speed_ratio', 'loudness_ratio', 'emotion_scale', 'pitch', 'generate_status', 'audio_url', 'error_msg'); if ($group_name) { $query->where('group_name', 'like', "%{$group_name}%"); } if ($id) { $query->where('id', $id); } if ($voice_type) { $query->where('voice_type', $voice_type); } return $query->orderBy('id')->get()->map(function ($value) { return (array)$value; })->toArray(); } private function sign($key, $msg) { return hash_hmac('sha256', $msg, $key, true); } // 生成签名密钥 private function getSignatureKey($key, $dateStamp, $regionName, $serviceName) { $kDate = $this->sign($key, $dateStamp); $kRegion = $this->sign($kDate, $regionName); $kService = $this->sign($kRegion, $serviceName); $kSigning = $this->sign($kService, 'request'); return $kSigning; } // 获取签名头信息 private function getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key) { $contenttype = 'application/x-www-form-urlencoded'; $accept = 'application/json'; // 获取当前UTC时间 $t = new DateTime('now', new DateTimeZone('UTC')); $xdate = $t->format('Ymd\THis\Z'); $datestamp = $t->format('Ymd'); // 1. 拼接规范请求串 $canonical_uri = '/'; $canonical_querystring = $request_parameters; $canonical_headers = "content-type:{$contenttype}\nhost:{$host}\nx-date:{$xdate}\n"; $signed_headers = 'content-type;host;x-date'; // 空请求体的SHA256哈希 $payload_hash = hash('sha256', ''); $canonical_request = implode("\n", [ $method, $canonical_uri, $canonical_querystring, $canonical_headers, $signed_headers, $payload_hash ]); // 2. 拼接待签名字符串 $algorithm = 'HMAC-SHA256'; $credential_scope = implode('/', [$datestamp, $region, $service, 'request']); $hashed_canonical_request = hash('sha256', $canonical_request); $string_to_sign = implode("\n", [ $algorithm, $xdate, $credential_scope, $hashed_canonical_request ]); // 3. 计算签名 $signing_key = $this->getSignatureKey($secret_key, $datestamp, $region, $service); $signature = hash_hmac('sha256', $string_to_sign, $signing_key); // 4. 添加签名到请求头 $authorization_header = sprintf( '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s', $algorithm, $access_key, $credential_scope, $signed_headers, $signature ); return [ 'Accept' => $accept, 'Content-Type' => $contenttype, 'X-Date' => $xdate, 'Authorization' => $authorization_header ]; } // 文字合成语音(火山引擎) public function tts($data) { $url = 'https://openspeech.bytedance.com/api/v1/tts'; $headers = [ 'Authorization' => 'Bearer;'.env('VOLC_TOKEN'), 'Content-Type' => 'application/json; charset=UTF-8' ]; $post_data = [ 'app' => [ 'appid' => env('VOLC_APPID'), 'token' => env('VOLC_TOKEN'), 'cluster' => 'volcano_tts' ], 'user' => [ 'uid' => 'mp_audio' ], // 'audio' => [ // 'voice_type' => // ], ]; } public function scriptList($data) { $script_id = getProp($data, 'script_id'); $script_name = getProp($data, 'script_name'); $query = DB::table('mp_scripts')->where('is_deleted', 0)->select('*'); if ($script_id) { $query->where('id', $script_id); } if ($script_name) { $query->where('script_name', 'like', "%{$script_name}%"); } return $query->orderBy('created_at', 'desc')->paginate(12); } public function scripts($data) { $script_id = getProp($data, 'script_id'); $script_name = getProp($data, 'script_name'); $query = DB::table('mp_scripts')->where('is_deleted', 0)->select('id as script_id', 'script_name'); if ($script_id) { $query->where('id', $script_id); } if ($script_name) { $query->where('script_name', 'like', "%{$script_name}%"); } return $query->orderBy('created_at', 'desc')->get()->map(function ($value) { return (array)$value; })->toArray(); } public function scriptInfo($data) { $script_id = getProp($data, 'script_id'); $script = DB::table('mp_scripts')->where('id', $script_id)->where('is_deleted', 0) ->selectRaw('id as script_id, script_name, intro, highlights, roles, role_relationship, core_contradiction, art_style, scenes, status, start_episode_sequence, end_episode_sequence, episode_num, remark, created_at')->first(); if (!$script) Utils::throwError('20003:该剧本不存在!'); $script = (array)$script; $script['roles'] = json_decode($script['roles']); $script['scenes'] = json_decode($script['scenes']); // 获取分集信息 $episodes = DB::table('mp_script_episodes')->where('script_id', $script_id)->orderBy('episode_number') ->select('id', 'episode_number', 'title', 'content', 'segment_number', 'segment_content') ->orderBy('episode_number')->orderBy('segment_number')->get()->map(function ($value) { return (array)$value; })->toArray(); $return_episodes = []; foreach($episodes as $episode) { $episode_number = getProp($episode, 'episode_number'); if (isset($return_episodes[$episode_number])) { $return_episodes[$episode_number]['segments'][] = [ 'segment_number' => getProp($episode, 'segment_number'), 'segment_content' => getProp($episode, 'segment_content') ]; }else { $return_episodes[$episode_number] = [ 'episode_number' => $episode_number, 'title' => getProp($episode, 'title'), 'segments' => [ [ 'segment_number' => getProp($episode, 'segment_number'), 'segment_content' => getProp($episode, 'segment_content') ] ] ]; } } $script['episodes'] = array_values($return_episodes); return $script; } public function createScript($data) { $script_name = getProp($data, 'script_name'); $uid = Site::getUid(); // 获取当前用户ID if (!$script_name) Utils::throwError('20001:剧本名称不能为空'); return DB::table('mp_scripts')->insertGetId([ 'script_name' => $script_name, 'user_id' => $uid, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); } /** * 上传文件识别文字内容并与 DeepSeek 进行对话(流式输出版本) * * @param array $data 包含以下参数: * - file: 上传的文件(UploadedFile 对象)或文件路径 * - question: 用户问题(可选) * - model: 使用的模型(r1 或 v3,默认 v3) * @return \Generator 返回生成器,用于流式输出 */ public function chatWithFileStream($data) { $script_id = getProp($data, 'script_id'); $file = getProp($data, 'file'); $content = getProp($data, 'content', ''); $bid = getProp($data, 'bid', 0); $start_episode_sequence = getProp($data, 'start_episode_sequence', 1); $total_episode_num = getProp($data, 'total_episode_num', 30); $prompt = getProp($data, 'prompt'); $question = getProp($data, 'question', "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.要求一定要给出我需要的分镜集数,不能少7.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。\n本次修改要求如下: {$prompt}\n"); $model = getProp($data, 'model'); if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->value('id')) { Utils::throwError('20003:该模型不存在!'); } // 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash $thinkingMode = getProp($data, 'thinking', 'disabled'); // 默认非思考模式 $reasoningEffort = getProp($data, 'reasoning_effort', 'high'); // 默认思考强度 if ($model === 'deepseek-chat') { // deepseek-chat 映射到 deepseek-v4-flash,非思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'disabled'; } elseif ($model === 'deepseek-reasoner') { // deepseek-reasoner 映射到 deepseek-v4-flash,思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'enabled'; } $script = DB::table('mp_scripts')->where('id', $script_id)->first(); if (!$script) { Utils::throwError('20003:剧本不存在'); } if (!$file && !$content && !$bid) { Utils::throwError('20003:请上传文件或选择书籍或输入内容'); } // 提取文件内容 if ($file) { $content = $this->extractFileContent($file); if (!$content) { Utils::throwError('20003:无法提取文件内容,请检查文件格式'); } } elseif ($bid) { $content = $this->getContentByBid($bid); if (!$content) { Utils::throwError('20003:无法获取书籍内容,请检查书籍'); } } elseif ($content) { $content = filterContent($content); } elseif ($prompt) { $content = filterContent($prompt); } else { Utils::throwError('20003:请上传文件或选择书籍或输入内容'); } // 构建消息 $messages = [ $this->sys_message, [ 'role' => 'user', 'content' => "以下是文档内容:\n\n{$content}\n\n用户问题:{$question}" ] ]; $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => 8192, 'temperature' => 0.7, 'frequency_penalty' => 0, 'presence_penalty' => 0, 'thinking' => ['type' => $thinkingMode], 'response_format' => ['type' => 'text'], 'stream' => true ]; if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort; // 根据模型类型选择调用方法 $fullContent = ''; $fullReasoningContent = ''; $usage = []; if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // DeepSeek 官方模型使用 DeepSeek API $streamGenerator = $this->deepSeekStreamResponse($post_data); } else { // 其他模型使用火山引擎API $streamGenerator = $this->volcEngineChatCompletion($post_data); } // 处理流式输出 foreach ($streamGenerator as $chunk) { if (isset($chunk['type'])) { if ($chunk['type'] === 'done') { // 最终结果 $fullContent = $chunk['full_content']; $fullReasoningContent = $chunk['full_reasoning']; $usage = isset($chunk['usage']) ? $chunk['usage'] : []; } else { // 逐块yield数据 yield $chunk; } } } dLog('deepseek')->info('完整内容: '.$fullContent); // $client = new Client(['timeout' => 1200, 'verify' => false]); // $response = $client->post($this->url, [ // 'json' => $post_data, // 'headers' => $this->headers, // 'stream' => true // 启用流式响应 // ]); // $body = $response->getBody(); // $fullContent = ''; // $fullReasoningContent = ''; // $usage = []; // $buffer = ''; // Log::info('开始读取 DeepSeek 流式响应'); // // 逐行读取流式响应 // while (!$body->eof()) { // $chunk = $body->read(1024); // 每次读取 1KB // $buffer .= $chunk; // // 按行分割 // $lines = explode("\n", $buffer); // // 保留最后一个不完整的行 // $buffer = array_pop($lines); // foreach ($lines as $line) { // $line = trim($line); // // 跳过空行 // if (empty($line)) { // continue; // } // // Log::info('收到原始行数据', ['line' => $line]); // // 检查是否是 SSE 数据行 // if (strpos($line, 'data: ') !== 0) { // Log::warning('非 SSE 数据行', ['line' => $line]); // continue; // } // $data = substr($line, 6); // 移除 "data: " 前缀 // if ($data === '[DONE]') { // Log::info('收到结束标记'); // break 2; // 跳出两层循环 // } // try { // $json = json_decode($data, true); // if (json_last_error() !== JSON_ERROR_NONE) { // Log::warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]); // continue; // } // // Log::info('成功解析 JSON', ['has_choices' => isset($json['choices'])]); // if (isset($json['choices'][0]['delta'])) { // $delta = $json['choices'][0]['delta']; // // 处理思维链内容(仅 deepseek-reasoner 模型) // // 注释掉以下内容则不返回d思维链内容 // if (isset($delta['reasoning_content'])) { // $fullReasoningContent .= $delta['reasoning_content']; // // Log::info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]); // yield [ // 'type' => 'reasoning', // 'content' => $delta['reasoning_content'], // 'full_reasoning' => $fullReasoningContent // ]; // } // // 处理最终回答内容 // if (isset($delta['content'])) { // $fullContent .= $delta['content']; // // Log::info('输出回答内容', ['length' => strlen($delta['content'])]); // yield [ // 'type' => 'content', // 'content' => $delta['content'], // // 'full_content' => $fullContent // ]; // } // } // // 获取使用统计信息 // if (isset($json['usage'])) { // $usage = $json['usage']; // Log::info('收到使用统计', ['usage' => $usage]); // } // } catch (\Exception $e) { // Log::error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]); // continue; // } // } // } // Log::info('流式读取完成', [ // 'content_length' => strlen($fullContent), // 'reasoning_length' => strlen($fullReasoningContent) // ]); // 处理完整内容并返回最终结果 $script_arr = []; if ($fullContent) { $script_arr = handleScriptContent($fullContent); } // 返回前保存剧本内容 DB::table('mp_scripts')->where('id', $script_id)->update([ 'content' => $content, 'updated_at' => date('Y-m-d H:i:s') ]); yield [ 'type' => 'done', 'script' => $script_arr, 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage ]; } /** * 上传文件识别文字内容并与 DeepSeek 进行对话 * * @param array $data 包含以下参数: * - file: 上传的文件(UploadedFile 对象)或文件路径 * - question: 用户问题(可选,默认为"请总结这个文件的内容") * - model: 使用的模型(r1 或 v3,默认 v3) * @return array */ public function chatWithFile($data) { $script_id = getProp($data, 'script_id'); $file = getProp($data, 'file'); $content = getProp($data, 'content', ''); $bid = getProp($data, 'bid', 0); $start_episode_sequence = getProp($data, 'start_episode_sequence', 1); $total_episode_num = getProp($data, 'total_episode_num', 30); $prompt = getProp($data, 'prompt'); $question = getProp($data, 'question', "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.要求一定要给出我需要的分镜集数,不能少7.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。\n本次修改要求如下: {$prompt}\n"); // 处理文本模型 $model = getProp($data, 'model'); if (!$model) { // 用户没有输入,使用默认值 $model = 'doubao-seed-2-0-mini-260215'; } // 验证模型是否在可用模型表中 if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) { $model = 'doubao-seed-2-0-mini-260215'; } // 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash $thinkingMode = getProp($data, 'thinking', 'disabled'); // 默认非思考模式 $reasoningEffort = getProp($data, 'reasoning_effort', 'high'); // 默认思考强度 if ($model === 'deepseek-chat') { // deepseek-chat 映射到 deepseek-v4-flash,非思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'disabled'; } elseif ($model === 'deepseek-reasoner') { // deepseek-reasoner 映射到 deepseek-v4-flash,思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'enabled'; } $script = DB::table('mp_scripts')->where('id', $script_id)->first(); if (!$script) { Utils::throwError('20003:剧本不存在'); } if (!$file && !$content && !$bid) { Utils::throwError('20003:请上传文件或选择书籍或输入内容'); } // 提取文件内容 if ($file) { $content = $this->extractFileContent($file); if (!$content) { Utils::throwError('20003:无法提取文件内容,请检查文件格式'); } } elseif ($bid) { $content = $this->getContentByBid($bid); if (!$content) { Utils::throwError('20003:无法获取书籍内容,请检查书籍'); } } elseif ($content) { $content = filterContent($content); } elseif ($prompt) { $content = filterContent($prompt); } else { Utils::throwError('20003:请上传文件或选择书籍或输入内容'); } // 构建消息 $messages = [ $this->sys_message, [ 'role' => 'user', 'content' => "以下是文档内容:\n\n{$content}\n\n用户问题:{$question}" ] ]; $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => 8192, 'temperature' => 0.7, 'frequency_penalty' => 0, 'presence_penalty' => 0, 'thinking' => ['type' => $thinkingMode], 'response_format' => ['type' => 'text'], 'stream' => false ]; if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort; // 根据模型类型选择调用方法 $fullContent = ''; $usage = []; if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // DeepSeek 官方模型使用 DeepSeek API $chatResult = $this->chatOnly($post_data); } else { // 其他模型使用火山引擎API $chatResult = $this->volcEngineChatCompletion($post_data); } if (is_array($chatResult)) { $fullContent = $chatResult['fullContent']; $usage = $chatResult['usage']; } $script_arr = []; // 处理结果 if ($fullContent) { $script_arr = handleScriptContent($fullContent); } // 返回前保存剧本内容 DB::table('mp_scripts')->where('id', $script_id)->update([ 'content' => $content, 'updated_at' => date('Y-m-d H:i:s') ]); return [ 'script' => $script_arr, 'answer' => $fullContent, 'usage' => $usage ]; } public function saveScript($data) { $script_arr =getProp($data, 'script', []); if (!$script_arr) Utils::throwError('20003:剧本内容不能为空'); $start_episode_sequence = getProp($data, 'start_episode_sequence', 1); $script_id = getProp($data, 'script_id'); if (!$script_id) Utils::throwError('20003:剧本ID不能为空'); try { DB::beginTransaction(); $update_data = [ 'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '', 'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '', 'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '', 'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '', 'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '', 'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '', 'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '', 'status' => 3, 'start_episode_sequence' => $start_episode_sequence, 'end_episode_sequence' => $start_episode_sequence + count($script_arr['episodes']) - 1, 'episode_num' => count($script_arr['episodes']), 'updated_at' => date('Y-m-d H:i:s') ]; // 保存剧本内容 $boolen = DB::table('mp_scripts')->where('id', $script_id)->update($update_data); if (!$boolen) { dLog('deepseek')->info('剧本内容保存失败', $update_data); logDB('deepseek', 'error', '剧本内容保存失败', ['script_id' => $script_id]); Utils::throwError('20003:剧本内容保存失败'); } // 保存分集内容 // 删除历史分集内容 $boolen1 = DB::table('mp_script_episodes')->where('script_id', $script_id)->delete(); $episodes = []; $sequence = 1; foreach ($script_arr['episodes'] as $episode) { $segment_number = 1; foreach($episode['segments'] as $segment) { $episodes[] = [ 'script_id' => $script_id, 'episode_number' => $episode['episode_number'], 'title' => $episode['title'], // 'content' => $episode['content'], 'content' => '', 'segment_number' => $segment_number, 'segment_content' => $segment['segment_content'], 'sequence' => $sequence, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]; $sequence++; $segment_number++; } } $boolen2 = DB::table('mp_script_episodes')->insert($episodes); if (!$boolen2) { dLog('deepseek')->info('分集内容保存失败', $episodes); logDB('deepseek', 'error', '分集内容保存失败', ['episodes_count' => count($episodes)]); Utils::throwError('20003:分集内容保存失败'); } } catch (\Exception $e) { DB::rollBack(); Utils::throwError('20003:'.$e->getMessage()); } DB::commit(); return true; } public function exportScript($data) { $script = $this->scriptInfo($data); $export_type = getProp($data, 'export_type', 'txt'); // 生成文件名 $filename = $script['script_name'] . '_' . date('YmdHis'); // 根据导出类型生成不同格式的文件 switch ($export_type) { case 'txt': return $this->exportScriptAsTxt($script, $filename); case 'pdf': return $this->exportScriptAsPdf($script, $filename); default: Utils::throwError('20003:不支持的导出格式,支持:txt、pdf'); } } /** * 导出剧本为TXT格式 */ private function exportScriptAsTxt($script, $filename) { // 构建TXT内容 $content = $this->buildScriptContent($script); // 设置响应头,直接下载 header('Content-Type: text/plain; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '.txt"'); header('Content-Length: ' . strlen($content)); // 输出内容并结束 echo $content; exit(); } /** * 导出剧本为PDF格式 */ private function exportScriptAsPdf($script, $filename) { // 使用HTML转PDF的方式来更好地支持中文 $htmlContent = $this->buildScriptHtmlForPdf($script); // 创建PDF实例 $pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false); // 设置文档信息 $pdf->SetCreator('剧本导出系统'); $pdf->SetAuthor('系统'); $pdf->SetTitle($script['script_name']); $pdf->SetSubject('剧本导出'); // 设置边距 $pdf->SetMargins(15, 15, 15); $pdf->SetAutoPageBreak(TRUE, 15); // 添加页面 $pdf->AddPage(); // 注册并使用 simsun.ttf 字体 $pdf->SetFont('simsun', '', 12); // 设置字体和大小 // 使用HTML内容生成PDF $pdf->writeHTML($htmlContent, true, false, true, false, ''); // 直接输出PDF文件供下载 header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="' . $filename . '.pdf"'); // 直接输出PDF内容 $pdf->Output($filename . '.pdf', 'D'); exit(); } /** * 构建用于PDF的HTML内容 */ private function buildScriptHtmlForPdf($script) { $html = ''; // 标题 $html .= '

' . htmlspecialchars($script['script_name']) . '

'; // 剧本简介 if (!empty($script['intro'])) { $html .= '

剧本简介

'; $html .= '

' . nl2br(htmlspecialchars($script['intro'])) . '

'; } // 亮点 if (!empty($script['highlights'])) { $html .= '

亮点

'; $html .= '

' . nl2br(htmlspecialchars($script['highlights'])) . '

'; } // 核心矛盾 if (!empty($script['core_contradiction'])) { $html .= '

核心矛盾

'; $html .= '

' . nl2br(htmlspecialchars($script['core_contradiction'])) . '

'; } // 艺术风格 if (!empty($script['art_style'])) { $html .= '

艺术风格

'; $html .= '

' . nl2br(htmlspecialchars($script['art_style'])) . '

'; } // 角色列表 if (!empty($script['roles']) && is_array($script['roles'])) { $html .= '

角色列表

'; $html .= ''; } // 角色关系 if (!empty($script['role_relationship'])) { $html .= '

角色关系

'; $html .= '

' . nl2br(htmlspecialchars($script['role_relationship'])) . '

'; } // 场景列表 if (!empty($script['scenes']) && is_array($script['scenes'])) { $html .= '

场景列表

'; $html .= ''; } // 分集内容 if (!empty($script['episodes']) && is_array($script['episodes'])) { $html .= '

分集内容

'; foreach ($script['episodes'] as $episode) { $html .= '
'; $html .= '

第' . htmlspecialchars($episode['episode_number']) . '集'; if (!empty($episode['title'])) { $html .= ':' . htmlspecialchars($episode['title']); } $html .= '

'; // 处理分段内容 if (!empty($episode['segments']) && is_array($episode['segments'])) { foreach ($episode['segments'] as $segment) { if (!empty($segment['segment_content'])) { $html .= '
'; if (!empty($segment['segment_number'])) { $html .= '第' . htmlspecialchars($segment['segment_number']) . '段
'; } $html .= nl2br(htmlspecialchars($segment['segment_content'])); $html .= '
'; } } } $html .= '
'; } } return $html; } /** * 构建PDF内容 */ private function buildPdfContent($pdf, $script) { // 标题 $pdf->SetFont('dejavusans', 'B', 18); $pdf->Cell(0, 15, $script['script_name'], 0, 1, 'C'); $pdf->Ln(5); // 基本信息 $pdf->SetFont('dejavusans', '', 12); $pdf->Cell(0, 8, '创建时间:' . $script['created_at'], 0, 1); $pdf->Cell(0, 8, '状态:' . $script['status_info'], 0, 1); $pdf->Ln(5); // 剧本简介 if (!empty($script['intro'])) { $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 10, '剧本简介', 0, 1); $pdf->SetFont('dejavusans', '', 12); $pdf->MultiCell(0, 8, $script['intro'], 0, 'L'); $pdf->Ln(3); } // 亮点 if (!empty($script['highlights'])) { $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 10, '亮点', 0, 1); $pdf->SetFont('dejavusans', '', 12); $pdf->MultiCell(0, 8, $script['highlights'], 0, 'L'); $pdf->Ln(3); } // 核心矛盾 if (!empty($script['core_contradiction'])) { $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 10, '核心矛盾', 0, 1); $pdf->SetFont('dejavusans', '', 12); $pdf->MultiCell(0, 8, $script['core_contradiction'], 0, 'L'); $pdf->Ln(3); } // 艺术风格 if (!empty($script['art_style'])) { $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 10, '艺术风格', 0, 1); $pdf->SetFont('dejavusans', '', 12); $pdf->MultiCell(0, 8, $script['art_style'], 0, 'L'); $pdf->Ln(3); } // 备注 if (!empty($script['remark'])) { $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 10, '备注', 0, 1); $pdf->SetFont('dejavusans', '', 12); $pdf->MultiCell(0, 8, $script['remark'], 0, 'L'); $pdf->Ln(3); } // 角色列表 if (!empty($script['roles']) && is_array($script['roles'])) { $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 10, '角色列表', 0, 1); $pdf->SetFont('dejavusans', '', 12); foreach ($script['roles'] as $index => $role) { if (is_array($role) || is_object($role)) { $pdf->Cell(0, 8, ($index + 1) . '. ' . getProp($role, 'role', '') . ':' . getProp($role, 'description', ''), 0, 1); } else { $pdf->Cell(0, 8, ($index + 1) . '. ' . $role, 0, 1); } } $pdf->Ln(3); } // 角色关系 if (!empty($script['role_relationship'])) { $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 10, '角色关系', 0, 1); $pdf->SetFont('dejavusans', '', 12); $pdf->MultiCell(0, 8, $script['role_relationship'], 0, 'L'); $pdf->Ln(3); } // 场景列表 if (!empty($script['scenes']) && is_array($script['scenes'])) { $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 10, '场景列表', 0, 1); $pdf->SetFont('dejavusans', '', 12); foreach ($script['scenes'] as $index => $scene) { if (is_array($scene) || is_object($scene)) { $pdf->Cell(0, 8, ($index + 1) . '. ' . getProp($scene, 'scene', '') . ':' . getProp($scene, 'description', ''), 0, 1); } else { $pdf->Cell(0, 8, ($index + 1) . '. ' . $scene, 0, 1); } } $pdf->Ln(5); } // 分集内容 if (!empty($script['episodes']) && is_array($script['episodes'])) { $pdf->SetFont('dejavusans', 'B', 16); $pdf->Cell(0, 12, '分集内容', 0, 1); $pdf->Ln(3); foreach ($script['episodes'] as $episode) { // 检查是否需要新页面 if ($pdf->GetY() > 250) { $pdf->AddPage(); } $pdf->SetFont('dejavusans', 'B', 14); $episodeTitle = '第' . $episode['episode_number'] . '集'; if (!empty($episode['title'])) { $episodeTitle .= ':' . $episode['title']; } $pdf->Cell(0, 10, $episodeTitle, 0, 1); // 处理分段内容 if (!empty($episode['segments']) && is_array($episode['segments'])) { $pdf->SetFont('dejavusans', '', 12); foreach ($episode['segments'] as $segment) { if (!empty($segment['segment_content'])) { // 如果有分段编号,显示分段标题 if (!empty($segment['segment_number'])) { $pdf->SetFont('dejavusans', 'B', 12); $pdf->Cell(0, 8, '第' . $segment['segment_number'] . '段', 0, 1); $pdf->SetFont('dejavusans', '', 12); } $pdf->MultiCell(0, 8, $segment['segment_content'], 0, 'L'); $pdf->Ln(2); } } } $pdf->Ln(5); } } } /** * 构建剧本文本内容 */ private function buildScriptContent($script) { $content = ''; // 剧本基本信息 if (!empty($script['intro'])) { $content .= "###故事梗概\n" . $script['intro'] . "\n\n"; } if (!empty($script['highlights'])) { $content .= "###剧本亮点\n" . $script['highlights'] . "\n\n"; } if (!empty($script['role_relationship'])) { $content .= "###人物关系\n" . $script['role_relationship'] . "\n\n"; } if (!empty($script['core_contradiction'])) { $content .= "###核心矛盾\n" . $script['core_contradiction'] . "\n\n"; } if (!empty($script['roles']) && is_array($script['roles'])) { $content .= "###主体列表\n"; foreach ($script['roles'] as $role) { $content .= getProp($role, 'role') . ":" . getProp($role, 'description') . "\n"; } $content .= "\n\n"; } if (!empty($script['art_style'])) { $content .= "###美术风格\n" . $script['art_style'] . "\n\n"; } // 场景信息 if (!empty($script['scenes']) && is_array($script['scenes'])) { $content .= "###场景列表\n"; foreach ($script['scenes'] as $scene) { $content .= getProp($scene, 'scene') . ":" . getProp($scene, 'description') . "\n"; } $content .= "\n\n"; } // 分集内容 if (!empty($script['episodes']) && is_array($script['episodes'])) { $content .= "###分集剧本\n"; foreach ($script['episodes'] as $episode) { $content .= "##分集" . $episode['episode_number'] . "\n分集名"; if (!empty($episode['title'])) { $content .= ":" . $episode['title']; } $content .= "\n"; // 处理分镜 if (!empty($episode['segments']) && is_array($episode['segments'])) { foreach ($episode['segments'] as $segment) { $content .= "分镜" . $episode['episode_number'] . '-' . $segment['segment_number'] . "\n"; $content .= $segment['segment_content'] . "\n\n"; } } } } return $content; } /** * 上传文件识别文字内容并与 DeepSeek 进行对话(流式输出版本) * * @param array $data 包含以下参数: * - file: 上传的文件(UploadedFile 对象)或文件路径 * - question: 用户问题(可选) * - model: 使用的模型(r1 或 v3,默认 v3) * @return \Generator 返回生成器,用于流式输出 */ public function addChat($data) { $uid = Site::getUid(); $anime_id = getProp($data, 'anime_id'); if (!$anime_id) Utils::throwError('20003:请选择对话'); $anime = DB::table('mp_animes')->where('id', $anime_id)->where('is_deleted', 0)->first(); if (!$anime) Utils::throwError('20003:对话不存在'); $file = getProp($data, 'file'); $content = getProp($data, 'content', ''); $bid = getProp($data, 'bid', 0); $script_id = getProp($data, 'script_id', 0); $prompt = getProp($data, 'prompt'); // 处理文本模型 $model = getProp($data, 'model'); if (!$model) { // 用户没有输入,从anime表获取 $model = getProp($anime, 'model'); if (!$model) { // anime表也没有,使用默认值 $model = 'doubao-seed-2-0-mini-260215'; } } // 验证模型是否在可用模型表中 if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) { $model = 'doubao-seed-2-0-mini-260215'; } $is_multi = getProp($anime, 'is_multi'); $is_single = (int)$is_multi !== 1; $question = getProp($data, 'question', $is_single ? "请根据实例格式完成单剧集剧本及分镜\n本次修改要求如下:\n{$prompt}" : "请根据实例格式完成剧本大纲\n本次修改要求如下:\n{$prompt}"); // if (!$file && !$content && !$bid) { // Utils::throwError('20003:请上传文件或选择书籍或输入内容'); // } // 提取文件内容 if ($file) { $content = $this->extractFileContent($file); if (!$content) { Utils::throwError('20003:无法提取文件内容,请检查文件格式'); } } elseif ($script_id) { $content = $this->getContentByScriptId($script_id); if (!$content) { Utils::throwError('20003:无法获取剧本内容,请检查剧本'); } } elseif ($bid) { $content = $this->getContentByBid($bid); if (!$content) { Utils::throwError('20003:无法获取书籍内容,请检查书籍'); } } elseif ($content) { $content = filterContent($content); } else { $content = getProp($anime, 'content'); if (!$content && $prompt) { // 如果动漫记录中没有内容则根据提示词生成 $need_generate_content = true; } } // 美术风格 $input_art_style = getProp($data, 'art_style'); $timbres_content = "青年男:北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年 青年女:柔美女友、魅力女友、爽快思思、高冷御姐、知性温婉、甜心小美、清新女声、甜美桃子、温柔文雅、甜美小源、贴心闺蜜 中年男:胡子叔叔、油腻大叔、神秘法师、优柔帮主 中年女:邻居阿姨 老年男:幽默大爷 老年女:婆婆 萌娃:奶气萌娃"; // 判断是否需要生成原文内容 $need_generate_content = isset($need_generate_content) && $need_generate_content; // 如果需要生成原文内容,添加额外的要求 $content_generation_requirement = $need_generate_content ? "\n5.如果用户只提供了创作要求而没有提供具体文档内容,你需要根据要求创作完整的原文内容(小说形式,包含对话和叙述),并将原文内容放在最后一个板块###原文内容中输出。原文内容应该详细、生动,如果用户没有明确生成的字数,则字数不少于1000字。" : ""; $systemPrompt = $is_single ? "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容输出单剧集完整策划稿。单剧集模式下,必须一次性输出可直接保存的大纲与完整分镜,不允许只输出大纲。 强制要求: 1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出 2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答 3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等 4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement} 5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n 普通要求: 1.剧集标题必须与文档内容高度相关,剧集序号是1。 2.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如角色-少年、角色-青年、角色-中年、角色-老年这种情况,不得出现角色-愤怒、角色-隐忍、角色-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细 2.1<主体列表>的音色必须在以下音色中($timbres_content)选择,需要角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。 3.2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述 4.<分镜剧本>要求包含“分幕标题”以及“分镜信息”: 4.1“分幕标题”是根据章节中的场景进行拆分,类似于“第1幕 场景、时间及室内或室外”的信息,其中场景只能在所给出的场景列表中选(不带场景详细描述)。 4.2“分镜信息”需同一场景内的对话内容完成分镜(每一幕分镜尽量不得低于5个),需包含“画面描述”、“构图设计”、“运镜调度”、“配音角色”、“台词内容”、“画面类型”、“尾帧描述”、“情感”、“性别”、“语速”、“音量”、“情感强度”、“音调”这几个部分,其中“画面描述”如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)。 4.3分镜序号用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨分幕中的分镜序号需连续,但跨剧集中的不需要连续,如第2集的第1幕分镜序号从1开始)。 4.4“分镜信息”中的“台词内容”需满足以下要求: (1. 台词来源规则:- 台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容)。- 不允许改写、总结、压缩、扩写或拼接原文对白。- 不允许生成原文中不存在的对话。2. 非对话过滤:- 旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:- 如果该分镜中没有人物对话,则“台词内容”必须返回“无”。- 严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:- 不得根据语境补全人物可能说的话。) 4.5“分镜信息”中的“配音角色”需对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色),不得自己生成。 4.6“分镜信息”中的“场景”需对应本集中的<场景列表>中的一个(不带详细表述)。 4.7“分镜信息”中的“出镜角色”需对应本集中的<主体列表>中的一个或多个(不带详细描述),如果没有出镜角色则填无。 4.8“分镜信息”中的“尾帧描述”需根据本镜和下一镜的剧本信息进行过渡生成,用于后续转视频进行衔接上下镜头的画面。 4.9“分镜信息”中的“情感”需根据台词内容和剧情氛围分析得出,可选值为:开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰鼓励、咆哮、温柔、自然讲述、情感电台、磁性、广告营销、气泡音、新闻播报、娱乐八卦,不要在情感可选词后加任何解释性的词语,比如(对自己)、(对别人)等,不要自定义情感,必须在可选值中选择。 4.10“分镜信息”中的“性别”需根据配音角色的性别确定,可选值为:1(男)、2(女)、0(中性)。 4.11“分镜信息”中的“语速”需根据台词内容和情感氛围确定,可选值范围为:-50到100之间的整数,100代表2.0倍速,-50代表0.5倍数,负值表示减慢,正值表示加快,0表示正常语速。 4.12“分镜信息”中的“音量”需根据台词内容和情感氛围确定,可选值范围为:-50到100之间的整数,100代表2.0倍音量,-50代表0.5倍音量,负值表示降低音量,正值表示提高音量,0表示正常音量。 4.13“分镜信息”中的“情感强度”需根据情感的强烈程度确定,可选值范围为:1到5之间的整数,默认情况下选择4。 4.14“分镜信息”中的“音调”需根据台词内容和情感氛围确定,可选值范围为:-12到12之间的整数,负值表示降低音调,正值表示提高音调,0表示正常音调。\n 示例如下:\n ###剧本名:西昆仑 ###故事梗概 内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###剧本亮点 亮点1:绝境奇药,生死一线 画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。 亮点2:结拜兄妹,化解尴尬 阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。 亮点3:纤发夺命,情愫暗涌 拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。 ###人物关系 阿雪与梁萧之间存在兄妹之情。 ###核心矛盾 阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###主体列表 阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。 阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。 梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。 阴阳球:天地异宝,能化生精气,救人于危难。 问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。 ###美术风格 基础画风风格词:厚涂古风 整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。 ###场景列表 林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。 ###分镜剧本 ##第1幕:徐家老旧厨房 白天 室内 分镜1 画面描述:许芸-校服装站在厨房门口,书包背在肩上,徐母手持扫帚,正对着许芸的背部猛烈挥打。卞大伟站在锅炉后方,双手抱胸,阴沉的目光落在许芸身上。 场景:徐家老旧厨房 构图设计:全景,低角度仰视 运镜调度:手持拍摄 配音角色:旁白 出镜角色:许芸-校服装、徐母 台词内容:我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我, 画面类型:普通画面 尾帧描述:手持拍摄微晃镜头,中年妇女猛力挥动扫帚击打少女背部,少女身体因冲击微颤,阴影中的肥胖男子目光冷冷跟随。 分镜2 画面描述:徐母面容扭曲,嘴唇快速开合,扫帚挥舞在空中。 场景:徐家老旧厨房 构图设计:近景,徐母面部特写 运镜调度:固定镜头 配音角色:徐母 出镜角色:徐母 台词内容:问我夜不归宿死哪儿去了。 画面类型:对口型 尾帧描述:固定镜头,中年妇女面部肌肉剧烈抖动,嘴唇快速开合进行辱骂,背景中的扫帚在空中上下挥动。 ##第2幕:徐家简陋客厅 黄昏 室内 分镜3 画面描述:客厅窗户透出昏黄的夕阳余晖,空荡荡的客厅中央只有几把木凳和一张方桌,显得异常安静。 场景:徐家简陋客厅 构图设计:全景,景深虚化 运镜调度:固定镜头 配音角色:旁白 出镜角色:无 台词内容:可我没想到,半个月后,徐母居然再一次外出, 画面类型:普通画面 尾帧描述:固定镜头。昏黄的夕阳余晖在地面上缓慢移动,空气中的浮尘在光束中跳动,室内维持着死寂的安静。 分镜4 画面描述:一张泛黄的火车票特写,上面显示着隔壁市的站名和日期。 场景:徐家简陋客厅 构图设计:特写,火车票 运镜调度:固定镜头 配音角色:旁白 出镜角色:无 台词内容:这次是去隔壁市看望她生病的好姐妹。 画面类型:普通画面 尾帧描述:固定镜头。光影在火车票表面微微晃动,强调其陈旧的纸张纹理与关键的行程信息。\n " : "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n 强制要求:\n 1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出 2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答 3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等 4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement} 5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n 普通要求:\n 1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n 2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述\n\n 示例如下:\n ###剧本名:西昆仑 ###故事梗概 内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###剧本亮点 亮点1:绝境奇药,生死一线 画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。 亮点2:结拜兄妹,化解尴尬 阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。 亮点3:纤发夺命,情愫暗涌 拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。 ###人物关系 阿雪与梁萧之间存在兄妹之情。 ###核心矛盾 阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###主体列表 阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。 阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。 梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。 阴阳球:天地异宝,能化生精气,救人于危难。 问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。 ###美术风格 基础画风风格词:厚涂古风 整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。 ###场景列表 林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。"; // 构建消息 $messages = [ [ 'role' => 'system', 'content' => $systemPrompt ], [ 'role' => 'user', 'content' => "以下是文档内容: {$content} 用户问题: {$question}" ] ]; // 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash $thinkingMode = getProp($data, 'thinking', 'disabled'); // 默认非思考模式 $reasoningEffort = getProp($data, 'reasoning_effort', 'high'); // 默认思考强度 if ($model === 'deepseek-chat') { // deepseek-chat 映射到 deepseek-v4-flash,非思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'disabled'; } elseif ($model === 'deepseek-reasoner') { // deepseek-reasoner 映射到 deepseek-v4-flash,思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'enabled'; } $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => 8192, 'temperature' => 0.7, 'frequency_penalty' => 0, 'presence_penalty' => 0, 'response_format' => ['type' => 'text'], 'thinking' => ['type'=>$thinkingMode], 'stream' => true // 启用流式输出 ]; if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort; // 根据模型类型选择调用方法 $fullContent = ''; $fullReasoningContent = ''; $usage = []; if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // DeepSeek 官方模型使用 DeepSeek API $streamGenerator = $this->deepSeekStreamResponse($post_data); } else { // 其他模型使用火山引擎API $streamGenerator = $this->volcEngineChatCompletion($post_data); } // 处理流式输出 if (!is_iterable($streamGenerator)) { dLog('deepseek')->error('addChat流式生成器无效', ['generator_type' => gettype($streamGenerator)]); Utils::throwError('20003:接口返回数据异常,请重新请求'); } foreach ($streamGenerator as $chunk) { if (isset($chunk['type'])) { if ($chunk['type'] === 'done') { // 最终结果 $fullContent = $chunk['full_content']; $fullReasoningContent = $chunk['full_reasoning']; $usage = isset($chunk['usage']) ? $chunk['usage'] : []; } else { // 逐块yield数据 yield $chunk; } } } dLog('deepseek')->info('完整内容: '.$fullContent); logDB('deepseek', 'info', '完整内容', ['content' => $fullContent, 'anime_id' => $anime_id]); // 处理完整内容并返回最终结果 $script_arr = []; if ($fullContent) { $script_arr = handleScriptContent($fullContent); dLog('deepseek')->info('解析内容', $script_arr); logDB('deepseek', 'info', '解析内容', $script_arr); } if (empty($script_arr['roles'])) { // 未生成剧本相关内容,保存对话记录并返回提示 dLog('deepseek')->info('未生成剧本相关的内容'); try { DB::beginTransaction(); $now = date('Y-m-d H:i:s'); // 保存对话记录 $records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'user', 'content' => "用户问题:\n {$prompt}\n\n 以下是文档内容:\n {$content}", 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'assistant', 'content' => $fullContent, 'created_at' => $now, 'updated_at' => $now ] ]; DB::table('mp_anime_records')->insert($records); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('deepseek')->error('保存对话记录失败', ['error' => $e->getMessage()]); } yield [ 'type' => 'done', 'script' => [], 'episode' => [], 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage, 'error' => '未生成剧本相关的内容,请调整提示词再试' ]; return; } // 如果需要生成原文内容,从script_arr中提取 if ($need_generate_content && isset($script_arr['content']) && $script_arr['content']) { $content = $script_arr['content']; dLog('deepseek')->info('从AI生成结果中提取原文内容', ['content_length' => strlen($content)]); if (!$content) { yield [ 'type' => 'done', 'script' => [], 'episode' => [], 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage, 'error' => '未生成剧本内容,请调整提示词再试' ]; return; } } // 替换美术风格 $mappedArtStyle = $this->getArtStylePromptByInput($input_art_style); if ($mappedArtStyle !== '') { $script_arr['art_style'] = $mappedArtStyle; $fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle); } // 新建动漫 $anime_name = getProp($script_arr, 'script_name'); if (!$anime_name) { // 未识别到剧本名,保存对话记录并返回提示 dLog('deepseek')->info('未能识别到剧本名称'); try { DB::beginTransaction(); $now = date('Y-m-d H:i:s'); // 保存对话记录 $records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'user', 'content' => "用户问题:\n {$prompt}\n\n 以下是文档内容:\n {$content}", 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'assistant', 'content' => $fullContent, 'created_at' => $now, 'updated_at' => $now ] ]; DB::table('mp_anime_records')->insert($records); DB::commit(); } catch (\Exception $e) { DB::rollBack(); dLog('deepseek')->error('保存对话记录失败', ['error' => $e->getMessage()]); } yield [ 'type' => 'done', 'script' => [], 'episode' => [], 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage, 'error' => '未能识别到剧本名称,请调整提示词再试' ]; return; } // 确认对话名称唯一性 if (DB::table('mp_animes')->where('anime_name', $anime_name)->value('id')) { $anime_name = $anime_name . '_' . date('YmdHis'); } $table_data = [ 'user_id' => $uid, 'anime_name' => $anime_name, 'model' => $model, 'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '', 'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '', 'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '', 'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '', 'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '', 'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '', 'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '', 'status' => '解析完成', 'content' => $content, 'is_multi' => $is_multi, 'generate_status' => '待执行', 'updated_at' => date('Y-m-d H:i:s') ]; $single_episode = []; try { DB::beginTransaction(); $now = date('Y-m-d H:i:s'); // 更新动漫大纲 $boolen = DB::table('mp_animes')->where('id', $anime_id)->update($table_data); if (!$boolen) { dLog('deepseek')->info('动漫保存失败', $table_data); Utils::throwError('20003:动漫保存失败'); } // 保存对话记录 $records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'user', 'content' => "用户问题:\n {$prompt}\n\n 以下是文档内容:\n {$content}", 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'assistant', 'content' => $fullContent, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ] ]; // 保存对话记录 $boolen3 = DB::table('mp_anime_records')->insert($records); if (!$boolen3) { Utils::throwError('20003:对话记录保存失败'); } if ($is_single) { $episode_arr = $this->buildSingleEpisodeFromScript($script_arr, $fullContent, $anime_name); if (empty($episode_arr['acts'])) { Utils::throwError('20003:单剧集分镜内容解析失败,请确认输出格式'); } $saveResult = $this->saveEpisodeVersionData( $anime_id, 1, $episode_arr, is_array(getProp($script_arr, 'roles')) ? getProp($script_arr, 'roles') : [], is_array(getProp($script_arr, 'scenes')) ? getProp($script_arr, 'scenes') : [], null, null, false, $content, $now ); $single_episode = $saveResult['episode']; $episode_id = $saveResult['episode_id']; $boolen5 = DB::table('mp_animes')->where('id', $anime_id)->update([ 'roles' => json_encode($saveResult['merged_roles'], 256), 'scenes' => json_encode($saveResult['merged_scenes'], 256), 'updated_at' => $now ]); if ($boolen5 === false) { Utils::throwError('20003:单剧集主表资源同步失败'); } $table_data['roles'] = json_encode($saveResult['merged_roles'], 256); $table_data['scenes'] = json_encode($saveResult['merged_scenes'], 256); $episode_record_content = '确认分镜大纲'; if ($content !== '') { $episode_record_content .= "\n\n###上传内容\n" . $content; } $episode_records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'role' => 'user', 'content' => $episode_record_content, 'sequence' => 1, 'episode_id' => $episode_id, 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'role' => 'assistant', 'content' => $fullContent, 'sequence' => 1, 'episode_id' => $episode_id, 'created_at' => $now, 'updated_at' => $now ] ]; $boolen4 = DB::table('mp_anime_records')->insert($episode_records); if (!$boolen4) { Utils::throwError('20003:单剧集分镜记录保存失败'); } } }catch (\Exception $e) { DB::rollBack(); dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage()); yield [ 'type' => 'done', 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage, 'error' => $e->getMessage(), ]; return; } DB::commit(); dLog('deepseek')->info('保存完毕'); if ($is_single && !empty($single_episode)) { $this->batchSetGlobalRoleImg([ 'anime_id' => $anime_id, ]); $this->batchSetGlobalSceneImg([ 'anime_id' => $anime_id, ]); } $table_data['anime_id'] = $anime_id; $table_data['roles'] = json_decode($table_data['roles'], true); $table_data['scenes'] = json_decode($table_data['scenes'], true); // $table_data['episodes'] = $script_arr['episodes']; unset($table_data['created_at']); unset($table_data['updated_at']); unset($table_data['status']); dLog('deepseek')->info('开始返回'); yield [ 'type' => 'done', 'script' => $table_data, 'episode' => $single_episode, 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage ]; } public function reGenerateAnime($data) { $uid = Site::getUid(); $file = getProp($data, 'file'); $content = getProp($data, 'content', ''); $bid = getProp($data, 'bid', 0); $script_id = getProp($data, 'script_id', 0); $anime_id = getProp($data, 'anime_id'); $anime = DB::table('mp_animes')->where('id', $anime_id)->first(); if (!$anime) { Utils::throwError('20003:该对话不存在'); } $prompt = getProp($data, 'prompt'); // 处理文本模型 $model = getProp($data, 'model'); if (!$model) { // 用户没有输入,从anime表获取 $model = getProp($anime, 'model'); if (!$model) { // anime表也没有,使用默认值 $model = 'doubao-seed-2-0-mini-260215'; } } // 验证模型是否在可用模型表中 if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) { $model = 'doubao-seed-2-0-mini-260215'; } $is_multi = getProp($anime, 'is_multi', 1); $is_single = (int)$is_multi !== 1; // 单剧集模式下,如果已经生成了剧集,则不允许继续调整大纲 if ($is_single) { $episode_exists = DB::table('mp_anime_episodes') ->where('anime_id', $anime_id) ->where('sequence', 1) ->exists(); if ($episode_exists) { Utils::throwError('20003:单剧集模式下已生成剧集,不可继续调整大纲,请去策划剧集'); } } $question = getProp($data, 'question', $is_single ? "请根据实例格式完成单剧集剧本及分镜\n本次修改要求如下:\n{$prompt}" : "请根据实例格式完成剧本大纲\n本次修改要求如下:\n{$prompt}"); // 提取文件内容 if ($file) { $content = $this->extractFileContent($file); if (!$content) { Utils::throwError('20003:无法提取文件内容,请检查文件格式'); } } elseif ($script_id) { $content = $this->getContentByScriptId($script_id); if (!$content) { Utils::throwError('20003:无法获取剧本内容,请检查剧本'); } } elseif ($bid) { $content = $this->getContentByBid($bid); if (!$content) { Utils::throwError('20003:无法获取书籍内容,请检查书籍'); } } elseif ($content) { $content = filterContent($content); }else { $content = filterContent(getProp($anime, 'content')); if (!$content && $prompt) { // 如果动漫记录中没有内容则根据提示词生成 $need_generate_content = true; } } // 判断是否需要生成原文内容 $need_generate_content = isset($need_generate_content) && $need_generate_content; // 如果需要生成原文内容,添加额外的要求 $content_generation_requirement = $need_generate_content ? "\n5.如果用户只提供了创作要求而没有提供具体文档内容,你需要根据要求创作完整的原文内容(小说形式,包含对话和叙述),并将原文内容放在最后一个板块###原文内容中输出。原文内容应该详细、生动,如果用户没有明确生成的字数,则字数不少于1000字。" : ""; // 美术风格 $input_art_style = getProp($data, 'art_style'); $timbres_content = "青年男:北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年 青年女:柔美女友、魅力女友、爽快思思、高冷御姐、知性温婉、甜心小美、清新女声、甜美桃子、温柔文雅、甜美小源、贴心闺蜜 中年男:胡子叔叔、油腻大叔、神秘法师、优柔帮主 中年女:邻居阿姨 老年男:幽默大爷 老年女:婆婆 萌娃:奶气萌娃"; $system_message = ['role'=>'system', 'content'=>$is_single ? "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容输出单剧集完整策划稿。单剧集模式下,必须一次性输出可直接保存的大纲与完整分镜,不允许只输出大纲。 强制要求: 1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出 2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答 3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等 4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement} 5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n 普通要求: 1.剧集标题必须与文档内容高度相关,剧集序号是1。 2.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如角色-少年、角色-青年、角色-中年、角色-老年这种情况,不得出现角色-愤怒、角色-隐忍、角色-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细 2.1<主体列表>的音色必须在以下音色中($timbres_content)选择,需要角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。 3.2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述 4.<分镜剧本>要求包含“分幕标题”以及“分镜信息”: 4.1“分幕标题”是根据章节中的场景进行拆分,类似于“第1幕 场景、时间及室内或室外”的信息,其中场景只能在所给出的场景列表中选(不带场景详细描述)。 4.2“分镜信息”需同一场景内的对话内容完成分镜(每一幕分镜尽量不得低于5个),需包含“画面描述”、“构图设计”、“运镜调度”、“配音角色”、“台词内容”、“画面类型”、“尾帧描述”、“情感”、“性别”、“语速”、“音量”、“情感强度”、“音调”这几个部分,其中“画面描述”如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)。 4.3分镜序号用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨分幕中的分镜序号需连续,但跨剧集中的不需要连续,如第2集的第1幕分镜序号从1开始)。 4.4“分镜信息”中的“台词内容”需满足以下要求: (1. 台词来源规则:- 台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容)。- 不允许改写、总结、压缩、扩写或拼接原文对白。- 不允许生成原文中不存在的对话。2. 非对话过滤:- 旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:- 如果该分镜中没有人物对话,则“台词内容”必须返回“无”。- 严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:- 不得根据语境补全人物可能说的话。) 4.5“分镜信息”中的“配音角色”需对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色),不得自己生成。 4.6“分镜信息”中的“场景”需对应本集中的<场景列表>中的一个(不带详细表述)。 4.7“分镜信息”中的“出镜角色”需对应本集中的<主体列表>中的一个或多个(不带详细描述),如果没有出镜角色则填无。 4.8“分镜信息”中的“尾帧描述”需根据本镜和下一镜的剧本信息进行过渡生成,用于后续转视频进行衔接上下镜头的画面。 4.9“分镜信息”中的“情感”需根据台词内容和剧情氛围分析得出,可选值为:开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰鼓励、咆哮、温柔、自然讲述、情感电台、磁性、广告营销、气泡音、新闻播报、娱乐八卦,不要在情感可选词后加任何解释性的词语,比如(对自己)、(对别人)等,不要自定义情感,必须在可选值中选择。 4.10“分镜信息”中的“性别”需根据配音角色的性别确定,可选值为:1(男)、2(女)、0(中性)。 4.11“分镜信息”中的“语速”需根据台词内容和情感氛围确定,可选值范围为:-50到100之间的整数,100代表2.0倍速,-50代表0.5倍数,负值表示减慢,正值表示加快,0表示正常语速。 4.12“分镜信息”中的“音量”需根据台词内容和情感氛围确定,可选值范围为:-50到100之间的整数,100代表2.0倍音量,-50代表0.5倍音量,负值表示降低音量,正值表示提高音量,0表示正常音量。 4.13“分镜信息”中的“情感强度”需根据情感的强烈程度确定,可选值范围为:1到5之间的整数,默认情况下选择4。 4.14“分镜信息”中的“音调”需根据台词内容和情感氛围确定,可选值范围为:-12到12之间的整数,负值表示降低音调,正值表示提高音调,0表示正常音调。\n 示例如下:\n ###剧本名:西昆仑 ###故事梗概 内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###剧本亮点 亮点1:绝境奇药,生死一线 画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。 亮点2:结拜兄妹,化解尴尬 阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。 亮点3:纤发夺命,情愫暗涌 拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。 ###人物关系 阿雪与梁萧之间存在兄妹之情。 ###核心矛盾 阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###主体列表 阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。 阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。 梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。 阴阳球:天地异宝,能化生精气,救人于危难。 问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。 ###美术风格 基础画风风格词:厚涂古风 整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。 ###场景列表 林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。 ###分镜剧本 ##第1幕:徐家老旧厨房 白天 室内 分镜1 画面描述:许芸-校服装站在厨房门口,书包背在肩上,徐母手持扫帚,正对着许芸的背部猛烈挥打。卞大伟站在锅炉后方,双手抱胸,阴沉的目光落在许芸身上。 场景:徐家老旧厨房 构图设计:全景,低角度仰视 运镜调度:手持拍摄 配音角色:旁白 出镜角色:许芸-校服装、徐母 台词内容:我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我, 画面类型:普通画面 尾帧描述:手持拍摄微晃镜头,中年妇女猛力挥动扫帚击打少女背部,少女身体因冲击微颤,阴影中的肥胖男子目光冷冷跟随。 分镜2 画面描述:徐母面容扭曲,嘴唇快速开合,扫帚挥舞在空中。 场景:徐家老旧厨房 构图设计:近景,徐母面部特写 运镜调度:固定镜头 配音角色:徐母 出镜角色:徐母 台词内容:问我夜不归宿死哪儿去了。 画面类型:对口型 尾帧描述:固定镜头,中年妇女面部肌肉剧烈抖动,嘴唇快速开合进行辱骂,背景中的扫帚在空中上下挥动。 ##第2幕:徐家简陋客厅 黄昏 室内 分镜3 画面描述:客厅窗户透出昏黄的夕阳余晖,空荡荡的客厅中央只有几把木凳和一张方桌,显得异常安静。 场景:徐家简陋客厅 构图设计:全景,景深虚化 运镜调度:固定镜头 配音角色:旁白 出镜角色:无 台词内容:可我没想到,半个月后,徐母居然再一次外出, 画面类型:普通画面 尾帧描述:固定镜头。昏黄的夕阳余晖在地面上缓慢移动,空气中的浮尘在光束中跳动,室内维持着死寂的安静。 分镜4 画面描述:一张泛黄的火车票特写,上面显示着隔壁市的站名和日期。 场景:徐家简陋客厅 构图设计:特写,火车票 运镜调度:固定镜头 配音角色:旁白 出镜角色:无 台词内容:这次是去隔壁市看望她生病的好姐妹。 画面类型:普通画面 尾帧描述:固定镜头。光影在火车票表面微微晃动,强调其陈旧的纸张纹理与关键的行程信息。\n " : "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n 强制要求:\n 1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出 2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答 3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等 4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement} 5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n 普通要求:\n 1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n 2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述\n\n 示例如下:\n ###剧本名:西昆仑 ###故事梗概 内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###剧本亮点 亮点1:绝境奇药,生死一线 画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。 亮点2:结拜兄妹,化解尴尬 阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。 亮点3:纤发夺命,情愫暗涌 拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。 ###人物关系 阿雪与梁萧之间存在兄妹之情。 ###核心矛盾 阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###主体列表 阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。 阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。 梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。 阴阳球:天地异宝,能化生精气,救人于危难。 问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。 ###美术风格 基础画风风格词:厚涂古风 整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。 ###场景列表 林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。"]; // 如果提示词$prompt有类似于改成N集或调整成N集这类的文字信息,则提取出N这个集数 $episode_count = $this->extractEpisodeNumber($prompt); if ((int)getProp($anime, 'is_multi') !== 1) { yield [ 'type' => 'done', 'answer' => '当前剧本是单剧集,不支持修改剧集数', 'reasoning' => '', 'usage' => [] ]; return; } if ($episode_count) { $question = "请根据实例格式完成{$episode_count}集剧本大纲"; $system_message = [ 'role' => 'system', 'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集详细内容>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n 强制要求:\n 1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出 2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答 3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等 4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement} 5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n 普通要求:\n 1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n 2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述\n 3.<分集详细内容>中每一集的内容需尽可能的详细,保留对话内容(使用原文内容,不得自己生成),一般每集字数尽量不低于500字,每集结尾需以##第N章 作为章节标题\n\n 示例如下:\n ###剧本名:西昆仑 ###故事梗概 内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###剧本亮点 亮点1:绝境奇药,生死一线 画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。 亮点2:结拜兄妹,化解尴尬 阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。 亮点3:纤发夺命,情愫暗涌 拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。 ###人物关系 阿雪与梁萧之间存在兄妹之情。 ###核心矛盾 阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。 ###主体列表 阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。 阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。 梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。 阴阳球:天地异宝,能化生精气,救人于危难。 问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。 ###美术风格 基础画风风格词:厚涂古风 整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。 ###场景列表 林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。 ###分集详细内容 ##第1章 重生 我重生了。 源于一个男人偏执的暗恋。 ##第2章 相救 我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我,问我夜不归宿死哪儿去了。 我眼睛扫向站在锅炉后的卞大伟。" ]; // 构建消息 $messages = [ $system_message, [ 'role' => 'user', 'content' => "以下是文档内容: {$content} 用户问题: {$question}" ] ]; }else { if (strstr($prompt, '重新生成') || strstr($prompt, '再生成')) { // 构建消息 $messages = [ $system_message, [ 'role' => 'user', 'content' => "以下是文档内容: {$content} 用户问题: {$question}" ] ]; }else { $messages = DB::table('mp_anime_records')->where('anime_id', $anime_id)->where('sequence', 0) ->orderBy('id')->select('role', 'content')->get()->map(function ($value) { return (array)$value; })->toArray(); array_unshift($messages, $system_message); $messages[] = [ 'role' => 'user', 'content' => $prompt ]; } } // 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash $thinkingMode = getProp($data, 'thinking', 'disabled'); // 默认非思考模式 $reasoningEffort = getProp($data, 'reasoning_effort', 'high'); // 默认思考强度 if ($model === 'deepseek-chat') { // deepseek-chat 映射到 deepseek-v4-flash,非思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'disabled'; } elseif ($model === 'deepseek-reasoner') { // deepseek-reasoner 映射到 deepseek-v4-flash,思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'enabled'; } $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => 8192, 'temperature' => 0.7, 'frequency_penalty' => 0, 'presence_penalty' => 0, 'response_format' => ['type' => 'text'], 'thinking' => ['type' => $thinkingMode], 'stream' => true // 启用流式输出 ]; if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort; // 根据模型类型选择调用方法 $fullContent = ''; $fullReasoningContent = ''; $usage = []; // dd($post_data); // dLog('deepseek')->info('请求参数: '.json_encode($post_data, 256)); if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // DeepSeek 官方模型使用 DeepSeek API $streamGenerator = $this->deepSeekStreamResponse($post_data); } else { // 其他模型使用火山引擎API $streamGenerator = $this->volcEngineChatCompletion($post_data); } // 处理流式输出 foreach ($streamGenerator as $chunk) { if (isset($chunk['type'])) { if ($chunk['type'] === 'done') { // 最终结果 $fullContent = $chunk['full_content']; $fullReasoningContent = $chunk['full_reasoning']; $usage = isset($chunk['usage']) ? $chunk['usage'] : []; } else { // 逐块yield数据 yield $chunk; } } } dLog('deepseek')->info('完整内容: '.$fullContent); logDB('deepseek', 'info', '完整内容', ['content' => $fullContent, 'anime_id' => $anime_id]); // 处理完整内容并返回最终结果 $script_arr = []; if ($fullContent) { $script_arr = handleScriptContent($fullContent); dLog('deepseek')->info('解析内容', $script_arr); logDB('deepseek', 'info', '解析内容', $script_arr); } if (empty($script_arr['roles'])) { // 未生成剧本相关内容,保存对话记录并返回提示 dLog('deepseek')->info('未生成剧本相关的内容'); try { $now = date('Y-m-d H:i:s'); // 保存对话记录 $records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'user', 'content' => $prompt, 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'assistant', 'content' => $fullContent, 'created_at' => $now, 'updated_at' => $now ] ]; DB::table('mp_anime_records')->insert($records); } catch (\Exception $e) { dLog('deepseek')->error('保存对话记录失败', ['error' => $e->getMessage()]); } yield [ 'type' => 'done', 'script' => [], 'episode' => [], 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage, 'error' => '未生成剧本相关的内容,请调整提示词再试' ]; return; } // 替换美术风格 $mappedArtStyle = $this->getArtStylePromptByInput($input_art_style); if ($mappedArtStyle !== '') { $script_arr['art_style'] = $mappedArtStyle; $fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle); } $table_data = [ 'user_id' => $uid, 'model' => $model, 'anime_name' => isset($script_arr['script_name']) ? $script_arr['script_name'] : '', 'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '', 'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '', 'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '', 'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '', 'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '', 'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '', 'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '', 'status' => '解析完成', 'generate_status' => '待执行', 'updated_at' => date('Y-m-d H:i:s') ]; // 如果是修改集数,则将content内容更新(会改变原文) if (isset($episode_count) && $episode_count > 0) { if (isset($script_arr['content']) && $script_arr['content']) $table_data['content'] = $script_arr['content']; $table_data['end_episode_sequence'] = $table_data['episode_num'] = $episode_count; }else { // 如果需要生成原文内容,从script_arr中提取 if ($need_generate_content && isset($script_arr['content']) && $script_arr['content']) { $table_data['content'] = $script_arr['content']; dLog('deepseek')->info('从AI生成结果中提取原文内容', ['content_length' => strlen($script_arr['content'])]); if (!$content) { yield [ 'type' => 'done', 'script' => [], 'episode' => [], 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage, 'error' => '未生成剧本内容,请调整提示词再试' ]; return; } } else { $chapters = $this->splitContent($content); $chapter_count = count($chapters); if ($chapter_count > 0) { $table_data['end_episode_sequence'] = $table_data['episode_num'] = $chapter_count; } } } $single_episode = []; try { DB::beginTransaction(); $now = date('Y-m-d H:i:s'); // 更新动漫大纲 $boolen = DB::table('mp_animes')->where('id', $anime_id)->update($table_data); if (!$boolen) { dLog('deepseek')->info('对话修改失败', $table_data); logDB('deepseek', 'error', '对话修改失败', ['anime_id' => $anime_id]); Utils::throwError('20003:对话修改失败'); } // 保存对话记录 $records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'user', 'content' => $prompt, 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => 0, 'role' => 'assistant', 'content' => $fullContent, 'created_at' => $now, 'updated_at' => $now ] ]; // 保存对话记录 $boolen3 = DB::table('mp_anime_records')->insert($records); if (!$boolen3) { Utils::throwError('20003:对话记录保存失败'); } // 单剧集模式:生成并保存剧集信息 if ($is_single) { $anime_name = getProp($anime, 'anime_name', '未命名'); $episode_arr = $this->buildSingleEpisodeFromScript($script_arr, $fullContent, $anime_name); if (empty($episode_arr['acts'])) { Utils::throwError('20003:单剧集分镜内容解析失败,请确认输出格式'); } $saveResult = $this->saveEpisodeVersionData( $anime_id, 1, $episode_arr, is_array(getProp($script_arr, 'roles')) ? getProp($script_arr, 'roles') : [], is_array(getProp($script_arr, 'scenes')) ? getProp($script_arr, 'scenes') : [], null, null, false, $content, $now ); $single_episode = $saveResult['episode']; $episode_id = $saveResult['episode_id']; $boolen5 = DB::table('mp_animes')->where('id', $anime_id)->update([ 'roles' => json_encode($saveResult['merged_roles'], 256), 'scenes' => json_encode($saveResult['merged_scenes'], 256), 'updated_at' => $now ]); if ($boolen5 === false) { Utils::throwError('20003:单剧集主表资源同步失败'); } $table_data['roles'] = json_encode($saveResult['merged_roles'], 256); $table_data['scenes'] = json_encode($saveResult['merged_scenes'], 256); $episode_record_content = '确认分镜大纲'; if ($content !== '') { $episode_record_content .= "\n\n###上传内容\n" . $content; } $episode_records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'role' => 'user', 'content' => $episode_record_content, 'sequence' => 1, 'episode_id' => $episode_id, 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'role' => 'assistant', 'content' => $fullContent, 'sequence' => 1, 'episode_id' => $episode_id, 'created_at' => $now, 'updated_at' => $now ] ]; $boolen4 = DB::table('mp_anime_records')->insert($episode_records); if (!$boolen4) { Utils::throwError('20003:单剧集分镜记录保存失败'); } } }catch (\Exception $e) { DB::rollBack(); dLog('deepseek')->info('修改对话错误信息: '.$e->getMessage()); logDB('deepseek', 'error', '修改对话失败', ['error' => $e->getMessage(), 'anime_id' => $anime_id]); yield [ 'type' => 'done', 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage, 'error' => $e->getMessage(), ]; return; } DB::commit(); dLog('deepseek')->info('保存完毕'); if ($is_single && !empty($single_episode)) { $this->batchSetGlobalRoleImg([ 'anime_id' => $anime_id, ]); $this->batchSetGlobalSceneImg([ 'anime_id' => $anime_id, ]); } $table_data['anime_id'] = $anime_id; $table_data['roles'] = json_decode($table_data['roles'], true); $table_data['scenes'] = json_decode($table_data['scenes'], true); unset($table_data['updated_at']); unset($table_data['status']); yield [ 'type' => 'done', 'script' => $table_data, 'episode' => $single_episode, 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage ]; } public function chat($data) { $uid = Site::getUid(); $anime_id = getProp($data, 'anime_id'); $file = getProp($data, 'file'); $content = getProp($data, 'content', ''); $bid = getProp($data, 'bid', 0); $script_id = getProp($data, 'script_id', 0); $episode_number = getProp($data, 'episode_number', 1); $prompt = trim((string)getProp($data, 'prompt', '确认分镜大纲')); $anime = DB::table('mp_animes')->where('id', $anime_id)->first(); if (!$anime) Utils::throwError('20003:该对话不存在!'); if ((int)getProp($anime, 'is_multi') !== 1 && (int)$episode_number !== 1) Utils::throwError('20003:单剧集不支持其他剧集序号'); $current_episode = DB::table('mp_anime_episodes')->where('anime_id', $anime_id)->where('episode_number', $episode_number)->where('is_default', 1)->first(); $base_episode = $current_episode ?: DB::table('mp_anime_episodes')->where('anime_id', $anime_id)->where('episode_number', $episode_number)->orderByDesc('id')->first(); $roles = $base_episode ? json_decode((string)getProp($base_episode, 'roles'), true) : json_decode((string)getProp($anime, 'roles'), true); $scenes = $base_episode ? json_decode((string)getProp($base_episode, 'scenes'), true) : json_decode((string)getProp($anime, 'scenes'), true); $roles = is_array($roles) ? $roles : []; $scenes = is_array($scenes) ? $scenes : []; // 转换主体列表和场景列表的格式 $roles_content = $this->buildEpisodeItemContent($roles, 'role'); $scenes_content = $this->buildEpisodeItemContent($scenes, 'scene'); // 获取音色列表 // $timbres = DB::table('mp_timbres')->select('timbre_name')->where('is_enabled', 1)->where('id', '<=', 15)->get()->map(function ($value) { // $value = (array)$value; // $value['timbre_name'] = str_replace('(多情感)', '', $value['timbre_name']); // return $value; // })->pluck('timbre_name')->toArray(); // $timbres_content = implode('、', $timbres); $timbres_content = "青年男:北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年 青年女:柔美女友、魅力女友、爽快思思、高冷御姐、知性温婉、甜心小美、清新女声、甜美桃子、温柔文雅、甜美小源、贴心闺蜜 中年男:胡子叔叔、油腻大叔、神秘法师、优柔帮主 中年女:邻居阿姨 老年男:幽默大爷 老年女:婆婆 萌娃:奶气萌娃"; $existing_roles = $base_episode ? json_decode((string)getProp($base_episode, 'roles'), true) : []; $existing_scenes = $base_episode ? json_decode((string)getProp($base_episode, 'scenes'), true) : []; $existing_roles = is_array($existing_roles) ? $existing_roles : []; $existing_scenes = is_array($existing_scenes) ? $existing_scenes : []; // 提取文件内容 if ($file) { $uploaded_content = $this->extractFileContent($file); if (!$uploaded_content) { Utils::throwError('20003:无法提取文件内容,请检查文件格式'); } } elseif ($script_id) { $uploaded_content = $this->getContentByScriptId($script_id); if (!$uploaded_content) { Utils::throwError('20003:无法获取剧本内容,请检查剧本'); } } elseif ($bid) { $uploaded_content = $this->getContentByBid($bid); if (!$uploaded_content) { Utils::throwError('20003:无法获取书籍内容,请检查书籍'); } } elseif ($content) { $uploaded_content = filterContent((string)getProp($data, 'content', '')); } // elseif ($prompt) { // $uploaded_content = filterContent($prompt); // } else { $uploaded_content = ''; } $prompt = trim((string)getProp($data, 'prompt', '确认分镜大纲')); $demo = "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需包含以下几个板块: <剧集标题><故事梗概><美术风格><主体列表><场景列表><分镜剧本>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n 强制要求: 1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出 2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答 3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等 4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求 5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n 普通要求: 1.剧集标题必须与文档内容高度相关,剧集序号是{$episode_number}。 2.<主体列表>必须在以下主体中选择多个(根据拆分出的分镜剧本来确认具体几个),不得自定义(需带上冒号后的具体描述):\n{$roles_content}。 2.1如果分集内容中出现了<主体列表>中不存在的新主体,则不用在上述主体中选择,而是按照相同的格式新增一个主体显示在主题列表中。 2.2主体列表的音色必须在以下音色中($timbres_content)选择,需要角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。 3.<场景列表>必须在以下场景中选择多个(需带上冒号后的具体描述),根据拆分出的分镜剧本来确认具体几个,除非出现<场景列表>中不存在的场景,否则不得自定义:\n{$scenes_content}。 4.<分镜剧本>要求包含“分幕标题”以及“分镜信息”: 4.1“分幕标题”是根据章节中的场景进行拆分,类似于“第1幕 场景、时间及室内或室外”的信息,其中场景只能在所给出的场景列表中选(不带场景详细描述)。 4.2“分镜信息”需同一场景内的对话内容完成分镜(每一幕分镜尽量不得低于5个),需包含“画面描述”、“构图设计”、“运镜调度”、“配音角色”、“台词内容”、“画面类型”、“尾帧描述”、“情感”、“性别”、“语速”、“音量”、“情感强度”、“音调”这几个部分,其中“画面描述”如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)。 4.3分镜序号用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨分幕中的分镜序号需连续,但跨剧集中的不需要连续,如第2集的第1幕分镜序号从1开始)。 4.4“分镜信息”中的“台词内容”需满足以下要求: (1. 台词来源规则:- 台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容)。- 不允许改写、总结、压缩、扩写或拼接原文对白。- 不允许生成原文中不存在的对话。2. 非对话过滤:- 旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:- 如果该分镜中没有人物对话,则“台词内容”必须返回“无”。- 严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:- 不得根据语境补全人物可能说的话。) 4.5“分镜信息”中的“配音角色”需对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色),不得自己生成。 4.6“分镜信息”中的“场景”需对应本集中的<场景列表>中的一个(不带详细表述)。 4.7“分镜信息”中的“出镜角色”需对应本集中的<主体列表>中的一个或多个(不带详细描述),如果没有出镜角色则填无。 4.8“分镜信息”中的“尾帧描述”需根据本镜和下一镜的剧本信息进行过渡生成,用于后续转视频进行衔接上下镜头的画面。 4.9“分镜信息”中的“情感”需根据台词内容和剧情氛围分析得出,可选值为:开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰鼓励、咆哮、温柔、自然讲述、情感电台、磁性、广告营销、气泡音、新闻播报、娱乐八卦,不要在情感可选词后加任何解释性的词语,比如(对自己)、(对别人)等,不要自定义情感,必须在可选值中选择。 4.10“分镜信息”中的“性别”需根据配音角色的性别确定,可选值为:1(男)、2(女)、0(中性)。 4.11“分镜信息”中的“语速”需根据台词内容和情感氛围确定,可选值范围为:-50到100之间的整数,100代表2.0倍速,-50代表0.5倍数,负值表示减慢,正值表示加快,0表示正常语速。 4.12“分镜信息”中的“音量”需根据台词内容和情感氛围确定,可选值范围为:-50到100之间的整数,100代表2.0倍音量,-50代表0.5倍音量,负值表示降低音量,正值表示提高音量,0表示正常音量。 4.13“分镜信息”中的“情感强度”需根据情感的强烈程度确定,可选值范围为:1到5之间的整数,默认情况下选择4。 4.14“分镜信息”中的“音调”需根据台词内容和情感氛围确定,可选值范围为:-12到12之间的整数,负值表示降低音调,正值表示提高音调,0表示正常音调。\n 示例格式:\n 第1集:隐形的守护者 ###故事梗概 内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。 ###美术风格 基础画风风格词:韩漫二次元 视觉风格描述:整体采用精致的韩漫厚涂风格,以浅灰冷调为基调,营造出重生复仇的清冷与压抑感。画面强调高对比度的光影处理,网吧场景通过低饱和度的杂乱色调表现颓废感,而清晨车祸瞬间则利用强烈的动态模糊与局部高光,突显“透明守护者”存在的神秘感与宿命感。人物形象细腻,眼神充满坚毅与破碎交织的故事感,完美契合偏执救赎的主题。 ###主体列表 许芸-校服装:重生后的“假千金”,清醒果敢,不再任人唯亲。她是一名高中生,身材纤细,留着乌黑齐肩短发,眼神坚定。{{甜心小美}} 许芸-卫衣装:半个月后居家或外出时的装扮,在清冷的环境中透着一丝坚毅与破碎感。{{爽快思思}} 程景:偏执深情的学霸,为救许芸变得“不可见”,是始终默默守护的隐形爱人。他身材高大挺拔,面容英俊。{{阳光青年}} 卞大伟:许芸名义上的表哥,阴险暴戾的无业游民。他身材肥胖,面部肉感重,一双小眼睛透着阴冷。{{广州德哥}} 徐母:许芸亲生母亲,重男轻女且极度刻薄,面容严厉。{{邻居阿姨}} ###场景列表 徐家老旧厨房:这是徐母劳作和惩罚许芸的地方。空间狭窄阴暗,充满了生活琐碎的压抑感。室内有一个旧式锅炉,案板上散落着面粉和包好的馄饨,光影对比强烈,凸显出家庭环境的冷酷。 徐家简陋客厅:半个月后冲突爆发的中心。陈设简单且陈旧,只有几把木凳和一张方桌。窗帘半掩,透进微弱的冷光,营造出一种暴风雨前的宁静与不安感。 老旧公寓门口:许芸逃出家门后回望的视点。门框斑驳,透出屋内昏暗压抑的光线,与室外略显空旷的走廊形成对比,象征着逃离与未知的守护。 破败巷弄与臭水沟:家门外的外部环境。一条狭窄、潮湿的巷弄,旁边是散发着冷气的臭水沟,水面偶尔泛起涟漪。这里是“透明守护者”受伤后经过的地方,氛围凄清。 潮湿的巷弄地面:特写视角。灰色的水泥地面湿迹斑斑,这是许芸发现血脚印并进行最后告白的地方。强调地面的质感和清冷的基调。 ###分镜剧本 ##第1幕:徐家老旧厨房 白天 室内 分镜1 画面描述:许芸-校服装站在厨房门口,书包背在肩上,徐母手持扫帚,正对着许芸的背部猛烈挥打。卞大伟站在锅炉后方,双手抱胸,阴沉的目光落在许芸身上。 场景:徐家老旧厨房 构图设计:全景,低角度仰视 运镜调度:手持拍摄 配音角色:旁白 出镜角色:许芸-校服装、徐母 台词内容:我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我, 画面类型:普通画面 尾帧描述:手持拍摄微晃镜头,中年妇女猛力挥动扫帚击打少女背部,少女身体因冲击微颤,阴影中的肥胖男子目光冷冷跟随。 分镜2 画面描述:徐母面容扭曲,嘴唇快速开合,扫帚挥舞在空中。 场景:徐家老旧厨房 构图设计:近景,徐母面部特写 运镜调度:固定镜头 配音角色:徐母 出镜角色:徐母 台词内容:问我夜不归宿死哪儿去了。 画面类型:对口型 尾帧描述:固定镜头,中年妇女面部肌肉剧烈抖动,嘴唇快速开合进行辱骂,背景中的扫帚在空中上下挥动。 ##第2幕:徐家简陋客厅 黄昏 室内 分镜3 画面描述:客厅窗户透出昏黄的夕阳余晖,空荡荡的客厅中央只有几把木凳和一张方桌,显得异常安静。 场景:徐家简陋客厅 构图设计:全景,景深虚化 运镜调度:固定镜头 配音角色:旁白 出镜角色:无 台词内容:可我没想到,半个月后,徐母居然再一次外出, 画面类型:普通画面 尾帧描述:固定镜头。昏黄的夕阳余晖在地面上缓慢移动,空气中的浮尘在光束中跳动,室内维持着死寂的安静。 分镜4 画面描述:一张泛黄的火车票特写,上面显示着隔壁市的站名和日期。 场景:徐家简陋客厅 构图设计:特写,火车票 运镜调度:固定镜头 配音角色:旁白 出镜角色:无 台词内容:这次是去隔壁市看望她生病的好姐妹。 画面类型:普通画面 尾帧描述:固定镜头。光影在火车票表面微微晃动,强调其陈旧的纸张纹理与关键的行程信息。 \n\n"; $origin_prompt = trim((string)getProp($data, 'prompt', '确认分镜大纲')); $is_regenerate_version = !in_array($origin_prompt, ['确认分镜大纲', '继续策划下一集'], true); $is_global_generate_pics = $origin_prompt == '确认分镜大纲' ? 1 : 0; $prompt = $origin_prompt; $is_single = (int)getProp($anime, 'is_multi') !== 1; // 获取章节内容 $full_content = getProp($anime, 'content'); // 根据章节内容拆分章节 $chapters = $this->splitContent($full_content); $chapter_content = isset($chapters[($episode_number-1)]['content']) ? $chapters[($episode_number-1)]['content'] : ''; if (!$chapter_content) $chapter_content = $full_content; // 如果无法拆分章节则直接用原文 $messages = []; if ($prompt == '确认分镜大纲') { $content = $uploaded_content ?: $chapter_content; if ($is_single) $content = $full_content; // 单剧集使用全文 if (!$content) { Utils::throwError('20003:章节内容无法获取,请确认'); } $question = $is_single ? $demo . "请根据以上要求分析完整剧情,直接输出第1集完整单剧集分镜剧本,需一次性生成并可直接保存,剧情内容如下: \n\n$content" : $demo . "请根据以上要求分析本章剧情,完成分镜剧本,本章剧情内容如下: \n\n$content"; // 构建消息 $messages[] = [ 'role' => 'user', 'content' => $question ]; }else if ($prompt == '继续策划下一集') { if ($is_single) { Utils::throwError('20003:单剧集不支持继续策划下一集'); } $content = $uploaded_content ?: $chapter_content; if (!$content) { Utils::throwError('20003:章节内容无法获取,请确认'); } $question = $demo."请根据以上要求分析本章剧情,完成分镜剧本(需衔接上一章内容),本章剧情内容如下: \n\n$content"; // 获取上一集最后记录 $prev_sequence = $episode_number - 1; $messages = $this->getEpisodeChatMessages($anime_id, $prev_sequence); // 构建消息 $messages[] = [ 'role' => 'user', 'content' => $question ]; }else { $content = $uploaded_content ?: trim((string)getProp($base_episode, 'content')); if (!$content) { $content = $is_single ? $full_content : ($chapter_content ?: $full_content); } if (!$content) { Utils::throwError('20003:章节内容无法获取,请确认'); } $messages = $this->getEpisodeChatMessages($anime_id, $episode_number); $targetEpisodeText = $is_single ? '第1集完整单剧集版本' : "第{$episode_number}集完整的新版本剧集"; $question = $demo . "\n请基于以上历史对话记录,重新生成{$targetEpisodeText},并严格按示例格式输出完整内容。" . "\n\n本次修改要求如下:\n{$origin_prompt}"; $messages[] = [ 'role' => 'user', 'content' => $question ]; } // 处理文本模型 $model = getProp($data, 'model'); if (!$model) { // 用户没有输入,从anime表获取 $anime = DB::table('mp_animes')->where('id', $anime_id)->first(); $model = getProp($anime, 'model'); if (!$model) { // anime表也没有,使用默认值 $model = 'doubao-seed-2-0-mini-260215'; } } // 验证模型是否在可用模型表中 if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) { $model = 'doubao-seed-2-0-mini-260215'; } // 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash $thinkingMode = getProp($data, 'thinking', 'disabled'); // 默认非思考模式 $reasoningEffort = getProp($data, 'reasoning_effort', 'high'); // 默认思考强度 if ($model === 'deepseek-chat') { // deepseek-chat 映射到 deepseek-v4-flash,非思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'disabled'; } elseif ($model === 'deepseek-reasoner') { // deepseek-reasoner 映射到 deepseek-v4-flash,思考模式 $model = 'deepseek-v4-flash'; $thinkingMode = 'enabled'; } $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => 8192, 'temperature' => 0.7, 'frequency_penalty' => 0, 'presence_penalty' => 0, 'response_format' => ['type' => 'text'], 'thinking' => ['type' => $thinkingMode], 'stream' => true // 启用流式输出 ]; if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort; // 根据模型类型选择调用方法 $fullContent = ''; $fullReasoningContent = ''; $usage = []; if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // DeepSeek 官方模型使用 DeepSeek API $streamGenerator = $this->deepSeekStreamResponse($post_data); } else { // 其他模型使用火山引擎API $streamGenerator = $this->volcEngineChatCompletion($post_data); } // 处理流式输出 if (!is_iterable($streamGenerator)) { dLog('deepseek')->error('chat流式生成器无效', ['generator_type' => gettype($streamGenerator)]); Utils::throwError('20003:接口返回数据异常,请重新请求'); } foreach ($streamGenerator as $chunk) { if (isset($chunk['type'])) { if ($chunk['type'] === 'done') { // 最终结果 $fullContent = $chunk['full_content']; $fullReasoningContent = $chunk['full_reasoning']; $usage = isset($chunk['usage']) ? $chunk['usage'] : []; } else { // 逐块yield数据 yield $chunk; } } } dLog('deepseek')->info('完整内容: '.$fullContent); logDB('deepseek', 'info', '完整内容', ['content' => $fullContent, 'anime_id' => $anime_id]); // 处理完整内容并返回最终结果 $episode_arr = handleEpisodeContent($fullContent); logDB('deepseek', 'info', '解析内容', $episode_arr); exit(); if (empty($episode_arr['acts'])) { // 未生成剧本相关内容,保存对话记录并返回提示 dLog('deepseek')->info('未生成分镜剧本相关的内容'); try { $now = date('Y-m-d H:i:s'); // 保存对话记录 $records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => $episode_number, 'role' => 'user', 'content' => $prompt, 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'sequence' => $episode_number, 'role' => 'assistant', 'content' => $fullContent, 'created_at' => $now, 'updated_at' => $now ] ]; DB::table('mp_anime_records')->insert($records); } catch (\Exception $e) { dLog('deepseek')->error('保存对话记录失败', ['error' => $e->getMessage()]); } yield [ 'type' => 'done', 'episode' => [], 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage, 'error' => '未生成分镜剧本相关的内容,请调整提示词再试' ]; return; } $existing_roles = $base_episode ? json_decode((string)getProp($base_episode, 'roles'), true) : []; $existing_scenes = $base_episode ? json_decode((string)getProp($base_episode, 'scenes'), true) : []; $existing_roles = is_array($existing_roles) ? $existing_roles : []; $existing_scenes = is_array($existing_scenes) ? $existing_scenes : []; $now = date('Y-m-d H:i:s'); try { DB::beginTransaction(); $saveResult = $this->saveEpisodeVersionData( $anime_id, $episode_number, $episode_arr, $existing_roles, $existing_scenes, $current_episode, $base_episode, $is_regenerate_version, $content, $now ); $episode = $saveResult['episode']; $episode_id = $saveResult['episode_id']; $merged_roles = $saveResult['merged_roles']; $merged_scenes = $saveResult['merged_scenes']; $record_content = $origin_prompt; if ($uploaded_content !== '') { $record_content .= "\n\n###上传内容\n" . $uploaded_content; } $records = [ [ 'uid' => $uid, 'anime_id' => $anime_id, 'role' => 'user', 'content' => $record_content, 'sequence' => $episode_number, 'episode_id' => $episode_id, 'created_at' => $now, 'updated_at' => $now ], [ 'uid' => $uid, 'anime_id' => $anime_id, 'role' => 'assistant', 'content' => $fullContent, 'sequence' => $episode_number, 'episode_id' => $episode_id, 'created_at' => $now, 'updated_at' => $now ] ]; $boolen4 = DB::table('mp_anime_records')->insert($records); if (!$boolen4) { Utils::throwError('20003:对话记录保存失败'); } // 保存模型到anime表 DB::table('mp_animes')->where('id', $anime_id)->update([ 'model' => $model, 'updated_at' => $now ]); }catch (\Exception $e) { DB::rollBack(); dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage()); yield [ 'type' => 'done', 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage, 'error' => $e->getMessage(), ]; return; } DB::commit(); if ($is_global_generate_pics) { // 批量生成全局角色图片 $this->batchSetGlobalRoleImg([ 'anime_id' => $anime_id, ]); // 批量生成全局场景图片 $this->batchSetGlobalSceneImg([ 'anime_id' => $anime_id, ]); } $episode['episode_id'] = $episode_id; $episode['roles'] = $merged_roles; $episode['scenes'] = $merged_scenes; $episode['acts'] = isset($episode_arr['acts']) && is_array($episode_arr['acts']) ? $episode_arr['acts'] : []; yield [ 'type' => 'done', 'episode' => $episode, 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage ]; } private function buildSingleEpisodeFromScript(array $script_arr, string $fullContent, string $animeName = ''): array { $episode_arr = [ 'episode_title' => $animeName, // 单剧集默认使用剧本名 'intro' => getProp($script_arr, 'intro'), 'art_style' => getProp($script_arr, 'art_style'), 'roles' => is_array(getProp($script_arr, 'roles')) ? getProp($script_arr, 'roles') : [], 'scenes' => is_array(getProp($script_arr, 'scenes')) ? getProp($script_arr, 'scenes') : [], 'acts' => is_array(getProp($script_arr, 'acts')) ? getProp($script_arr, 'acts') : [], ]; if (empty($episode_arr['acts'])) { $fallback_episode_arr = handleEpisodeContent($fullContent); if (!empty($fallback_episode_arr['acts'])) { $episode_arr = array_merge($fallback_episode_arr, [ 'intro' => getProp($fallback_episode_arr, 'intro') ?: getProp($script_arr, 'intro'), 'art_style' => getProp($fallback_episode_arr, 'art_style') ?: getProp($script_arr, 'art_style'), 'roles' => !empty(getProp($fallback_episode_arr, 'roles')) ? getProp($fallback_episode_arr, 'roles') : getProp($script_arr, 'roles', []), 'scenes' => !empty(getProp($fallback_episode_arr, 'scenes')) ? getProp($fallback_episode_arr, 'scenes') : getProp($script_arr, 'scenes', []), ]); } } // if (!getProp($episode_arr, 'episode_title')) { // $episode_title = ''; // $episodes = getProp($script_arr, 'episodes', []); // if (is_array($episodes) && !empty($episodes[0]['title'])) { // $episode_title = '第1集:' . $episodes[0]['title']; // } // if (!$episode_title) { // $episode_title = '第1集:' . ($animeName ?: '未命名'); // } // $episode_arr['episode_title'] = $episode_title; // } return $episode_arr; } private function saveEpisodeVersionData( int $anime_id, int $episode_number, array $episode_arr, array $existing_roles, array $existing_scenes, $current_episode, $base_episode, bool $is_regenerate_version, string $content, string $now ): array { $generated_roles = isset($episode_arr['roles']) && is_array($episode_arr['roles']) ? $episode_arr['roles'] : []; $generated_scenes = isset($episode_arr['scenes']) && is_array($episode_arr['scenes']) ? $episode_arr['scenes'] : []; $merged_roles = $this->mergeEpisodeResourceItems($existing_roles, $generated_roles, 'role'); $merged_scenes = $this->mergeEpisodeResourceItems($existing_scenes, $generated_scenes, 'scene'); // 获取 mp_animes 表中的全局 roles 和 scenes 用于对比 $anime = DB::table('mp_animes')->where('id', $anime_id)->first(); $global_roles = json_decode(getProp($anime, 'roles'), true); $global_scenes = json_decode(getProp($anime, 'scenes'), true); $global_roles = is_array($global_roles) ? $global_roles : []; $global_scenes = is_array($global_scenes) ? $global_scenes : []; $art_style = getProp($episode_arr, 'art_style') ?: getProp($anime, 'art_style'); // 检查并创建 roles 的图片生成任务 $merged_roles = $this->checkAndCreateImageTasks($merged_roles, $global_roles, 'role', $art_style); // 检查并创建 scenes 的图片生成任务 $merged_scenes = $this->checkAndCreateImageTasks($merged_scenes, $global_scenes, 'scene', $art_style); $role_arr = []; foreach ($merged_roles as $item) { $role_arr[$item['role']] = $item; } $version_count = DB::table('mp_anime_episodes') ->where('anime_id', $anime_id) ->where('episode_number', $episode_number) ->count('id'); $episode = [ 'anime_id' => $anime_id, 'episode_number' => $episode_number, 'title' => getProp($episode_arr, 'episode_title'), 'content' => $content, 'intro' => getProp($episode_arr, 'intro'), 'art_style' => getProp($episode_arr, 'art_style'), 'roles' => json_encode($merged_roles, 256), 'scenes' => json_encode($merged_scenes, 256), 'generate_status' => '待执行', 'generate_at' => $now, 'is_default' => 1, 'updated_at' => $now, ]; $del_flag = false; if ($is_regenerate_version) { DB::table('mp_anime_episodes') ->where('anime_id', $anime_id) ->where('episode_number', $episode_number) ->update(['is_default' => 0, 'updated_at' => $now]); $episode['sequence'] = $version_count + 1; $episode['created_at'] = $now; $episode_id = DB::table('mp_anime_episodes')->insertGetId($episode); if (!$episode_id) { Utils::throwError('20003:创建剧集版本失败'); } } else { $target_episode = $current_episode ?: $base_episode; if ($target_episode) { $episode_id = getProp($target_episode, 'id'); $episode['sequence'] = getProp($target_episode, 'sequence', 1); DB::table('mp_anime_episodes') ->where('anime_id', $anime_id) ->where('episode_number', $episode_number) ->where('id', '<>', $episode_id) ->update(['is_default' => 0, 'updated_at' => $now]); $boolen = DB::table('mp_anime_episodes')->where('id', $episode_id)->update($episode); if ($boolen === false) { logDB('deepseek', 'error', '分集内容保存失败', ['anime_id' => $anime_id, 'episode_id' => $episode_id]); Utils::throwError('20003:分集内容保存失败'); } $del_flag = true; } else { DB::table('mp_anime_episodes') ->where('anime_id', $anime_id) ->where('episode_number', $episode_number) ->update(['is_default' => 0, 'updated_at' => $now]); $episode['sequence'] = $version_count > 0 ? ($version_count + 1) : 1; $episode['created_at'] = $now; $episode_id = DB::table('mp_anime_episodes')->insertGetId($episode); if (!$episode_id) { logDB('deepseek', 'error', '分集内容保存失败', ['anime_id' => $anime_id, 'episode_number' => $episode_number]); Utils::throwError('20003:分集内容保存失败'); } } } $segments = []; $acts = isset($episode_arr['acts']) && is_array($episode_arr['acts']) ? $episode_arr['acts'] : []; $emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray(); $emotion_list = array_flip($emotion_list); foreach ($acts as $act) { $act_segments = getProp($act, 'segments', []); if (!is_array($act_segments)) { continue; } foreach ($act_segments as $segment) { $voice_actor = getProp($segment, 'voice_actor'); $timbre_info = isset($role_arr[$voice_actor]) ? $role_arr[$voice_actor] : []; $voice_type = getProp($timbre_info, 'voice_type'); $timbre_emotion = DB::table('mp_timbres')->where('timbre_type', $voice_type)->value('emotion'); if ($timbre_emotion) { $timbre_emotion = explode(',', $timbre_emotion); } else { $timbre_emotion = []; } $emotion = getProp($segment, 'emotion', '中性'); if (!in_array($emotion, $timbre_emotion)) { $emotion = '中性'; } $emotion_type = isset($emotion_list[$emotion]) ? $emotion_list[$emotion] : 'neutral'; $segments[] = [ 'anime_id' => $anime_id, 'episode_id' => $episode_id, 'episode_number' => $episode_number, 'act_number' => getProp($act, 'act_number'), 'act_title' => getProp($act, 'act_title'), 'segment_id' => getProp($segment, 'segment_id'), 'segment_number' => getProp($segment, 'segment_number'), 'segment_content' => getProp($segment, 'segment_content'), 'description' => getProp($segment, 'description'), 'composition' => getProp($segment, 'composition'), 'camera_movement' => getProp($segment, 'camera_movement'), 'voice_actor' => $voice_actor, 'dialogue' => getProp($segment, 'dialogue'), 'frame_type' => getProp($segment, 'frame_type'), 'scene' => getProp($segment, 'scene'), 'characters' => getProp($segment, 'characters'), 'tail_frame' => getProp($segment, 'tail_frame'), 'voice_name' => getProp($timbre_info, 'voice_name'), 'voice_type' => $voice_type, 'voice_audio_url' => getProp($timbre_info, 'voice_audio_url'), 'emotion' => $emotion, 'emotion_type' => $emotion_type, 'gender' => getProp($segment, 'gender', '0'), 'speed_ratio' => getProp($segment, 'speed_ratio', 0), 'loudness_ratio' => getProp($segment, 'loudness_ratio', 0), 'emotion_scale' => getProp($segment, 'emotion_scale', 4), 'pitch' => getProp($segment, 'pitch', 0), 'created_at' => $now, 'updated_at' => $now ]; } } if ($segments) { if ($del_flag) { DB::table('mp_episode_segments') ->where('anime_id', $anime_id) ->where('episode_id', $episode_id) ->delete(); } $boolen = DB::table('mp_episode_segments')->insert($segments); if (!$boolen) { logDB('deepseek', 'error', '分镜内容保存失败', ['anime_id' => $anime_id, 'episode_id' => $episode_id, 'segments_count' => count($segments)]); Utils::throwError('20003:分镜内容保存失败'); } } $episode['episode_id'] = $episode_id; $episode['roles'] = $merged_roles; $episode['scenes'] = $merged_scenes; $episode['acts'] = $acts; return [ 'episode' => $episode, 'episode_id' => $episode_id, 'merged_roles' => $merged_roles, 'merged_scenes' => $merged_scenes, ]; } private function buildEpisodeItemContent(array $items, string $nameKey): string { $content = ''; foreach ($items as $item) { $name = trim((string)getProp($item, $nameKey)); if ($name === '') { continue; } $description = trim((string)getProp($item, 'description')); $content .= $name . ': ' . $description . "\n"; } return trim($content); } private function getEpisodeChatMessages($animeId, $sequence): array { if ((int)$sequence <= 0) { return []; } return DB::table('mp_anime_records') ->where('anime_id', $animeId) ->where('sequence', $sequence) ->select('role', 'content') ->orderBy('created_at') ->orderBy('id') ->get() ->map(function ($value) { return (array)$value; }) ->toArray(); } private function mergeEpisodeResourceItems(array $existingItems, array $generatedItems, string $nameKey): array { $existingMap = []; foreach ($existingItems as $item) { $itemKey = $this->normalizeEpisodeResourceKey(getProp($item, $nameKey)); if ($itemKey === '') { continue; } $existingMap[$itemKey] = $item; } if (!$generatedItems) { return array_values($existingItems); } $merged = []; foreach ($generatedItems as $item) { $itemKey = $this->normalizeEpisodeResourceKey(getProp($item, $nameKey)); if ($itemKey === '') { continue; } if (isset($existingMap[$itemKey])) { if (trim((string)getProp($item, 'description')) === '') { $merged[] = $existingMap[$itemKey]; continue; } // 检查内容是否发生变化 $isChanged = $this->isEpisodeResourceChanged($existingMap[$itemKey], $item, $nameKey); if (!$isChanged) { // nameKey 和 description 一致,继承现有项的 URL、task_id 和 task_status $mergedItem = [ $nameKey => trim((string)getProp($item, $nameKey)), 'description' => trim((string)getProp($item, 'description')), ]; // 继承现有项的 URL $existingUrl = getProp($existingMap[$itemKey], 'url'); if (!empty($existingUrl)) { $mergedItem['url'] = $existingUrl; } // 继承现有项的 task_id $existingTaskId = getProp($existingMap[$itemKey], 'task_id'); if (!empty($existingTaskId)) { $mergedItem['task_id'] = $existingTaskId; } // 继承现有项的 task_status $existingTaskStatus = getProp($existingMap[$itemKey], 'task_status'); if (!empty($existingTaskStatus)) { $mergedItem['task_status'] = $existingTaskStatus; } // 保留生成项的音色字段 $voiceName = getProp($item, 'voice_name'); if (!empty($voiceName)) { $mergedItem['voice_name'] = $voiceName; } $voiceType = getProp($item, 'voice_type'); if (!empty($voiceType)) { $mergedItem['voice_type'] = $voiceType; } $audioUrl = getProp($item, 'voice_audio_url'); if (!empty($audioUrl)) { $mergedItem['voice_audio_url'] = $audioUrl; } $merged[] = $mergedItem; } else { // nameKey 或 description 不一致,重置 URL、task_id 和 task_status,但保留音色字段 $mergedItem = [ $nameKey => trim((string)getProp($item, $nameKey)), 'description' => trim((string)getProp($item, 'description')), ]; // 保留生成项的音色字段 $voiceName = getProp($item, 'voice_name'); if (!empty($voiceName)) { $mergedItem['voice_name'] = $voiceName; } $voiceType = getProp($item, 'voice_type'); if (!empty($voiceType)) { $mergedItem['voice_type'] = $voiceType; } $audioUrl = getProp($item, 'voice_audio_url'); if (!empty($audioUrl)) { $mergedItem['voice_audio_url'] = $audioUrl; } // 不继承 URL、task_id 和 task_status(重置状态) $merged[] = $mergedItem; } } else { // 新增项,保留生成项的所有字段 $merged[] = $this->resetEpisodeResourceItem($item, $nameKey, ['url', 'task_id', 'task_status', 'voice_name', 'voice_type', 'voice_audio_url']); } } return $merged ?: array_values($existingItems); } private function resetEpisodeResourceItem(array $item, string $nameKey, array $preserveFields = []): array { $resetItem = [ $nameKey => trim((string)getProp($item, $nameKey)), 'description' => trim((string)getProp($item, 'description')), ]; // 保留指定的字段 foreach ($preserveFields as $field) { $value = getProp($item, $field); if (!empty($value)) { $resetItem[$field] = $value; } } return $resetItem; } private function isEpisodeResourceChanged(array $existingItem, array $generatedItem, string $nameKey): bool { $existingKey = $this->normalizeEpisodeResourceKey(getProp($existingItem, $nameKey)); $generatedKey = $this->normalizeEpisodeResourceKey(getProp($generatedItem, $nameKey)); if ($existingKey !== $generatedKey) { return true; } return trim((string)getProp($existingItem, 'description')) !== trim((string)getProp($generatedItem, 'description')); } private function normalizeEpisodeResourceKey($value): string { $value = trim((string)$value); if ($value === '') { return ''; } return strtolower((string)preg_replace('/\s+/u', '', $value)); } // 生成全局角色图片 private function batchSetGlobalRoleImg($data, $is_force=false) { $anime_id = getProp($data, 'anime_id'); if (!$anime_id) Utils::throwError('1002:请选择对话'); $anime = DB::table('mp_animes')->where('id', $anime_id)->where('is_deleted', 0)->first(); $roles = json_decode(getProp($anime, 'roles'), true); $art_style = getProp($anime, 'art_style'); $update_flag = false; foreach ($roles as &$role) { $role_name = getProp($role, 'role'); if ($role_name == '旁白') continue; $description = getProp($role, 'description'); if ($art_style) $description = "美术风格:\n$art_style\n主体描述:$description"; // 参考图地址 $ref_img_url = getProp($role, 'url'); if (!$is_force && $ref_img_url) continue; $update_flag = true; // 此处调用AIImageGenerationService的生成图片任务接口: createImageGenerationTask(注意使用默认参数,prompt使用角色描述),并返回任务id try { $params = [ 'prompt' => $description, 'ref_img_urls' => !empty($ref_img_url) ? [$ref_img_url] : [] ]; $task = $this->aiImageGenerationService->createImageGenerationTask($params); $task_id = $task->id; if (!$task_id) { Utils::throwError("20003: 角色({$role_name})生成图片失败"); } $role['task_id'] = $task_id; $role['task_status'] = 'processing'; } catch (\Exception $e) { Utils::throwError("20003:" . $e->getMessage()); } } if (!$update_flag) return true; // 保存角色信息 $boolen = DB::table('mp_animes')->where('id', $anime_id)->update([ 'roles' => json_encode($roles, 256), 'generate_status' => '执行中', 'generate_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); if (!$boolen) { Utils::throwError('20003:保存角色信息失败'); } return $boolen; } // 生成全局场景图片 private function batchSetGlobalSceneImg($data, $is_force=false) { $anime_id = getProp($data, 'anime_id'); if (!$anime_id) Utils::throwError('1002:请选择对话'); $anime = DB::table('mp_animes')->where('id', $anime_id)->where('is_deleted', 0)->first(); $scenes = json_decode(getProp($anime, 'scenes'), true); $art_style = getProp($anime, 'art_style'); $update_flag = false; foreach ($scenes as &$scene) { $scene_name = getProp($scene, 'scene'); $description = getProp($scene, 'description'); if ($art_style) $description = "美术风格:\n$art_style\n\n场景描述:$description"; // 参考图地址 $ref_img_url = getProp($scene, 'url'); if (!$is_force && $ref_img_url) continue; $update_flag = true; // 此处调用AIImageGenerationService的生成图片任务接口: createImageGenerationTask(注意使用默认参数,prompt使用角色描述),并返回任务id try { $params = [ 'prompt' => $description, 'ref_img_urls' => !empty($ref_img_url) ? [$ref_img_url] : [] ]; $task = $this->aiImageGenerationService->createImageGenerationTask($params); $task_id = $task->id; if (!$task_id) { Utils::throwError("20003: 角色({$scene_name})生成图片失败"); } $scene['task_id'] = $task_id; $scene['task_status'] = 'processing'; } catch (\Exception $e) { Utils::throwError("20003:" . $e->getMessage()); } } if (!$update_flag) return true; // 保存角色信息 $boolen = DB::table('mp_animes')->where('id', $anime_id)->update([ 'scenes' => json_encode($scenes, 256), 'generate_status' => '执行中', 'generate_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); if (!$boolen) { Utils::throwError('20003:保存角色信息失败'); } return $boolen; } public function chatChangeImg($data) { $segment = getProp($data, 'segment'); $segment_content = getProp($segment, 'segment_content'); $prompt = getProp($data, 'prompt'); $ref_img = getProp($data, 'ref_img'); $question = "请修改已有的内容{$segment_content}\n修改要求如下:\n{$prompt}"; // 处理文本模型 $model = getProp($data, 'model'); if (!$model) { // 用户没有输入,从anime表获取 $segment_id = getProp($segment, 'segment_id'); if ($segment_id) { $episode_id = DB::table('mp_episode_segments')->where('segment_id', $segment_id)->value('episode_id'); if ($episode_id) { $anime_id = DB::table('mp_anime_episodes')->where('id', $episode_id)->value('anime_id'); if ($anime_id) { $model = DB::table('mp_animes')->where('id', $anime_id)->value('model'); } } } if (!$model) { // anime表也没有,使用默认值 $model = 'doubao-seed-2-0-mini-260215'; } } // 验证模型是否在可用模型表中 if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) { $model = 'doubao-seed-2-0-mini-260215'; } $messages[] = [ 'role' => 'user', 'content' => $question ]; $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => 8192, 'temperature' => 0.7, 'frequency_penalty' => 0, 'presence_penalty' => 0, 'response_format' => ['type' => 'text'], 'stream' => false // 是否启用流式输出 ]; if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // DeepSeek 官方模型使用 DeepSeek API $chatResult = $this->chatOnly($post_data); } else { // 其他模型使用火山引擎API $chatResult = $this->volcEngineChatCompletion($post_data); } if (is_array($chatResult)) { extract($chatResult); }else { Utils::throwError('20003: 接口调用失败!'); } return [ 'type' => 'done', // 'episode' => $episode, 'answer' => $fullContent, 'reasoning' => $fullReasoningContent, 'usage' => $usage ]; } // 仅调用deepseek对话接口 private function chatOnly($post_data) { $client = new Client(['timeout' => 1200, 'verify' => false]); $result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]); $response = $result->getBody()->getContents(); $response_arr = json_decode($response, true); dLog('deepseek')->info('deepseek请求完成', ['response' => $response_arr]); $fullContent = ''; $fullReasoningContent = []; $usage = isset($response_arr['usage']) ? $response_arr['usage'] : []; if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) { $fullContent = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : ''; $fullReasoningContent = isset($response_arr['choices'][0]['message']['reasoning_content']) ? $response_arr['choices'][0]['message']['reasoning_content'] : ''; } return [ 'fullContent' => $fullContent, 'fullReasoningContent' => $fullReasoningContent, 'usage' => $usage ]; } private function splitContent($content) { // 根据章节标题来拆分章节内容,返回包含标题和内容的二维数组 $chapters = []; // 匹配不同格式的章节标题:###第X章、##第X章、第X章 $pattern = '/^(#{0,3}\s*第[^章]*章[^\n]*)\n(.*?)(?=\n#{0,3}\s*第[^章]*章|\z)/ms'; if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { $title = trim($match[1], "# \t\n\r\0\x0B"); // 去除标题前的#号和空白字符 $chapterContent = trim($match[2]); // 去除内容前后的空白字符 if (!empty($title) && !empty($chapterContent)) { $chapters[] = [ 'title' => $title, 'content' => $chapterContent ]; } } }else { $chapters[] = [ 'title' => '', 'content' => $content ]; } return $chapters; } public function getContentByBid($bid) { $chapters = DB::table('chapters as c')->select('c.id', 'c.name', 'cc.content') ->leftJoin('chapter_contents as cc', 'c.chapter_content_id', '=', 'cc.id') ->where('c.bid', $bid)->where('c.is_check', 1)->where('c.is_deleted', 0)->orderBy('c.sequence')->get(); $content = ''; foreach ($chapters as $chapter) { $content .= $chapter->content . PHP_EOL . PHP_EOL; } return $content; } public function getContentByScriptId($script_id) { $content = DB::table('mp_scripts')->where('id', $script_id)->value('content'); if (!$content) $content = ''; return $content; } /** * 根据文件类型提取文本内容 * * @param mixed $file 文件对象或文件路径 * @return string */ public function extractFileContent($file) { // 获取文件路径和扩展名 if (is_string($file)) { $filePath = $file; $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)); } else { // Laravel UploadedFile 对象 $filePath = $file->getRealPath(); $extension = strtolower($file->getClientOriginalExtension()); } $content = ''; switch ($extension) { case 'txt': $content = $this->extractTxtContent($filePath); break; case 'pdf': $content = $this->extractPdfContent($filePath); break; case 'doc': case 'docx': $content = $this->extractWordContent($filePath); break; // case 'jpg': // case 'jpeg': // case 'png': // case 'gif': // case 'bmp': // case 'webp': // $content = $this->extractImageContent($filePath); // break; default: Utils::throwError('20003:不支持的文件格式,支持:txt、pdf'); } return $content; } /** * 提取 TXT 文件内容 */ private function extractTxtContent($filePath) { if (!file_exists($filePath)) { Utils::throwError('20003:文件不存在'); } $content = file_get_contents($filePath); // 尝试检测并转换编码 $encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312', 'BIG5'], true); if ($encoding && $encoding !== 'UTF-8') { $content = mb_convert_encoding($content, 'UTF-8', $encoding); } return trim($content); } /** * 提取 PDF 文件内容 */ private function extractPdfContent($filePath) { if (!file_exists($filePath)) { Utils::throwError('20003:文件不存在'); } try { $parser = new PdfParser(); $pdf = $parser->parseFile($filePath); $content = $pdf->getText(); return trim($content); } catch (\Exception $e) { dLog('deepseek')->error('PDF解析失败: ' . $e->getMessage()); logDB('deepseek', 'error', 'PDF解析失败', ['error' => $e->getMessage()]); Utils::throwError('20003:PDF解析失败,请确保文件格式正确'); } } /** * 提取 Word 文件内容(支持 doc 和 docx) */ private function extractWordContent($filePath) { if (!file_exists($filePath)) { Utils::throwError('20003:文件不存在'); } try { $phpWord = WordIOFactory::load($filePath); $content = ''; foreach ($phpWord->getSections() as $section) { foreach ($section->getElements() as $element) { $content .= $this->extractWordElementText($element); } } return trim($content); } catch (\Exception $e) { dLog('deepseek')->error('Word解析失败: ' . $e->getMessage()); logDB('deepseek', 'error', 'Word解析失败', ['error' => $e->getMessage()]); Utils::throwError('20003:Word解析失败,请确保文件格式正确'); } } /** * 递归提取 Word 元素中的文本 */ private function extractWordElementText($element) { $text = ''; if (method_exists($element, 'getText')) { $text .= $element->getText() . "\n"; } elseif (method_exists($element, 'getElements')) { foreach ($element->getElements() as $childElement) { $text .= $this->extractWordElementText($childElement); } } return $text; } /** * 提取图片内容(使用火山引擎 OCR) */ private function extractImageContent($filePath) { if (!file_exists($filePath)) { Utils::throwError('20003:文件不存在'); } try { // 读取图片并转换为 base64 $imageData = file_get_contents($filePath); $base64Image = base64_encode($imageData); // 调用火山引擎 OCR 服务 $content = $this->callVolcEngineOCR($base64Image); return trim($content); } catch (\Exception $e) { dLog('deepseek')->error('图片OCR识别失败: ' . $e->getMessage()); logDB('deepseek', 'error', '图片OCR识别失败', ['error' => $e->getMessage()]); Utils::throwError('20003:图片识别失败'); } } /** * 调用火山引擎 OCR 服务识别图片文字 * * @param string $base64Image base64 编码的图片数据 * @return string 识别的文字内容 */ private function callVolcEngineOCR($base64Image) { $method = 'POST'; $service = 'cv'; $host = 'visual.volcengineapi.com'; $region = env('VOLC_REGION', 'cn-north-1'); $access_key = env('VOLC_AK'); $secret_key = env('VOLC_SK'); $action = 'OCRNormal'; $version = '2020-08-26'; if (!$access_key || !$secret_key) { Utils::throwError('20003:请配置火山引擎 AK/SK'); } // 请求体 $body = json_encode([ 'image_base64' => $base64Image ]); // 生成签名 $headers = $this->getVolcEngineSignHeaders( $method, $service, $host, $region, "Action={$action}&Version={$version}", $access_key, $secret_key, $body ); $client = new Client(['timeout' => 60, 'verify' => false]); $response = $client->post("https://{$host}/?Action={$action}&Version={$version}", [ 'headers' => $headers, 'body' => $body ]); $response_arr = json_decode($response->getBody()->getContents(), true); // 提取识别的文字 $textLines = []; if (isset($response_arr['data']['line_texts'])) { $textLines = $response_arr['data']['line_texts']; } elseif (isset($response_arr['data']['ocr_infos'])) { foreach ($response_arr['data']['ocr_infos'] as $info) { if (isset($info['text'])) { $textLines[] = $info['text']; } } } if (empty($textLines)) { dLog('deepseek')->warning('OCR识别结果为空', ['response' => $response_arr]); return ''; } return implode("\n", $textLines); } /** * 生成火山引擎 API 签名头(用于 OCR 等 POST 请求) */ private function getVolcEngineSignHeaders($method, $service, $host, $region, $queryString, $access_key, $secret_key, $body = '') { $contentType = 'application/json'; $accept = 'application/json'; // 获取当前UTC时间 $t = new DateTime('now', new DateTimeZone('UTC')); $xDate = $t->format('Ymd\THis\Z'); $dateStamp = $t->format('Ymd'); // 计算 body 的 sha256 哈希 $payloadHash = hash('sha256', $body); // 1. 拼接规范请求串 $canonicalUri = '/'; $canonicalQueryString = $queryString; $canonicalHeaders = "content-type:{$contentType}\nhost:{$host}\nx-content-sha256:{$payloadHash}\nx-date:{$xDate}\n"; $signedHeaders = 'content-type;host;x-content-sha256;x-date'; $canonicalRequest = implode("\n", [ $method, $canonicalUri, $canonicalQueryString, $canonicalHeaders, $signedHeaders, $payloadHash ]); // 2. 拼接待签名字符串 $algorithm = 'HMAC-SHA256'; $credentialScope = implode('/', [$dateStamp, $region, $service, 'request']); $hashedCanonicalRequest = hash('sha256', $canonicalRequest); $stringToSign = implode("\n", [ $algorithm, $xDate, $credentialScope, $hashedCanonicalRequest ]); // 3. 计算签名 $signingKey = $this->getSignatureKey($secret_key, $dateStamp, $region, $service); $signature = hash_hmac('sha256', $stringToSign, $signingKey); // 4. 添加签名到请求头 $authorizationHeader = sprintf( '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s', $algorithm, $access_key, $credentialScope, $signedHeaders, $signature ); return [ 'Accept' => $accept, 'Content-Type' => $contentType, 'Host' => $host, 'X-Date' => $xDate, 'X-Content-Sha256'=> $payloadHash, 'Authorization' => $authorizationHeader ]; } public function generateScriptWords($cid, $model = 'r1') { ini_set('max_execution_time', 0); if (!$cid) Utils::throwError('20003: 请选择章节!'); $content = DB::table('chapters as c')->leftJoin('chapter_contents as cc', 'c.chapter_content_id', '=', 'cc.id')->where('c.id', $cid)->value('cc.content'); // 模型兼容性处理和思考模式设置 $thinkingMode = 'disabled'; $reasoningEffort = 'high'; if ($model == 'r1') { $model = 'deepseek-v4-flash'; // r1 映射到 deepseek-v4-flash 思考模式 $thinkingMode = 'enabled'; } else { $model = 'deepseek-v4-flash'; // 默认映射到 deepseek-v4-flash 非思考模式 $thinkingMode = 'disabled'; } $messages = [ [ 'role' => 'system', 'content' => '下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男或女):台词{情感}”输出,需要注意以下几点要求: 1.角色名后不要加入任何其他词语,只能加不包括旁白的性别,在男或女中选 2.非对话部分请全部用旁白角色代替 3.情感必须在【开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰鼓励、咆哮、温柔、自然讲述、情感电台、磁性、广告营销、气泡音、新闻播报、娱乐八卦】中选一个,不得使用其他词语' ], [ 'role' => 'user', 'content' => $content ] ]; $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => 8192, 'temperature' => 1, 'frequency_penalty' => 0, 'presence_penalty' => 0, 'thinking' => ['type' => $thinkingMode], 'response_format' => [ 'type' => 'text' ], 'stream' => false ]; if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort; // 根据模型类型选择调用方法 $fullContent = ''; if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // DeepSeek 官方模型使用 DeepSeek API $chatResult = $this->chatOnly($post_data); } else { // 其他模型使用火山引擎API $chatResult = $this->volcEngineChatCompletion($post_data); } if (is_array($chatResult)) { $fullContent = $chatResult['fullContent']; } return $fullContent; } /** * 智能化解析集数信息 * 支持多种表达方式,如:改成3集、调整成5集、修改为10集等 * * @param string $prompt 用户输入的提示词 * @return int|null 解析出的集数,如果没有找到则返回null */ private function extractEpisodeNumber($prompt) { if (empty($prompt)) { return null; } // 定义各种可能的表达模式 $patterns = [ // 基本模式:改成N集、调整成N集、修改成N集 '/(?:改成|调整成|修改成|变成|设置成|设为|改为|调为|修改为|变为)\s*(\d+)\s*集/u', // 扩展模式:分成N集、拆分成N集、分割成N集 '/(?:分成|拆分成|分割成|划分成|分为)\s*(\d+)\s*集/u', // 数量模式:N集、共N集、总共N集 '/(?:共|总共|一共|总计)?\s*(\d+)\s*集/u', // 制作模式:制作N集、生成N集、创建N集 '/(?:制作|生成|创建|产出|输出)\s*(\d+)\s*集/u', // 计划模式:计划N集、预计N集、打算N集 '/(?:计划|预计|打算|准备|想要)\s*(\d+)\s*集/u', // 需要模式:需要N集、要N集 '/(?:需要|要|需)\s*(\d+)\s*集/u', // 中文数字模式:改成三集、调整成五集等 '/(?:改成|调整成|修改成|变成|设置成|设为|改为|调为|修改为|变为|分成|拆分成|分割成|划分成|分为|制作|生成|创建|产出|输出|计划|预计|打算|准备|想要|需要|要|需)\s*([一二三四五六七八九十]+)\s*集/u', // 英文数字模式 '/(?:change\s+to|adjust\s+to|modify\s+to|set\s+to|make\s+it|split\s+into|divide\s+into)\s*(\d+)\s*(?:episodes?|eps?)/i', ]; // 中文数字转阿拉伯数字的映射 $chineseNumbers = [ '一' => 1, '二' => 2, '三' => 3, '四' => 4, '五' => 5, '六' => 6, '七' => 7, '八' => 8, '九' => 9, '十' => 10, '十一' => 11, '十二' => 12, '十三' => 13, '十四' => 14, '十五' => 15, '十六' => 16, '十七' => 17, '十八' => 18, '十九' => 19, '二十' => 20 ]; // 逐个尝试匹配模式 foreach ($patterns as $pattern) { if (preg_match($pattern, $prompt, $matches)) { $number = trim($matches[1]); // 如果是中文数字,转换为阿拉伯数字 if (isset($chineseNumbers[$number])) { return $chineseNumbers[$number]; } // 如果是阿拉伯数字,直接返回 if (is_numeric($number)) { $episodeNum = intval($number); // 合理性检查:集数应该在1-100之间 if ($episodeNum >= 1 && $episodeNum <= 100) { return $episodeNum; } } } } return null; } /** * 调用火山引擎对话(Chat) API * * @param array $params 包含以下参数: * - model: 模型ID(必填) * - messages: 消息列表(必填) * - stream: 是否流式输出(可选,默认false) * - max_tokens: 最大输出token数(可选) * - temperature: 采样温度(可选,默认1) * - top_p: 核采样概率(可选,默认0.7) * - frequency_penalty: 频率惩罚系数(可选,默认0) * - presence_penalty: 存在惩罚系数(可选,默认0) * - stop: 停止词(可选) * - tools: 工具列表(可选) * @return array|Generator 非流式返回数组,流式返回生成器 */ public function volcEngineChatCompletion(array $params) { $apiKey = env('VOLC_AI_API_KEY'); if (empty($apiKey)) { Utils::throwError('20003:火山引擎API Key未配置,请检查环境变量VOLC_AI_API_KEY'); } // 构建请求参数 $originalModel = $params['model'] ?? 'deepseek-v3-2-251201'; $requestData = [ 'model' => isset(\App\Consts\BaseConst::MODELS_MAP[$originalModel]) ? \App\Consts\BaseConst::MODELS_MAP[$originalModel] : $originalModel, 'messages' => $params['messages'] ?? [], ]; if (!in_array($originalModel, $this->valid_text_models)) { Utils::throwError('20003:该模型不支持!'); } // 添加可选参数 if (isset($params['stream'])) { $requestData['stream'] = $params['stream']; } if (isset($params['stream_options'])) { $requestData['stream_options'] = $params['stream_options']; } if (isset($params['max_tokens'])) { $requestData['max_tokens'] = $params['max_tokens']; } if (isset($params['max_completion_tokens'])) { $requestData['max_completion_tokens'] = $params['max_completion_tokens']; } if (isset($params['temperature'])) { $requestData['temperature'] = $params['temperature']; } if (isset($params['top_p'])) { $requestData['top_p'] = $params['top_p']; } if (isset($params['frequency_penalty'])) { $requestData['frequency_penalty'] = $params['frequency_penalty']; } if (isset($params['presence_penalty'])) { $requestData['presence_penalty'] = $params['presence_penalty']; } if (isset($params['stop'])) { $requestData['stop'] = $params['stop']; } if (isset($params['tools'])) { $requestData['tools'] = $params['tools']; } if (isset($params['tool_choice'])) { $requestData['tool_choice'] = $params['tool_choice']; } if (isset($params['response_format'])) { $requestData['response_format'] = $params['response_format']; } if (isset($params['thinking'])) { // 如果 thinking 已经是数组格式,直接使用;否则转换为正确格式 if (is_array($params['thinking'])) { $requestData['thinking'] = $params['thinking']; if (isset($params['reasoning_effort']) && $params['thinking']['type'] == 'enabled') { $requestData['reasoning_effort'] = $params['reasoning_effort']; } } else { $requestData['thinking'] = ['type' => $params['thinking']]; if (isset($params['reasoning_effort']) && $params['thinking'] == 'enabled') { $requestData['reasoning_effort'] = $params['reasoning_effort']; } } } else { $requestData['thinking'] = ['type' => 'disabled']; } dLog('deepseek')->info('火山引擎对话API请求参数: ', $requestData); $client = new Client(['verify' => false, 'timeout' => 1200]); try { // 判断是否为流式输出 $isStream = isset($params['stream']) && $params['stream'] === true; if ($isStream) { // 流式输出 return $this->volcEngineChatCompletionStream($client, $apiKey, $requestData); } else { // 非流式输出 $response = $client->post('https://ark.cn-beijing.volces.com/api/v3/chat/completions', [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', ], 'json' => $requestData ]); $responseData = json_decode($response->getBody(), true); dLog('deepseek')->info('火山引擎对话API响应: ', $responseData); $usage = isset($responseData['usage']) ? $responseData['usage'] : []; if (isset($responseData['choices']) && count($responseData['choices']) > 0) { $fullContent = isset($responseData['choices'][0]['message']['content']) ? $responseData['choices'][0]['message']['content'] : ''; $fullReasoningContent = isset($responseData['choices'][0]['message']['reasoning_content']) ? $responseData['choices'][0]['message']['reasoning_content'] : ''; } return [ 'fullContent' => $fullContent, 'fullReasoningContent' => $fullReasoningContent, 'usage' => $usage ]; } } catch (\Exception $e) { dLog('deepseek')->error('火山引擎对话API请求异常: ' . $e->getMessage()); logDB('deepseek', 'error', '火山引擎对话API请求失败', ['error' => $e->getMessage(), 'model' => $requestData['model']]); Utils::throwError('20003:火山引擎对话API请求失败: ' . $e->getMessage()); } } /** * 火山引擎对话API流式输出 * * @param Client $client HTTP客户端 * @param string $apiKey API密钥 * @param array $requestData 请求数据 * @return Generator */ private function volcEngineChatCompletionStream($client, $apiKey, $requestData) { try { $response = $client->post('https://ark.cn-beijing.volces.com/api/v3/chat/completions', [ 'headers' => [ 'Authorization' => 'Bearer ' . $apiKey, 'Content-Type' => 'application/json', ], 'json' => $requestData, 'stream' => true ]); $body = $response->getBody(); $buffer = ''; $fullContent = ''; $fullReasoningContent = ''; $usage = []; dLog('deepseek')->info('开始读取火山引擎对话API流式响应'); // 逐行读取流式响应 while (!$body->eof()) { $chunk = $body->read(1024); $buffer .= $chunk; // 按行分割 $lines = explode("\n", $buffer); $buffer = array_pop($lines); foreach ($lines as $line) { $line = trim($line); if (empty($line)) { continue; } // 检查是否是SSE数据行 if (strpos($line, 'data: ') !== 0) { continue; } $data = substr($line, 6); if ($data === '[DONE]') { dLog('deepseek')->info('收到结束标记'); break 2; } try { $json = json_decode($data, true); if (json_last_error() !== JSON_ERROR_NONE) { dLog('deepseek')->warning('JSON解析错误: ' . json_last_error_msg()); continue; } if (isset($json['choices'][0]['delta'])) { $delta = $json['choices'][0]['delta']; // 处理思维链内容 if (isset($delta['reasoning_content'])) { $fullReasoningContent .= $delta['reasoning_content']; yield [ 'type' => 'reasoning', 'content' => $delta['reasoning_content'], 'full_reasoning' => $fullReasoningContent ]; } // 处理回答内容 if (isset($delta['content'])) { $fullContent .= $delta['content']; yield [ 'type' => 'content', 'content' => $delta['content'], ]; } } // 获取使用统计信息 if (isset($json['usage'])) { $usage = $json['usage']; dLog('deepseek')->info('收到使用统计', ['usage' => $usage]); } } catch (\Exception $e) { dLog('deepseek')->error('解析流式响应失败: ' . $e->getMessage()); continue; } } } dLog('deepseek')->info('流式读取完成', [ 'content_length' => strlen($fullContent), 'reasoning_length' => strlen($fullReasoningContent) ]); yield [ 'type' => 'done', 'full_content' => $fullContent, 'full_reasoning' => $fullReasoningContent, 'usage' => $usage ]; } catch (\Exception $e) { dLog('deepseek')->error('火山引擎对话API流式请求异常: ' . $e->getMessage()); logDB('deepseek', 'error', '火山引擎对话API流式请求失败', ['error' => $e->getMessage()]); throw $e; } } /** * 检查并创建图片生成任务 * * @param array $merged_items 合并后的资源列表(roles 或 scenes) * @param array $global_items 全局资源列表(来自 mp_animes 表) * @param string $nameKey 资源名称字段('role' 或 'scene') * @param string $art_style 美术风格 * @return array 更新后的资源列表 */ private function checkAndCreateImageTasks(array $merged_items, array $global_items, string $nameKey, string $art_style = ''): array { // 构建全局资源映射表,用于快速查找 $global_map = []; foreach ($global_items as $item) { $itemKey = $this->normalizeEpisodeResourceKey(getProp($item, $nameKey)); if ($itemKey === '') { continue; } $global_map[$itemKey] = $item; } // 遍历合并后的资源列表,检查是否需要创建图片生成任务 foreach ($merged_items as &$item) { $item_name = getProp($item, $nameKey); $item_description = getProp($item, 'description'); // $item_url = getProp($item, 'url'); // // 如果已有图片URL,跳过 // if (!empty($item_url)) { // continue; // } // 如果是旁白角色,跳过 if ($nameKey === 'role' && $item_name === '旁白') { continue; } $itemKey = $this->normalizeEpisodeResourceKey($item_name); if ($itemKey === '') { continue; } $need_create_task = false; // 条件1:在全局资源中找不到匹配的名称 if (!isset($global_map[$itemKey])) { $need_create_task = true; } else { // 条件2:名称匹配但描述不一致 $global_description = trim((string)getProp($global_map[$itemKey], 'description')); $current_description = trim((string)$item_description); if ($global_description !== $current_description) { $need_create_task = true; } } // 如果需要创建任务,调用图片生成服务 if ($need_create_task) { try { $description = $item_description; // 添加美术风格前缀 if ($art_style) { $prefix = $nameKey === 'role' ? '主体描述' : '场景描述'; $description = "美术风格:\n{$art_style}\n\n{$prefix}:{$description}"; } $params = [ 'prompt' => $description, 'ref_img_urls' => [] ]; $task = $this->aiImageGenerationService->createImageGenerationTask($params); $task_id = $task->id; if ($task_id) { $item['task_id'] = $task_id; $item['task_status'] = 'processing'; $resource_type = $nameKey === 'role' ? '角色' : '场景'; dLog('deepseek')->info("{$resource_type}({$item_name})创建图片生成任务成功", ['task_id' => $task_id]); } } catch (\Exception $e) { $resource_type = $nameKey === 'role' ? '角色' : '场景'; dLog('deepseek')->error("{$resource_type}({$item_name})创建图片生成任务失败: " . $e->getMessage()); logDB('deepseek', 'error', "{$resource_type}创建图片生成任务失败", ['name' => $item_name, 'error' => $e->getMessage()]); // 不抛出异常,继续处理其他资源 } } } return $merged_items; } /** * 解析分镜内容的公开方法 */ public function parseSegmentContent($segment_content, $model = null) { if (!$model) { $model = DB::table('mp_text_models')->where('is_enabled', 1)->orderBy('id')->value('model'); if (!$model) { return $segment_content; } } // 构建系统提示词 $systemPrompt = "你是一个专业的分镜内容解析助手。请将用户提供的分镜内容解析成标准格式,包含以下字段: 画面描述:详细描述画面内容 构图设计:镜头构图方式 运镜调度:摄影机运动方式 配音角色:配音的角色名称 台词内容:角色说的话 画面类型:普通画面或对口型 场景:拍摄场景 出镜角色:画面中出现的角色 尾帧描述:镜头结束时的画面描述 情感:配音情感(开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰鼓励、咆哮、温柔、自然讲述、情感电台、磁性、广告营销、气泡音、新闻播报、娱乐八卦) 性别:1(男)、2(女)、0(中性) 语速:-50到100之间的整数 音量:-50到100之间的整数 情感强度:1到5之间的整数 音调:-12到12之间的整数 请严格按照上述格式输出,如果某个字段无法确定,请设置合理的默认值。"; // 构建消息 $messages = [ [ 'role' => 'system', 'content' => $systemPrompt ], [ 'role' => 'user', 'content' => $segment_content ] ]; $post_data = [ 'model' => $model, 'messages' => $messages, // 'max_tokens' => 2048, 'temperature' => 0.7, 'frequency_penalty' => 0, 'presence_penalty' => 0, 'response_format' => ['type' => 'text'], 'stream' => false ]; try { if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) { // 使用DeepSeek API $chatResult = $this->chatOnly($post_data); } else { // 使用火山引擎API $chatResult = $this->volcEngineChatCompletion($post_data); } if (is_array($chatResult) && !empty($chatResult['fullContent'])) { return $chatResult['fullContent']; } } catch (\Exception $e) { dLog('deepseek')->error('解析分镜内容失败: ' . $e->getMessage()); logDB('deepseek', 'error', '解析分镜内容失败', ['error' => $e->getMessage()]); } return $segment_content; } }