aiImageGenerationService = $aiImageGenerationService;
$this->pointsService = $pointsService;
$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;
// Gemini文本模型列表
$this->gemini_text_models = BaseConst::GEMINI_TEXT_MODELS;
// 百度 AI 网关 GLM 文本模型列表
$this->baidu_glm_text_models = BaseConst::BAIDU_GLM_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集: 第二集的标题
场景描述:地点、时间、场景
运镜:固定近距离
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话"
];
}
/**
* 通用文生文方法 - 支持多模型流式输出、图片输入;视频输入仅 Gemini 模型支持
*
* @param array $data 请求参数
* @return \Generator 返回生成器,用于流式输出
*/
public function generateText($data) {
// 获取请求参数
$model = getProp($data, 'model', 'deepseek-v4-flash'); // 默认使用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
$videos = getProp($data, 'videos', []); // Gemini 支持视频文件
$videoUrls = getProp($data, 'video_urls', []); // Gemini 支持视频URL
$isGeminiModel = in_array($model, $this->gemini_text_models);
if ((!empty($videos) || !empty($videoUrls)) && !$isGeminiModel) {
Utils::throwError('20003:仅Gemini模型支持视频输入');
}
// 计费预检:纯文生文按 normal 档(3);有参考图/视频输入按模型固定价格(图生文)
$uid = Site::getUid();
$chargeModality = '';
$chargePoints = 0;
$hasImageMedia = !empty($images) || !empty($imageUrls)
|| $this->messagesContainMedia($messages, ['image_url', 'image']);
$hasVideoMedia = !empty($videos) || !empty($videoUrls)
|| $this->messagesContainMedia($messages, ['video_url', 'video']);
if ($hasImageMedia || $hasVideoMedia) {
$chargeModality = $hasVideoMedia ? 'video' : 'image';
$chargePoints = $this->pointsService->getImageChatChargePoints($model);
} else {
$chargeModality = 'text';
$chargePoints = $this->pointsService->getChatChargePoints('', 'normal');
}
if ($chargePoints > 0) {
$this->pointsService->checkUserPointsEnough($chargePoints, $uid);
}
// 如果没有提供messages,则根据system_prompt、content和images构建
if (empty($messages)) {
$messages = [];
// 添加系统提示词
if (!empty($systemPrompt)) {
$messages[] = [
'role' => 'system',
'content' => $systemPrompt
];
}
// 构建用户消息内容(支持文本+图片)
if (!empty($prompt) || !empty($images) || !empty($imageUrls) || !empty($videos) || !empty($videoUrls)) {
$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
]
];
}
}
}
// 处理上传的视频文件(仅 Gemini)
if (!empty($videos) && $isGeminiModel) {
if (!is_array($videos)) {
$videos = [$videos];
}
foreach ($videos as $video) {
$inlineData = $this->processMediaToInlineData($video);
if ($inlineData) {
$userMessage['content'][] = [
'type' => 'video',
'inline_data' => $inlineData
];
}
}
}
// 处理视频URL(仅 Gemini)
if (!empty($videoUrls) && $isGeminiModel) {
if (!is_array($videoUrls)) {
$videoUrls = [$videoUrls];
}
foreach ($videoUrls as $url) {
if (!empty($url)) {
$userMessage['content'][] = [
'type' => 'video_url',
'video_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'];
}
// 根据模型类型选择流式实现
$streamGenerator = $this->dispatchStreamByModel($post_data);
if ($chargePoints > 0) {
return $this->wrapGenerateTextCharge($streamGenerator, $uid, $model, $chargePoints, $chargeModality);
}
// 免费场景(0 积分):流式生成成功后仅记录 token 使用明细
return $this->wrapGenerateTextTokenRecord($streamGenerator, $uid, $model);
}
/**
* 流式生成成功后仅记录 token 明细(纯文本免费场景,不扣积分)
*
* @param \Generator $generator
* @param int $uid
* @param string $model
* @return \Generator
*/
private function wrapGenerateTextTokenRecord(\Generator $generator, int $uid, string $model, string $source = 'generateText'): \Generator
{
$recorded = false;
foreach ($generator as $chunk) {
if (!$recorded && isset($chunk['type']) && $chunk['type'] === 'done') {
$this->recordTextTokenOnly($uid, $this->pointsService->getTokensFromUsage(getProp($chunk, 'usage', [])), [
'model' => $model,
'source' => $source,
], $model);
$recorded = true;
}
yield $chunk;
}
}
/**
* 流式生成成功后再按档位/模型规则扣费(文生文 normal 档 / 图生文按模型固定价格)
*
* @param \Generator $generator
* @param int $uid
* @param string $model
* @param int $points
* @param string $modality
* @return \Generator
*/
private function wrapGenerateTextCharge(\Generator $generator, int $uid, string $model, int $points, string $modality): \Generator
{
$charged = false;
foreach ($generator as $chunk) {
if (!$charged && isset($chunk['type']) && $chunk['type'] === 'done') {
$fullContent = (string)getProp($chunk, 'full_content', getProp($chunk, 'answer', ''));
if ($fullContent !== '') {
$this->chargeMultimodal($uid, $model, $points, $modality, getProp($chunk, 'usage', []));
$charged = true;
}
}
yield $chunk;
}
}
/**
* AI 对话(含图片/视频 -> 文本)成功后扣费并记录积分与 token 明细
*
* @param int $uid
* @param string $model
* @param int $points
* @param string $modality
* @param mixed $usage
* @return void
*/
private function chargeMultimodal(int $uid, string $model, int $points, string $modality, $usage): void
{
$result = $this->pointsService->recordChatCharge(
$uid,
$points,
$this->pointsService->getTokensFromUsage($usage),
[
'model' => $model,
'modality' => $modality,
'source' => 'generateText',
],
'',
$model
);
if (empty($result['charged'])) {
throw new \Exception('AI对话计费失败: ' . (string)($result['reason'] ?? 'unknown'));
}
}
/**
* 检查 OpenAI 风格消息中是否包含指定媒体类型
*
* @param array $messages
* @param array $mediaTypes
* @return bool
*/
private function messagesContainMedia(array $messages, array $mediaTypes): bool
{
foreach ($messages as $message) {
$content = getProp($message, 'content', '');
if (!is_array($content)) {
continue;
}
foreach ($content as $part) {
if (is_array($part) && in_array(getProp($part, 'type', ''), $mediaTypes, true)) {
return true;
}
}
}
return false;
}
public function createGenerateText($data) {
$file = getProp($data, 'file');
$episode_num = getProp($data, 'episode_num');
$uid = Site::getUid();
$cpid = Site::getCpid();
if (!$file) {
Utils::throwError('20003:请上传文件');
}
// 提取文件内容
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
// 拆分章节并校验章节总数、标题连续性
if ($episode_num) {
$this->validateGenerateTextChapters($content, (int)$episode_num);
}
// 获取原始文件名(不含扩展名)作为script_name
// 注意:不能用pathinfo()取FILENAME,PHP在部分Linux环境下对中文等
// 多字节文件名会解析为空(PHP Bug #47954 / #30014),这里改为按扩展名
// 字节安全剥离,不依赖服务器locale设置
$originalName = $file->getClientOriginalName();
$extension = $file->getClientOriginalExtension();
$script_name = $extension !== '' && substr($originalName, -strlen($extension) - 1) === '.' . $extension
? substr($originalName, 0, -strlen($extension) - 1)
: $originalName;
if ($script_name === '') {
Utils::throwError('20003:未识别到剧本名,请联系管理员');
}
// 如果同一用户的剧本名存在则提示
if (DB::table('mp_scripts')->where('script_name', $script_name)->where('user_id', $uid)->where('is_deleted', 0)->exists()) {
Utils::throwError('20003:你已上传过该剧本,请修改文件名后上传');
}
// 插入数据到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
];
}
/**
* AI对话调用成功后的积分扣费(返回结果前调用)
*
* 计费失败抛错,由调用方事务回滚,避免“成功但未扣费”。
*
* @param int $uid
* @param int $tokens
* @param array $chargeInfo
* @return void
*/
private function chargeChatSuccess(int $uid, int $tokens, array $chargeInfo, string $chargeType = 'special'): void
{
// api_type 记录真实模型名(缺失时回退 deepseek 大类),保证明细与统计口径准确
$model = (string)getProp($chargeInfo, 'model', '');
$result = $this->pointsService->recordChatCharge(
$uid,
$this->pointsService->getChatChargePoints($model, $chargeType),
$tokens,
$chargeInfo,
'',
$model !== '' ? $model : 'deepseek'
);
if (empty($result['charged'])) {
throw new \Exception('AI对话计费失败: ' . (string)($result['reason'] ?? 'unknown'));
}
}
/**
* 安全获取当前登录用户ID
*
* 命令行/队列等无用户上下文的场景返回 0,由记录方法自行跳过。
*
* @return int
*/
private function getCurrentUid(): int
{
try {
return (int)Site::getUid();
} catch (\Throwable $e) {
return 0;
}
}
/**
* 记录文生文 token 使用明细(仅记录,不扣积分)
*
* 用于不按积分计费的文生文接口;记录失败不影响主流程。
*
* @param int $uid 用户ID(0 时跳过)
* @param int $tokens 消耗 token 数
* @param array $chargeInfo 计费/来源信息(model、source 等)
* @param string $apiType API 类型/模型标识
* @return void
*/
private function recordTextTokenOnly(int $uid, int $tokens, array $chargeInfo, string $apiType = ''): void
{
try {
$this->pointsService->recordTokenOnlyDetail(
$uid,
$tokens,
$chargeInfo,
MpUserPointsDetail::TYPE_CHAT,
$apiType,
''
);
} catch (\Throwable $e) {
dLog('points')->warning('token明细记录异常(不影响主流程): ' . $e->getMessage(), [
'uid' => $uid,
'source' => $chargeInfo['source'] ?? '',
]);
}
}
/**
* 拆分剧本内容并校验章节总数与标题连续性
*
* @param string $content
* @param int $episodeNum
* @return void
*/
private function validateGenerateTextChapters($content, $episodeNum)
{
$chapters = splitContent($content);
if (count($chapters) !== $episodeNum) {
Utils::throwError('20003:章节总数与集数不一致,请检查上传内容');
}
if (empty($chapters)) {
return;
}
// 先尝试从标题正则中解析序号,确认是否从1开始连续递增
$numbers = [];
$unparsedIndex = null;
foreach ($chapters as $index => $chapter) {
$num = $this->parseChapterNumber($chapter['title']);
if ($num === null) {
$unparsedIndex = $index;
break;
}
$numbers[] = $num;
}
if ($unparsedIndex === null) {
$expected = 1;
foreach ($numbers as $index => $num) {
if ($num !== $expected) {
$title = mb_substr(trim((string)$chapters[$index]['title']), 0, 40);
Utils::throwError('20003:章节序号不连续:第' . ($index + 1) . '个章节“' . $title . '”序号为' . $num . ',预期为' . $expected);
}
$expected++;
}
return;
}
// 标题格式无法用正则确认时,交给模型分析
$titles = array_column($chapters, 'title');
$aiResult = $this->validateChapterSequenceByAI($titles);
if ($aiResult['valid'] === false) {
$badIndex = $aiResult['invalid_index'] ?: $unparsedIndex + 1;
if (isset($chapters[$badIndex - 1])) {
$title = mb_substr(trim((string)$chapters[$badIndex - 1]['title']), 0, 40);
Utils::throwError('20003:章节序号不连续:第' . $badIndex . '个章节“' . $title . '”序号异常,请检查');
}
Utils::throwError('20003:章节序号不连续,请检查章节标题格式');
}
if ($aiResult['valid'] === null) {
if (isset($chapters[$unparsedIndex])) {
$title = mb_substr(trim((string)$chapters[$unparsedIndex]['title']), 0, 40);
Utils::throwError('20003:无法确认第' . ($unparsedIndex + 1) . '个章节“' . $title . '”的章节序号,请检查标题格式');
}
Utils::throwError('20003:无法确认章节标题连续性,请检查章节标题格式');
}
}
/**
* 从章节标题中解析序号,支持阿拉伯数字和中文数字
*
* @param string $title
* @return int|null
*/
private function parseChapterNumber($title)
{
if (!preg_match('/第\s*([一二两三四五六七八九十百千万0-9]+)\s*[章节集幕场回话]/u', $title, $match)) {
return null;
}
return $this->convertChineseNumber($match[1]);
}
/**
* 中文数字转阿拉伯数字(支持到千)
*
* @param string $numStr
* @return int
*/
private function convertChineseNumber($numStr)
{
if (preg_match('/^\d+$/', $numStr)) {
return (int)$numStr;
}
$map = [
'零' => 0,
'一' => 1,
'二' => 2,
'两' => 2,
'三' => 3,
'四' => 4,
'五' => 5,
'六' => 6,
'七' => 7,
'八' => 8,
'九' => 9
];
$total = 0;
$section = 0;
$length = mb_strlen($numStr);
for ($i = 0; $i < $length; $i++) {
$char = mb_substr($numStr, $i, 1);
if (isset($map[$char])) {
$section = $map[$char];
} elseif ($char === '十') {
$total += $section === 0 ? 10 : $section * 10;
$section = 0;
} elseif ($char === '百') {
$total += $section === 0 ? 100 : $section * 100;
$section = 0;
} elseif ($char === '千') {
$total += $section === 0 ? 1000 : $section * 1000;
$section = 0;
}
}
return $total + $section;
}
/**
* 用 deepseek-v4-flash 非流式判断章节标题是否从1开始连续编号
*
* @param array $titles
* @return array{valid: bool|null, invalid_index: int|null}
*/
private function validateChapterSequenceByAI($titles)
{
$titleLines = [];
foreach (array_slice($titles, 0, 200) as $index => $title) {
$titleLines[] = ($index + 1) . '. ' . mb_substr(trim((string)$title), 0, 80);
}
$result = [
'valid' => null,
'invalid_index' => null
];
$post_data = [
'model' => 'deepseek-v4-flash',
'messages' => [
[
'role' => 'system',
'content' => '你是剧本章节校验助手。用户会给出章节标题列表,请判断这些标题中的编号是否从第1章/第1集开始连续递增,支持阿拉伯数字和中文数字。只输出 JSON,格式为 {"valid":true};若不连续则输出 {"valid":false,"invalid_index":N},其中 N 是第一个序号异常的章节序号(从1开始),不要输出其他内容。',
],
[
'role' => 'user',
'content' => implode("\n", $titleLines),
],
],
'temperature' => 0,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'stream' => false,
];
try {
$result = $this->chatOnly($post_data, 8);
$content = trim((string)($result['fullContent'] ?? ''));
if (preg_match('/"valid"\s*:\s*(true|false)/i', $content, $match)) {
$result['valid'] = strtolower($match[1]) === 'true';
}
if (preg_match('/"invalid_index"\s*:\s*(\d+)/i', $content, $match)) {
$result['invalid_index'] = (int)$match[1];
}
// 仅记录 token 使用明细(不扣积分):该校验为流程内的额外模型调用
$this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($result['usage'] ?? []), [
'model' => 'deepseek-v4-flash',
'source' => 'validateChapterSequenceByAI',
], 'deepseek-v4-flash');
} catch (\Exception $e) {
dLog('deepseek')->error('章节连续性AI校验失败: ' . $e->getMessage());
}
return $result;
}
/**
* 通用文生文(流式版本)- newGenerateText 的 stream=1 分支
* 支持与 newGenerateText 相同的参数(模板/剧本/JSON输出/图片输入),
* 输出格式与 generateText 一致:yield type=content / reasoning / done
*
* @param array $data 请求参数
* @return \Generator
*/
public function newGenerateTextStream($data, $existingTask = null) {
$model = getProp($data, 'model', 'deepseek-v4-pro');
$messages = getProp($data, 'messages', []);
$systemPrompt = getProp($data, 'system_prompt', '');
$prompt = getProp($data, 'prompt', '');
$images = getProp($data, 'images', []);
$imageUrls = getProp($data, 'image_urls', []);
$responseFormat = getProp($data, 'response_format', 'text');
$templateId = getProp($data, 'template_id', 0);
$script_id = getProp($data, 'script_id', 0);
$sequence = getProp($data, 'sequence', 0);
$generateTaskId = 0;
// 异步 worker 场景直接使用任务记录中的用户,避免命令行下无登录上下文
$uid = $existingTask ? (int)$existingTask->uid : (int)Site::getUid();
if ($script_id > 0 && !$existingTask) {
$runningTask = DB::table('mp_script_generate_tasks')
->where('uid', $uid)
->where('script_id', $script_id)
->orderByDesc('id')
->first();
if ($runningTask && in_array(getProp($runningTask, 'status'), ['pending', 'processing'])) {
Utils::throwError('20003:该剧本资产正在生成中,请稍后重试');
}
}
// 如果提供了script_id,从数据库获取剧本内容作为原文
$originalContent = '';
$hasValidScript = false;
$rawPrompt = $prompt; // 保存用户原始输入,供模板一致性判断
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:剧本内容为空');
}
$hasValidScript = true;
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 ?? '';
if (!empty($templatePrompt)) {
// 模板提示词与用户输入基本一致时只保留一个,避免重复提示词
// 剧本模式下当前 prompt 含原文内容需保留,其余情况以模板内容为准
$trimmedRawPrompt = trim((string)$rawPrompt);
if ($trimmedRawPrompt !== '' && mb_strtolower($trimmedRawPrompt) === mb_strtolower(trim($templatePrompt))) {
if (!$hasValidScript) {
$prompt = $templatePrompt;
}
} elseif (!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}"
]
];
}
}
}
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;
}
}
if (empty($messages)) {
Utils::throwError('20003:请提供有效的对话内容');
}
// 模型兼容性处理
$thinkingMode = getProp($data, 'thinking', 'disabled');
$reasoningEffort = getProp($data, 'reasoning_effort', 'high');
if ($model === 'deepseek-chat') {
$model = 'deepseek-v4-flash';
$thinkingMode = 'disabled';
} elseif ($model === 'deepseek-reasoner') {
$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' => true
];
if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort;
if (isset($data['top_p'])) {
$post_data['top_p'] = $data['top_p'];
}
// response_format=json 时附加 JSON 说明与响应格式(与 newGenerateText 一致)
if ($responseFormat === '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;
}
}
}
if (!$hasJsonKeyword) {
$lastMessageIndex = count($post_data['messages']) - 1;
if ($lastMessageIndex >= 0 && $post_data['messages'][$lastMessageIndex]['role'] === 'user') {
$lastContent = $post_data['messages'][$lastMessageIndex]['content'];
if (is_string($lastContent)) {
$post_data['messages'][$lastMessageIndex]['content'] .= "\n\n请以JSON格式返回结果。";
} elseif (is_array($lastContent)) {
foreach ($post_data['messages'][$lastMessageIndex]['content'] as &$contentPart) {
if ($contentPart['type'] === 'text') {
$contentPart['text'] .= "\n\n请以JSON格式返回结果。";
break;
}
}
}
}
}
$post_data['response_format'] = ['type' => 'json_object'];
}
// 如果提供了script_id,创建剧本资产生成任务
if ($script_id > 0) {
if ($existingTask) {
// 异步 worker:接管已创建的 pending 任务,不再新建
$generateTaskId = (int)$existingTask->id;
} else {
// 同步调用:保持原行为,创建 processing 任务
$generateTaskId = DB::table('mp_script_generate_tasks')->insertGetId([
'uid' => $uid,
'script_id' => $script_id,
'sequence' => $sequence,
'status' => 'processing',
'prompt' => getProp($data, 'prompt', ''),
'started_at' => date('Y-m-d H:i:s'),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
}
}
// 积分余额预检(仅存在有效剧本时按 chat 类型计费,不足直接报错)
if ($hasValidScript) {
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
}
// 根据模型类型选择流式实现
$streamGenerator = $this->dispatchStreamByModel($post_data);
// 流式输出包装:done 时更新任务状态、扣费并保存对话记录
return $this->wrapNewGenerateTextStream($streamGenerator, [
'uid' => $uid,
'model' => $model,
'generate_task_id' => $generateTaskId,
'has_valid_script' => $hasValidScript,
'script_id' => $script_id,
'sequence' => $sequence,
'original_prompt' => getProp($data, 'prompt', ''),
'response_format' => $responseFormat,
]);
}
/**
* 流式生成收尾包装:done 时更新剧本资产生成任务、扣除积分并保存对话记录
*
* @param \Generator $generator
* @param array $ctx
* @return \Generator
*/
private function wrapNewGenerateTextStream(\Generator $generator, array $ctx): \Generator
{
$fullContent = '';
$usage = [];
$finished = false;
try {
foreach ($generator as $chunk) {
if (isset($chunk['type']) && $chunk['type'] === 'content') {
$fullContent .= (string)getProp($chunk, 'content', '');
}
if (isset($chunk['type']) && $chunk['type'] === 'done') {
$fullContent = (string)getProp($chunk, 'full_content', $fullContent);
$usage = getProp($chunk, 'usage', []);
// 流式场景下部分模型/中转站不强制 JSON 输出,兜底提取标准 JSON
if (($ctx['response_format'] ?? 'text') === 'json') {
$fullContent = $this->extractJsonContent($fullContent);
}
$finished = true;
// 保存对话记录并将 records 附加到 done 消息(无论 script_id 是否有值都返回 records 数组)
$chunk['records'] = [];
if (!empty($ctx['script_id'])) {
try {
$now = now();
// 两条对话记录(user/assistant)在同一事务内写入,保证原子性
$chunk['records'] = DB::transaction(function () use ($ctx, $fullContent, $now) {
DB::table('mp_script_records')->insert([
[
'uid' => $ctx['uid'],
'script_id' => $ctx['script_id'],
'sequence' => $ctx['sequence'],
'role' => 'user',
'content' => $ctx['original_prompt'],
'created_at' => $now,
'updated_at' => $now,
],
[
'uid' => $ctx['uid'],
'script_id' => $ctx['script_id'],
'sequence' => $ctx['sequence'],
'role' => 'assistant',
'content' => $fullContent,
'created_at' => $now,
'updated_at' => $now,
],
]);
return DB::table('mp_script_records')
->where('uid', $ctx['uid'])
->where('script_id', $ctx['script_id'])
->where('sequence', $ctx['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();
});
} catch (\Exception $e) {
dLog('deepseek')->error('保存剧本对话记录失败: ' . $e->getMessage());
$chunk['records'] = [];
}
}
}
yield $chunk;
}
if ($finished) {
// 任务状态与扣费在同一事务内:扣费失败则任务状态一并回滚,由外层 catch 置为 failed
DB::transaction(function () use ($ctx, $fullContent, $usage) {
if (!empty($ctx['generate_task_id'])) {
DB::table('mp_script_generate_tasks')->where('id', $ctx['generate_task_id'])->update([
'status' => 'success',
'result' => $fullContent,
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
if ($ctx['has_valid_script'] && $fullContent !== '') {
$this->chargeChatSuccess($ctx['uid'], $this->pointsService->getTokensFromUsage($usage), [
'model' => $ctx['model'],
'script_id' => $ctx['script_id'],
'sequence' => $ctx['sequence'],
'source' => 'newGenerateText',
]);
}
});
// JSON 输出结果的安全校验/修复与观察(事务外执行,不改变 result 与 SSE 推送内容)
if (!empty($ctx['generate_task_id'])) {
$this->guardScriptGenerateJsonResult(
(int)$ctx['generate_task_id'],
(int)($ctx['script_id'] ?? 0),
(int)($ctx['uid'] ?? 0),
(string)$fullContent,
(string)($ctx['response_format'] ?? '')
);
}
}
} catch (\Throwable $e) {
if (!empty($ctx['generate_task_id'])) {
DB::table('mp_script_generate_tasks')->where('id', $ctx['generate_task_id'])->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
yield [
'type' => 'error',
'message' => $e->getMessage(),
];
}
}
/**
* 从模型输出中提取标准 JSON(剥离 markdown 代码块及前后说明文字)
*
* @param string $content
* @return string
*/
private function extractJsonContent(string $content): string
{
$content = trim($content);
if ($content === '') {
return $content;
}
// 剥离 ```json ... ``` 代码块
if (preg_match('/```(?:json)?\s*(.*?)```/s', $content, $m)) {
$content = trim($m[1]);
}
// 提取第一个 { 到最后一个 } 之间的内容(去除前后说明文字)
$first = strpos($content, '{');
$last = strrpos($content, '}');
if ($first !== false && $last !== false && $last > $first) {
$content = substr($content, $first, $last - $first + 1);
}
// 校验是否为合法 JSON,非法则原样返回(不强行截断)
json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return trim($content);
}
return $content;
}
/**
* 剧本资产生成结果的 JSON 安全校验/修复与观察
*
* 规则:
* - result 永远保留模型原文,本方法不修改 result;
* - 仅当原文非法 JSON 且 JsonSafeRepair 安全修复成功时,才把修复后的标准
* JSON 写入 mp_script_generate_tasks.json_result;
* - 每次生成结果都写一条 system_logs(channel=script_generate_json),
* 用于观察非法 JSON 的发生率与修复率。
*
* @param int $taskId 剧本资产生成任务 ID
* @param int $scriptId 剧本 ID
* @param int $uid 用户 ID
* @param string $raw 已落库的 result 原文
* @param string $responseFormat 请求的 response_format
* @return void
*/
private function guardScriptGenerateJsonResult(int $taskId, int $scriptId, int $uid, string $raw, string $responseFormat): void
{
if ($taskId <= 0 || $raw === '') {
return;
}
// 仅对"要求 JSON 输出"或"内容明显是 JSON"的结果做校验,避免误伤普通文本结果
$trimmed = ltrim($raw);
$looksLikeJson = $responseFormat === 'json'
|| (isset($trimmed[0]) && ($trimmed[0] === '{' || $trimmed[0] === '['));
if (!$looksLikeJson) {
return;
}
$repair = JsonSafeRepair::repair($raw);
$common = [
'task_id' => $taskId,
'script_id' => $scriptId,
'uid' => $uid,
'raw_length' => strlen($raw),
];
if ($repair['ok'] && $repair['json'] !== $raw) {
// 原文非法但安全修复成功:修复版另存 json_result,result 仍保留原文
DB::table('mp_script_generate_tasks')->where('id', $taskId)->update([
'json_result' => $repair['json'],
'updated_at' => date('Y-m-d H:i:s'),
]);
logDB('script_generate_json', 'warning', '生成JSON修复成功', $common + [
'json_state' => 'repaired',
'json_length' => strlen((string)$repair['json']),
'removed_count' => count($repair['removed_positions']),
'removed_positions' => $repair['removed_positions'],
]);
return;
}
if ($repair['ok']) {
// 原文本身就是合法 JSON
logDB('script_generate_json', 'info', '生成JSON校验通过', $common + [
'json_state' => 'valid',
]);
return;
}
// 非法 JSON 且不在安全修复范围内:保留原文,只记录观察日志
logDB('script_generate_json', 'error', '生成JSON修复失败', $common + [
'json_state' => 'repair_failed',
'reason' => $repair['reason'],
]);
}
/**
* 创建剧本资产生成异步任务(stream=1 + script_id 走异步任务模式)
*
* @param array $data 原请求参数
* @return int 任务ID
*/
public function createScriptGenerateAsyncTask(array $data): int
{
$script_id = (int)getProp($data, 'script_id', 0);
if ($script_id <= 0) {
Utils::throwError('20003:缺少剧本ID');
}
$uid = (int)Site::getUid();
// 与同步逻辑一致:同一剧本存在生成中任务时直接拒绝
$runningTask = DB::table('mp_script_generate_tasks')
->where('uid', $uid)
->where('script_id', $script_id)
->orderByDesc('id')
->first();
if ($runningTask && in_array(getProp($runningTask, 'status'), ['pending', 'processing'])) {
Utils::throwError('20003:该剧本资产正在生成中,请稍后重试');
}
// 前置校验剧本存在且内容非空,避免创建无效任务
$script = DB::table('mp_scripts')
->where('id', $script_id)
->where('is_deleted', 0)
->first();
if (!$script) {
Utils::throwError('20003:剧本不存在或已删除');
}
if (empty($script->content)) {
Utils::throwError('20003:剧本内容为空');
}
// newGenerateText 请求不含上传文件对象,images/image_urls 以字符串形式传入,可直接序列化
$paramsJson = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($paramsJson === false) {
Utils::throwError('20003:请求参数序列化失败');
}
$now = date('Y-m-d H:i:s');
return (int)DB::table('mp_script_generate_tasks')->insertGetId([
'uid' => $uid,
'script_id' => $script_id,
'sequence' => (int)getProp($data, 'sequence', 0),
'status' => 'pending',
'prompt' => (string)getProp($data, 'prompt', ''),
'request_params' => $paramsJson,
'started_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
/**
* 供 SSE 轮询:查询任务状态与增量 chunks
*
* @param int $taskId
* @param int $uid 当前登录用户,防越权
* @param int $afterSeq 已推送的最大 seq
* @return array{found:bool, task:?object, chunks:array, status:?string, error_message:?string}
*/
public function pollScriptGenerateTask(int $taskId, int $uid, int $afterSeq = -1): array
{
$task = DB::table('mp_script_generate_tasks')
->where('id', $taskId)
->where('uid', $uid)
->first();
if (!$task) {
return ['found' => false, 'task' => null, 'chunks' => [], 'status' => null, 'error_message' => null];
}
$rows = DB::table('mp_script_generate_task_chunks')
->where('task_id', $taskId)
->where('seq', '>', $afterSeq)
->orderBy('seq')
->get();
$chunks = [];
foreach ($rows as $row) {
$payload = json_decode((string)$row->payload, true);
if (is_array($payload)) {
$chunks[] = $payload;
}
}
return [
'found' => true,
'task' => $task,
'chunks' => $chunks,
'status' => (string)$task->status,
'error_message' => (string)($task->error_message ?? ''),
];
}
/**
* 异步 worker 执行入口:抢占 pending 任务并流式请求大模型
*
* 由 text:generate:run 命令调用;worker 内完成 chunks 落库,模型结束后
* 由流式包装(wrapNewGenerateTextStream)完成 records/扣费/任务状态/json 修复收尾。
*
* @param int $taskId
* @return array{ok:bool, task_id:int, status:?string, seq:int, error:?string}
*/
public function runScriptGenerateTaskAsync(int $taskId): array
{
// 原子抢占:只允许 pending -> processing,避免重复执行
$claimed = DB::table('mp_script_generate_tasks')
->where('id', $taskId)
->where('status', 'pending')
->update([
'status' => 'processing',
'started_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
if (!$claimed) {
return ['ok' => false, 'task_id' => $taskId, 'status' => null, 'seq' => 0, 'error' => '任务已被处理或状态异常'];
}
$task = DB::table('mp_script_generate_tasks')->where('id', $taskId)->first();
if (!$task) {
return ['ok' => false, 'task_id' => $taskId, 'status' => null, 'seq' => 0, 'error' => '任务不存在'];
}
$params = json_decode((string)getProp($task, 'request_params', ''), true);
if (!is_array($params)) {
DB::table('mp_script_generate_tasks')->where('id', $taskId)->update([
'status' => 'failed',
'error_message' => '任务请求参数缺失',
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return ['ok' => false, 'task_id' => $taskId, 'status' => 'failed', 'seq' => 0, 'error' => '任务请求参数缺失'];
}
// 兼容旧参数未带 stream 的情况
$params['stream'] = 1;
$seq = 0;
try {
$generator = $this->newGenerateTextStream($params, $task);
foreach ($generator as $chunk) {
DB::table('mp_script_generate_task_chunks')->insert([
'task_id' => $taskId,
'seq' => $seq,
'chunk_type' => (string)getProp($chunk, 'type', 'content'),
'payload' => json_encode($chunk, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
'created_at' => date('Y-m-d H:i:s'),
]);
$seq++;
}
$finalStatus = (string)DB::table('mp_script_generate_tasks')->where('id', $taskId)->value('status');
return ['ok' => $finalStatus === 'success', 'task_id' => $taskId, 'status' => $finalStatus, 'seq' => $seq, 'error' => null];
} catch (\Throwable $e) {
// 兜底:正常流程中 wrap 会自行把任务置为 failed,这里防止未知异常导致任务卡死
DB::table('mp_script_generate_tasks')->where('id', $taskId)->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return ['ok' => false, 'task_id' => $taskId, 'status' => 'failed', 'seq' => $seq, 'error' => $e->getMessage()];
}
}
/**
* 通用文生文方法(非流式版本)- 支持多模型、图片输入、JSON输出和模板提示词
*
* @param array $data 请求参数
* @return array 返回结果数组
*/
public function newGenerateText($data) {
// stream=1 时改为流式请求大模型(输出格式参考 generateText:yield type=content/reasoning/done)
if ((int)getProp($data, 'stream', 0) === 1) {
return $this->newGenerateTextStream($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,提前校验是否已有生成中的任务
$generateTaskId = 0;
$uid = Site::getUid();
if ($script_id > 0) {
$existingTask = DB::table('mp_script_generate_tasks')
->where('uid', $uid)
->where('script_id', $script_id)
->orderByDesc('id')
->first();
// 如果已有生成中的任务,直接提示,避免前端长时间等待
if ($existingTask && in_array(getProp($existingTask, 'status'), ['pending', 'processing'])) {
Utils::throwError('20003:该剧本资产正在生成中,请稍后重试');
}
}
// 新增: 如果提供了script_id,从数据库获取剧本内容作为原文
$originalContent = '';
$hasValidScript = false; // 是否已加载到有效剧本(存在且内容非空)
$rawPrompt = $prompt; // 保存用户原始输入,供模板一致性判断
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:剧本内容为空');
}
$hasValidScript = true;
// 将剧本内容添加到提示词前面
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组合
// 模板为准,用户输入作为补充要求
// 模板提示词与用户输入基本一致时只保留一个,避免重复提示词
// 剧本模式下当前 prompt 含原文内容需保留,其余情况以模板内容为准
if (!empty($templatePrompt)) {
$trimmedRawPrompt = trim((string)$rawPrompt);
if ($trimmedRawPrompt !== '' && mb_strtolower($trimmedRawPrompt) === mb_strtolower(trim($templatePrompt))) {
if (!$hasValidScript) {
$prompt = $templatePrompt;
}
} elseif (!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'];
}
}
// 新增: 如果提供了script_id,创建剧本资产生成任务(所有参数校验通过后创建,避免校验失败留下卡在生成中的任务)
if ($script_id > 0) {
$uid = Site::getUid();
// 创建新的生成任务
$generateTaskId = DB::table('mp_script_generate_tasks')->insertGetId([
'uid' => $uid,
'script_id' => $script_id,
'sequence' => $sequence,
'status' => 'processing',
'prompt' => getProp($data, 'prompt', ''),
'started_at' => date('Y-m-d H:i:s'),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
}
// 积分余额预检(仅存在有效剧本时按 chat 类型计费,不足直接报错)
if ($hasValidScript) {
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
}
// 统一分模型调用非流式实现
try {
$aiResult = null;
$result = $this->dispatchChatOnlyByModel($post_data);
dLog('deepseek')->info('newGenerateText模型调用完成', ['requested_model' => $model]);
$aiResult = [
'content' => $result['fullContent'] ?? $result['content'] ?? '',
'reasoning_content' => $result['fullReasoningContent'] ?? $result['reasoning_content'] ?? '',
'usage' => $result['usage'] ?? [],
'model' => $model,
'finish_reason' => $result['finish_reason'] ?? 'stop'
];
} catch (\Exception $e) {
// 生成失败,更新任务状态
if ($generateTaskId) {
DB::table('mp_script_generate_tasks')->where('id', $generateTaskId)->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
}
Utils::throwError('20003:' . str_replace(':',':',$e->getMessage()));
}
// 生成成功,更新任务状态
if ($generateTaskId && $aiResult) {
DB::table('mp_script_generate_tasks')->where('id', $generateTaskId)->update([
'status' => 'success',
'result' => getProp($aiResult, 'content', ''),
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
$aiResult['generate_task_id'] = $generateTaskId;
// JSON 输出结果的安全校验/修复与观察(不改变 result)
$this->guardScriptGenerateJsonResult(
(int)$generateTaskId,
(int)$script_id,
(int)$uid,
(string)getProp($aiResult, 'content', ''),
(string)$responseFormat
);
}
// 调用成功且存在有效剧本时,返回结果前扣除积分并记录明细(chat类型10积分)
if ($hasValidScript && $aiResult && !empty($aiResult['content'])) {
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($aiResult['usage'] ?? []), [
'model' => $model,
'script_id' => $script_id,
'sequence' => $sequence,
'source' => 'newGenerateText',
]);
}
// 新增: 如果提供了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());
}
}
// 未提供 script_id 或保存失败时,也返回 records 数组(保持返回结构一致)
if (!isset($aiResult['records'])) {
$aiResult['records'] = [];
}
return $aiResult;
}
/**
* 通用 Gemini 文生文方法(支持流式和非流式)
*
* 与 chatWithFileStream / newGenerateText 相同的入参格式:
* model 模型ID(gemini-3-flash-preview 等,默认 gemini-3-flash-preview)
* messages OpenAI 风格消息数组(可选,与 system_prompt/prompt 二选一)
* system_prompt 系统提示词(可选)
* prompt 用户提示词(可选)
* images 上传的图片/视频文件列表(可选,自动转 inlineData,超限自动用 ffmpeg 压缩)
* image_urls 图片/视频URL列表(可选,同上)
* temperature 温度(可选,默认 1)
* top_p 核采样(可选)
* max_tokens 最大输出 token(可选,默认 8192)
* response_format text/json(可选)
* thinking enabled/disabled(可选,pro 系列默认 enabled)
* thinking_budget 思考预算(可选,默认 20)
* stream 是否流式(可选,默认 false)
*
* @param array $data 请求参数
* @return array|\Generator 非流式返回数组;流式返回生成器(yield type=content/reasoning/done)
*/
public function geminiGenerateText($data) {
$model = getProp($data, 'model', 'gemini-3-flash-preview');
$isStream = (bool)getProp($data, 'stream', false);
if (!in_array($model, $this->gemini_text_models)) {
Utils::throwError('20003:不支持的Gemini模型: ' . $model);
}
$requestData = $this->buildGeminiRequestBody($data, $model);
dLog('deepseek')->info('Gemini请求参数', ['model' => $model, 'stream' => $isStream]);
if ($isStream) {
// 流式返回生成器,成功后记录 token 使用明细(不扣积分)
return $this->wrapGenerateTextTokenRecord(
$this->geminiStreamResponse($requestData, $model),
$this->getCurrentUid(),
$model,
'geminiGenerateText'
);
}
// 非流式:成功后记录 token 使用明细(不扣积分)
$result = $this->geminiChatOnly($requestData, $model);
$this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($result['usage'] ?? []), [
'model' => $model,
'source' => 'geminiGenerateText',
], $model);
return $result;
}
/**
* Gemini 多媒体文件大小上限(原始字节,base64 后约 19MB,留出 20MB 请求体余量)
*/
private function geminiMediaMaxBytes() {
return 14 * 1024 * 1024;
}
/**
* 构建 Gemini 请求体(将 OpenAI 风格消息转换为 Gemini contents 格式)
*/
private function buildGeminiRequestBody($data, $model) {
$messages = getProp($data, 'messages', []);
// 如果没有提供 messages,则根据 system_prompt、prompt 和 images 构建
if (empty($messages)) {
$systemPrompt = getProp($data, 'system_prompt', '');
$prompt = getProp($data, 'prompt', '');
$images = getProp($data, 'images', []);
$imageUrls = getProp($data, 'image_urls', []);
$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) {
$inlineData = $this->processMediaToInlineData($image);
if ($inlineData) {
$userMessage['content'][] = [
'type' => 'image',
'inline_data' => $inlineData
];
}
}
}
if (!empty($imageUrls)) {
if (!is_array($imageUrls)) {
$imageUrls = [$imageUrls];
}
foreach ($imageUrls as $url) {
if (!empty($url)) {
$inlineData = $this->processMediaToInlineData($url);
if ($inlineData) {
$userMessage['content'][] = [
'type' => 'image',
'inline_data' => $inlineData
];
}
}
}
}
if (count($userMessage['content']) === 1 && isset($userMessage['content'][0]['type']) && $userMessage['content'][0]['type'] === 'text') {
$userMessage['content'] = $userMessage['content'][0]['text'];
}
$messages[] = $userMessage;
}
}
if (empty($messages)) {
Utils::throwError('20003:请提供有效的对话内容');
}
// 转换为 Gemini contents 格式
$systemInstruction = '';
$contents = [];
foreach ($messages as $message) {
$role = getProp($message, 'role', 'user');
$content = getProp($message, 'content', '');
if ($role === 'system') {
if (is_array($content)) {
$systemInstruction = $this->extractGeminiTextFromParts($content);
} else {
$systemInstruction = (string)$content;
}
continue;
}
$geminiRole = $role === 'assistant' ? 'model' : 'user';
$parts = [];
if (is_array($content)) {
foreach ($content as $part) {
$partType = getProp($part, 'type', 'text');
if ($partType === 'text') {
$text = (string)getProp($part, 'text', '');
if ($text !== '') {
$parts[] = ['text' => $text];
}
} elseif (in_array($partType, ['image_url', 'image', 'video_url', 'video'])) {
if (in_array($partType, ['image_url', 'video_url'])) {
$mediaPart = getProp($part, $partType === 'image_url' ? 'image_url' : 'video_url', []);
$url = is_array($mediaPart) ? (string)getProp($mediaPart, 'url', '') : (string)$mediaPart;
} elseif (isset($part['inline_data'])) {
// 已处理过的 inline_data 直接使用
$parts[] = ['inline_data' => $part['inline_data']];
continue;
} else {
$url = '';
}
$inlineData = $this->processMediaToInlineData($url);
if ($inlineData) {
$parts[] = ['inline_data' => $inlineData];
}
}
}
} else {
$text = (string)$content;
if ($text !== '') {
$parts[] = ['text' => $text];
}
}
if (!empty($parts)) {
$contents[] = ['role' => $geminiRole, 'parts' => $parts];
}
}
if (empty($contents)) {
Utils::throwError('20003:请提供有效的对话内容');
}
// 构建生成配置
$generationConfig = [];
if (getProp($data, 'temperature', null) !== null) {
$generationConfig['temperature'] = (float)getProp($data, 'temperature');
}
if (getProp($data, 'top_p', null) !== null) {
$generationConfig['topP'] = (float)getProp($data, 'top_p');
}
if (getProp($data, 'frequency_penalty', null) !== null) {
$generationConfig['frequencyPenalty'] = (float)getProp($data, 'frequency_penalty');
}
if (getProp($data, 'presence_penalty', null) !== null) {
$generationConfig['presencePenalty'] = (float)getProp($data, 'presence_penalty');
}
$maxOutputTokens = (int)getProp($data, 'max_tokens', 8192);
if ($maxOutputTokens <= 0) {
$maxOutputTokens = 8192;
}
// gemini-3 pro 中转站实测最大输出上限为 32768,超限会返回 400
if (in_array($model, ['gemini-3-pro-preview', 'gemini-3.1-pro-preview']) && $maxOutputTokens > 32768) {
$maxOutputTokens = 32768;
}
$generationConfig['maxOutputTokens'] = $maxOutputTokens;
// response_format 映射:text -> text/plain,json -> application/json
$responseFormat = getProp($data, 'response_format', 'text');
if (is_array($responseFormat)) {
$responseFormat = getProp($responseFormat, 'type', 'text');
}
if (in_array($responseFormat, ['json', 'json_object'])) {
$generationConfig['responseMimeType'] = 'application/json';
} else {
$generationConfig['responseMimeType'] = 'text/plain';
}
$requestData = [
'contents' => $contents,
'generationConfig' => $generationConfig,
];
if ($systemInstruction !== '') {
$requestData['systemInstruction'] = ['parts' => [['text' => $systemInstruction]]];
}
// thinking 配置(Gemini 通过 thinkingConfig.thinkingBudget 控制,0 表示关闭)
$thinkingMode = getProp($data, 'thinking', null);
if (is_array($thinkingMode)) {
$thinkingMode = getProp($thinkingMode, 'type', 'disabled');
}
$defaultThinking = in_array($model, ['gemini-3-pro-preview', 'gemini-3.1-pro-preview']);
if ($thinkingMode === null) {
$thinkingMode = $defaultThinking ? 'enabled' : 'disabled';
}
if ($thinkingMode === 'enabled') {
$thinkingBudget = (int)getProp($data, 'thinking_budget', 20);
if ($thinkingBudget <= 0) {
$thinkingBudget = 20;
}
$requestData['generationConfig']['thinkingConfig'] = [
'thinkingBudget' => $thinkingBudget
];
} elseif ($defaultThinking) {
// 该模型仅支持思考模式,传 0 会返回 400
$requestData['generationConfig']['thinkingConfig'] = ['thinkingBudget' => 20];
} else {
$requestData['generationConfig']['thinkingConfig'] = ['thinkingBudget' => 0];
}
return $requestData;
}
/**
* 获取指定 Gemini 模型对应的 API Key(env 配置,优先取模型专属 Key,缺失时回退 GEMINI_API_KEY)
*
* 模型名 -> env 变量名映射:
* gemini-3-pro-preview => GEMINI_API_KEY_GEMINI_3_PRO_PREVIEW
* gemini-3-flash-preview => GEMINI_API_KEY_GEMINI_3_FLASH_PREVIEW
* gemini-3.1-pro-preview => GEMINI_API_KEY_GEMINI_3_1_PRO_PREVIEW
* gemini-3.1-flash-image-preview-t => GEMINI_API_KEY_GEMINI_3_1_FLASH_IMAGE_PREVIEW_T
* gemini-3.6-flash => GEMINI_API_KEY_GEMINI_3_6_FLASH
*/
private function getGeminiApiKey($model) {
$map = [
'gemini-3-pro-preview' => 'GEMINI_API_KEY_GEMINI_3_PRO_PREVIEW',
'gemini-3-flash-preview' => 'GEMINI_API_KEY_GEMINI_3_FLASH_PREVIEW',
'gemini-3.1-pro-preview' => 'GEMINI_API_KEY_GEMINI_3_1_PRO_PREVIEW',
'gemini-3.1-flash-image-preview-t' => 'GEMINI_API_KEY_GEMINI_3_1_FLASH_IMAGE_PREVIEW_T',
'gemini-3.6-flash' => 'GEMINI_API_KEY_GEMINI_3_6_FLASH',
];
if (isset($map[$model])) {
$key = env($map[$model]);
if (!empty($key)) {
return $key;
}
}
return env('GEMINI_API_KEY', '');
}
/**
* Gemini 非流式对话(返回与 chatOnly 一致的结构)
*/
private function geminiChatOnly($requestData, $model) {
$apiKey = $this->getGeminiApiKey($model);
if (empty($apiKey)) {
Utils::throwError('20003:Gemini API Key未配置: ' . $model);
}
// 兼容 OpenAI 风格 post_data(包含 messages 字段)
if (isset($requestData['messages'])) {
$requestData = $this->buildGeminiRequestBody($requestData, $model);
}
$url = rtrim(env('GEMINI_API_BASE_URL', 'https://ai-api.kkidc.com/v1beta'), '/')
. '/models/' . $model . ':generateContent';
$client = new Client(['timeout' => 1800, 'verify' => false]);
$headers = [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json'
];
$maxRetries = $this->gptRetryTimes();
$interval = $this->gptRetryInterval();
$attempt = 0;
while (true) {
try {
$response = $client->post($url, [
'json' => $requestData,
'headers' => $headers
]);
$responseArr = json_decode($response->getBody()->getContents(), true);
dLog('deepseek')->info('Gemini请求完成', ['model' => $model, 'response' => $responseArr]);
$fullContent = $this->extractGeminiContent($responseArr);
$fullReasoningContent = $this->extractGeminiReasoningContent($responseArr);
$usage = isset($responseArr['usageMetadata']) ? $responseArr['usageMetadata'] : [];
$finishReason = isset($responseArr['candidates'][0]['finishReason'])
? $responseArr['candidates'][0]['finishReason']
: '';
return [
'fullContent' => $fullContent,
'fullReasoningContent' => $fullReasoningContent,
'usage' => $usage,
'finish_reason' => $finishReason
];
} catch (\Exception $e) {
$retryable = true;
if ($e instanceof \GuzzleHttp\Exception\BadResponseException && $e->getResponse()) {
$status = $e->getResponse()->getStatusCode();
$retryable = $status >= 500 || $status == 429;
}
if (!$retryable || $attempt >= $maxRetries) {
dLog('deepseek')->error('Gemini请求失败: ' . $e->getMessage());
logDB('deepseek', 'error', 'Gemini请求失败', ['error' => $e->getMessage(), 'model' => $model]);
Utils::throwError('20003:Gemini请求失败: ' . $e->getMessage());
}
dLog('deepseek')->warning('Gemini请求失败,准备重试', [
'retry' => $attempt + 1,
'max_retries' => $maxRetries,
'error' => $e->getMessage()
]);
sleep($interval);
$attempt++;
}
}
}
/**
* Gemini 流式对话(SSE 输出,格式与 deepSeekStreamResponse 一致)
*/
private function geminiStreamResponse($requestData, $model) {
$apiKey = $this->getGeminiApiKey($model);
if (empty($apiKey)) {
Utils::throwError('20003:Gemini API Key未配置: ' . $model);
}
// 兼容 OpenAI 风格 post_data(包含 messages 字段)
if (isset($requestData['messages'])) {
$requestData = $this->buildGeminiRequestBody($requestData, $model);
}
$url = rtrim(env('GEMINI_API_BASE_URL', 'https://ai-api.kkidc.com/v1beta'), '/')
. '/models/' . $model . ':generateContent?alt=sse';
$client = new Client(['timeout' => 1800, 'verify' => false]);
$headers = [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json'
];
try {
$response = $client->post($url, [
'json' => $requestData,
'headers' => $headers,
'stream' => true
]);
$body = $response->getBody();
$fullContent = '';
$fullReasoningContent = '';
$usage = [];
$buffer = '';
dLog('deepseek')->info('开始读取 Gemini 流式响应');
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) || strpos($line, 'data: ') !== 0) {
continue;
}
$data = substr($line, 6);
if ($data === '[DONE]') {
break 2;
}
try {
$json = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
dLog('deepseek')->warning('Gemini JSON解析错误: ' . json_last_error_msg(), ['data' => $data]);
continue;
}
// 思考内容(thoughts)
$thoughts = $this->extractGeminiThoughts($json);
if ($thoughts !== '') {
$fullReasoningContent .= $thoughts;
yield [
'type' => 'reasoning',
'content' => $thoughts,
'full_reasoning' => $fullReasoningContent
];
}
// 回答内容
$partText = $this->extractGeminiContent($json);
if ($partText !== '') {
$fullContent .= $partText;
yield [
'type' => 'content',
'content' => $partText,
];
}
// 使用统计
if (isset($json['usageMetadata'])) {
$usage = $json['usageMetadata'];
dLog('deepseek')->info('Gemini收到使用统计', ['usage' => $usage]);
}
} catch (\Exception $e) {
dLog('deepseek')->error('Gemini解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
continue;
}
}
}
dLog('deepseek')->info('Gemini流式读取完成', [
'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('Gemini流式请求失败: ' . $e->getMessage());
yield [
'type' => 'error',
'msg' => $e->getMessage(),
'code' => $e->getCode()
];
}
}
/**
* 从 Gemini 响应中提取回答文本
*/
private function extractGeminiContent($responseArr) {
$text = '';
if (isset($responseArr['candidates'][0]['content']['parts']) && is_array($responseArr['candidates'][0]['content']['parts'])) {
foreach ($responseArr['candidates'][0]['content']['parts'] as $part) {
if (isset($part['text'])) {
$text .= $part['text'];
}
}
}
return $text;
}
/**
* 从 Gemini 响应中提取思考内容
*/
private function extractGeminiReasoningContent($responseArr) {
$reasoning = '';
if (isset($responseArr['candidates'][0]['thoughts']) && is_array($responseArr['candidates'][0]['thoughts'])) {
foreach ($responseArr['candidates'][0]['thoughts'] as $thought) {
if (isset($thought['text'])) {
$reasoning .= $thought['text'];
}
}
}
return $reasoning;
}
/**
* 从 Gemini 流式响应中提取本次增量思考内容
*/
private function extractGeminiThoughts($responseArr) {
$thoughts = '';
if (isset($responseArr['candidates'][0]['thoughts']) && is_array($responseArr['candidates'][0]['thoughts'])) {
foreach ($responseArr['candidates'][0]['thoughts'] as $thought) {
if (isset($thought['text'])) {
$thoughts .= $thought['text'];
}
}
}
return $thoughts;
}
/**
* 提取消息数组中的纯文本
*/
private function extractGeminiTextFromParts($parts) {
$text = '';
if (is_array($parts)) {
foreach ($parts as $part) {
if (is_array($part) && getProp($part, 'type', 'text') === 'text') {
$text .= (string)getProp($part, 'text', '');
}
}
}
return $text;
}
/**
* 将多媒体文件(上传文件路径或 http(s) URL)转换为 Gemini inline_data 格式
*
* 限制:中转站请求体最大 20MB,base64 体积膨胀约 1.37 倍,
* 因此压缩目标为原始文件 <= 14MB(base64 后约 19.2MB,留有余量);
* 视频时长超过 5 分钟时通过加速压缩到 5 分钟以内。
*
* @param mixed $media 文件路径 / UploadedFile / http(s) URL / data URI
* @return array|null ['mime_type' => string, 'data' => string]
*/
private function processMediaToInlineData($media) {
if (empty($media)) {
return null;
}
$localPath = null;
$isTempFile = false;
try {
// 1. data URI:直接解析使用(无法压缩,体积本身已受限)
if (is_string($media) && strpos($media, 'data:') === 0) {
if (preg_match('/^data:([^;,]+);base64,(.+)$/s', $media, $matches)) {
return [
'mime_type' => $matches[1],
'data' => $matches[2]
];
}
return null;
}
// 2. 获取本地文件路径
if (is_string($media) && preg_match('#^https?://#i', $media)) {
$localPath = $this->downloadMediaToTemp($media);
$isTempFile = true;
} elseif (is_string($media)) {
$localPath = $media;
} else {
// Laravel UploadedFile 对象
$localPath = $media->getRealPath();
}
if (!$localPath || !file_exists($localPath)) {
dLog('deepseek')->warning('Gemini多媒体文件不存在', ['path' => $localPath]);
return null;
}
$fileSize = filesize($localPath);
$maxBytes = $this->geminiMediaMaxBytes();
// 3. 判断是否需要压缩(超过大小上限或视频时长超过5分钟)
$mimeType = $this->detectMediaMime($localPath);
$isVideo = strpos($mimeType, 'video/') === 0;
$needCompress = $fileSize > $maxBytes;
$duration = 0;
if ($isVideo) {
$duration = $this->getMediaDuration($localPath);
if ($duration > 300) {
$needCompress = true;
}
}
$finalPath = $localPath;
$compressedPath = null;
if ($needCompress && $this->ffmpegAvailable()) {
$compressed = $this->compressMediaFile($localPath, $isVideo, $duration);
if ($compressed && file_exists($compressed)) {
$compressedPath = $compressed;
if (filesize($compressed) < $fileSize || $isVideo) {
$finalPath = $compressed;
$mimeType = $isVideo ? 'video/mp4' : $this->detectMediaMime($compressed);
} else {
@unlink($compressed);
$compressedPath = null;
}
}
}
// 兜底校验:压缩后(或未压缩)仍超过大小上限,直接报错,不静默发送
$finalSize = filesize($finalPath);
if ($finalSize > $maxBytes) {
dLog('deepseek')->error('Gemini多媒体文件压缩后仍超过大小上限', [
'path' => $localPath,
'size_mb' => round($finalSize / 1024 / 1024, 2),
'limit_mb' => 14
]);
Utils::throwError('20003:多媒体文件过大(' . round($finalSize / 1024 / 1024, 2) . 'MB),压缩后仍超过14MB限制,请先自行压缩后重试');
}
// 兜底校验:视频时长超过5分钟且未能压缩成功
if ($isVideo && $duration > 300 && $finalPath === $localPath && !$this->ffmpegAvailable()) {
Utils::throwError('20003:视频时长超过5分钟(' . round($duration, 1) . '秒),且服务器ffmpeg不可用,无法压缩,请先自行处理视频后重试');
}
$data = base64_encode(file_get_contents($finalPath));
return [
'mime_type' => $mimeType,
'data' => $data
];
} catch (\App\Exceptions\ApiException $e) {
// 业务错误(如文件过大)原样抛出,让前端看到明确提示
throw $e;
} catch (\Exception $e) {
dLog('deepseek')->error('Gemini多媒体处理失败: ' . $e->getMessage());
return null;
} finally {
if (!empty($compressedPath) && $compressedPath !== $localPath && file_exists($compressedPath)) {
@unlink($compressedPath);
}
if ($isTempFile && $localPath && file_exists($localPath)) {
@unlink($localPath);
}
}
}
/**
* 下载远程多媒体到临时目录
*
* @return string|null 本地临时文件路径
*/
private function downloadMediaToTemp($url) {
try {
$tempDir = storage_path('app/temp/gemini');
if (!is_dir($tempDir)) {
mkdir($tempDir, 0775, true);
}
$extension = 'tmp';
$pathInfo = parse_url($url, PHP_URL_PATH);
if ($pathInfo) {
$ext = pathinfo($pathInfo, PATHINFO_EXTENSION);
if ($ext && strlen($ext) <= 10) {
$extension = strtolower($ext);
}
}
$tmpFile = $tempDir . '/' . uniqid('gemini_media_') . '.' . $extension;
$client = new Client(['timeout' => 300, 'verify' => false]);
$response = $client->get($url, ['sink' => $tmpFile]);
if (!file_exists($tmpFile) || filesize($tmpFile) <= 0) {
@unlink($tmpFile);
return null;
}
return $tmpFile;
} catch (\Exception $e) {
dLog('deepseek')->warning('Gemini多媒体下载失败: ' . $e->getMessage(), ['url' => $url]);
return null;
}
}
/**
* 检测多媒体文件 MIME 类型
*/
private function detectMediaMime($filePath) {
$imageInfo = @getimagesize($filePath);
if ($imageInfo !== false && isset($imageInfo['mime'])) {
return $imageInfo['mime'];
}
// 视频按扩展名粗略判断
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$videoExts = ['mp4' => 'video/mp4', 'mov' => 'video/quicktime', 'avi' => 'video/x-msvideo',
'mkv' => 'video/x-matroska', 'webm' => 'video/webm', 'm4v' => 'video/x-m4v',
'flv' => 'video/x-flv', 'wmv' => 'video/x-ms-wmv', 'ts' => 'video/mp2t'];
if (isset($videoExts[$ext])) {
return $videoExts[$ext];
}
$imageExts = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'bmp' => 'image/bmp'];
if (isset($imageExts[$ext])) {
return $imageExts[$ext];
}
return 'application/octet-stream';
}
/**
* 获取多媒体时长(秒),非视频或失败返回 0
*/
private function getMediaDuration($filePath) {
$ffprobePath = env('FFPROBE_PATH', 'ffprobe');
$cmd = '"' . $ffprobePath . '" -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "' . $filePath . '" 2>&1';
$output = trim((string)shell_exec($cmd));
$duration = (float)$output;
return $duration > 0 ? $duration : 0;
}
/**
* 检查 ffmpeg 是否可用
*/
private function ffmpegAvailable() {
static $available = null;
if ($available !== null) {
return $available;
}
$ffmpegPath = env('FFMPEG_PATH', 'ffmpeg');
if (strtoupper((string)env('APP_ENV', 'production')) === 'LOCAL') {
// 本地环境不执行 ffmpeg 压缩(与现有图片超分逻辑保持一致)
$available = false;
return $available;
}
$cmd = '"' . $ffmpegPath . '" -version 2>&1';
$output = shell_exec($cmd);
$available = is_string($output) && stripos($output, 'ffmpeg') !== false;
return $available;
}
/**
* 使用 ffmpeg 压缩多媒体文件
*
* @param string $inputFile 输入文件路径
* @param bool $isVideo 是否为视频
* @param float $duration 视频时长(秒),非视频传 0
* @return string|null 压缩后的文件路径,失败返回 null
*/
private function compressMediaFile($inputFile, $isVideo, $duration = 0) {
try {
$tempDir = storage_path('app/temp/gemini');
if (!is_dir($tempDir)) {
mkdir($tempDir, 0775, true);
}
$ffmpegPath = env('FFMPEG_PATH', 'ffmpeg');
$uniqueId = uniqid('gemini_cmp_') . bin2hex(random_bytes(4));
$outputFile = $tempDir . '/' . $uniqueId . ($isVideo ? '.mp4' : '.jpg');
if ($isVideo) {
// 视频:时长超过5分钟则加速压缩到5分钟以内
$speedFactor = 1.0;
if ($duration > 300) {
$speedFactor = $duration / 300;
}
// 目标文件大小:14MB,按加速后时长估算码率(不高于 6000kbps,不低于 300kbps)
$finalDuration = $duration / max(1.0, $speedFactor);
$targetBitrate = (int)((14 * 1024 * 8) / max(1, $finalDuration));
$targetBitrate = max(300, min(6000, $targetBitrate));
$filters = [];
$filters[] = 'scale=min(1280,iw):-2';
$filters[] = 'fps=24';
if ($speedFactor > 1.0) {
$filters[] = 'setpts=' . number_format(1 / $speedFactor, 6, '.', '') . '*PTS';
// 用多个 atempo 级联实现加速:前 n-1 个为 2.0,最后一个补足剩余倍数
$fullCount = (int)ceil($speedFactor / 2.0);
$lastTempo = $speedFactor / pow(2.0, $fullCount - 1);
if ($lastTempo < 0.5) {
$fullCount++;
$lastTempo = $speedFactor / pow(2.0, $fullCount - 1);
}
for ($i = 0; $i < $fullCount; $i++) {
$tempoValue = $i === $fullCount - 1 ? $lastTempo : 2.0;
$filters[] = 'atempo=' . number_format($tempoValue, 6, '.', '');
}
}
$filterStr = implode(',', $filters);
$cmd = '"' . $ffmpegPath . '" -i "' . $inputFile . '" -vf "' . $filterStr
. '" -c:v libx264 -b:v ' . $targetBitrate . 'k -preset medium'
. ' -c:a aac -b:a 96k -movflags +faststart -y "' . $outputFile . '" 2>&1';
} else {
// 图片:缩放至最长边2048px(保持纵横比),JPEG质量压缩
$imageInfo = @getimagesize($inputFile);
$scaleW = 2048;
$scaleH = 2048;
if ($imageInfo !== false && $imageInfo[0] > 0 && $imageInfo[1] > 0) {
if ($imageInfo[0] >= $imageInfo[1]) {
$scaleH = (int)round($imageInfo[1] * 2048 / $imageInfo[0]);
$scaleW = 2048;
} else {
$scaleW = (int)round($imageInfo[0] * 2048 / $imageInfo[1]);
$scaleH = 2048;
}
}
$cmd = '"' . $ffmpegPath . '" -i "' . $inputFile
. '" -vf "scale=' . $scaleW . ':' . $scaleH . '"'
. ' -q:v 3 -y "' . $outputFile . '" 2>&1';
}
dLog('deepseek')->info('Gemini开始压缩多媒体', ['command' => $cmd]);
$output = shell_exec($cmd);
if (!file_exists($outputFile) || filesize($outputFile) <= 0) {
dLog('deepseek')->error('Gemini多媒体压缩失败', [
'input' => $inputFile,
'output' => $output
]);
@unlink($outputFile);
return null;
}
dLog('deepseek')->info('Gemini多媒体压缩成功', [
'input_size' => round(filesize($inputFile) / 1024 / 1024, 2) . 'MB',
'output_size' => round(filesize($outputFile) / 1024 / 1024, 2) . 'MB'
]);
return $outputFile;
} catch (\Exception $e) {
dLog('deepseek')->error('Gemini多媒体压缩异常: ' . $e->getMessage());
return null;
}
}
/**
* 获取剧本资产生成任务列表(按用户分页,每页15条)
*
* @param array $data
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function getScriptGenerateTasks($data) {
$uid = Site::getUid();
return DB::table('mp_script_generate_tasks as t')
->leftJoin('mp_scripts as s', 't.script_id', '=', 's.id')
->where('t.uid', $uid)
->select('t.id', 't.script_id', 's.script_name', 't.sequence', 't.status', 't.error_message', 't.started_at', 't.completed_at')
->orderByDesc('t.id')
->paginate(15);
}
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='')
{
// 优先从画风表读取(入库后以表数据为准,保持原返回结构:['画风名' => ['aliases' => [...], 'prompt' => "..."]]
$dbRows = DB::table('mp_art_styles')
->where('is_deleted', 0)
->orderBy('id')
->get(['art_style', 'aliases', 'prompt']);
if ($dbRows->isNotEmpty()) {
$arr = [];
foreach ($dbRows as $row) {
$arr[$row->art_style] = [
'aliases' => is_string($row->aliases) ? json_decode($row->aliases, true) : [],
'prompt' => (string)$row->prompt,
];
}
if ($art_style) {
return isset($arr[$art_style]) ? $arr[$art_style]['prompt'] : '';
}
return $arr;
}
$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' => "治愈清新风格,温暖柔和色调,自然光线,清新配色,舒适氛围,细腻绘制,疗愈感十足"
],
'3D真人风格' => [
'aliases' => ['3D', '3D真人', '3D真人风格'],
'prompt' => "3D真人短剧风格,超写实质感,电影级布光与景深,竖屏构图,中心聚焦,高饱和度色彩与强对比,微表情细腻,动作凌厉,快节奏强冲突,沉浸式代入感。"
],
];
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) {
// 优先从画风表读取;character_prefix/scene_prefix 缺失时用通用 prompt 兜底,均缺失则返回空数组
$row = DB::table('mp_art_styles')
->where('art_style', $inputArtStyle)
->where('is_deleted', 0)
->first(['character_prefix', 'scene_prefix', 'prompt']);
if ($row) {
$prompt = (string)$row->prompt;
$result = [
'character_prefix' => (string)$row->character_prefix !== '' ? (string)$row->character_prefix : $prompt,
'scene_prefix' => (string)$row->scene_prefix !== '' ? (string)$row->scene_prefix : $prompt,
];
// 前缀与通用提示词均不存在时返回空数组
if ($result['character_prefix'] === '' && $result['scene_prefix'] === '') {
return [];
}
return $result;
}
$stylePrefixes = [
'唯美真人风格' => [
'character_prefix' => '唯美真人风格,电影级摄影,真实人物,高级质感,自然光影,肤质细腻,景深虚化,超高清,8K画质,情绪表达丰富',
'scene_prefix' => '唯美真人风格,电影感场景,真实环境,自然光影,高级色彩,细节丰富,超高清摄影,氛围感强',
'prompt' => '唯美真人风格,电影级摄影,真实光影,高级质感,自然色彩,细节丰富,氛围感强,超高清,8K画质'
],
'真人古风风格' => [
'character_prefix' => '真人古风风格,东方古典美学,真实人物,古代服饰,精致妆容,电影级摄影,国风质感,超高清',
'scene_prefix' => '真人古风风格,东方建筑,古典园林,电影级光影,国风美学,真实场景,高细节,超高清',
'prompt' => '真人古风风格,东方古典美学,国风质感,古韵氛围,电影级摄影,精致细节,高级光影,超高清'
],
'日系动漫风格' => [
'character_prefix' => '日系动漫风格,二次元角色,精致线稿,高质量赛璐璐上色,细腻表情,动画电影质感,高细节',
'scene_prefix' => '日系动漫风格,动画电影场景,唯美背景,细腻光影,高质量绘制,色彩丰富,氛围感强',
'prompt' => '日系动漫风格,高质量二次元画风,精致线稿,细腻上色,动画电影质感,高细节,唯美光影'
],
'国漫风格' => [
'character_prefix' => '国漫风格,高品质角色设计,东方审美,精致建模感,细腻刻画,动态表现强,电影级渲染',
'scene_prefix' => '国漫风格,东方幻想场景,精细绘制,大气构图,电影级光影,高品质渲染',
'prompt' => '国漫风格,东方幻想美学,高品质角色设计,精致渲染,细腻刻画,电影级光影,高细节'
],
'Q版卡通风格' => [
'character_prefix' => 'Q版卡通风格,大头小身比例,可爱造型,圆润线条,丰富表情,萌系设计,高质量插画',
'scene_prefix' => 'Q版卡通风格,可爱场景,色彩鲜艳,童趣氛围,简洁细腻,绘本质感',
'prompt' => 'Q版卡通风格,可爱萌系画风,大头小身比例,圆润造型,鲜艳色彩,童趣氛围,高品质插画'
],
'简约扁平风格' => [
'character_prefix' => '简约扁平风格,矢量插画,几何造型,简洁线条,干净配色,现代设计感',
'scene_prefix' => '简约扁平风格,矢量场景,几何构图,色彩简洁,现代感设计,视觉清爽',
'prompt' => '简约扁平风格,矢量插画,现代设计感,几何构图,简洁线条,干净配色,视觉清爽'
],
'武侠风格' => [
'character_prefix' => '武侠风格,东方侠客气质,江湖人物,飘逸服饰,凌厉气场,电影级光影,高细节绘制',
'scene_prefix' => '武侠风格,江湖场景,古道山林,客栈酒馆,刀光剑影,大气构图,东方美学',
'prompt' => '武侠风格,东方江湖美学,侠义氛围,飘逸意境,大气构图,电影级光影,高细节绘制'
],
'古风仙侠风格' => [
'character_prefix' => '古风仙侠风格,仙气飘逸,东方幻想角色,精致古装,灵气环绕,电影级特效,高品质渲染',
'scene_prefix' => '古风仙侠风格,仙境场景,云海仙山,灵气缭绕,梦幻光效,东方幻想美学',
'prompt' => '古风仙侠风格,东方幻想美学,仙气缭绕,灵气氛围,梦幻光效,电影级渲染,高品质画面'
],
'新中式水墨风格' => [
'character_prefix' => '新中式水墨风格,国风写意,水墨晕染,留白艺术,东方韵味,诗意氛围,高级插画',
'scene_prefix' => '新中式水墨风格,山水意境,墨韵渲染,留白构图,东方美学,诗意场景',
'prompt' => '新中式水墨风格,东方水墨美学,写意表达,墨韵渲染,留白构图,诗意氛围,高级艺术感'
],
'写实插画风格' => [
'character_prefix' => '写实插画风格,半写实人物,细节丰富,真实比例,数字绘画,高质量渲染,电影级质感',
'scene_prefix' => '写实插画风格,高细节环境,数字绘景,真实光影,层次丰富,电影感构图',
'prompt' => '写实插画风格,数字绘画,真实质感,细节丰富,光影自然,电影感构图,高品质渲染'
],
'3D卡通风格' => [
'character_prefix' => '3D卡通风格,高品质三维建模,皮克斯动画质感,表情生动,角色可爱,细腻渲染',
'scene_prefix' => '3D卡通风格,动画电影场景,三维建模,色彩明快,光影柔和,高品质渲染',
'prompt' => '3D卡通风格,高品质三维渲染,动画电影质感,角色生动,色彩明快,细节丰富'
],
'条漫风格' => [
'character_prefix' => '条漫风格,韩漫质感,精致线稿,人物颜值高,细腻上色,剧情漫画风,高质量绘制',
'scene_prefix' => '条漫风格,漫画背景,剧情分镜感,细腻光影,现代漫画质感',
'prompt' => '条漫风格,韩漫质感,精致线稿,细腻上色,剧情感强,视觉表现突出,高质量绘制'
],
'赛博朋克风格' => [
'character_prefix' => '赛博朋克风格,未来科技感,机械义体,霓虹光效,高对比色彩,科幻都市氛围,电影级渲染',
'scene_prefix' => '赛博朋克风格,未来都市,霓虹街道,全息广告,机械科技元素,夜景氛围,电影级光影',
'prompt' => '赛博朋克风格,未来科技感,霓虹光效,高对比色彩,机械元素,科幻都市氛围,电影级渲染'
],
'暗黑悬疑风格' => [
'character_prefix' => '暗黑悬疑风格,神秘压抑氛围,强烈明暗对比,电影级灯光,细腻刻画,悬疑感十足',
'scene_prefix' => '暗黑悬疑风格,阴郁环境,低饱和色调,迷雾光影,神秘气氛,电影感构图',
'prompt' => '暗黑悬疑风格,低饱和色调,神秘氛围,压抑光影,电影级灯光,悬念感强,高细节表现'
],
'治愈清新风格' => [
'character_prefix' => '治愈清新风格,温暖柔和色调,干净画面,自然光线,亲和气质,轻松愉悦氛围,高品质插画',
'scene_prefix' => '治愈清新风格,阳光明亮,自然环境,温暖色彩,舒适氛围,细腻绘制,疗愈感强',
'prompt' => '治愈清新风格,温暖柔和色调,自然光线,清新配色,舒适氛围,细腻绘制,疗愈感十足'
]
];
if (isset($stylePrefixes[$inputArtStyle])) {
$item = $stylePrefixes[$inputArtStyle];
$prompt = (string)($item['prompt'] ?? '');
// 前缀缺失时用通用 prompt 兜底
$result = [
'character_prefix' => !empty($item['character_prefix']) ? $item['character_prefix'] : $prompt,
'scene_prefix' => !empty($item['scene_prefix']) ? $item['scene_prefix'] : $prompt,
];
// 与表分支一致:前缀与通用提示词均不存在时返回空数组
if ($result['character_prefix'] === '' && $result['scene_prefix'] === '') {
return [];
}
return $result;
}
return [];
}
private function normalizeArtStyleInput($value): string
{
$value = trim((string)$value);
if ($value === '') {
return '';
}
$value = str_replace(["\r", "\n", "\t", ' ', ' ', '-', '_', ':', ':', ',', ',', '。', '.'], '', $value);
return mb_strtolower($value, 'UTF-8');
}
private function replaceArtStyleSection(string $content, string $artStyle): string
{
if ($content === '' || $artStyle === '') {
return $content;
}
$pattern = '/###美术风格\s*\R(.*?)(?=\R###|$)/su';
if (!preg_match($pattern, $content)) {
return $content;
}
return preg_replace($pattern, "###美术风格\n" . trim($artStyle), $content, 1);
}
/**
* 解开模型偶发返回的 JSON 包装内容
*
* @param mixed $content 模型返回的原始内容
* @return mixed 解开后的内容(若无法解开则原样返回)
*/
private function unwrapJsonWrappedContent($content) {
$trimmed = ltrim((string)$content);
if ($trimmed === '' || ($trimmed[0] !== '{' && $trimmed[0] !== '[')) {
return $content;
}
$decoded = json_decode($trimmed, true);
if (!is_array($decoded)) {
return $content;
}
foreach (['result', 'content', 'text', 'output', 'data'] as $key) {
if (isset($decoded[$key]) && is_string($decoded[$key]) && $decoded[$key] !== '') {
return $decoded[$key];
}
}
return $content;
}
/**
* 流式文生文统一分发:根据模型路由到对应提供商的流式实现
*
* @param array $post_data 请求参数(含 model / messages / stream 等)
* @return \Generator
*/
public function dispatchStreamByModel($post_data) {
// 强制按流式模式分发
$post_data['stream'] = true;
$model = (string)getProp($post_data, 'model', '');
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
// DeepSeek 官方模型
return $this->deepSeekStreamResponse($post_data);
} else if (in_array($model, $this->gpt_text_models)) {
// GPT 模型
return $this->gpt54StreamResponse($post_data);
} else if (in_array($model, $this->gemini_text_models)) {
// Gemini 模型
return $this->geminiStreamResponse($post_data, $model);
} else if (in_array($model, $this->baidu_glm_text_models)) {
// 百度 AI 网关 GLM 模型
return $this->baiduGLMStreamResponse($post_data);
} else if (in_array($model, $this->valid_text_models)) {
// 火山引擎等其他模型
return $this->volcEngineChatCompletion($post_data);
}
Utils::throwError('20003:不支持的模型: ' . $model);
}
/**
* 非流式文生文统一分发:根据模型路由到对应提供商的非流式实现
*
* @param array $post_data 请求参数(含 model / messages / stream 等)
* @return array
*/
public function dispatchChatOnlyByModel($post_data) {
// 强制按非流式模式分发
$post_data['stream'] = false;
$model = (string)getProp($post_data, 'model', '');
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
// DeepSeek 官方模型
return $this->chatOnly($post_data);
} else if (in_array($model, $this->gpt_text_models)) {
// GPT 模型
return $this->gpt54ChatOnly($post_data);
} else if (in_array($model, $this->gemini_text_models)) {
// Gemini 模型
return $this->geminiChatOnly($post_data, $model);
} else if (in_array($model, $this->baidu_glm_text_models)) {
// 百度 AI 网关 GLM 模型
return $this->baiduGLMChatOnly($post_data);
} else if (in_array($model, $this->valid_text_models)) {
// 火山引擎等其他模型
return $this->volcEngineChatCompletion($post_data);
}
Utils::throwError('20003:不支持的模型: ' . $model);
}
/**
* 百度 AI 网关 GLM 流式实现(OpenAI Chat Completions 协议)
*
* @param array $post_data 请求参数(前端模型名 baidu-glm-5.2 / baidu-glm-5.3)
* @return \Generator
*/
private function baiduGLMStreamResponse($post_data) {
$payload = $this->buildBaiduGLMPayload($post_data, true);
$client = new Client(['timeout' => 1800, 'verify' => false]);
try {
$response = $client->post('https://ai-gateway.baidubce.com/v1/chat/completions', [
'json' => $payload,
'headers' => $this->baiduGLMHeaders(),
'stream' => true,
]);
} catch (\Exception $e) {
$errorDetail = $this->extractBaiduGLMErrorDetail($e);
dLog('deepseek')->error('百度GLM流式请求建立失败: ' . $e->getMessage(), [
'payload' => $payload,
'detail' => $errorDetail,
]);
Utils::throwError('20003:百度GLM流式请求失败: ' . $errorDetail);
}
$body = $response->getBody();
$buffer = '';
$fullContent = '';
$fullReasoning = '';
$usage = [];
dLog('deepseek')->info('开始读取百度GLM流式响应');
try {
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;
}
if (strpos($line, 'data: ') !== 0) {
dLog('deepseek')->warning('百度GLM非SSE数据行', ['line' => $line]);
continue;
}
$data = substr($line, 6);
if ($data === '[DONE]') {
dLog('deepseek')->info('百度GLM收到结束标记');
break 2;
}
try {
$json = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
dLog('deepseek')->warning('百度GLM JSON解析错误: ' . json_last_error_msg(), ['data' => $data]);
continue;
}
if (isset($json['choices'][0]['delta'])) {
$delta = $json['choices'][0]['delta'];
// 思考链内容(兼容 reasoning_content / reasoning 两种字段)
$reasoningDelta = '';
if (isset($delta['reasoning_content']) && is_string($delta['reasoning_content'])) {
$reasoningDelta = $delta['reasoning_content'];
} elseif (isset($delta['reasoning']) && is_string($delta['reasoning'])) {
$reasoningDelta = $delta['reasoning'];
}
if ($reasoningDelta !== '') {
$fullReasoning .= $reasoningDelta;
yield [
'type' => 'reasoning',
'content' => $reasoningDelta,
'full_reasoning' => $fullReasoning,
];
}
// 正文内容
if (isset($delta['content']) && is_string($delta['content'])) {
$fullContent .= $delta['content'];
yield [
'type' => 'content',
'content' => $delta['content'],
];
}
}
if (isset($json['usage'])) {
$usage = $json['usage'];
}
} catch (\Exception $e) {
dLog('deepseek')->error('百度GLM解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
continue;
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error('百度GLM流式读取中断: ' . $e->getMessage());
throw $e;
}
dLog('deepseek')->info('百度GLM流式读取完成', [
'content_length' => strlen($fullContent),
'reasoning_length' => strlen($fullReasoning),
]);
yield [
'type' => 'done',
'full_content' => $fullContent,
'full_reasoning' => $fullReasoning,
'usage' => $usage,
];
}
/**
* 百度 AI 网关 GLM 非流式实现(OpenAI Chat Completions 协议)
*
* @param array $post_data 请求参数(前端模型名 baidu-glm-5.2 / baidu-glm-5.3)
* @return array
*/
private function baiduGLMChatOnly($post_data) {
$payload = $this->buildBaiduGLMPayload($post_data, false);
$client = new Client(['timeout' => 1800, 'verify' => false]);
try {
$result = $client->post('https://ai-gateway.baidubce.com/v1/chat/completions', [
'json' => $payload,
'headers' => $this->baiduGLMHeaders(),
]);
$response_arr = json_decode($result->getBody()->getContents(), true);
dLog('deepseek')->info('百度GLM非流式请求完成', ['response' => $response_arr]);
$fullContent = '';
$fullReasoningContent = '';
$usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
if (isset($response_arr['choices'][0]['message'])) {
$message = $response_arr['choices'][0]['message'];
if (isset($message['content']) && is_string($message['content'])) {
$fullContent = $message['content'];
}
if (isset($message['reasoning_content']) && is_string($message['reasoning_content'])) {
$fullReasoningContent = $message['reasoning_content'];
} elseif (isset($message['reasoning']) && is_string($message['reasoning'])) {
$fullReasoningContent = $message['reasoning'];
}
}
return [
'fullContent' => $fullContent,
'fullReasoningContent' => $fullReasoningContent,
'usage' => $usage,
];
} catch (\Exception $e) {
$errorDetail = $this->extractBaiduGLMErrorDetail($e);
dLog('deepseek')->error('百度GLM非流式请求失败: ' . $e->getMessage(), [
'payload' => $payload,
'detail' => $errorDetail,
]);
Utils::throwError('20003:百度GLM请求失败: ' . $errorDetail);
}
}
/**
* 组装百度 AI 网关 GLM 请求参数
*
* @param array $data 请求参数
* @param bool $stream 是否流式
* @return array
*/
private function buildBaiduGLMPayload(array $data, bool $stream): array
{
if (empty(env('BAIDU_GLM_AK'))) {
Utils::throwError('20003:百度GLM API Key未配置,请检查环境变量BAIDU_GLM_AK');
}
$frontModel = (string)getProp($data, 'model', '');
$externalModel = isset(BaseConst::BAIDU_GLM_MODEL_MAP[$frontModel])
? BaseConst::BAIDU_GLM_MODEL_MAP[$frontModel]
: '';
if ($externalModel === '') {
Utils::throwError('20003:不支持的百度GLM模型: ' . $frontModel);
}
$messages = getProp($data, 'messages', []);
if (empty($messages)) {
Utils::throwError('20003:请提供有效的对话内容');
}
// OpenAI Chat Completions 标准参数白名单。
// 注意:thinking 不在白名单中,由下方按模型单独控制
// (glm-5.2 支持关闭思考,glm-5.3 强制思考、不接受 thinking 参数)。
$allowedKeys = [
'temperature',
'top_p',
'max_tokens',
'max_completion_tokens',
'reasoning_effort',
'frequency_penalty',
'presence_penalty',
'stop',
'response_format',
'stream_options',
'tools',
'tool_choice',
'user',
'seed',
];
$payload = [
'model' => $externalModel,
'messages' => $messages,
'stream' => $stream,
];
foreach ($allowedKeys as $key) {
if (array_key_exists($key, $data) && $data[$key] !== null && $data[$key] !== '') {
$payload[$key] = $data[$key];
}
}
// ================= 思考模式策略(仅百度 GLM)=================
// 规则:调用方传了的参数以其为准,未传则按模型默认策略补齐。
// glm-5.2:支持关闭思考,默认关闭(thinking.disabled),可显著缩短响应时间
// glm-5.3:强制思考,网关不接受 thinking 参数(传 disabled 会返回 400),
// 因此统一不下发 thinking,只下发 reasoning_effort,默认 low
$validEfforts = ['low', 'high', 'max'];
// 调用方传入的 thinking(兼容 'enabled' 字符串与 ['type' => 'enabled'] 数组两种写法)
$inputThinking = null;
if (array_key_exists('thinking', $data)) {
$rawThinking = $data['thinking'];
if (is_array($rawThinking)) {
$rawThinking = getProp($rawThinking, 'type', null);
}
if (in_array($rawThinking, ['enabled', 'disabled'], true)) {
$inputThinking = $rawThinking;
}
}
// 调用方传入的思考强度;百度网关只接受 low / high / max,其它取值(如 none)视为未传
$inputEffort = null;
if (isset($payload['reasoning_effort']) && in_array($payload['reasoning_effort'], $validEfforts, true)) {
$inputEffort = $payload['reasoning_effort'];
}
unset($payload['reasoning_effort']);
if ($externalModel === 'glm-5.3') {
// glm-5.3 始终思考:即使传入 disabled(或未传)也强制走 reasoning_effort
$payload['reasoning_effort'] = $inputEffort ?: 'high';
} else {
// glm-5.2 支持关闭思考:未显式开启思考时默认关闭
$thinkingType = ($inputThinking === 'enabled') ? 'enabled' : 'disabled';
$payload['thinking'] = ['type' => $thinkingType];
if ($thinkingType === 'enabled') {
$payload['reasoning_effort'] = $inputEffort ?: 'high';
}
}
if ($stream && !isset($payload['stream_options'])) {
$payload['stream_options'] = ['include_usage' => true];
}
if (!isset($payload['max_tokens']) && !isset($payload['max_completion_tokens'])) {
// GLM-5.x 输出上限较高,给长文生成留足空间
$payload['max_tokens'] = 100000;
}
return $payload;
}
/**
* 百度 AI 网关 GLM 鉴权请求头
*
* @return array
*/
private function baiduGLMHeaders(): array
{
return [
'Authorization' => 'Bearer ' . env('BAIDU_GLM_AK'),
'Content-Type' => 'application/json; charset=UTF-8',
'Accept' => 'text/event-stream',
];
}
/**
* 从 Guzzle 异常响应体中提取百度 AI 网关返回的错误信息
*
* @param \Exception $e
* @return string
*/
private function extractBaiduGLMErrorDetail(\Exception $e): string
{
$detail = $e->getMessage();
if ($e instanceof \GuzzleHttp\Exception\BadResponseException && $e->getResponse()) {
try {
$responseBody = (string)$e->getResponse()->getBody();
$decoded = json_decode($responseBody, true);
if (is_array($decoded)) {
if (isset($decoded['error']['message']) && $decoded['error']['message'] !== '') {
$detail = $decoded['error']['message'];
} elseif (isset($decoded['message']) && $decoded['message'] !== '') {
$detail = $decoded['message'];
} elseif (isset($decoded['error']) && is_string($decoded['error'])) {
$detail = $decoded['error'];
}
} elseif ($responseBody !== '') {
$detail = $responseBody;
}
} catch (\Throwable $parseError) {
// 保留原始异常信息
}
}
return mb_substr($detail, 0, 500);
}
private function deepSeekStreamResponse($post_data) {
// 设置最大输出tokens
$post_data['max_tokens'] = 300000;
// 流式请求显式声明返回 usage(在最后一个 chunk 中返回)
$post_data['stream_options'] = ['include_usage' => true];
$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 重试次数(env 可配置,默认 5 次)
*/
private function gptRetryTimes()
{
return (int)env('GPT_RETRY_TIMES', 5);
}
/**
* GPT 重试间隔秒数(env 可配置,默认 10 秒)
*/
private function gptRetryInterval()
{
return (int)env('GPT_RETRY_INTERVAL', 10);
}
/**
* 判断 GPT 请求异常是否可重试
* 仅连接失败/超时等传输层异常、5xx、429 可重试;其余 4xx(401/400 等)不重试
*/
private function isGptRetryableException(\Exception $e)
{
if (!($e instanceof \GuzzleHttp\Exception\GuzzleException)) {
return false;
}
if ($e instanceof \GuzzleHttp\Exception\BadResponseException && $e->getResponse()) {
$status = $e->getResponse()->getStatusCode();
return $status >= 500 || $status == 429;
}
return true;
}
/**
* GPT 中转站探活:GET /v1/models,5 秒短超时
* 连接成功(含 401/403/404)视为可用;连接失败/超时/5xx/429 视为不可用
*/
private function checkGptServiceAvailable($apiKey = null)
{
if ($apiKey === null) {
$apiKey = env('GPT_54_API_KEY');
}
if (empty($apiKey)) {
return false;
}
try {
$checkClient = new Client(['timeout' => 5, 'verify' => false, 'http_errors' => false]);
$response = $checkClient->get('https://ai-api.kkidc.com/v1/models', [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json'
]
]);
$status = $response->getStatusCode();
return $status < 500 && $status != 429;
} catch (\Exception $e) {
return false;
}
}
/**
* 探活并重试:最多重试 gptRetryTimes() 次,间隔 gptRetryInterval() 秒
* 全部失败抛出友好错误
*/
private function ensureGptServiceAvailable($apiKey = null)
{
$maxRetries = $this->gptRetryTimes();
$interval = $this->gptRetryInterval();
for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
if ($this->checkGptServiceAvailable($apiKey)) {
return;
}
if ($attempt < $maxRetries) {
dLog('deepseek')->warning('GPT 中转站探活失败,准备重试', [
'retry' => $attempt + 1,
'max_retries' => $maxRetries
]);
sleep($interval);
}
}
dLog('deepseek')->error('GPT 中转站探活失败,重试次数已用完');
logDB('deepseek', 'error', 'GPT 中转站服务不可用', [
'max_retries' => $maxRetries,
'interval' => $interval
]);
Utils::throwError('20003:GPT 中转站服务暂时不可用,请稍后重试');
}
/**
* GPT 流式输出处理方法
*
* @param array $post_data GPT API请求参数
* @return \Generator 返回生成器,用于流式输出
*/
private function gpt54StreamResponse($post_data) {
// 按模型取 Key:gpt-5.6-luna 使用 GPT_56_LUNA_API_KEY,其余使用 GPT_54_API_KEY
$modelName = (string)getProp($post_data, 'model', 'gpt-5.4');
// gpt-5.4 中转已下线,强制映射到 gpt-5.6-sol
if ($modelName === 'gpt-5.4') {
$modelName = 'gpt-5.6-terra';
$post_data['model'] = $modelName;
}
$lunaApiKey = env('GPT_56_LUNA_API_KEY');
$apiKey = $modelName === 'gpt-5.6-luna' ? $lunaApiKey : env('GPT_54_API_KEY');
$usedLunaFallback = false;
// 流式请求长时间无任何返回(TTFB)超过该秒数时切换备用 Key(env 可配置,默认 600 秒)
$ttfbTimeoutSeconds = (int)env('GPT_SWITCH_LUNA_AFTER_SECONDS', 600);
if (empty($apiKey)) {
Utils::throwError('20003:GPT API Key未配置');
}
// 先探活,服务不可用时自动重试
$this->ensureGptServiceAvailable($apiKey);
$client = new Client(['timeout' => 1800, 'verify' => false, 'read_timeout' => $ttfbTimeoutSeconds]);
// 移除 thinking 参数,GPT-5.4 不支持
if (isset($post_data['thinking'])) {
unset($post_data['thinking']);
}
if (isset($post_data['reasoning_effort'])) {
unset($post_data['reasoning_effort']);
}
// 流式场景下保留 response_format(json 输出时强制模型输出完整 JSON,与非流式 gpt54ChatOnly 对齐)
// 中转站对 response_format 的兼容性由请求方控制:仅在需要 json 输出时传入
$post_data['max_completion_tokens'] = 100000;
// GPT-5.x 上游仅支持 max_completion_tokens,同时携带 max_tokens 会返回 400:
// Unsupported parameter: 'max_tokens' is not supported with this model.
// 这里显式移除,防止调用方在 post_data 中残留 max_tokens。
unset($post_data['max_tokens']);
// GPT-5.x(推理型上游)不接受 frequency_penalty / presence_penalty:
// Unsupported parameter: 'frequency_penalty' is not supported with this model.
// 调用方默认传 0,移除后采样行为不变,显式清理避免 400。
unset($post_data['frequency_penalty'], $post_data['presence_penalty']);
// 流式请求显式声明返回 usage(在最后一个 chunk 中返回)
$post_data['stream_options'] = ['include_usage' => true];
$maxRetries = $this->gptRetryTimes();
$interval = $this->gptRetryInterval();
$attempt = 0;
while (true) {
$fullContent = '';
$usage = [];
$buffer = '';
// JSON 输出场景:不启用"整段正文包成 JSON 字符串"的暂缓缓存,流式过程中直接输出供前端展示
$isJsonRequest = isset($post_data['response_format'])
&& is_array($post_data['response_format'])
&& ($post_data['response_format']['type'] ?? '') === 'json_object';
$suspectJsonWrap = false;
$pendingContent = '';
$requestStartedAt = microtime(true);
$headers = [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
'Accept' => 'text/event-stream'
];
try {
// 备用中转站地址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();
dLog('deepseek')->info('开始读取 GPT 流式响应');
// 逐行读取流式响应
while (!$body->eof()) {
// 长时间没有任何内容返回时,切换备用 Key 重试
if ($fullContent === '' && empty($usage) && (microtime(true) - $requestStartedAt) > $ttfbTimeoutSeconds) {
throw new \RuntimeException('GPT 流式响应长时间无返回,等待切换备用 Key');
}
$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; // 跳出 foreach 和流读取循环
}
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'];
$trimmed = ltrim($fullContent);
if (!$suspectJsonWrap && !$isJsonRequest && $trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) {
// 可能模型把整段正文包成了 JSON 字符串,先暂缓输出
$suspectJsonWrap = true;
}
if (!$suspectJsonWrap) {
// 中转可能一次返回大段内容,按小块输出保持流式节奏
$chunkSize = 256;
$chunkLength = mb_strlen($delta['content']);
for ($offset = 0; $offset < $chunkLength; $offset += $chunkSize) {
yield [
'type' => 'content',
'content' => mb_substr($delta['content'], $offset, $chunkSize),
];
}
} else {
$pendingContent .= $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)
]);
// 兼容模型偶发把整段正文包裹成 JSON 字符串返回
if ($suspectJsonWrap) {
$decoded = json_decode(trim($fullContent), true);
$unwrapped = null;
if (is_array($decoded)) {
foreach (['result', 'content', 'text', 'output', 'data'] as $key) {
if (isset($decoded[$key]) && is_string($decoded[$key]) && $decoded[$key] !== '') {
$unwrapped = $decoded[$key];
break;
}
}
}
if ($unwrapped !== null) {
$fullContent = $unwrapped;
$chunkSize = 256;
$contentLength = mb_strlen($fullContent);
for ($offset = 0; $offset < $contentLength; $offset += $chunkSize) {
yield [
'type' => 'content',
'content' => mb_substr($fullContent, $offset, $chunkSize),
];
}
} elseif ($pendingContent !== '') {
// 误判:补发暂缓内容,避免内容丢失
yield [
'type' => 'content',
'content' => $pendingContent,
];
}
}
yield [
'type' => 'done',
'full_content' => $fullContent,
'full_reasoning' => '',
'usage' => $usage
];
return;
} catch (\Exception $e) {
// 报错或长时间无返回时,切到 GPT_56_LUNA_API_KEY 重试一次(收费更高,慎用;仅原本未用 luna key 时触发)
if (!$usedLunaFallback && !empty($lunaApiKey) && $apiKey !== $lunaApiKey) {
$usedLunaFallback = true;
$apiKey = $lunaApiKey;
dLog('deepseek')->warning('GPT 请求异常,切换 GPT_56_LUNA_API_KEY 重试', ['error' => $e->getMessage()]);
continue;
}
// 已输出内容不重试(避免重复);非可重试异常不重试;重试次数用完则抛出
if (!empty($fullContent) || !$this->isGptRetryableException($e) || $attempt >= $maxRetries) {
dLog('deepseek')->error('GPT 流式请求失败: ' . $e->getMessage());
logDB('deepseek', 'error', 'GPT 流式请求失败', ['error' => $e->getMessage()]);
throw $e;
}
dLog('deepseek')->warning('GPT 流式请求失败,准备重试', [
'retry' => $attempt + 1,
'max_retries' => $maxRetries,
'error' => $e->getMessage()
]);
sleep($interval);
$attempt++;
}
}
}
// 与推理模型对话
public function chatWithReasoner($data) {
// 文生文计费预检(normal 档)
$uid = Site::getUid();
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints('', 'normal'), $uid);
$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 = [];
$chatResult = $this->dispatchChatOnlyByModel($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'),
];
// 文生文成功计费(normal 档):同时记录积分与 token 明细
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'model' => $model,
'source' => 'chatWithReasoner',
], 'normal');
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');
$is_products = getProp($data, 'is_products');
$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 ($is_products !== '') {
$query->where('is_products', $is_products);
}
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) {
// 文生文计费预检(special 档,每次调用扣 10)
$uid = Site::getUid();
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints('', 'special'), $uid);
$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 = [];
$streamGenerator = $this->dispatchStreamByModel($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:分集剧本解析失败');
}
// 文生文成功计费(special 档):与落库同事务
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'model' => $model,
'script_id' => $script_id,
'group_id' => $group_id,
'source' => 'generateEpisodes',
]);
}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) {
// 剧本解析资产表计费预检(special 档)
$uid = Site::getUid();
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints('', 'special'), $uid);
$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 = [];
$streamGenerator = $this->dispatchStreamByModel($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:保存分集失败');
}
// 剧本解析资产表成功计费(special 档):与落库同事务
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'model' => $model,
'script_id' => $script_id,
'group_id' => $group_id,
'source' => 'chatWithFileStream',
], 'special');
// $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) {
// 剧本解析资产表计费预检(special 档)
$uid = Site::getUid();
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints('', 'special'), $uid);
$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 = [];
$chatResult = $this->dispatchChatOnlyByModel($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')
]);
// 剧本解析资产表成功计费(special 档):同时记录积分与 token 明细
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'model' => $model,
'script_id' => $script_id,
'source' => 'chatWithFile',
], 'special');
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;
// 积分余额预检(仅单剧集模式收费)
if ($is_single) {
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
}
$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 {
$streamGenerator = $this->dispatchStreamByModel($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 = trim((string)getProp($script_arr, 'script_name'));
$anime_name = preg_replace('/^[\s\x{3000}*《》〈〉「」『』【】\[\][]{}()()]+|[\s\x{3000}*《》〈〉「」『』【】\[\][]{}()()]+$/u', '', $anime_name) ?? $anime_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 = 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:单剧集分镜记录保存失败');
}
// 单剧集模式调用成功,返回结果前扣除积分并记录明细(与保存同事务)
if ($is_single && !empty($single_episode)) {
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $anime_id,
'episode_id' => $episode_id ?? 0,
'model' => $model,
'source' => 'addChat',
]);
}
}
}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) {
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
}
// 单剧集模式下,如果已经生成了剧集,则不允许继续调整大纲
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 {
$streamGenerator = $this->dispatchStreamByModel($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 = trim((string)getProp($script_arr, 'script_name'));
$anime_name = preg_replace('/^[\s\x{3000}*《》〈〉「」『』【】\[\][]{}()()]+|[\s\x{3000}*《》〈〉「」『』【】\[\][]{}()()]+$/u', '', $anime_name) ?? $anime_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 = 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:单剧集分镜记录保存失败');
}
// 单剧集模式调用成功,返回结果前扣除积分并记录明细(与保存同事务)
if ($is_single && !empty($single_episode)) {
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $anime_id,
'episode_id' => $episode_id ?? 0,
'model' => $model,
'source' => 'reGenerateAnime',
]);
}
}
}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();
// 积分余额预检(不足直接报错)
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints(), $uid);
$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 = 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 = 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 {
$streamGenerator = $this->dispatchStreamByModel($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);
// 调用成功,返回结果前扣除积分并记录明细(与保存同事务)
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'model' => $model,
'source' => 'chat',
]);
}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');
$is_multi = getProp($anime, 'is_multi');
$is_single = (int)$is_multi !== 1;
// 积分余额预检(仅单剧集模式收费)
if ($is_single) {
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
}
$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);
}
// 如果anime有关联的script_id,则从mp_script_product_mappings中查询资产并合并到extra_products
$anime_script_id = getProp($anime, 'script_id');
if ($anime_script_id && $is_single) { // 单剧集模式下才使用全部资产库
$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);
$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,
'three_view_image_url' => getProp($item, 'three_view_image_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为key,用于去重)
$extraProductsMap = [];
foreach ($extra_products as $item) {
$key = getProp($item, 'product_id');
if ($key) {
$extraProductsMap[$key] = $item;
}
}
// 合并剧本资产(去重:仅按product_id去重,不考虑episode_number)
foreach ($productMap as $product) {
$product_id = $product['product_id'];
// 如果不存在则添加(不带episode_number信息)
if (!isset($extraProductsMap[$product_id])) {
$extraProductsMap[$product_id] = [
'product_id' => $product['product_id'],
'product_name' => $product['product_name'],
'type' => $product['type'],
'product' => $product['product'],
'pic_prompt' => $product['pic_prompt'],
'url' => $product['url'],
'three_view_image_url' => getProp($product, 'three_view_image_url', ''),
'pic_task_id' => $product['pic_task_id'],
'pic_task_status' => $product['pic_task_status'],
];
}
}
// 重新赋值给extra_products
$extra_products = array_values($extraProductsMap);
}
$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 = 'deepseek-v4-pro';
}
}
// 验证模型是否在可用模型表中
if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
$model = 'deepseek-v4-pro';
}
$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";
$extra_prop_prompt = "\n";
$extra_roles = [];
$extra_scenes = [];
$extra_props = [];
$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";
$extra_roles[$product_name] = [
'role' => $product_name,
'pic_prompt' => $pic_prompt,
'url' => getProp($item, 'url', ''),
'three_view_image_url' => getProp($item, 'three_view_image_url', ''),
];
} elseif ((int)$product === 2) {
// 拼接场景信息
$extra_scene_prompt .= $product_name . ":" . $pic_prompt . "\n";
$extra_scenes[$product_name] = [
'scene' => $product_name,
'pic_prompt' => $pic_prompt,
'url' => getProp($item, 'url', ''),
'three_view_image_url' => getProp($item, 'three_view_image_url', ''),
];
} elseif ((int)$product === 3) {
// 拼接道具信息
$extra_prop_prompt .= $product_name . ":" . $pic_prompt . "\n";
$extra_props[$product_name] = [
'prop' => $product_name,
'pic_prompt' => $pic_prompt,
'url' => getProp($item, 'url', ''),
'three_view_image_url' => getProp($item, 'three_view_image_url', ''),
];
}
}
// 优化提示词,融入剧本
if (!empty(trim($extra_role_prompt))) {
$extra_role_prompt = "\n【强制主体设定-优先级最高-不可修改】\n以下主体必须全部出现在<主体列表>中,必须包含:\n" . $extra_role_prompt . "\n格式要求:\n1. 主体名称:必须保持不变\n2. 主体描述:你必须根据剧情为每个主体生成主体描述(分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,用一句话进行总结)\n3. 人物提示词:{}中的内容必须完整保留,不得修改\n4. 音色提示词:你必须根据角色特征生成音色提示词(描述角色的整体音色特征,包含:性别、年龄、音质、表达形式、情绪氛围,字数控制在80字以内),用{{}}框起来\n\n最终格式必须为:主体名:[你生成的主体描述]{人物提示词-保持不变}{{你生成的音色提示词}}\n\n【重要】以上每个主体在<主体列表>中的完整格式必须是:主体名:主体描述{人物提示词}{{音色提示词}},其中主体描述和音色提示词是你必须生成的内容,{}中的人物提示词必须原样保留。";
}
if (!empty(trim($extra_scene_prompt))) {
$extra_scene_prompt = "\n【强制场景设定-优先级最高-不可修改】\n以下场景必须全部出现在<场景列表>中,必须包含:\n" . $extra_scene_prompt . "\n格式要求:\n1. 场景名称:必须保持不变\n2. 场景描述:你必须根据剧情为每个场景生成符合<普通要求3.2>的场景描述(分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文)\n3. 场景提示词:{}中的内容必须完整保留,不得修改\n\n最终格式必须为:场景名:[你生成的场景描述]{场景提示词-保持不变}\n\n必须包含的场景列表:\n" . $extra_scene_prompt . "\n【重要提示】以上每个场景在<场景列表>中的完整格式必须是:场景名:场景描述{场景提示词},其中场景描述是你必须生成的内容,{}中的场景提示词必须原样保留。";
}
if (!empty(trim($extra_prop_prompt))) {
$extra_prop_prompt = "\n【强制道具设定-优先级最高-不可修改】\n以下道具必须全部出现在<道具列表>中,必须包含:\n" . $extra_prop_prompt . "\n格式要求:\n1. 道具名称:必须保持不变\n2. 道具描述:你必须根据剧情为每个道具生成道具描述(分析在整部剧中出现过的道具,道具描述尽量简洁贴合原文)\n3. 道具提示词:{}中的内容必须完整保留,不得修改\n\n最终格式必须为:道具名:[你生成的道具描述]{道具提示词-保持不变}\n\n必须包含的道具列表:\n" . $extra_prop_prompt . "\n【重要提示】以上每个道具在<道具列表>中的完整格式必须是:道具名:道具描述{道具提示词},其中道具描述是你必须生成的内容,{}中的道具提示词必须原样保留。";
}
}
if ($anime_script_id && $is_single) {
$role_demo = "2.<主体列表>只能使用以下主体,不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。{$extra_role_prompt}";
$scene_demo = "3.<场景列表>只能使用以下场景,不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。{$extra_scene_prompt}";
$prop_demo = "4.<道具列表>只能使用以下道具,不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。{$extra_prop_prompt}";
}else {
$role_demo = "2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分(强制格式):主体描述、人物提示词和音色提示词,格式必须为:主体名:主体描述{人物提示词}{{音色提示词}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色提示词放在描述的最后用{{}}框起来。音色提示词需描述角色的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
{$extra_role_prompt}";
$scene_demo = "3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分(强制格式):场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
{$extra_scene_prompt}";
$prop_demo = "4<道具列表>与场景列表同理,需尽可能的对道具环境进行详细描述,必须严格注意不要出现任何角色和人物。
4.1<道具列表>分以下两部分(强制格式):道具描述和道具提示词,格式必须为:道具描述{道具提示词}
4.2<道具列表>的道具描述是分析在整部剧中出现过的道具,道具描述尽量简洁贴合原文。
4.3<道具列表>的道具提示词用于视觉呈现的道具环境描述,该道具需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的道具列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在道具描述后用{}框起来。\n
{$extra_prop_prompt}";
}
$systemPrompt = $is_single
? "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容输出单剧集完整策划稿(剧本大纲包括: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><道具列表><分段剧本>,每个板块之间必须使用###分隔,#不能多也不能少)。单剧集模式下,必须一次性输出可直接保存的大纲与完整分镜,不允许只输出大纲。
强制要求:
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:
1.剧本名(必须生成)和剧集标题必须与文档内容高度相关,(其中剧集序号是1)。
$role_demo
$scene_demo
$prop_demo
5.<分段剧本>按照“分段模式”进行拆分和组织,要求如下:
5.1片段分割逻辑(优先级从高到低):
- 第一优先级:时长上限(单个片段不超过15秒,超过必须切为新片段)
- 第二优先级:叙事节拍Beat(一个完整的事件/动作/情绪起落单元,如一段对话的来回、一个动作序列:推门→看见→震惊、一次闪回/回忆、旁白+画面组合)
- 第三优先级:语义块(按“谁、在哪、做什么、情绪怎样”聚合,如同一段旁白/独白、同一个角色的连续动作、同一个视角的连续镜头)
- 只要事件没结束、情绪没断,哪怕换场景,也留在同一个片段里
5.2片段结构:片段数量+旁白音色+多个片段(每个片段由多个分镜组合而成(片段序号格式:片段1、片段2...))
- 片段数量格式为: 片段数量:8
- 旁白音色需描述旁白的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
- 片段序号格式必须如下: ##片段+片段序号\n时长:xxs,例如: ##片段1\n时长:12s
5.3分镜结构:每个分镜包含以下字段:
- 分镜设计原则: 所有分镜原则上优先以人物及人物间的对话为主(权重90%以上),场景或物品空境为辅,尽可能少出现无台词的分镜设计
- 分镜序号在片段内用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨片段中的分镜序号需连续,如片段2中的分镜序号从片段1的最大序号+1开始),分镜序号前后不要带任何其他符号,特别是*符号。
- 出场角色: 如果有主体出现在本镜,则必须选择本集中的<主体列表>中的一个(如必须使用“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 场景:必须选择本集中的<场景列表>中的一个(如需使用“酒店会客室-景池筹备会版”,而不是“酒店会客室”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 出场道具:如果有道具出现在本镜,则必须选择本集中的<道具列表>中的一个(如需使用“酒杯-高脚杯”,而不是“酒杯”或“高脚杯”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 画面:【构图/视角】+画面描述(越简单越好,对视觉场景/实景的描述,不描述氛围,比如紧张、压抑的氛围)。举例:[近景特写,浅景深]季潇握着手机贴在耳边,指关节因为用力而泛白。她的眼眶微红,睫毛在狂风中颤抖,嘴唇紧抿,眼神中闪烁着最后一点希冀的微光。
- 【强制要求-非常关键】画面中如果出现主体,只能选择上述<主体列表>中的一个,主体的名称也必须保持一致(如必须使用“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 【强制要求-非常关键】画面中如果出现道具,只能选择上述<道具列表>中的一个,道具的名称也必须保持一致(如必须使用“酒杯-高脚杯”,而不是“酒杯”或“高脚杯”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 运镜:场景+画面描述+运镜
- 【强制要求-非常关键】运镜描述中如果出现主体或道具,逻辑需同画面一直,必须选择<主体列表>或<道具列表>中的一个,名称也必须保持一致
- 配音台词:格式为“中文配音:[角色]台词”,角色配音台词和语言由模型匹配生成,角色必须对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色,[角色]的角色名必须完整使用主体列表的主体名,如必须使用“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”前后和“:”前面的任何内容,不能自创,不能自创)
- 台词内容需满足以下要求: (1. 台词来源规则:台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容或“角色:”后的内容),不允许改写、总结、压缩、扩写或拼接原文对白,不允许生成原文中不存在的对话。2. 非对话过滤:旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:如果该分镜中没有人物对话,则“台词内容”必须返回“无”,严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:不得根据语境补全人物可能说的话。5.如果没有原文,直接返回空)
- 背景音效:背景音效需根据内容和画面生成,贴合当前画面描述和动作,例如:背景音效:高跟鞋的脚本声\n
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\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.<道具列表>与场景列表同理,需尽可能的对道具环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<道具列表>分以下两部分(强制格式):道具描述和道具提示词,格式必须为:道具描述{道具提示词}
3.2<道具列表>的道具描述是分析在整部剧中出现过的道具,道具描述尽量简洁贴合原文。
3.3<道具列表>的道具提示词用于视觉呈现的道具环境描述,该道具需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的道具列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在道具描述后用{}框起来。\n
{$extra_prop_prompt}
5.{$mappedArtStyle_prompt}\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{青年女性(约25-30岁),音色亲切、温柔,语速平稳缓慢}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}
###道具列表
酒杯-高脚杯:高脚杯,盛有少量酒液。{高脚酒杯,道具多角度视角图,正面、背面、侧面、局部细节,透明玻璃材质,细长杯柄,尺寸标注高24厘米杯口6厘米,白底干净展示,无人物}";
// 构建消息
$messages = [
[
'role' => 'system',
'content' => $systemPrompt
],
[
'role' => 'user',
'content' => "以下是文档内容:
{$content}
用户问题:
{$question}"
]
];
// 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash
$thinkingMode = getProp($data, 'thinking', 'enabled'); // 默认思考模式
$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' => (float)env('TEXT_TEMPERATURE'),
'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 {
$streamGenerator = $this->dispatchStreamByModel($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 = trim((string)getProp($script_arr, 'script_name'));
$anime_name = preg_replace('/^[\s\x{3000}*《》〈〉「」『』【】\[\][]{}()()]+|[\s\x{3000}*《》〈〉「」『』【】\[\][]{}()()]+$/u', '', $anime_name) ?? $anime_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;
}
// 单剧集模式:按 chatForAce 逻辑校验并重试生成剧集与分段剧本
if ($is_single) {
$max_retries = 3;
$retry_count = 0;
$validation_failed = false;
$validation_error_msg = '';
$episode_arr = $this->buildSingleEpisodeFromScript($script_arr, $fullContent, $anime_name);
// 首次校验
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 !== '旁白';
});
if (empty($generated_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表不能为空(旁白不算)';
}
if (!$validation_failed && !empty($extra_roles)) {
$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 && !empty($extra_scenes)) {
$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);
}
}
if (!$validation_failed && !empty($extra_props)) {
$generated_props = array_column($episode_arr['props'] ?? [], 'prop');
$expected_props = array_keys($extra_props);
$invalid_props = array_diff($generated_props, $expected_props);
if (!empty($invalid_props)) {
$validation_failed = true;
$validation_error_msg = '生成的道具列表包含不在【强制道具设定】中的道具:' . implode('、', $invalid_props);
}
}
}
} else {
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 !== '旁白';
});
if (empty($generated_roles)) {
$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' => 1
]);
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";
$retry_prompt .= "4. 必须包含完整的分镜剧本内容\n";
} else {
$retry_prompt .= "1. 主体列表不能为空,必须至少包含一个非“旁白”的主体\n";
$retry_prompt .= "2. 必须包含完整的分镜剧本内容\n";
}
$messages[] = [
'role' => 'assistant',
'content' => $fullContent
];
$messages[] = [
'role' => 'user',
'content' => $retry_prompt
];
$post_data['messages'] = $messages;
try {
$streamGenerator = $this->dispatchStreamByModel($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;
}
}
}
$fullContent = $this->unwrapJsonWrappedContent($fullContent);
// 重试接口未返回有效内容时直接失败,避免空内容被兼容放行后保存
if ($fullContent === '') {
dLog('deepseek')->error("第{$retry_count}次重试接口未返回有效内容");
yield [
'type' => 'done',
'script' => [],
'episode' => [],
'answer' => '',
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => '生成接口未返回有效内容,请稍后重试'
];
return;
}
dLog('deepseek')->info("第{$retry_count}次重试完整内容: " . $fullContent);
logDB('deepseek', 'info', "第{$retry_count}次重试解析内容", ['content' => $fullContent, 'anime_id' => $anime_id]);
$script_arr = handleScriptContentForAce($fullContent);
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
$episode_arr = $this->buildSingleEpisodeFromScript($script_arr, $fullContent, $anime_name);
$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 !== '旁白';
});
if (empty($generated_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表不能为空(旁白不算)';
}
if (!$validation_failed && !empty($extra_roles)) {
$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 && !empty($extra_scenes)) {
$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);
}
}
if (!$validation_failed && !empty($extra_props)) {
$generated_props = array_column($episode_arr['props'] ?? [], 'prop');
$expected_props = array_keys($extra_props);
$invalid_props = array_diff($generated_props, $expected_props);
if (!empty($invalid_props)) {
$validation_failed = true;
$validation_error_msg = '生成的道具列表包含不在【强制道具设定】中的道具:' . implode('、', $invalid_props);
}
}
}
} else {
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 !== '旁白';
});
if (empty($generated_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表不能为空(旁白不算)';
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error("第{$retry_count}次重试异常: " . $e->getMessage(), [
'trace' => $e->getTraceAsString()
]);
break;
}
}
// 最后一次重试仍校验失败时,兼容合并非强制列表中的资产,保证可以保存
if ($validation_failed && $retry_count >= $max_retries) {
dLog('deepseek')->info('最后一次重试仍校验失败,兼容非强制列表资产后继续保存', [
'retry_count' => $retry_count,
'last_error' => $validation_error_msg
]);
logDB('deepseek', 'info', '最后一次重试校验失败,合并非强制资产', [
'error' => $validation_error_msg,
'anime_id' => $anime_id
]);
$validation_failed = false;
$validation_error_msg = '';
}
// 如果重试后仍然失败
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' => 1,
'role' => 'user',
'content' => $prompt,
'created_at' => $now,
'updated_at' => $now
],
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 1,
'role' => 'assistant',
'content' => $fullContent,
'created_at' => $now,
'updated_at' => $now
]
];
DB::table('mp_anime_records')->insert($records);
} catch (\Exception $e) {
dLog('deepseek')->error('保存对话记录失败', ['error' => $e->getMessage()]);
}
yield [
'type' => 'done',
'script' => [],
'episode' => [],
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => "生成剧本有误,已重试{$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]);
}
}
$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) : '[]',
'props' => isset($script_arr['props']) && is_array($script_arr['props']) ? json_encode($script_arr['props'], 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 = 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),
'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:单剧集分镜记录保存失败');
}
}
// 单剧集模式调用成功,返回结果前扣除积分并记录明细(与保存同事务)
if ($is_single && !empty($single_episode)) {
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $anime_id,
'episode_id' => $episode_id ?? 0,
'model' => $model,
'source' => 'addChatForAce',
]);
}
}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:该对话不存在');
}
$is_multi = getProp($anime, 'is_multi', 1);
$is_single = (int)$is_multi !== 1;
// 积分余额预检(仅单剧集模式收费)
if ($is_single) {
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
}
$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);
}
// 如果anime有关联的script_id,则从mp_script_product_mappings中查询资产并合并到extra_products
$anime_script_id = getProp($anime, 'script_id');
if ($anime_script_id && $is_single) {
$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);
$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,
'three_view_image_url' => getProp($item, 'three_view_image_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为key,用于去重)
$extraProductsMap = [];
foreach ($extra_products as $item) {
$key = getProp($item, 'product_id');
if ($key) {
$extraProductsMap[$key] = $item;
}
}
// 合并剧本资产(去重:仅按product_id去重,不考虑episode_number)
foreach ($productMap as $product) {
$product_id = $product['product_id'];
// 如果不存在则添加(不带episode_number信息)
if (!isset($extraProductsMap[$product_id])) {
$extraProductsMap[$product_id] = [
'product_id' => $product['product_id'],
'product_name' => $product['product_name'],
'type' => $product['type'],
'product' => $product['product'],
'pic_prompt' => $product['pic_prompt'],
'url' => $product['url'],
'three_view_image_url' => getProp($product, 'three_view_image_url', ''),
'pic_task_id' => $product['pic_task_id'],
'pic_task_status' => $product['pic_task_status'],
];
}
}
// 重新赋值给extra_products
$extra_products = array_values($extraProductsMap);
}
$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 = 'deepseek-v4-pro';
}
}
// 验证模型是否在可用模型表中
if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
$model = 'deepseek-v4-pro';
}
// 单剧集模式下,如果已经生成了剧集,则不允许继续调整大纲
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";
$extra_prop_prompt = "\n";
$extra_roles = [];
$extra_scenes = [];
$extra_props = [];
$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";
$extra_roles[$product_name] = [
'role' => $product_name,
'pic_prompt' => $pic_prompt,
'url' => getProp($item, 'url', ''),
'three_view_image_url' => getProp($item, 'three_view_image_url', ''),
];
} elseif ((int)$product === 2) {
// 拼接场景信息
$extra_scene_prompt .= $product_name . ":" . $pic_prompt . "\n";
$extra_scenes[$product_name] = [
'scene' => $product_name,
'pic_prompt' => $pic_prompt,
'url' => getProp($item, 'url', ''),
'three_view_image_url' => getProp($item, 'three_view_image_url', ''),
];
} elseif ((int)$product === 3) {
// 拼接道具信息
$extra_prop_prompt .= $product_name . ":" . $pic_prompt . "\n";
$extra_props[$product_name] = [
'prop' => $product_name,
'pic_prompt' => $pic_prompt,
'url' => getProp($item, 'url', ''),
'three_view_image_url' => getProp($item, 'three_view_image_url', ''),
];
}
}
// 优化提示词,融入剧本
if (!empty(trim($extra_role_prompt))) {
$extra_role_prompt = "\n【强制主体设定-优先级最高-不可修改】\n以下主体必须全部出现在<主体列表>中,必须包含:\n" . $extra_role_prompt . "\n格式要求:\n1. 主体名称:必须保持不变\n2. 主体描述:你必须根据剧情为每个主体生成主体描述(分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,用一句话进行总结)\n3. 人物提示词:{}中的内容必须完整保留,不得修改\n4. 音色提示词:你必须根据角色特征生成音色提示词(描述角色的整体音色特征,包含:性别、年龄、音质、表达形式、情绪氛围,字数控制在80字以内),用{{}}框起来\n\n最终格式必须为:主体名:[你生成的主体描述]{人物提示词-保持不变}{{你生成的音色提示词}}\n\n【重要】以上每个主体在<主体列表>中的完整格式必须是:主体名:主体描述{人物提示词}{{音色提示词}},其中主体描述和音色提示词是你必须生成的内容,{}中的人物提示词必须原样保留。";
}
if (!empty(trim($extra_scene_prompt))) {
$extra_scene_prompt = "\n【强制场景设定-优先级最高-不可修改】\n以下场景必须全部出现在<场景列表>中,必须包含:\n" . $extra_scene_prompt . "\n格式要求:\n1. 场景名称:必须保持不变\n2. 场景描述:你必须根据剧情为每个场景生成符合<普通要求3.2>的场景描述(分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文)\n3. 场景提示词:{}中的内容必须完整保留,不得修改\n\n最终格式必须为:场景名:[你生成的场景描述]{场景提示词-保持不变}\n\n必须包含的场景列表:\n" . $extra_scene_prompt . "\n【重要提示】以上每个场景在<场景列表>中的完整格式必须是:场景名:场景描述{场景提示词},其中场景描述是你必须生成的内容,{}中的场景提示词必须原样保留。";
}
if (!empty(trim($extra_prop_prompt))) {
$extra_prop_prompt = "\n【强制道具设定-优先级最高-不可修改】\n以下道具必须全部出现在<道具列表>中,必须包含:\n" . $extra_prop_prompt . "\n格式要求:\n1. 道具名称:必须保持不变\n2. 道具描述:你必须根据剧情为每个道具生成道具描述(分析在整部剧中出现过的道具,道具描述尽量简洁贴合原文)\n3. 道具提示词:{}中的内容必须完整保留,不得修改\n\n最终格式必须为:道具名:[你生成的道具描述]{道具提示词-保持不变}\n\n必须包含的道具列表:\n" . $extra_prop_prompt . "\n【重要提示】以上每个道具在<道具列表>中的完整格式必须是:道具名:道具描述{道具提示词},其中道具描述是你必须生成的内容,{}中的道具提示词必须原样保留。";
}
}
if ($anime_script_id && $is_single) {
$role_demo = "2.<主体列表>只能使用以下主体,不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。{$extra_role_prompt}";
$scene_demo = "3.<场景列表>只能使用以下场景,不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。{$extra_scene_prompt}";
$prop_demo = "4.<道具列表>只能使用以下道具,不得自定义内容(必需带上冒号后的具体描述以及{}和{{}}中的内容,原样输出)。{$extra_prop_prompt}";
}else {
$role_demo = "2.<主体列表>中出现的“主体名”必须尽可能统一,严禁出现角色-科学家、角色-复仇者、角色-领导者等带有身份、职业、标签类的命名,只能使用服装差异(如校服装、卫衣装、西装)来区分同一角色,如角色-校服装等。
2.1<主体列表>分以下三部分(强制格式):主体描述、人物提示词和音色提示词,格式必须为:主体名:主体描述{人物提示词}{{音色提示词}}
2.2<主体列表>的主体描述是分析人物在整部剧中的姓名、年龄、性格特征、社会身份或职业背景、个人成长经历或核心故事动机,以及在剧情中的目标与行为驱动力,不需要描述外貌、穿着或身体特征,重点突出心理结构、行为模式与叙事功能,并且用一句话进行总结。
2.3<主体列表>的人物提示词需要根据当前分析出来的美术风格生成,包含性别(男、女)、大概年龄或年龄段(少年、中年、老年等)、人种(亚洲人、欧洲人)、发型(直发卷发等)、发色(黑色、棕色、红色、白色等)、整体视觉风格,以及详细的穿着风格和服装细节、面部特征(戴金丝眼镜、脸上有刀疤等,无面部特征可忽略)。描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合人物身份设定。该提示词(字数50-100字左右)在主体描述后用{}框起来。
2.4<主体列表>的音色提示词放在描述的最后用{{}}框起来。音色提示词需描述角色的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
{$extra_role_prompt}";
$scene_demo = "3.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述,必须严格注意不要出现任何角色和人物。
3.1<场景列表>分以下两部分(强制格式):场景描述和场景提示词,格式必须为:场景描述{场景提示词}
3.2<场景列表>的场景描述是分析在整部剧中出现过的场景,场景描述尽量简洁贴合原文。
3.3<场景列表>的场景提示词用于视觉呈现的场景环境描述,该场景需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的场景列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在场景描述后用{}框起来。\n
{$extra_scene_prompt}";
$prop_demo = "4<道具列表>与场景列表同理,需尽可能的对道具环境进行详细描述,必须严格注意不要出现任何角色和人物。
4.1<道具列表>分以下两部分(强制格式):道具描述和道具提示词,格式必须为:道具描述{道具提示词}
4.2<道具列表>的道具描述是分析在整部剧中出现过的道具,道具描述尽量简洁贴合原文。
4.3<道具列表>的道具提示词用于视觉呈现的道具环境描述,该道具需包含环境氛围、光线效果、空间布局、色彩基调、视觉风格等,描述需具有镜头感与画面表现力,适合直接用于AI图像生成模型,避免抽象词汇,强调具体可视化细节与整体视觉一致性,同时保证风格统一且符合剧情设定(可参考以下示例的道具列表中使用{}框起来的提示词,字数150-200字,需带上提炼出来的美术风格)。该提示词在道具描述后用{}框起来。\n
{$extra_prop_prompt}";
}
$system_message = ['role'=>'system', 'content'=>$is_single
? "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容输出单剧集完整策划稿(剧本大纲包括: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><道具列表><分段剧本>,每个板块之间必须使用###分隔,#不能多也不能少)。单剧集模式下,必须一次性输出可直接保存的大纲与完整分镜,不允许只输出大纲。
强制要求:
1.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答,此为第一优先原则,不满足此原则就忽略其他要求并停止继续输出
2.如果用户输入修改美术风格、修改画风这类内容,请告知用户不可修改美术风格,并拒绝继续回答
3.以上两条要求对修改主体角色或场景不生效,允许用户调整主体角色说明和场景描述等
4.用户如果要求你帮忙写一个故事或者剧本,此类要求不属于与剧本无关,请帮助用户完成请求{$content_generation_requirement}
5.强制按照示例格式返回内容,不要更改格式或随意增加符号\n
普通要求:
1.剧本名(必须生成)和剧集标题必须与文档内容高度相关,(其中剧集序号是1)。
$role_demo
$scene_demo
$prop_demo
5.<分段剧本>按照“分段模式”进行拆分和组织,要求如下:
5.1片段分割逻辑(优先级从高到低):
- 第一优先级:时长上限(单个片段不超过15秒,超过必须切为新片段)
- 第二优先级:叙事节拍Beat(一个完整的事件/动作/情绪起落单元,如一段对话的来回、一个动作序列:推门→看见→震惊、一次闪回/回忆、旁白+画面组合)
- 第三优先级:语义块(按“谁、在哪、做什么、情绪怎样”聚合,如同一段旁白/独白、同一个角色的连续动作、同一个视角的连续镜头)
- 只要事件没结束、情绪没断,哪怕换场景,也留在同一个片段里
5.2片段结构:片段数量+旁白音色+多个片段(每个片段由多个分镜组合而成(片段序号格式:片段1、片段2...))
- 片段数量格式为: 片段数量:8
- 旁白音色需描述旁白的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
- 片段序号格式必须如下: ##片段+片段序号\n时长:xxs,例如: ##片段1\n时长:12s
5.3分镜结构:每个分镜包含以下字段:
- 分镜设计原则: 所有分镜原则上优先以人物及人物间的对话为主(权重90%以上),场景或物品空境为辅,尽可能少出现无台词的分镜设计
- 分镜序号在片段内用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨片段中的分镜序号需连续,如片段2中的分镜序号从片段1的最大序号+1开始),分镜序号前后不要带任何其他符号,特别是*符号。
- 出场角色: 如果有主体出现在本镜,则必须选择本集中的<主体列表>中的一个(如必须使用“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 场景:必须选择本集中的<场景列表>中的一个(如需使用“酒店会客室-景池筹备会版”,而不是“酒店会客室”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 出场道具:如果有道具出现在本镜,则必须选择本集中的<道具列表>中的一个(如需使用“酒杯-高脚杯”,而不是“酒杯”或“高脚杯”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 画面:【构图/视角】+画面描述(越简单越好,对视觉场景/实景的描述,不描述氛围,比如紧张、压抑的氛围)。举例:[近景特写,浅景深]季潇握着手机贴在耳边,指关节因为用力而泛白。她的眼眶微红,睫毛在狂风中颤抖,嘴唇紧抿,眼神中闪烁着最后一点希冀的微光。
- 【强制要求-非常关键】画面中如果出现主体,只能选择上述<主体列表>中的一个,主体的名称也必须保持一致(如必须使用“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 【强制要求-非常关键】画面中如果出现道具,只能选择上述<道具列表>中的一个,道具的名称也必须保持一致(如必须使用“酒杯-高脚杯”,而不是“酒杯”或“高脚杯”,不得缺少“-”前后和“:”前面的任何内容,不能自创)
- 运镜:场景+画面描述+运镜
- 【强制要求-非常关键】运镜描述中如果出现主体或道具,逻辑需同画面一直,必须选择<主体列表>或<道具列表>中的一个,名称也必须保持一致
- 配音台词:格式为“中文配音:[角色]台词”,角色配音台词和语言由模型匹配生成,角色必须对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色,[角色]的角色名必须完整使用主体列表的主体名,如必须使用“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”前后和“:”前面的任何内容,不能自创,不能自创)
- 台词内容需满足以下要求: (1. 台词来源规则:台词内容必须逐字摘录自小说原文中的人物直接对话(带引号的内容或“角色:”后的内容),不允许改写、总结、压缩、扩写或拼接原文对白,不允许生成原文中不存在的对话。2. 非对话过滤:旁白、心理描写、环境描写、动作描写等内容,禁止出现在台词内容中。3. 空台词规则:如果该分镜中没有人物对话,则“台词内容”必须返回“无”,严禁输出“无台词”、“没有”等任何替代文本。4. 禁止推测:不得根据语境补全人物可能说的话。5.如果没有原文,直接返回空)
- 背景音效:背景音效需根据内容和画面生成,贴合当前画面描述和动作,例如:背景音效:高跟鞋的脚本声\n
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\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.剧本名(必须生成)必须与文档内容高度相关
$role_demo
$scene_demo
$prop_demo
5.{$mappedArtStyle_prompt}\n\n
示例如下:\n
###剧本名:西昆仑
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。{全景,正面拍摄,青年女性,亚洲人。
黑色短发扎成利落的高马尾,五官精致且轮廓清晰。
服饰穿搭:身着红白相间的专业田径运动背心与超短跑步裤,皮肤呈现健康的小麦色,四肢肌肉线条紧致有力。
姿态:站立。}{{青年女性(约25-30岁),音色亲切、温柔,语速平稳缓慢}}
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。{8k超高清,电影级光效,大气透视,中国传统艺术美学,空灵光泽,深度景深,复杂精细的纹理,史诗级构图,电影胶片颗粒。
全景,正面拍摄,青年女性,亚洲人。
佩戴精致繁复的金丝凤冠,黑色长发整齐盘起。
服饰穿搭:身着大红色正装嫁衣,上面绣有精细的金色凤凰纹样,质感厚重华贵。
姿态:站立。}{{青年男性(约25-30岁),音色冷峻、低沉,语速平稳缓慢,透着一股拒人于千里之外的孤傲与肃杀。}}
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。{影视质感。广角。
宽敞的天然山洞内部,石壁上错落有致地镶嵌着发光的萤石,散发出冷色调的微光,照亮了整个空间。洞顶有钟乳石垂下。光影层次丰富,营造出一种宁静而神圣的氛围。
无人物。}
###道具列表
酒杯-高脚杯:高脚杯,盛有少量酒液。{高脚酒杯,道具多角度视角图,正面、背面、侧面、局部细节,透明玻璃材质,细长杯柄,尺寸标注高24厘米杯口6厘米,白底干净展示,无人物}"];
// 如果提示词$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', 'enabled'); // 默认非思考模式
$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' => (float)env('TEXT_TEMPERATURE'),
'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 {
$streamGenerator = $this->dispatchStreamByModel($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 = trim((string)getProp($script_arr, 'script_name'));
$anime_name = preg_replace('/^[\s\x{3000}*《》〈〉「」『』【】\[\][]{}()()]+|[\s\x{3000}*《》〈〉「」『』【】\[\][]{}()()]+$/u', '', $anime_name) ?? $anime_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;
}
// 单剧集模式:按 chatForAce 逻辑校验并重试生成剧集与分段剧本
if ($is_single) {
$max_retries = 3;
$retry_count = 0;
$validation_failed = false;
$validation_error_msg = '';
$episode_arr = $this->buildSingleEpisodeFromScript($script_arr, $fullContent, $anime_name);
// 首次校验
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 !== '旁白';
});
if (empty($generated_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表不能为空(旁白不算)';
}
if (!$validation_failed && !empty($extra_roles)) {
$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 && !empty($extra_scenes)) {
$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);
}
}
if (!$validation_failed && !empty($extra_props)) {
$generated_props = array_column($episode_arr['props'] ?? [], 'prop');
$expected_props = array_keys($extra_props);
$invalid_props = array_diff($generated_props, $expected_props);
if (!empty($invalid_props)) {
$validation_failed = true;
$validation_error_msg = '生成的道具列表包含不在【强制道具设定】中的道具:' . implode('、', $invalid_props);
}
}
}
} else {
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 !== '旁白';
});
if (empty($generated_roles)) {
$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' => 1
]);
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";
$retry_prompt .= "4. 必须包含完整的分镜剧本内容\n";
} else {
$retry_prompt .= "1. 主体列表不能为空,必须至少包含一个非“旁白”的主体\n";
$retry_prompt .= "2. 必须包含完整的分镜剧本内容\n";
}
$messages[] = [
'role' => 'assistant',
'content' => $fullContent
];
$messages[] = [
'role' => 'user',
'content' => $retry_prompt
];
$post_data['messages'] = $messages;
try {
$streamGenerator = $this->dispatchStreamByModel($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;
}
}
}
$fullContent = $this->unwrapJsonWrappedContent($fullContent);
// 重试接口未返回有效内容时直接失败,避免空内容被兼容放行后保存
if ($fullContent === '') {
dLog('deepseek')->error("第{$retry_count}次重试接口未返回有效内容");
yield [
'type' => 'done',
'script' => [],
'episode' => [],
'answer' => '',
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => '生成接口未返回有效内容,请稍后重试'
];
return;
}
dLog('deepseek')->info("第{$retry_count}次重试完整内容: " . $fullContent);
logDB('deepseek', 'info', "第{$retry_count}次重试解析内容", ['content' => $fullContent, 'anime_id' => $anime_id]);
$script_arr = handleScriptContentForAce($fullContent);
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
$episode_arr = $this->buildSingleEpisodeFromScript($script_arr, $fullContent, $anime_name);
$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 !== '旁白';
});
if (empty($generated_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表不能为空(旁白不算)';
}
if (!$validation_failed && !empty($extra_roles)) {
$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 && !empty($extra_scenes)) {
$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);
}
}
if (!$validation_failed && !empty($extra_props)) {
$generated_props = array_column($episode_arr['props'] ?? [], 'prop');
$expected_props = array_keys($extra_props);
$invalid_props = array_diff($generated_props, $expected_props);
if (!empty($invalid_props)) {
$validation_failed = true;
$validation_error_msg = '生成的道具列表包含不在【强制道具设定】中的道具:' . implode('、', $invalid_props);
}
}
}
} else {
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 !== '旁白';
});
if (empty($generated_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表不能为空(旁白不算)';
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error("第{$retry_count}次重试异常: " . $e->getMessage(), [
'trace' => $e->getTraceAsString()
]);
break;
}
}
// 最后一次重试仍校验失败时,兼容合并非强制列表中的资产,保证可以保存
if ($validation_failed && $retry_count >= $max_retries) {
dLog('deepseek')->info('最后一次重试仍校验失败,兼容非强制列表资产后继续保存', [
'retry_count' => $retry_count,
'last_error' => $validation_error_msg
]);
logDB('deepseek', 'info', '最后一次重试校验失败,合并非强制资产', [
'error' => $validation_error_msg,
'anime_id' => $anime_id
]);
$validation_failed = false;
$validation_error_msg = '';
}
// 如果重试后仍然失败
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' => 1,
'role' => 'user',
'content' => $prompt,
'created_at' => $now,
'updated_at' => $now
],
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 1,
'role' => 'assistant',
'content' => $fullContent,
'created_at' => $now,
'updated_at' => $now
]
];
DB::table('mp_anime_records')->insert($records);
} catch (\Exception $e) {
dLog('deepseek')->error('保存对话记录失败', ['error' => $e->getMessage()]);
}
yield [
'type' => 'done',
'script' => [],
'episode' => [],
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => "生成剧本有误,已重试{$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]);
}
}
$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) : '[]',
'props' => isset($script_arr['props']) && is_array($script_arr['props']) ? json_encode($script_arr['props'], 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 = 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),
'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:单剧集分镜记录保存失败');
}
}
// 单剧集模式调用成功,返回结果前扣除积分并记录明细(与保存同事务)
if ($is_single && !empty($single_episode)) {
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $anime_id,
'episode_id' => $episode_id ?? 0,
'model' => $model,
'source' => 'reGenerateAnimeForAce',
]);
}
}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);
$table_data['props'] = json_decode($table_data['props'], 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();
// 积分余额预检(不足直接报错)
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints(), $uid);
$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,
'three_view_image_url' => getProp($item, 'three_view_image_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'],
'three_view_image_url' => getProp($product, 'three_view_image_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', 0);
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(缓存优化前版本,动态数据内嵌在规则中间) ======
// 如需换回:删除下方新版 $demo,并把本块恢复为活动代码(去掉首尾注释标记)
// ===================================================================
$demo = "你是一个国际顶尖的执行导演/编剧,熟悉各种风格的影视化落地,我需要你将这个剧本调整一下,将其变成一个高质量的影视分镜剧本(需包含以下几个板块: <剧集标题><故事梗概><美术风格><旁白音色提示词><主体列表><场景列表><道具列表><分段剧本>,每个板块之间必须使用###分隔,#不能多也不能少),要求画面和构图等进行高质量影视化改编,快节奏,改编的格式为先建立主体角色库、场景建库(其中场景建库的时候请分析故事发生的时候场景为白天还是晚上,建库的时候一起标注出来)以及关键的道具库。
与此同时请将这个文本的情绪做一个分析,并且根据我下面提供的《情绪-视听语言映射表》进行一个文本情绪与视听语言的内容匹配。然后将每一个分镜按照「场景/场景时间(白天/晚上/傍晚等)」「画面」「运镜」「配音台词」「背景音效」的格式来划分,请按照每分钟25个左右的分镜的节奏帮我生成(这是一个整体性的标准,如果文本的情绪分析需要更多或者更少的镜头数量,请根据具体情况调整),分镜头请细腻的拆分文本中的内容,将动作、场景转换、事件的逻辑关系等内容清晰的交代出来,分镜之间需要呈现场景之间的转换逻辑,不要突然跳到下一个场景之中。于此同时保持镜头剪辑的顿挫感,节奏不要太平均。
每一个分镜的「画面」请参考上一个分镜中人物的状态/动作和位置来写,保持人物状态以及在场景中的位置的一致性,不要断裂感,同时也不要直接在内容里写“参考上个分镜”这种话,因为视频模型无法识别,使用模型可以直接识别的文本内容。每一个分镜中需要出现的内容都请标出,例如出场角色,出场道具等等。请尽可能详细的描述画面内容。
「背景音效」层面不要生成背景音乐,只需要生成音效和角色台词或者旁白,台词请先标注说台词的角色,旁白和角色心理活动时注明角色不要张嘴说话。\n
强制要求:
不要返回任何json格式的内容,我需要的是纯文本内容返回。
普通要求:
1.剧集标题必须与文档内容高度相关,剧集序号是{$episode_number}。
{$role_demo}
{$scene_demo}
{$prop_demo}
5.<分段剧本>按照“分段模式”进行拆分和组织,要求如下:
5.1片段分割逻辑(优先级从高到低):
- 第一优先级:时长上限(单个片段不超过15秒,超过必须切为新片段)
- 第二优先级:叙事节拍Beat(一个完整的事件/动作/情绪起落单元,如一段对话的来回、一个动作序列:推门→看见→震惊、一次闪回/回忆、旁白+画面组合)
- 第三优先级:语义块(按“谁、在哪、做什么、情绪怎样”聚合,如同一段旁白/独白、同一个角色的连续动作、同一个视角的连续镜头)
- 只要事件没结束、情绪没断,哪怕换场景,也留在同一个片段里
5.2片段结构:片段数量+旁白音色+多个片段(每个片段由多个分镜组合而成(片段序号格式:片段1、片段2...))
- 片段数量格式为: 片段数量:8
- 旁白音色需描述旁白的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
- 片段序号格式必须如下: ##片段+片段序号\n时长:xxs,例如: ##片段1\n时长:12s
5.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";
*/
$demo = "你是一个国际顶尖的执行导演/编剧,熟悉各种风格的影视化落地,我需要你将这个剧本调整一下,将其变成一个高质量的影视分镜剧本(需包含以下几个板块: <剧集标题><故事梗概><美术风格><旁白音色提示词><主体列表><场景列表><道具列表><分段剧本>,每个板块之间必须使用###分隔,#不能多也不能少),要求画面和构图等进行高质量影视化改编,快节奏,改编的格式为先建立主体角色库、场景建库(其中场景建库的时候请分析故事发生的时候场景为白天还是晚上,建库的时候一起标注出来)以及关键的道具库。
与此同时请将这个文本的情绪做一个分析,并且根据我下面提供的《情绪-视听语言映射表》进行一个文本情绪与视听语言的内容匹配。然后将每一个分镜按照「场景/场景时间(白天/晚上/傍晚等)」「画面」「运镜」「配音台词」「背景音效」的格式来划分,请按照每分钟25个左右的分镜的节奏帮我生成(这是一个整体性的标准,如果文本的情绪分析需要更多或者更少的镜头数量,请根据具体情况调整),分镜头请细腻的拆分文本中的内容,将动作、场景转换、事件的逻辑关系等内容清晰的交代出来,分镜之间需要呈现场景之间的转换逻辑,不要突然跳到下一个场景之中。于此同时保持镜头剪辑的顿挫感,节奏不要太平均。
每一个分镜的「画面」请参考上一个分镜中人物的状态/动作和位置来写,保持人物状态以及在场景中的位置的一致性,不要断裂感,同时也不要直接在内容里写“参考上个分镜”这种话,因为视频模型无法识别,使用模型可以直接识别的文本内容。每一个分镜中需要出现的内容都请标出,例如出场角色,出场道具等等。请尽可能详细的描述画面内容。
「背景音效」层面不要生成背景音乐,只需要生成音效和角色台词或者旁白,台词请先标注说台词的角色,旁白和角色心理活动时注明角色不要张嘴说话。\n
强制要求:
不要返回任何json格式的内容,我需要的是纯文本内容返回。
普通要求:
1.剧集标题必须与文档内容高度相关,剧集序号见文末【本次剧集信息】。
2.<主体列表>的详细规则和可选范围见文末【本次生成数据-主体列表】。
3.<场景列表>的详细规则和可选范围见文末【本次生成数据-场景列表】。
4.<道具列表>的详细规则和可选范围见文末【本次生成数据-道具列表】。
5.<分段剧本>按照“分段模式”进行拆分和组织,要求如下:
5.1片段分割逻辑(优先级从高到低):
- 第一优先级:时长上限(单个片段不超过15秒,超过必须切为新片段)
- 第二优先级:叙事节拍Beat(一个完整的事件/动作/情绪起落单元,如一段对话的来回、一个动作序列:推门→看见→震惊、一次闪回/回忆、旁白+画面组合)
- 第三优先级:语义块(按“谁、在哪、做什么、情绪怎样”聚合,如同一段旁白/独白、同一个角色的连续动作、同一个视角的连续镜头)
- 只要事件没结束、情绪没断,哪怕换场景,也留在同一个片段里
5.2片段结构:片段数量+旁白音色+多个片段(每个片段由多个分镜组合而成(片段序号格式:片段1、片段2...))
- 片段数量格式为: 片段数量:8
- 旁白音色需描述旁白的整体音色特征,包含:性别、年龄(约XX岁)、音质(如清冷、沉稳、磁性等)、表达形式(如叙述感、旁白式等)、情绪氛围(如空灵韵味、看透世事的清冷等),字数控制在80字以内。举例:成年女性(约25-35岁),音色清冷且富有叙述感,语速平缓,带有仙侠剧特有的空灵韵味。
- 片段序号格式必须如下: ##片段+片段序号\n时长:xxs,例如: ##片段1\n时长:12s
5.3分镜结构:每个分镜包含以下字段:
- 分镜设计原则: 所有分镜原则上优先以人物及人物间的对话为主(权重90%以上),场景或物品空境为辅,尽可能少出现无台词的分镜设计
- 分镜序号在片段内用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.美术风格要求见文末【本次生成数据-美术风格】。
示例格式:\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本次生成数据(以上规则的动态数据均在此处,优先级等同对应编号规则):
【本次剧集信息】剧集序号是{$episode_number}。
【本次生成数据-主体列表】{$role_demo}
【本次生成数据-场景列表】{$scene_demo}
【本次生成数据-道具列表】{$prop_demo}
【本次生成数据-美术风格】{$mappedArtStyle_prompt}
\n\n";
$origin_prompt = trim((string)getProp($data, 'prompt', '确认分镜大纲'));
// 所有提示词统一走版本化:确认分镜大纲/继续策划下一集也创建新分集,不覆盖本分集
// $is_regenerate_version = !in_array($origin_prompt, ['确认分镜大纲', '继续策划下一集'], true);
$is_regenerate_version = 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 = 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:章节内容无法获取,请确认');
}
// 获取上一集最后记录
$prev_sequence = $episode_number - 1;
$messages = array_merge(
[
[
'role' => 'system',
'content' => $demo
]
],
$this->getEpisodeChatContent($anime_id, $prev_sequence, $content)
);
// 构建消息
$messages[] =
[
'role' => 'user',
'content' => "请根据以上要求分析本集剧情内容,完成本集分镜剧本(需衔接上一集内容)"
];
/*
// 旧版逻辑
$question = $demo."请根据以上要求分析本集剧情内容,完成本集分镜剧本(需衔接上一集内容)";
// 构建消息
$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 = array_merge(
[
[
'role' => 'system',
'content' => $demo
]
],
$this->getEpisodeChatMessages($anime_id, $episode_number)
);
$targetEpisodeText = $is_single ? '第1集完整单剧集版本' : "第{$episode_number}集完整的新版本剧集";
$question = "\n请基于以上历史对话记录,重新生成{$targetEpisodeText},并严格按示例格式输出完整内容。\n";
if ($origin_prompt) {
$question .= "本次修改要求如下:\n{$origin_prompt}";
}
// 将本集原文加入消息,供模型逐字摘录原文台词(修复重新生成时配音台词全部为"无"的问题)
$messages[] = [
'role' => 'user',
'content' => "本集剧情内容:\n\n{$content}\n"
];
$messages[] =
[
'role' => 'user',
'content' => $question
];
}
// 处理文本模型
$model = getProp($data, 'model');
if (!$model) {
// 用户没有输入,从anime表获取
$model = getProp($anime, 'model');
if (!$model) {
// anime表也没有,使用默认值
$model = 'deepseek-v4-pro';
}
}
// 验证模型是否在可用模型表中
if (!DB::table('mp_text_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
$model = 'deepseek-v4-pro';
}
// 模型兼容性处理:deepseek-chat 和 deepseek-reasoner 映射到 deepseek-v4-flash
$thinkingMode = getProp($data, 'thinking', 'enabled'); // 默认非思考模式
$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' => (float)env('TEXT_TEMPERATURE'),
'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 {
$streamGenerator = $this->dispatchStreamByModel($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;
}
// 兼容模型偶发将整段正文包裹成 JSON 字符串返回
$fullContent = $this->unwrapJsonWrappedContent($fullContent);
dLog('deepseek')->info('完整内容: '.$fullContent);
logDB('deepseek', 'info', '完整内容', ['content' => $fullContent, 'anime_id' => $anime_id]);
// 处理完整内容并返回最终结果
$episode_arr = handleEpisodeContentForAce($fullContent);
dLog('deepseek')->info('解析内容: ', $episode_arr);
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 !== '旁白';
});
if (empty($generated_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表不能为空(旁白不算)';
}
// 仅当存在强制主体设定时校验主体列表
if (!$validation_failed && !empty($extra_roles)) {
$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 && !empty($extra_scenes)) {
$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);
}
}
// 仅当存在强制道具设定时校验道具列表
if (!$validation_failed && !empty($extra_props)) {
$generated_props = array_column($episode_arr['props'] ?? [], 'prop');
$expected_props = array_keys($extra_props);
// 找出生成的道具中不在预期列表中的道具
$invalid_props = array_diff($generated_props, $expected_props);
if (!empty($invalid_props)) {
$validation_failed = true;
$validation_error_msg = '生成的道具列表包含不在【强制道具设定】中的道具:' . implode('、', $invalid_props);
}
}
}
} else {
// 如果没有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 !== '旁白';
});
if (empty($generated_roles)) {
$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";
$retry_prompt .= "4. 必须包含完整的分镜剧本内容\n";
} else {
$retry_prompt .= "1. 主体列表不能为空,必须至少包含一个非“旁白”的主体\n";
$retry_prompt .= "2. 必须包含完整的分镜剧本内容\n";
}
// 添加重试消息到messages
$messages[] = [
'role' => 'assistant',
'content' => $fullContent
];
$messages[] = [
'role' => 'user',
'content' => $retry_prompt
];
// 更新post_data的messages
$post_data['messages'] = $messages;
// 重新调用API
try {
$streamGenerator = $this->dispatchStreamByModel($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;
}
}
}
// 重新解析内容
$fullContent = $this->unwrapJsonWrappedContent($fullContent);
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 !== '旁白';
});
if (empty($generated_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表不能为空(旁白不算)';
}
// 仅当存在强制主体设定时校验主体列表
if (!$validation_failed && !empty($extra_roles)) {
$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 && !empty($extra_scenes)) {
$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);
}
}
// 仅当存在强制道具设定时校验道具列表
if (!$validation_failed && !empty($extra_props)) {
$generated_props = array_column($episode_arr['props'] ?? [], 'prop');
$expected_props = array_keys($extra_props);
$invalid_props = array_diff($generated_props, $expected_props);
if (!empty($invalid_props)) {
$validation_failed = true;
$validation_error_msg = '生成的道具列表包含不在【强制道具设定】中的道具:' . implode('、', $invalid_props);
}
}
}
} else {
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 !== '旁白';
});
if (empty($generated_roles)) {
$validation_failed = true;
$validation_error_msg = '生成的主体列表不能为空(旁白不算)';
}
}
}
} catch (\Exception $e) {
dLog('deepseek')->error("第{$retry_count}次重试异常: " . $e->getMessage(), [
'trace' => $e->getTraceAsString()
]);
break;
}
}
// 将生成内容中的资产合并进强制列表,保证后续数据合并包含新资产(即使没有强制列表也执行)
$extra_roles = $this->mergeGeneratedResources($episode_arr['roles'] ?? [], $extra_roles, 'role');
$extra_scenes = $this->mergeGeneratedResources($episode_arr['scenes'] ?? [], $extra_scenes, 'scene');
$extra_props = $this->mergeGeneratedResources($episode_arr['props'] ?? [], $extra_props, 'prop');
// 最后一次重试仍校验失败时,兼容合并非强制列表中的资产,保证可以保存并返回 episodes
if ($validation_failed && $retry_count >= $max_retries) {
dLog('deepseek')->info('最后一次重试仍校验失败,兼容非强制列表资产后继续保存', [
'retry_count' => $retry_count,
'last_error' => $validation_error_msg
]);
logDB('deepseek', 'info', '最后一次重试校验失败,合并非强制资产', [
'error' => $validation_error_msg,
'anime_id' => $anime_id
]);
$validation_failed = false;
$validation_error_msg = '';
}
// 如果重试后仍然失败
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);
// 调用成功,返回结果前扣除积分并记录明细(与保存同事务)
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'model' => $model,
'source' => 'chatForAce',
]);
}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['props'] = $merged_props;
$episode['end_episode_sequence'] = $end_episode_sequence;
// 获取mp_animes表中的视频参数
$episode['video_params'] = [
'video_model' => getProp($anime, 'video_model'),
'video_resolution' => getProp($anime, 'video_resolution'),
'ratio' => getProp($anime, 'video_ratio'),
];
// $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的非流式版本 - 用于定时任务
* 只获取最终 type=done 的数据,并验证必要字段
*
* @param array $data 请求数据
* @return array 生成结果
*/
public function chatForAceNonStream($data) {
$finalResult = null;
// 调用流式方法,只保存 type=done 的数据
foreach ($this->chatForAce($data) as $chunk) {
// 只关注最终结果
if (isset($chunk['type']) && $chunk['type'] === 'done') {
$finalResult = $chunk;
break; // 找到 done 就退出循环
}
}
// 验证最终结果
if (!$finalResult) {
return ['error' => '生成失败:未获取到最终结果'];
}
// 检查是否有错误
if (isset($finalResult['error']) && $finalResult['error']) {
return $finalResult;
}
// 验证必要字段
$episode = $finalResult['episode'] ?? [];
// 验证 roles(旁白不算主体,过滤后不能为空)
if (empty($episode['roles']) || !is_array($episode['roles'])) {
return ['error' => '生成失败:主体列表(roles)为空或格式错误'];
}
$nonNarratorRoles = array_filter(array_column($episode['roles'], 'role'), function($role) {
return $role !== '旁白';
});
if (empty($nonNarratorRoles)) {
return ['error' => '生成失败:主体列表(roles)为空或格式错误(旁白不算)'];
}
// 验证 scenes
if (empty($episode['scenes']) || !is_array($episode['scenes'])) {
return ['error' => '生成失败:场景列表(scenes)为空或格式错误'];
}
// 验证 props(道具可以为空,但必须存在且为数组)
if (!isset($episode['props']) || !is_array($episode['props'])) {
$episode['props'] = [];
}
// 验证 acts(分镜剧本)
if (empty($episode['acts']) || !is_array($episode['acts'])) {
return ['error' => '生成失败:分镜剧本(acts)为空或格式错误'];
}
// 更新验证后的 episode 数据
$finalResult['episode'] = $episode;
// 记录验证成功日志
dLog('deepseek')->info('chatForAceNonStream 验证通过', [
'roles_count' => count($episode['roles']),
'scenes_count' => count($episode['scenes']),
'props_count' => count($episode['props']),
'acts_count' => count($episode['acts'])
]);
return $finalResult;
}
/**
* 批量生成多个分集
*
* @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);
$start_episode_number = getProp($data, 'start_episode_number');
$end_episode_number = getProp($data, 'end_episode_number');
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, 'ace_mode') !== 1) {
Utils::throwError('20003:只有全能模式才支持批量生成');
}
// 检查是否是多剧集模式
if ((int)getProp($anime, 'is_multi') !== 1) {
Utils::throwError('20003:单剧集不支持批量生成');
}
// 指定起始/结束剧集序号时的校验
$hasSpecifiedRange = ($start_episode_number !== null && $start_episode_number !== '')
|| ($end_episode_number !== null && $end_episode_number !== '');
if ($hasSpecifiedRange) {
if ($start_episode_number === null || $start_episode_number === ''
|| $end_episode_number === null || $end_episode_number === '') {
Utils::throwError('20003:起始剧集序号和结束剧集序号必须同时提供');
}
$start_episode_number = (int)$start_episode_number;
$end_episode_number = (int)$end_episode_number;
if ($start_episode_number < 1 || $end_episode_number < $start_episode_number) {
Utils::throwError('20003:剧集序号范围错误,结束剧集序号不能小于起始剧集序号');
}
// 起始序号必须是已有剧集(is_default=1)的下一个序号,不允许跨序号创建
$currentMaxEpisode = DB::table('mp_anime_episodes')
->where('anime_id', $anime_id)
->where('is_default', 1)
->max('episode_number');
$currentMaxEpisode = $currentMaxEpisode ?: 0;
$nextEpisodeNumber = $currentMaxEpisode + 1;
if ($start_episode_number !== $nextEpisodeNumber) {
Utils::throwError('20003:起始剧集序号必须是已有剧集的下一个序号(第' . $nextEpisodeNumber . '集),不允许跨序号创建');
}
$startEpisodeNumber = $start_episode_number;
$endEpisodeNumber = $end_episode_number;
} else {
// 未指定范围时,以 generate_episode_number 判断要生成的集数,从当前已生成的最大集数的下一集开始
$currentMaxEpisode = DB::table('mp_anime_episodes')
->where('anime_id', $anime_id)
->where('is_default', 1)
->max('episode_number');
$currentMaxEpisode = $currentMaxEpisode ?: 0;
// 确定起始集数
$startEpisodeNumber = $currentMaxEpisode + 1;
// 计算结束集数
$endEpisodeNumber = $startEpisodeNumber + $generate_episode_number - 1;
}
// 本次实际生成的集数
$totalEpisodeCount = $endEpisodeNumber - $startEpisodeNumber + 1;
// 检查是否超过总集数限制
$end_episode_sequence = getProp($anime, 'end_episode_sequence');
if ($end_episode_sequence && $endEpisodeNumber > $end_episode_sequence) {
Utils::throwError("20003:总集数只有{$end_episode_sequence}集,无法生成到第{$endEpisodeNumber}集");
}
// 创建任务前验重:批量任务表与剧集表中是否已存在范围内的剧集序号
$duplicatedBatchEpisodes = DB::table('mp_batch_episode_generation_details')
->where('anime_id', $anime_id)
->whereBetween('episode_number', [$startEpisodeNumber, $endEpisodeNumber])
->pluck('episode_number')
->unique()
->sort()
->values()
->toArray();
$duplicatedAnimeEpisodes = DB::table('mp_anime_episodes')
->where('anime_id', $anime_id)
->where('is_default', 1)
->whereBetween('episode_number', [$startEpisodeNumber, $endEpisodeNumber])
->pluck('episode_number')
->unique()
->sort()
->values()
->toArray();
if (!empty($duplicatedBatchEpisodes) || !empty($duplicatedAnimeEpisodes)) {
$duplicateMessages = [];
if (!empty($duplicatedBatchEpisodes)) {
$duplicateMessages[] = '批量生成任务中已存在第' . implode('、', $duplicatedBatchEpisodes) . '集';
}
if (!empty($duplicatedAnimeEpisodes)) {
$duplicateMessages[] = '剧集中已存在第' . implode('、', $duplicatedAnimeEpisodes) . '集';
}
Utils::throwError('20003:' . implode(';', $duplicateMessages) . ',请勿重复创建');
}
// 计算本次实际需要创建的任务数(跳过已存在且未失败的任务;失败任务会删除重建,算作创建)
$existingSkippedCount = DB::table('mp_batch_episode_generation_details')
->where('anime_id', $anime_id)
->whereBetween('episode_number', [$startEpisodeNumber, $endEpisodeNumber])
->whereIn('status', ['pending', 'processing', 'completed'])
->count();
$needEpisodeCount = ($endEpisodeNumber - $startEpisodeNumber + 1) - $existingSkippedCount;
// 积分余额预检:每个剧集生成成功将扣 CHAT_CHARGE_POINTS 积分。
// 此处仅预检不扣费,实际扣费由定时任务调用 chatForAce(经 chatForAceNonStream)时按集扣取,避免重复扣费。
if ($needEpisodeCount > 0) {
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints() * $needEpisodeCount, $uid);
}
$now = date('Y-m-d H:i:s');
$createdTasks = [];
$skippedTasks = [];
// 为每一集创建详情记录
for ($episodeNumber = $startEpisodeNumber; $episodeNumber <= $endEpisodeNumber; $episodeNumber++) {
// 检查是否已有该集的任务记录
$existingTask = DB::table('mp_batch_episode_generation_details')
->where('anime_id', $anime_id)
->where('episode_number', $episodeNumber)
->first();
if ($existingTask) {
// 如果已存在且未完成,跳过
if (in_array($existingTask->status, ['pending', 'processing'])) {
$skippedTasks[] = $episodeNumber;
continue;
}
// 如果是失败状态,可以重新创建(删除旧记录)
if ($existingTask->status === 'failed') {
DB::table('mp_batch_episode_generation_details')
->where('id', $existingTask->id)
->delete();
}
// 如果是已完成状态,也跳过
if ($existingTask->status === 'completed') {
$skippedTasks[] = $episodeNumber;
continue;
}
}
// 准备任务数据
$taskData = [
'anime_id' => $anime_id,
'uid' => $uid,
'cpid' => $cpid,
'episode_number' => $episodeNumber,
'status' => 'pending',
'request_data' => json_encode($data, JSON_UNESCAPED_UNICODE),
'retry_count' => 0,
'created_at' => $now,
'updated_at' => $now
];
// 创建任务记录
$taskId = DB::table('mp_batch_episode_generation_details')->insertGetId($taskData);
if ($taskId) {
$createdTasks[] = $episodeNumber;
}
}
if (empty($createdTasks)) {
if (!empty($skippedTasks)) {
Utils::throwError('20003:所有集数都已存在任务记录,无需重复创建。已跳过集数:' . implode(',', $skippedTasks));
} else {
Utils::throwError('20003:创建批量生成任务失败');
}
}
// 创建/复用任务中心记录(按入参+type验重,避免重复任务)
$taskCenterId = $this->createBatchGenerateEpisodesTaskCenter(
$uid,
$cpid,
$anime_id,
$generate_episode_number,
$startEpisodeNumber,
$endEpisodeNumber
);
return [
'anime_id' => $anime_id,
'start_episode' => $startEpisodeNumber,
'end_episode' => $endEpisodeNumber,
'total_episodes' => $totalEpisodeCount,
'created_episodes' => $createdTasks,
'skipped_episodes' => $skippedTasks,
'message' => "已创建批量生成任务,将生成第{$startEpisodeNumber}集到第{$endEpisodeNumber}集,共{$totalEpisodeCount}集。" .
"已创建: " . implode(',', $createdTasks) .
(!empty($skippedTasks) ? ";已跳过: " . implode(',', $skippedTasks) : ""),
'task_center_id' => $taskCenterId
];
}
/**
* 根据入参创建或复用批量生成分集的任务中心记录
*
* 以入参(anime_id、generate_episode_number)计算 md5 存入 params_md5 字段用于验重:
* 同一用户/企业下,相同入参且任务未结束(pending/processing)时复用已有任务,
* 避免生成重复任务;唯一索引 (uid, cpid, task_type, params_md5) 兜底并发重复。
*
* @param int|string $uid
* @param int|string $cpid
* @param int|string $anime_id
* @param int $generate_episode_number
* @param int $startEpisodeNumber
* @param int $endEpisodeNumber
* @return int 任务中心ID
*/
private function createBatchGenerateEpisodesTaskCenter($uid, $cpid, $anime_id, $generate_episode_number, $startEpisodeNumber, $endEpisodeNumber) {
$params = [
'anime_id' => (int)$anime_id,
'generate_episode_number' => (int)$generate_episode_number,
'start_episode' => (int)$startEpisodeNumber,
'end_episode' => (int)$endEpisodeNumber,
];
// 验重键:入参 + 生成范围(anime_id + generate_episode_number + start/end)+ task_type
$paramsMd5 = md5(json_encode([
'anime_id' => (int)$anime_id,
'generate_episode_number' => (int)$generate_episode_number,
'start_episode' => (int)$startEpisodeNumber,
'end_episode' => (int)$endEpisodeNumber,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
// 验重:相同 md5 且任务未结束(pending/processing)时复用已有任务中心记录
$existing = DB::table('mp_task_center')
->where('uid', (int)$uid)
->where('cpid', (int)$cpid)
->where('task_type', MpTaskCenter::TYPE_BATCH_GENERATE_EPISODES)
->whereIn('status', [MpTaskCenter::STATUS_PENDING, MpTaskCenter::STATUS_PROCESSING])
->where('params_md5', $paramsMd5)
->orderBy('id', 'desc')
->first();
if ($existing) {
dLog('deepseek')->info('复用批量生成分集任务中心记录,避免重复任务', [
'anime_id' => $anime_id,
'task_center_id' => $existing->id,
'params_md5' => $paramsMd5,
]);
return (int)$existing->id;
}
$now = date('Y-m-d H:i:s');
DB::table('mp_task_center')->insertOrIgnore([
'uid' => (int)$uid,
'cpid' => (int)$cpid,
'task_type' => MpTaskCenter::TYPE_BATCH_GENERATE_EPISODES,
'title' => '批量生成分集',
'ref_task_id' => 0,
'status' => MpTaskCenter::STATUS_PROCESSING,
'result' => null,
'error_message' => null,
'prompt' => null,
'params' => json_encode($params + ['params_md5' => $paramsMd5], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'params_md5' => $paramsMd5,
'created_at' => $now,
'updated_at' => $now,
]);
// 获取任务中心ID(insertOrIgnore 冲突时复用并发插入的已有记录)
$taskId = (int)DB::table('mp_task_center')
->where('uid', (int)$uid)
->where('cpid', (int)$cpid)
->where('task_type', MpTaskCenter::TYPE_BATCH_GENERATE_EPISODES)
->where('params_md5', $paramsMd5)
->orderBy('id', 'desc')
->value('id');
dLog('deepseek')->info('创建批量生成分集任务中心记录', [
'anime_id' => $anime_id,
'task_center_id' => $taskId,
'params_md5' => $paramsMd5,
]);
return $taskId;
}
/**
* 批量生成分集全部结束后,更新任务中心记录的状态和结果
*
* 由批量生成 worker 子进程在退出前调用。分集详情任务的状态会在多处更新
* (completed/failed/pending/processing),这里只在所有详情任务均已结束时
* 统一汇总更新,避免在每个更新点重复处理。
*
* @param int|string $animeId
* @return int 更新的任务中心记录数
*/
public function finishBatchGenerateEpisodesTaskCenters($animeId) {
$animeId = (int)$animeId;
$updated = 0;
// 该 anime 是否还有未结束的详情任务
$hasUnfinished = DB::table('mp_batch_episode_generation_details')
->where('anime_id', $animeId)
->whereIn('status', ['pending', 'processing'])
->exists();
// 扫描该 anime 相关的未完成任务中心记录
$tasks = DB::table('mp_task_center')
->where('task_type', MpTaskCenter::TYPE_BATCH_GENERATE_EPISODES)
->whereIn('status', [MpTaskCenter::STATUS_PENDING, MpTaskCenter::STATUS_PROCESSING])
->orderBy('id', 'desc')
->limit(500)
->get();
if ($hasUnfinished && !$tasks) {
return 0;
}
foreach ($tasks as $task) {
try {
$taskParams = json_decode((string)$task->params, true);
if (!is_array($taskParams) || (int)($taskParams['anime_id'] ?? 0) !== $animeId) {
continue;
}
$startEpisode = (int)($taskParams['start_episode'] ?? 0);
$endEpisode = (int)($taskParams['end_episode'] ?? 0);
if ($startEpisode < 1 || $endEpisode < $startEpisode) {
continue;
}
$details = DB::table('mp_batch_episode_generation_details')
->where('anime_id', $animeId)
->whereBetween('episode_number', [$startEpisode, $endEpisode])
->get();
// 该批次范围内存在缺失记录,等待下一次检查
if ($details->isEmpty() || $details->count() < ($endEpisode - $startEpisode + 1)) {
continue;
}
$completedEpisodes = [];
$failedEpisodes = [];
$allFinished = true;
foreach ($details as $detail) {
if ($detail->status === 'completed') {
$completedEpisodes[] = $detail->episode_number;
} elseif ($detail->status === 'failed') {
$failedEpisodes[] = [
'episode_number' => $detail->episode_number,
'error_message' => $detail->error_message ?? '',
];
} else {
// pending/processing(防御处理,正常不会走到)
$allFinished = false;
break;
}
}
if (!$allFinished) {
continue;
}
$result = json_encode([
'anime_id' => $animeId,
'start_episode' => $startEpisode,
'end_episode' => $endEpisode,
'total_episodes' => count($details),
'completed_count' => count($completedEpisodes),
'failed_count' => count($failedEpisodes),
'completed_episodes' => $completedEpisodes,
'failed_episodes' => $failedEpisodes,
], JSON_UNESCAPED_UNICODE);
DB::table('mp_task_center')
->where('id', $task->id)
->update([
'status' => !empty($failedEpisodes) ? MpTaskCenter::STATUS_FAILED : MpTaskCenter::STATUS_SUCCESS,
'result' => $result,
'error_message' => !empty($failedEpisodes) ? ('有 ' . count($failedEpisodes) . ' 集生成失败') : null,
'updated_at' => date('Y-m-d H:i:s'),
]);
$updated++;
dLog('command')->info('批量生成分集任务已完成,更新任务中心', [
'task_center_id' => $task->id,
'anime_id' => $animeId,
'start_episode' => $startEpisode,
'end_episode' => $endEpisode,
]);
} catch (\Exception $e) {
dLog('command')->error('更新批量生成分集任务中心异常', [
'task_center_id' => $task->id,
'anime_id' => $animeId,
'error' => $e->getMessage(),
]);
}
}
return $updated;
}
/**
* 获取批量生成任务状态
*
* @param array $data 请求数据
* @return array 任务状态信息
*/
public function getBatchGenerationTaskStatus($data) {
$anime_id = getProp($data, 'anime_id');
if (!$anime_id) {
Utils::throwError('20003:anime_id参数缺失');
}
// 获取该anime的所有任务
$tasks = DB::table('mp_batch_episode_generation_details')
->where('anime_id', $anime_id)
->orderBy('episode_number')
->get()
->map(function($item) {
return [
'id' => $item->id,
'episode_number' => $item->episode_number,
'status' => $item->status,
'error_message' => $item->error_message,
'retry_count' => $item->retry_count,
'created_at' => $item->created_at,
'updated_at' => $item->updated_at,
'completed_at' => $item->completed_at
];
})
->toArray();
if (empty($tasks)) {
Utils::throwError('20003:该剧集没有批量生成任务');
}
// 统计各状态数量
$totalCount = count($tasks);
$pendingCount = 0;
$processingCount = 0;
$completedCount = 0;
$failedCount = 0;
$failed_episodes = [];
foreach ($tasks as $task) {
switch ($task['status']) {
case 'pending':
$pendingCount++;
break;
case 'processing':
$processingCount++;
break;
case 'completed':
$completedCount++;
break;
case 'failed':
$failedCount++;
// 组装失败分集对象(新对象):分集序号、错误原因
$failed_episodes[] = [
'episode_number' => $task['episode_number'],
'error_message' => $task['error_message'] ?? ''
];
break;
}
}
// 计算进度百分比
$progress = 0;
if ($totalCount > 0) {
$progress = round(($completedCount / $totalCount) * 100, 2);
}
// 判断整体状态
$overallStatus = 'processing';
if ($completedCount === $totalCount) {
$overallStatus = 'completed';
} elseif ($pendingCount === $totalCount) {
$overallStatus = 'pending';
} elseif ($failedCount > 0 && ($pendingCount + $processingCount) === 0) {
$overallStatus = 'failed';
}
return [
'anime_id' => $anime_id,
'total_episodes' => $totalCount,
'pending_count' => $pendingCount,
'processing_count' => $processingCount,
'completed_count' => $completedCount,
'failed_count' => $failedCount,
'progress' => $progress,
'overall_status' => $overallStatus,
'failed_episodes' => $failed_episodes,
'tasks' => $tasks
];
}
/**
* 将生成内容中不在强制列表中的资源合并进强制列表
* 用于最后一次重试仍校验失败时,兼容剧情需要的新资产
*
* @param array $generated 生成内容中的资源列表
* @param array $forced 强制资源列表(key为资源名称)
* @param string $type 资源类型:role、scene、prop
* @return array
*/
private function mergeGeneratedResources(array $generated, array $forced, string $type): array
{
foreach ($generated as $item) {
$name = getProp($item, $type === 'role' ? 'role' : ($type === 'scene' ? 'scene' : 'prop'));
if ($name === null || $name === '') {
continue;
}
if ($name === '旁白') {
continue;
}
if (!isset($forced[$name])) {
$forced[$name] = $item;
}
}
return $forced;
}
/**
* 智能匹配并替换主体和场景名称
* 如果生成的主体或场景名称是强制设定名称的子串,则自动替换为完整名称
*
* @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);
$check_mode = (int)getProp($data, 'check_mode', 0); // 1=预览确认模式(仅 act_id 模式生效,不落库,返回 editAct 入参)
// 单片段模式:act_id 可以是数据库真实ID(数字)或预览生成的虚拟ID(字符串),非空即单片段模式
$isActIdMode = !empty($act_id);
// 验证: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只能选择一个');
}
// 积分余额预检:act_id 单片段模式按 normal 档,episode_id 剧集模式按 special 档
$chargeType = $isActIdMode ? 'normal' : 'special';
if ($isActIdMode) {
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints('', $chargeType), $uid);
} else {
$is_generated = DB::table('mp_anime_episodes')->where('id', $episode_id)->value('is_generated');
if ($is_generated) Utils::throwError('20003:该分集已不支持重新生成片段,请创建副本后重试!');
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints('', $chargeType), $uid);
}
// 如果提供了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)) {
$trimmedPrompt = trim((string)$prompt);
if ($trimmedPrompt !== '' && mb_strtolower($trimmedPrompt) === mb_strtolower(trim($template_prompt))) {
$final_prompt = $template_prompt;
} elseif (!empty($prompt)) {
$final_prompt = $template_prompt . "\n\n补充要求:" . $prompt;
} else {
$final_prompt = $template_prompt;
}
} else {
$final_prompt = $prompt;
}
// 获取需要重新生成的内容参考
$reference_content = '';
$target_episode_id = 0;
$target_anime_id = 0;
$target_episode_number = 0;
if ($isActIdMode) {
// 单个片段模式:获取该片段的内容
$segment = DB::table('mp_episode_segments')
->where('id', $act_id)
->first();
// 数据库不存在时,尝试从预览虚拟 act_id 保存的 redis 数据中恢复
if (!$segment) {
$previewData = Redis::get($this->buildActPreviewKey($act_id));
if ($previewData) {
$previewData = json_decode($previewData, true);
$segment = (object)[
'anime_id' => getProp($previewData, 'anime_id'),
'episode_id' => getProp($previewData, 'episode_id'),
'episode_number' => getProp($previewData, 'episode_number'),
'act_number' => getProp($previewData, 'act_number'),
'act_duration' => getProp($previewData, 'act_duration'),
'act_content' => getProp($previewData, 'act_content'),
];
}
}
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}";
//$reference_content = "\n\n【本集原始内容】\n{$episode_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);
$props = json_decode($episode->props ?? '[]', 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);
}
}
$props_ref = '';
if (!empty($props) && is_array($props)) {
$props_list = [];
foreach ($props as $prop) {
$prop_name = getProp($prop, 'prop', '');
$prop_desc = getProp($prop, 'description', '');
$pic_prompt = getProp($prop, 'pic_prompt', '');
if (!empty($prop_name)) {
$prop_line = $prop_name;
if (!empty($prop_desc)) $prop_line .= ":" . $prop_desc;
if (!empty($pic_prompt)) $prop_line .= "{" . $pic_prompt . "}";
$props_list[] = $prop_line;
}
}
if (!empty($props_list)) {
$props_ref = "\n\n【道具列表参考】\n" . implode("\n", $props_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 .= "\n\n【生成要求】\n- 请以【原分段剧本内容】为蓝本,保留剧情脉络、角色、场景与分段结构,但必须围绕【用户要求】对相关片段进行实质性修改;若【用户要求】未指定具体修改点,也需对画面、运镜、台词等描述进行改写,禁止整段原样复制。\n";
// $user_prompt .= $roles_ref;
// $user_prompt .= $scenes_ref;
// 输出格式铁律(用户消息侧再次强制,避免模型自由发挥)
$user_prompt .= "\n\n【输出格式铁律-必须严格遵守】\n";
if ($isActIdMode) {
$user_prompt .= "- 请直接以“##片段1”开头,紧跟“时长:X s”行,然后输出本片段的完整分镜内容(分镜序号、出场角色、场景、出场道具、画面、运镜、配音台词、背景音效),分镜从1开始连续编号。\n";
$user_prompt .= "- 只允许根据【原分段剧本内容】生成一个片段(即“##片段1”),禁止生成多个片段。\n";
} else {
$user_prompt .= "- 请以“###分段剧本”开头,依次输出“片段数量:N”、“旁白音色:...”,然后按“##片段N”逐段输出,每段必须紧跟“时长:X s”行,再输出该片段的完整分镜内容(分镜序号、出场角色、场景、出场道具、画面、运镜、配音台词、背景音效),分镜从1开始连续编号。\n";
}
$user_prompt .= "- 缺少“##片段N”标题或“时长:”行即为格式错误;输出前必须检查格式,不得输出其他任何内容。\n";
$user_prompt .= "- 片段时长必须在4秒到15秒之间(4s≤时长≤15s),超过15秒必须拆分为多个片段,不得输出低于4秒或超过15秒的片段。\n";
// 整集模式:强制生成与原来一致的片段数量
if ($episode_id > 0 && isset($segments)) {
$originSegmentCount = count($segments);
$user_prompt .= "- 本集原有{$originSegmentCount}个片段,必须严格生成{$originSegmentCount}个片段(##片段1 到 ##片段{$originSegmentCount}),不得多不得少。\n";
}
$system_prompt = "\n\n【输出格式铁律-最高优先级】\n";
if ($isActIdMode) {
$system_prompt .= "- 直接以“##片段1”开头,紧跟“时长:X s”行,然后输出本片段分镜内容\n";
$system_prompt .= "- 只允许根据【原分段剧本内容】生成一个片段(即“##片段1”),禁止生成多个片段。\n";
} else {
$system_prompt .= "- 以“###分段剧本”开头,输出“片段数量:N”、“旁白音色:...”\n";
$system_prompt .= "- 然后按“##片段N”逐段输出,每段紧跟“时长:X s”行\n";
}
$system_prompt .= "- 每个片段必须严格以“##片段N”开头(N从1开始连续),并且必须包含“时长:X s”行,缺少即为格式错误\n";
$system_prompt .= "- 片段时长必须在4~15秒之间(4s≤时长≤15s),超过15秒必须拆分为多个片段,禁止输出低于4秒或超过15秒的片段\n";
$system_prompt .= "- 输出前检查:若缺少##片段N标题或时长行,视为输出无效,必须修正后输出\n\n";
$system_prompt .= "【强制要求】\n";
// $system_prompt .= "- 请严格参考【本集原始内容】或【原分段剧本内容】进行本次生成,注意新生成的分段剧本内容一定不要跟【原分段剧本内容】一致,要进行一定程度调整\n";
$system_prompt .= "- 【重要】必须参考【本集原始内容】与【原分段剧本内容】中的剧情脉络、角色、场景、道具和分段结构,但严禁原样复制或几乎照抄原文;必须围绕【用户要求】对相关内容进行实质性修改,若生成结果与【原分段剧本内容】完全一致或未体现用户要求,视为生成失败\n";
$system_prompt .= "- 请仅生成<分段剧本>部分,不要生成其他任何内容:\n";
$system_prompt .= "- 第一优先级:片段时长上限(单个片段不超过15秒,超过必须切为新片段)\n";
$system_prompt .= "- 每个分镜包含:分镜序号、出场角色、场景、出场道具、画面、运镜、配音台词、背景音效\n";
$system_prompt .= "- 场景必须从【场景列表参考】中选择(如果提供了场景列表),场景名必须精确匹配【场景列表参考】中的名称(如需匹配“酒店会客室-景池筹备会版”,而不是“酒店会客室”,不得缺少“-”前后和“:”前面的任何内容,不能自创,不能自创){$scenes_ref}\n";
$system_prompt .= "- 出场角色(如果有出现在本分镜)必须从【主体列表参考】中选择(如果提供了主体列表),角色名必须精准匹配【主体列表参考】中的名称(如需匹配“沈秋岚-程家居家围裙造型”,而不是“沈秋岚”,不得缺少“-”前后和“:”前面的任何内容,不能自创,不能自创){$roles_ref}\n";
$system_prompt .= "- 出场道具(如果有出现在本分镜)必须从【道具列表参考】中选择(如果提供了道具列表),道具名必须精准匹配【道具列表参考】中的名称(如需匹配“智能手机-黑色版”,而不是“智能手机”,不得缺少“-”前后和“:”前面的任何内容,不能自创,不能自创){$props_ref}\n";
$system_prompt .= "- 【重要】在“画面:”和“运镜:”描述中,如果提到角色或道具,必须使用【主体列表参考】或【道具列表参考】中的完整名称,一个字都不能少,一个字都不能改。例如:正确写法“运镜:镜头推向{林启明-黑色大衣七爷造型}”,错误写法“运镜:镜头推向林启明”(缺少造型后缀)。必须包含“-”及其后面的所有内容,不得简化、不得省略、不得自行修改\n";
$system_prompt .= "- 保持美术风格的一致性(如果提供了美术风格参考)\n";
$system_prompt .= "- 【重要】每个片段必须以“##片段N”开头(N为序号),并且必须包含“时长:X s”行,缺少片段标题或时长视为格式错误\n";
// 按模式构造强制输出格式:只传 act_id(单片段)时不需要 ###分段剧本/片段数量/旁白音色;传 episode_id(整集)时需要完整格式
$segmentHeader = '';
if ($episode_id > 0) {
$segmentHeader = "###分段剧本\n片段数量:8\n旁白音色:成年男性(约35-50岁),音色厚重且富有磁性,带有老电影叙事般的沧桑感,语调低沉平稳,富有江湖气息。\n\n";
}
$system_prompt .= "- 参考示例格式如下,此为第一优先规则,必须按以下格式生成内容,请忽略其他任何提示词部分关于规则的描述:\n" . $segmentHeader . "##片段1\n时长:12s\n分镜1\n出场角色:刀疤脸\n场景:边境小镇街道\n出场道具:酒杯-高脚杯\n画面:[大远景,冷色调,高对比度] 漫天黄沙在狂风中如怒龙翻滚,遮蔽了微弱的星光。悦来酒馆残破的木门在风中剧烈摇晃,铁链撞击木板发出沉重的闷响,四周建筑尽显荒凉。\n运镜:大远景固定镜头,随后风沙席卷向镜头形成遮挡转场。\n配音台词:\n背景音效:\n\n分镜2\n出场角色:刀疤脸\n场景:悦来酒馆\n出场道具:酒杯-高脚杯\n画面:[全景,暖色调阴影构图] 酒馆内部昏暗,几盏油灯在风中疯狂摇曳,投射出扭曲的长影。角落里的食客低头缩颈,双手紧紧攥着粗瓷碗,空气中漂浮着细小的尘埃。\n运镜:缓慢横移,掠过阴影中瑟缩的食客。\n配音台词:\n背景音效:\n\n分镜3\n出场角色:刀疤脸\n场景:悦来酒馆\n出场道具:酒杯-高脚杯\n画面:[中景,三分法构图] 三名大汉围坐在正中央的圆桌旁,腰间挂着的“武林盟”金色令牌在灯火下闪烁。桌上残羹冷炙横陈,一名大汉正张狂地大笑,脚踩在长凳上。\n运镜:从中景推向令牌特写。\n配音台词:\n背景音效:\n\n分镜4\n出场角色:刀疤脸\n场景:悦来酒馆\n出场道具:酒杯-高脚杯\n画面:[特写,低角度仰拍] 刀疤脸横肉颤动,嘴角挂着轻蔑的笑。他随手倾覆手中的酒碗,琥珀色的液体在空中划过弧线,重重溅在老李打着补丁的布鞋和裤脚上。\n运镜:极速推向酒水溅起的瞬间。\n配音台词:\n背景音效:\n\n分镜5\n出场角色:刀疤脸\n场景:悦来酒馆\n出场道具:酒杯-高脚杯\n画面:[近景,荷兰角倾斜构图] 刀疤脸斜睨着眼,昏暗的灯光勾勒出他脸上狰狞的伤疤。他身体前倾,阴影完全笼罩了对面的老李,眼神如毒蛇般阴冷。\n运镜:固定机位,利用倾斜构图制造压迫感。\n配音台词:中文配音:[刀疤脸]老李头,这酒里掺水了吧?\n背景音效:";
// 获取模型配置
$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' => 1.35,
'frequency_penalty' => 0.3,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'thinking' => ['type' => $thinkingMode],
'stream' => false // 非流式输出
];
if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort;
// 调用AI生成新的分段剧本
try {
$fullContent = '';
$usage = [];
// 根据模型类型选择调用方法
$chatResult = $this->dispatchChatOnlyByModel($post_data);
if (is_array($chatResult)) {
$fullContent = $chatResult['fullContent'] ?? '';
$usage = $chatResult['usage'] ?? [];
}
$generated_content = $fullContent;
// 归一化"场景:"标签换行格式:场景:\n场景名\n => 场景:场景名\n,保证场景字段为一行
$generated_content = preg_replace('/场景[::]\s*\n\s*([^\n]+)/u', '场景:$1', $generated_content);
if (empty($generated_content)) {
Utils::throwError('20003:AI生成内容为空,请重试');
}
dLog('deepseek')->info('regenerateSegmentScript AI生成内容', [
'act_id' => $act_id,
'episode_id' => $episode_id,
'content_length' => mb_strlen($generated_content),
'content_head' => mb_substr($generated_content, 0, 500),
'content_tail' => mb_substr($generated_content, -200),
]);
// 解析生成的内容 - 按片段分割
$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];
}
}
// 时长范围约束:4~15秒,超限兜底修正(0=未知按最低4秒)
if ($actDuration > 15) {
$actDuration = 15;
}
if ($actDuration < 4) {
$actDuration = 4;
}
// 清理act_content:去除时长、旁白音色等非分镜内容行,只保留分镜内容
$cleaned_content = $this->cleanSegmentContent($actContent);
$parsed_segments[] = [
'act_number' => $actNumber,
'act_title' => $actTitle,
'act_duration' => $actDuration,
'act_content' => $cleaned_content,
];
}
// 兜底:模型未输出 ##片段N 标题时(如直接输出分镜正文),将整个生成内容作为单个片段解析(act_id 单片段场景)
if (empty($parsed_segments)) {
$fallbackContent = $this->cleanSegmentContent($generated_content);
if ($fallbackContent !== '') {
$fallbackDuration = 0;
if (preg_match('/时长[::]\s*([^\n]+)/u', $generated_content, $durationMatch)) {
if (preg_match('/([-+]?[0-9]*\.?[0-9]+)/', $durationMatch[1], $numMatch)) {
$fallbackDuration = (float)$numMatch[1];
}
}
// 内容未带时长时,单片段模式沿用原片段的时长
if ($fallbackDuration <= 0 && $isActIdMode && !empty($segment->act_duration)) {
$fallbackDuration = (float)$segment->act_duration;
}
// 时长范围约束:4~15秒,超限兜底修正(0=未知按最低4秒)
if ($fallbackDuration > 15) {
$fallbackDuration = 15;
}
if ($fallbackDuration < 4) {
$fallbackDuration = 4;
}
$parsed_segments[] = [
'act_number' => 1,
'act_title' => '片段1',
'act_duration' => $fallbackDuration,
'act_content' => $fallbackContent,
];
}
}
// act_id 单片段模式:只允许一个片段,多余片段丢弃,仅保留第一个
if ($isActIdMode && count($parsed_segments) > 1) {
dLog('deepseek')->info('regenerateSegmentScript act_id 模式仅保留第一个片段', [
'act_id' => $act_id,
'original_count' => count($parsed_segments),
]);
$parsed_segments = array_slice($parsed_segments, 0, 1);
}
dLog('deepseek')->info('regenerateSegmentScript 解析结果', [
'act_id' => $act_id,
'episode_id' => $episode_id,
'segment_count' => count($parsed_segments),
'segments' => array_map(function ($seg) {
return [
'act_number' => $seg['act_number'],
'act_duration' => $seg['act_duration'],
'content_length' => mb_strlen($seg['act_content']),
];
}, $parsed_segments),
]);
if (empty($parsed_segments)) {
dLog('deepseek')->error('regenerateSegmentScript 无法解析生成的分段剧本', [
'act_id' => $act_id,
'episode_id' => $episode_id,
'content_length' => mb_strlen($generated_content),
'content_head' => mb_substr($generated_content, 0, 1000),
'content_tail' => mb_substr($generated_content, -300),
]);
Utils::throwError('20003:无法解析生成的分段剧本,请检查格式');
}
// 收集所有资产名称(角色、场景、道具)
$product_names = [];
// 收集角色名称
if (!empty($roles) && is_array($roles)) {
foreach ($roles as $role) {
$role_name = getProp($role, 'role', '');
if (!empty($role_name)) {
$product_names[] = $role_name;
}
}
}
// 收集场景名称
if (!empty($scenes) && is_array($scenes)) {
foreach ($scenes as $scene) {
$scene_name = getProp($scene, 'scene', '');
if (!empty($scene_name)) {
$product_names[] = $scene_name;
}
}
}
// 收集道具名称
if (!empty($props) && is_array($props)) {
foreach ($props as $prop) {
$prop_name = getProp($prop, 'prop', '');
if (!empty($prop_name)) {
$product_names[] = $prop_name;
}
}
}
// 去重并按长度降序排序(避免短名称先匹配到长名称的一部分)
$product_names = array_unique($product_names);
usort($product_names, function($a, $b) {
return mb_strlen($b) - mb_strlen($a);
});
// 处理每个片段的 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;
// 统一替换所有资产名称为 {资产名} 格式
// 使用占位符避免嵌套替换问题
$placeholder_map = [];
$placeholder_index = 0;
foreach ($product_names as $product_name) {
// 转义特殊字符
$escaped_product = preg_quote($product_name, '/');
// 检查是否匹配且未被 {} 包裹
$processed_content = preg_replace_callback(
'/(?>>';
$placeholder_map[$placeholder] = '{' . $matches[1] . '}';
$placeholder_index++;
return $placeholder;
},
$processed_content
);
}
// 将所有占位符替换回实际内容
foreach ($placeholder_map as $placeholder => $replacement) {
$processed_content = str_replace($placeholder, $replacement, $processed_content);
}
// 处理【镜头X】或【分镜X】重新编号为从1开始的连续整数【镜头X】
$shot_counter = 0;
$processed_content = preg_replace_callback(
// 兼容“【镜头 1】”“【分镜 1】”等分镜/镜头与数字间带空格的情况
'/【(?:镜头|分镜)\s*(\d+)】/u',
function($matches) use (&$shot_counter) {
$shot_counter++;
return '【镜头' . $shot_counter . '】';
},
$processed_content
);
$segment['act_show_content'] = $processed_content;
}
unset($segment); // 解除引用
// 保存到数据库
$now = date('Y-m-d H:i:s');
$saved_acts = []; // 用于记录保存后的片段信息
if ($isActIdMode) {
// 单个片段模式:更新单条记录
if (count($parsed_segments) > 0) {
$new_segment = $parsed_segments[0];
// 预览确认模式(check_mode=1):不落库,组装 editAct 入参返回,由前端确认后调用 editAct 更新
if ($check_mode === 1) {
// 单片段预览生成即按 normal 档扣费
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $target_anime_id,
'episode_id' => $target_episode_id,
'model' => $model,
'source' => 'regenerateSegmentScript',
], 'normal');
return [
'check_mode' => 1,
'act_id' => $act_id,
'act_title' => getProp($new_segment, 'act_title', ''),
'act_duration' => getProp($new_segment, 'act_duration', 0),
'act_content' => getProp($new_segment, 'act_show_content', ''),
];
}
// 虚拟 act_id 仅用于预览/参考,不允许直接保存到数据库
if (!is_numeric($act_id)) {
Utils::throwError('20003:该片段不可直接保存');
}
$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]);
// 单片段模式成功扣费(normal 档)
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $target_anime_id,
'episode_id' => $target_episode_id,
'model' => $model,
'source' => 'regenerateSegmentScript',
], 'normal');
}
} else {
// 预览确认模式(check_mode=1):不落库,返回展示参数供前端确认后调用 saveEpisodeActs 保存
if ($check_mode === 1) {
// 剧集模式预览生成即扣除积分并记录 token 明细(special 档)
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $target_anime_id,
'episode_id' => $target_episode_id,
'model' => $model,
'source' => 'regenerateSegmentScript',
], 'special');
// 为每个片段生成虚拟 act_id 并写入 redis(1天有效),供后续按虚拟 act_id 恢复片段数据
$previewActs = [];
foreach ($parsed_segments as $seg) {
$virtualActId = 'preview_' . time() . '_' . uniqid();
Redis::setex($this->buildActPreviewKey($virtualActId), 86400, json_encode([
'anime_id' => $target_anime_id,
'episode_id' => $target_episode_id,
'episode_number' => $target_episode_number,
'act_number' => $seg['act_number'],
'act_title' => $seg['act_title'],
'act_duration' => $seg['act_duration'],
'act_content' => $seg['act_content'],
'act_show_content'=> $seg['act_show_content'],
], JSON_UNESCAPED_UNICODE));
$previewActs[] = [
'act_id' => $virtualActId,
'act_number' => $seg['act_number'],
'act_title' => $seg['act_title'],
'act_duration' => $seg['act_duration'],
'act_content' => $seg['act_content'],
'act_show_content' => $seg['act_show_content'],
'video_url' => '',
'video_duration' => 0,
'video_time_point_start' => 0,
'video_time_point_end' => 0,
'last_frame_url' => '',
'current_type' => 0,
];
}
return [
'check_mode' => 1,
'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' => $previewActs,
];
}
// 整个剧集模式:删除原有记录并批量保存新记录
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);
}
// 剧集ID模式调用成功,返回结果前扣除积分并记录明细(special 档,与保存同事务)
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'anime_id' => $target_anime_id,
'episode_id' => $target_episode_id,
'model' => $model,
'source' => 'regenerateSegmentScript',
], 'special');
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;
}
}
/**
* 保存剧集片段(预览确认模式使用):删除旧数据并批量保存新数据到 mp_episode_segments
*
* 逻辑与 regenerateSegmentScript 的 episode_id + check_mode=0 保存保持一致。
*
* @param array $data 请求参数:episode_id 必填,acts 必填(预览返回的片段数组)
* @return array
*/
public function saveEpisodeActs($data) {
$uid = Site::getUid();
$episode_id = (int)getProp($data, 'episode_id', 0);
$acts = getProp($data, 'acts', []);
if (!$episode_id) {
Utils::throwError('20003:请提供剧集ID');
}
if (!is_array($acts) || empty($acts)) {
Utils::throwError('20003:请提供片段数据');
}
$episode = DB::table('mp_anime_episodes')->where('id', $episode_id)->first();
if (!$episode) {
Utils::throwError('20003:剧集不存在');
}
// 用户校验:只能调整自己的剧集
$anime = DB::table('mp_animes')->where('id', $episode->anime_id)->first();
if (!$anime || (int)$anime->user_id !== (int)$uid) {
Utils::throwError('20003:没有权限操作该剧集');
}
$anime_id = (int)$episode->anime_id;
$episode_number = (int)$episode->episode_number;
$now = date('Y-m-d H:i:s');
try {
DB::beginTransaction();
// 删除原有的所有片段记录
DB::table('mp_episode_segments')
->where('episode_id', $episode_id)
->delete();
// 批量插入新的片段记录
$segments_to_insert = [];
$index = 0;
foreach ($acts as $act) {
if (!is_array($act)) {
continue;
}
$index++;
$segments_to_insert[] = [
'anime_id' => $anime_id,
'episode_id' => $episode_id,
'episode_number' => $episode_number,
'act_number' => (int)getProp($act, 'act_number', $index),
'act_title' => (string)getProp($act, 'act_title', ''),
'act_duration' => (float)getProp($act, 'act_duration', 0),
'act_content' => (string)getProp($act, 'act_content', ''),
// 'act_show_content' => (string)getProp($act, 'act_show_content', ''),
'created_at' => $now,
'updated_at' => $now,
];
}
if (empty($segments_to_insert)) {
DB::rollBack();
Utils::throwError('20003:没有有效的片段数据');
}
DB::table('mp_episode_segments')->insert($segments_to_insert);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
dLog('deepseek')->error('保存剧集片段失败: ' . $e->getMessage(), [
'episode_id' => $episode_id,
]);
Utils::throwError('20003:保存失败,请重试');
}
// 查询保存后的所有片段记录(数组化,与 episodeInfoForAce 处理一致)
$saved_segments = DB::table('mp_episode_segments')
->where('episode_id', $episode_id)
->orderBy('act_number')
->get()
->map(function ($value) {
return (array)$value;
})
->toArray();
// 构造与 episodeInfoForAce 一致的 acts 与 total_duration
$acts = [];
$totalDuration = 0;
foreach ($saved_segments as $segment) {
$videoDuration = getProp($segment, 'video_duration');
$actDuration = getProp($segment, 'act_duration');
if (!empty($videoDuration) && is_numeric($videoDuration)) {
$totalDuration += floatval($videoDuration);
} elseif (!empty($actDuration) && is_numeric($actDuration)) {
$totalDuration += floatval($actDuration);
} else {
$totalDuration += 5;
}
$acts[] = [
'act_id' => getProp($segment, 'id'),
'act_number' => getProp($segment, 'act_number'),
'act_title' => getProp($segment, 'act_title'),
'act_duration' => getProp($segment, 'act_duration'),
'act_content' => getProp($segment, 'act_content'),
'video_url' => getProp($segment, 'video_url'),
'video_duration' => getProp($segment, 'video_duration', 0),
'video_time_point_start' => getProp($segment, 'video_time_point_start', 0),
'video_time_point_end' => getProp($segment, 'video_time_point_end', getProp($segment, 'video_duration', 0)),
'last_frame_url' => getProp($segment, 'last_frame_url'),
'current_type' => getProp($segment, 'current_type'),
];
}
// 顶层与 episodeInfoForAce 一致
$result = [
'episode_id' => $episode_id,
'anime_id' => $anime_id,
'episode_number' => $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),
'props' => getProp($episode, 'props') ? json_decode(getProp($episode, 'props'), true) : [],
'is_generated' => getProp($episode, 'is_generated'),
'acts' => array_values($acts),
'total_duration' => intval(round($totalDuration)),
];
// video_params(从 mp_animes 取,与 episodeInfoForAce 一致)
$animeRow = DB::table('mp_animes')->where('id', $anime_id)->first(['video_model', 'video_resolution', 'video_ratio']);
$result['video_params'] = [
'video_model' => getProp($animeRow, 'video_model'),
'video_resolution' => getProp($animeRow, 'video_resolution'),
'ratio' => getProp($animeRow, 'video_ratio'),
];
return $result;
}
/**
* 预览虚拟 act_id 在 redis 中的键
*
* @param mixed $actId
* @return string
*/
private function buildActPreviewKey($actId): string
{
return 'anime_act_preview:' . $actId;
}
/**
* 清理分镜内容:去除时长、旁白音色等非分镜行,只保留完整分镜内容(分镜标记统一为【分镜N】/【镜头N】)
*
* @param string $actContent
* @return string
*/
private function cleanSegmentContent(string $actContent): string
{
$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('/^(?:【)?(分镜|镜头)\s*(\d+)(?:】)?/u', $trimmed_line, $shot_match)) {
$count++;
$is_capturing = true;
// 统一归一化为【分镜N】/【镜头N】(兼容“镜头 1”“【镜头 1】”等写法)
$trimmed_line = preg_replace('/^(?:【)?(分镜|镜头)\s*(\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);
return $cleaned_content;
}
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') : [],
'props' => is_array(getProp($script_arr, 'props')) ? getProp($script_arr, 'props') : [],
'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 = array_values(array_filter($generated_roles, function ($item) {
return !empty($item['role'] ?? null);
}));
$merged_scenes = array_values(array_filter($generated_scenes, function ($item) {
return !empty($item['scene'] ?? null);
}));
$merged_props = array_values(array_filter($generated_props, function ($item) {
return !empty($item['prop'] ?? null);
}));
// $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'] : '';
$role['three_view_image_url'] = isset($ref_products[$role_name]['three_view_image_url']) ? $ref_products[$role_name]['three_view_image_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'] : '';
$scene['three_view_image_url'] = isset($ref_products[$scene_name]['three_view_image_url']) ? $ref_products[$scene_name]['three_view_image_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'] : '';
$prop['three_view_image_url'] = isset($ref_products[$prop_name]['three_view_image_url']) ? $ref_products[$prop_name]['three_view_image_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) {
// 文生文计费预检(normal 档)
$uid = Site::getUid();
$this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints('', 'normal'), $uid);
$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 // 是否启用流式输出
];
$chatResult = $this->dispatchChatOnlyByModel($post_data);
if (is_array($chatResult)) {
extract($chatResult);
}else {
Utils::throwError('20003: 接口调用失败!');
}
// 文生文成功计费(normal 档):同时记录积分与 token 明细
$this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
'model' => $model,
'source' => 'chatChangeImg',
], 'normal');
return [
'type' => 'done',
// 'episode' => $episode,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
/**
* 仅调用 deepseek 对话接口(非流式)
*
* @param array $post_data
* @param int $timeout
* @return array
*/
private function chatOnly($post_data, $timeout = 1800) {
$post_data['max_tokens'] = 300000;
$client = new Client(['timeout' => $timeout, '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
];
}
/**
* 用 deepseek-v4-flash 非流式分析报错,返回不超过50字的用户友好提示
*
* @param string $errorMessage
* @return string|null
*/
public function analyzeErrorMessage($errorMessage)
{
try {
$post_data = [
'model' => 'deepseek-v4-flash',
'messages' => [
[
'role' => 'system',
'content' => '你是视频生成任务的报错分析助手。请根据用户给出的原始报错,用一句中文提示告诉用户问题原因和下一步怎么做,不超过50个字,直接输出提示内容,不要解释、不要输出代码。',
],
[
'role' => 'user',
'content' => (string)$errorMessage,
],
],
'temperature' => 0.3,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'stream' => false,
];
$result = $this->chatOnly($post_data, 8);
// 调用成功即记录 token 使用明细(不扣积分),保证错误分析这类隐性 AI 调用进入统计口径
$this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($result['usage'] ?? []), [
'model' => 'deepseek-v4-flash',
'source' => 'analyzeErrorMessage',
], 'deepseek-v4-flash');
$content = trim((string)($result['fullContent'] ?? ''));
if ($content === '') {
return null;
}
return mb_substr($content, 0, 50);
} catch (\Exception $e) {
dLog('deepseek')->error('视频任务报错分析失败: ' . $e->getMessage(), [
'error_message' => $errorMessage
]);
return null;
}
}
// 仅调用 GPT-5.4 对话接口(非流式)
private function gpt54ChatOnly($post_data) {
// 按模型取 Key:gpt-5.6-luna 使用 GPT_56_LUNA_API_KEY,其余使用 GPT_54_API_KEY
$modelName = (string)getProp($post_data, 'model', 'gpt-5.4');
// gpt-5.4 中转已下线,强制映射到 gpt-5.6-sol
if ($modelName === 'gpt-5.4') {
$modelName = 'gpt-5.6-terra';
$post_data['model'] = $modelName;
}
$lunaApiKey = env('GPT_56_LUNA_API_KEY');
$apiKey = $modelName === 'gpt-5.6-luna' ? $lunaApiKey : env('GPT_54_API_KEY');
$usedLunaFallback = false;
if (empty($apiKey)) {
Utils::throwError('20003:GPT-5.4 API Key未配置');
}
// 先探活,服务不可用时自动重试
$this->ensureGptServiceAvailable($apiKey);
$client = new Client(['timeout' => 1800, 'verify' => false]);
// 移除 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;
// GPT-5.x 上游不支持 max_tokens,与 gpt54StreamResponse 保持一致,显式移除避免 400。
unset($post_data['max_tokens']);
// GPT-5.x 上游不接受 frequency_penalty / presence_penalty,移除默认值 0 不影响行为。
unset($post_data['frequency_penalty'], $post_data['presence_penalty']);
// 确保 stream 为 false
$post_data['stream'] = false;
$maxRetries = $this->gptRetryTimes();
$interval = $this->gptRetryInterval();
$attempt = 0;
while (true) {
try {
$headers = [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json'
];
// 备用中转站地址: 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
];
} catch (\Exception $e) {
// 报错或超时时,切到 GPT_56_LUNA_API_KEY 重试一次(收费更高,慎用;仅原本未用 luna key 时触发)
if (!$usedLunaFallback && !empty($lunaApiKey) && $apiKey !== $lunaApiKey) {
$usedLunaFallback = true;
$apiKey = $lunaApiKey;
dLog('deepseek')->warning('GPT-5.4 请求异常,切换 GPT_56_LUNA_API_KEY 重试', ['error' => $e->getMessage()]);
continue;
}
if (!$this->isGptRetryableException($e) || $attempt >= $maxRetries) {
dLog('deepseek')->error('GPT-5.4 请求失败: ' . $e->getMessage());
logDB('deepseek', 'error', 'GPT-5.4 请求失败', ['error' => $e->getMessage()]);
throw $e;
}
dLog('deepseek')->warning('GPT-5.4 请求失败,准备重试', [
'retry' => $attempt + 1,
'max_retries' => $maxRetries,
'error' => $e->getMessage()
]);
sleep($interval);
$attempt++;
}
}
}
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');
}
// 移除非法 UTF-8 字节,避免后续 JSON 编码或正则解析异常
if (!mb_check_encoding($content, 'UTF-8')) {
$cleaned = @iconv('UTF-8', 'UTF-8//IGNORE', $content);
$content = $cleaned === false ? $content : $cleaned;
}
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);
// 段落边界补换行(段落内的软换行已在元素递归中处理)
$content .= "\n";
}
}
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 ($element instanceof \PhpOffice\PhpWord\Element\TextBreak
|| $element instanceof \PhpOffice\PhpWord\Element\LineBreak) {
// 软换行:保留 Word 中的换行显示,避免"第X集"标题与正文粘连
$text .= "\n";
} elseif (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $childElement) {
$text .= $this->extractWordElementText($childElement);
}
} elseif (method_exists($element, 'getText')) {
$text .= $element->getText();
}
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' => (float)env('TEXT_TEMPERATURE'),
'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 = [];
$chatResult = $this->dispatchChatOnlyByModel($post_data);
if (is_array($chatResult)) {
$fullContent = $chatResult['fullContent'];
$usage = $chatResult['usage'] ?? [];
}
// 仅记录 token 使用明细(不扣积分)
$this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($usage), [
'model' => $model,
'cid' => $cid,
'source' => 'generateScriptWords',
], $model);
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)
{
// 百度 GLM 已由 dispatchStreamByModel / dispatchChatOnlyByModel 统一分发,此处不再转发
$frontModel = getProp($params, 'model', '');
if (in_array($frontModel, $this->baidu_glm_text_models, true)) {
Utils::throwError('20003:百度GLM模型请使用统一分发方法dispatchStreamByModel/dispatchChatOnlyByModel调用');
}
$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) {
// 流式输出
// 流式请求默认声明返回 usage(未显式传参时)
$requestData['stream_options'] = $requestData['stream_options'] ?? ['include_usage' => true];
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]);
}
}
}
}