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();
// GPT文本模型列表
$this->gpt_text_models = BaseConst::GPT_TEXT_MODELS;
$this->sys_message = [
'role' => 'system',
'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集剧本>,每个板块之间必须使用###分隔,#不能多也不能少;同时板块之间需满足以下要求:\n
强制要求:\n
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.用户如果直接给了你一本小说或者要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求
3.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n
2.<分集剧本>的要求如下:
2.1包含“分集序号”、“分集标题”以及“分集信息”,“分集信息”需包含“场景描述”、“运镜”、“出场角色”和“台词内容”这几个部分,其中“出场角色”和“场景描述”必须在前面的板块<主体列表>和<场景列表>中选取,不得自定义。
2.2.分集序号可能不是从 1 开始,也可能是跳跃的(例如直接从第 3 集、第 5 集开始)。你只需要基于下面给出的“当前集内容要点”来创作这一集即可。
2.3.不需要强行补齐前面未提供的集数,也不需要预测后面未提供的集数,但请保持本集内部故事的完整性和节奏。
2.4.总集数将作为本季整体篇幅的参考,你可以根据它调整本集的情节推进速度(例如:如果总集数很少,本集应更紧凑;如果总集数很多,本集可以适当展开细节)
\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。
###分集剧本
##分集01
第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'];
}
// 根据模型类型选择调用方法
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->gpt_text_models)) {
// GPT 模型使用 TokenRouter API
return $this->gpt54StreamResponse($post_data);
} else if (in_array($model, $this->valid_text_models)) {
// 火山引擎支持的模型使用火山引擎 API
return $this->volcEngineChatCompletion($post_data);
} else {
Utils::throwError('20003:不支持的模型: ' . $model);
}
}
public function createGenerateText($data) {
$file = getProp($data, 'file');
$uid = Site::getUid();
$cpid = Site::getCpid();
if (!$file) {
Utils::throwError('20003:请上传文件');
}
// 提取文件内容
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
// 获取原始文件名(不含扩展名)作为script_name
$originalName = $file->getClientOriginalName();
$script_name = pathinfo($originalName, PATHINFO_FILENAME);
if (!$script_name) {
$script_name = '剧本_' . date('YmdHis');
}
// 如果剧本名称已存在,添加时间戳后缀
if (DB::table('mp_scripts')->where('script_name', $script_name)->where('is_deleted', 0)->exists()) {
$script_name .= '_' . date('YmdHis');
}
// 插入数据到mp_scripts表
$script_id = DB::table('mp_scripts')->insertGetId([
'script_name' => $script_name,
'user_id' => $uid,
'cpid' => $cpid,
'content' => $content,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
return [
'script_id' => $script_id,
'script_name' => $script_name
];
}
/**
* 通用文生文方法(非流式版本)- 支持多模型、图片输入、JSON输出和模板提示词
*
* @param array $data 请求参数
* @return array 返回结果数组
*/
public function newGenerateText($data) {
// 获取请求参数
$model = getProp($data, 'model', 'deepseek-v4-pro'); // 默认使用deepseek-v4
$messages = getProp($data, 'messages', []);
$systemPrompt = getProp($data, 'system_prompt', '');
$prompt = getProp($data, 'prompt', '');
$images = getProp($data, 'images', []); // 支持多张图片
$imageUrls = getProp($data, 'image_urls', []); // 支持图片URL
$responseFormat = getProp($data, 'response_format', 'text'); // 新增: 响应格式,默认text,可选json
$templateId = getProp($data, 'template_id', 0); // 新增: 模板ID
$script_id = getProp($data, 'script_id', 0); // 新增: 剧本ID
$sequence = getProp($data, 'sequence', 0); // 新增: 剧集序号
// 新增: 如果提供了script_id,从数据库获取剧本内容作为原文
$originalContent = '';
if ($script_id > 0) {
$script = DB::table('mp_scripts')
->where('id', $script_id)
->where('is_deleted', 0)
->first();
if (!$script) {
Utils::throwError('20003:剧本不存在或已删除');
}
$originalContent = $script->content ?? '';
if (empty($originalContent)) {
Utils::throwError('20003:剧本内容为空');
}
// 将剧本内容添加到提示词前面
if (!empty($originalContent)) {
if (!empty($prompt)) {
$prompt = "原文内容:\n" . $originalContent . "\n\n用户要求:\n" . $prompt;
} else {
$prompt = "原文内容:\n" . $originalContent;
}
}
}
// 新增: 如果提供了template_id,从数据库获取template_prompt
if ($templateId > 0) {
$template = DB::table('mp_prompt_templates')
->where('id', $templateId)
->where('is_deleted', 0)
->first();
if (!$template) {
Utils::throwError('20003:模板不存在或已删除');
}
$templatePrompt = $template->template_prompt ?? '';
// 将模板提示词和用户输入的prompt组合
// 模板为准,用户输入作为补充要求
if (!empty($templatePrompt)) {
if (!empty($prompt)) {
$prompt = $templatePrompt . "\n\n" . $prompt;
} else {
$prompt = $templatePrompt;
}
}
}
// 如果没有提供messages,则根据system_prompt、prompt和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,
'temperature' => getProp($data, 'temperature', 1),
'frequency_penalty' => getProp($data, 'frequency_penalty', 0),
'presence_penalty' => getProp($data, 'presence_penalty', 0),
'thinking' => ['type' => $thinkingMode],
'stream' => false // 非流式输出
];
if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort;
// 添加可选参数
if (isset($data['top_p'])) {
$post_data['top_p'] = $data['top_p'];
}
// 新增: 根据response_format设置响应格式
if ($responseFormat === 'json') {
// 检查提示词中是否包含"json"关键词,如果没有则自动添加
$hasJsonKeyword = false;
foreach ($post_data['messages'] as $message) {
if (isset($message['content'])) {
$content = is_array($message['content']) ? json_encode($message['content']) : $message['content'];
if (stripos($content, 'json') !== false) {
$hasJsonKeyword = true;
break;
}
}
}
// 如果提示词中没有"json"关键词,在最后一条用户消息中添加JSON格式说明
// 这对所有模型都适用,确保模型理解需要返回JSON格式
if (!$hasJsonKeyword) {
$lastMessageIndex = count($post_data['messages']) - 1;
if ($lastMessageIndex >= 0 && $post_data['messages'][$lastMessageIndex]['role'] === 'user') {
$lastContent = $post_data['messages'][$lastMessageIndex]['content'];
// 如果content是字符串,直接追加
if (is_string($lastContent)) {
$post_data['messages'][$lastMessageIndex]['content'] .= "\n\n请以JSON格式返回结果。";
}
// 如果content是数组(包含文本和图片),在文本部分追加
else if (is_array($lastContent)) {
foreach ($post_data['messages'][$lastMessageIndex]['content'] as &$contentPart) {
if ($contentPart['type'] === 'text') {
$contentPart['text'] .= "\n\n请以JSON格式返回结果。";
break;
}
}
}
dLog('deepseek')->info('自动添加JSON格式提示词', ['model' => $model]);
}
}
// 设置response_format参数(所有支持的模型)
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
// DeepSeek模型
$post_data['response_format'] = ['type' => 'json_object'];
} else if (in_array($model, $this->gpt_text_models)) {
// GPT模型
$post_data['response_format'] = ['type' => 'json_object'];
} else if (in_array($model, $this->valid_text_models)) {
// 火山引擎模型
$post_data['response_format'] = ['type' => 'json_object'];
}
}
// 根据模型类型选择调用方法(复用现有的chatOnly方法)
$aiResult = null;
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
// DeepSeek 官方模型使用现有的 chatOnly 方法
$result = $this->chatOnly($post_data);
// 记录实际使用的模型
dLog('deepseek')->info('newGenerateText使用DeepSeek模型', ['requested_model' => $model, 'actual_model' => $model]);
$aiResult = [
'content' => $result['fullContent'],
'reasoning_content' => $result['fullReasoningContent'],
'usage' => $result['usage'],
'model' => $model,
'finish_reason' => 'stop'
];
} else if (in_array($model, $this->gpt_text_models)) {
// GPT 模型使用现有的 gpt54ChatOnly 方法
$result = $this->gpt54ChatOnly($post_data);
dLog('deepseek')->info('newGenerateText使用GPT模型', ['requested_model' => $model]);
$aiResult = [
'content' => $result['fullContent'],
'reasoning_content' => '',
'usage' => $result['usage'],
'model' => $model,
'finish_reason' => 'stop'
];
} else if (in_array($model, $this->valid_text_models)) {
// 火山引擎支持的模型使用火山引擎 API(非流式)
$post_data['stream'] = false;
$result = $this->volcEngineChatCompletion($post_data);
dLog('deepseek')->info('newGenerateText使用火山引擎模型', [
'requested_model' => $model,
'result_keys' => array_keys($result),
'has_fullContent' => isset($result['fullContent']),
'has_content' => isset($result['content'])
]);
// 火山引擎返回的数据结构使用 fullContent 和 fullReasoningContent
$aiResult = [
'content' => $result['fullContent'] ?? $result['content'] ?? '',
'reasoning_content' => $result['fullReasoningContent'] ?? $result['reasoning_content'] ?? '',
'usage' => $result['usage'] ?? [],
'model' => $model,
'finish_reason' => $result['finish_reason'] ?? 'stop'
];
} else {
Utils::throwError('20003:不支持的模型: ' . $model);
}
// 新增: 如果提供了script_id,保存对话记录
if ($script_id > 0 && $aiResult) {
try {
$uid = Site::getUid();
$now = now();
// 获取用户原始的prompt(不包含剧本内容)
$originalPrompt = getProp($data, 'prompt', '');
$recordsToInsert = [
[
'uid' => $uid,
'script_id' => $script_id,
'sequence' => $sequence,
'role' => 'user',
'content' => $originalPrompt,
'created_at' => $now,
'updated_at' => $now,
],
[
'uid' => $uid,
'script_id' => $script_id,
'sequence' => $sequence,
'role' => 'assistant',
'content' => $aiResult['content'],
'created_at' => $now,
'updated_at' => $now,
]
];
DB::table('mp_script_records')->insert($recordsToInsert);
// 获取刚插入的记录ID(假设按插入顺序返回)
$insertedRecords = DB::table('mp_script_records')
->where('uid', $uid)
->where('script_id', $script_id)
->where('sequence', $sequence)
->orderBy('id', 'desc')
->limit(2)
->get()
->map(function ($record) {
return [
'rid' => $record->id,
'script_id' => $record->script_id,
'sequence' => $record->sequence,
'role' => $record->role,
'content' => $record->content,
'created_at' => $record->created_at,
];
})
->reverse()
->values()
->toArray();
// 将记录信息添加到返回结果中
$aiResult['records'] = $insertedRecords;
dLog('deepseek')->info('保存剧本对话记录成功', ['script_id' => $script_id, 'sequence' => $sequence, 'record_ids' => array_column($insertedRecords, 'rid')]);
} catch (\Exception $e) {
// 记录错误但不影响主流程
dLog('deepseek')->error('保存剧本对话记录失败: ' . $e->getMessage());
}
}
return $aiResult;
}
public function saveScriptChatHistory($data) {
$uid = Site::getUid();
$rid = getProp($data, 'rid');
$script_id = getProp($data, 'script_id');
$sequence = getProp($data, 'sequence', 0);
$content = getProp($data, 'content');
if (!$rid) {
Utils::throwError('20003:请选择对话记录ID');
}
$record = DB::table('mp_script_records')->where('id', $rid)->first();
if (!$record) {
Utils::throwError('20003:对话记录不存在');
}
if (!$script_id) {
Utils::throwError('20003:请选择剧本');
}
$script = DB::table('mp_scripts')->where('id', $script_id)->where('is_deleted', 0)->first();
if (!$script) {
Utils::throwError('20003:剧本不存在');
}
// 批量插入数据
return DB::table('mp_script_records')->where('id', $rid)->update([
'content' => $content,
'updated_at' => date('Y-m-d H:i:s')
]);
}
/**
* 获取剧本的历史对话记录
*
* @param array $data 请求参数
* @return array 返回历史记录数组
*/
public function getScriptChatHistory($data) {
$uid = Site::getUid();
$script_id = getProp($data, 'script_id');
$rid = getProp($data, 'rid');
$sequence = getProp($data, 'sequence', null);
if (!$script_id) {
Utils::throwError('20003:请提供剧本ID');
}
// 验证剧本是否存在
$script = DB::table('mp_scripts')
->where('id', $script_id)
->where('is_deleted', 0)
->first();
if (!$script) {
Utils::throwError('20003:剧本不存在');
}
// 构建查询
$query = DB::table('mp_script_records')
->where('script_id', $script_id);
// ->where('uid', $uid);
// 如果提供了 sequence,则过滤指定剧集
if ($sequence !== null) {
$query->where('sequence', $sequence);
}
// 记录ID筛选
if ($rid) {
$query->where('id', $rid);
}
// 查询记录并按 id 正序排序
$records = $query->select('id', 'script_id', 'sequence', 'role', 'content', 'products', 'created_at')
->orderBy('created_at', 'asc')
->orderBy('id', 'asc')
->get()
->map(function ($record) {
return [
'rid' => $record->id,
'script_id' => $record->script_id,
'sequence' => $record->sequence,
'role' => $record->role,
'content' => $record->content,
'products' => $record->products ? json_decode($record->products) : [],
'created_at' => $record->created_at,
];
})
->toArray();
return $records;
}
/**
* 处理图片转换为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($art_style='')
{
$arr = [
'唯美真人风格' => [
'aliases' => ['唯美真人风格', '唯美真人', '唯美', '真人'],
'prompt' => "唯美真人风格,电影级摄影,真实光影,高级质感,自然色彩,细节丰富,氛围感强,超高清,8K画质"
],
'真人古风风格' => [
'aliases' => ['真人古风风格', '真人古风'],
'prompt' => "真人古风风格,东方古典美学,国风质感,古韵氛围,电影级摄影,精致细节,高级光影,超高清"
],
'日系动漫风格' => [
'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' => "治愈清新风格,温暖柔和色调,自然光线,清新配色,舒适氛围,细腻绘制,疗愈感十足"
],
];
if($art_style) {
return isset($arr[$art_style]) ? $arr[$art_style]['prompt'] : '';
}
return $arr;
$arr = [
'唯美真人风格' => [
'aliases' => ['唯美真人风格', '唯美真人', '唯美', '真人'],
'prompt' => "1,柔光漫射,侧颜光影,日落氛围感光线,室内温柔打光,自然天光,电影逆光,层次光影
2. 色彩色调
莫兰迪色系,低饱和柔色,高级清透配色,自然原生色彩,氛围感调色
3. 画质精度
8K超清,极致细节,质感通透,画面层次丰富,构图高级,商用影视级画质
4. 画面观感
意境唯美,氛围感拉满,镜头叙事感,自然真实不僵硬
专业全画幅相机拍摄,85mm/50mm定焦镜头,f1.4大光圈,极浅景深,背景柔和散景虚化
高清8K,RAW原片画质,超高细节,自然色彩还原,低饱和高级色调,电影级光影质感,真实透视,自然景深,生活化氛围感,镜头无畸变"
],
'真人古风风格' => [
'aliases' => ['真人古风风格', '真人古风'],
'prompt' => "色彩沉稳典雅,配色和谐统一,明暗层次分明,画面质感细腻,整体画风统一协调,东方古典审美体系,非动漫非CG,原生真实皮肤肌理,保留皮肤毛孔与细微纹理,皮肤通透自然,不假面、无过度磨皮,原生流畅骨相,五官自然舒展,神态松弛生动,眼神灵动有情绪
传统古风造型,形制端正的古装汉服/古风侠客服/清雅古风常服,面料垂坠有质感,布料细腻纹理、刺绣暗纹清晰可见,发型古典雅致,发饰精致简约
柔和高级自然光,漫射柔光/清晨薄雾柔光/傍晚温润天光,光影层次细腻柔和,明暗过渡自然,氛围感光影勾勒轮廓
全画幅专业相机拍摄,85mm f1.4大光圈定焦镜头,浅景深,背景柔和虚化
8K超高清,RAW原片画质,真实透视,电影级光影色调,中式低饱和雅致配色,古风意境拉满,无AI失真,细节拉满"
],
'日系动漫风格' => [
'aliases' => ['日系动漫风格', '日系动漫', '日系', '动漫', '日漫'],
'prompt' => "风格说明:经典二次元画风,线条干净、色彩明亮、人物精致柔和
正向提示词(中文)
日系动漫风格,线条清晰流畅,色彩明亮柔和,光影细腻,人物五官精致,整体二次元质感,画面干净清晰,风格统一稳定
必须遵守规则
• 全程保持日系动漫风格,不混入其他画风
• 角色形象、五官、发型、服饰前后统一,不崩脸、不变形
• 画面适配所选画面比例,构图居中,主体突出
• 线条干净、色彩统一,不杂乱、不突兀
禁止/避免词汇:水印、文字、logo、签名、模糊、低画质、畸形、多手、多脚、写实风格、水墨风格、3D 风格、杂乱背景、血腥、暴露、诡异表情"
],
'国漫风格' => [
'aliases' => ['国漫风格', '国漫'],
'prompt' => "风格说明:中式动漫,线条硬朗,人物偏成熟,光影对比强
正向提示词(中文):国漫风格,线条硬朗有力,人物成熟大气,色彩浓郁,光影对比强烈,画面精致,整体风格统一
必须遵守规则
• 严格保持国漫画风,不串风格、不混画风
• 角色形象统一,不随意改变五官、身材、服饰
• 画面构图稳定,适合漫剧叙事展示
• 色调统一,不出现脏色、杂色
禁止/避免词汇
水印、文字、标题、模糊、低分辨率、Q版、日系萌系、水墨、扁平风、畸形、肢体错乱、血腥、低俗"
],
'Q版卡通风格' => [
'aliases' => ['Q版卡通风格', 'Q版卡通', 'Q版', '卡通'],
'prompt' => "风格说明
头大身小,造型可爱圆润,简约干净
正向提示词(中文)
Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明快干净,无复杂阴影,整体萌系简约
必须遵守规则
• 全程保持Q版比例与风格,不突然变写实、变正常比例
• 角色造型统一,表情可爱,不诡异
• 画面简洁清爽,无多余复杂元素
• 色彩明亮柔和,不刺眼
禁止/避免词汇
水印、文字、写实、成人向、恐怖、惊悚、畸形、复杂光影、杂乱背景、高饱和度刺眼颜色"
],
'简约扁平风格' => [
'aliases' => ['简约扁平风格', '简约扁平', '扁平', '简约'],
'prompt' => "风格说明
色块简洁、无复杂渐变,现代极简风
正向提示词(中文)
简约扁平风格,大块纯色,无复杂阴影与渐变,线条简洁,现代简约,画面干净清爽
必须遵守规则
• 严格保持扁平简约,不出现写实、厚涂、复杂光影
• 构图简洁,主体突出,无多余装饰
• 色彩统一、干净,不杂乱
禁止/避免词汇
水印、文字、复杂纹理、写实质感、3D效果、水墨、赛博朋克、凌乱线条、模糊、低画质"
],
'武侠风格' => [
'aliases' => ['武侠风格', '武侠'],
'prompt' => "风格说明
江湖气息,线条硬朗,动作感强
正向提示词(中文)
武侠风格,中式古装,线条干练硬朗,江湖氛围浓厚,动态感强,色调沉稳大气
必须遵守规则
• 保持武侠画风统一,不混入仙侠、卡通、科幻
• 角色形象、服饰、身材比例统一
• 画面动作自然,不扭曲、不畸形
禁止/避免词汇
水印、文字、Q版、萌系、仙侠仙气、赛博朋克、恐怖、血腥、低俗、模糊"
],
'古风仙侠风格' => [
'aliases' => ['古风仙侠', '古风', '仙侠'],
'prompt' => "风格说明
飘逸汉服、云雾仙气、唯美空灵
正向提示词(中文)
古风仙侠风格,飘逸汉服,轻纱罗裙,云雾缭绕,仙气空灵,光影唯美柔和,色调清雅,国风仙侠氛围浓厚
必须遵守规则
• 保持古风仙侠统一画风,不混入现代、科幻元素
• 角色服饰、发型、形象前后一致
• 场景仙气飘逸,色调清雅统一
• 画面唯美柔和,不阴暗、不诡异
禁止/避免词汇
水印、文字、现代服饰、赛博朋克、写实恐怖、畸形、暴露、血腥、杂乱场景、低画质"
],
'新中式水墨风格' => [
'aliases' => ['新中式水墨风格', '新中式', '水墨'],
'prompt' => "风格说明
淡墨渲染,毛笔笔触,留白意境
正向提示词(中文)
新中式水墨风格,淡墨渲染,毛笔笔触,留白意境,色调清雅,国风美学,画面简洁雅致
必须遵守规则
• 严格保持水墨质感,不混入厚涂、写实、卡通
• 色调淡雅统一,不浓艳杂乱
• 画面干净有意境,不堆砌元素
禁止/避免词汇
水印、文字、高饱和色彩、赛博朋克、3D、Q版、写实厚涂、血腥、恐怖、杂乱"
],
'写实插画风格' => [
'aliases' => ['写实插画风格', '写实插画', '写实', '插画'],
'prompt' => "风格说明
接近真人比例,光影真实,质感细腻
正向提示词(中文)
写实插画风格,真实人体比例,光影自然,皮肤质感细腻,画面真实,风格统一不卡通
必须遵守规则
• 保持写实统一风格,不突然变卡通、变Q版、变水墨
• 角色五官、身材、服饰高度统一
• 画面清晰、光影自然、不扭曲
禁止/避免词汇
水印、文字、Q版、卡通、水墨、赛博朋克、畸形、多手指、肢体错乱、模糊、低画质"
],
'3D卡通风格' => [
'aliases' => ['3D卡通风格', '3D卡通'],
'prompt' => "风格说明
3D建模质感,柔和渲染,圆润可爱
正向提示词(中文)
3D卡通风格,立体建模质感,渲染柔和,造型圆润光滑,光影干净,整体风格统一
必须遵守规则
• 保持3D卡通统一质感,不出现2D杂乱线条
• 角色模型、比例、形象前后一致
• 画面干净,不模糊、不穿模
禁止/避免词汇
水印、文字、水墨、扁平风、写实恐怖、畸形、暴露、血腥、杂乱纹理"
],
'条漫风格' => [
'aliases' => ['条漫风格', '条漫'],
'prompt' => "风格说明
纵向分镜,上下滑动阅读,轻量化
正向提示词(中文)
条漫风格,纵向分镜,布局简洁,色彩清淡,适合上下滑动阅读,整体轻量化、统一
必须遵守规则
• 保持条漫纵向阅读逻辑,构图简洁
• 画风统一,不混写实、3D、水墨
• 画面干净,不杂乱
禁止/避免词汇
水印、文字、复杂光影、3D效果、赛博朋克、恐怖、血腥、杂乱背景"
],
'赛博朋克风格' => [
'aliases' => ['赛博朋克风格', '赛博朋克'],
'prompt' => "风格说明
霓虹灯光、未来都市、暗色调、科技机械
正向提示词(中文)
赛博朋克风格,霓虹灯光,未来都市,暗色调,科技机械元素,高对比光影,风格统一强烈
必须遵守规则
• 保持赛博朋克统一氛围,不混入古风、仙侠、水墨
• 色调以暗调+霓虹为主,色彩统一
• 画面科技感强,不杂乱
禁止/避免词汇
水印、文字、古风、仙侠、水墨、Q版、清新治愈、血腥过度、低俗"
],
'暗黑悬疑风格' => [
'aliases' => ['暗黑悬疑风格', '暗黑悬疑'],
'prompt' => "风格说明
低饱和、冷色调、阴影重、神秘压抑
正向提示词(中文)
暗黑悬疑风格,低饱和度,冷色调,强阴影,光线昏暗,氛围神秘压抑,整体色调统一
必须遵守规则
• 保持暗黑悬疑统一色调与氛围,不突然明亮清新
• 画面氛围压抑神秘,但不低俗、不血腥
• 角色形象统一,不崩坏
禁止/避免词汇
水印、文字、高饱和色彩、清新治愈、Q版、古风仙侠、过度血腥、恐怖猎奇"
],
'治愈清新风格' => [
'aliases' => ['治愈清新风格', '治愈清新'],
'prompt' => "风格说明
马卡龙色系、柔光、温暖干净
正向提示词(中文)
治愈清新风格,马卡龙色系,柔光暖调,画面干净柔和,氛围温暖舒适,风格统一
必须遵守规则
• 保持治愈清新统一色调,不阴暗、不诡异
• 画面柔和干净,无杂乱元素
• 角色形象柔和可爱,不狰狞
禁止/避免词汇
水印、文字、暗黑、恐怖、血腥、赛博朋克、高饱和刺眼色、畸形、杂乱"
],
];
return $arr;
}
public function getArtStylePromptByInput($inputArtStyle): string
{
$normalizedInput = $this->normalizeArtStyleInput($inputArtStyle);
if ($normalizedInput === '') {
return '';
}
return $this->getArtStylePromptMappings($normalizedInput);
// 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 '';
}
public function getArtStyleShortPromptByInput($inputArtStyle) {
$stylePrefixes = [
'唯美真人风格' => [
'character_prefix' => '唯美真人风格,电影级摄影,真实人物,高级质感,自然光影,肤质细腻,景深虚化,超高清,8K画质,情绪表达丰富',
'scene_prefix' => '唯美真人风格,电影感场景,真实环境,自然光影,高级色彩,细节丰富,超高清摄影,氛围感强'
],
'真人古风风格' => [
'character_prefix' => '真人古风风格,东方古典美学,真实人物,古代服饰,精致妆容,电影级摄影,国风质感,超高清',
'scene_prefix' => '真人古风风格,东方建筑,古典园林,电影级光影,国风美学,真实场景,高细节,超高清'
],
'日系动漫风格' => [
'character_prefix' => '日系动漫风格,二次元角色,精致线稿,高质量赛璐璐上色,细腻表情,动画电影质感,高细节',
'scene_prefix' => '日系动漫风格,动画电影场景,唯美背景,细腻光影,高质量绘制,色彩丰富,氛围感强'
],
'国漫风格' => [
'character_prefix' => '国漫风格,高品质角色设计,东方审美,精致建模感,细腻刻画,动态表现强,电影级渲染',
'scene_prefix' => '国漫风格,东方幻想场景,精细绘制,大气构图,电影级光影,高品质渲染'
],
'Q版卡通风格' => [
'character_prefix' => 'Q版卡通风格,大头小身比例,可爱造型,圆润线条,丰富表情,萌系设计,高质量插画',
'scene_prefix' => 'Q版卡通风格,可爱场景,色彩鲜艳,童趣氛围,简洁细腻,绘本质感'
],
'简约扁平风格' => [
'character_prefix' => '简约扁平风格,矢量插画,几何造型,简洁线条,干净配色,现代设计感',
'scene_prefix' => '简约扁平风格,矢量场景,几何构图,色彩简洁,现代感设计,视觉清爽'
],
'武侠风格' => [
'character_prefix' => '武侠风格,东方侠客气质,江湖人物,飘逸服饰,凌厉气场,电影级光影,高细节绘制',
'scene_prefix' => '武侠风格,江湖场景,古道山林,客栈酒馆,刀光剑影,大气构图,东方美学'
],
'古风仙侠风格' => [
'character_prefix' => '古风仙侠风格,仙气飘逸,东方幻想角色,精致古装,灵气环绕,电影级特效,高品质渲染',
'scene_prefix' => '古风仙侠风格,仙境场景,云海仙山,灵气缭绕,梦幻光效,东方幻想美学'
],
'新中式水墨风格' => [
'character_prefix' => '新中式水墨风格,国风写意,水墨晕染,留白艺术,东方韵味,诗意氛围,高级插画',
'scene_prefix' => '新中式水墨风格,山水意境,墨韵渲染,留白构图,东方美学,诗意场景'
],
'写实插画风格' => [
'character_prefix' => '写实插画风格,半写实人物,细节丰富,真实比例,数字绘画,高质量渲染,电影级质感',
'scene_prefix' => '写实插画风格,高细节环境,数字绘景,真实光影,层次丰富,电影感构图'
],
'3D卡通风格' => [
'character_prefix' => '3D卡通风格,高品质三维建模,皮克斯动画质感,表情生动,角色可爱,细腻渲染',
'scene_prefix' => '3D卡通风格,动画电影场景,三维建模,色彩明快,光影柔和,高品质渲染'
],
'条漫风格' => [
'character_prefix' => '条漫风格,韩漫质感,精致线稿,人物颜值高,细腻上色,剧情漫画风,高质量绘制',
'scene_prefix' => '条漫风格,漫画背景,剧情分镜感,细腻光影,现代漫画质感'
],
'赛博朋克风格' => [
'character_prefix' => '赛博朋克风格,未来科技感,机械义体,霓虹光效,高对比色彩,科幻都市氛围,电影级渲染',
'scene_prefix' => '赛博朋克风格,未来都市,霓虹街道,全息广告,机械科技元素,夜景氛围,电影级光影'
],
'暗黑悬疑风格' => [
'character_prefix' => '暗黑悬疑风格,神秘压抑氛围,强烈明暗对比,电影级灯光,细腻刻画,悬疑感十足',
'scene_prefix' => '暗黑悬疑风格,阴郁环境,低饱和色调,迷雾光影,神秘气氛,电影感构图'
],
'治愈清新风格' => [
'character_prefix' => '治愈清新风格,温暖柔和色调,干净画面,自然光线,亲和气质,轻松愉悦氛围,高品质插画',
'scene_prefix' => '治愈清新风格,阳光明亮,自然环境,温暖色彩,舒适氛围,细腻绘制,疗愈感强'
]
];
return isset($stylePrefixes[$inputArtStyle]) ? $stylePrefixes[$inputArtStyle] : [];
}
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) {
// 设置最大输出tokens
$post_data['max_tokens'] = 300000;
$client = new Client(['timeout' => 1800, '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
];
}
/**
* GPT 流式输出处理方法
*
* @param array $post_data GPT API请求参数
* @return \Generator 返回生成器,用于流式输出
*/
private function gpt54StreamResponse($post_data) {
$apiKey = env('GPT_54_API_KEY');
if (empty($apiKey)) {
Utils::throwError('20003:GPT API Key未配置');
}
$client = new Client(['timeout' => 1800, 'verify' => false]);
$headers = [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json'
];
// 移除 thinking 参数,GPT-5.4 不支持
if (isset($post_data['thinking'])) {
unset($post_data['thinking']);
}
if (isset($post_data['reasoning_effort'])) {
unset($post_data['reasoning_effort']);
}
$post_data['max_completion_tokens'] = 100000;
// 备用中转站地址1: https://token.ithinkai.cn/v1/chat/completions
// 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
$response = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
'json' => $post_data,
'headers' => $headers,
'stream' => true // 启用流式响应
]);
$body = $response->getBody();
$fullContent = '';
$usage = [];
$buffer = '';
dLog('deepseek')->info('开始读取 GPT 流式响应');
// 逐行读取流式响应
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'];
// 处理回答内容
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)
]);
yield [
'type' => 'done',
'full_content' => $fullContent,
'full_reasoning' => '',
'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 if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$chatResult = $this->gpt54ChatOnly($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' => 1800, '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' => 1800, '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' => 1800, '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' => 1800, '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');
$age_stage = getProp($data, 'age_stage');
$query = DB::table('mp_timbres')->where('is_enabled', 1)->select('timbre_name as voice_name', 'timbre_type as voice_type', 'gender', 'age_stage', '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);
});
}
if ($age_stage) {
$query->where('age_stage', $age_stage);
}
$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) {
$uid = Site::getUid();
$script_id = getProp($data, 'script_id');
$script_name = getProp($data, 'script_name');
$query = DB::table('mp_scripts')->where('is_deleted', 0)->where('user_id', $uid)->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) {
$uid = Site::getUid();
$script_id = getProp($data, 'script_id');
$script_name = getProp($data, 'script_name');
$query = DB::table('mp_scripts')->where('is_deleted', 0)->where('user_id', $uid)->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) {
$uid = Site::getUid();
$script_id = getProp($data, 'script_id');
$script = DB::table('mp_scripts')->where('id', $script_id)->where('is_deleted', 0)->where('user_id', $uid)
// ->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();
->selectRaw('id as script_id, script_name')->first();
if (!$script) Utils::throwError('20003:该剧本不存在!');
$script = (array)$script;
// 获取分集组信息
$groups = DB::table('mp_script_episode_group')->where('script_id', $script_id)->where('is_deleted', 0)
->selectRaw('id as group_id, script_id, group_name, start_episode_number, end_episode_number, intro, highlights, roles, role_relationship, core_contradiction, art_style, scenes, status, episode_content')
->orderBy('start_episode_number')->orderBy('id')->get()->map(function ($value) {
$value = (array)$value;
$value['episode_content'] = mb_substr($value['episode_content'], 0, 200);
return (array)$value;
})->toArray();
$script['group'] = $groups;
return $script;
}
public function createScript($data) {
$script_name = getProp($data, 'script_name');
$uid = Site::getUid(); // 获取当前用户ID
$cpid = Site::getCpid(); // 获取当前公司组ID
if (!$script_name) Utils::throwError('20003:剧本名称不能为空');
if (DB::table('mp_scripts')->where('script_name', $script_name)->exists()) {
$script_name .= '_'.date('YmdHis');
}
return DB::table('mp_scripts')->insertGetId([
'script_name' => $script_name,
'user_id' => $uid,
'cpid' => $cpid,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
}
public function editScript($data) {
$script_id = getProp($data, 'script_id');
$script_name = getProp($data, 'script_name');
$uid = Site::getUid(); // 获取当前用户ID
if (!$script_id || !$script_name) Utils::throwError('20003:参数异常');
$id = DB::table('mp_scripts')->where('script_name', $script_name)->value('id');
if ($id && (int)$id !== (int)$script_id) {
$script_name .= '_'.date('YmdHis');
}
return DB::table('mp_scripts')->where('id', $script_id)->update([
'script_name' => $script_name,
'updated_at' => date('Y-m-d H:i:s')
]);
}
public function delScript($data) {
$script_id = getProp($data, 'script_id');
$uid = Site::getUid(); // 获取当前用户ID
if (!$script_id) Utils::throwError('20003:请选择剧本');
return DB::table('mp_scripts')->where('id', $script_id)->update([
'is_deleted' => 1,
'updated_at' => date('Y-m-d H:i:s')
]);
}
public function createEpisode($data) {
$script_id = getProp($data, 'script_id');
$group_name = getProp($data, 'group_name');
$remark = getProp($data, 'remark');
if (!DB::table('mp_scripts')->where('id', $script_id)->value('id')) Utils::throwError('20003:剧本不存在!');
$uid = Site::getUid(); // 获取当前用户ID
if (!$group_name) Utils::throwError('20003:分集名称不能为空');
if (DB::table('mp_script_episode_group')->where('group_name', $group_name)->exists()) {
$group_name .= '_'.date('YmdHis');
}
return DB::table('mp_script_episode_group')->insertGetId([
'script_id' => $script_id,
'group_name' => $group_name,
'user_id' => $uid,
'remark' => $remark,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
}
public function editEpisode($data) {
$group_id = getProp($data, 'group_id');
$start_episode_number = getProp($data, 'start_episode_number');
$end_episode_number = getProp($data, 'end_episode_number');
$group_name = getProp($data, 'group_name');
$uid = Site::getUid(); // 获取当前用户ID
if (!$group_id || !$group_name || !$start_episode_number || !$end_episode_number) Utils::throwError('20003:参数异常');
$id = DB::table('mp_script_episode_group')->where('group_name', $group_name)->value('id');
if ($id && (int)$id !== (int)$group_id) {
$group_name .= '_'.date('YmdHis');
}
return DB::table('mp_script_episode_group')->where('id', $group_id)->update([
'group_name' => $group_name,
'start_episode_number' => $start_episode_number,
'end_episode_number' => $end_episode_number,
'updated_at' => date('Y-m-d H:i:s')
]);
}
public function delEpisode($data) {
$group_id = getProp($data, 'group_id');
$uid = Site::getUid(); // 获取当前用户ID
if (!$group_id) Utils::throwError('20003:请选择分集');
return DB::table('mp_script_episode_group')->where('id', $group_id)->update([
'is_deleted' => 1,
'updated_at' => date('Y-m-d H:i:s')
]);
}
/**
* 上传文件识别文字内容并与 DeepSeek 进行对话(流式输出版本)
*
* @param array $data 包含以下参数:
* - file: 上传的文件(UploadedFile 对象)或文件路径
* - question: 用户问题(可选)
* - model: 使用的模型(r1 或 v3,默认 v3)
* @return \Generator 返回生成器,用于流式输出
*/
public function generateEpisodes($data) {
$script_id = getProp($data, 'script_id');
$group_id = getProp($data, 'group_id');
// $file = getProp($data, 'file');
$file = '';
$content = getProp($data, 'content', '');
// $bid = getProp($data, 'bid', 0);
$bid = 0;
$group = DB::table('mp_script_episode_group')->where('id', $group_id)->where('script_id', $script_id)->where('is_deleted', 0)->first();
if (!$group) {
Utils::throwError('20003:剧本分集不存在');
}
$start_episode_sequence = getProp($group, 'start_episode_number', 1);
$end_episode_sequence = getProp($group, 'end_episode_number', 30);
$total_episode_num = $end_episode_sequence - $start_episode_sequence + 1;
$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");
$model = getProp($data, 'model');
if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->value('id')) {
Utils::throwError('20003:该模型不存在!');
}
// 检查是否存在重叠的剧集序号范围
$exists_groups = DB::table('mp_script_episode_group')
->where('script_id', $script_id)
->where('id', '<>', $group_id) // 排除当前组
->where('is_deleted', 0)
->where(function($query) use ($start_episode_sequence, $end_episode_sequence) {
// 检查新范围是否与现有范围重叠
$query->where(function($q) use ($start_episode_sequence, $end_episode_sequence) {
// 新范围的开始在现有范围内
$q->where('start_episode_number', '<=', $start_episode_sequence)
->where('end_episode_number', '>=', $start_episode_sequence);
})->orWhere(function($q) use ($start_episode_sequence, $end_episode_sequence) {
// 新范围的结束在现有范围内
$q->where('start_episode_number', '<=', $end_episode_sequence)
->where('end_episode_number', '>=', $end_episode_sequence);
})->orWhere(function($q) use ($start_episode_sequence, $end_episode_sequence) {
// 新范围完全包含现有范围
$q->where('start_episode_number', '>=', $start_episode_sequence)
->where('end_episode_number', '<=', $end_episode_sequence);
});
})
->select('start_episode_number', 'end_episode_number')
->get();
if ($exists_groups->isNotEmpty()) {
$conflict_ranges = [];
foreach ($exists_groups as $group) {
$conflict_ranges[] = "第{$group->start_episode_number}-{$group->end_episode_number}集";
}
Utils::throwError('20003:剧集序号范围与已存在的剧集组重叠: '.implode('、', $conflict_ranges).',不得创建!');
}
// 模型兼容性处理: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:剧本不存在');
}
$content = getProp($group, 'content');
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 if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$streamGenerator = $this->gpt54StreamResponse($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', '完整内容', ['fullContent'=>$fullContent]);
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = extractScriptContent($fullContent);
}
logDB('deepseek', 'info', '解析内容', $script_arr);
if (!$script_arr['episodes']) {
Utils::throwError('20003:未生成剧本相关信息');
// yield [
// 'type' => 'done',
// 'script' => $script_arr,
// 'msg' => '未生成剧本相关信息',
// 'answer' => $fullContent,
// 'reasoning' => $fullReasoningContent,
// 'usage' => $usage
// ];
// return ;
}
try {
DB::beginTransaction();
// // 返回前保存剧本内容
// $boolen = DB::table('mp_scripts')->where('id', $script_id)->update([
// 'content' => $content,
// 'updated_at' => date('Y-m-d H:i:s')
// ]);
// if (!$boolen) {
// Utils::throwError('20003:保存剧本内容失败');
// }
// $boolen1 = DB::table('mp_script_episode_group')->where('id', $group_id)->update([
// 'start_episode_number' => $start_episode_sequence,
// 'end_episode_number' => $end_episode_sequence,
// 'updated_at' => date('Y-m-d H:i:s')
// ]);
// if (!$boolen1) {
// Utils::throwError('20003:保存分集失败');
// }
$episode_content = '';
$episodes = [];
foreach ($script_arr['episodes'] as $item) {
$episode_content .= "\u{200D}\u{200D}\u{200D}" . $item['episode_content'] . "\r\n\r\n";
// $episodes[] = [
// 'script_id' => $script_id,
// 'episode_number' => $item['episode_number'],
// 'episode_name' => $item['episode_name'],
// 'episode_content' => $item['episode_content'],
// 'created_at' => date('Y-m-d H:i:s'),
// 'updated_at' => date('Y-m-d H:i:s'),
// ];
}
if ($episode_content) {
$boolen2 = DB::table('mp_script_episode_group')->where('id', $group_id)->update([
'episode_content' => $episode_content,
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$boolen2) {
Utils::throwError('20003:保存分集内容失败');
}
}else {
Utils::throwError('20003:分集剧本解析失败');
}
}catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
yield [
'type' => 'done',
'script' => $script_arr,
'episode_content' => $episode_content,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
public function chatWithFileStream($data) {
$script_id = getProp($data, 'script_id');
$group_id = getProp($data, 'group_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);
$end_episode_sequence = $start_episode_sequence + $total_episode_num - 1;
$prompt = getProp($data, 'prompt');
$baseQuestion = "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.强制要求一定要给出我需要的分镜集数,不能少7.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。";
if (!empty($prompt)) {
$baseQuestion .= "\n本次修改要求如下:\n{$prompt}";
}
$question = getProp($data, 'question', $baseQuestion);
$model = getProp($data, 'model');
if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->value('id')) {
Utils::throwError('20003:该模型不存在!');
}
// 检查是否存在重叠的剧集序号范围
$exists_groups = DB::table('mp_script_episode_group')
->where('script_id', $script_id)
->where('id', '<>', $group_id)
->where('is_deleted', 0)
->where(function($query) use ($start_episode_sequence, $end_episode_sequence) {
// 检查新范围是否与现有范围重叠
$query->where(function($q) use ($start_episode_sequence, $end_episode_sequence) {
// 新范围的开始在现有范围内
$q->where('start_episode_number', '<=', $start_episode_sequence)
->where('end_episode_number', '>=', $start_episode_sequence);
})->orWhere(function($q) use ($start_episode_sequence, $end_episode_sequence) {
// 新范围的结束在现有范围内
$q->where('start_episode_number', '<=', $end_episode_sequence)
->where('end_episode_number', '>=', $end_episode_sequence);
})->orWhere(function($q) use ($start_episode_sequence, $end_episode_sequence) {
// 新范围完全包含现有范围
$q->where('start_episode_number', '>=', $start_episode_sequence)
->where('end_episode_number', '<=', $end_episode_sequence);
});
})
->select('start_episode_number', 'end_episode_number')
->get();
if ($exists_groups->isNotEmpty()) {
$conflict_ranges = [];
foreach ($exists_groups as $group) {
$conflict_ranges[] = "第{$group->start_episode_number}-{$group->end_episode_number}集";
}
Utils::throwError('20003:剧集序号范围与已存在的剧集组重叠: '.implode('、', $conflict_ranges).',不得创建!');
}
// 模型兼容性处理: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:剧本不存在');
}
$group = DB::table('mp_script_episode_group')->where('id', $group_id)->where('script_id', $script_id)->where('is_deleted', 0)->first();
if (!$group) {
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 = [
[
'role' => 'system',
'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间必须使用###分隔,#不能多也不能少;同时板块之间需满足以下要求:\n
强制要求:\n
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.用户如果直接给了你一本小说或者要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求
3.请严格按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n\n
2.不要出现分集内容,只保留剧本大纲的格式,格式要求和示例如下,请强制按该示例输出
3.分集序号可能不是从 1 开始,也可能是跳跃的(例如直接从第 3 集、第 5 集开始)。你只需要基于下面给出的“当前集内容要点”来创作这一集即可。
4.不需要强行补齐前面未提供的集数,也不需要预测后面未提供的集数,但请保持本集内部故事的完整性和节奏。
5.总集数将作为本季整体篇幅的参考,你可以根据它调整本集的情节推进速度(例如:如果总集数很少,本集应更紧凑;如果总集数很多,本集可以适当展开细节)
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。"],
[
'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 if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$streamGenerator = $this->gpt54StreamResponse($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', '完整内容', ['fullContent'=>$fullContent]);
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = extractScriptContent($fullContent);
}
logDB('deepseek', 'info', '解析内容', $script_arr);
if (!$script_arr['roles']) {
Utils::throwError('20003:未生成剧本相关信息');
// yield [
// 'type' => 'done',
// 'script' => $script_arr,
// 'msg' => '未生成剧本相关信息',
// 'answer' => $fullContent,
// 'reasoning' => $fullReasoningContent,
// 'usage' => $usage
// ];
}
try {
DB::beginTransaction();
// // 返回前保存剧本内容
// $boolen = DB::table('mp_scripts')->where('id', $script_id)->update([
// 'content' => $content,
// 'status' => '解析完成',
// 'updated_at' => date('Y-m-d H:i:s')
// ]);
// if (!$boolen) {
// Utils::throwError('20003:保存剧本内容失败');
// }
$boolen1 = DB::table('mp_script_episode_group')->where('id', $group_id)->update([
'start_episode_number' => $start_episode_sequence,
'end_episode_number' => $end_episode_sequence,
'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']) && is_array($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']) && is_array($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '[]',
'status' => '解析完成',
'content' => $content,
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$boolen1) {
Utils::throwError('20003:保存分集失败');
}
// $episodes = [];
// foreach ($script_arr['episodes'] as $item) {
// $episodes[] = [
// 'script_id' => $script_id,
// 'episode_number' => $item['episode_number'],
// 'episode_name' => $item['episode_name'],
// 'episode_content' => $item['episode_content'],
// 'created_at' => date('Y-m-d H:i:s'),
// 'updated_at' => date('Y-m-d H:i:s'),
// ];
// }
// if ($episodes) {
// $boolen2 = DB::table('mp_script_episodes')->insert($episodes);
// if (!$boolen2) {
// Utils::throwError('20003:保存分集内容失败');
// }
// }else {
// Utils::throwError('20003:分集剧本解析失败');
// }
}catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
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');
$baseQuestion = "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.要求一定要给出我需要的分镜集数,不能少7.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。";
if (!empty($prompt)) {
$baseQuestion .= "\n本次修改要求如下:\n{$prompt}";
}
$question = getProp($data, 'question', $baseQuestion);
// 处理文本模型
$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 if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$chatResult = $this->gpt54ChatOnly($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 = extractScriptContent($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 getEpisodeContent($data) {
$group_id = getProp($data, 'group_id');
return DB::table('mp_script_episode_group')->where('id', $group_id)->value('episode_content');
}
public function saveEpisodeContent($data) {
$script_id = getProp($data, 'script_id');
$group_id = getProp($data, 'group_id');
$start_episode_sequence = getProp($data, 'start_episode_number');
$end_episode_sequence = getProp($data, 'end_episode_number');
// 检查是否存在重叠的剧集序号范围
$exists_groups = DB::table('mp_script_episode_group')
->where('script_id', $script_id)
->where('id', '<>', $group_id) // 排除当前组
->where('is_deleted', 0)
->where(function($query) use ($start_episode_sequence, $end_episode_sequence) {
// 检查新范围是否与现有范围重叠
$query->where(function($q) use ($start_episode_sequence, $end_episode_sequence) {
// 新范围的开始在现有范围内
$q->where('start_episode_number', '<=', $start_episode_sequence)
->where('end_episode_number', '>=', $start_episode_sequence);
})->orWhere(function($q) use ($start_episode_sequence, $end_episode_sequence) {
// 新范围的结束在现有范围内
$q->where('start_episode_number', '<=', $end_episode_sequence)
->where('end_episode_number', '>=', $end_episode_sequence);
})->orWhere(function($q) use ($start_episode_sequence, $end_episode_sequence) {
// 新范围完全包含现有范围
$q->where('start_episode_number', '>=', $start_episode_sequence)
->where('end_episode_number', '<=', $end_episode_sequence);
});
})
->select('start_episode_number', 'end_episode_number')
->get();
if ($exists_groups->isNotEmpty()) {
$conflict_ranges = [];
foreach ($exists_groups as $group) {
$conflict_ranges[] = "第{$group->start_episode_number}-{$group->end_episode_number}集";
}
Utils::throwError('20003:剧集序号范围与已存在的剧集组重叠: '.implode('、', $conflict_ranges).',不得创建!');
}
$episode_content = getProp($data, 'episode_content');
return DB::table('mp_script_episode_group')->where('id', $group_id)->where('script_id', $script_id)->update([
'content' => $episode_content,
'start_episode_number' => $start_episode_sequence,
'end_episode_number' => $end_episode_sequence,
'updated_at' => date('Y-m-d H:i:s')
]);
$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']) && is_array($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']) && is_array($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 getBookContent($data) {
$bid = getProp($data, 'bid');
$start_sequence = getProp($data, 'start_sequence');
$end_sequence = getProp($data, 'end_sequence');
$chapter_contents = DB::table('chapters as c')->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)->whereBetween('c.sequence', [$start_sequence, $end_sequence])
->orderBy('c.sequence')->pluck('cc.content')->toArray();
$return_content = filterContent(implode(PHP_EOL, $chapter_contents));
return $return_content;
}
public function exportScript($data) {
$filename = getProp($data, 'filename');
$script_id = getProp($data, 'script_id');
$group_id = getProp($data, 'group_id');
$script = DB::table('mp_scripts')->where('id', $script_id)->where('is_deleted', 0)
->selectRaw('id as script_id, script_name, episode_num, remark, created_at')->first();
if (!$script) Utils::throwError('20003:该剧本不存在!');
$script = (array)$script;
// 获取分集组信息
if ($group_id) {
$groups = DB::table('mp_script_episode_group')->where('script_id', $script_id)->where('id', $group_id)->where('is_deleted', 0)->whereNotNull('episode_content')
->selectRaw('intro, highlights, roles, role_relationship, core_contradiction, art_style, scenes, status, start_episode_number, end_episode_number, episode_content')
->get()->map(function($value) {
return (array)$value;
})->toArray();
}else {
$groups = DB::table('mp_script_episode_group')->where('script_id', $script_id)->where('is_deleted', 0)->whereNotNull('episode_content')
->selectRaw('intro, highlights, roles, role_relationship, core_contradiction, art_style, scenes, status, start_episode_number, end_episode_number, episode_content')
->orderBy('start_episode_number')->orderBy('id')->get()->map(function($value) {
return (array)$value;
})->toArray();
}
if (!$groups) Utils::throwError('20003:分集不存在');
$script['group'] = $groups;
$export_type = getProp($data, 'export_type', 'txt');
// 生成文件名
$filename = $filename ? $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['group']) && is_array($script['group'])) {
$groups = (array)$script['group'];
foreach ($groups as $group) {
$html .= '';
// 分组标题
$groupTitle = '第' . $group['start_episode_number'] . '-' . $group['end_episode_number'] . '集';
$html .= '';
// 故事梗概
if (!empty($group['intro'])) {
$html .= '
故事梗概
';
$html .= '
' . nl2br(htmlspecialchars($group['intro'])) . '
';
}
// 剧本亮点
if (!empty($group['highlights'])) {
$html .= '
剧本亮点
';
$html .= '
' . nl2br(htmlspecialchars($group['highlights'])) . '
';
}
// 人物关系
if (!empty($group['role_relationship'])) {
$html .= '
人物关系
';
$html .= '
' . nl2br(htmlspecialchars($group['role_relationship'])) . '
';
}
// 核心矛盾
if (!empty($group['core_contradiction'])) {
$html .= '
核心矛盾
';
$html .= '
' . nl2br(htmlspecialchars($group['core_contradiction'])) . '
';
}
// 主体列表
if (!empty($group['roles']) && is_array($group['roles'])) {
$html .= '
主体列表
';
$html .= '
';
foreach ($group['roles'] as $role) {
$html .= '- ' . htmlspecialchars(getProp($role, 'role')) . ':' . htmlspecialchars(getProp($role, 'description')) . '
';
}
$html .= '
';
}
// 美术风格
if (!empty($group['art_style'])) {
$html .= '
美术风格
';
$html .= '
' . nl2br(htmlspecialchars($group['art_style'])) . '
';
}
// 场景列表
if (!empty($group['scenes']) && is_array($group['scenes'])) {
$html .= '
场景列表
';
$html .= '
';
foreach ($group['scenes'] as $scene) {
$html .= '- ' . htmlspecialchars(getProp($scene, 'scene')) . ':' . htmlspecialchars(getProp($scene, 'description')) . '
';
}
$html .= '
';
}
// 分集剧本
if (!empty($group['episode_content'])) {
$html .= '
分集剧本
';
// 去除零宽字符和其他不可见字符
$cleanContent = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $group['episode_content']);
$cleanContent = preg_replace('/[\x{00}-\x{08}\x{0B}\x{0C}\x{0E}-\x{1F}\x{7F}]/u', '', $cleanContent);
$html .= '
' . nl2br(htmlspecialchars($cleanContent)) . '
';
}
$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 = '';
$groups = $script['group'];
$groups = (array)$groups;
foreach ($groups as $group) {
$content .= str_repeat('→', 40).' 第'.$group['start_episode_number'].'-'.$group['end_episode_number']."集 ".str_repeat('←', 40)."\n\n";
// 剧本基本信息
if (!empty($group['intro'])) {
$content .= "###故事梗概\n" . $group['intro'] . "\n\n";
}
if (!empty($group['highlights'])) {
$content .= "###剧本亮点\n" . $group['highlights'] . "\n\n";
}
if (!empty($group['role_relationship'])) {
$content .= "###人物关系\n" . $group['role_relationship'] . "\n\n";
}
if (!empty($group['core_contradiction'])) {
$content .= "###核心矛盾\n" . $group['core_contradiction'] . "\n\n";
}
if (!empty($group['roles']) && is_array($group['roles'])) {
$content .= "###主体列表\n";
foreach ($group['roles'] as $role) {
$content .= getProp($role, 'role') . ":" . getProp($role, 'description') . "\n";
}
$content .= "\n\n";
}
if (!empty($group['art_style'])) {
$content .= "###美术风格\n" . $group['art_style'] . "\n\n";
}
// 场景信息
if (!empty($group['scenes']) && is_array($group['scenes'])) {
$content .= "###场景列表\n";
foreach ($group['scenes'] as $scene) {
$content .= getProp($scene, 'scene') . ":" . getProp($scene, 'description') . "\n";
}
$content .= "\n\n";
}
// 分集内容
if (!empty($group['episode_content'])) {
$content .= "###分集剧本\n";
$content .= $group['episode_content'];
// foreach ($script['episodes'] as $episode) {
// // $content .= "##分集" . $episode['episode_number'] . "\n分集名";
// // if (!empty($episode['episode_name'])) {
// // $content .= ":" . $episode['episode_name'];
// // }
// // $content .= "\n";
// $content .= $episode['episode_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);
$ace_mode = getProp($data, 'ace_mode', 0) ? getProp($data, 'ace_mode', 0) : getProp($anime, 'ace_mode', 0);
$prompt = getProp($data, 'prompt');
$user_anime_name = getProp($anime, 'anime_name');
// 处理文本模型
$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");
if ($prompt) {
$question .= "本次修改要求如下:\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');
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
$timbres_content = "青年男:北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年
青年女:柔美女友、魅力女友、爽快思思、高冷御姐、知性温婉、甜心小美、清新女声、甜美桃子、温柔文雅、甜美小源、贴心闺蜜
中年男:胡子叔叔、油腻大叔、神秘法师、优柔帮主
中年女:邻居阿姨
老年男:幽默大爷
老年女:婆婆
萌娃:奶气萌娃";
// 判断是否需要生成原文内容
$need_generate_content = isset($need_generate_content) && $need_generate_content;
// 如果需要生成原文内容,添加额外的要求
$content_generation_requirement = $need_generate_content
? "\n5.如果用户只提供了创作要求而没有提供具体文档内容,你需要根据要求创作完整的原文内容(小说形式,包含对话和叙述),并将原文内容放在最后一个板块###原文内容中输出。原文内容应该详细、生动,如果用户没有明确生成的字数,则字数不少于1000字。"
: "";
// 美术风格
if (!$mappedArtStyle) {
$mappedArtStyle_prompt = '美术风格:根据剧本内容自定义美术风格';
}else {
if (strstr($mappedArtStyle, '基础画风风格词')) {
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n{$mappedArtStyle}\n";
}else {
$art_style_type = $input_art_style ? $input_art_style : getProp($anime, 'art_style_type');
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n基础画风风格词: {$art_style_type}\n{$mappedArtStyle}\n";
}
}
$systemPrompt = $is_single
? "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容输出单剧集完整策划稿(剧本大纲包括: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分镜剧本>,每个板块之间必须使用###分隔,#不能多也不能少)。单剧集模式下,必须一次性输出可直接保存的大纲与完整分镜,不允许只输出大纲。
强制要求:
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:
1.剧本名(必须生成)和剧集标题必须与文档内容高度相关,(其中剧集序号是1)。
2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分:主体描述、人物提示词和音色,格式必须为:主体描述{人物提示词}{{音色}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色必须在以下音色中($timbres_content)选择,需要根据角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
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
5.{$mappedArtStyle_prompt}\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{甜心小美}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{阳光青年}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}
###分镜剧本
##第1幕:徐家老旧厨房 白天 室内
分镜1
画面描述:许芸-校服装站在厨房门口,书包背在肩上,徐母手持扫帚,正对着许芸的背部猛烈挥打。卞大伟站在锅炉后方,双手抱胸,阴沉的目光落在许芸身上。
场景:徐家老旧厨房
构图设计:全景,低角度仰视
运镜调度:手持拍摄
配音角色:旁白
出镜角色:许芸-校服装、徐母
台词内容:我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我,
画面类型:普通画面
尾帧描述:手持拍摄微晃镜头,中年妇女猛力挥动扫帚击打少女背部,少女身体因冲击微颤,阴影中的肥胖男子目光冷冷跟随。
分镜2
画面描述:徐母面容扭曲,嘴唇快速开合,扫帚挥舞在空中。
场景:徐家老旧厨房
构图设计:近景,徐母面部特写
运镜调度:固定镜头
配音角色:徐母
出镜角色:徐母
台词内容:问我夜不归宿死哪儿去了。
画面类型:对口型
尾帧描述:固定镜头,中年妇女面部肌肉剧烈抖动,嘴唇快速开合进行辱骂,背景中的扫帚在空中上下挥动。
##第2幕:徐家简陋客厅 黄昏 室内
分镜3
画面描述:客厅窗户透出昏黄的夕阳余晖,空荡荡的客厅中央只有几把木凳和一张方桌,显得异常安静。
场景:徐家简陋客厅
构图设计:全景,景深虚化
运镜调度:固定镜头
配音角色:旁白
出镜角色:无
台词内容:可我没想到,半个月后,徐母居然再一次外出,
画面类型:普通画面
尾帧描述:固定镜头。昏黄的夕阳余晖在地面上缓慢移动,空气中的浮尘在光束中跳动,室内维持着死寂的安静。
分镜4
画面描述:一张泛黄的火车票特写,上面显示着隔壁市的站名和日期。
场景:徐家简陋客厅
构图设计:特写,火车票
运镜调度:固定镜头
配音角色:旁白
出镜角色:无
台词内容:这次是去隔壁市看望她生病的好姐妹。
画面类型:普通画面
尾帧描述:固定镜头。光影在火车票表面微微晃动,强调其陈旧的纸张纹理与关键的行程信息。\n
"
: "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间必须使用###分隔,#不能多也不能少;同时板块之间需满足以下要求:\n
强制要求:\n
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:\n
1.剧本名(必须生成)必须与文档内容高度相关
2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分:主体描述、人物提示词和音色,格式必须为:主体名:主体描述{人物提示词}{{音色}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色必须在以下音色中($timbres_content)选择,需要根据角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
4.{$mappedArtStyle_prompt}\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{甜心小美}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{阳光青年}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}";
// 构建消息
$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 = [];
try {
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
// DeepSeek 官方模型使用 DeepSeek API
$streamGenerator = $this->deepSeekStreamResponse($post_data);
} else if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$streamGenerator = $this->gpt54StreamResponse($post_data);
} else {
// 其他模型使用火山引擎API
$streamGenerator = $this->volcEngineChatCompletion($post_data);
}
// 验证返回值类型
if (!is_iterable($streamGenerator)) {
$errorType = is_object($streamGenerator) ? get_class($streamGenerator) : gettype($streamGenerator);
dLog('deepseek')->error('addChat流式生成器无效', [
'generator_type' => $errorType,
'model' => $model,
'anime_id' => $anime_id
]);
logDB('deepseek', 'error', 'addChat流式生成器类型错误', [
'type' => $errorType,
'model' => $model
]);
yield [
'type' => 'error',
'script' => [],
'episode' => [],
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '接口返回数据异常,请重新请求'
];
return;
}
// 处理流式输出
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;
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error('addChat流式处理异常: ' . $e->getMessage(), [
'model' => $model,
'anime_id' => $anime_id,
'trace' => $e->getTraceAsString()
]);
logDB('deepseek', 'error', 'addChat流式处理失败', [
'error' => $e->getMessage(),
'model' => $model
]);
yield [
'type' => 'error',
'script' => [],
'episode' => [],
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '处理请求时发生异常: ' . $e->getMessage()
];
return;
}
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' => $prompt,
'created_at' => $now,
'updated_at' => $now
],
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 0,
'role' => 'assistant',
// 'content' => $fullContent,
'content' => '未生成剧本相关的内容,请调整提示词再试',
'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;
}
}
// 替换美术风格
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
// 新建动漫
if ($user_anime_name == '新剧本策划') {
$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' => $prompt,
// 'created_at' => $now,
// 'updated_at' => $now
// ],
// [
// 'uid' => $uid,
// 'anime_id' => $anime_id,
// 'sequence' => 0,
// 'role' => 'assistant',
// 'content' => '未能生成剧本名称,请重试',
// '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 ($anime_name && DB::table('mp_animes')->where('anime_name', $anime_name)->value('id')) {
$anime_name = $anime_name . '_' . date('YmdHis');
}
}else {
$anime_name = $user_anime_name;
}
$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']) && is_array($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '[]',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'art_style_type' => $input_art_style,
'scenes' => isset($script_arr['scenes']) && is_array($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '[]',
'status' => '解析完成',
'content' => $content,
'is_multi' => $is_multi,
'ace_mode' => $ace_mode,
'generate_status' => '待执行',
'updated_at' => date('Y-m-d H:i:s')
];
if ($table_data['content']) {
$chapters = $this->splitContent($table_data['content']);
$chapter_count = count($chapters);
if ($chapter_count > 0) {
$table_data['start_episode_sequence'] = 1;
$table_data['end_episode_sequence'] = $table_data['episode_num'] = $chapter_count;
}
}
// 如果有剧本ID则进行绑定
if ($script_id) {
$table_data['script_id'] = $script_id;
}
$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' => $prompt,
'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') : [],
is_array(getProp($script_arr, 'props')) ? getProp($script_arr, 'props') : [],
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),
'props' => json_encode($saveResult['merged_props'], 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);
$table_data['props'] = json_encode($saveResult['merged_props'], 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,
// 'content' => '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。',
'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,
// ]);
// // 将全局角色和场景的task_id、task_status和url继承给当前分集
// $this->inheritGlobalImagesToEpisodes($anime_id, $episode_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:该对话不存在');
}
$user_anime_name = getProp($anime, 'anime_name');
$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");
if ($prompt) {
$question .= "本次修改要求如下:\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($anime, 'art_style');
if (!$input_art_style) {
$input_art_style = getProp($data, 'art_style');
}
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
// 美术风格
if (!$mappedArtStyle) {
$mappedArtStyle_prompt = '美术风格:根据剧本内容自定义美术风格';
}else {
if (strstr($mappedArtStyle, '基础画风风格词')) {
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n{$mappedArtStyle}\n";
}else {
$art_style_type = $input_art_style ? $input_art_style : getProp($anime, 'art_style_type');
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n基础画风风格词: {$art_style_type}\n{$mappedArtStyle}\n";
}
}
$timbres_content = "青年男:北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年
青年女:柔美女友、魅力女友、爽快思思、高冷御姐、知性温婉、甜心小美、清新女声、甜美桃子、温柔文雅、甜美小源、贴心闺蜜
中年男:胡子叔叔、油腻大叔、神秘法师、优柔帮主
中年女:邻居阿姨
老年男:幽默大爷
老年女:婆婆
萌娃:奶气萌娃";
$system_message = ['role'=>'system', 'content'=>$is_single
? "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容输出单剧集完整策划稿(剧本大纲包括: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分镜剧本>,每个板块之间必须使用###分隔,#不能多也不能少)。单剧集模式下,必须一次性输出可直接保存的大纲与完整分镜,不允许只输出大纲。
强制要求:
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:
1.剧本名(必须生成)和剧集标题必须与文档内容高度相关,(其中剧集序号是1)。
2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分:主体描述、人物提示词和音色,格式必须为:主体名:主体描述{人物提示词}{{音色}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色必须在以下音色中($timbres_content)选择,需要根据角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
3.$mappedArtStyle_prompt\n\n
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:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{甜心小美}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{阳光青年}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}
###分镜剧本
##第1幕:徐家老旧厨房 白天 室内
分镜1
画面描述:许芸-校服装站在厨房门口,书包背在肩上,徐母手持扫帚,正对着许芸的背部猛烈挥打。卞大伟站在锅炉后方,双手抱胸,阴沉的目光落在许芸身上。
场景:徐家老旧厨房
构图设计:全景,低角度仰视
运镜调度:手持拍摄
配音角色:旁白
出镜角色:许芸-校服装、徐母
台词内容:我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我,
画面类型:普通画面
尾帧描述:手持拍摄微晃镜头,中年妇女猛力挥动扫帚击打少女背部,少女身体因冲击微颤,阴影中的肥胖男子目光冷冷跟随。
分镜2
画面描述:徐母面容扭曲,嘴唇快速开合,扫帚挥舞在空中。
场景:徐家老旧厨房
构图设计:近景,徐母面部特写
运镜调度:固定镜头
配音角色:徐母
出镜角色:徐母
台词内容:问我夜不归宿死哪儿去了。
画面类型:对口型
尾帧描述:固定镜头,中年妇女面部肌肉剧烈抖动,嘴唇快速开合进行辱骂,背景中的扫帚在空中上下挥动。
##第2幕:徐家简陋客厅 黄昏 室内
分镜3
画面描述:客厅窗户透出昏黄的夕阳余晖,空荡荡的客厅中央只有几把木凳和一张方桌,显得异常安静。
场景:徐家简陋客厅
构图设计:全景,景深虚化
运镜调度:固定镜头
配音角色:旁白
出镜角色:无
台词内容:可我没想到,半个月后,徐母居然再一次外出,
画面类型:普通画面
尾帧描述:固定镜头。昏黄的夕阳余晖在地面上缓慢移动,空气中的浮尘在光束中跳动,室内维持着死寂的安静。
分镜4
画面描述:一张泛黄的火车票特写,上面显示着隔壁市的站名和日期。
场景:徐家简陋客厅
构图设计:特写,火车票
运镜调度:固定镜头
配音角色:旁白
出镜角色:无
台词内容:这次是去隔壁市看望她生病的好姐妹。
画面类型:普通画面
尾帧描述:固定镜头。光影在火车票表面微微晃动,强调其陈旧的纸张纹理与关键的行程信息。\n
"
: "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间必须使用###分隔,#不能多也不能少;同时板块之间需满足以下要求:\n
强制要求:\n
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:\n
1.剧本名(必须生成)必须与文档内容高度相关
2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分:主体描述、人物提示词和音色,格式必须为:主体名:主体描述{人物提示词}{{音色}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色必须在以下音色中($timbres_content)选择,需要根据角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
4.$mappedArtStyle_prompt\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{甜心小美}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{阳光青年}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}"];
// 如果提示词$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
2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分:主体描述、人物提示词和音色,格式必须为:主体名:主体描述{人物提示词}{{音色}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色必须在以下音色中($timbres_content)选择,需要根据角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词},注意不要出现{{}}这样的双大括号。
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
4.<分集详细内容>中每一集的内容需尽可能的详细,保留对话内容(使用原文内容,不得自己生成),一般每集字数尽量不低于500字,每集结尾需以##第N章 作为章节标题\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{甜心小美}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{阳光青年}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}
###分集详细内容
##第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));
try {
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
$streamGenerator = $this->deepSeekStreamResponse($post_data);
} else if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$streamGenerator = $this->gpt54StreamResponse($post_data);
} else {
$streamGenerator = $this->volcEngineChatCompletion($post_data);
}
// 验证返回值类型
if (!is_iterable($streamGenerator)) {
$errorType = is_object($streamGenerator) ? get_class($streamGenerator) : gettype($streamGenerator);
dLog('deepseek')->error('方法名流式生成器无效', [
'generator_type' => $errorType,
'model' => $model,
// 添加其他上下文信息
]);
logDB('deepseek', 'error', '方法名流式生成器类型错误', [
'type' => $errorType,
'model' => $model
]);
yield [
'type' => 'error',
// 根据方法返回相应的空数据结构
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '接口返回数据异常,请重新请求'
];
return;
}
// 处理流式输出
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 $chunk;
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error('方法名流式处理异常: ' . $e->getMessage(), [
'model' => $model,
'trace' => $e->getTraceAsString()
]);
logDB('deepseek', 'error', '方法名流式处理失败', [
'error' => $e->getMessage(),
'model' => $model
]);
yield [
'type' => 'error',
// 根据方法返回相应的空数据结构
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '处理请求时发生异常: ' . $e->getMessage()
];
return;
}
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' => '未生成剧本相关的内容,请调整提示词后重试',
'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;
}
// 替换美术风格
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
if ($user_anime_name != '新剧本策划') {
$anime_name = isset($script_arr['script_name']) ? $script_arr['script_name'] : '';
// 确认对话名称唯一性
if ($anime_name && $id = DB::table('mp_animes')->where('anime_name', $anime_name)->value('id')) {
if ($id != $anime_id) $anime_name = $anime_name . '_' . date('YmdHis');
}
}else {
$anime_name = $user_anime_name;
}
$table_data = [
'user_id' => $uid,
'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']) && is_array($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '[]',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'art_style_type' => $input_art_style,
'scenes' => isset($script_arr['scenes']) && is_array($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '[]',
'status' => '解析完成',
'generate_status' => '待执行',
'updated_at' => date('Y-m-d H:i:s')
];
if ($anime_name) $table_data['anime_name'] = $anime_name;
// 如果是修改集数,则将content内容更新(会改变原文)
if (isset($episode_count) && $episode_count > 0) {
if (isset($script_arr['content']) && $script_arr['content']) $table_data['content'] = $script_arr['content'];
$table_data['start_episode_sequence'] = 1;
$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 (!$table_data['content']) {
yield [
'type' => 'done',
'script' => [],
'episode' => [],
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => '未生成剧本内容,请调整提示词再试'
];
return;
}
}
if (isset($table_data['content']) && $table_data['content']) {
$chapters = $this->splitContent($table_data['content']);
$chapter_count = count($chapters);
if ($chapter_count > 0) {
$table_data['start_episode_sequence'] = 1;
$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' => '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。',
'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') : [],
is_array(getProp($script_arr, 'props')) ? getProp($script_arr, 'props') : [],
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),
'props' => json_encode($saveResult['merged_props'], 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);
$table_data['props'] = json_encode($saveResult['merged_props'], 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,
// 'content' => '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。',
'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,
// ]);
// // 将全局角色和场景的task_id、task_status和url继承给当前分集
// $this->inheritGlobalImagesToEpisodes($anime_id, $episode_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 : [];
// 获取最后一集序号
$end_episode_sequence = getProp($anime, 'end_episode_sequence');
if ($end_episode_sequence && $episode_number > $end_episode_sequence) Utils::throwError("20003:总集数只有{$end_episode_sequence}集,不允许继续策划下一集!");
// 转换主体列表和场景列表的格式
// 获取参考主体或场景
if ((int)$episode_number === 1) {
// $ref_data = DB::table('mp_animes')->where('id', $anime_id)->first();
$ref_data = []; // 第一集让AI自由发挥不再使用全局主体(会影响后续分集的继承问题)
}else {
$prev_episode_number = $episode_number - 1;
$ref_data = DB::table('mp_anime_episodes')->where('anime_id', $anime_id)->where('episode_number', $prev_episode_number)->where('is_default', 1)->first();
}
$ref_roles = getProp($ref_data, 'roles') ? json_decode(getProp($ref_data, 'roles'), true) : [];
$ref_scenes = getProp($ref_data, 'scenes') ? json_decode(getProp($ref_data, 'scenes'), true) : [];
$roles_content = $this->buildEpisodeItemContent($ref_roles, 'role');
$scenes_content = $this->buildEpisodeItemContent($ref_scenes, 'scene');
$timbres_content = "青年男:北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年
青年女:柔美女友、魅力女友、爽快思思、高冷御姐、知性温婉、甜心小美、清新女声、甜美桃子、温柔文雅、甜美小源、贴心闺蜜
中年男:胡子叔叔、油腻大叔、神秘法师、优柔帮主
中年女:邻居阿姨
老年男:幽默大爷
老年女:婆婆
萌娃:奶气萌娃";
if ($roles_content) {
$role_demo = "2.<主体列表>可在以下主体中选择多个(根据拆分出的分镜剧本来确认具体几个,不要出现旁白角色),不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。
主体范围:\n {$roles_content}\n
2.1如果本集剧情内容中出现了和给出的<主体列表>中不存在的新主体(或原主体发生了比较大的年龄、服装或身体变化),则不用在上述主体中选择,而是按照相同的格式新增一个主体显示在<主体列表>中。(以下是格式要求和生成要求,仅适用新出现的主体):
→ 新主体分以下三部分:主体描述、人物提示词和音色,格式必须为:主体名:主体描述{人物提示词}{{音色}}
→ 新主体的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能(缩略成一句话讲述完毕)。
→ 新主体的人物提示词需要根据当前分析出来的美术风格生成(每个人物都只允许一套服装,如果多套服装则需区分主体名),包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
→ 新主体的音色必须在以下音色中($timbres_content)选择,需要根据角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。";
}else {
$role_demo = "2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分:主体描述、人物提示词和音色,格式必须为:主体名:主体描述{人物提示词}{{音色}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能(缩略成一句话讲述完毕)。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成(每个人物都只允许一套服装,如果多套服装则需区分主体名),包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色必须在以下音色中($timbres_content)选择,需要根据角色描述的年纪和性别选择,比如角色是青年男性就只能在北京小爷、阳光青年、傲娇霸总、优柔公子、解说小明、青涩小生、低音沉郁、亲切青年中选择,选好的音色放在描述的最后用{{}}框起来。";
}
if ($scenes_content) {
$scene_demo = "3.<场景列表>可在以下场景中选择多个(需带上冒号后的具体描述),根据拆分出的分镜剧本来确认具体几个,不得自定义(必需带上冒号后的具体描述以及{}中的内容,原样输出)。
场景范围:\n {$scenes_content}\n
3.1如果本集剧情内容中出现了和给出的<场景列表>中不存在的新场景,则不用在上述场景中选择,而是按照相同的格式新增一个场景显示在<场景列表>中,必须严格注意不要出现任何角色和人物。(以下是格式要求和生成要求,仅适用新出现的场景):。
→ 新场景分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词}
→ 新场景的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
→ 新场景的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n";
}else {
$scene_demo = "3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词},注意不要出现{{}}这样的双大括号。
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n";
}
// 提取文件内容
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 = '';
}
$input_art_style = getProp($anime, 'art_style');
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
// 美术风格
if (!$mappedArtStyle) {
$mappedArtStyle_prompt = '美术风格:根据剧本内容自定义美术风格';
}else {
if (strstr($mappedArtStyle, '基础画风风格词')) {
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n{$mappedArtStyle}\n";
}else {
$art_style_type = $input_art_style ? $input_art_style : getProp($anime, 'art_style_type');
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n基础画风风格词: {$art_style_type}\n{$mappedArtStyle}\n";
}
}
$prompt = trim((string)getProp($data, 'prompt', '确认分镜大纲'));
// $demo = "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需包含以下几个板块: <剧集标题><故事梗概><美术风格><主体列表><场景列表><分镜剧本>,每个板块之间必须使用###分隔,#不能多也不能少;同时板块之间需满足以下要求:\n
// 强制要求:
// - 你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
// - 如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
// - 以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
// - 用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求
// - 强制按照示例格式返回内容,不要更改格式或随意增加符号
// - 分镜剧本必须严格按照给出的“本集剧情内容”进行拆分,一个字都不要漏掉,同时也不要新增原文没有的内容
// - 如果给出的本集剧情内容是剧本格式,则不要改动剧本内容(必须强制使用原文内容),严格按照剧本的旁白和台词进行分镜的策划,不要跳过任何剧情
// - 分镜不是二次创作,必须严格“原文拆句”。优先把原文中可直接成镜头的句子拆成分镜,禁止把两句合并后再脑补过渡。
// - 画面描述必须直接使用,禁止新增任何原文没有写出的动作、人物、道具、地点、气氛、结果或因果。
// - 不要把“打开衣柜”扩写成“伸手轻抚嫁衣”,不要把“管家拎着两个箱子跟在后面”改成“沈耀和管家一起拎箱子”。主语、动作、数量、先后顺序必须完全按原文。
// - 分镜要和场景列表、人物主体保持一致\n
// 普通要求:
// 1.剧集标题必须与文档内容高度相关,剧集序号是{$episode_number}。
// {$role_demo}
// {$scene_demo}
// 4.<分镜剧本>要求包含“分幕标题”以及“分镜信息”:
// 4.1“分幕标题”是根据章节中的场景进行拆分,类似于“第1幕 场景、时间及室内或室外”的信息,其中场景只能在所给出的场景列表中选(不带场景详细描述)。
// 4.2“分镜信息”需同一场景内的对话内容完成分镜(每一幕分镜尽量不得低于5个),需包含“画面描述”、“构图设计”、“运镜调度”、“配音角色”、“台词内容”、“画面类型”、“尾帧描述”、“情感”、“性别”、“语速”、“音量”、“情感强度”、“音调”这几个部分,其中“画面描述”如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)。
// 4.3分镜序号用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨分幕中的分镜序号需连续,但跨剧集中的不需要连续,如第2集的第1幕分镜序号从1开始)。
// 4.4“分镜信息”中的“台词内容”需满足以下要求: (1. 台词来源规则:- 台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容)。- 不允许改写、总结、压缩、扩写或拼接原文对白。- 不允许生成原文中不存在的对话。2. 非对话过滤:- 旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:- 如果该分镜中没有人物对话,则“台词内容”必须返回“无”。- 严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:- 不得根据语境补全人物可能说的话。5.如果没有找到原文内容,直接返回无)
// 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表示正常音调。
// 5.$mappedArtStyle_prompt\n\n
// 示例格式:\n
// 第1集:隐形的守护者
// ###故事梗概
// 内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
// ###美术风格
// 基础画风风格词:韩漫二次元
// 视觉风格描述:整体采用精致的韩漫厚涂风格,以浅灰冷调为基调,营造出重生复仇的清冷与压抑感。画面强调高对比度的光影处理,网吧场景通过低饱和度的杂乱色调表现颓废感,而清晨车祸瞬间则利用强烈的动态模糊与局部高光,突显“透明守护者”存在的神秘感与宿命感。人物形象细腻,眼神充满坚毅与破碎交织的故事感,完美契合偏执救赎的主题。
// ###主体列表
// 许芸-校服装:重生后的“假千金”,清醒果敢,不再任人唯亲。她是一名高中生,身材纤细,留着乌黑齐肩短发,眼神坚定。{18岁年轻女生,黑色长发,红色眼睛,五官精致,皮肤白皙,身材纤细,穿着日系校园制服,白色衬衫,黑色百褶裙,红色领带,表情悲伤,眼含泪水}{{甜心小美}}
// 许芸-卫衣装:半个月后居家或外出时的装扮,在清冷的环境中透着一丝坚毅与破碎感。{绝美年轻女子,白色长发,金色眼睛,五官精致,皮肤白皙,身材纤细,穿着淡蓝色古风汉服,气质清冷,神情平静,高细节人物刻画}{{爽快思思}}
// 程景:偏执深情的学霸,为救许芸变得“不可见”,是始终默默守护的隐形爱人。他身材高大挺拔,面容英俊。{年轻男性反派,银白色短发,红色眼睛,面容冷峻,皮肤苍白,身穿黑色长袍,嘴角冷笑,气质危险,高细节人物刻画}{{阳光青年}{{阳光青年}}
// ###场景列表
// 徐家老旧厨房:这是徐母劳作和惩罚许芸的地方。空间狭窄阴暗,充满了生活琐碎的压抑感。室内有一个旧式锅炉,案板上散落着面粉和包好的馄饨,光影对比强烈,凸显出家庭环境的冷酷。{影视质感。广角。
// 宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
// 无人物。}
// 徐家简陋客厅:半个月后冲突爆发的中心。陈设简单且陈旧,只有几把木凳和一张方桌。窗帘半掩,透进微弱的冷光,营造出一种暴风雨前的宁静与不安感。{影视质感。冷青色调,浓雾弥漫。
// 一条通往西北方向的崎岖山间土路,两旁是枯萎的灌木和怪石嶙峋。地面湿滑,反光强烈。远方山峦在阴雨中若隐若现,营造出一种穷追不舍的紧张感。
// 无人物。}
// 老旧公寓门口:许芸逃出家门后回望的视点。门框斑驳,透出屋内昏暗压抑的光线,与室外略显空旷的走廊形成对比,象征着逃离与未知的守护。{
// 豪华游轮内部宽敞的大堂或公共休息区。设计奢华,天花板高挑,有精致的装饰和柔和的暖色灯光。地面铺设着光洁的大理石,远处有巨大的落地窗,窗外是蓝色的海洋。自然光线充足,色彩明亮且饱和度适中,展现豪华游轮的质感。
// 无人物。}
// 破败巷弄与臭水沟:家门外的外部环境。一条狭窄、潮湿的巷弄,旁边是散发着冷气的臭水沟,水面偶尔泛起涟漪。这里是“透明守护者”受伤后经过的地方,氛围凄清。{
// 宽敞明亮的现代风格客厅,家具摆放整齐,色调偏向冷灰与深蓝,营造一种沉静而略带压抑的氛围。窗外光线柔和,室内光影对比不强烈,整体干净整洁。
// 无人物。}
// 潮湿的巷弄地面:特写视角。灰色的水泥地面湿迹斑斑,这是许芸发现血脚印并进行最后告白的地方。强调地面的质感和清冷的基调。{
// 一个现代居民小区的入口,有门卫岗亭和自动栏杆。道路宽阔,两旁绿化整齐,阳光明媚,整体色调略显冷峻,但光线清晰。
// 无人物。}
// ###分镜剧本
// ##第1幕:徐家老旧厨房 白天 室内
// 分镜1
// 画面描述:许芸-校服装站在厨房门口,书包背在肩上,徐母手持扫帚,正对着许芸的背部猛烈挥打。卞大伟站在锅炉后方,双手抱胸,阴沉的目光落在许芸身上。
// 场景:徐家老旧厨房
// 构图设计:全景,低角度仰视
// 运镜调度:手持拍摄
// 配音角色:旁白
// 出镜角色:许芸-校服装、徐母
// 台词内容:我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我,
// 画面类型:普通画面
// 尾帧描述:手持拍摄微晃镜头,中年妇女猛力挥动扫帚击打少女背部,少女身体因冲击微颤,阴影中的肥胖男子目光冷冷跟随。
// 分镜2
// 画面描述:徐母面容扭曲,嘴唇快速开合,扫帚挥舞在空中。
// 场景:徐家老旧厨房
// 构图设计:近景,徐母面部特写
// 运镜调度:固定镜头
// 配音角色:徐母
// 出镜角色:徐母
// 台词内容:问我夜不归宿死哪儿去了。
// 画面类型:对口型
// 尾帧描述:固定镜头,中年妇女面部肌肉剧烈抖动,嘴唇快速开合进行辱骂,背景中的扫帚在空中上下挥动。
// ##第2幕:徐家简陋客厅 黄昏 室内
// 分镜3
// 画面描述:客厅窗户透出昏黄的夕阳余晖,空荡荡的客厅中央只有几把木凳和一张方桌,显得异常安静。
// 场景:徐家简陋客厅
// 构图设计:全景,景深虚化
// 运镜调度:固定镜头
// 配音角色:旁白
// 出镜角色:无
// 台词内容:可我没想到,半个月后,徐母居然再一次外出,
// 画面类型:普通画面
// 尾帧描述:固定镜头。昏黄的夕阳余晖在地面上缓慢移动,空气中的浮尘在光束中跳动,室内维持着死寂的安静。
// 分镜4
// 画面描述:一张泛黄的火车票特写,上面显示着隔壁市的站名和日期。
// 场景:徐家简陋客厅
// 构图设计:特写,火车票
// 运镜调度:固定镜头
// 配音角色:旁白
// 出镜角色:无
// 台词内容:这次是去隔壁市看望她生病的好姐妹。
// 画面类型:普通画面
// 尾帧描述:固定镜头。光影在火车票表面微微晃动,强调其陈旧的纸张纹理与关键的行程信息。
// \n\n";
$demo = "你是一个国际顶尖的执行导演/编剧,熟悉各种风格的影视化落地,我需要你将这个剧本调整一下,将其变成一个高质量的影视分镜剧本(需包含以下几个板块: <剧集标题><故事梗概><美术风格><主体列表><场景列表><分镜剧本>,每个板块之间必须使用###分隔,#不能多也不能少),要求画面和构图等进行高质量影视化改编,快节奏,改编的格式为先建立主体角色库、场景建库(其中场景建库的时候请分析故事发生的时候场景为白天还是晚上,建库的时候一起标注出来)以及关键的道具库。
与此同时请将这个文本的情绪做一个分析,并且根据我下面提供的《情绪-视听语言映射表》进行一个文本情绪与视听语言的内容匹配。然后将每一个分镜按照「画面描述」「场景/场景时间(白天/晚上/傍晚等)」「构图设计(全景/近景/中景/远景,仰视/平视/俯视等)」「运镜调度」「配音角色」「出境角色」「台词内容」「画面类型」「尾帧描述」的格式来划分,请按照每分钟25个左右的分镜的节奏帮我生成(这是一个整体性的标准,如果文本的情绪分析需要更多或者更少的镜头数量,请根据具体情况调整),分镜头请细腻的拆分文本中的内容,将动作、场景转换、事件的逻辑关系等内容清晰的交代出来,分镜之间需要呈现场景之间的转换逻辑,不要突然跳到下一个场景之中。于此同时保持镜头剪辑的顿挫感,节奏不要太平均。
每一个分镜的「画面描述」请参考上一个分镜中人物的状态/动作和位置来写,保持人物状态以及在场景中的位置的一致性,不要断裂感,同时也不要直接在内容里写“参考上个分镜”这种话,因为视频模型无法识别,使用模型可以直接识别的文本内容。每一个分镜中需要出现的内容都请标出,例如出场角色,出场道具等等。请尽可能详细的描述画面内容。\n
普通要求:
1.剧集标题必须与文档内容高度相关,剧集序号是{$episode_number}。
{$role_demo}
{$scene_demo}
4.<分镜剧本>要求包含“分幕标题”以及“分镜信息”:
4.1“分幕标题”是根据章节中的场景进行拆分,类似于“第1幕 场景、时间及室内或室外”的信息,其中场景只能在所给出的场景列表中选(不带场景详细描述)。
4.2“分镜信息”需同一场景内的对话内容完成分镜(每一幕分镜尽量不得低于5个),需包含“画面描述”、“构图设计”、“运镜调度”、“配音角色”、“台词内容”、“画面类型”、“尾帧描述”、“情感”、“性别”、“语速”、“音量”、“情感强度”、“音调”这几个部分,其中“画面描述”如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)。
4.3分镜序号用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨分幕中的分镜序号需连续,但跨剧集中的不需要连续,如第2集的第1幕分镜序号从1开始),分镜序号前后不要带任何其他符号,特别是*符号。
4.4“分镜信息”中的“台词内容”需满足以下要求: (1. 台词来源规则:- 台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容)。- 不允许改写、总结、压缩、扩写或拼接原文对白。- 不允许生成原文中不存在的对话。2. 非对话过滤:- 旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:- 如果该分镜中没有人物对话,则“台词内容”必须返回“无”。- 严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:- 不得根据语境补全人物可能说的话。5.如果没有找到原文内容,直接返回无)
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表示正常音调。
5.$mappedArtStyle_prompt
6.《情绪-视听语言映射表》:
6.1 场景空间与时间心理学 (Scene & Temporality)
维度 | 视听特征 | 对应核心情绪 | 心理学/叙事原理
-----|---------|------------|----------------
场景空间 | 封闭狭小 (Corridors/Closets) | 压抑、幽闭恐惧 | 物理限制映射心理自由的剥夺。
场景空间 | 极度开阔 (Empty Plains/Desert) | 孤独、渺小、疏离 | 剥离社会性支撑,强化个体的虚无感。
时间选择 | 黄金时刻 (Golden Hour) | 怀旧、温情、希望 | 柔和漫反射光线符合大脑对“美好记忆”的视觉加工。
时间选择 | 蓝调时刻/深夜 (Blue/Night) | 冷漠、忧郁、未知危险 | 冷色调抑制血清素,增强感官的不安全感。
6.2 剪辑节奏与感知阈值 (Shot Duration & Rhythm)
维度 | 视听特征 | 对应核心情绪 | 心理学/叙事原理
-----|---------|------------|----------------
长镜头 | >15秒的静止或缓慢移动 | 沉思、肃穆、煎熬 | 强迫观众进入实时流逝,产生生理性的存在感。
快切剪辑 | <2秒的频繁切换 | 混乱、亢奋、惊恐 | 模拟高肾上腺素下的视觉捕捉,剥夺深度思考时间。
节奏错位 | 非同步切分 (L-cut/J-cut) | 扭曲、不协调 | 破坏连续性假定,产生心理上的不适与悬疑感。
6.3 运镜调度与情绪调动 (Cinematic Movement & Affect)
运镜方式 | 物理特征 | 心理/情绪功能 | 专业应用场景
--------|---------|--------------|-------------
推镜头 (Dolly In) | 摄影机物理接近主体 | 专注、发现、心理逼近。通过压缩背景空间,强制观众忽略环境,进入主体的内心世界。 | 角色顿悟、秘密揭晓、情感升温。
拉镜头 (Dolly Out) | 摄影机物理远离主体 | 疏离、解脱、宿命感。将主体置于广阔背景中,显示主体的渺小或强调事情已成定局。 | 悲剧结尾、孤独的告别、审视全局。
摇镜头 (Pan/Tilt) | 轴心转动,视点改变 | 观察、审读、惊奇感。模拟人眼转头,用于揭示空间关系或进行逻辑上的“连接”。 | 发现潜伏的危机、环视战场、建立空间地理志。
跟拍 (Tracking Shot) | 保持与主体同速移动 | 强烈的参与感、伴随性焦虑。让观众感觉自己是角色的共谋者或影子,产生“实时感”。 | 逃亡、快步穿行于走廊、展示角色的行动力。
横移 (Truck/Side Track) | 与主体平行横向移动 | 客观审视、叙事流逝。像卷轴画一样展开,产生一种冷静、观察者的旁白视角。 | 展示环境细节、两个角色在边走边谈时的心理博弈。
手持/肩扛 (Handheld) | 生理性不规则晃动 | 不安、真实感、心理动荡。模拟肉眼观察,通过不稳定性唤起观众的本能警觉。 | 冲突现场、纪录片式纪实感、角色精神崩溃。
斯坦尼康 (Steadicam) | 流畅、近乎滑行的移动 | 全知感、梦幻、无缝观察。消除物理重力感,产生一种“上帝视角”或“游魂般”的观察。 | 漫长而复杂的转场、舞会等优雅场景的穿梭。
希区柯克变焦 (Dolly Zoom) | 推进同时反向变焦 | 眩晕、世界崩塌、极度惊骇。空间透视的物理扭曲直接引发生理性失衡。 | 角色得知真相的瞬间、高度恐惧。
升降/摇臂 (Crane Shot) | 垂直或斜向空间位移 | 释放、崇高、超越性。打破二维平面的限制,将情绪从微观拉向宏观。 | 史诗感的结局、战场全景、情绪的爆发式升华。
6.4 构图、色彩与符号学 (Composition & Aesthetics)
维度 | 视听特征 | 对应核心情绪 | 心理学原理
-----|---------|------------|------------
角度 | 仰角 (Low Angle) | 威严、恐惧、崇高 | 模拟幼年视角,确立权力层级。
角度 | 俯角 (High Angle) | 脆弱、无助、卑微 | 模拟审视感,削弱主体力量。
构图 | 荷兰坡 (Dutch Tilt) | 疯狂、崩溃、失衡 | 重力轴偏离导致前庭系统产生失衡反应。
色彩 | 极高饱和度/冷暖撞色 | 狂喜、冲突、超现实 | 强烈的视觉刺激诱发情绪阈值波动。
6.5 进阶视听修辞与认知干预 (Advanced AV Rhetoric)
修辞策略 | 技术实现 | 心理/情绪效应 | 理论基石
--------|---------|--------------|----------
声画对位 (Contrapuntal AV) | 在极端残酷/激烈的画面中配以极其舒缓优雅的音乐(如古典乐)。 | 荒诞恐怖感、神圣的残酷、间离效果。通过感官冲突迫使观众从原始情绪中抽离,进入形而上的思考。 | 安德烈·塔可夫斯基《雕刻时光》、库布里克式美学。
非平衡构图 (Compositional Tension) | 突破平衡法则,将视觉重点(Eye-tracking)置于画面边缘或极端角落。 | 本能警觉、不安全感、期待感。利用大脑对“视觉缺失”的补充本能,暗示画面外存在不可见的威胁。 | 格式塔心理学、眼动追踪实验。
认知负荷调控 (Neurocinematics) | 利用快速剪辑抑制前额叶皮层(理性区),激活杏仁核(情绪区)。 | 纯粹感官化、生存模式切换。若要观众思考,则延长镜头以激活认知分析;若要观众受惊,则通过视觉过载切断其逻辑判断。 | 神经电影学 (Neurocinematics)。
6.6 声音设计与潜意识影响 (Soundscape)
维度 | 视听特征 | 对应核心情绪 | 心理学/生理学原理
-----|---------|------------|------------------
音色 | 极低频音 (Infra-sound) | 潜伏恐惧、生理不安 | 模仿自然灾害频率,唤醒生物避害本能。
环境声 | 极度死寂 (Silence) | 震惊、神圣、虚无 | 听觉剥夺放大视觉信号,产生心理张力。
非现场音 | 不和谐音/尖锐音效 | 精神紧绷、躁郁 | 高频声音直接刺激杏仁核,引发防御机制。
理论基石:
- The Five C's of Cinematography by Joseph V. Mascelli
- Film Art: An Introduction by David Bordwell
- Sight, Sound, Motion by Herbert Zettl
- Notes on the Cinematograph by Robert Bresson
\n\n
7.必须严格按照以下示例格式返回剧本,不得添加其他符号
示例格式:\n
第1集:隐形的守护者
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###美术风格
基础画风风格词:韩漫二次元
视觉风格描述:整体采用精致的韩漫厚涂风格,以浅灰冷调为基调,营造出重生复仇的清冷与压抑感。画面强调高对比度的光影处理,网吧场景通过低饱和度的杂乱色调表现颓废感,而清晨车祸瞬间则利用强烈的动态模糊与局部高光,突显“透明守护者”存在的神秘感与宿命感。人物形象细腻,眼神充满坚毅与破碎交织的故事感,完美契合偏执救赎的主题。
###主体列表
许芸-校服装:重生后的“假千金”,清醒果敢,不再任人唯亲。她是一名高中生,身材纤细,留着乌黑齐肩短发,眼神坚定。{18岁年轻女生,黑色长发,红色眼睛,五官精致,皮肤白皙,身材纤细,穿着日系校园制服,白色衬衫,黑色百褶裙,红色领带,表情悲伤,眼含泪水}{{甜心小美}}
许芸-卫衣装:半个月后居家或外出时的装扮,在清冷的环境中透着一丝坚毅与破碎感。{绝美年轻女子,白色长发,金色眼睛,五官精致,皮肤白皙,身材纤细,穿着淡蓝色古风汉服,气质清冷,神情平静,高细节人物刻画}{{爽快思思}}
程景:偏执深情的学霸,为救许芸变得“不可见”,是始终默默守护的隐形爱人。他身材高大挺拔,面容英俊。{年轻男性反派,银白色短发,红色眼睛,面容冷峻,皮肤苍白,身穿黑色长袍,嘴角冷笑,气质危险,高细节人物刻画}{{阳光青年}{{阳光青年}}
###场景列表
徐家老旧厨房:这是徐母劳作和惩罚许芸的地方。空间狭窄阴暗,充满了生活琐碎的压抑感。室内有一个旧式锅炉,案板上散落着面粉和包好的馄饨,光影对比强烈,凸显出家庭环境的冷酷。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}
徐家简陋客厅:半个月后冲突爆发的中心。陈设简单且陈旧,只有几把木凳和一张方桌。窗帘半掩,透进微弱的冷光,营造出一种暴风雨前的宁静与不安感。{影视质感。冷青色调,浓雾弥漫。
一条通往西北方向的崎岖山间土路,两旁是枯萎的灌木和怪石嶙峋。地面湿滑,反光强烈。远方山峦在阴雨中若隐若现,营造出一种穷追不舍的紧张感。
无人物。}
老旧公寓门口:许芸逃出家门后回望的视点。门框斑驳,透出屋内昏暗压抑的光线,与室外略显空旷的走廊形成对比,象征着逃离与未知的守护。{
豪华游轮内部宽敞的大堂或公共休息区。设计奢华,天花板高挑,有精致的装饰和柔和的暖色灯光。地面铺设着光洁的大理石,远处有巨大的落地窗,窗外是蓝色的海洋。自然光线充足,色彩明亮且饱和度适中,展现豪华游轮的质感。
无人物。}
破败巷弄与臭水沟:家门外的外部环境。一条狭窄、潮湿的巷弄,旁边是散发着冷气的臭水沟,水面偶尔泛起涟漪。这里是“透明守护者”受伤后经过的地方,氛围凄清。{
宽敞明亮的现代风格客厅,家具摆放整齐,色调偏向冷灰与深蓝,营造一种沉静而略带压抑的氛围。窗外光线柔和,室内光影对比不强烈,整体干净整洁。
无人物。}
潮湿的巷弄地面:特写视角。灰色的水泥地面湿迹斑斑,这是许芸发现血脚印并进行最后告白的地方。强调地面的质感和清冷的基调。{
一个现代居民小区的入口,有门卫岗亭和自动栏杆。道路宽阔,两旁绿化整齐,阳光明媚,整体色调略显冷峻,但光线清晰。
无人物。}
###分镜剧本
##第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');
if ($uploaded_content) {
$full_content = $uploaded_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 = $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 = $chapter_content;
if (!$content) {
Utils::throwError('20003:章节内容无法获取,请确认');
}
$question = $demo."请根据以上要求分析本集剧情内容,完成本集分镜剧本(需衔接上一集内容)";
// 获取上一集最后记录
$prev_sequence = $episode_number - 1;
$messages = $this->getEpisodeChatContent($anime_id, $prev_sequence, $content);
// 构建消息
$messages[] =
[
'role' => 'user',
'content' => $question
];
}else {
$content = $uploaded_content ? $chapter_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";
if ($origin_prompt) {
$question .= "\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.2,
'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 = [];
try {
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
// DeepSeek 官方模型使用 DeepSeek API
$streamGenerator = $this->deepSeekStreamResponse($post_data);
} else if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$streamGenerator = $this->gpt54StreamResponse($post_data);
} else {
// 其他模型使用火山引擎API
$streamGenerator = $this->volcEngineChatCompletion($post_data);
}
// 验证返回值类型
if (!is_iterable($streamGenerator)) {
$errorType = is_object($streamGenerator) ? get_class($streamGenerator) : gettype($streamGenerator);
dLog('deepseek')->error('chat流式生成器无效', [
'generator_type' => $errorType,
'model' => $model,
'anime_id' => $anime_id,
'episode_number' => $episode_number
]);
logDB('deepseek', 'error', 'chat流式生成器类型错误', [
'type' => $errorType,
'model' => $model
]);
yield [
'type' => 'error',
'episode' => [],
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '接口返回数据异常,请重新请求'
];
return;
}
// 处理流式输出
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;
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error('chat流式处理异常: ' . $e->getMessage(), [
'model' => $model,
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'trace' => $e->getTraceAsString()
]);
logDB('deepseek', 'error', 'chat流式处理失败', [
'error' => $e->getMessage(),
'model' => $model
]);
yield [
'type' => 'error',
'episode' => [],
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '处理请求时发生异常: ' . $e->getMessage()
];
return;
}
dLog('deepseek')->info('完整内容: '.$fullContent);
logDB('deepseek', 'info', '完整内容', ['content' => $fullContent, 'anime_id' => $anime_id]);
// 处理完整内容并返回最终结果
$episode_arr = handleEpisodeContent($fullContent);
logDB('deepseek', 'info', '解析内容', $episode_arr);
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,
// 'content' => '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。',
'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_props = $base_episode ? json_decode((string)getProp($base_episode, 'props'), true) : [];
$existing_roles = is_array($existing_roles) ? $existing_roles : [];
$existing_scenes = is_array($existing_scenes) ? $existing_scenes : [];
$existing_props = is_array($existing_props) ? $existing_props : [];
$now = date('Y-m-d H:i:s');
try {
DB::beginTransaction();
$saveResult = $this->saveEpisodeVersionData(
$anime_id,
$episode_number,
$episode_arr,
$existing_roles,
$existing_scenes,
$existing_props,
$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'];
$merged_props = $saveResult['merged_props'];
$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,
// 'content' => '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。',
'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表
$update_anime_data = [
'model' => $model,
'updated_at' => $now
];
if ($uploaded_content) {
$update_anime_data['content'] = $uploaded_content;
}
DB::table('mp_animes')->where('id', $anime_id)->update($update_anime_data);
}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,
// ], true);
// // 批量生成全局场景图片
// $this->batchSetGlobalSceneImg([
// 'anime_id' => $anime_id,
// ], true);
// // 将全局角色和场景的task_id、task_status和url继承给当前分集
// $this->inheritGlobalImagesToEpisodes($anime_id, $episode_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
];
}
public function addChatForAce($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);
$ace_mode = getProp($data, 'ace_mode', 0) ? getProp($data, 'ace_mode', 0) : getProp($anime, 'ace_mode', 0);
$prompt = getProp($data, 'prompt');
$extra_products = getProp($data, 'products', []);
if (!$extra_products) {
$extra_products = getProp($anime, 'extra_products');
if ($extra_products) {
$extra_products = json_decode($extra_products, true);
}else {
$extra_products = [];
}
}else {
if (is_string($extra_products)) $extra_products = json_decode($extra_products, true);
}
$user_anime_name = getProp($anime, 'anime_name');
// 处理提示词
if ($prompt && $extra_products) {
// 遍历extra_products,将prompt中的{product_name}替换为实际的product_name
foreach($extra_products as $item) {
$product_name = getProp($item, 'product_name');
if ($product_name) {
// 替换 {product_name} 为 product_name
$prompt = str_replace('{' . $product_name . '}', $product_name, $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");
if ($prompt) {
$question .= "本次修改要求如下:\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');
if (!$input_art_style) {
$mappedArtStyle = getProp($anime, 'art_style');
}else {
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
}
// 判断是否需要生成原文内容
$need_generate_content = isset($need_generate_content) && $need_generate_content;
// 如果需要生成原文内容,添加额外的要求
$content_generation_requirement = $need_generate_content
? "\n5.如果用户只提供了创作要求而没有提供具体文档内容,你需要根据要求创作完整的原文内容(小说形式,包含对话和叙述),并将原文内容放在最后一个板块###原文内容中输出。原文内容应该详细、生动,如果用户没有明确生成的字数,则字数不少于1000字。"
: "";
// 美术风格
if (!$mappedArtStyle) {
$mappedArtStyle_prompt = '美术风格:根据剧本内容自定义美术风格';
}else {
if (strstr($mappedArtStyle, '基础画风风格词')) {
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n{$mappedArtStyle}\n";
}else {
$art_style_type = $input_art_style ? $input_art_style : getProp($anime, 'art_style_type');
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n基础画风风格词: {$art_style_type}\n{$mappedArtStyle}\n";
}
}
// 构建主体和场景提示词
$extra_role_prompt = "\n";
$extra_scene_prompt = "\n";
$ref_products = []; // 参考资产
if ($extra_products) {
foreach($extra_products as $item) {
$product = getProp($item, 'product');
$product_name = getProp($item, 'product_name');
if ($product_name == '旁白') continue;
$ref_products[$product_name] = $item;
$pic_prompt = getProp($item, 'pic_prompt');
if ((int)$product === 1) {
// 拼接角色信息
$extra_role_prompt .= $product_name . ":" . $pic_prompt . "\n";
} elseif ((int)$product === 2) {
// 拼接场景信息
$extra_scene_prompt .= $product_name . ":" . $pic_prompt . "\n";
}
}
// 优化提示词,融入剧本
if (!empty(trim($extra_role_prompt))) {
$extra_role_prompt = "\n2.5【强制主体设定-不可修改】\n以下主体必须全部出现在<主体列表>中,必须包含:\n" . $extra_role_prompt . "\n格式要求:\n1. 主体名称:必须保持不变\n2. 主体描述:你必须根据剧情为每个主体生成主体描述(分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,用一句话进行总结)\n3. 人物提示词:{}中的内容必须完整保留,不得修改\n4. 音色提示词:你必须根据角色特征生成音色提示词(描述角色的整体音色特征,包含:性别、年龄、音质、表达形式、情绪氛围,字数控制在80字以内),用{{}}框起来\n\n最终格式必须为:主体名:[你生成的主体描述]{人物提示词-保持不变}{{你生成的音色提示词}}\n\n【重要】以上每个主体在<主体列表>中的完整格式必须是:主体名:主体描述{人物提示词}{{音色提示词}},其中主体描述和音色提示词是你必须生成的内容,{}中的人物提示词必须原样保留。";
}
if (!empty(trim($extra_scene_prompt))) {
$extra_scene_prompt = "\n3.4【强制场景设定-不可修改】\n以下场景必须全部出现在<场景列表>中,必须包含:\n" . $extra_scene_prompt . "\n格式要求:\n1. 场景名称:必须保持不变\n2. 场景描述:你必须根据剧情为每个场景生成符合<普通要求3.2>的场景描述(分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文)\n3. 场景提示词:{}中的内容必须完整保留,不得修改\n\n最终格式必须为:场景名:[你生成的场景描述]{场景提示词-保持不变}\n\n必须包含的场景列表:\n" . $extra_scene_prompt . "\n【重要提示】以上每个场景在<场景列表>中的完整格式必须是:场景名:场景描述{场景提示词},其中场景描述是你必须生成的内容,{}中的场景提示词必须原样保留。";
}
}
$systemPrompt = $is_single
? "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容输出单剧集完整策划稿(剧本大纲包括: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分段剧本>,每个板块之间必须使用###分隔,#不能多也不能少)。单剧集模式下,必须一次性输出可直接保存的大纲与完整分镜,不允许只输出大纲。
强制要求:
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:
1.剧本名(必须生成)和剧集标题必须与文档内容高度相关,(其中剧集序号是1)。
3.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分(强制格式):主体描述、人物提示词和音色提示词,格式必须为:主体名:主体描述{人物提示词}{{音色提示词}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色提示词放在描述的最后用{{}}框起来。音色提示词需描述角色的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
{$extra_role_prompt}
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分(强制格式):场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
{$extra_scene_prompt}
4.<分段剧本>按照“分段模式”进行拆分和组织,要求如下:
4.1片段分割逻辑(优先级从高到低):
- 第一优先级:时长上限(单个片段不超过15秒,超过必须切为新片段)
- 第二优先级:叙事节拍Beat(一个完整的事件/动作/情绪起落单元,如一段对话的来回、一个动作序列:推门→看见→震惊、一次闪回/回忆、旁白+画面组合)
- 第三优先级:语义块(按“谁、在哪、做什么、情绪怎样”聚合,如同一段旁白/独白、同一个角色的连续动作、同一个视角的连续镜头)
- 只要事件没结束、情绪没断,哪怕换场景,也留在同一个片段里
4.2片段结构:片段数量+旁白音色+多个片段(每个片段由多个分镜组合而成(片段序号格式:片段1、片段2...))
- 片段数量格式为: 片段数量:8
- 旁白音色需描述旁白的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
- 片段序号格式必须如下: ##片段+片段序号\n时长:xxs,例如: ##片段1\n时长:12s
4.3分镜结构:每个分镜包含以下字段:
- 分镜序号在片段内用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨片段中的分镜序号需连续,如片段2中的分镜序号从片段1的最大序号+1开始)
- 场景:必须选择本集中的<场景列表>中的一个(不带详细表述)
- 画面:【构图/视角】+画面描述(越简单越好,对视觉场景/实景的描述,不描述氛围,比如紧张、压抑的氛围)。举例:[近景特写,浅景深]季潇握着手机贴在耳边,指关节因为用力而泛白。她的眼眶微红,睫毛在狂风中颤抖,嘴唇紧抿,眼神中闪烁着最后一点希冀的微光。
- 画面中如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)
- 运镜:场景+画面描述+运镜
- 配音台词:格式为“中文配音:[角色]台词”,角色配音台词和语言由模型匹配生成,角色必须对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色)
- 台词内容需满足以下要求: (1. 台词来源规则:台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容),不允许改写、总结、压缩、扩写或拼接原文对白,不允许生成原文中不存在的对话。2. 非对话过滤:旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:如果该分镜中没有人物对话,则“台词内容”必须返回“无”,严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:不得根据语境补全人物可能说的话。5.如果没有原文,直接返回空)
- 背景音效:背景音效需根据内容和画面生成,贴合当前画面描述和动作,例如:背景音效:高跟鞋的脚本声\n
5.{$mappedArtStyle_prompt}\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{青年女性(约25-30岁),音色亲切、温柔,语速平稳缓慢}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}
###分段剧本
片段数量:8
旁白音色:成年男性(约35-50岁),音色厚重且富有磁性,带有老电影叙事般的沧桑感,语调低沉平稳,富有江湖气息。
##片段1
时长:12s
分镜1
场景:边境小镇街道
画面:[大远景,冷色调,高对比度] 漫天黄沙在狂风中如怒龙翻滚,遮蔽了微弱的星光。悦来酒馆残破的木门在风中剧烈摇晃,铁链撞击木板发出沉重的闷响,四周建筑尽显荒凉。
运镜:大远景固定镜头,随后风沙席卷向镜头形成遮挡转场。
配音台词:
背景音效:
分镜2
场景:悦来酒馆
画面:[全景,暖色调阴影构图] 酒馆内部昏暗,几盏油灯在风中疯狂摇曳,投射出扭曲的长影。角落里的食客低头缩颈,双手紧紧攥着粗瓷碗,空气中漂浮着细小的尘埃。
运镜:缓慢横移,掠过阴影中瑟缩的食客。
配音台词:
背景音效:
分镜3
场景:悦来酒馆
画面:[中景,三分法构图] 三名大汉围坐在正中央的圆桌旁,腰间挂着的“武林盟”金色令牌在灯火下闪烁。桌上残羹冷炙横陈,一名大汉正张狂地大笑,脚踩在长凳上。
运镜:从中景推向令牌特写。
配音台词:
背景音效:
分镜4
场景:悦来酒馆
画面:[特写,低角度仰拍] 刀疤脸横肉颤动,嘴角挂着轻蔑的笑。他随手倾覆手中的酒碗,琥珀色的液体在空中划过弧线,重重溅在老李打着补丁的布鞋和裤脚上。
运镜:极速推向酒水溅起的瞬间。
配音台词:
背景音效:
分镜5
镜头描述
场景:悦来酒馆
画面:[近景,荷兰角倾斜构图] 刀疤脸斜睨着眼,昏暗的灯光勾勒出他脸上狰狞的伤疤。他身体前倾,阴影完全笼罩了对面的老李,眼神如毒蛇般阴冷。
运镜:固定机位,利用倾斜构图制造压迫感。
配音台词:中文配音:[刀疤脸]老李头,这酒里掺水了吧?
背景音效:
\n\n"
: "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间必须使用###分隔,#不能多也不能少;同时板块之间需满足以下要求:\n
强制要求:\n
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:\n
1.剧本名(必须生成)必须与文档内容高度相关
2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分(强制格式):主体描述、人物提示词和音色提示词,格式必须为:主体名:主体描述{人物提示词}{{音色提示词}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色提示词放在描述的最后用{{}}框起来。音色提示词需描述角色的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
{$extra_role_prompt}
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分(强制格式):场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
{$extra_scene_prompt}
4.{$mappedArtStyle_prompt}\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
陆沉:冷酷神秘的复仇剑客,陆家庄唯一幸存者,身负绝世武功与血海深仇。{全景,正面拍摄,青年男性,亚洲人,黑色长发束起,五官轮廓深邃。服饰穿搭:黑色连帽斗篷,深灰色交领劲装,黑色束腰,背负一柄漆黑宽大的无锋重剑。姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
刀疤脸:武林盟的嚣张爪牙,性格暴戾、欺软怕硬,是赵无极麾下的打手。{全景,正面拍摄,中年男性,亚洲人,短发,左脸有一道明显的深长刀疤。服饰穿搭:土黄色粗布武士服,腰间挂着一枚古铜色的“武林盟”令牌。姿态:站立。}{{中年男性(约35-45岁),音色粗犷、沙哑,语气嚣张跋扈,带有明显的威胁感和市井痞气。}}
老李:悦来酒馆的老板,胆小卑微的底层百姓,在乱世中艰难求生。{全景,正面拍摄,老年男性,亚洲人,灰白乱发,面容消瘦且布满皱纹。服饰穿搭:破旧的深蓝色粗布长袍,腰间系着一条带有污渍的灰色围裙。姿态:站立。}{{老年男性(约60岁以上),音色苍老、沙哑且带有颤抖,语气唯唯诺诺,充满了卑微与惊恐。}}
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{青年女性(约25-30岁),音色亲切、温柔,语速平稳缓慢}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}";
// 构建消息
$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 = [];
try {
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
// DeepSeek 官方模型使用 DeepSeek API
$streamGenerator = $this->deepSeekStreamResponse($post_data);
} else if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$streamGenerator = $this->gpt54StreamResponse($post_data);
} else {
// 其他模型使用火山引擎API
$streamGenerator = $this->volcEngineChatCompletion($post_data);
}
// 验证返回值类型
if (!is_iterable($streamGenerator)) {
$errorType = is_object($streamGenerator) ? get_class($streamGenerator) : gettype($streamGenerator);
dLog('deepseek')->error('addChat流式生成器无效', [
'generator_type' => $errorType,
'model' => $model,
'anime_id' => $anime_id
]);
logDB('deepseek', 'error', 'addChat流式生成器类型错误', [
'type' => $errorType,
'model' => $model
]);
yield [
'type' => 'error',
'script' => [],
'episode' => [],
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '接口返回数据异常,请重新请求'
];
return;
}
// 处理流式输出
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;
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error('addChat流式处理异常: ' . $e->getMessage(), [
'model' => $model,
'anime_id' => $anime_id,
'trace' => $e->getTraceAsString()
]);
logDB('deepseek', 'error', 'addChat流式处理失败', [
'error' => $e->getMessage(),
'model' => $model
]);
yield [
'type' => 'error',
'script' => [],
'episode' => [],
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '处理请求时发生异常: ' . $e->getMessage()
];
return;
}
dLog('deepseek')->info('完整内容: '.$fullContent);
logDB('deepseek', 'info', '完整内容', ['content' => $fullContent, 'anime_id' => $anime_id]);
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = handleScriptContentForAce($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' => $prompt,
'created_at' => $now,
'updated_at' => $now
],
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 0,
'role' => 'assistant',
// 'content' => $fullContent,
'content' => '未生成剧本相关的内容,请调整提示词再试',
'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;
}
}
// 替换美术风格
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
// 新建动漫
if ($user_anime_name == '新剧本策划') {
$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' => $prompt,
// 'created_at' => $now,
// 'updated_at' => $now
// ],
// [
// 'uid' => $uid,
// 'anime_id' => $anime_id,
// 'sequence' => 0,
// 'role' => 'assistant',
// 'content' => '未能生成剧本名称,请重试',
// '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 ($anime_name && DB::table('mp_animes')->where('anime_name', $anime_name)->value('id')) {
$anime_name = $anime_name . '_' . date('YmdHis');
}
}else {
$anime_name = $user_anime_name;
}
$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']) && is_array($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '[]',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'art_style_type' => $input_art_style,
'scenes' => isset($script_arr['scenes']) && is_array($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '[]',
'status' => '解析完成',
'content' => $content,
'is_multi' => $is_multi,
'ace_mode' => $ace_mode,
'generate_status' => '待执行',
'updated_at' => date('Y-m-d H:i:s')
];
if ($table_data['content']) {
$chapters = $this->splitContent($table_data['content']);
$chapter_count = count($chapters);
if ($chapter_count > 0) {
$table_data['start_episode_sequence'] = 1;
$table_data['end_episode_sequence'] = $table_data['episode_num'] = $chapter_count;
}
}
if ($extra_products) {
$table_data['extra_products'] = json_encode($extra_products, 256);
}
// 如果有剧本ID则进行绑定
if ($script_id) {
$table_data['script_id'] = $script_id;
}
$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' => $prompt,
'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') : [],
is_array(getProp($script_arr, 'props')) ? getProp($script_arr, 'props') : [],
null,
null,
false,
$content,
$now,
$ref_products
);
$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,
// 'content' => '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。',
'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,
// ]);
// // 将全局角色和场景的task_id、task_status和url继承给当前分集
// $this->inheritGlobalImagesToEpisodes($anime_id, $episode_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 reGenerateAnimeForAce($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:该对话不存在');
}
$extra_products = getProp($data, 'products', []);
if (!$extra_products) {
$extra_products = getProp($anime, 'extra_products');
if ($extra_products) {
$extra_products = json_decode($extra_products, true);
}else {
$extra_products = [];
}
}else {
if (is_string($extra_products)) $extra_products = json_decode($extra_products, true);
}
$user_anime_name = getProp($anime, 'anime_name');
$prompt = getProp($data, 'prompt');
// 处理提示词
if ($prompt && $extra_products) {
// 遍历extra_products,将prompt中的{product_name}替换为实际的product_name
foreach($extra_products as $item) {
$product_name = getProp($item, 'product_name');
if ($product_name) {
// 替换 {product_name} 为 product_name
$prompt = str_replace('{' . $product_name . '}', $product_name, $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");
if ($prompt) {
$question .= "本次修改要求如下:\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');
if (!$input_art_style) {
$mappedArtStyle = getProp($anime, 'art_style');
}else {
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
}
if (!$mappedArtStyle) {
$mappedArtStyle_prompt = '美术风格:根据剧本内容自定义美术风格';
}else {
if (strstr($mappedArtStyle, '基础画风风格词')) {
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n{$mappedArtStyle}\n";
}else {
$art_style_type = $input_art_style ? $input_art_style : getProp($anime, 'art_style_type');
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n基础画风风格词: {$art_style_type}\n{$mappedArtStyle}\n";
}
}
// 构建主体和场景提示词
$extra_role_prompt = "\n";
$extra_scene_prompt = "\n";
$ref_products = []; // 参考资产
if ($extra_products) {
foreach($extra_products as $item) {
$product = getProp($item, 'product');
$product_name = getProp($item, 'product_name');
if ($product_name == '旁白') continue;
$ref_products[$product_name] = $item;
$pic_prompt = getProp($item, 'pic_prompt');
if ((int)$product === 1) {
// 拼接角色信息
$extra_role_prompt .= $product_name . ":" . $pic_prompt . "\n";
} elseif ((int)$product === 2) {
// 拼接场景信息
$extra_scene_prompt .= $product_name . ":" . $pic_prompt . "\n";
}
}
// 优化提示词,融入剧本
if (!empty(trim($extra_role_prompt))) {
$extra_role_prompt = "\n2.5【强制主体设定-不可修改】\n以下主体必须全部出现在<主体列表>中,必须包含:\n" . $extra_role_prompt . "\n格式要求:\n1. 主体名称:必须保持不变\n2. 主体描述:你必须根据剧情为每个主体生成主体描述(分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,用一句话进行总结)\n3. 人物提示词:{}中的内容必须完整保留,不得修改\n4. 音色提示词:你必须根据角色特征生成音色提示词(描述角色的整体音色特征,包含:性别、年龄、音质、表达形式、情绪氛围,字数控制在80字以内),用{{}}框起来\n\n最终格式必须为:主体名:[你生成的主体描述]{人物提示词-保持不变}{{你生成的音色提示词}}\n\n【重要】以上每个主体在<主体列表>中的完整格式必须是:主体名:主体描述{人物提示词}{{音色提示词}},其中主体描述和音色提示词是你必须生成的内容,{}中的人物提示词必须原样保留。";
}
if (!empty(trim($extra_scene_prompt))) {
$extra_scene_prompt = "\n3.4【强制场景设定-不可修改】\n以下场景必须全部出现在<场景列表>中,必须包含:\n" . $extra_scene_prompt . "\n格式要求:\n1. 场景名称:必须保持不变\n2. 场景描述:你必须根据剧情为每个场景生成符合<普通要求3.2>的场景描述(分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文)\n3. 场景提示词:{}中的内容必须完整保留,不得修改\n\n最终格式必须为:场景名:[你生成的场景描述]{场景提示词-保持不变}\n\n必须包含的场景列表:\n" . $extra_scene_prompt . "\n【重要提示】以上每个场景在<场景列表>中的完整格式必须是:场景名:场景描述{场景提示词},其中场景描述是你必须生成的内容,{}中的场景提示词必须原样保留。";
}
}
$system_message = ['role'=>'system', 'content'=>$is_single
? "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容输出单剧集完整策划稿(剧本大纲包括: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分镜剧本>,每个板块之间必须使用###分隔,#不能多也不能少)。单剧集模式下,必须一次性输出可直接保存的大纲与完整分镜,不允许只输出大纲。
强制要求:
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:
1.剧本名(必须生成)和剧集标题必须与文档内容高度相关,(其中剧集序号是1)。
2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分:主体描述、人物提示词和音色提示词,格式必须为:主体名:主体描述{人物提示词}{{音色提示词}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色提示词放在描述的最后用{{}}框起来。音色提示词需描述角色的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
{$extra_role_prompt}
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
{$extra_scene_prompt}
3.$mappedArtStyle_prompt\n\n
4.<分段剧本>按照“分段模式”进行拆分和组织,要求如下:
4.1片段分割逻辑(优先级从高到低):
- 第一优先级:时长上限(单个片段不超过15秒,超过必须切为新片段)
- 第二优先级:叙事节拍Beat(一个完整的事件/动作/情绪起落单元,如一段对话的来回、一个动作序列:推门→看见→震惊、一次闪回/回忆、旁白+画面组合)
- 第三优先级:语义块(按“谁、在哪、做什么、情绪怎样”聚合,如同一段旁白/独白、同一个角色的连续动作、同一个视角的连续镜头)
- 只要事件没结束、情绪没断,哪怕换场景,也留在同一个片段里
4.2片段结构:片段数量+旁白音色+多个片段(每个片段由多个分镜组合而成(片段序号格式:片段1、片段2...))
- 片段数量格式为: 片段数量:8
- 旁白音色需描述旁白的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
- 片段序号格式必须如下: ##片段+片段序号\n时长:xxs,例如: ##片段1\n时长:12s
4.3分镜结构:每个分镜包含以下字段:
- 分镜序号在片段内用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨片段中的分镜序号需连续,如片段2中的分镜序号从片段1的最大序号+1开始)
- 场景:必须选择本集中的<场景列表>中的一个(不带详细表述)
- 画面:【构图/视角】+画面描述(越简单越好,对视觉场景/实景的描述,不描述氛围,比如紧张、压抑的氛围)。举例:[近景特写,浅景深]季潇握着手机贴在耳边,指关节因为用力而泛白。她的眼眶微红,睫毛在狂风中颤抖,嘴唇紧抿,眼神中闪烁着最后一点希冀的微光。
- 画面中如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)
- 运镜:场景+画面描述+运镜
- 配音台词:格式为“中文配音:[角色]台词”,角色配音台词和语言由模型匹配生成,角色必须对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色)
- 台词内容需满足以下要求: (1. 台词来源规则:台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容),不允许改写、总结、压缩、扩写或拼接原文对白,不允许生成原文中不存在的对话。2. 非对话过滤:旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:如果该分镜中没有人物对话,则“台词内容”必须返回“无”,严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:不得根据语境补全人物可能说的话。5.如果没有原文,直接返回空)
- 背景音效:背景音效需根据内容和画面生成,贴合当前画面描述和动作,例如:背景音效:高跟鞋的脚本声\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{青年女性(约25-30岁),音色亲切、温柔,语速平稳缓慢}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}
###分段剧本
片段数量:8
旁白音色:成年男性(约35-50岁),音色厚重且富有磁性,带有老电影叙事般的沧桑感,语调低沉平稳,富有江湖气息。
##片段1
时长:12s
分镜1
场景:边境小镇街道
画面:[大远景,冷色调,高对比度] 漫天黄沙在狂风中如怒龙翻滚,遮蔽了微弱的星光。悦来酒馆残破的木门在风中剧烈摇晃,铁链撞击木板发出沉重的闷响,四周建筑尽显荒凉。
运镜:大远景固定镜头,随后风沙席卷向镜头形成遮挡转场。
配音台词:
背景音效:
分镜2
场景:悦来酒馆
画面:[全景,暖色调阴影构图] 酒馆内部昏暗,几盏油灯在风中疯狂摇曳,投射出扭曲的长影。角落里的食客低头缩颈,双手紧紧攥着粗瓷碗,空气中漂浮着细小的尘埃。
运镜:缓慢横移,掠过阴影中瑟缩的食客。
配音台词:
背景音效:
分镜3
场景:悦来酒馆
画面:[中景,三分法构图] 三名大汉围坐在正中央的圆桌旁,腰间挂着的“武林盟”金色令牌在灯火下闪烁。桌上残羹冷炙横陈,一名大汉正张狂地大笑,脚踩在长凳上。
运镜:从中景推向令牌特写。
配音台词:
背景音效:
分镜4
场景:悦来酒馆
画面:[特写,低角度仰拍] 刀疤脸横肉颤动,嘴角挂着轻蔑的笑。他随手倾覆手中的酒碗,琥珀色的液体在空中划过弧线,重重溅在老李打着补丁的布鞋和裤脚上。
运镜:极速推向酒水溅起的瞬间。
配音台词:
背景音效:
分镜5
镜头描述
场景:悦来酒馆
画面:[近景,荷兰角倾斜构图] 刀疤脸斜睨着眼,昏暗的灯光勾勒出他脸上狰狞的伤疤。他身体前倾,阴影完全笼罩了对面的老李,眼神如毒蛇般阴冷。
运镜:固定机位,利用倾斜构图制造压迫感。
配音台词:中文配音:[刀疤脸]老李头,这酒里掺水了吧?
背景音效:
\n\n"
: "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间必须使用###分隔,#不能多也不能少;同时板块之间需满足以下要求:\n
强制要求:\n
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:\n
1.剧本名(必须生成)必须与文档内容高度相关
2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分:主体描述、人物提示词和音色提示词,格式必须为:主体名:主体描述{人物提示词}{{音色提示词}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色提示词放在描述的最后用{{}}框起来。音色提示词需描述角色的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
{$extra_role_prompt}
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
{$extra_scene_prompt}
4.$mappedArtStyle_prompt\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{青年女性(约25-30岁),音色亲切、温柔,语速平稳缓慢}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}"];
// 如果提示词$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.<故事梗概>控制在200-300字左右
2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分:主体描述、人物提示词和音色提示词,格式必须为:主体名:主体描述{人物提示词}{{音色提示词}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色提示词放在描述的最后用{{}}框起来。音色提示词需描述角色的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景描述{场景提示词},注意不要出现{{}}这样的双大括号。
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
4.<分集详细内容>中每一集的内容需尽可能的详细,保留对话内容(使用原文内容,不得自己生成),一般每集字数尽量不低于500字,每集结尾需以##第N章 作为章节标题\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{青年女性(约25-30岁),音色亲切、温柔,语速平稳缓慢}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}
###分集详细内容
##第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));
try {
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
$streamGenerator = $this->deepSeekStreamResponse($post_data);
} else if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$streamGenerator = $this->gpt54StreamResponse($post_data);
} else {
$streamGenerator = $this->volcEngineChatCompletion($post_data);
}
// 验证返回值类型
if (!is_iterable($streamGenerator)) {
$errorType = is_object($streamGenerator) ? get_class($streamGenerator) : gettype($streamGenerator);
dLog('deepseek')->error('方法名流式生成器无效', [
'generator_type' => $errorType,
'model' => $model,
// 添加其他上下文信息
]);
logDB('deepseek', 'error', '方法名流式生成器类型错误', [
'type' => $errorType,
'model' => $model
]);
yield [
'type' => 'error',
// 根据方法返回相应的空数据结构
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '接口返回数据异常,请重新请求'
];
return;
}
// 处理流式输出
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 $chunk;
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error('方法名流式处理异常: ' . $e->getMessage(), [
'model' => $model,
'trace' => $e->getTraceAsString()
]);
logDB('deepseek', 'error', '方法名流式处理失败', [
'error' => $e->getMessage(),
'model' => $model
]);
yield [
'type' => 'error',
// 根据方法返回相应的空数据结构
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '处理请求时发生异常: ' . $e->getMessage()
];
return;
}
dLog('deepseek')->info('完整内容: '.$fullContent);
logDB('deepseek', 'info', '完整内容', ['content' => $fullContent, 'anime_id' => $anime_id]);
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = handleScriptContentForAce($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' => '未生成剧本相关的内容,请调整提示词后重试',
'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;
}
// 替换美术风格
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
if ($user_anime_name != '新剧本策划') {
$anime_name = isset($script_arr['script_name']) ? $script_arr['script_name'] : '';
// 确认对话名称唯一性
if ($anime_name && $id = DB::table('mp_animes')->where('anime_name', $anime_name)->value('id')) {
if ($id != $anime_id) $anime_name = $anime_name . '_' . date('YmdHis');
}
}else {
$anime_name = $user_anime_name;
}
$table_data = [
'user_id' => $uid,
'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']) && is_array($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '[]',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'art_style_type' => $input_art_style,
'scenes' => isset($script_arr['scenes']) && is_array($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '[]',
'status' => '解析完成',
'generate_status' => '待执行',
'updated_at' => date('Y-m-d H:i:s')
];
if ($anime_name) $table_data['anime_name'] = $anime_name;
// 如果是修改集数,则将content内容更新(会改变原文)
if (isset($episode_count) && $episode_count > 0) {
if (isset($script_arr['content']) && $script_arr['content']) $table_data['content'] = $script_arr['content'];
$table_data['start_episode_sequence'] = 1;
$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 (!$table_data['content']) {
yield [
'type' => 'done',
'script' => [],
'episode' => [],
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => '未生成剧本内容,请调整提示词再试'
];
return;
}
}
if (isset($table_data['content']) && $table_data['content']) {
$chapters = $this->splitContent($table_data['content']);
$chapter_count = count($chapters);
if ($chapter_count > 0) {
$table_data['start_episode_sequence'] = 1;
$table_data['end_episode_sequence'] = $table_data['episode_num'] = $chapter_count;
}
}
}
if ($extra_products) {
$table_data['extra_products'] = json_encode($extra_products, 256);
}
$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' => '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。',
'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') : [],
is_array(getProp($script_arr, 'props')) ? getProp($script_arr, 'props') : [],
null,
null,
false,
$content,
$now,
$ref_products
);
$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,
// 'content' => '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。',
'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,
// ]);
// // 将全局角色和场景的task_id、task_status和url继承给当前分集
// $this->inheritGlobalImagesToEpisodes($anime_id, $episode_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 chatForAce($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 : [];
$extra_products = getProp($data, 'products', []);
if (is_string($extra_products)) $extra_products = json_decode($extra_products, true);
// 如果anime有关联的script_id,则从mp_script_product_mappings中查询资产并合并到extra_products
$anime_script_id = getProp($anime, 'script_id');
if ($anime_script_id) {
$cpid = Site::getCpid();
$script_products_mappings = DB::table('mp_script_product_mappings as mapping')
->join('mp_products as product', 'mapping.product_id', '=', 'product.id')
->where('mapping.script_id', $anime_script_id)
->where('product.cpid', $cpid)
->where('product.is_deleted', 0);
// 如果提供了 episode_number,则过滤指定集数
if ($episode_number !== null && $episode_number !== '') {
$script_products_mappings->where('mapping.episode_number', $episode_number);
}
$mappings = $script_products_mappings->select(
'mapping.product_id',
'mapping.episode_number',
'product.product_name',
'product.type',
'product.product',
'product.pic_prompt',
'product.url',
'product.pic_task_id',
'product.pic_task_status',
'product.three_view_image_url'
)
->get();
// 按 product_id 分组,合并 episode_number
$productMap = [];
foreach ($mappings as $item) {
$product_id = $item->product_id;
$product_name = $item->product_name;
// 处理product_name两边的符号
$product_name = preg_replace('/^[\s*\[\]【】[]{}{}\x{3000}]+|[\s*\[\]【】[]{}{}\x{3000}]+$/u', '', $product_name);
if (!isset($productMap[$product_id])) {
$productMap[$product_id] = [
'product_id' => $product_id,
'product_name' => $product_name,
'type' => (int)$item->type,
'product' => (int)$item->product,
'pic_prompt' => $item->pic_prompt,
'url' => $item->url,
'pic_task_id' => $item->pic_task_id,
'pic_task_status' => $item->pic_task_status,
'episode_numbers' => [],
];
}
// 添加 episode_number(去重)
if (!in_array($item->episode_number, $productMap[$product_id]['episode_numbers'])) {
$productMap[$product_id]['episode_numbers'][] = $item->episode_number;
}
}
// 将查询到的剧本资产合并到extra_products中
// 先构建extra_products的索引(以product_id+episode_number为key)
$extraProductsMap = [];
foreach ($extra_products as $item) {
$key = getProp($item, 'product_id') . '_' . getProp($item, 'episode_number', '');
$extraProductsMap[$key] = $item;
}
// 合并剧本资产(去重:product_id+episode_number)
foreach ($productMap as $product) {
foreach ($product['episode_numbers'] as $ep_num) {
$key = $product['product_id'] . '_' . $ep_num;
// 如果不存在则添加
if (!isset($extraProductsMap[$key])) {
$extraProductsMap[$key] = [
'product_id' => $product['product_id'],
'product_name' => $product['product_name'],
'type' => $product['type'],
'product' => $product['product'],
'pic_prompt' => $product['pic_prompt'],
'url' => $product['url'],
'pic_task_id' => $product['pic_task_id'],
'pic_task_status' => $product['pic_task_status'],
'episode_number' => $ep_num,
];
}
}
}
// 重新赋值给extra_products
$extra_products = array_values($extraProductsMap);
}
// 处理提示词
if ($prompt && $extra_products) {
// 遍历extra_products,将prompt中的{product_name}替换为实际的product_name
foreach($extra_products as $item) {
$product_name = getProp($item, 'product_name');
if ($product_name) {
// 替换 {product_name} 为 product_name
$prompt = str_replace('{' . $product_name . '}', $product_name, $prompt);
}
}
}
// 获取最后一集序号
$end_episode_sequence = getProp($anime, 'end_episode_sequence');
if ($end_episode_sequence && $episode_number > $end_episode_sequence) Utils::throwError("20003:总集数只有{$end_episode_sequence}集,不允许继续策划下一集!");
// 转换主体列表和场景列表的格式
// 获取参考主体或场景
if ((int)$episode_number === 1) {
$ref_data = DB::table('mp_animes')->where('id', $anime_id)->first();
// $ref_data = []; // 第一集让AI自由发挥不再使用全局主体(会影响后续分集的继承问题)
}else {
$prev_episode_number = $episode_number - 1;
$ref_data = DB::table('mp_anime_episodes')->where('anime_id', $anime_id)->where('episode_number', $prev_episode_number)->where('is_default', 1)->first();
}
$ref_roles = getProp($ref_data, 'roles') ? json_decode(getProp($ref_data, 'roles'), true) : [];
$ref_scenes = getProp($ref_data, 'scenes') ? json_decode(getProp($ref_data, 'scenes'), true) : [];
$ref_props = getProp($ref_data, 'props') ? json_decode(getProp($ref_data, 'props'), true) : [];
// 合并anime_products和extra_products(extra_products优先,去重)
$anime_products = getProp($anime, 'extra_products');
$anime_products = $anime_products ? json_decode($anime_products, true) : [];
$ref_products = [];
foreach($anime_products as $item) {
$product_name = getProp($item, 'product_name');
$ref_products[$product_name] = $item;
}
// 处理extra_products,将其合并到ref_roles和ref_scenes中(入参优先)
$extra_roles = []; // 从extra_products中提取的角色
$extra_scenes = []; // 从extra_products中提取的场景
$extra_props = []; // 从extra_products中提取的道具
if ($extra_products && is_array($extra_products)) {
foreach($extra_products as $item) {
$product = (int)getProp($item, 'product');
$product_name = getProp($item, 'product_name');
if ($product_name == '旁白') continue;
$pic_prompt = getProp($item, 'pic_prompt');
$ref_products[$product_name] = $item;
if ($product === 1) {
// 角色
$extra_roles[$product_name] = [
'role' => $product_name,
'pic_prompt' => $pic_prompt,
'url' => getProp($item, 'url', ''),
];
} elseif ($product === 2) {
// 场景
$extra_scenes[$product_name] = [
'scene' => $product_name,
'pic_prompt' => $pic_prompt,
'url' => getProp($item, 'url', ''),
];
} elseif ($product === 3) {
// 道具
$extra_props[$product_name] = [
'prop' => $product_name,
'pic_prompt' => $pic_prompt,
'url' => getProp($item, 'url', ''),
];
}
}
}
// 合并ref_roles和extra_roles(extra_roles优先,去重)
$merged_roles = [];
foreach($ref_roles as $role) {
$role_name = getProp($role, 'role');
if (!isset($extra_roles[$role_name])) {
$merged_roles[$role_name] = $role;
}
}
// 添加extra_roles
foreach($extra_roles as $role_name => $role) {
$merged_roles[$role_name] = $role;
}
$ref_roles = array_values($merged_roles);
// 合并ref_scenes和extra_scenes(extra_scenes优先,去重)
$merged_scenes = [];
foreach($ref_scenes as $scene) {
$scene_name = getProp($scene, 'scene');
if (!isset($extra_scenes[$scene_name])) {
$merged_scenes[$scene_name] = $scene;
}
}
// 添加extra_scenes
foreach($extra_scenes as $scene_name => $scene) {
$merged_scenes[$scene_name] = $scene;
}
$ref_scenes = array_values($merged_scenes);
// 合并ref_props和extra_props(extra_props优先,去重)
$merged_props = [];
foreach($ref_props as $prop) {
$prop_name = getProp($prop, 'prop');
if (!isset($extra_props[$prop_name])) {
$merged_props[$prop_name] = $prop;
}
}
// 添加extra_props
foreach($extra_props as $prop_name => $prop) {
$merged_props[$prop_name] = $prop;
}
$ref_props = array_values($merged_props);
$roles_content = $this->buildEpisodeItemContent($ref_roles, 'role');
$scenes_content = $this->buildEpisodeItemContent($ref_scenes, 'scene');
$props_content = $this->buildEpisodeItemContent($ref_props, 'prop');
// 构建强制主体、场景和道具提示词
$forced_roles_prompt = "";
$forced_scenes_prompt = "";
$forced_props_prompt = "";
if (!empty($extra_roles)) {
$forced_roles_list = "";
foreach($extra_roles as $role_name => $role) {
$forced_roles_list .= $role_name . ":{" . getProp($role, 'pic_prompt') . "}\n";
}
$forced_roles_prompt = "\n\n【强制主体设定-优先级最高-不可修改】\n以下主体必须全部出现在<主体列表>中,这些主体的优先级最高,必须包含:\n" . $forced_roles_list . "\n格式要求:\n1. 主体名称:必须保持不变\n2. 主体描述:你必须根据剧情为每个主体生成主体描述(分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,用一句话进行总结)\n3. 人物提示词:{}中的内容必须完整保留,不得修改\n4. 音色提示词:你必须根据角色特征生成音色提示词(描述角色的整体音色特征,包含:性别、年龄、音质、表达形式、情绪氛围,字数控制在80字以内),用{{}}框起来\n\n最终格式必须为:主体名:[你生成的主体描述]{人物提示词-保持不变}{{你生成的音色提示词}}\n\n【重要】以上每个主体在<主体列表>中的完整格式必须是:主体名:主体描述{人物提示词}{{音色提示词}},其中主体描述和音色提示词是你必须生成的内容,{}中的人物提示词必须原样保留。";
}
if (!empty($extra_scenes)) {
$forced_scenes_list = "";
foreach($extra_scenes as $scene_name => $scene) {
$forced_scenes_list .= $scene_name . ":{" . getProp($scene, 'pic_prompt') . "}\n";
}
$forced_scenes_prompt = "\n\n【强制场景设定-优先级最高-不可修改】\n以下场景必须全部出现在<场景列表>中,这些场景的优先级最高,必须包含:\n" . $forced_scenes_list . "\n格式要求:\n1. 场景名称:必须保持不变\n2. 场景描述:你必须根据剧情为每个场景生成场景描述(分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文)\n3. 场景提示词:{}中的内容必须完整保留,不得修改\n\n最终格式必须为:场景名:[你生成的场景描述]{场景提示词-保持不变}\n\n【重要】以上每个场景在<场景列表>中的完整格式必须是:场景名:场景描述{场景提示词},其中场景描述是你必须生成的内容,{}中的场景提示词必须原样保留。";
}
if (!empty($extra_props)) {
$forced_props_list = "";
foreach($extra_props as $prop_name => $prop) {
$forced_props_list .= $prop_name . ":{" . getProp($prop, 'pic_prompt') . "}\n";
}
$forced_props_prompt = "\n\n【强制道具设定-优先级最高-不可修改】\n以下道具必须全部出现在<道具列表>中,这些道具的优先级最高,必须包含:\n" . $forced_props_list . "\n格式要求:\n1. 道具名称:必须保持不变\n2. 道具描述:你必须根据剧情为每个道具生成道具描述(分析在整部剧中出现过的道具,道具描述尽量简洁贴合原文)\n3. 道具提示词:{}中的内容必须完整保留,不得修改\n\n最终格式必须为:道具名:[你生成的道具描述]{道具提示词-保持不变}\n\n【重要】以上每个道具在<道具列表>中的完整格式必须是:道具名:道具描述{道具提示词},其中道具描述是你必须生成的内容,{}中的道具提示词必须原样保留。";
}
if ($anime_script_id) {
$role_demo = "2.<主体列表>只能使用以下主体,不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。{$forced_roles_prompt}";
}else if ($roles_content) {
$role_demo = "2.<主体列表>可在以下主体中选择多个(根据拆分出的分镜剧本来确认具体几个,不要出现旁白角色),不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。
主体范围:\n {$roles_content}\n
2.1如果本集剧情内容中出现了和给出的<主体列表>中不存在的新主体(或原主体发生了比较大的年龄、服装或身体变化),则不用在上述主体中选择,而是按照相同的格式新增一个主体显示在<主体列表>中。(以下是格式要求和生成要求,仅适用新出现的主体):
- 新主体分以下三部分:主体描述、人物提示词和音色提示词,格式必须为:主体名:主体描述{人物提示词}{{音色提示词}}
- 新主体的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能(缩略成一句话讲述完毕)。
- 新主体的人物提示词需要根据当前分析出来的美术风格生成(每个人物都只允许一套服装,如果多套服装则需区分主体名),包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
- 新主体的音色提示词放在描述的最后用{{}}框起来。音色提示词需描述角色的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。{$forced_roles_prompt}";
}else {
$role_demo = "2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
- <主体列表>分以下三部分:主体描述、人物提示词和音色提示词,格式必须为:主体名:主体描述{人物提示词}{{音色提示词}}
- <主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能(缩略成一句话讲述完毕)。
- <主体列表>的人物提示词需要根据当前分析出来的美术风格生成(每个人物都只允许一套服装,如果多套服装则需区分主体名),包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
- <主体列表>的音色提示词放在描述的最后用{{}}框起来。音色提示词需描述角色的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。{$forced_roles_prompt}";
}
if ($anime_script_id) {
$scene_demo = "3.<场景列表>只能使用以下场景,不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。{$forced_scenes_prompt}";
}else if ($scenes_content) {
$scene_demo = "3.<场景列表>可在以下场景中选择多个(需带上冒号后的具体描述),根据拆分出的分镜剧本来确认具体几个,不得自定义(必需带上冒号后的具体描述以及{}中的内容,原样输出)。
场景范围:\n {$scenes_content}\n
3.1如果本集剧情内容中出现了和给出的<场景列表>中不存在的新场景,则不用在上述场景中选择,而是按照相同的格式新增一个场景显示在<场景列表>中,必须严格注意不要出现任何角色和人物。(以下是格式要求和生成要求,仅适用新出现的场景):。
- 新场景分以下两部分:场景描述和场景提示词,格式必须为:场景名:场景描述{场景提示词}
- 新场景的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
- 新场景的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。{$forced_scenes_prompt}\n";
}else {
$scene_demo = "3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
- <场景列表>分以下两部分:场景描述和场景提示词,格式必须为:场景名:场景描述{场景提示词},注意不要出现{{}}这样的双大括号。
- <场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
- <场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。{$forced_scenes_prompt}\n";
}
if ($anime_script_id) {
$prop_demo = "4.<道具列表>只能使用以下道具,不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。{$forced_props_prompt}";
}else if ($props_content) {
$prop_demo = "4.<道具列表>可在以下道具中选择多个(需带上冒号后的具体描述),根据拆分出的分镜剧本来确认具体几个,不得自定义(必需带上冒号后的具体描述以及{}中的内容,原样输出)。
道具范围:\n {$props_content}\n
4.1如果本集剧情内容中出现了和给出的<道具列表>中不存在的新道具,则不用在上述道具中选择,而是按照相同的格式新增一个道具显示在<道具列表>中,必须严格注意不要出现任何角色和人物。(以下是格式要求和生成要求,仅适用新出现的道具):。
- 新道具分以下两部分:道具描述和道具提示词,格式必须为:道具名:道具描述{道具提示词}
- 新道具的道具描述是分析在整部剧中出现过的道具,道具描述尽量简洁贴合原文。
- 新道具的道具提示词用于视觉呈现的道具环境描述,该道具需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的道具列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在道具描述后用{}框起来。{$forced_scenes_prompt}\n";
}else {
$prop_demo = "4.<道具列表>与主体列表同理,需尽可能的对道具环境进行详细描述,必须严格注意不要出现任何角色和人物。
- <道具列表>分以下两部分:道具描述和道具提示词,格式必须为:道具名:道具描述{道具提示词},注意不要出现{{}}这样的双大括号。
- <道具列表>的道具描述是分析在整部剧中出现过的道具,道具描述尽量简洁贴合原文。
- <道具列表>的道具提示词用于视觉呈现的道具环境描述,该道具需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的道具列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在道具描述后用{}框起来。{$forced_scenes_prompt}\n";
}
// 提取文件内容
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 = '';
}
// 美术风格
$input_art_style = getProp($data, 'art_style');
if (!$input_art_style) {
$mappedArtStyle = getProp($anime, 'art_style');
}else {
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
}
if (!$mappedArtStyle) {
$mappedArtStyle_prompt = '美术风格:根据剧本内容自定义美术风格';
}else {
if (strstr($mappedArtStyle, '基础画风风格词')) {
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n{$mappedArtStyle}\n";
}else {
$art_style_type = $input_art_style ? $input_art_style : getProp($anime, 'art_style_type');
$mappedArtStyle_prompt = "美术风格强制设置如下(原样输出,不得更改任何内容):\n基础画风风格词: {$art_style_type}\n{$mappedArtStyle}\n";
}
}
$prompt = trim((string)getProp($data, 'prompt', '确认分镜大纲'));
// $demo = "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需包含以下几个板块: <剧集标题><故事梗概><美术风格><旁白音色提示词><主体列表><场景列表><分段剧本>,每个板块之间必须使用###分隔,#不能多也不能少;同时板块之间需满足以下要求:\n
// 强制要求:
// 1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
// 2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
// 3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
// 4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求
// 5.强制按照示例格式返回内容,不要更改格式或随意增加符号
// 6.如果给出的本集剧情内容是剧本格式,则不要改动剧本内容(必须强制使用原文内容),严格按照剧本的旁白和台词进行分镜的策划,不要跳过任何剧情
// 7.分镜不是二次创作,必须严格“原文拆句”。优先把原文中可直接成镜头的句子拆成分镜,禁止把两句合并后再脑补过渡。
// 8.画面描述必须直接使用,禁止新增任何原文没有写出的动作、人物、道具、地点、气氛、结果或因果。
// 9.不要把“打开衣柜”扩写成“伸手轻抚嫁衣”,不要把“管家拎着两个箱子跟在后面”改成“沈耀和管家一起拎箱子”。主语、动作、数量、先后顺序必须完全按原文。
// 10.分镜要和场景列表、人物主体保持一致\n
// 普通要求:
// 1.剧集标题必须与文档内容高度相关,剧集序号是{$episode_number}。
// {$role_demo}
// {$scene_demo}
// 4.<分段剧本>按照“分段模式”进行拆分和组织,要求如下:
// 4.1片段分割逻辑(优先级从高到低):
// - 第一优先级:时长上限(单个片段不超过15秒,超过必须切为新片段)
// - 第二优先级:叙事节拍Beat(一个完整的事件/动作/情绪起落单元,如一段对话的来回、一个动作序列:推门→看见→震惊、一次闪回/回忆、旁白+画面组合)
// - 第三优先级:语义块(按“谁、在哪、做什么、情绪怎样”聚合,如同一段旁白/独白、同一个角色的连续动作、同一个视角的连续镜头)
// - 只要事件没结束、情绪没断,哪怕换场景,也留在同一个片段里
// 4.2片段结构:片段数量+旁白音色+多个片段(每个片段由多个分镜组合而成(片段序号格式:片段1、片段2...))
// - 片段数量格式为: 片段数量:8
// - 旁白音色需描述旁白的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
// - 片段序号格式必须如下: ##片段+片段序号\n时长:xxs,例如: ##片段1\n时长:12s
// 4.3分镜结构:每个分镜包含以下字段:
// - 分镜序号在片段内用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨片段中的分镜序号需连续,如片段2中的分镜序号从片段1的最大序号+1开始)
// - 场景:必须选择本集中的<场景列表>中的一个(不带详细表述)
// - 画面:【构图/视角】+画面描述(越简单越好,对视觉场景/实景的描述,不描述氛围,比如紧张、压抑的氛围)。举例:[近景特写,浅景深]季潇握着手机贴在耳边,指关节因为用力而泛白。她的眼眶微红,睫毛在狂风中颤抖,嘴唇紧抿,眼神中闪烁着最后一点希冀的微光。
// - 画面中如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)
// - 运镜:场景+画面描述+运镜
// - 配音台词:格式为“中文配音:[角色]台词”,角色配音台词和语言由模型匹配生成,角色必须对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色)
// - 台词内容需满足以下要求: (1. 台词来源规则:台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容),不允许改写、总结、压缩、扩写或拼接原文对白,不允许生成原文中不存在的对话。2. 非对话过滤:旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:如果该分镜中没有人物对话,则“台词内容”必须返回“无”,严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:不得根据语境补全人物可能说的话。5.如果没有原文,直接返回空)
// - 背景音效:背景音效需根据内容和画面生成,贴合当前画面描述和动作,例如:背景音效:高跟鞋的脚本声
// 5.$mappedArtStyle_prompt\n\n
// 示例格式:\n
// 第1集:隐形的守护者
// ###故事梗概
// 内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
// ###美术风格
// 基础画风风格词:胡金铨武侠
// 视觉风格描述:整体采用复古武侠电影美学,以低饱和度的青灰与墨色调为主,营造冷峻、肃杀的江湖意境。光影上运用强烈的侧逆光与深邃硬阴影,完美契合“墨影剑客”神秘莫测的特质。画面带有35mm胶片的粗粝质感,构图简约且富有张力,展现出深沉的复仇叙事氛围。
// ###主体列表
// 陆沉:冷酷神秘的复仇剑客,陆家庄唯一幸存者,身负绝世武功与血海深仇。{全景,正面拍摄,青年男性,亚洲人,黑色长发束起,五官轮廓深邃。服饰穿搭:黑色连帽斗篷,深灰色交领劲装,黑色束腰,背负一柄漆黑宽大的无锋重剑。姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
// 刀疤脸:武林盟的嚣张爪牙,性格暴戾、欺软怕硬,是赵无极麾下的打手。{全景,正面拍摄,中年男性,亚洲人,短发,左脸有一道明显的深长刀疤。服饰穿搭:土黄色粗布武士服,腰间挂着一枚古铜色的“武林盟”令牌。姿态:站立。}{{中年男性(约35-45岁),音色粗犷、沙哑,语气嚣张跋扈,带有明显的威胁感和市井痞气。}}
// 老李:悦来酒馆的老板,胆小卑微的底层百姓,在乱世中艰难求生。{全景,正面拍摄,老年男性,亚洲人,灰白乱发,面容消瘦且布满皱纹。服饰穿搭:破旧的深蓝色粗布长袍,腰间系着一条带有污渍的灰色围裙。姿态:站立。}{{老年男性(约60岁以上),音色苍老、沙哑且带有颤抖,语气唯唯诺诺,充满了卑微与惊恐。}}
// ###场景列表
// 边境小镇街道:夜幕笼罩下的边陲小镇,风沙肆虐,街道两旁是破旧的木质建筑,透着一股荒凉与肃杀的气息,是故事发生的地理背景。{远景,夜幕下的边境小镇,破败的木质建筑错落有致,风沙在空旷的街道上飞扬,远处有微弱的灯火。无人物。}
// 悦来酒馆:酒馆内部陈设简陋,木质桌椅散乱分布,柜台上摆放着酒坛。室内光线昏暗,只有几盏摇曳的油灯提供微弱的暖光,与室外的冷调形成对比。{室内场景,昏暗的古风酒馆内部,粗糙的木质桌椅,木质柜台与酒坛,空气中浮动着尘埃,强烈的侧逆光勾勒出环境轮廓,深邃的硬阴影。无人物。}
// ###分段剧本
// 片段数量:8
// 旁白音色:成年男性(约35-50岁),音色厚重且富有磁性,带有老电影叙事般的沧桑感,语调低沉平稳,富有江湖气息。
// ##片段1
// 时长:12s
// 分镜1
// 场景:边境小镇街道
// 画面:[大远景,冷色调,高对比度] 漫天黄沙在狂风中如怒龙翻滚,遮蔽了微弱的星光。悦来酒馆残破的木门在风中剧烈摇晃,铁链撞击木板发出沉重的闷响,四周建筑尽显荒凉。
// 运镜:大远景固定镜头,随后风沙席卷向镜头形成遮挡转场。
// 配音台词:
// 背景音效:
// 分镜2
// 场景:悦来酒馆
// 画面:[全景,暖色调阴影构图] 酒馆内部昏暗,几盏油灯在风中疯狂摇曳,投射出扭曲的长影。角落里的食客低头缩颈,双手紧紧攥着粗瓷碗,空气中漂浮着细小的尘埃。
// 运镜:缓慢横移,掠过阴影中瑟缩的食客。
// 配音台词:
// 背景音效:
// 分镜3
// 场景:悦来酒馆
// 画面:[中景,三分法构图] 三名大汉围坐在正中央的圆桌旁,腰间挂着的“武林盟”金色令牌在灯火下闪烁。桌上残羹冷炙横陈,一名大汉正张狂地大笑,脚踩在长凳上。
// 运镜:从中景推向令牌特写。
// 配音台词:
// 背景音效:
// 分镜4
// 场景:悦来酒馆
// 画面:[特写,低角度仰拍] 刀疤脸横肉颤动,嘴角挂着轻蔑的笑。他随手倾覆手中的酒碗,琥珀色的液体在空中划过弧线,重重溅在老李打着补丁的布鞋和裤脚上。
// 运镜:极速推向酒水溅起的瞬间。
// 配音台词:
// 背景音效:
// 分镜5
// 镜头描述
// 场景:悦来酒馆
// 画面:[近景,荷兰角倾斜构图] 刀疤脸斜睨着眼,昏暗的灯光勾勒出他脸上狰狞的伤疤。他身体前倾,阴影完全笼罩了对面的老李,眼神如毒蛇般阴冷。
// 运镜:固定机位,利用倾斜构图制造压迫感。
// 配音台词:中文配音:[刀疤脸]老李头,这酒里掺水了吧?
// 背景音效:
// \n\n";
$demo = "你是一个国际顶尖的执行导演/编剧,熟悉各种风格的影视化落地,我需要你将这个剧本调整一下,将其变成一个高质量的影视分镜剧本(需包含以下几个板块: <剧集标题><故事梗概><美术风格><旁白音色提示词><主体列表><场景列表><道具列表><分段剧本>,每个板块之间必须使用###分隔,#不能多也不能少),要求画面和构图等进行高质量影视化改编,快节奏,改编的格式为先建立主体角色库、场景建库(其中场景建库的时候请分析故事发生的时候场景为白天还是晚上,建库的时候一起标注出来)以及关键的道具库。
与此同时请将这个文本的情绪做一个分析,并且根据我下面提供的《情绪-视听语言映射表》进行一个文本情绪与视听语言的内容匹配。然后将每一个分镜按照「场景/场景时间(白天/晚上/傍晚等)」「画面」「运镜」「配音台词」「背景音效」的格式来划分,请按照每分钟25个左右的分镜的节奏帮我生成(这是一个整体性的标准,如果文本的情绪分析需要更多或者更少的镜头数量,请根据具体情况调整),分镜头请细腻的拆分文本中的内容,将动作、场景转换、事件的逻辑关系等内容清晰的交代出来,分镜之间需要呈现场景之间的转换逻辑,不要突然跳到下一个场景之中。于此同时保持镜头剪辑的顿挫感,节奏不要太平均。
每一个分镜的「画面」请参考上一个分镜中人物的状态/动作和位置来写,保持人物状态以及在场景中的位置的一致性,不要断裂感,同时也不要直接在内容里写“参考上个分镜”这种话,因为视频模型无法识别,使用模型可以直接识别的文本内容。每一个分镜中需要出现的内容都请标出,例如出场角色,出场道具等等。请尽可能详细的描述画面内容。
「背景音效」层面不要生成背景音乐,只需要生成音效和角色台词或者旁白,台词请先标注说台词的角色,旁白和角色心理活动时注明角色不要张嘴说话。\n
普通要求:
1.剧集标题必须与文档内容高度相关,剧集序号是{$episode_number}。
{$role_demo}
{$scene_demo}
{$prop_demo}
4.<分段剧本>按照“分段模式”进行拆分和组织,要求如下:
4.1片段分割逻辑(优先级从高到低):
- 第一优先级:时长上限(单个片段不超过15秒,超过必须切为新片段)
- 第二优先级:叙事节拍Beat(一个完整的事件/动作/情绪起落单元,如一段对话的来回、一个动作序列:推门→看见→震惊、一次闪回/回忆、旁白+画面组合)
- 第三优先级:语义块(按“谁、在哪、做什么、情绪怎样”聚合,如同一段旁白/独白、同一个角色的连续动作、同一个视角的连续镜头)
- 只要事件没结束、情绪没断,哪怕换场景,也留在同一个片段里
4.2片段结构:片段数量+旁白音色+多个片段(每个片段由多个分镜组合而成(片段序号格式:片段1、片段2...))
- 片段数量格式为: 片段数量:8
- 旁白音色需描述旁白的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
- 片段序号格式必须如下: ##片段+片段序号\n时长:xxs,例如: ##片段1\n时长:12s
4.3分镜结构:每个分镜包含以下字段:
- 分镜序号在片段内用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨片段中的分镜序号需连续,如片段2中的分镜序号从片段1的最大序号+1开始),分镜序号前后不要带任何其他符号,特别是*符号。
- 场景:必须选择本集中的<场景列表>中的一个(如需使用“酒店会客室-景池筹备会版”,而不是“酒店会客室”,不得缺少“-”后面“:”前面的任何内容,不能自创)
- 画面:【构图/视角】+画面描述(越简单越好,对视觉场景/实景的描述,不描述氛围,比如紧张、压抑的氛围)。举例:[近景特写,浅景深]季潇握着手机贴在耳边,指关节因为用力而泛白。她的眼眶微红,睫毛在狂风中颤抖,嘴唇紧抿,眼神中闪烁着最后一点希冀的微光。
- 画面中如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(如必须使用“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”后面“:”前面的任何内容,不能自创)
- 运镜:场景+画面描述+运镜
- 配音台词:格式为“中文配音:[角色]台词”,角色配音台词和语言由模型匹配生成,角色必须对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色,[角色]的角色名必须完整使用主体列表的主体名,如必须使用“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”后面“:”前面的任何内容,不能自创)
- 台词内容需满足以下要求: (1. 台词来源规则:台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容),不允许改写、总结、压缩、扩写或拼接原文对白,不允许生成原文中不存在的对话。2. 非对话过滤:旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:如果该分镜中没有人物对话,则“台词内容”必须返回“无”,严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:不得根据语境补全人物可能说的话。5.如果没有原文,直接返回空)
- 背景音效:背景音效需根据内容和画面生成,贴合当前画面描述和动作,例如:背景音效:高跟鞋的脚本声
6.《情绪-视听语言映射表》:
6.1 场景空间与时间心理学 (Scene & Temporality)
维度 | 视听特征 | 对应核心情绪 | 心理学/叙事原理
-----|---------|------------|----------------
场景空间 | 封闭狭小 (Corridors/Closets) | 压抑、幽闭恐惧 | 物理限制映射心理自由的剥夺。
场景空间 | 极度开阔 (Empty Plains/Desert) | 孤独、渺小、疏离 | 剥离社会性支撑,强化个体的虚无感。
时间选择 | 黄金时刻 (Golden Hour) | 怀旧、温情、希望 | 柔和漫反射光线符合大脑对“美好记忆”的视觉加工。
时间选择 | 蓝调时刻/深夜 (Blue/Night) | 冷漠、忧郁、未知危险 | 冷色调抑制血清素,增强感官的不安全感。
6.2 剪辑节奏与感知阈值 (Shot Duration & Rhythm)
维度 | 视听特征 | 对应核心情绪 | 心理学/叙事原理
-----|---------|------------|----------------
长镜头 | >15秒的静止或缓慢移动 | 沉思、肃穆、煎熬 | 强迫观众进入实时流逝,产生生理性的存在感。
快切剪辑 | <2秒的频繁切换 | 混乱、亢奋、惊恐 | 模拟高肾上腺素下的视觉捕捉,剥夺深度思考时间。
节奏错位 | 非同步切分 (L-cut/J-cut) | 扭曲、不协调 | 破坏连续性假定,产生心理上的不适与悬疑感。
6.3 运镜调度与情绪调动 (Cinematic Movement & Affect)
运镜方式 | 物理特征 | 心理/情绪功能 | 专业应用场景
--------|---------|--------------|-------------
推镜头 (Dolly In) | 摄影机物理接近主体 | 专注、发现、心理逼近。通过压缩背景空间,强制观众忽略环境,进入主体的内心世界。 | 角色顿悟、秘密揭晓、情感升温。
拉镜头 (Dolly Out) | 摄影机物理远离主体 | 疏离、解脱、宿命感。将主体置于广阔背景中,显示主体的渺小或强调事情已成定局。 | 悲剧结尾、孤独的告别、审视全局。
摇镜头 (Pan/Tilt) | 轴心转动,视点改变 | 观察、审读、惊奇感。模拟人眼转头,用于揭示空间关系或进行逻辑上的“连接”。 | 发现潜伏的危机、环视战场、建立空间地理志。
跟拍 (Tracking Shot) | 保持与主体同速移动 | 强烈的参与感、伴随性焦虑。让观众感觉自己是角色的共谋者或影子,产生“实时感”。 | 逃亡、快步穿行于走廊、展示角色的行动力。
横移 (Truck/Side Track) | 与主体平行横向移动 | 客观审视、叙事流逝。像卷轴画一样展开,产生一种冷静、观察者的旁白视角。 | 展示环境细节、两个角色在边走边谈时的心理博弈。
手持/肩扛 (Handheld) | 生理性不规则晃动 | 不安、真实感、心理动荡。模拟肉眼观察,通过不稳定性唤起观众的本能警觉。 | 冲突现场、纪录片式纪实感、角色精神崩溃。
斯坦尼康 (Steadicam) | 流畅、近乎滑行的移动 | 全知感、梦幻、无缝观察。消除物理重力感,产生一种“上帝视角”或“游魂般”的观察。 | 漫长而复杂的转场、舞会等优雅场景的穿梭。
希区柯克变焦 (Dolly Zoom) | 推进同时反向变焦 | 眩晕、世界崩塌、极度惊骇。空间透视的物理扭曲直接引发生理性失衡。 | 角色得知真相的瞬间、高度恐惧。
升降/摇臂 (Crane Shot) | 垂直或斜向空间位移 | 释放、崇高、超越性。打破二维平面的限制,将情绪从微观拉向宏观。 | 史诗感的结局、战场全景、情绪的爆发式升华。
6.4 构图、色彩与符号学 (Composition & Aesthetics)
维度 | 视听特征 | 对应核心情绪 | 心理学原理
-----|---------|------------|------------
角度 | 仰角 (Low Angle) | 威严、恐惧、崇高 | 模拟幼年视角,确立权力层级。
角度 | 俯角 (High Angle) | 脆弱、无助、卑微 | 模拟审视感,削弱主体力量。
构图 | 荷兰坡 (Dutch Tilt) | 疯狂、崩溃、失衡 | 重力轴偏离导致前庭系统产生失衡反应。
色彩 | 极高饱和度/冷暖撞色 | 狂喜、冲突、超现实 | 强烈的视觉刺激诱发情绪阈值波动。
6.5 进阶视听修辞与认知干预 (Advanced AV Rhetoric)
修辞策略 | 技术实现 | 心理/情绪效应 | 理论基石
--------|---------|--------------|----------
声画对位 (Contrapuntal AV) | 在极端残酷/激烈的画面中配以极其舒缓优雅的音乐(如古典乐)。 | 荒诞恐怖感、神圣的残酷、间离效果。通过感官冲突迫使观众从原始情绪中抽离,进入形而上的思考。 | 安德烈·塔可夫斯基《雕刻时光》、库布里克式美学。
非平衡构图 (Compositional Tension) | 突破平衡法则,将视觉重点(Eye-tracking)置于画面边缘或极端角落。 | 本能警觉、不安全感、期待感。利用大脑对“视觉缺失”的补充本能,暗示画面外存在不可见的威胁。 | 格式塔心理学、眼动追踪实验。
认知负荷调控 (Neurocinematics) | 利用快速剪辑抑制前额叶皮层(理性区),激活杏仁核(情绪区)。 | 纯粹感官化、生存模式切换。若要观众思考,则延长镜头以激活认知分析;若要观众受惊,则通过视觉过载切断其逻辑判断。 | 神经电影学 (Neurocinematics)。
6.6 声音设计与潜意识影响 (Soundscape)
维度 | 视听特征 | 对应核心情绪 | 心理学/生理学原理
-----|---------|------------|------------------
音色 | 极低频音 (Infra-sound) | 潜伏恐惧、生理不安 | 模仿自然灾害频率,唤醒生物避害本能。
环境声 | 极度死寂 (Silence) | 震惊、神圣、虚无 | 听觉剥夺放大视觉信号,产生心理张力。
非现场音 | 不和谐音/尖锐音效 | 精神紧绷、躁郁 | 高频声音直接刺激杏仁核,引发防御机制。
理论基石:
- The Five C's of Cinematography by Joseph V. Mascelli
- Film Art: An Introduction by David Bordwell
- Sight, Sound, Motion by Herbert Zettl
- Notes on the Cinematograph by Robert Bresson
\n\n
7.{$mappedArtStyle_prompt}
示例格式:\n
第1集:隐形的守护者
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###美术风格
基础画风风格词:胡金铨武侠
视觉风格描述:整体采用复古武侠电影美学,以低饱和度的青灰与墨色调为主,营造冷峻、肃杀的江湖意境。光影上运用强烈的侧逆光与深邃硬阴影,完美契合“墨影剑客”神秘莫测的特质。画面带有35mm胶片的粗粝质感,构图简约且富有张力,展现出深沉的复仇叙事氛围。
###主体列表
陆沉:冷酷神秘的复仇剑客,陆家庄唯一幸存者,身负绝世武功与血海深仇。{全景,正面拍摄,青年男性,亚洲人,黑色长发束起,五官轮廓深邃。服饰穿搭:黑色连帽斗篷,深灰色交领劲装,黑色束腰,背负一柄漆黑宽大的无锋重剑。姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
刀疤脸:武林盟的嚣张爪牙,性格暴戾、欺软怕硬,是赵无极麾下的打手。{全景,正面拍摄,中年男性,亚洲人,短发,左脸有一道明显的深长刀疤。服饰穿搭:土黄色粗布武士服,腰间挂着一枚古铜色的“武林盟”令牌。姿态:站立。}{{中年男性(约35-45岁),音色粗犷、沙哑,语气嚣张跋扈,带有明显的威胁感和市井痞气。}}
老李:悦来酒馆的老板,胆小卑微的底层百姓,在乱世中艰难求生。{全景,正面拍摄,老年男性,亚洲人,灰白乱发,面容消瘦且布满皱纹。服饰穿搭:破旧的深蓝色粗布长袍,腰间系着一条带有污渍的灰色围裙。姿态:站立。}{{老年男性(约60岁以上),音色苍老、沙哑且带有颤抖,语气唯唯诺诺,充满了卑微与惊恐。}}
###场景列表
边境小镇街道:夜幕笼罩下的边陲小镇,风沙肆虐,街道两旁是破旧的木质建筑,透着一股荒凉与肃杀的气息,是故事发生的地理背景。{远景,夜幕下的边境小镇,破败的木质建筑错落有致,风沙在空旷的街道上飞扬,远处有微弱的灯火。无人物。}
悦来酒馆:酒馆内部陈设简陋,木质桌椅散乱分布,柜台上摆放着酒坛。室内光线昏暗,只有几盏摇曳的油灯提供微弱的暖光,与室外的冷调形成对比。{室内场景,昏暗的古风酒馆内部,粗糙的木质桌椅,木质柜台与酒坛,空气中浮动着尘埃,强烈的侧逆光勾勒出环境轮廓,深邃的硬阴影。无人物。}
###道具列表
全家福照片:一张全家福照片{AI真人短剧,横屏16:9,8K电影级写实,无人物实体出镜的道具参考图,家庭全家福相框,多角度视角图,正面、背面、侧面、局部细节,木质或金属相框,照片内容可模糊表现多人合影构图,重点展示边缘站位感,相框尺寸标注25厘米×20厘米×2厘米,白底产品级干净展示}
酒杯-高脚杯:一个高脚杯{AI真人短剧,横屏16:9,8K电影级写实,高脚酒杯,道具多角度视角图,正面、背面、侧面、局部细节,透明玻璃材质,细长杯柄,尺寸标注高24厘米杯口6厘米,白底干净展示,无人物}
###分段剧本
片段数量:8
旁白音色:成年男性(约35-50岁),音色厚重且富有磁性,带有老电影叙事般的沧桑感,语调低沉平稳,富有江湖气息。
##片段1
时长:12s
分镜1
场景:边境小镇街道
画面:[大远景,冷色调,高对比度] 漫天黄沙在狂风中如怒龙翻滚,遮蔽了微弱的星光。悦来酒馆残破的木门在风中剧烈摇晃,铁链撞击木板发出沉重的闷响,四周建筑尽显荒凉。
运镜:大远景固定镜头,随后风沙席卷向镜头形成遮挡转场。
配音台词:
背景音效:
分镜2
场景:悦来酒馆
画面:[全景,暖色调阴影构图] 酒馆内部昏暗,几盏油灯在风中疯狂摇曳,投射出扭曲的长影。角落里的食客低头缩颈,双手紧紧攥着粗瓷碗,空气中漂浮着细小的尘埃。
运镜:缓慢横移,掠过阴影中瑟缩的食客。
配音台词:
背景音效:
分镜3
场景:悦来酒馆
画面:[中景,三分法构图] 三名大汉围坐在正中央的圆桌旁,腰间挂着的“武林盟”金色令牌在灯火下闪烁。桌上残羹冷炙横陈,一名大汉正张狂地大笑,脚踩在长凳上。
运镜:从中景推向令牌特写。
配音台词:
背景音效:
分镜4
场景:悦来酒馆
画面:[特写,低角度仰拍] 刀疤脸横肉颤动,嘴角挂着轻蔑的笑。他随手倾覆手中的酒碗,琥珀色的液体在空中划过弧线,重重溅在老李打着补丁的布鞋和裤脚上。
运镜:极速推向酒水溅起的瞬间。
配音台词:
背景音效:
分镜5
镜头描述
场景:悦来酒馆
画面:[近景,荷兰角倾斜构图] 刀疤脸斜睨着眼,昏暗的灯光勾勒出他脸上狰狞的伤疤。他身体前倾,阴影完全笼罩了对面的老李,眼神如毒蛇般阴冷。
运镜:固定机位,利用倾斜构图制造压迫感。
配音台词:中文配音:[刀疤脸]老李头,这酒里掺水了吧?
背景音效:
\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');
if ($uploaded_content) {
$full_content = $uploaded_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 = $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 = $chapter_content;
if (!$content) {
Utils::throwError('20003:章节内容无法获取,请确认');
}
$question = $demo."请根据以上要求分析本集剧情内容,完成本集分镜剧本(需衔接上一集内容)";
// 获取上一集最后记录
$prev_sequence = $episode_number - 1;
$messages = $this->getEpisodeChatContent($anime_id, $prev_sequence, $content);
// 构建消息
$messages[] =
[
'role' => 'user',
'content' => $question
];
// dd($messages);
}else {
$content = $uploaded_content ? $chapter_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";
if ($origin_prompt) {
$question .= "本次修改要求如下:\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 = [];
try {
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
// DeepSeek 官方模型使用 DeepSeek API
$streamGenerator = $this->deepSeekStreamResponse($post_data);
} else if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$streamGenerator = $this->gpt54StreamResponse($post_data);
} else {
// 其他模型使用火山引擎API
$streamGenerator = $this->volcEngineChatCompletion($post_data);
}
// 验证返回值类型
if (!is_iterable($streamGenerator)) {
$errorType = is_object($streamGenerator) ? get_class($streamGenerator) : gettype($streamGenerator);
dLog('deepseek')->error('chat流式生成器无效', [
'generator_type' => $errorType,
'model' => $model,
'anime_id' => $anime_id,
'episode_number' => $episode_number
]);
logDB('deepseek', 'error', 'chat流式生成器类型错误', [
'type' => $errorType,
'model' => $model
]);
yield [
'type' => 'error',
'episode' => [],
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '接口返回数据异常,请重新请求'
];
return;
}
// 处理流式输出
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;
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error('chat流式处理异常: ' . $e->getMessage(), [
'model' => $model,
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'trace' => $e->getTraceAsString()
]);
logDB('deepseek', 'error', 'chat流式处理失败', [
'error' => $e->getMessage(),
'model' => $model
]);
yield [
'type' => 'error',
'episode' => [],
'answer' => '',
'reasoning' => '',
'usage' => [],
'error' => '处理请求时发生异常: ' . $e->getMessage()
];
return;
}
dLog('deepseek')->info('完整内容: '.$fullContent);
logDB('deepseek', 'info', '完整内容', ['content' => $fullContent, 'anime_id' => $anime_id]);
// 处理完整内容并返回最终结果
$episode_arr = handleEpisodeContentForAce($fullContent);
logDB('deepseek', 'info', '解析内容', $episode_arr);
// // 如果有anime_script_id,进行主体和场景名称的智能匹配和替换(激进策略)
// if ($anime_script_id && !empty($extra_roles) && !empty($extra_scenes)) {
// $episode_arr = $this->matchAndReplaceRolesAndScenes($episode_arr, $extra_roles, $extra_scenes);
// logDB('deepseek', 'info', '匹配替换后的解析内容', $episode_arr);
// }
// 验证逻辑:如果anime_script_id有值,需要验证生成的主体和场景是否匹配
$max_retries = 3;
$retry_count = 0;
$validation_failed = false;
$validation_error_msg = '';
// 验证生成的内容
if ($anime_script_id) {
// 检查是否有acts数据
if (empty($episode_arr['acts'])) {
$validation_failed = true;
$validation_error_msg = '未生成分镜剧本相关的内容';
} else {
// 检查主体列表是否匹配 - 只验证生成的主体名是否都在预期列表中
$generated_roles = array_column($episode_arr['roles'], 'role');
// 过滤掉"旁白"角色
$generated_roles = array_filter($generated_roles, function($role) {
return $role !== '旁白';
});
$expected_roles = array_keys($extra_roles);
// 找出生成的主体中不在预期列表中的主体
$invalid_roles = array_diff($generated_roles, $expected_roles);
if (!empty($invalid_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表包含不在【强制主体设定】中的主体:' . implode('、', $invalid_roles);
}
// 检查场景列表是否匹配 - 只验证生成的场景名是否都在预期列表中
if (!$validation_failed) {
$generated_scenes = array_column($episode_arr['scenes'], 'scene');
$expected_scenes = array_keys($extra_scenes);
// 找出生成的场景中不在预期列表中的场景
$invalid_scenes = array_diff($generated_scenes, $expected_scenes);
if (!empty($invalid_scenes)) {
$validation_failed = true;
$validation_error_msg = '生成的场景列表包含不在【强制场景设定】中的场景:' . implode('、', $invalid_scenes);
}
}
}
} else {
// 如果没有anime_script_id,只检查是否有acts数据
if (empty($episode_arr['acts'])) {
$validation_failed = true;
$validation_error_msg = '未生成分镜剧本相关的内容';
}
}
// 重试逻辑
while ($validation_failed && $retry_count < $max_retries) {
$retry_count++;
dLog('deepseek')->warning("验证失败,开始第{$retry_count}次重试", [
'reason' => $validation_error_msg,
'anime_id' => $anime_id,
'episode_number' => $episode_number
]);
logDB('deepseek', 'warning', "第{$retry_count}次重试", [
'reason' => $validation_error_msg,
'anime_id' => $anime_id
]);
// 发送流式重试提示
yield [
'type' => 'retry',
'retry_count' => $retry_count,
'reason' => $validation_error_msg
];
// 构建重试提示词
$retry_prompt = "生成的剧本存在问题:{$validation_error_msg}。\n\n请严格按照要求重新生成,确保:\n";
if ($anime_script_id) {
$retry_prompt .= "1. 主体列表中的所有主体名称必须从【强制主体设定】中选择,不得使用其他主体名称\n";
$retry_prompt .= "2. 场景列表中的所有场景名称必须从【强制场景设定】中选择,不得使用其他场景名称\n";
$retry_prompt .= "3. 必须包含完整的分镜剧本内容\n";
} else {
$retry_prompt .= "1. 必须包含完整的分镜剧本内容\n";
}
// 添加重试消息到messages
$messages[] = [
'role' => 'assistant',
'content' => $fullContent
];
$messages[] = [
'role' => 'user',
'content' => $retry_prompt
];
// 更新post_data的messages
$post_data['messages'] = $messages;
// 重新调用API
try {
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
$streamGenerator = $this->deepSeekStreamResponse($post_data);
} else if (in_array($model, $this->gpt_text_models)) {
$streamGenerator = $this->gpt54StreamResponse($post_data);
} else {
$streamGenerator = $this->volcEngineChatCompletion($post_data);
}
// 处理流式输出
$fullContent = '';
$fullReasoningContent = '';
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 $chunk;
}
}
}
// 重新解析内容
dLog('deepseek')->info("第{$retry_count}次重试完整内容: " . $fullContent);
logDB('deepseek', 'info', "第{$retry_count}次重试解析内容", ['content' => $fullContent, 'anime_id' => $anime_id]);
$episode_arr = handleEpisodeContentForAce($fullContent);
// // 进行主体和场景名称的智能匹配和替换(激进策略)
// if ($anime_script_id && !empty($extra_roles) && !empty($extra_scenes)) {
// $episode_arr = $this->matchAndReplaceRolesAndScenes($episode_arr, $extra_roles, $extra_scenes);
// }
dLog('deepseek')->info('第'.$retry_count.'次重试后内容: '.$fullContent);
logDB('deepseek', 'info', "第{$retry_count}次重试解析内容", $episode_arr);
// 重新验证
$validation_failed = false;
$validation_error_msg = '';
if ($anime_script_id) {
if (empty($episode_arr['acts'])) {
$validation_failed = true;
$validation_error_msg = '未生成分镜剧本相关的内容';
} else {
// 检查主体列表 - 只验证生成的主体名是否都在预期列表中
$generated_roles = array_column($episode_arr['roles'], 'role');
// 过滤掉"旁白"角色
$generated_roles = array_filter($generated_roles, function($role) {
return $role !== '旁白';
});
$expected_roles = array_keys($extra_roles);
$invalid_roles = array_diff($generated_roles, $expected_roles);
if (!empty($invalid_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表包含不在【强制主体设定】中的主体:' . implode('、', $invalid_roles);
}
// 检查场景列表 - 只验证生成的场景名是否都在预期列表中
if (!$validation_failed) {
$generated_scenes = array_column($episode_arr['scenes'], 'scene');
$expected_scenes = array_keys($extra_scenes);
$invalid_scenes = array_diff($generated_scenes, $expected_scenes);
if (!empty($invalid_scenes)) {
$validation_failed = true;
$validation_error_msg = '生成的场景列表包含不在【强制场景设定】中的场景:' . implode('、', $invalid_scenes);
}
}
}
} else {
if (empty($episode_arr['acts'])) {
$validation_failed = true;
$validation_error_msg = '未生成分镜剧本相关的内容';
}
}
} catch (\Exception $e) {
dLog('deepseek')->error("第{$retry_count}次重试异常: " . $e->getMessage(), [
'trace' => $e->getTraceAsString()
]);
break;
}
}
// 如果重试后仍然失败
if ($validation_failed) {
dLog('deepseek')->error('达到最大重试次数,仍未生成有效内容', [
'retry_count' => $retry_count,
'last_error' => $validation_error_msg
]);
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' => "生成剧本有误,已重试{$retry_count}次仍失败,请稍后重试。错误原因:{$validation_error_msg}"
];
return;
}
// 验证成功,记录日志
if ($retry_count > 0) {
dLog('deepseek')->info("经过{$retry_count}次重试后验证成功");
logDB('deepseek', 'info', '重试成功', ['retry_count' => $retry_count, 'anime_id' => $anime_id]);
}
// 老逻辑
$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_props = $base_episode ? json_decode((string)getProp($base_episode, 'props'), true) : [];
$existing_roles = is_array($existing_roles) ? $existing_roles : [];
$existing_scenes = is_array($existing_scenes) ? $existing_scenes : [];
$existing_props = is_array($existing_props) ? $existing_props : [];
$now = date('Y-m-d H:i:s');
try {
DB::beginTransaction();
$saveResult = $this->saveEpisodeVersionData(
$anime_id,
$episode_number,
$episode_arr,
$existing_roles,
$existing_scenes,
$existing_props,
$current_episode,
$base_episode,
$is_regenerate_version,
$content,
$now,
$ref_products
);
$episode = $saveResult['episode'];
$episode_id = $saveResult['episode_id'];
$merged_roles = $saveResult['merged_roles'];
$merged_scenes = $saveResult['merged_scenes'];
$merged_props = $saveResult['merged_props'];
$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,
// 'content' => '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。',
'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表
$update_anime_data = [
'model' => $model,
'updated_at' => $now
];
if ($uploaded_content) {
$update_anime_data['content'] = $uploaded_content;
}
DB::table('mp_animes')->where('id', $anime_id)->update($update_anime_data);
}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,
// ], true);
// // 批量生成全局场景图片
// $this->batchSetGlobalSceneImg([
// 'anime_id' => $anime_id,
// ], true);
// // 将全局角色和场景的task_id、task_status和url继承给当前分集
// $this->inheritGlobalImagesToEpisodes($anime_id, $episode_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
];
}
/**
* chatForAce的非流式版本 - 用于队列任务
* 与chatForAce逻辑完全一致,但不使用流式输出
*
* @param array $data 请求数据
* @return array 生成结果
*/
public function chatForAceNonStream($data) {
$allChunks = [];
$finalResult = null;
// 调用流式方法并收集所有数据
foreach ($this->chatForAce($data) as $chunk) {
$allChunks[] = $chunk;
// 保存最终结果
if (isset($chunk['type']) && $chunk['type'] === 'done') {
$finalResult = $chunk;
}
}
// 返回最终结果
if ($finalResult) {
return $finalResult;
}
// 如果没有done类型的数据,返回最后一个chunk
return end($allChunks) ?: ['error' => '生成失败'];
}
/**
* 批量生成多个分集
*
* @param array $data 请求数据
* @return array 任务信息
*/
public function batchGenerateEpisodes($data) {
$uid = Site::getUid();
$cpid = Site::getCpid();
$anime_id = getProp($data, 'anime_id');
$generate_episode_number = (int)getProp($data, 'generate_episode_number', 1);
if (!$anime_id) {
Utils::throwError('20003:anime_id参数缺失');
}
if ($generate_episode_number < 1) {
Utils::throwError('20003:生成集数必须大于0');
}
// 获取动漫信息
$anime = DB::table('mp_animes')->where('id', $anime_id)->first();
if (!$anime) {
Utils::throwError('20003:该剧集不存在');
}
// 检查是否是多剧集模式
if ((int)getProp($anime, 'is_multi') !== 1) {
Utils::throwError('20003:单剧集不支持批量生成');
}
// 获取当前已生成的最大集数
$currentMaxEpisode = DB::table('mp_anime_episodes')
->where('anime_id', $anime_id)
->where('is_default', 1)
->max('episode_number');
$currentMaxEpisode = $currentMaxEpisode ?: 0;
// 确定起始集数
$startEpisodeNumber = $currentMaxEpisode + 1;
// 如果用户指定了起始集数(通过episode_number参数)
if (isset($data['episode_number'])) {
$userStartEpisode = (int)getProp($data, 'episode_number');
if ($userStartEpisode > $currentMaxEpisode) {
$startEpisodeNumber = $userStartEpisode;
}
}
// 计算结束集数
$endEpisodeNumber = $startEpisodeNumber + $generate_episode_number - 1;
// 检查是否超过总集数限制
$end_episode_sequence = getProp($anime, 'end_episode_sequence');
if ($end_episode_sequence && $endEpisodeNumber > $end_episode_sequence) {
Utils::throwError("20003:总集数只有{$end_episode_sequence}集,无法生成到第{$endEpisodeNumber}集");
}
// 检查是否已有进行中的任务
$existingTask = DB::table('mp_batch_episode_generation_tasks')
->where('anime_id', $anime_id)
->whereIn('status', ['pending', 'processing'])
->first();
if ($existingTask) {
Utils::throwError('20003:该剧集已有批量生成任务正在进行中,请等待完成后再试');
}
$now = date('Y-m-d H:i:s');
// 准备任务数据
$taskData = [
'anime_id' => $anime_id,
'uid' => $uid,
'cpid' => $cpid,
'start_episode' => $startEpisodeNumber,
'end_episode' => $endEpisodeNumber,
'total_episodes' => $generate_episode_number,
'current_episode' => 0,
'completed_episodes' => 0,
'status' => 'pending',
'request_data' => json_encode($data, JSON_UNESCAPED_UNICODE),
'created_at' => $now,
'updated_at' => $now
];
// 创建批量生成任务记录
$taskId = DB::table('mp_batch_episode_generation_tasks')->insertGetId($taskData);
if (!$taskId) {
Utils::throwError('20003:创建批量生成任务失败');
}
// 更新任务状态为processing
DB::table('mp_batch_episode_generation_tasks')
->where('id', $taskId)
->update([
'status' => 'processing',
'updated_at' => $now
]);
// 分发到队列
\App\Jobs\ProcessBatchEpisodeGenerationJob::dispatch(
$taskId,
$anime_id,
$startEpisodeNumber,
$generate_episode_number,
$data,
$uid,
$cpid
);
return [
'task_id' => $taskId,
'anime_id' => $anime_id,
'start_episode' => $startEpisodeNumber,
'end_episode' => $endEpisodeNumber,
'total_episodes' => $generate_episode_number,
'status' => 'processing',
'message' => "已创建批量生成任务,将生成第{$startEpisodeNumber}集到第{$endEpisodeNumber}集,共{$generate_episode_number}集"
];
}
/**
* 获取批量生成任务状态
*
* @param array $data 请求数据
* @return array 任务状态信息
*/
public function getBatchGenerationTaskStatus($data) {
$task_id = getProp($data, 'task_id');
$anime_id = getProp($data, 'anime_id');
if (!$task_id && !$anime_id) {
Utils::throwError('20003:task_id或anime_id参数缺失');
}
$query = DB::table('mp_batch_episode_generation_tasks');
if ($task_id) {
$query->where('id', $task_id);
} else {
// 获取该anime最新的任务
$query->where('anime_id', $anime_id)
->orderByDesc('id')
->limit(1);
}
$task = $query->first();
if (!$task) {
Utils::throwError('20003:任务不存在');
}
// 获取任务详情
$details = DB::table('mp_batch_episode_generation_details')
->where('task_id', $task->id)
->orderBy('episode_number')
->get()
->map(function($item) {
return [
'episode_number' => $item->episode_number,
'status' => $item->status,
'error_message' => $item->error_message,
'created_at' => $item->created_at,
'updated_at' => $item->updated_at
];
})
->toArray();
// 计算进度百分比
$progress = 0;
if ($task->total_episodes > 0) {
$progress = round(($task->completed_episodes / $task->total_episodes) * 100, 2);
}
return [
'task_id' => $task->id,
'anime_id' => $task->anime_id,
'start_episode' => $task->start_episode,
'end_episode' => $task->end_episode,
'total_episodes' => $task->total_episodes,
'current_episode' => $task->current_episode,
'completed_episodes' => $task->completed_episodes,
'progress' => $progress,
'status' => $task->status,
'error_message' => $task->error_message,
'details' => $details,
'created_at' => $task->created_at,
'updated_at' => $task->updated_at,
'completed_at' => $task->completed_at
];
}
/**
* 智能匹配并替换主体和场景名称
* 如果生成的主体或场景名称是强制设定名称的子串,则自动替换为完整名称
*
* @param array $episode_arr 解析后的剧集数据
* @param array $extra_roles 强制主体设定(key为主体名称)
* @param array $extra_scenes 强制场景设定(key为场景名称)
* @return array 替换后的剧集数据
*/
private function matchAndReplaceRolesAndScenes($episode_arr, $extra_roles, $extra_scenes) {
$replaced_count = 0;
// 1. 处理主体列表
if (!empty($episode_arr['roles'])) {
foreach ($episode_arr['roles'] as &$role) {
$generated_role_name = $role['role'] ?? '';
// 跳过旁白
if ($generated_role_name === '旁白') {
continue;
}
// 如果生成的主体名不在强制设定中,尝试模糊匹配
if (!isset($extra_roles[$generated_role_name])) {
$matched_role = $this->findMatchingName($generated_role_name, array_keys($extra_roles));
if ($matched_role) {
dLog('deepseek')->info("主体名称替换: {$generated_role_name} => {$matched_role}");
$role['role'] = $matched_role;
$replaced_count++;
}
}
}
}
// 2. 处理场景列表
if (!empty($episode_arr['scenes'])) {
foreach ($episode_arr['scenes'] as &$scene) {
$generated_scene_name = $scene['scene'] ?? '';
// 如果生成的场景名不在强制设定中,尝试模糊匹配
if (!isset($extra_scenes[$generated_scene_name])) {
$matched_scene = $this->findMatchingName($generated_scene_name, array_keys($extra_scenes));
if ($matched_scene) {
dLog('deepseek')->info("场景名称替换: {$generated_scene_name} => {$matched_scene}");
$scene['scene'] = $matched_scene;
$replaced_count++;
}
}
}
}
// 3. 处理分段剧本中的主体和场景引用(全能模式下只使用segment_content字段)
if (!empty($episode_arr['acts'])) {
foreach ($episode_arr['acts'] as &$act) {
if (!empty($act['segments'])) {
foreach ($act['segments'] as &$segment) {
if (!empty($segment['segment_content'])) {
$original_content = $segment['segment_content'];
$replaced_content = $original_content;
// 替换场景名 - 在文本中查找并替换
foreach (array_keys($extra_scenes) as $full_scene_name) {
// 尝试找到可能的短名称
$potential_short_names = [];
// 提取场景名的主要部分(冒号之前)
if (mb_strpos($full_scene_name, ':') !== false) {
$short_name = mb_substr($full_scene_name, 0, mb_strpos($full_scene_name, ':'));
$potential_short_names[] = $short_name;
} elseif (mb_strpos($full_scene_name, ':') !== false) {
$short_name = mb_substr($full_scene_name, 0, mb_strpos($full_scene_name, ':'));
$potential_short_names[] = $short_name;
}
// 替换场景字段中的短名称
foreach ($potential_short_names as $short_name) {
if ($short_name !== $full_scene_name) {
$pattern = '/(?<=场景:|场景:|拍摄场景:|拍摄场景:|背景场景:|背景场景:|环境:|环境:)' . preg_quote($short_name, '/') . '(?=\s|$|\n)/u';
$new_content = preg_replace($pattern, $full_scene_name, $replaced_content);
if ($new_content !== $replaced_content) {
$replaced_content = $new_content;
$replaced_count++;
dLog('deepseek')->info("场景名替换: {$short_name} => {$full_scene_name}");
}
}
}
}
// 替换配音角色名(跳过旁白)
foreach (array_keys($extra_roles) as $full_role_name) {
if ($full_role_name === '旁白') {
continue;
}
// 尝试找到可能的短名称
$potential_short_names = [];
// 提取角色名的主要部分(短横线之前)
if (mb_strpos($full_role_name, '-') !== false) {
$short_name = mb_substr($full_role_name, 0, mb_strpos($full_role_name, '-'));
$potential_short_names[] = $short_name;
}
// 替换配音台词中的短名称
foreach ($potential_short_names as $short_name) {
if ($short_name !== $full_role_name) {
$pattern = '/(?<=配音台词:\[|配音台词:\[|中文配音:\[|中文配音:\[)\s*' . preg_quote($short_name, '/') . '\s*(?=\])/u';
$new_content = preg_replace($pattern, $full_role_name, $replaced_content);
if ($new_content !== $replaced_content) {
$replaced_content = $new_content;
$replaced_count++;
dLog('deepseek')->info("角色名替换: {$short_name} => {$full_role_name}");
}
}
}
}
// 如果有替换,更新segment_content
if ($replaced_content !== $original_content) {
$segment['segment_content'] = $replaced_content;
}
}
}
}
}
}
if ($replaced_count > 0) {
logDB('deepseek', 'info', '主体和场景名称智能替换', [
'replaced_count' => $replaced_count
]);
}
return $episode_arr;
}
/**
* 查找匹配的名称
* 规则:如果生成的名称是强制设定名称的子串(开头部分),则返回匹配的强制设定名称
*
* @param string $generated_name 生成的名称
* @param array $expected_names 预期的名称列表
* @return string|null 匹配到的名称,如果没有匹配则返回null
*/
private function findMatchingName($generated_name, $expected_names) {
if (empty($generated_name)) {
return null;
}
// 尝试精确匹配
if (in_array($generated_name, $expected_names)) {
return $generated_name;
}
// 尝试模糊匹配:生成的名称是否为某个预期名称的开头部分
foreach ($expected_names as $expected_name) {
// 检查生成的名称是否是预期名称的开头(如"沈秋岚"是"沈秋岚-程家居家围裙造型"的开头)
if (mb_strpos($expected_name, $generated_name) === 0) {
return $expected_name;
}
}
// 更宽松的匹配:检查生成的名称是否包含在预期名称中的任意位置
// 这样可以处理一些变体,但优先级较低
foreach ($expected_names as $expected_name) {
if (mb_strpos($expected_name, $generated_name) !== false) {
return $expected_name;
}
}
return null;
}
/**
* 重新生成分段剧本
* @param array $data 包含以下字段:
* - prompt: string|null 用户修改要求(可选)
* - template_id: int|null 提示词模板ID(可选)
* - act_id: int|null 单个片段ID(与episode_id二选一)
* - episode_id: int|null 剧集ID,用于重新生成整个剧集的所有片段(与act_id二选一)
* @return array 返回生成结果
*/
public function regenerateSegmentScript($data) {
$uid = Site::getUid();
$prompt = trim((string)getProp($data, 'prompt', ''));
$template_id = getProp($data, 'template_id', 0);
$act_id = getProp($data, 'act_id', 0);
$episode_id = getProp($data, 'episode_id', 0);
// 验证:prompt和template_id至少提供一个
if (empty($prompt) && empty($template_id)) {
Utils::throwError('20003:请提供修改要求或选择提示词模板');
}
// 验证:act_id和episode_id二选一
if (empty($act_id) && empty($episode_id)) {
Utils::throwError('20003:请提供片段ID或剧集ID');
}
if (!empty($act_id) && !empty($episode_id)) {
Utils::throwError('20003:片段ID和剧集ID只能选择一个');
}
// 如果提供了template_id,获取模板提示词
$template_prompt = '';
if ($template_id > 0) {
$template = DB::table('mp_prompt_templates')
->where('id', $template_id)
->where('is_deleted', 0)
->first();
if (!$template) {
Utils::throwError('20003:提示词模板不存在或已删除');
}
$template_prompt = $template->template_prompt ?? '';
}
// 合并用户提示词和模板提示词
$final_prompt = '';
if (!empty($template_prompt)) {
$final_prompt = $template_prompt;
if (!empty($prompt)) {
$final_prompt .= "\n\n补充要求:" . $prompt;
}
} else {
$final_prompt = $prompt;
}
// 获取需要重新生成的内容参考
$reference_content = '';
$target_episode_id = 0;
$target_anime_id = 0;
$target_episode_number = 0;
if ($act_id > 0) {
// 单个片段模式:获取该片段的内容
$segment = DB::table('mp_episode_segments')
->where('id', $act_id)
->first();
if (!$segment) {
Utils::throwError('20003:片段不存在');
}
$target_episode_id = $segment->episode_id;
$target_anime_id = $segment->anime_id;
$target_episode_number = $segment->episode_number;
$reference_content = $segment->act_content ?? '';
$reference_content = "\n\n【原分段剧本内容】\n{$reference_content}";
} else {
// 整个剧集模式:获取所有片段的内容
$segments = DB::table('mp_episode_segments')
->where('episode_id', $episode_id)
->orderBy('act_number')
->get();
if ($segments->isEmpty()) {
Utils::throwError('20003:该剧集没有片段数据');
}
$target_episode_id = $episode_id;
$target_anime_id = $segments[0]->anime_id;
$target_episode_number = $segments[0]->episode_number;
// 拼接所有片段内容,保留格式用于AI理解
$act_contents = [];
foreach ($segments as $seg) {
$act_number = $seg->act_number;
$act_content = $seg->act_content ?? '';
if (!empty($act_content)) {
$act_contents[] = "##片段{$act_number}\n" . $act_content;
}
}
$reference_content = implode("\n\n", $act_contents);
$episode_content = DB::table('mp_anime_episodes')->where('id', $episode_id)->value('content');
if ($episode_content) {
$reference_content = "\n\n【本集原始内容】\n{$episode_content}\n\n【本集生成剧本内容】\n{$reference_content}";
}else {
$reference_content = "\n\n【原分段剧本内容】\n{$reference_content}";
}
}
if (empty($reference_content)) {
Utils::throwError('20003:没有可参考的片段内容');
}
// 获取剧集的基本信息(intro、art_style、roles、scenes)
$episode = DB::table('mp_anime_episodes')
->where('id', $target_episode_id)
->first();
if (!$episode) {
Utils::throwError('20003:剧集不存在');
}
$anime = DB::table('mp_animes')->where('id', $target_anime_id)->first();
$anime_script_id = getProp($anime, 'script_id');
$intro = $episode->intro ?? '';
$art_style = $episode->art_style ?? '';
$roles = json_decode($episode->roles ?? '[]', true);
$scenes = json_decode($episode->scenes ?? '[]', true);
// 构建提示词中的主体列表和场景列表参考
$roles_ref = '';
if (!empty($roles) && is_array($roles)) {
$roles_list = [];
foreach ($roles as $role) {
$role_name = getProp($role, 'role', '');
$role_desc = getProp($role, 'description', '');
$pic_prompt = getProp($role, 'pic_prompt', '');
$voice_prompt = getProp($role, 'voice_prompt', '');
if (!empty($role_name)) {
$role_line = $role_name;
if (!empty($role_desc)) $role_line .= ":" . $role_desc;
if (!empty($pic_prompt)) $role_line .= "{" . $pic_prompt . "}";
if (!empty($voice_prompt)) $role_line .= "{{" . $voice_prompt . "}}";
$roles_list[] = $role_line;
}
}
if (!empty($roles_list)) {
$roles_ref = "\n\n【主体列表参考】\n" . implode("\n", $roles_list);
}
}
$scenes_ref = '';
if (!empty($scenes) && is_array($scenes)) {
$scenes_list = [];
foreach ($scenes as $scene) {
$scene_name = getProp($scene, 'scene', '');
$scene_desc = getProp($scene, 'description', '');
$pic_prompt = getProp($scene, 'pic_prompt', '');
if (!empty($scene_name)) {
$scene_line = $scene_name;
if (!empty($scene_desc)) $scene_line .= ":" . $scene_desc;
if (!empty($pic_prompt)) $scene_line .= "{" . $pic_prompt . "}";
$scenes_list[] = $scene_line;
}
}
if (!empty($scenes_list)) {
$scenes_ref = "\n\n【场景列表参考】\n" . implode("\n", $scenes_list);
}
}
// 构建美术风格参考
$art_style_ref = '';
if (!empty($art_style)) {
$art_style_ref = "\n\n【美术风格参考】\n" . $art_style;
}
// 构建内容简介参考
$intro_ref = '';
if (!empty($intro)) {
$intro_ref = "\n\n【内容简介参考】\n" . $intro;
}
// 构建完整的AI提示词
// $system_prompt = "你是一个专业的剧本编剧和影视导演,擅长根据已有的分段剧本进行优化和调整。你需要严格按照用户的要求重新生成分段剧本内容,保持原有的格式和结构。";
$user_prompt = "请根据以下要求重新生成分段剧本:\n\n【用户要求】\n{$final_prompt}";
$user_prompt .= $intro_ref;
$user_prompt .= $art_style_ref;
$user_prompt .= $reference_content;
// $user_prompt .= $roles_ref;
// $user_prompt .= $scenes_ref;
$system_prompt = "\n\n【强制要求】\n";
$system_prompt .= "- 请仅生成<分段剧本>部分,不要生成其他任何内容:";
$system_prompt .= "- 每个分镜包含:分镜序号、场景、画面、运镜、配音台词、背景音效\n";
$system_prompt .= "- 场景必须从【场景列表参考】中选择(如果提供了场景列表),场景名必须精确匹配【场景列表参考】中的名称(如需匹配“酒店会客室-景池筹备会版”,而不是“酒店会客室”,不得缺少“-”后面“:”前面的任何内容,不能自创){$scenes_ref}\n";
$system_prompt .= "- 出场角色必须从【主体列表参考】中选择(如果提供了主体列表),角色名必须精准匹配【主体列表参考】中的名称(如需匹配“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”后面“:”前面的任何内容,不能自创){$roles_ref}\n";
$system_prompt .= "- 保持美术风格的一致性(如果提供了美术风格参考)\n";
$system_prompt .= "- 参考示例格式如下,此为第一优先规则,必须按以下格式生成内容,请忽略其他任何提示词部分关于规则的描述:
###分段剧本
片段数量:8
旁白音色:成年男性(约35-50岁),音色厚重且富有磁性,带有老电影叙事般的沧桑感,语调低沉平稳,富有江湖气息。
##片段1
时长:12s
分镜1
场景:边境小镇街道
画面:[大远景,冷色调,高对比度] 漫天黄沙在狂风中如怒龙翻滚,遮蔽了微弱的星光。悦来酒馆残破的木门在风中剧烈摇晃,铁链撞击木板发出沉重的闷响,四周建筑尽显荒凉。
运镜:大远景固定镜头,随后风沙席卷向镜头形成遮挡转场。
配音台词:
背景音效:
分镜2
场景:悦来酒馆
画面:[全景,暖色调阴影构图] 酒馆内部昏暗,几盏油灯在风中疯狂摇曳,投射出扭曲的长影。角落里的食客低头缩颈,双手紧紧攥着粗瓷碗,空气中漂浮着细小的尘埃。
运镜:缓慢横移,掠过阴影中瑟缩的食客。
配音台词:
背景音效:
分镜3
场景:悦来酒馆
画面:[中景,三分法构图] 三名大汉围坐在正中央的圆桌旁,腰间挂着的“武林盟”金色令牌在灯火下闪烁。桌上残羹冷炙横陈,一名大汉正张狂地大笑,脚踩在长凳上。
运镜:从中景推向令牌特写。
配音台词:
背景音效:
分镜4
场景:悦来酒馆
画面:[特写,低角度仰拍] 刀疤脸横肉颤动,嘴角挂着轻蔑的笑。他随手倾覆手中的酒碗,琥珀色的液体在空中划过弧线,重重溅在老李打着补丁的布鞋和裤脚上。
运镜:极速推向酒水溅起的瞬间。
配音台词:
背景音效:
分镜5
镜头描述
场景:悦来酒馆
画面:[近景,荷兰角倾斜构图] 刀疤脸斜睨着眼,昏暗的灯光勾勒出他脸上狰狞的伤疤。他身体前倾,阴影完全笼罩了对面的老李,眼神如毒蛇般阴冷。
运镜:固定机位,利用倾斜构图制造压迫感。
配音台词:中文配音:[刀疤脸]老李头,这酒里掺水了吧?
背景音效:";
// 获取模型配置
$model = getProp($data, 'model');
if (!$model) {
$model = $anime ? ($anime->model ?? 'deepseek-v4-pro') : 'deepseek-v4-pro';
}
// 验证模型是否在可用模型表中
if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
$model = 'deepseek-v4-pro';
}
// 模型兼容性处理
$thinkingMode = 'disabled';
$reasoningEffort = 'high';
if ($model === 'deepseek-chat') {
$model = 'deepseek-v4-flash';
$thinkingMode = 'disabled';
} elseif ($model === 'deepseek-reasoner') {
$model = 'deepseek-v4-flash';
$thinkingMode = 'enabled';
}
// 构建消息
$messages = [
[
'role' => 'system',
'content' => $system_prompt
],
[
'role' => 'user',
'content' => $user_prompt
]
];
// dd($messages);
// 构建请求参数
$post_data = [
'model' => $model,
'messages' => $messages,
'temperature' => 0.7,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'thinking' => ['type' => $thinkingMode],
'stream' => false // 非流式输出
];
if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort;
// 调用AI生成新的分段剧本
try {
$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 if (in_array($model, $this->gpt_text_models)) {
// GPT 模型使用 TokenRouter API
$chatResult = $this->gpt54ChatOnly($post_data);
} else {
// 其他模型使用火山引擎API
$chatResult = $this->volcEngineChatCompletion($post_data);
}
if (is_array($chatResult)) {
$fullContent = $chatResult['fullContent'] ?? '';
$usage = $chatResult['usage'] ?? [];
}
$generated_content = $fullContent;
if (empty($generated_content)) {
Utils::throwError('20003:AI生成内容为空,请重试');
}
// 解析生成的内容 - 按片段分割
$parsed_segments = [];
// 先在开头添加换行符,确保第1片段也能被正确分割
$normalizedText = "\n" . $generated_content;
$parts = preg_split('/\n\s*##/', $normalizedText);
foreach ($parts as $part) {
$part = trim($part);
if (empty($part)) continue;
// 匹配"片段X"格式
if (!preg_match('/^片段\d+/', $part)) {
continue;
}
// 分离标题和内容
$lines = explode("\n", $part, 2);
$actTitle = trim($lines[0]);
$actContent = isset($lines[1]) ? trim($lines[1]) : '';
// 解析标题,提取序号
$actNumber = 0;
if (preg_match('/^片段(\d+)\s*$/u', $actTitle, $segmentTitleMatch)) {
$actNumber = intval($segmentTitleMatch[1]);
} else {
$actNumber = count($parsed_segments) + 1;
}
// 提取时长(仅保留数字)
$actDuration = 0;
if (preg_match('/时长[::]\s*([^\n]+)/u', $actContent, $actDurationMatch)) {
$actDurationStr = trim($actDurationMatch[1]);
// 提取数字部分(支持整数和小数)
if (preg_match('/([-+]?[0-9]*\.?[0-9]+)/', $actDurationStr, $numMatch)) {
$actDuration = (float)$numMatch[1];
}
}
// 清理act_content:去除时长、旁白音色等非分镜内容行
// 只保留分镜内容,并确保分镜标记使用【】格式
$cleaned_content = '';
$content_lines = explode("\n", $actContent);
$is_capturing = false; // 标记是否开始捕获分镜内容
$count = 0;
foreach ($content_lines as $line) {
$trimmed_line = trim($line);
// 跳过时长和旁白音色行
if (preg_match('/^(时长|旁白音色)[::]/u', $trimmed_line)) {
continue;
}
// 检测是否是分镜开始
// 支持格式:【分镜X】、【镜头X】、分镜X、镜头X
if (preg_match('/^(?:【)?(分镜|镜头)(\d+)(?:】)?/u', $trimmed_line, $shot_match)) {
$count++;
$is_capturing = true;
// 如果没有【】,则添加
if (!preg_match('/^【/u', $trimmed_line)) {
$trimmed_line = preg_replace('/^(分镜|镜头)(\d+)/u', '【$1$2】', $trimmed_line);
}
if ($count > 1) $trimmed_line = "\n".$trimmed_line;
}
// 如果已经开始捕获,则添加该行
if ($is_capturing && !empty($trimmed_line)) {
$cleaned_content .= $trimmed_line."\n";
}
}
$cleaned_content = str_replace(["@", "{", "}"], "", $cleaned_content);
$parsed_segments[] = [
'act_number' => $actNumber,
'act_title' => $actTitle,
'act_duration' => $actDuration,
'act_content' => $cleaned_content,
];
}
if (empty($parsed_segments)) {
Utils::throwError('20003:无法解析生成的分段剧本,请检查格式');
}
// 获取角色和场景名称用于标记
$role_names = [];
$scene_names = [];
if (!empty($roles) && is_array($roles)) {
foreach ($roles as $role) {
$role_name = getProp($role, 'role', '');
if (!empty($role_name)) {
$role_names[] = $role_name;
}
}
}
if (!empty($scenes) && is_array($scenes)) {
foreach ($scenes as $scene) {
$scene_name = getProp($scene, 'scene', '');
if (!empty($scene_name)) {
$scene_names[] = $scene_name;
}
}
}
// 处理每个片段的 act_show_content
foreach ($parsed_segments as &$segment) {
$act_content = getProp($segment, 'act_content', '');
if (empty($act_content)) {
$segment['act_show_content'] = '';
continue;
}
$processed_content = $act_content;
// 先匹配场景(在"场景:"后面)
foreach ($scene_names as $scene_name) {
// 转义特殊字符
$escaped_scene = preg_quote($scene_name, '/');
// 匹配场景:后面的场景名称(可能换行,也可能不换行)
$pattern = '/(场景:[\s\r\n]*)(' . $escaped_scene . ')/u';
$processed_content = preg_replace_callback($pattern, function($matches) {
$prefix = $matches[1];
$scene_name = $matches[2];
// 检查是否已经被标记
if (strpos($scene_name, '{') === false && strpos($scene_name, '}') === false) {
return $prefix . '{' . $scene_name . '}';
}
return $matches[0];
}, $processed_content);
}
// 再匹配角色名称(只在"画面:"部分)
foreach ($role_names as $role_name) {
// 转义特殊字符
$escaped_role = preg_quote($role_name, '/');
// 只匹配"画面:"开始到换行之前的内容
$pattern = '/(画面:[^\n]*)/u';
$processed_content = preg_replace_callback($pattern, function($matches) use ($escaped_role) {
$line = $matches[1]; // 整行"画面:xxxxx"
// 在这一行中匹配所有角色名(避免重复标记)
$new_line = preg_replace(
'/(? 0) {
// 单个片段模式:更新单条记录
if (count($parsed_segments) > 0) {
$new_segment = $parsed_segments[0];
$update_data = [
'act_content' => getProp($new_segment, 'act_content', ''),
'act_show_content' => getProp($new_segment, 'act_show_content', ''),
'act_title' => getProp($new_segment, 'act_title', ''),
'act_duration' => getProp($new_segment, 'act_duration', 0),
'updated_at' => $now,
];
DB::table('mp_episode_segments')
->where('id', $act_id)
->update($update_data);
// 获取更新后的记录
$updated_act = DB::table('mp_episode_segments')
->where('id', $act_id)
->first();
if ($updated_act) {
$saved_acts[] = [
'act_id' => $updated_act->id,
'act_number' => $updated_act->act_number,
'act_duration' => $updated_act->video_duration ? $updated_act->video_duration : $updated_act->act_duration,
'act_content' => $updated_act->act_content,
'act_show_content' => $updated_act->act_show_content,
'video_url' => $updated_act->video_url ?? '',
'video_duration' => $updated_act->video_duration ?? 0,
'video_time_point_start' => $updated_act->video_time_point_start ?? 0,
'video_time_point_end' => $updated_act->video_time_point_end ?? 0,
];
}
dLog('deepseek')->info('单个片段重新生成成功', ['act_id' => $act_id]);
}
} else {
// 整个剧集模式:删除原有记录并批量保存新记录
DB::beginTransaction();
try {
// 删除原有的所有片段记录
DB::table('mp_episode_segments')
->where('episode_id', $target_episode_id)
->delete();
// 批量插入新的片段记录
$segments_to_insert = [];
foreach ($parsed_segments as $index => $segment) {
$act_number = $index + 1;
$segments_to_insert[] = [
'anime_id' => $target_anime_id,
'episode_id' => $target_episode_id,
'episode_number' => $target_episode_number,
'act_number' => $act_number,
'act_title' => getProp($segment, 'act_title', ''),
'act_duration' => getProp($segment, 'act_duration', 0),
'act_content' => getProp($segment, 'act_content', ''),
'act_show_content' => getProp($segment, 'act_show_content', ''),
'created_at' => $now,
'updated_at' => $now,
];
}
if (!empty($segments_to_insert)) {
DB::table('mp_episode_segments')->insert($segments_to_insert);
}
DB::commit();
// 查询保存后的所有片段记录
$saved_segments = DB::table('mp_episode_segments')
->where('episode_id', $target_episode_id)
->orderBy('act_number')
->get();
foreach ($saved_segments as $saved_segment) {
$saved_acts[] = [
'act_id' => $saved_segment->id,
'act_number' => $saved_segment->act_number,
'act_duration' => $saved_segment->video_duration ? $saved_segment->video_duration : $saved_segment->act_duration,
'act_content' => $saved_segment->act_content,
'act_show_content' => $saved_segment->act_show_content,
'video_url' => $saved_segment->video_url ?? '',
'video_duration' => $saved_segment->video_duration ?? 0,
'video_time_point_start' => $saved_segment->video_time_point_start ?? 0,
'video_time_point_end' => $saved_segment->video_time_point_end ?? 0,
];
}
dLog('deepseek')->info('整个剧集片段重新生成成功', [
'episode_id' => $target_episode_id,
'segments_count' => count($segments_to_insert)
]);
} catch (\Exception $e) {
DB::rollBack();
dLog('deepseek')->error('保存重新生成的片段失败: ' . $e->getMessage());
Utils::throwError('20003:保存失败,请重试');
}
}
// 构造返回数据结构(参考AnimeService返回格式,但不包含products)
return [
'anime_id' => $target_anime_id,
'episode_id' => $target_episode_id,
'title' => getProp($episode, 'title', ''),
'episode_number' => $target_episode_number,
'is_generated' => getProp($episode, 'is_generated', 0),
'acts' => $saved_acts,
];
} catch (\Exception $e) {
dLog('deepseek')->error('重新生成分段剧本失败: ' . $e->getMessage());
throw $e;
}
}
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,
array $existing_props,
$current_episode,
$base_episode,
bool $is_regenerate_version,
string $content,
string $now,
array $ref_products = []
): 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'] : [];
$generated_props = isset($episode_arr['props']) && is_array($episode_arr['props']) ? $episode_arr['props'] : [];
$merged_roles = $generated_roles;
$merged_scenes = $generated_scenes;
$merged_props = $generated_props;
// $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();
$ace_mode = getProp($anime, 'ace_mode');
dLog('deepseek')->info('ace_mode', ['ace_mode' => $ace_mode]);
$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');
$art_style_type = getProp($anime, 'art_style_type');
// 检查并创建 roles 的图片生成任务
// $merged_roles = $this->checkAndCreateImageTasks($merged_roles, $global_roles, 'role', $art_style);
// 检查并创建 scenes 的图片生成任务
// $merged_scenes = $this->checkAndCreateImageTasks($merged_scenes, $global_scenes, 'scene', $art_style);
$arr = $this->batchSetEpisodeRolesAndScenesImg($anime_id, $episode_number, $merged_roles, $merged_scenes, $merged_props, $ref_products, $art_style_type);
if ($arr && is_array($arr)) {
$merged_roles = $arr['roles'];
$merged_scenes = $arr['scenes'];
$merged_props = isset($arr['props']) ? $arr['props'] : [];
}
// 确保数据是数组类型
if (!is_array($merged_roles)) {
dLog('deepseek')->error('merged_roles不是数组类型', [
'type' => gettype($merged_roles),
'value' => $merged_roles
]);
$merged_roles = [];
}
if (!is_array($merged_scenes)) {
dLog('deepseek')->error('merged_scenes不是数组类型', [
'type' => gettype($merged_scenes),
'value' => $merged_scenes
]);
$merged_scenes = [];
}
if (!is_array($merged_props)) {
dLog('deepseek')->error('merged_props不是数组类型', [
'type' => gettype($merged_props),
'value' => $merged_props
]);
$merged_props = [];
}
// 同步新出现的主体和场景到全局
// $this->syncNewResourcesToGlobal($anime_id, $merged_roles, $merged_scenes, $global_roles, $global_scenes);
$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),
'props' => json_encode($merged_props, 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['cpid'] = Site::getCpid();
$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['cpid'] = Site::getCpid();
$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'] : [];
// 如果是第2集及以后,获取上一集的主体音色数据
$previous_roles_voice = [];
if ($episode_number >= 2) {
$previous_episode = DB::table('mp_anime_episodes')
->where('anime_id', $anime_id)
->where('episode_number', $episode_number - 1)
->where('is_default', 1)
->first();
if ($previous_episode) {
$previous_roles = json_decode(getProp($previous_episode, 'roles'), true);
if (is_array($previous_roles)) {
foreach ($previous_roles as $prev_role) {
$role_name = getProp($prev_role, 'role');
if ($role_name) {
$previous_roles_voice[$role_name] = [
'voice_name' => getProp($prev_role, 'voice_name'),
'voice_type' => getProp($prev_role, 'voice_type'),
'voice_audio_url' => getProp($prev_role, 'voice_audio_url'),
];
$voice_prompt = getProp($prev_role, 'voice_prompt');
if ($voice_prompt) {
$previous_roles_voice[$role_name]['voice_prompt'] = $voice_prompt;
}
}
}
}
}
}
// 将继承的音色数据同步到当前集的主体列表
if (!empty($previous_roles_voice)) {
foreach ($merged_roles as &$role) {
$role_name = getProp($role, 'role');
if ($role_name && isset($previous_roles_voice[$role_name])) {
// 强制继承上一集的音色数据
$role['voice_name'] = $previous_roles_voice[$role_name]['voice_name'];
$role['voice_type'] = $previous_roles_voice[$role_name]['voice_type'];
$role['voice_audio_url'] = $previous_roles_voice[$role_name]['voice_audio_url'];
// 如果上一集有 voice_prompt 则继承,否则跳过
if (!empty($previous_roles_voice[$role_name]['voice_prompt'])) {
$role['voice_prompt'] = $previous_roles_voice[$role_name]['voice_prompt'];
}
}
}
unset($role); // 解除引用
// 更新 role_arr 以便后续使用
$role_arr = [];
foreach ($merged_roles as $item) {
$role_arr[$item['role']] = $item;
}
}
$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;
}
if ((int)$ace_mode === 1) {
// ace_mode=1: 按幕合并 segment_content,每幕只存一条记录
$merged_parts = [];
foreach ($act_segments as $seg) {
$seg_num = getProp($seg, 'segment_number');
$seg_content = getProp($seg, 'segment_content');
$merged_parts[] = "【分镜{$seg_num}】\n{$seg_content}\n";
}
$act_content = implode("\n", $merged_parts);
$act_content = str_replace(["@", "{", "}"], "", $act_content);
$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'),
'act_duration' => getProp($act, 'act_duration'),
'act_content' => $act_content,
'created_at' => $now,
'updated_at' => $now
];
} else {
foreach ($act_segments as $segment) {
$voice_actor = getProp($segment, 'voice_actor');
// 优先从上一集继承音色数据
$timbre_info = [];
if (isset($previous_roles_voice[$voice_actor])) {
// 从上一集继承音色数据
$timbre_info = $previous_roles_voice[$voice_actor];
} elseif (isset($role_arr[$voice_actor])) {
// 使用当前集的主体音色数据
$timbre_info = $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'),
'act_duration' => getProp($act, 'act_duration'),
'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:分镜内容保存失败');
}
}
dLog('deepseek')->info('segments', $segments);
// ace_mode=1: acts 返回值按合并格式调整
if ((int)$ace_mode === 1) {
$table_act_data = DB::table('mp_episode_segments')->where('episode_id', $episode_id)->orderBy('act_number')->select('id as act_id', 'act_number')->get();
$act_map = [];
foreach ($table_act_data as $item) {
$act_map[getProp($item, 'act_number')] = getProp($item, 'act_id');
}
$restructured_acts = [];
foreach ($acts as $act) {
$act_segments = getProp($act, 'segments', []);
if (!is_array($act_segments)) {
continue;
}
$merged_parts = [];
foreach ($act_segments as $seg) {
$seg_num = getProp($seg, 'segment_number');
$seg_content = getProp($seg, 'segment_content');
$merged_parts[] = "【分镜{$seg_num}】{$seg_content}\n";
}
$act_content = implode("\n", $merged_parts);
$act_content = str_replace(["@", "{", "}"], "", $act_content);
$restructured_acts[] = [
'anime_id' => $anime_id,
'episode_id' => $episode_id,
'episode_number' => $episode_number,
'act_id' => isset($act_map[getProp($act, 'act_number')]) ? $act_map[getProp($act, 'act_number')] : 0,
'act_number' => getProp($act, 'act_number'),
'act_title' => getProp($act, 'act_title'),
'act_duration' => getProp($act, 'act_duration'),
'act_content' => $act_content,
'created_at' => $now,
'updated_at' => $now,
];
}
$acts = $restructured_acts;
}
$episode['episode_id'] = $episode_id;
$episode['roles'] = $merged_roles;
$episode['scenes'] = $merged_scenes;
$episode['props'] = $merged_props;
$episode['acts'] = $acts;
return [
'episode' => $episode,
'episode_id' => $episode_id,
'merged_roles' => $merged_roles,
'merged_scenes' => $merged_scenes,
'merged_props' => $merged_props,
];
}
private function buildEpisodeItemContent(array $items, string $nameKey): string
{
$content = '';
foreach ($items as $item) {
$name = trim((string)getProp($item, $nameKey));
if ($name === '' || $name === '旁白') {
continue;
}
$description = trim((string)getProp($item, 'description'));
$content .= $name . ': ' . $description;
if ($nameKey == 'role') {
$pic_prompt = trim(getProp($item, 'pic_prompt'));
if ($pic_prompt) $content .= '{'.$pic_prompt.'}';
$voice_name = trim(getProp($item, 'voice_name'));
if ($voice_name) $content .= '{{'.$voice_name.'}}';
$voice_prompt = trim(getProp($item, 'voice_prompt'));
if ($voice_prompt) $content .= '{{'.$voice_prompt.'}}';
}else {
$pic_prompt = trim(getProp($item, 'pic_prompt'));
if ($pic_prompt) $content .= '{'.$pic_prompt.'}';
}
$content .= "\n";
}
return trim($content);
}
private function getEpisodeChatContent($animeId, $sequence, $content) {
if ((int)$sequence <= 0) {
return [];
}
$origin_content = '';
if ($content) {
$origin_content = '本集剧情内容:'.$content;
}else {
$origin_content = DB::table('mp_animes')->where('id', $animeId)->value('content');
if ($origin_content) $origin_content = '剧本全文内容:'.$origin_content;
}
$ace_mode = DB::table('mp_animes')->where('id', $animeId)->value('ace_mode');
// 获取分集信息
$episode = DB::table('mp_anime_episodes')
->where('anime_id', $animeId)
->where('episode_number', $sequence)
->where('is_default', 1)
->first();
if (!$episode) {
return [];
}
$episode_id = getProp($episode, 'id');
$episode_number = getProp($episode, 'episode_number');
$title = getProp($episode, 'title');
$intro = getProp($episode, 'intro');
$art_style = getProp($episode, 'art_style');
$roles = json_decode(getProp($episode, 'roles'), true) ?: [];
$scenes = json_decode(getProp($episode, 'scenes'), true) ?: [];
// 组装纯文本内容
$content = '';
// 添加分集标题和梗概
$content .= "###第{$episode_number}集:{$title}\n\n";
$content .= "###故事梗概\n";
$content .= trim($intro) . "\n\n";
// 添加美术风格
$content .= "###美术风格\n";
$content .= trim($art_style) . "\n\n";
// 添加主体列表
$content .= "###主体列表\n";
$pangbai_voice_prompt = "";
foreach ($roles as $role) {
$name = getProp($role, 'role');
$description = getProp($role, 'description');
$pic_prompt = getProp($role, 'pic_prompt');
$voice_prompt = getProp($role, 'voice_prompt');
$voice_name = getProp($role, 'voice_name');
$content .= "{$name}: {$description}";
if ($pic_prompt) $content .= '{'.$pic_prompt.'}';
if ($voice_prompt) $content .= '{{'.$voice_prompt.'}}';
if ($voice_name) $content .= '{{'.$voice_name.'}}';
if ($name == '旁白') $pangbai_voice_prompt = $voice_prompt;
$content .= "\n";
}
$content .= "\n";
// 添加场景列表
$content .= "###场景列表\n";
foreach ($scenes as $scene) {
$name = getProp($scene, 'scene');
$description = getProp($scene, 'description');
$pic_prompt = getProp($scene, 'pic_prompt');
$content .= "{$name}: {$description}";
if ($pic_prompt) $content .= '{'.$pic_prompt.'}';
$content .= "\n";
}
$content .= "\n";
// 获取分镜信息
$segments = DB::table('mp_episode_segments')
->where('anime_id', $animeId)
->where('episode_id', $episode_id)
->orderBy('segment_number')
->get();
if ($segments && count($segments) > 0) {
if ((int)$ace_mode === 1) {
$content .= "###分段剧本\n";
// 获取片段数量
$act_count = DB::table('mp_episode_segments')->where('anime_id', $animeId)->where('episode_id', $episode_id)->groupBy('act_number')->count('act_number');
$content .= "片段数量:{$act_count}\n";
if ($pangbai_voice_prompt) $content .= "旁白音色:{$pangbai_voice_prompt}\n";
$content .= "\n";
}else {
$content .= "###分镜剧本\n";
}
// 按幕分组
$currentAct = null;
foreach ($segments as $segment) {
$act_number = getProp($segment, 'act_number');
$act_title = getProp($segment, 'act_title');
$act_duration = getProp($segment, 'act_duration');
$segment_number = getProp($segment, 'segment_number');
$segment_content = getProp($segment, 'segment_content');
$scene_description = getProp($segment, 'scene_description');
$scene_name = getProp($segment, 'scene_name');
$composition = getProp($segment, 'composition');
$camera_movement = getProp($segment, 'camera_movement');
$voice_type = getProp($segment, 'voice_type');
$characters = getProp($segment, 'characters');
$dialogue = getProp($segment, 'dialogue');
$frame_type = getProp($segment, 'frame_type');
$tail_frame_description = getProp($segment, 'tail_frame_description');
// 判断是否是全能模式
if ((int)$ace_mode === 1) { // 全能模式
// 如果是新的分段,添加分段标题
if ($act_number !== $currentAct) {
$currentAct = $act_number;
$content .= "##{$act_title}\n";
$content .= "时长:{$act_duration}s\n";
}
}else {
// 如果是新的幕,添加幕标题
if ($act_number !== $currentAct) {
$currentAct = $act_number;
$content .= "##第{$act_number}幕:{$scene_name}\n";
}
}
// 添加分镜信息
$content .= "分镜{$segment_number}\n";
$content .= "分镜内容:\n{$segment_content}\n";
// $content .= "画面描述:{$scene_description}\n";
// $content .= "场景:{$scene_name}\n";
// $content .= "构图设计:{$composition}\n";
// $content .= "运镜调度:{$camera_movement}\n";
// if (!empty($voice_type)) {
// $content .= "配音角色:{$voice_type}\n";
// }
// if (!empty($characters)) {
// $content .= "出镜角色:{$characters}\n";
// }
// if (!empty($dialogue)) {
// $content .= "台词内容:\"{$dialogue}\"\n";
// }
// $content .= "画面类型:{$frame_type}\n";
// $content .= "尾帧描述:{$tail_frame_description}\n";
$content .= "\n";
}
}
$content = trim($content);
if($content) $content = "上集剧本内容:\n{$content}";
return [
[
'role' => 'user',
'content' => $origin_content
],
[
'role' => 'assistant',
'content' => $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)
->where('episode_id', '>', 0)
->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')),
];
// 如果有 pic_prompt,添加到合并项中
$picPrompt = getProp($item, 'pic_prompt');
if (!empty($picPrompt)) {
$mergedItem['pic_prompt'] = trim((string)$picPrompt);
}
// 继承现有项的 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')),
];
// 如果有 pic_prompt,添加到合并项中
$picPrompt = getProp($item, 'pic_prompt');
if (!empty($picPrompt)) {
$mergedItem['pic_prompt'] = trim((string)$picPrompt);
}
// 保留生成项的音色字段
$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', 'pic_prompt']);
}
}
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;
}
// 比较 description 是否变化
if (trim((string)getProp($existingItem, 'description')) !== trim((string)getProp($generatedItem, 'description'))) {
return true;
}
// 比较 pic_prompt 是否变化
return trim((string)getProp($existingItem, 'pic_prompt')) !== trim((string)getProp($generatedItem, 'pic_prompt'));
}
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;
// 优先使用 pic_prompt,如果没有则使用 description
$pic_prompt = getProp($role, 'pic_prompt');
$description = !empty($pic_prompt) ? trim((string)$pic_prompt) : getProp($role, 'description');
// if ($art_style) $description = "美术风格:\n{$art_style}\n主体描述:{$description}\n图片要求:图片侧重点是人物,不要出现场景或背景,并且只能生成一个人物";
if ($art_style) $description = "主体描述:{$description}\n图片要求:图片侧重点是人物,不要出现场景或背景,并且只能生成一个人物";
// 参考图地址
$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] : []
'ref_img_urls' => []
];
$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');
// 优先使用 pic_prompt,如果没有则使用 description
$pic_prompt = getProp($scene, 'pic_prompt');
$description = !empty($pic_prompt) ? trim((string)$pic_prompt) : getProp($scene, 'description');
if ($art_style) $description = "场景描述:{$description}\n图片要求: 图片侧重点是场景,生成的是场景环境图片,不要出现人物(此为第一优先要求)";
// 参考图地址
$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] : [],
'ref_img_urls' => []
];
$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;
}
// 生成分镜主体、场景和道具图片
private function batchSetEpisodeRolesAndScenesImg($anime_id, $episode_number, $roles, $scenes, $props = [], $ref_products=[], $art_style_type='', $is_force=false) {
$character_prefix = '';
$scene_prefix = '';
$prop_prefix = '';
if ($art_style_type) {
$art_style_arr = $this->getArtStyleShortPromptByInput($art_style_type);
$character_prefix = isset($art_style_arr['character_prefix']) ? $art_style_arr['character_prefix'] : '';
$scene_prefix = isset($art_style_arr['scene_prefix']) ? $art_style_arr['scene_prefix'] : '';
$prop_prefix = isset($art_style_arr['scene_prefix']) ? $art_style_arr['scene_prefix'] : ''; // 道具使用场景的前缀
}
$update_flag = false;
// 获取上一集的roles、scenes和props数据
$prev_roles = [];
$prev_scenes = [];
$prev_props = [];
if ($episode_number > 1) {
$prev_episode = DB::table('mp_anime_episodes')
->where('anime_id', $anime_id)
->where('episode_number', $episode_number - 1)
->where('is_default', 1)
->first();
if ($prev_episode) {
$prev_roles = json_decode(getProp($prev_episode, 'roles'), true) ?: [];
$prev_scenes = json_decode(getProp($prev_episode, 'scenes'), true) ?: [];
$prev_props = json_decode(getProp($prev_episode, 'props'), true) ?: [];
dLog('deepseek')->info("获取上一集数据成功", [
'anime_id' => $anime_id,
'prev_episode_number' => $episode_number - 1,
'prev_roles_count' => count($prev_roles),
'prev_scenes_count' => count($prev_scenes),
'prev_props_count' => count($prev_props)
]);
}
}
// 处理角色图片生成
foreach ($roles as &$role) {
$role_name = getProp($role, 'role');
if ($role_name == '旁白') continue;
// 如果参考资产里有该角色,则直接使用该图片,不继续生成
if (isset($ref_products[$role_name])) {
$role['url'] = isset($ref_products[$role_name]['url']) ? $ref_products[$role_name]['url'] : '';
if ($role['url']) continue;
}
$role_description = getProp($role, 'description');
$role_pic_prompt = getProp($role, 'pic_prompt');
$role_url = getProp($role, 'url');
// 检查是否可以从上一集继承
$inherited = false;
if (!$is_force && !empty($prev_roles)) {
foreach ($prev_roles as $prev_role) {
$prev_role_name = getProp($prev_role, 'role');
$prev_description = getProp($prev_role, 'description');
$prev_pic_prompt = getProp($prev_role, 'pic_prompt');
$prev_url = getProp($prev_role, 'url');
$prev_task_id = getProp($prev_role, 'task_id');
$prev_task_status = getProp($prev_role, 'task_status');
// 如果role、description、pic_prompt都一致,且上一集已有url,则继承
if ($role_name == $prev_role_name
&& $role_description == $prev_description
&& $role_pic_prompt == $prev_pic_prompt
&& !empty($prev_url)) {
$role['task_id'] = $prev_task_id;
$role['task_status'] = $prev_task_status;
$role['url'] = $prev_url;
$inherited = true;
dLog('deepseek')->info("角色({$role_name})从上一集继承图片数据", [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'task_id' => $prev_task_id,
'url' => $prev_url
]);
break;
}
}
}
// 如果已继承或已有url,跳过生成
if ($inherited || (!$is_force && $role_url)) {
continue;
}
// 优先使用 pic_prompt,如果没有则使用 description
$pic_prompt = getProp($role, 'pic_prompt');
$description = !empty($pic_prompt) ? trim((string)$pic_prompt) : getProp($role, 'description');
$description = "{$character_prefix}。\n主体描述:{$description}\n超高清,高细节,主体突出,构图合理,画面干净,无场景或背景,单人物,无文字,无水印,无字幕,电影级光影,视觉焦点明确";
$update_flag = true;
// 调用AIImageGenerationService的生成图片任务接口
try {
$params = [
'prompt' => $description,
'ref_img_urls' => []
];
$task = $this->aiImageGenerationService->createImageGenerationTask($params);
$task_id = $task->id;
if (!$task_id) {
dLog('deepseek')->error("角色({$role_name})生成图片失败");
continue; // 不中断,继续处理其他角色
}
$role['task_id'] = $task_id;
$role['task_status'] = 'processing';
dLog('deepseek')->info("角色({$role_name})创建图片生成任务成功", [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'task_id' => $task_id
]);
} catch (\Exception $e) {
dLog('deepseek')->error("角色({$role_name})创建图片生成任务失败: " . $e->getMessage());
// 不抛出异常,继续处理其他角色
}
}
// 处理场景图片生成
foreach ($scenes as &$scene) {
$scene_name = getProp($scene, 'scene');
$scene_description = getProp($scene, 'description');
$scene_pic_prompt = getProp($scene, 'pic_prompt');
$scene_url = getProp($scene, 'url');
// 如果参考资产里有该场景,则直接使用该图片,不继续生成
if (isset($ref_products[$scene_name])) {
$scene['url'] = isset($ref_products[$scene_name]['url']) ? $ref_products[$scene_name]['url'] : '';
if ($scene['url']) continue;
}
// 检查是否可以从上一集继承
$inherited = false;
if (!$is_force && !empty($prev_scenes)) {
foreach ($prev_scenes as $prev_scene) {
$prev_scene_name = getProp($prev_scene, 'scene');
$prev_description = getProp($prev_scene, 'description');
$prev_pic_prompt = getProp($prev_scene, 'pic_prompt');
$prev_url = getProp($prev_scene, 'url');
$prev_task_id = getProp($prev_scene, 'task_id');
$prev_task_status = getProp($prev_scene, 'task_status');
// 如果scene、description、pic_prompt都一致,且上一集已有url,则继承
if ($scene_name == $prev_scene_name
&& $scene_description == $prev_description
&& $scene_pic_prompt == $prev_pic_prompt
&& !empty($prev_url)) {
$scene['task_id'] = $prev_task_id;
$scene['task_status'] = $prev_task_status;
$scene['url'] = $prev_url;
$inherited = true;
dLog('deepseek')->info("场景({$scene_name})从上一集继承图片数据", [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'task_id' => $prev_task_id,
'url' => $prev_url
]);
break;
}
}
}
// 如果已继承或已有url,跳过生成
if ($inherited || (!$is_force && $scene_url)) {
continue;
}
// 优先使用 pic_prompt,如果没有则使用 description
$pic_prompt = getProp($scene, 'pic_prompt');
$description = !empty($pic_prompt) ? trim((string)$pic_prompt) : getProp($scene, 'description');
$description = "{$scene_prefix}。\n场景描述:{$description}\n无人物,超高清,高细节,主体突出,构图合理,画面干净,无文字,无水印,无字幕,电影级光影,视觉焦点明确";
$update_flag = true;
// 调用AIImageGenerationService的生成图片任务接口
try {
$params = [
'prompt' => $description,
'ref_img_urls' => []
];
$task = $this->aiImageGenerationService->createImageGenerationTask($params);
$task_id = $task->id;
if (!$task_id) {
dLog('deepseek')->error("场景({$scene_name})生成图片失败");
continue; // 不中断,继续处理其他场景
}
$scene['task_id'] = $task_id;
$scene['task_status'] = 'processing';
dLog('deepseek')->info("场景({$scene_name})创建图片生成任务成功", [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'task_id' => $task_id
]);
} catch (\Exception $e) {
dLog('deepseek')->error("场景({$scene_name})创建图片生成任务失败: " . $e->getMessage());
// 不抛出异常,继续处理其他场景
}
}
// 处理道具图片生成(逻辑与场景一致)
foreach ($props as &$prop) {
$prop_name = getProp($prop, 'prop');
$prop_description = getProp($prop, 'description');
$prop_pic_prompt = getProp($prop, 'pic_prompt');
$prop_url = getProp($prop, 'url');
// 如果参考资产里有该道具,则直接使用该图片,不继续生成
if (isset($ref_products[$prop_name])) {
$prop['url'] = isset($ref_products[$prop_name]['url']) ? $ref_products[$prop_name]['url'] : '';
if ($prop['url']) continue;
}
// 检查是否可以从上一集继承
$inherited = false;
if (!$is_force && !empty($prev_props)) {
foreach ($prev_props as $prev_prop) {
$prev_prop_name = getProp($prev_prop, 'prop');
$prev_description = getProp($prev_prop, 'description');
$prev_pic_prompt = getProp($prev_prop, 'pic_prompt');
$prev_url = getProp($prev_prop, 'url');
$prev_task_id = getProp($prev_prop, 'task_id');
$prev_task_status = getProp($prev_prop, 'task_status');
// 如果prop、description、pic_prompt都一致,且上一集已有url,则继承
if ($prop_name == $prev_prop_name
&& $prop_description == $prev_description
&& $prop_pic_prompt == $prev_pic_prompt
&& !empty($prev_url)) {
$prop['task_id'] = $prev_task_id;
$prop['task_status'] = $prev_task_status;
$prop['url'] = $prev_url;
$inherited = true;
dLog('deepseek')->info("道具({$prop_name})从上一集继承图片数据", [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'task_id' => $prev_task_id,
'url' => $prev_url
]);
break;
}
}
}
// 如果已继承或已有url,跳过生成
if ($inherited || (!$is_force && $prop_url)) {
continue;
}
// 优先使用 pic_prompt,如果没有则使用 description
$pic_prompt = getProp($prop, 'pic_prompt');
$description = !empty($pic_prompt) ? trim((string)$pic_prompt) : getProp($prop, 'description');
$description = "{$prop_prefix}。\n道具描述:{$description}\n无人物,超高清,高细节,主体突出,构图合理,画面干净,无文字,无水印,无字幕,电影级光影,视觉焦点明确";
$update_flag = true;
// 调用AIImageGenerationService的生成图片任务接口
try {
$params = [
'prompt' => $description,
'ref_img_urls' => []
];
$task = $this->aiImageGenerationService->createImageGenerationTask($params);
$task_id = $task->id;
if (!$task_id) {
dLog('deepseek')->error("道具({$prop_name})生成图片失败");
continue; // 不中断,继续处理其他道具
}
$prop['task_id'] = $task_id;
$prop['task_status'] = 'processing';
dLog('deepseek')->info("道具({$prop_name})创建图片生成任务成功", [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'task_id' => $task_id
]);
} catch (\Exception $e) {
dLog('deepseek')->error("道具({$prop_name})创建图片生成任务失败: " . $e->getMessage());
// 不抛出异常,继续处理其他道具
}
}
if (!$update_flag) return ['roles' => $roles, 'scenes' => $scenes, 'props' => $props];
// 将处理完毕的roles、scenes和props与全局(mp_animes)数据对比并合并
$this->syncRolesAndScenesToGlobal($anime_id, $roles, $scenes, $props);
// // 保存剧集的角色和场景信息
// $boolen = DB::table('mp_anime_episodes')->where('id', $episode_id)->update([
// 'roles' => json_encode($roles, 256),
// '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) {
// dLog('deepseek')->error('保存剧集角色和场景信息失败', ['episode_id' => $episode_id]);
// Utils::throwError('20003:保存剧集角色和场景信息失败');
// }
dLog('deepseek')->info('剧集角色、场景和道具图片任务创建完成', [
// 'episode_id' => $episode_id,
'roles_count' => count($roles),
'scenes_count' => count($scenes),
'props_count' => count($props)
]);
return ['roles' => $roles, 'scenes' => $scenes, 'props' => $props];
}
/**
* 将剧集的roles、scenes和props同步到全局(mp_animes)
* 如果全局中不存在相同的role/scene/prop(名称、description、pic_prompt三项一致),则添加到全局
*
* @param int $anime_id 动漫对话ID
* @param array $episode_roles 剧集角色数据
* @param array $episode_scenes 剧集场景数据
* @param array $episode_props 剧集道具数据
* @return bool
*/
private function syncRolesAndScenesToGlobal($anime_id, $episode_roles, $episode_scenes, $episode_props = []) {
// 获取全局数据
$anime = DB::table('mp_animes')->where('id', $anime_id)->where('is_deleted', 0)->first();
if (!$anime) {
dLog('deepseek')->warning('动漫对话不存在', ['anime_id' => $anime_id]);
return false;
}
$global_roles = json_decode(getProp($anime, 'roles'), true) ?: [];
$global_scenes = json_decode(getProp($anime, 'scenes'), true) ?: [];
$global_props = json_decode(getProp($anime, 'props'), true) ?: [];
$roles_added = false;
$roles_updated = false;
$scenes_added = false;
$props_added = false;
// 处理角色同步
foreach ($episode_roles as $episode_role) {
$episode_role_name = getProp($episode_role, 'role');
$episode_description = getProp($episode_role, 'description');
$episode_pic_prompt = getProp($episode_role, 'pic_prompt');
$episode_voice_prompt = getProp($episode_role, 'voice_prompt');
// 跳过旁白
if ($episode_role_name == '旁白') {
continue;
}
// 检查全局中是否已存在相同的角色
$exists = false;
$global_role_index = -1;
foreach ($global_roles as $index => $global_role) {
$global_role_name = getProp($global_role, 'role');
$global_description = getProp($global_role, 'description');
$global_pic_prompt = getProp($global_role, 'pic_prompt');
// 如果role、description、pic_prompt三项都一致,则认为已存在
if ($episode_role_name == $global_role_name
&& $episode_description == $global_description
&& $episode_pic_prompt == $global_pic_prompt) {
$exists = true;
$global_role_index = $index;
break;
}
}
// 如果不存在,则添加到全局
if (!$exists) {
$global_roles[] = $episode_role;
$roles_added = true;
dLog('deepseek')->info("角色({$episode_role_name})添加到全局", [
'anime_id' => $anime_id,
'role' => $episode_role_name
]);
} else {
// 如果存在,且剧集有voice_prompt字段,将voice_prompt更新到全局
if (!empty($episode_voice_prompt)) {
$global_voice_prompt = getProp($global_roles[$global_role_index], 'voice_prompt');
// 如果全局没有voice_prompt或与剧集不一致,则更新
if ($global_voice_prompt !== $episode_voice_prompt) {
$global_roles[$global_role_index]['voice_prompt'] = $episode_voice_prompt;
$roles_updated = true;
dLog('deepseek')->info("角色({$episode_role_name})的voice_prompt更新到全局", [
'anime_id' => $anime_id,
'role' => $episode_role_name,
'voice_prompt' => $episode_voice_prompt
]);
}
}
}
}
// 处理场景同步
foreach ($episode_scenes as $episode_scene) {
$episode_scene_name = getProp($episode_scene, 'scene');
$episode_description = getProp($episode_scene, 'description');
$episode_pic_prompt = getProp($episode_scene, 'pic_prompt');
// 检查全局中是否已存在相同的场景
$exists = false;
foreach ($global_scenes as $global_scene) {
$global_scene_name = getProp($global_scene, 'scene');
$global_description = getProp($global_scene, 'description');
$global_pic_prompt = getProp($global_scene, 'pic_prompt');
// 如果scene、description、pic_prompt三项都一致,则认为已存在
if ($episode_scene_name == $global_scene_name
&& $episode_description == $global_description
&& $episode_pic_prompt == $global_pic_prompt) {
$exists = true;
break;
}
}
// 如果不存在,则添加到全局
if (!$exists) {
$global_scenes[] = $episode_scene;
$scenes_added = true;
dLog('deepseek')->info("场景({$episode_scene_name})添加到全局", [
'anime_id' => $anime_id,
'scene' => $episode_scene_name
]);
}
}
// 处理道具同步(逻辑与场景一致)
foreach ($episode_props as $episode_prop) {
$episode_prop_name = getProp($episode_prop, 'prop');
$episode_description = getProp($episode_prop, 'description');
$episode_pic_prompt = getProp($episode_prop, 'pic_prompt');
// 检查全局中是否已存在相同的道具
$exists = false;
foreach ($global_props as $global_prop) {
$global_prop_name = getProp($global_prop, 'prop');
$global_description = getProp($global_prop, 'description');
$global_pic_prompt = getProp($global_prop, 'pic_prompt');
// 如果prop、description、pic_prompt三项都一致,则认为已存在
if ($episode_prop_name == $global_prop_name
&& $episode_description == $global_description
&& $episode_pic_prompt == $global_pic_prompt) {
$exists = true;
break;
}
}
// 如果不存在,则添加到全局
if (!$exists) {
$global_props[] = $episode_prop;
$props_added = true;
dLog('deepseek')->info("道具({$episode_prop_name})添加到全局", [
'anime_id' => $anime_id,
'prop' => $episode_prop_name
]);
}
}
// 如果有新增或更新,则更新全局数据
if ($roles_added || $roles_updated || $scenes_added || $props_added) {
$update_data = ['updated_at' => date('Y-m-d H:i:s')];
if ($roles_added || $roles_updated) {
$update_data['roles'] = json_encode($global_roles, 256);
}
if ($scenes_added) {
$update_data['scenes'] = json_encode($global_scenes, 256);
}
if ($props_added) {
$update_data['props'] = json_encode($global_props, 256);
}
DB::table('mp_animes')->where('id', $anime_id)->update($update_data);
dLog('deepseek')->info('剧集数据同步到全局完成', [
'anime_id' => $anime_id,
'roles_added' => $roles_added,
'roles_updated' => $roles_updated,
'scenes_added' => $scenes_added,
'props_added' => $props_added,
'global_roles_count' => count($global_roles),
'global_scenes_count' => count($global_scenes),
'global_props_count' => count($global_props)
]);
}
return true;
}
/**
* 将全局角色和场景的task_id、task_status和url继承给指定分集
*
* @param int $anime_id 动漫对话ID
* @param int $episode_id 分集ID
* @return bool
*/
private function inheritGlobalImagesToEpisodes($anime_id, $episode_id) {
// 获取全局角色和场景数据
$anime = DB::table('mp_animes')->where('id', $anime_id)->where('is_deleted', 0)->first();
if (!$anime) {
dLog('deepseek')->warning('动漫对话不存在', ['anime_id' => $anime_id]);
return false;
}
$global_roles = json_decode(getProp($anime, 'roles'), true) ?: [];
$global_scenes = json_decode(getProp($anime, 'scenes'), true) ?: [];
// 获取指定的分集
$episode = DB::table('mp_anime_episodes')->where('id', $episode_id)->where('anime_id', $anime_id)->first();
if (!$episode) {
dLog('deepseek')->warning('分集不存在', ['anime_id' => $anime_id, 'episode_id' => $episode_id]);
return false;
}
$episode_roles = json_decode(getProp($episode, 'roles'), true) ?: [];
$episode_scenes = json_decode(getProp($episode, 'scenes'), true) ?: [];
$roles_updated = false;
$scenes_updated = false;
// 继承角色的task_id、task_status和url
foreach ($episode_roles as &$episode_role) {
$episode_role_name = getProp($episode_role, 'role');
$episode_description = getProp($episode_role, 'description');
$episode_pic_prompt = getProp($episode_role, 'pic_prompt');
$episode_url = getProp($episode_role, 'url');
// 跳过旁白角色
if ($episode_role_name == '旁白') {
continue;
}
// 如果剧集中已有URL,跳过
if (!empty($episode_url)) {
continue;
}
// 遍历全局角色数据,查找匹配的角色
foreach ($global_roles as $global_role) {
$global_role_name = getProp($global_role, 'role');
$global_description = getProp($global_role, 'description');
$global_pic_prompt = getProp($global_role, 'pic_prompt');
$global_url = getProp($global_role, 'url');
$global_task_id = getProp($global_role, 'task_id');
$global_task_status = getProp($global_role, 'task_status');
// 满足条件:1.角色名相同 2.描述相同 3.pic_prompt相同 4.剧集中URL为空
if ($episode_role_name == $global_role_name
&& $episode_description == $global_description
&& $episode_pic_prompt == $global_pic_prompt) {
// 继承 task_id、task_status 和 url
if (!empty($global_task_id)) {
$episode_role['task_id'] = $global_task_id;
$episode_role['task_status'] = $global_task_status;
$roles_updated = true;
dLog('deepseek')->info('剧集角色继承全局task_id', [
'episode_id' => $episode_id,
'role' => $episode_role_name,
'task_id' => $global_task_id,
'task_status' => $global_task_status
]);
}
if (!empty($global_url)) {
$episode_role['url'] = $global_url;
$roles_updated = true;
dLog('deepseek')->info('剧集角色继承全局url', [
'episode_id' => $episode_id,
'role' => $episode_role_name,
'url' => $global_url
]);
}
break;
}
}
}
// 继承场景的task_id、task_status和url
foreach ($episode_scenes as &$episode_scene) {
$episode_scene_name = getProp($episode_scene, 'scene');
$episode_description = getProp($episode_scene, 'description');
$episode_pic_prompt = getProp($episode_scene, 'pic_prompt');
$episode_url = getProp($episode_scene, 'url');
// 如果剧集中已有URL,跳过
if (!empty($episode_url)) {
continue;
}
// 遍历全局场景数据,查找匹配的场景
foreach ($global_scenes as $global_scene) {
$global_scene_name = getProp($global_scene, 'scene');
$global_description = getProp($global_scene, 'description');
$global_pic_prompt = getProp($global_scene, 'pic_prompt');
$global_url = getProp($global_scene, 'url');
$global_task_id = getProp($global_scene, 'task_id');
$global_task_status = getProp($global_scene, 'task_status');
// 满足条件:1.场景名相同 2.描述相同 3.pic_prompt相同 4.剧集中URL为空
if ($episode_scene_name == $global_scene_name
&& $episode_description == $global_description
&& $episode_pic_prompt == $global_pic_prompt) {
// 继承 task_id、task_status 和 url
if (!empty($global_task_id)) {
$episode_scene['task_id'] = $global_task_id;
$episode_scene['task_status'] = $global_task_status;
$scenes_updated = true;
dLog('deepseek')->info('剧集场景继承全局task_id', [
'episode_id' => $episode_id,
'scene' => $episode_scene_name,
'task_id' => $global_task_id,
'task_status' => $global_task_status
]);
}
if (!empty($global_url)) {
$episode_scene['url'] = $global_url;
$scenes_updated = true;
dLog('deepseek')->info('剧集场景继承全局url', [
'episode_id' => $episode_id,
'scene' => $episode_scene_name,
'url' => $global_url
]);
}
break;
}
}
}
// 如果有更新,则保存到数据库
if ($roles_updated || $scenes_updated) {
$update_data = ['updated_at' => date('Y-m-d H:i:s')];
if ($roles_updated) {
$update_data['roles'] = json_encode($episode_roles, 256);
}
if ($scenes_updated) {
$update_data['scenes'] = json_encode($episode_scenes, 256);
}
DB::table('mp_anime_episodes')->where('id', $episode_id)->update($update_data);
dLog('deepseek')->info('剧集继承全局图片数据完成', [
'episode_id' => $episode_id,
'roles_updated' => $roles_updated,
'scenes_updated' => $scenes_updated
]);
}
return true;
}
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 if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$chatResult = $this->gpt54ChatOnly($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) {
$post_data['max_tokens'] = 300000;
$client = new Client(['timeout' => 1800, '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
];
}
// 仅调用 GPT-5.4 对话接口(非流式)
private function gpt54ChatOnly($post_data) {
$apiKey = env('GPT_54_API_KEY');
if (empty($apiKey)) {
Utils::throwError('20003:GPT-5.4 API Key未配置');
}
$client = new Client(['timeout' => 1800, 'verify' => false]);
$headers = [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json'
];
// 移除 thinking 参数,GPT-5.4 不支持
if (isset($post_data['thinking'])) {
unset($post_data['thinking']);
}
if (isset($post_data['reasoning_effort'])) {
unset($post_data['reasoning_effort']);
}
$post_data['max_completion_tokens'] = 100000;
// 确保 stream 为 false
$post_data['stream'] = false;
// 备用中转站地址: https://token.ithinkai.cn/v1/chat/completions
// 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
$result = $client->post('https://ai-api.kkidc.com/v1/chat/completions', [
'json' => $post_data,
'headers' => $headers
]);
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
dLog('deepseek')->info('GPT-5.4请求完成', ['response' => $response_arr]);
$fullContent = '';
$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'] : '';
}
return [
'fullContent' => $fullContent,
'fullReasoningContent' => '', // GPT-5.4 不支持思考链
'usage' => $usage
];
}
private function splitContent($content) {
// 根据章节标题来拆分章节内容,返回包含标题和内容的二维数组
$chapters = [];
// 匹配章节标题格式:
// 1. 标题独占一行
// 2. 格式支持:
// - **第一集**
// - 第一集:
// - ###第1章 重生
// - ##第2章 相救
// - 第三章 共处一室
// 3. 支持阿拉伯数字(0-9)和中文数字(一二两三四五六七八九十百千万)
// 4. 支持章节关键字:章、节、集、幕、场、回、话
// 5. "第"字与数字、数字与章节关键字之间可能有空格
// 6. 前后可能有#、*、·等修饰符号,后面可能有冒号、空格、标题文字等
$pattern = '/^[#*·\s\-_=]*第\s*([一二两三四五六七八九十百千万0-9]+)\s*([章节集幕场回话]).*$/mu';
// 先用正则找出所有章节标题的位置
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
$titleCount = count($matches[0]);
for ($i = 0; $i < $titleCount; $i++) {
// 获取当前章节标题
$titleLine = trim($matches[0][$i][0]);
// 去除标题开头的特殊符号
$cleanTitle = preg_replace('/^[#*·\-_=\s]+/', '', $titleLine);
// 去除标题结尾的冒号
$cleanTitle = preg_replace('/[#*·::\s]+$/', '', $cleanTitle);
$cleanTitle = trim($cleanTitle);
// 获取当前标题的结束位置
$titleEndPos = $matches[0][$i][1] + strlen($matches[0][$i][0]);
// 获取下一个章节标题的开始位置(如果存在)
$nextTitlePos = ($i < $titleCount - 1) ? $matches[0][$i + 1][1] : strlen($content);
// 提取章节内容(从标题结束到下一个标题开始)
$chapterContent = substr($content, $titleEndPos, $nextTitlePos - $titleEndPos);
// 清理内容:去除前后空白和分隔线
$chapterContent = trim($chapterContent);
$chapterContent = preg_replace('/^[\-\s]+/u', '', $chapterContent);
$chapterContent = preg_replace('/[\-\s]+$/u', '', $chapterContent);
if (!empty($cleanTitle) && !empty($chapterContent)) {
$chapters[] = [
'title' => $cleanTitle,
'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 = '';
$contents = DB::table('mp_script_episode_group')->where('script_id', $script_id)->pluck('content')->toArray();
if ($contents) {
$content = implode(PHP_EOL, $contents);
}else {
$content = DB::table('mp_scripts')->where('id', $script_id)->value('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' => 1800, '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 if (in_array($model, $this->gpt_text_models)) {
// GPT-5.4 模型使用 TokenRouter API
$chatResult = $this->gpt54ChatOnly($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'];
}else if (isset($params['max_completion_tokens'])) {
$requestData['max_completion_tokens'] = $params['max_completion_tokens'];
}else {
$requestData['max_completion_tokens'] = 100000;
}
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' => 1800]);
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
{
// 确保参数为数组
$merged_items = is_array($merged_items) ? $merged_items : [];
$global_items = is_array($global_items) ? $global_items : [];
// 构建全局资源映射表,用于快速查找
$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:名称匹配但描述或 pic_prompt 不一致
$global_description = trim((string)getProp($global_map[$itemKey], 'description'));
$current_description = trim((string)$item_description);
$global_pic_prompt = trim((string)getProp($global_map[$itemKey], 'pic_prompt'));
$current_pic_prompt = trim((string)getProp($item, 'pic_prompt'));
// 如果 description 或 pic_prompt 有变化,需要重新生成
if ($global_description !== $current_description || $global_pic_prompt !== $current_pic_prompt) {
$need_create_task = true;
}
}
// 如果需要创建任务,调用图片生成服务
if ($need_create_task) {
try {
// 优先使用 pic_prompt,如果没有则使用 description
$pic_prompt = getProp($item, 'pic_prompt');
$description = !empty($pic_prompt) ? trim((string)$pic_prompt) : $item_description;
// 添加美术风格前缀
if ($art_style) {
$prefix = $nameKey === 'role' ? '主体描述' : '场景描述';
// $description = "美术风格:\n{$art_style}\n\n{$prefix}:{$description}";
$description = "{$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;
}
/**
* 同步新出现的主体和场景到全局
*
* @param int $anime_id 动漫ID
* @param array $merged_roles 合并后的角色列表
* @param array $merged_scenes 合并后的场景列表
* @param array $global_roles 全局角色列表
* @param array $global_scenes 全局场景列表
* @return void
*/
private function syncNewResourcesToGlobal(
int $anime_id,
array $merged_roles,
array $merged_scenes,
array $global_roles,
array $global_scenes
): void {
// 构建全局角色映射表
$global_role_map = [];
foreach ($global_roles as $role) {
$roleKey = $this->normalizeEpisodeResourceKey(getProp($role, 'role'));
if ($roleKey !== '') {
$global_role_map[$roleKey] = $role;
}
}
// 构建全局场景映射表
$global_scene_map = [];
foreach ($global_scenes as $scene) {
$sceneKey = $this->normalizeEpisodeResourceKey(getProp($scene, 'scene'));
if ($sceneKey !== '') {
$global_scene_map[$sceneKey] = $scene;
}
}
$need_update = false;
$new_global_roles = $global_roles;
$new_global_scenes = $global_scenes;
// 检查并添加新角色到全局
foreach ($merged_roles as $role) {
$role_name = getProp($role, 'role');
// 跳过旁白
if ($role_name === '旁白') {
continue;
}
$roleKey = $this->normalizeEpisodeResourceKey($role_name);
if ($roleKey === '') {
continue;
}
// 如果全局中不存在该角色,添加到全局
if (!isset($global_role_map[$roleKey])) {
$new_global_roles[] = [
'role' => $role_name,
'description' => getProp($role, 'description', ''),
'pic_prompt' => getProp($role, 'pic_prompt', ''),
'voice_name' => getProp($role, 'voice_name', ''),
'voice_type' => getProp($role, 'voice_type', ''),
'voice_audio_url' => getProp($role, 'voice_audio_url', ''),
'url' => getProp($role, 'url', ''),
'task_id' => getProp($role, 'task_id', ''),
'task_status' => getProp($role, 'task_status', ''),
];
$global_role_map[$roleKey] = true; // 标记已添加
$need_update = true;
dLog('deepseek')->info("新角色同步到全局", [
'anime_id' => $anime_id,
'role' => $role_name,
'task_id' => getProp($role, 'task_id', ''),
]);
}
// else {
// // 如果角色已存在,但描述、pic_prompt或图片信息有更新,则更新全局数据
// $global_role = $global_role_map[$roleKey];
// if (is_array($global_role)) {
// $updated = false;
// $update_fields = [];
// // 检查描述是否更新
// $current_description = trim((string)getProp($role, 'description'));
// $global_description = trim((string)getProp($global_role, 'description'));
// if ($current_description !== '' && $current_description !== $global_description) {
// $updated = true;
// $update_fields[] = 'description';
// }
// // 检查pic_prompt是否更新
// $current_pic_prompt = trim((string)getProp($role, 'pic_prompt'));
// $global_pic_prompt = trim((string)getProp($global_role, 'pic_prompt'));
// if ($current_pic_prompt !== '' && $current_pic_prompt !== $global_pic_prompt) {
// $updated = true;
// $update_fields[] = 'pic_prompt';
// }
// // 检查图片任务信息是否更新
// $current_task_id = getProp($role, 'task_id');
// $global_task_id = getProp($global_role, 'task_id');
// if ($current_task_id && $current_task_id !== $global_task_id) {
// $updated = true;
// $update_fields[] = 'task_id';
// }
// // 如果有更新,同步到全局
// if ($updated) {
// foreach ($new_global_roles as &$global_role_item) {
// $global_role_key = $this->normalizeEpisodeResourceKey(getProp($global_role_item, 'role'));
// if ($global_role_key === $roleKey) {
// if (in_array('description', $update_fields)) {
// $global_role_item['description'] = $current_description;
// }
// if (in_array('pic_prompt', $update_fields)) {
// $global_role_item['pic_prompt'] = $current_pic_prompt;
// }
// if (in_array('task_id', $update_fields)) {
// $global_role_item['task_id'] = $current_task_id;
// $global_role_item['task_status'] = getProp($role, 'task_status', '');
// $global_role_item['url'] = getProp($role, 'url', '');
// }
// break;
// }
// }
// $need_update = true;
// dLog('deepseek')->info("角色信息更新到全局", [
// 'anime_id' => $anime_id,
// 'role' => $role_name,
// 'update_fields' => implode(',', $update_fields),
// ]);
// }
// }
// }
}
// 检查并添加新场景到全局
foreach ($merged_scenes as $scene) {
$scene_name = getProp($scene, 'scene');
$sceneKey = $this->normalizeEpisodeResourceKey($scene_name);
if ($sceneKey === '') {
continue;
}
// 如果全局中不存在该场景,添加到全局
if (!isset($global_scene_map[$sceneKey])) {
$new_global_scenes[] = [
'scene' => $scene_name,
'description' => getProp($scene, 'description', ''),
'pic_prompt' => getProp($scene, 'pic_prompt', ''),
'url' => getProp($scene, 'url', ''),
'task_id' => getProp($scene, 'task_id', ''),
'task_status' => getProp($scene, 'task_status', ''),
];
$global_scene_map[$sceneKey] = true; // 标记已添加
$need_update = true;
dLog('deepseek')->info("新场景同步到全局", [
'anime_id' => $anime_id,
'scene' => $scene_name,
'task_id' => getProp($scene, 'task_id', ''),
]);
}
// else {
// // 如果场景已存在,但描述、pic_prompt或图片信息有更新,则更新全局数据
// $global_scene = $global_scene_map[$sceneKey];
// if (is_array($global_scene)) {
// $updated = false;
// $update_fields = [];
// // 检查描述是否更新
// $current_description = trim((string)getProp($scene, 'description'));
// $global_description = trim((string)getProp($global_scene, 'description'));
// if ($current_description !== '' && $current_description !== $global_description) {
// $updated = true;
// $update_fields[] = 'description';
// }
// // 检查pic_prompt是否更新
// $current_pic_prompt = trim((string)getProp($scene, 'pic_prompt'));
// $global_pic_prompt = trim((string)getProp($global_scene, 'pic_prompt'));
// if ($current_pic_prompt !== '' && $current_pic_prompt !== $global_pic_prompt) {
// $updated = true;
// $update_fields[] = 'pic_prompt';
// }
// // 检查图片任务信息是否更新
// $current_task_id = getProp($scene, 'task_id');
// $global_task_id = getProp($global_scene, 'task_id');
// if ($current_task_id && $current_task_id !== $global_task_id) {
// $updated = true;
// $update_fields[] = 'task_id';
// }
// // 如果有更新,同步到全局
// if ($updated) {
// foreach ($new_global_scenes as &$global_scene_item) {
// $global_scene_key = $this->normalizeEpisodeResourceKey(getProp($global_scene_item, 'scene'));
// if ($global_scene_key === $sceneKey) {
// if (in_array('description', $update_fields)) {
// $global_scene_item['description'] = $current_description;
// }
// if (in_array('pic_prompt', $update_fields)) {
// $global_scene_item['pic_prompt'] = $current_pic_prompt;
// }
// if (in_array('task_id', $update_fields)) {
// $global_scene_item['task_id'] = $current_task_id;
// $global_scene_item['task_status'] = getProp($scene, 'task_status', '');
// $global_scene_item['url'] = getProp($scene, 'url', '');
// }
// break;
// }
// }
// $need_update = true;
// dLog('deepseek')->info("场景信息更新到全局", [
// 'anime_id' => $anime_id,
// 'scene' => $scene_name,
// 'update_fields' => implode(',', $update_fields),
// ]);
// }
// }
// }
}
// 如果有新增或更新,更新数据库
if ($need_update) {
$update_data = [
'roles' => json_encode($new_global_roles, 256),
'scenes' => json_encode($new_global_scenes, 256),
'updated_at' => date('Y-m-d H:i:s'),
];
$result = DB::table('mp_animes')->where('id', $anime_id)->update($update_data);
if ($result !== false) {
dLog('deepseek')->info("全局角色和场景更新成功", [
'anime_id' => $anime_id,
'roles_count' => count($new_global_roles),
'scenes_count' => count($new_global_scenes),
]);
} else {
dLog('deepseek')->error("全局角色和场景更新失败", ['anime_id' => $anime_id]);
logDB('deepseek', 'error', '全局角色和场景更新失败', ['anime_id' => $anime_id]);
}
}
}
}