url = 'https://api.deepseek.com/chat/completions';
$this->api_key = env('DEEPSEEK_API_KEY');
$this->headers = [
'Authorization' => 'Bearer '.$this->api_key,
'Content-Type' => 'application/json; charset=UTF-8'
];
$this->sys_message = [
'role' => 'system',
'content' => '你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集剧本>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n\n
2.<分集剧本>要求包含“分集序号”、“分集标题”以及分镜信息,分镜信息需包含“场景描述”、“运镜”、“出场角色”和“台词内容”这几个部分,其中“出场角色”和“场景描述”必须在前面的板块<主体列表>和<场景列表>中选取,不得自定义。\n\n
示例如下:\n\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。
###分集剧本
##分集01
分集名: 第一集
分镜01-1
场景描述:地点、时间、场景
运镜:固定近距离
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话
分镜01-2
场景描述:地点、时间、场景
运镜:从近到远
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话
分镜01-3
场景描述:地点、时间、场景
运镜:快速拉近
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话
##分集02
分集名: 第二集
分镜02-1
场景描述:地点、时间、场景
运镜:从近到远
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话
分镜02-2
场景描述:地点、时间、场景
运镜:从近到远
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话'
];
$this->test_content = <<<'EOD'
###剧本名:看不见的守护者
###故事梗概
少女许芸重生回到悲剧发生前一个月,她本是豪门假千金,上辈子被真千金徐清遥报复,被迫联姻后惨死。一个暗恋她至深的陌生男人程景,用与系统交易的代价换她重生,代价是她永远看不见他。许芸利用先知改变命运,躲避了表哥卞大伟的侵犯,并试图回归平凡生活。在危机四伏的重生路上,那个“看不见”的守护者程景一次次救她于危难。许芸从恐惧、好奇到依赖,并主动追寻这个神秘存在。两人在“一个看得见,一个看不见”的荒诞设定下,发展出一段跨越生死、充满拉扯与救赎的深情。最终,许芸的执着冲破了系统规则,两人得以相见相守,共度余生。
###剧本亮点
亮点1:重生设定与“隐形”守护者的双重奇幻
开篇即用快速剪辑展现许芸上辈子的惨死与陌生男人程景为她下葬、与系统交易的画面,建立强烈的戏剧冲突与悬念。重生后,许芸视角中程景的“不存在”与观众视角(或通过环境互动)能感知的“存在”形成奇妙反差,如被无形力量救下车、凭空出现的面条、地上的血脚印等,视觉呈现新颖,情感张力十足。
亮点2:“看见”与“看不见”的极致情感拉扯
许芸对着一团空气说话、试探、甚至调情的场景,充满了戏剧性的孤独与浪漫。她努力感知程景的存在,通过细微痕迹(如血迹、水渍、移动的物品)与他“交流”。这种单向可见的互动,将暗恋的卑微与守护的深情推到极致,观众能深切共情两人近在咫尺却无法触及的煎熬与甜蜜。
亮点3:悬疑推进与身份揭秘的层层递进
剧本并非单纯恋爱,穿插着许芸对抗原生家庭伤害、智斗恶毒表哥卞大伟的主线。程景的“隐形”能力成为她破局的关键外挂,但也带来了新的危险。随着许芸通过学校花名册、资助信等线索一步步接近程景的真实身份,悬疑感与情感共鸣同步加深,直到最终高潮的“相见”时刻,情感得到彻底释放。
###人物关系
许芸与程景:被守护者与隐形守护者,后成为恋人、夫妻。许芸从依赖、好奇到深爱并主动追寻程景;程景则因感恩与深爱,默默守护许芸两世。
许芸与徐家父母:亲生父母,关系疏离冷漠。母亲重男轻女,偏袒侄子卞大伟;父亲怯懦,后期在许芸影响下有所改变。
许芸与卞大伟:表哥,施暴者与主要反派,对许芸有侵犯意图,是前期主要威胁。
许芸与徐清遥:身份互换的真假千金,上辈子是仇敌,这辈子因许芸提前归位身份而关系未彻底恶化,但存在潜在矛盾。
许芸与夏颜:高中同桌兼闺蜜,后期因同时喜欢程景而产生嫉妒与隔阂,是推动程景“分手”谎言的工具人。
###核心矛盾
1. **生存矛盾**:重生后的许芸需躲避上辈子的悲剧命运(被卞大伟侵犯、被家庭出卖联姻),在恶劣原生环境中挣扎求生。
2. **感知矛盾**:系统规则导致许芸无法看见、听见程景,而程景深爱并守护着她。两人之间存在极致的“接触”渴望与“隔绝”现实的矛盾。
3. **情感矛盾**:许芸在依赖与追寻程景的过程中渐生情愫,但程景因自卑(身份差距)、身体创伤(以为不育)及系统限制,一度选择逃避和“分手”,造成两人的情感拉扯。
4. **身份矛盾**:真假千金的错位人生带来的家庭伦理与社会地位冲突,是许芸前世悲剧的根源,也是她今生需要面对和化解的阴影。
###主体列表
许芸-重生迷茫:刚重生时,心怀恐惧与决绝,试图改变命运。
许芸-坚韧求生:面对原生家庭压迫,冷静周旋,努力读书改变命运。
许芸-主动追寻:对“空气人”程景从好奇到依赖,再到大胆主动地试探与追求。
程景-隐形守护:始终在许芸身边,默默保护、照顾她,情感深沉而克制。
程景-隐忍挣扎:因系统限制和自身顾虑,承受着无法被感知的孤独与爱而不得的痛苦。
卞大伟-阴狠施暴:对许芸心怀不轨,是具象化的危险来源。
徐母-偏执刻薄:重男轻女,维护侄子,是家庭伤害的主要施加者。
徐父-怯懦转变:前期麻木,后期在女儿影响下逐渐觉醒。
夏颜-闺蜜变情敌:前期提供帮助,后期因嫉妒程景而说谎离间。
“系统”:无形的规则设定,制造了主角间“看不见”的核心障碍。
###美术风格
基础画风风格词:写实细腻的现代都市情感风格,带有轻微悬疑色彩。
整体采用电影质感的写实风格,色彩随情绪和剧情变化。许芸重生前的回忆片段使用冷峻、压抑的蓝灰色调,对比强烈。重生后的现实生活,色调偏向自然光感,但徐家馄饨店等场景色调偏暗、拥挤,体现压抑感。当“隐形”的程景互动时,画面注重细节特写(如自动移动的物体、凹陷的床垫、凭空出现的水杯),并辅以微妙的光影变化或轻柔的焦点虚化来暗示他的“存在”。许芸与程景的独处时光,尤其是阁楼场景,色调转为温暖、柔和的橙黄色,营造私密与暧昧氛围。最终相见时刻,光线明亮饱满,色彩鲜活,象征隔阂的打破与希望降临。
###场景列表
徐家馄饨店:拥挤、老旧、充满烟火气的小店,是许芸重生后的主要生活场景,也是危机发生地。
学校教室/走廊:青春校园环境,是许芸努力学习、与夏颜交流的主要场景。
网吧包间:许芸躲避第一夜危机的地点,嘈杂、昏暗。
城市街道/路口:多次发生危机(车祸、被卞大伟堵截)和程景隐形救援的场景。
程景的阁楼:狭小但整洁,是两人的秘密基地和情感发酵的主要空间,充满生活气息与私密感。
医院:程景受伤后治疗的地点,也是许芸感到无助与寻找他的地方。
法院门口:卞大伟宣判后,徐母闹事、许芸再次感知到程景存在的场景。
###分集剧本
##分集01
分集名:重生与隐形救援
分镜01
场景描述:徐家馄饨店后院卧室,清晨,光线昏暗。许芸从噩梦中惊醒,额头上全是冷汗。
运镜:特写许芸惊恐睁大的双眼,快速闪回上辈子被家暴、惨死、以及一个模糊男人背影为她下葬的碎片画面。
出场角色:许芸
台词内容:
许芸(内心独白,带着颤音):我重生了…源于一个男人偏执的暗恋。
分镜02
场景描述:馄饨店前厅,清晨。徐母穿戴整齐,对正在收拾书包的许芸吩咐。卞大伟在锅炉后假装忙碌,眼神阴鸷地偷瞄许芸。
运镜:中景,先聚焦徐母严厉的脸,然后镜头平移,扫过乖巧点头的许芸,最后落在卞大伟身上,给他一个眼神特写。
出场角色:许芸,徐母,卞大伟
台词内容:
徐母:今天店就交给你表哥看着,晚上一放学就回来帮他干活。
许芸:(低头)嗯。
徐母:(转身离开)
(许芸目光扫向卞大伟,内心独白):上辈子,就是他侮辱了徐清遥…也是我一切悲剧的开始。
分镜03
场景描述:网吧包间,夜。许芸坐在电脑前,屏幕光映着她苍白的脸。周围烟雾缭绕,嘈杂。
运镜:固定机位,侧面拍摄许芸蜷缩在椅子里的身影,环境音突出键盘声和隐约的游戏音效。
出场角色:许芸
台词内容:
许芸(内心独白):我在这里躲了一夜…可脑子里全是上辈子的事。徐清遥的恨,养父母的冷漠,那个老畜生的折磨…还有,那个给我收尸的陌生男人…
分镜04
场景描述:城市街道,清晨。许芸背着书包,困倦地过马路。一辆车急速驶来。
运镜:主观镜头,许芸视线模糊地看向冲来的车。紧接着第三视角,许芸的卫衣后领突然向后一紧,她整个人被“拽”离原地,车子擦身而过。
出场角色:许芸,路人老奶奶
台词内容:
(急刹车声)
老奶奶:(拄着拐走近)这司机开车横冲直撞,要不是小姑娘你反应快…
许芸:(惊魂未定,四下张望)不是我自己躲的…
(内心独白):是他!他就在我身边!
分镜05
场景描述:馄饨店后屋,白天。许芸刚进门,徐母拿着扫帚劈头盖脸打来。卞大伟在门口阴沉地看着。
运镜:手持晃动镜头,模拟挨打的混乱感。穿插卞大伟站在逆光门口的阴沉中景。
出场角色:许芸,徐母,卞大伟
台词内容:
徐母:死丫头!夜不归宿死哪儿去了?
许芸:(躲闪)去许家让清遥给我补课了!
徐母:(打累了,扔下扫帚)去包馄饨!
(许芸沉默地洗手,开始包馄饨,余光警惕着卞大伟)
##分集02
分集名:危机再现与阁楼初夜
分镜01
场景描述:馄饨店后屋,傍晚。许芸放学推门进来,被埋伏的卞大伟一把抓住手腕。
运镜:快速推近,特写许芸惊愕的脸和卞大伟狞笑的手。镜头跟随挣扎的两人摇晃,撞倒桌椅。
出场角色:许芸,卞大伟
台词内容:
许芸:你干什么?放开我!
卞大伟:(喘着粗气)今天可没人救你了!
(许芸挣扎中摸出兜里的小刀)
分镜02
场景描述:同上场景。卞大伟正要压倒许芸,突然怪叫着向后仰倒,仿佛被无形力量击中。
运镜:慢镜头,卞大伟身体不自然地扭曲、摔倒。许芸趁机爬起,震惊地看着“空气”。
出场角色:许芸,卞大伟
台词内容:
卞大伟:(对着空气怒吼)你他妈谁啊?!
许芸:(心口狂跳,看向卞大伟扭打的方向)是他…只能是他!
许芸:(朝“空气”大喊)我们走!
分镜03
场景描述:老旧居民楼楼梯,傍晚。许芸跟着一串突然出现在地上的“血脚印”奔跑。
运镜:俯拍镜头,跟随许芸上楼的脚步和地上断断续续的血脚印。
出场角色:许芸
台词内容:
许芸:(对着前方的空气)你受伤了!
(血脚印停住)
许芸:(执拗地)我要跟你在一起。
分镜04
场景描述:狭小阁楼内,夜。许芸坐在床边,好奇地打量房间。小矮几上凭空出现两碗热气腾腾的面条。
运镜:全景展示阁楼布局,然后聚焦到矮几上“自动出现”的面碗。对面碗里的面条开始“自动”减少。
出场角色:许芸
台词内容:
许芸:(看着对面“自动”减少的面条,眼泪突然掉下来)啪嗒…好惨…这男的太惨了…
分镜05
场景描述:阁楼床上,夜。许芸穿着宽大T恤躺在床上。她突然睁开眼,感觉嘴唇上有温热柔软的触感。
运镜:特写许芸猛然睁大的眼睛,和微微湿润的嘴唇。背景是浴室隐约的水声。
出场角色:许芸
台词内容:
许芸:(内心惊愕)他在偷吻我?我…感觉到了?
(她闭眼假装睡着,感觉脖颈又被亲了一下,忍不住轻颤)
(浴室水声再次响起)
许芸:(嘴角微微勾起)好像…发现了系统的BUG?
##分集03
分集名:身份追寻与最终相见
分镜01
场景描述:学校教室,白天。许芸拿着重点班花名册,请同桌夏颜念名字。夏颜念到第一个名字时,许芸一脸茫然。
运镜:过肩镜头,从许芸视角看夏颜的嘴在动,但配以表示“无声”或“雪花噪音”的音效和画面扭曲效果。
出场角色:许芸,夏颜
台词内容:
夏颜:(念名单)…
许芸:(打断,急切地)夏夏,等高考后,你能帮我打听一下1号同学报哪所大学吗?我想跟他一起。
夏颜:(皱眉,嘴巴张合——许芸听不清)
许芸:(自顾自点头,眼冒星星)对,我喜欢1号同学!
分镜02
场景描述:法院外台阶,白天。卞大伟宣判后被带走,徐母发疯般冲过来要打许芸,被徐父拦住。许芸下台阶时,突然停下,嗅了嗅空气。
运镜:中景拍摄混乱场面,然后特写许芸突然愣住、微微抽动鼻翼的表情。
出场角色:许芸,徐母,徐父
台词内容:
徐母:(尖叫)许芸!你毁了我的一切!
许芸:(对徐父)爸,拉住她!(下台阶,脚步一顿)…皂角味?
(内心独白):他来了?为什么…
分镜03
场景描述:程景家楼下小平台,傍晚。许芸站在平台边缘,下面是草坪。
运镜:全景,许芸孤身站在平台边,风吹动她的头发。然后切换为她主观视角,看向下方。
出场角色:许芸
台词内容:
许芸:(环顾四周,大声说)1号同学,没有你,我不想活了!
(作势要跳)
分镜04
场景描述:同上场景。许芸身体前倾跌落的瞬间,被一股无形的力量拦腰抱住,然后轻轻“丢”在平台地面上。
运镜:高速摄影,捕捉许芸跌落瞬间被“空气”拦截并缓缓放下的超现实画面。
出场角色:许芸
台词内容:
许芸:(被抱住时惊喜)1号同学!我就知道…(被丢到地上,屁股疼)哎哟!
许芸:(爬起来,对着空气喊)你非要这么折磨我吗?还是你和夏颜在一起了?!
分镜05
场景描述:阁楼内,夜。许芸被浴室水声吵醒,迷迷糊糊走过去,赫然看见一个裸身男人(程景)在洗澡。
运镜:从许芸迷糊的主观镜头,到看清浴室景象时猛地拉近、定格在程景背影的震惊视角。
出场角色:许芸,程景
台词内容:
许芸:(惊吓后退)啊!你…你是谁?!
(程景转身,两人对视)
程景:(震惊,试探)许芸?你能看见我?
许芸:(警惕)我当然能看见你!变-态!
(程景扯掉浴巾,许芸惊呼转身又转回)
程景:(快速穿裤子)我就是你的1号同学。
分镜06
场景描述:阁楼内,片刻后。许芸接着夏颜的电话,程景站在一旁。接完电话,许芸走向程景。
运镜:双人镜头,许芸一边接电话,一边目光复杂地看着程景。挂电话后,她主动靠近,镜头柔和。
出场角色:许芸,程景
台词内容:
许芸:(对电话)…这挺好,可以毫无顾忌了。(挂电话,走向程景)
许芸:(圈住程景脖子)夏颜说,你受伤了,可能做不了爸爸?
程景:(眼神一暗)…嗯。
许芸:(微笑)没关系。我只想跟你好好地在一起。
(两人相拥,窗外天色渐亮)
EOD;
}
private function getArtStylePromptMappings(): array
{
return [
'厚涂古风' => [
'aliases' => ['厚涂古风', '古风厚涂', '国风厚涂', '古风'],
'prompt' => "基础画风风格词:厚涂古风\n整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,可辅以温暖的金色、琥珀色光晕强化画面情绪。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。"
],
'现代都市情感' => [
'aliases' => ['现代都市', '都市情感', '现代情感', '都市写实', '现代写实'],
'prompt' => "基础画风风格词:写实细腻的现代都市情感风格,带有轻微悬疑色彩。\n整体采用电影质感的写实风格,色彩随情绪和剧情变化。日常场景偏自然光感与生活化细节,压抑或冲突场景可适度加强蓝灰、暗绿色调,营造情感张力与轻悬疑氛围。人物造型追求真实可信,环境道具需体现都市生活气息。"
],
'现实主义超现实' => [
'aliases' => ['现实主义', '超现实', '现实主义超现实', '现实主义奇幻', '现代奇幻写实'],
'prompt' => "基础画风风格词:现实主义的细腻光影与超现实元素结合\n整体以现实主义基调塑造人物与场景,色彩饱和度适中偏低,通过细腻光影与局部超现实视觉元素制造情绪冲击。画面在真实生活质感之上融入象征性表达,让现实压迫感与奇异氛围并存。"
],
'韩漫二次元' => [
'aliases' => ['韩漫', '韩漫二次元', '二次元', '漫画风', '韩系漫画'],
'prompt' => "基础画风风格词:韩漫二次元\n整体采用精致清晰的韩漫二次元风格,人物五官立体、轮廓干净,画面注重高颜值塑造与情绪表达。色彩通透,光影偏柔和,兼顾浪漫氛围与剧情张力,适合强化角色关系和戏剧冲突。"
],
];
}
private function getArtStylePromptByInput($inputArtStyle): string
{
$normalizedInput = $this->normalizeArtStyleInput($inputArtStyle);
if ($normalizedInput === '') {
return '';
}
foreach ($this->getArtStylePromptMappings() as $mapping) {
foreach ($mapping['aliases'] as $alias) {
$normalizedAlias = $this->normalizeArtStyleInput($alias);
if ($normalizedAlias === '') {
continue;
}
if (strpos($normalizedInput, $normalizedAlias) !== false || strpos($normalizedAlias, $normalizedInput) !== false) {
return $mapping['prompt'];
}
}
}
return '';
}
private function normalizeArtStyleInput($value): string
{
$value = trim((string)$value);
if ($value === '') {
return '';
}
$value = str_replace(["\r", "\n", "\t", ' ', ' ', '-', '_', ':', ':', ',', ',', '。', '.'], '', $value);
return mb_strtolower($value, 'UTF-8');
}
private function replaceArtStyleSection(string $content, string $artStyle): string
{
if ($content === '' || $artStyle === '') {
return $content;
}
$pattern = '/###美术风格\s*\R(.*?)(?=\R###|$)/su';
if (!preg_match($pattern, $content)) {
return $content;
}
return preg_replace($pattern, "###美术风格\n" . trim($artStyle), $content, 1);
}
// 与推理模型对话
public function chatWithReasoner($data) {
$content = getProp($data, 'content');
$model = getProp($data, 'model', 'r1');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
// 获取可选情感(根据音色可支持情感选)
$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, // R1模型: deepseek-reasoner V3模型: deepseek-chat
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 1, // 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。
// 'top_p' => 1, // 作为调节采样温度的替代方案(<=1),模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。
'frequency_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。
'presence_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。
'response_format' => [
'type' => 'text' // 默认值text,回答的结果输出文字(非接口返回值是text,接口返回值还是json字串),还可选:json_object,输出json格式
],
'stream' => false // 是否流式输出,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
$update_data = [];
$content = '';
if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
$content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
$update_data = [
'role' => 'assistant',
'content' => $response_arr['choices'][0]['message']['content'],
'usage' => isset($response_arr['usage']) ? $response_arr['usage'] : []
];
}
// 处理获取到的剧本数据
$script_content = handleScriptWords($content, $enable_emotion);
$result = [
'origin_content' => $content,
'roles' => getProp($script_content, 'roles'),
'words' => getProp($script_content, 'words'),
];
return $result;
}
public function resetParagraphAudio($data) {
$bid = getProp($data, 'bid');
$cid = getProp($data, 'cid');
$version_id = getProp($data, 'version_id');
if (!DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->value('id')) return true;
return DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->update([
'generate_status' => '待制作',
'updated_at' => date('Y-m-d H:i:s')
]);
}
public function saveParagraphAudio($data) {
$bid = getProp($data, 'bid');
$cid = getProp($data, 'cid');
$version_id = getProp($data, 'version_id');
$sequence = getProp($data, 'sequence');
$id = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->where('sequence', $sequence)->value('id');
// 获取所有情感
$emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray();
$emotion_list = array_flip($emotion_list);
// 获取音色支持情感
$timbre_emotion = DB::table('mp_timbres')->where('timbre_type', getProp($data, 'voice_type'))->value('emotion');
$timbre_emotion = explode(',', $timbre_emotion);
$emotion = getProp($data, 'emotion');
if (!in_array($emotion, $timbre_emotion)) $emotion = '中性';
$emotion_type = isset($emotion_list[$emotion]) ? $emotion_list[$emotion] : 'neutral';
$list = [
'bid' => $bid,
'cid' => $cid,
'version_id' => $version_id,
'sequence' => $sequence,
'role' => getProp($data, 'role'),
'gender' => getProp($data, 'gender'),
'text' => trim(getProp($data, 'text')),
'emotion' => $emotion,
'emotion_type' => $emotion_type,
'voice_type' => getProp($data, 'voice_type'),
'voice_name' => getProp($data, 'voice_name'),
'speed_ratio' => getProp($data, 'speed_ratio', 0),
'loudness_ratio'=> getProp($data, 'loudness_ratio', 0),
'emotion_scale' => getProp($data, 'emotion_scale', 4),
'pitch' => getProp($data, 'pitch', 0),
// 'paragraph_audio_url' => '',
'generate_status' => '制作中',
'error_msg' => '',
'updated_at' => date('Y-m-d H:i:s')
];
$continue = false;
// 判断是否含有中文、数字或英文,如果没有则跳过合成
if (!preg_match('/[\x{4e00}-\x{9fa5}]+/u', $list['text']) && !preg_match('/[0-9]+/', $list['text']) && !preg_match('/[a-zA-Z]+/', $list['text'])) {
$list['generate_status'] = '制作成功';
$list['paragraph_audio_url'] = 'https://zw-audiobook.tos-cn-beijing.volces.com/effects/ellipses_2s.mp3';
$list['subtitle_info'] = '{"duration": 2.0, "words": []}';
$continue = true;
}
// if (getProp($data, 'paragraph_audio_url')) $list['paragraph_audio_url'] = getProp($data, 'paragraph_audio_url');
if ($id) {
$boolen = DB::table('mp_chapter_paragraph_audios')->where('id', $id)->update($list);
}else {
$list['created_at'] = date('Y-m-d H:i:s');
$id = DB::table('mp_chapter_paragraph_audios')->insertGetId($list);
$boolen = $id ? true : false;
}
// 如果更新成功则加入查询队列
if ($boolen) {
$redis_key = "select-{$bid}-{$version_id}-{$cid}";
Redis::sadd($redis_key, $id);
}
if ($boolen && !$continue) {
$boolen = false;
$client = new Client(['timeout' => 300, 'verify' => false]);
// 根据ID通过API通知合成音频
// $result = $client->get("http://47.240.171.155:5000/api/previewTask?taskId={$id}");
$result = $client->get("http://122.9.129.83:5000/api/previewTask?taskId={$id}");
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
$error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
Log::info('通知火山生成段落音频失败: '.$error_msg);
Utils::throwError('20003:通知火山生成段落音频失败');
}
$boolen = true;
}
return $boolen;
}
public function insertAudioEffect($data) {
$audio_id = getProp($data, 'audio_id');
$chapter_audio = DB::table('mp_chapter_audios')->where('id', $audio_id)->first();
$bid = getProp($chapter_audio, 'bid');
$version_id = getProp($chapter_audio, 'version_id');
$cid = getProp($chapter_audio, 'cid');
if (getProp($chapter_audio, 'generate_status') != '制作成功') Utils::throwError('20003:请先完成有声制作');
$audio_effect_json = getProp($data, 'audio_effect_json');
if (!is_string($audio_effect_json)) $audio_effect_json = json_encode($audio_effect_json, 256);
try {
DB::beginTransaction();
$count = DB::table('mp_audio_tasks')->where('audio_id', $audio_id)->count('id');
if (!$count) {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】';
}else {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')';
}
$boolen1 = DB::table('mp_chapter_audios')->where('id', $audio_id)->update([
'audio_effect_status' => '添加中',
'audio_effect_json' => $audio_effect_json,
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$boolen1) {
DB::rollBack();
Utils::throwError('20003:更新生成数据失败');
}
$id = DB::table('mp_audio_tasks')->insertGetId([
'audio_id' => $audio_id,
'generate_status' => '执行中',
'generate_json' => json_encode([], 256),
'bid' => $bid,
'book_name' => getProp($chapter_audio, 'book_name'),
'version_id' => $version_id,
'version_name' => getProp($chapter_audio, 'version_name'),
'cid' => $cid,
'chapter_name' => getProp($chapter_audio, 'chapter_name'),
'task_name' => $task_name,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$id) {
DB::rollBack();
Utils::throwError('20003:创建任务失败');
}
}catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
$client = new Client(['timeout' => 300, 'verify' => false]);
// 根据ID通过API通知合成音频
// $result = $client->get("http://47.240.171.155:5000/api/postTask?taskId={$id}");
$result = $client->get("http://122.9.129.83:5000/api/postTask?taskId={$id}");
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
$error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
Log::info('通知火山插入音效失败: '.$error_msg);
Utils::throwError('20003:通知火山插入音效失败');
}
return true;
}
public function insertBgm($data) {
$audio_id = getProp($data, 'audio_id');
$chapter_audio = DB::table('mp_chapter_audios')->where('id', $audio_id)->first();
$bid = getProp($chapter_audio, 'bid');
$version_id = getProp($chapter_audio, 'version_id');
$cid = getProp($chapter_audio, 'cid');
if (getProp($chapter_audio, 'generate_status') != '制作成功') Utils::throwError('20003:请先完成有声制作');
$bgm_json = getProp($data, 'bgm_json');
if (!is_string($bgm_json)) $bgm_json = json_encode($bgm_json, 256);
if (!$bgm_json) {
$bgm_json = getProp($data, 'audio_effect_json');
if (!is_string($bgm_json)) $bgm_json = json_encode($bgm_json, 256);
}
try {
DB::beginTransaction();
$count = DB::table('mp_audio_tasks')->where('audio_id', $audio_id)->count('id');
if (!$count) {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】';
}else {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')';
}
$boolen1 = DB::table('mp_chapter_audios')->where('id', $audio_id)->update([
'bgm_status' => '添加中',
'bgm_json' => $bgm_json,
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$boolen1) {
DB::rollBack();
Utils::throwError('20003:更新生成数据失败');
}
$id = DB::table('mp_audio_tasks')->insertGetId([
'audio_id' => $audio_id,
'generate_status' => '执行中',
'generate_json' => json_encode([], 256),
'bid' => $bid,
'book_name' => getProp($chapter_audio, 'book_name'),
'version_id' => $version_id,
'version_name' => getProp($chapter_audio, 'version_name'),
'cid' => $cid,
'chapter_name' => getProp($chapter_audio, 'chapter_name'),
'task_name' => $task_name,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$id) {
DB::rollBack();
Utils::throwError('20003:创建任务失败');
}
}catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
$client = new Client(['timeout' => 300, 'verify' => false]);
// 根据ID通过API通知合成音频
// $result = $client->get("http://47.240.171.155:5000/api/postTask?taskId={$id}");
$result = $client->get("http://122.9.129.83:5000/api/postTask?taskId={$id}");
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
$error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
Log::info('通知火山插入bgm失败: '.$error_msg);
Utils::throwError('20003:通知火山插入bgm失败');
}
return true;
}
public function getParagraphAudios($data) {
$bid = getProp($data, 'bid');
$cid = getProp($data, 'cid');
$version_id = getProp($data, 'version_id');
$sequence = getProp($data, 'sequence');
// 获取已生成的音频
$query = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id);
if ($sequence) $query->where('sequence', $sequence);
$paragraph_audios = $query->orderBy('sequence')->get();
$result = [];
foreach($paragraph_audios as $item) {
$result[] = [
'sequence' => getProp($item, 'sequence'),
'role' => getProp($item, 'role'),
'gender' => getProp($item, 'gender'),
'text' => trim(getProp($item, 'text')),
'emotion' => getProp($item, 'emotion'),
'emotion_type' => getProp($item, 'emotion_type'),
'voice_type' => getProp($item, 'voice_type'),
'voice_name' => getProp($item, 'voice_name'),
'speed_ratio' => getProp($item, 'speed_ratio'),
'loudness_ratio' => getProp($item, 'loudness_ratio'),
'emotion_scale' => getProp($item, 'emotion_scale'),
'paragraph_audio_url' => getProp($item, 'paragraph_audio_url'),
'subtitle_info' => json_decode(getProp($item, 'subtitle_info'), true),
];
}
return $result;
}
// 新增合成任务
public function addGenerateTask($data) {
$bid = getProp($data, 'bid');
$cid = getProp($data, 'cid');
$version_id = getProp($data, 'version_id');
$generate_json = getProp($data, 'generate_json');
// 获取已生成的音频
$paragraph_audios = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->get();
$paragraph_list = [];
foreach($paragraph_audios as $item) {
$paragraph_list[getProp($item, 'sequence')] = [
'role' => getProp($item, 'role'),
'gender' => getProp($item, 'gender'),
'text' => trim(getProp($item, 'text')),
'emotion' => getProp($item, 'emotion'),
'emotion_type' => getProp($item, 'emotion_type'),
'voice_type' => getProp($item, 'voice_type'),
'voice_name' => getProp($item, 'voice_name'),
'speed_ratio' => getProp($item, 'speed_ratio'),
'loudness_ratio' => getProp($item, 'loudness_ratio'),
'emotion_scale' => getProp($item, 'emotion_scale'),
'paragraph_audio_url' => getProp($item, 'paragraph_audio_url'),
];
}
// 更新角色-音色信息
$existed_role_info = DB::table('mp_book_version')->where('bid', $bid)->where('id', $version_id)->value('role_info');
$existed_role_info = json_decode($existed_role_info, true);
if (!$existed_role_info) $existed_role_info = [];
// 获取情感信息
$emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray();
$emotion_list = array_flip($emotion_list);
// 获取音色对应情感组
$timbre_emotions = DB::table('mp_timbres')->where('is_enabled', 1)->select('timbre_type', 'emotion')->get();
$timbre_emotion_list = [];
foreach($timbre_emotions as $item) {
$timbre_emotion_list[getProp($item, 'timbre_type')] = explode(',', getProp($item, 'emotion'));
}
// 构造生成音频的json
$words = json_decode($generate_json, true);
// 最终合成前的参数组
$mp_chapter_paragraph_audios = [];
$sequence = 1;
$complete_paragraph_sequences = [];
foreach($words as &$word) {
if (!isset($word['text']) || !isset($word['emotion']) || !isset($word['voice_type']) || !isset($word['voice_name']) || !isset($word['speed_ratio']) || !isset($word['loudness_ratio']) || !isset($word['emotion_scale'])) Utils::throwError('20003:参数格式有误');
if (!($word['text']) || !($word['voice_type']) || !($word['voice_name'])) Utils::throwError('20003:参数不得为空');
$role = getProp($word, 'role');
$word['gender'] = (int)$word['gender'];
// 判断音色对应情感是否支持,不支持则调整为中性
$access_emotion = isset($timbre_emotion_list[$word['voice_type']]) ? $timbre_emotion_list[$word['voice_type']] : [];
if (!in_array($word['emotion'], $access_emotion)) $word['emotion'] = '中性';
// 如果有对应情感则赋值,没有则默认为中性(neutral)
if (isset($emotion_list[getProp($word, 'emotion')])) {
$word['emotion_type'] = $emotion_list[getProp($word, 'emotion')];
}else {
$word['emotion'] = '中性';
$word['emotion_type'] = 'neutral';
}
$existed_role_info[$role] = [
'timbre_type' => $word['voice_type'],
'timbre_name' => $word['voice_name'],
'emotion' => $word['emotion'],
'emotion_type' => $word['emotion_type'],
'speed_ratio' => $word['speed_ratio'],
'loudness_ratio'=> $word['loudness_ratio'],
'emotion_scale' => $word['emotion_scale'],
'pitch' => $word['pitch']
];
$word['paragraph_audio_url'] = '';
// 判断生成参数是否相同,相同则直接使用已生成的音频
$paragraph = isset($paragraph_list[$sequence]) ? $paragraph_list[$sequence] : [];
// 如果音频存在并且参数都未改变则使用已生成的音频
if (getProp($paragraph, 'paragraph_audio_url')) {
// if (getProp($paragraph, 'role') == getProp($word, 'role') && getProp($paragraph, 'text') == getProp($word, 'text') &&
// getProp($paragraph, 'voice_type') == getProp($word, 'voice_type') &&
// getProp($paragraph, 'speed_ratio') == getProp($word, 'speed_ratio') && getProp($paragraph, 'loudness_ratio') == getProp($word, 'loudness_ratio') &&
// getProp($paragraph, 'emotion_scale') == getProp($word, 'emotion_scale'))
// {
// $word['paragraph_audio_url'] = getProp($paragraph, 'paragraph_audio_url');
// }
$word['paragraph_audio_url'] = getProp($paragraph, 'paragraph_audio_url');
}
$tmp = $word;
// 组装章节分句音频数据
// $tmp['sequence'] = getProp($word, 'sequence');
// $tmp['text'] = getProp($word, 'text');
// $tmp['emotion'] = getProp($word, 'emotion');
// $tmp['emotion_type'] = getProp($word, 'emotion_type');
// $tmp['voice_name'] = getProp($word, 'voice_name');
// $tmp['voice_type'] = getProp($word, 'voice_type');
// $tmp['speed_ratio'] = getProp($word, 'speed_ratio');
// $tmp['loudness_ratio'] = getProp($word, 'loudness_ratio');
// $tmp['emotion_scale'] = getProp($word, 'emotion_scale');
$tmp['bid'] = $bid;
$tmp['version_id'] = $version_id;
$tmp['cid'] = $cid;
$tmp['sequence'] = $sequence;
$tmp['created_at'] = date('Y-m-d H:i:s');
$tmp['updated_at'] = date('Y-m-d H:i:s');
if (!getProp($tmp, 'paragraph_audio_url')) Utils::throwError('20003:段落未生成音频,请等待完成后提交');
$mp_chapter_paragraph_audios[] = $tmp;
$complete_paragraph_sequences[] = $sequence;
$sequence++;
}
$generate_json = json_encode($words, 256);
// 判断是否有未生成字幕的段落
$no_subtitle_sequences = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->whereIn('sequence', $complete_paragraph_sequences)->whereNull('subtitle_info')->pluck('sequence')->toArray();
if ($no_subtitle_sequences) {
Utils::throwError('20003:请先将以下段落序号生成字幕('.implode('、', $no_subtitle_sequences).')');
}
try {
DB::beginTransaction();
$role_info = json_encode($existed_role_info, 256);
$boolen = DB::table('mp_book_version')->where('bid', $bid)->where('id', $version_id)->update(['role_info' => $role_info, 'updated_at' => date('Y-m-d H:i:s')]);
if (!$boolen) {
DB::rollBack();
Utils::throwError('20003:更新角色信息失败');
}
$count = DB::table('mp_audio_tasks')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->count('id');
$chapter_audio = DB::table('mp_chapter_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->first();
if (!$count) {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】';
}else {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')';
}
$boolen1 = DB::table('mp_chapter_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->update(['generate_status'=>'制作中', 'generate_json' => $generate_json, 'updated_at' => date('Y-m-d H:i:s')]);
if (!$boolen1) {
DB::rollBack();
Utils::throwError('20003:更新生成数据失败');
}
$id = DB::table('mp_audio_tasks')->insertGetId([
'audio_id' => getProp($chapter_audio, 'id'),
'generate_status' => '执行中',
'generate_json' => $generate_json,
'bid' => $bid,
'book_name' => getProp($chapter_audio, 'book_name'),
'version_id' => $version_id,
'version_name' => getProp($chapter_audio, 'version_name'),
'cid' => $cid,
'chapter_name' => getProp($chapter_audio, 'chapter_name'),
'task_name' => $task_name,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$id) {
DB::rollBack();
Utils::throwError('20003:创建任务失败');
}
// 删除不在段落序号范围内的其他数据
if ($complete_paragraph_sequences) {
DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->whereNotIn('sequence', $complete_paragraph_sequences)->delete();
}
// // 删除章节分句音频数据并重新插入
// DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->delete();
// $boolen3 = DB::table('mp_chapter_paragraph_audios')->insert($mp_chapter_paragraph_audios);
// if (!$boolen3) {
// DB::rollBack();
// Utils::throwError('20003:更新章节分句音频失败');
// }
} catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
// 通知火山生成音频
$client = new Client(['timeout' => 300, 'verify' => false]);
// 根据ID通过API通知合成音频
// $result = $client->get("http://47.240.171.155:5000/api/chapterTask?taskId={$id}");
$result = $client->get("http://122.9.129.83:5000/api/chapterTask?taskId={$id}");
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
$error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
Log::info('通知火山生成音频失败: '.$error_msg);
Utils::throwError('20003:通知火山生成音频失败');
}
return true;
}
public function timbreList($data) {
$gender = getProp($data, 'gender');
$timbre_name = getProp($data, 'voice_name');
$category_id = getProp($data, 'category_id');
$query = DB::table('mp_timbres')->where('is_enabled', 1)->select('timbre_name as voice_name', 'timbre_type as voice_type', 'gender', 'language', 'emotion', 'label', 'first_category_id', 'first_category_name', 'second_category_id', 'second_category_name', 'third_category_id', 'third_category_name', 'audio_url');
if ($gender) {
$query->where('gender', $gender);
}
if ($timbre_name) {
$query->where('timbre_name', 'like', "%{$timbre_name}%");
}
if ($category_id) {
$query->where(function ($query) use ($category_id) {
$query->where('first_category_id', $category_id)->orWhere('second_category_id', $category_id)->orWhere('third_category_id', $category_id);
});
}
$list = $query->get()->map(function ($value) {
$value = (array)$value;
$value['voice_name'] = str_replace('(多情感)', '', $value['voice_name']);
return $value;
})->toArray();
return $list;
}
// 生成火山临时token
public function setStsToken() {
// ************* 配置参数 *************
$method = 'GET';
$service = 'sts';
$host = 'open.volcengineapi.com';
$region = env('VOLC_REGION');
$endpoint = 'https://open.volcengineapi.com';
// $endpoint = 'https://tos-cn-beijing.volces.com';
$access_key = env('VOLC_AK');
$secret_key = env('VOLC_SK');
// 获取缓存中的token,如果没有则请求接口
$token = Redis::get('volc_sts_token');
if (!$token) {
// 查询参数
$query_parameters = [
'Action' => 'AssumeRole',
'RoleSessionName' => 'user@zw',
'RoleTrn' => 'trn:iam::2102575520:role/tos_role',
'Version' => '2018-01-01'
];
// 生成URL编码的查询字符串
$request_parameters = http_build_query($query_parameters, '', '&', PHP_QUERY_RFC3986);
// 获取签名头信息
$headers = $this->getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key);
// 构建完整URL
$request_url = $endpoint . '?' . $request_parameters;
$client = new Client(['verify' => false]);
$response = $client->get($request_url, ['headers' => $headers]);
$response_arr = json_decode($response->getBody()->getContents(), true);
$result = [
'SessionToken' => isset($response_arr['Result']['Credentials']['SessionToken']) ? $response_arr['Result']['Credentials']['SessionToken'] : '',
'AccessKeyId' => isset($response_arr['Result']['Credentials']['AccessKeyId']) ? $response_arr['Result']['Credentials']['AccessKeyId'] : '',
'SecretAccessKey' => isset($response_arr['Result']['Credentials']['SecretAccessKey']) ? $response_arr['Result']['Credentials']['SecretAccessKey'] : '',
'Region' => env('VOLC_REGION'),
'Endpoint' => env('VOLC_END_POINT'),
'Bucket' => env('VOLC_BUCKET'),
];
// 缓存token
Redis::setex('volc_sts_token', 3000, json_encode($result));
return $result;
} else {
return json_decode($token, true);
}
// $response = $response['Response'];
// $access_key = $response['Credentials']['AccessKeyId'];
// $secret_key = $response['Credentials']['AccessKeySecret'];
// $security_token = $response['Credentials']['SecurityToken'];
// $expiration = $response['Credentials']['Expiration'];
// dd($response_arr);
}
public function emotionGroups($data) {
$id = getProp($data, 'group_id');
$group_name = getProp($data, 'group_name');
$voice_type = getProp($data, 'voice_type');
$query = DB::table('mp_emotion_groups')->where('is_enabled', 1)->select('id as group_id', 'group_name',
'emotion', 'emotion_type', 'voice_name', 'voice_type', 'speed_ratio', 'loudness_ratio', 'emotion_scale',
'pitch', 'generate_status', 'audio_url', 'error_msg');
if ($group_name) {
$query->where('group_name', 'like', "%{$group_name}%");
}
if ($id) {
$query->where('id', $id);
}
if ($voice_type) {
$query->where('voice_type', $voice_type);
}
return $query->orderBy('id')->get()->map(function ($value) {
return (array)$value;
})->toArray();
}
private function sign($key, $msg) {
return hash_hmac('sha256', $msg, $key, true);
}
// 生成签名密钥
private function getSignatureKey($key, $dateStamp, $regionName, $serviceName) {
$kDate = $this->sign($key, $dateStamp);
$kRegion = $this->sign($kDate, $regionName);
$kService = $this->sign($kRegion, $serviceName);
$kSigning = $this->sign($kService, 'request');
return $kSigning;
}
// 获取签名头信息
private function getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key) {
$contenttype = 'application/x-www-form-urlencoded';
$accept = 'application/json';
// 获取当前UTC时间
$t = new DateTime('now', new DateTimeZone('UTC'));
$xdate = $t->format('Ymd\THis\Z');
$datestamp = $t->format('Ymd');
// 1. 拼接规范请求串
$canonical_uri = '/';
$canonical_querystring = $request_parameters;
$canonical_headers = "content-type:{$contenttype}\nhost:{$host}\nx-date:{$xdate}\n";
$signed_headers = 'content-type;host;x-date';
// 空请求体的SHA256哈希
$payload_hash = hash('sha256', '');
$canonical_request = implode("\n", [
$method,
$canonical_uri,
$canonical_querystring,
$canonical_headers,
$signed_headers,
$payload_hash
]);
// 2. 拼接待签名字符串
$algorithm = 'HMAC-SHA256';
$credential_scope = implode('/', [$datestamp, $region, $service, 'request']);
$hashed_canonical_request = hash('sha256', $canonical_request);
$string_to_sign = implode("\n", [
$algorithm,
$xdate,
$credential_scope,
$hashed_canonical_request
]);
// 3. 计算签名
$signing_key = $this->getSignatureKey($secret_key, $datestamp, $region, $service);
$signature = hash_hmac('sha256', $string_to_sign, $signing_key);
// 4. 添加签名到请求头
$authorization_header = sprintf(
'%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
$algorithm,
$access_key,
$credential_scope,
$signed_headers,
$signature
);
return [
'Accept' => $accept,
'Content-Type' => $contenttype,
'X-Date' => $xdate,
'Authorization' => $authorization_header
];
}
// 文字合成语音(火山引擎)
public function tts($data) {
$url = 'https://openspeech.bytedance.com/api/v1/tts';
$headers = [
'Authorization' => 'Bearer;'.env('VOLC_TOKEN'),
'Content-Type' => 'application/json; charset=UTF-8'
];
$post_data = [
'app' => [
'appid' => env('VOLC_APPID'),
'token' => env('VOLC_TOKEN'),
'cluster' => 'volcano_tts'
],
'user' => [
'uid' => 'mp_audio'
],
// 'audio' => [
// 'voice_type' =>
// ],
];
}
public function scriptList($data) {
$script_id = getProp($data, 'script_id');
$script_name = getProp($data, 'script_name');
$query = DB::table('mp_scripts')->where('is_deleted', 0)->select('*');
if ($script_id) {
$query->where('id', $script_id);
}
if ($script_name) {
$query->where('script_name', 'like', "%{$script_name}%");
}
return $query->orderBy('created_at', 'desc')->paginate(12);
}
public function scriptInfo($data) {
$script_id = getProp($data, 'script_id');
$script = DB::table('mp_scripts')->where('id', $script_id)->where('is_deleted', 0)
->selectRaw('id as script_id, script_name, intro, highlights, roles, role_relationship, core_contradiction, art_style, scenes, status, start_episode_sequence, end_episode_sequence, episode_num, remark, created_at')->first();
if (!$script) Utils::throwError('20003:该剧本不存在!');
$script = (array)$script;
$script['roles'] = json_decode($script['roles']);
$script['scenes'] = json_decode($script['scenes']);
// 获取分集信息
$episodes = DB::table('mp_script_episodes')->where('script_id', $script_id)->orderBy('episode_number')
->select('id', 'episode_number', 'title', 'content', 'segment_number', 'segment_content')
->orderBy('episode_number')->orderBy('segment_number')->get()->map(function ($value) {
return (array)$value;
})->toArray();
$return_episodes = [];
foreach($episodes as $episode) {
$episode_number = getProp($episode, 'episode_number');
if (isset($return_episodes[$episode_number])) {
$return_episodes[$episode_number]['segments'][] = [
'segment_number' => getProp($episode, 'segment_number'),
'segment_content' => getProp($episode, 'segment_content')
];
}else {
$return_episodes[$episode_number] = [
'episode_number' => $episode_number,
'title' => getProp($episode, 'title'),
'segments' => [
[
'segment_number' => getProp($episode, 'segment_number'),
'segment_content' => getProp($episode, 'segment_content')
]
]
];
}
}
$script['episodes'] = array_values($return_episodes);
return $script;
}
public function createScript($data) {
$script_name = getProp($data, 'script_name');
$uid = Site::getUid(); // 获取当前用户ID
if (!$script_name) Utils::throwError('20001:剧本名称不能为空');
return DB::table('mp_scripts')->insertGetId([
'script_name' => $script_name,
'user_id' => $uid,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
}
/**
* 上传文件识别文字内容并与 DeepSeek 进行对话(流式输出版本)
*
* @param array $data 包含以下参数:
* - file: 上传的文件(UploadedFile 对象)或文件路径
* - question: 用户问题(可选)
* - model: 使用的模型(r1 或 v3,默认 v3)
* @return \Generator 返回生成器,用于流式输出
*/
public function chatWithFileStream($data) {
$script_id = getProp($data, 'script_id');
$file = getProp($data, 'file');
$content = getProp($data, 'content', '');
$bid = getProp($data, 'bid', 0);
$start_episode_sequence = getProp($data, 'start_episode_sequence', 1);
$total_episode_num = getProp($data, 'total_episode_num', 30);
$question = getProp($data, 'question', "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.要求一定要给出我需要的分镜集数,不能少7.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。");
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$script = DB::table('mp_scripts')->where('id', $script_id)->first();
if (!$script) {
Utils::throwError('20003:剧本不存在');
}
if (!$file && !$content && !$bid) {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 提取文件内容
if ($content) {
$content = filterContent($content);
} elseif ($file) {
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
} elseif ($bid) {
$content = $this->getContentByBid($bid);
if (!$content) {
Utils::throwError('20003:无法获取书籍内容,请检查书籍');
}
} 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,
'response_format' => ['type' => 'text'],
'stream' => true // 启用流式输出
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$response = $client->post($this->url, [
'json' => $post_data,
'headers' => $this->headers,
'stream' => true // 启用流式响应
]);
$body = $response->getBody();
$fullContent = '';
$fullReasoningContent = '';
$usage = [];
$buffer = '';
Log::info('开始读取 DeepSeek 流式响应');
// 逐行读取流式响应
while (!$body->eof()) {
$chunk = $body->read(1024); // 每次读取 1KB
$buffer .= $chunk;
// 按行分割
$lines = explode("\n", $buffer);
// 保留最后一个不完整的行
$buffer = array_pop($lines);
foreach ($lines as $line) {
$line = trim($line);
// 跳过空行
if (empty($line)) {
continue;
}
// Log::info('收到原始行数据', ['line' => $line]);
// 检查是否是 SSE 数据行
if (strpos($line, 'data: ') !== 0) {
Log::warning('非 SSE 数据行', ['line' => $line]);
continue;
}
$data = substr($line, 6); // 移除 "data: " 前缀
if ($data === '[DONE]') {
Log::info('收到结束标记');
break 2; // 跳出两层循环
}
try {
$json = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
continue;
}
// Log::info('成功解析 JSON', ['has_choices' => isset($json['choices'])]);
if (isset($json['choices'][0]['delta'])) {
$delta = $json['choices'][0]['delta'];
// 处理思维链内容(仅 deepseek-reasoner 模型)
// 注释掉以下内容则不返回d思维链内容
if (isset($delta['reasoning_content'])) {
$fullReasoningContent .= $delta['reasoning_content'];
// Log::info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]);
yield [
'type' => 'reasoning',
'content' => $delta['reasoning_content'],
'full_reasoning' => $fullReasoningContent
];
}
// 处理最终回答内容
if (isset($delta['content'])) {
$fullContent .= $delta['content'];
// Log::info('输出回答内容', ['length' => strlen($delta['content'])]);
yield [
'type' => 'content',
'content' => $delta['content'],
'full_content' => $fullContent
];
}
}
// 获取使用统计信息
if (isset($json['usage'])) {
$usage = $json['usage'];
Log::info('收到使用统计', ['usage' => $usage]);
}
} catch (\Exception $e) {
Log::error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
continue;
}
}
}
Log::info('流式读取完成', [
'content_length' => strlen($fullContent),
'reasoning_length' => strlen($fullReasoningContent)
]);
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = handleScriptContent($fullContent);
}
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) {
// dd(handleScriptContent(''));
$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);
$question = getProp($data, 'question', "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.要求一定要给出我需要的分镜集数,不能少7.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。");
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$script = DB::table('mp_scripts')->where('id', $script_id)->first();
if (!$script) {
Utils::throwError('20003:剧本不存在');
}
if (!$file && !$content && !$bid) {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 提取文件内容
if ($content) {
$content = filterContent($content);
} elseif ($file) {
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
} elseif ($bid) {
$content = $this->getContentByBid($bid);
if (!$content) {
Utils::throwError('20003:无法获取书籍内容,请检查书籍');
}
} 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,
'response_format' => ['type' => 'text'],
'stream' => false
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
$content = '';
$usage = [];
if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
$content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
$usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
}
// \Log::info('返回值内容: '.$content);
$script_arr = [];
// 处理结果
if ($content) {
$script_arr = handleScriptContent($content);
}
return [
'script' => $script_arr,
'answer' => $content,
'usage' => $usage
];
}
public function saveScript($data) {
$script_arr =getProp($data, 'script', []);
if (!$script_arr) Utils::throwError('20003:剧本内容不能为空');
$start_episode_sequence = getProp($data, 'start_episode_sequence', 1);
$script_id = getProp($data, 'script_id');
if (!$script_id) Utils::throwError('20003:剧本ID不能为空');
try {
DB::beginTransaction();
$update_data = [
'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '',
'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '',
'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '',
'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '',
'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '',
'status' => 3,
'start_episode_sequence' => $start_episode_sequence,
'end_episode_sequence' => $start_episode_sequence + count($script_arr['episodes']) - 1,
'episode_num' => count($script_arr['episodes']),
'updated_at' => date('Y-m-d H:i:s')
];
// 保存剧本内容
$boolen = DB::table('mp_scripts')->where('id', $script_id)->update($update_data);
if (!$boolen) {
dLog('deepseek')->info('剧本内容保存失败', $update_data);
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);
Utils::throwError('20003:分集内容保存失败');
}
} catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
return true;
}
public function exportScript($data) {
$script = $this->scriptInfo($data);
$export_type = getProp($data, 'export_type', 'txt');
// 生成文件名
$filename = $script['script_name'] . '_' . date('YmdHis');
// 根据导出类型生成不同格式的文件
switch ($export_type) {
case 'txt':
return $this->exportScriptAsTxt($script, $filename);
case 'pdf':
return $this->exportScriptAsPdf($script, $filename);
default:
Utils::throwError('20003:不支持的导出格式,支持:txt、pdf');
}
}
/**
* 导出剧本为TXT格式
*/
private function exportScriptAsTxt($script, $filename) {
// 构建TXT内容
$content = $this->buildScriptContent($script);
// 设置响应头,直接下载
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.txt"');
header('Content-Length: ' . strlen($content));
// 输出内容并结束
echo $content;
exit();
}
/**
* 导出剧本为PDF格式
*/
private function exportScriptAsPdf($script, $filename) {
// 使用HTML转PDF的方式来更好地支持中文
$htmlContent = $this->buildScriptHtmlForPdf($script);
// 创建PDF实例
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
// 设置文档信息
$pdf->SetCreator('剧本导出系统');
$pdf->SetAuthor('系统');
$pdf->SetTitle($script['script_name']);
$pdf->SetSubject('剧本导出');
// 设置边距
$pdf->SetMargins(15, 15, 15);
$pdf->SetAutoPageBreak(TRUE, 15);
// 添加页面
$pdf->AddPage();
// 注册并使用 simsun.ttf 字体
$pdf->SetFont('simsun', '', 12); // 设置字体和大小
// 使用HTML内容生成PDF
$pdf->writeHTML($htmlContent, true, false, true, false, '');
// 直接输出PDF文件供下载
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '.pdf"');
// 直接输出PDF内容
$pdf->Output($filename . '.pdf', 'D');
exit();
}
/**
* 构建用于PDF的HTML内容
*/
private function buildScriptHtmlForPdf($script) {
$html = '';
// 标题
$html .= '
' . htmlspecialchars($script['script_name']) . '
';
// 剧本简介
if (!empty($script['intro'])) {
$html .= '剧本简介
';
$html .= '' . nl2br(htmlspecialchars($script['intro'])) . '
';
}
// 亮点
if (!empty($script['highlights'])) {
$html .= '亮点
';
$html .= '' . nl2br(htmlspecialchars($script['highlights'])) . '
';
}
// 核心矛盾
if (!empty($script['core_contradiction'])) {
$html .= '核心矛盾
';
$html .= '' . nl2br(htmlspecialchars($script['core_contradiction'])) . '
';
}
// 艺术风格
if (!empty($script['art_style'])) {
$html .= '艺术风格
';
$html .= '' . nl2br(htmlspecialchars($script['art_style'])) . '
';
}
// 角色列表
if (!empty($script['roles']) && is_array($script['roles'])) {
$html .= '角色列表
';
$html .= '';
foreach ($script['roles'] as $role) {
if (is_array($role) || is_object($role)) {
$html .= '- ' . htmlspecialchars(getProp($role, 'role', '')) . ':' . htmlspecialchars(getProp($role, 'description', '')) . '
';
} else {
$html .= '- ' . htmlspecialchars($role) . '
';
}
}
$html .= '
';
}
// 角色关系
if (!empty($script['role_relationship'])) {
$html .= '角色关系
';
$html .= '' . nl2br(htmlspecialchars($script['role_relationship'])) . '
';
}
// 场景列表
if (!empty($script['scenes']) && is_array($script['scenes'])) {
$html .= '场景列表
';
$html .= '';
foreach ($script['scenes'] as $scene) {
if (is_array($scene) || is_object($scene)) {
$html .= '- ' . htmlspecialchars(getProp($scene, 'scene', '')) . ':' . htmlspecialchars(getProp($scene, 'description', '')) . '
';
} else {
$html .= '- ' . htmlspecialchars($scene) . '
';
}
}
$html .= '
';
}
// 分集内容
if (!empty($script['episodes']) && is_array($script['episodes'])) {
$html .= '分集内容
';
foreach ($script['episodes'] as $episode) {
$html .= '';
$html .= '
第' . htmlspecialchars($episode['episode_number']) . '集';
if (!empty($episode['title'])) {
$html .= ':' . htmlspecialchars($episode['title']);
}
$html .= '
';
// 处理分段内容
if (!empty($episode['segments']) && is_array($episode['segments'])) {
foreach ($episode['segments'] as $segment) {
if (!empty($segment['segment_content'])) {
$html .= '
';
if (!empty($segment['segment_number'])) {
$html .= '第' . htmlspecialchars($segment['segment_number']) . '段
';
}
$html .= nl2br(htmlspecialchars($segment['segment_content']));
$html .= '
';
}
}
}
$html .= '
';
}
}
return $html;
}
/**
* 构建PDF内容
*/
private function buildPdfContent($pdf, $script) {
// 标题
$pdf->SetFont('dejavusans', 'B', 18);
$pdf->Cell(0, 15, $script['script_name'], 0, 1, 'C');
$pdf->Ln(5);
// 基本信息
$pdf->SetFont('dejavusans', '', 12);
$pdf->Cell(0, 8, '创建时间:' . $script['created_at'], 0, 1);
$pdf->Cell(0, 8, '状态:' . $script['status_info'], 0, 1);
$pdf->Ln(5);
// 剧本简介
if (!empty($script['intro'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '剧本简介', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['intro'], 0, 'L');
$pdf->Ln(3);
}
// 亮点
if (!empty($script['highlights'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '亮点', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['highlights'], 0, 'L');
$pdf->Ln(3);
}
// 核心矛盾
if (!empty($script['core_contradiction'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '核心矛盾', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['core_contradiction'], 0, 'L');
$pdf->Ln(3);
}
// 艺术风格
if (!empty($script['art_style'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '艺术风格', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['art_style'], 0, 'L');
$pdf->Ln(3);
}
// 备注
if (!empty($script['remark'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '备注', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['remark'], 0, 'L');
$pdf->Ln(3);
}
// 角色列表
if (!empty($script['roles']) && is_array($script['roles'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '角色列表', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
foreach ($script['roles'] as $index => $role) {
if (is_array($role) || is_object($role)) {
$pdf->Cell(0, 8, ($index + 1) . '. ' . getProp($role, 'role', '') . ':' . getProp($role, 'description', ''), 0, 1);
} else {
$pdf->Cell(0, 8, ($index + 1) . '. ' . $role, 0, 1);
}
}
$pdf->Ln(3);
}
// 角色关系
if (!empty($script['role_relationship'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '角色关系', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['role_relationship'], 0, 'L');
$pdf->Ln(3);
}
// 场景列表
if (!empty($script['scenes']) && is_array($script['scenes'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '场景列表', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
foreach ($script['scenes'] as $index => $scene) {
if (is_array($scene) || is_object($scene)) {
$pdf->Cell(0, 8, ($index + 1) . '. ' . getProp($scene, 'scene', '') . ':' . getProp($scene, 'description', ''), 0, 1);
} else {
$pdf->Cell(0, 8, ($index + 1) . '. ' . $scene, 0, 1);
}
}
$pdf->Ln(5);
}
// 分集内容
if (!empty($script['episodes']) && is_array($script['episodes'])) {
$pdf->SetFont('dejavusans', 'B', 16);
$pdf->Cell(0, 12, '分集内容', 0, 1);
$pdf->Ln(3);
foreach ($script['episodes'] as $episode) {
// 检查是否需要新页面
if ($pdf->GetY() > 250) {
$pdf->AddPage();
}
$pdf->SetFont('dejavusans', 'B', 14);
$episodeTitle = '第' . $episode['episode_number'] . '集';
if (!empty($episode['title'])) {
$episodeTitle .= ':' . $episode['title'];
}
$pdf->Cell(0, 10, $episodeTitle, 0, 1);
// 处理分段内容
if (!empty($episode['segments']) && is_array($episode['segments'])) {
$pdf->SetFont('dejavusans', '', 12);
foreach ($episode['segments'] as $segment) {
if (!empty($segment['segment_content'])) {
// 如果有分段编号,显示分段标题
if (!empty($segment['segment_number'])) {
$pdf->SetFont('dejavusans', 'B', 12);
$pdf->Cell(0, 8, '第' . $segment['segment_number'] . '段', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
}
$pdf->MultiCell(0, 8, $segment['segment_content'], 0, 'L');
$pdf->Ln(2);
}
}
}
$pdf->Ln(5);
}
}
}
/**
* 构建剧本文本内容
*/
private function buildScriptContent($script) {
$content = '';
// 剧本基本信息
if (!empty($script['intro'])) {
$content .= "###故事梗概\n" . $script['intro'] . "\n\n";
}
if (!empty($script['highlights'])) {
$content .= "###剧本亮点\n" . $script['highlights'] . "\n\n";
}
if (!empty($script['role_relationship'])) {
$content .= "###人物关系\n" . $script['role_relationship'] . "\n\n";
}
if (!empty($script['core_contradiction'])) {
$content .= "###核心矛盾\n" . $script['core_contradiction'] . "\n\n";
}
if (!empty($script['roles']) && is_array($script['roles'])) {
$content .= "###主体列表\n";
foreach ($script['roles'] as $role) {
$content .= getProp($role, 'role') . ":" . getProp($role, 'description') . "\n";
}
$content .= "\n\n";
}
if (!empty($script['art_style'])) {
$content .= "###美术风格\n" . $script['art_style'] . "\n\n";
}
// 场景信息
if (!empty($script['scenes']) && is_array($script['scenes'])) {
$content .= "###场景列表\n";
foreach ($script['scenes'] as $scene) {
$content .= getProp($scene, 'scene') . ":" . getProp($scene, 'description') . "\n";
}
$content .= "\n\n";
}
// 分集内容
if (!empty($script['episodes']) && is_array($script['episodes'])) {
$content .= "###分集剧本\n";
foreach ($script['episodes'] as $episode) {
$content .= "##分集" . $episode['episode_number'] . "\n分集名";
if (!empty($episode['title'])) {
$content .= ":" . $episode['title'];
}
$content .= "\n";
// 处理分镜
if (!empty($episode['segments']) && is_array($episode['segments'])) {
foreach ($episode['segments'] as $segment) {
$content .= "分镜" . $episode['episode_number'] . '-' . $segment['segment_number'] . "\n";
$content .= $segment['segment_content'] . "\n\n";
}
}
}
}
return $content;
}
/**
* 上传文件识别文字内容并与 DeepSeek 进行对话(流式输出版本)
*
* @param array $data 包含以下参数:
* - file: 上传的文件(UploadedFile 对象)或文件路径
* - question: 用户问题(可选)
* - model: 使用的模型(r1 或 v3,默认 v3)
* @return \Generator 返回生成器,用于流式输出
*/
public function addChat($data) {
$uid = Site::getUid();
$file = getProp($data, 'file');
$content = getProp($data, 'content', '');
$bid = getProp($data, 'bid', 0);
$question =getProp($data, 'question', "请根据实例格式完成剧本大纲");
$prompt = getProp($data, 'prompt', '帮我根据给出的内容生成短剧大纲');
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
if (!$file && !$content && !$bid) {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 提取文件内容
if ($content) {
$content = filterContent($content);
} elseif ($file) {
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
} elseif ($bid) {
$content = $this->getContentByBid($bid);
if (!$content) {
Utils::throwError('20003:无法获取书籍内容,请检查书籍');
}
} else {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 美术风格
$input_art_style = getProp($data, 'art_style');
// 构建消息
$messages = [
[
'role' => 'system',
'content' => '你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n\n
2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述
示例如下:\n\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。'
],
[
'role' => 'user',
'content' => "以下是文档内容:
{$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' => true // 启用流式输出
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$response = $client->post($this->url, [
'json' => $post_data,
'headers' => $this->headers,
'stream' => true // 启用流式响应
]);
$body = $response->getBody();
$fullContent = '';
$fullReasoningContent = '';
$usage = [];
$buffer = '';
dLog('deepseek')->info('开始读取 DeepSeek 流式响应');
// 逐行读取流式响应
while (!$body->eof()) {
$chunk = $body->read(1024); // 每次读取 1KB
$buffer .= $chunk;
// 按行分割
$lines = explode("\n", $buffer);
// 保留最后一个不完整的行
$buffer = array_pop($lines);
foreach ($lines as $line) {
$line = trim($line);
// 跳过空行
if (empty($line)) {
continue;
}
// dLog('deepseek')->info('收到原始行数据', ['line' => $line]);
// 检查是否是 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;
}
// dLog('deepseek')->info('成功解析 JSON', ['has_choices' => isset($json['choices'])]);
if (isset($json['choices'][0]['delta'])) {
$delta = $json['choices'][0]['delta'];
// 处理思维链内容(仅 deepseek-reasoner 模型)
// 注释掉以下内容则不返回d思维链内容
if (isset($delta['reasoning_content'])) {
$fullReasoningContent .= $delta['reasoning_content'];
// dLog('deepseek')->info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]);
yield [
'type' => 'reasoning',
'content' => $delta['reasoning_content'],
'full_reasoning' => $fullReasoningContent
];
}
// 处理最终回答内容
if (isset($delta['content'])) {
$fullContent .= $delta['content'];
// dLog('deepseek')->info('输出回答内容', ['length' => strlen($delta['content'])]);
yield [
'type' => 'content',
'content' => $delta['content'],
'full_content' => $fullContent
];
}
}
// 获取使用统计信息
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)
]);
dLog('deepseek')->info('完整内容: '.$fullContent);
// $fullContent = $this->test_content;
// $fullReasoningContent = [];
// $usage = [];
// $uid = 0;
// $content = '';
// $question = '';
// $anime_id = 10;
// $fullContent = '###剧本名:看不见的守护者
// ###故事梗概
// 假千金许芸重生回到悲剧发生前一个月,为改变被养父母逼迫联姻、最终家暴致死的命运,她提前与真千金徐清遥身份归位,回到亲生父母家。然而,亲生母亲重男轻女,表哥卞大伟对她虎视眈眈。在危机时刻,一个因系统规则而无法被她看见、听见的“空气人”屡次救她于危难。许芸逐渐发现,“空气人”竟是高中时她曾资助过的学霸程景,他因感恩与暗恋,用自己的一切换她重生。两人在“看不见”的困境中相互试探、彼此救赎。许芸勇敢反击原生家庭的伤害,并执着地追寻程景的存在。最终,她的坚持冲破了系统限制,两人得以相见相守,共度余生。
// ###剧本亮点
// 亮点1:无形守护,生死相随
// 开场即用蒙太奇手法快速闪回许芸上辈子惨死、陌生男人(程景)为她收尸下葬的悲凉画面,与重生后她在车流中被“无形力量”猛然拉回的惊险瞬间形成强烈对比。通过许芸茫然四顾、观众却能看到空气中隐约波动的特效,营造出“守护者无处不在却不可见”的悬疑与浪漫感,奠定故事独特基调。
// 亮点2:阁楼共生,暧昧无声
// 狭小阁楼内,许芸与“看不见”的程景共处。画面聚焦于两碗面条自动减少、带血纸巾莫名出现、深夜有人喂水等细节,通过许芸的独角戏表演和空镜头的巧妙运用,将无形的陪伴与克制的欲望可视化。尤其是许芸主动“挑衅”、程景流鼻血的幽默桥段,在压抑中迸发甜涩的青春悸动,情感张力十足。
// 亮点3:真相撕裂,破障重逢
// 当许芸从徐清遥处得知程景是她曾资助的男孩时,记忆碎片如潮水般涌入脑海的视觉呈现。高潮在于许芸为逼程景现身而“跳楼”,程景下意识抱住她却被“看见”的瞬间。镜头从许芸视角切换为全知视角,程景英俊却惊慌的脸首次清晰展现,两人对视间,长达十年的暗恋、守护与挣扎在此刻爆发,极具情感冲击力。
// ###人物关系
// 许芸与程景:从被资助者与资助者,发展为被守护者与隐形守护者,最终成为恋人、夫妻。
// 许芸与徐母(生母):对抗关系,徐母重男轻女,偏袒侄子卞大伟,是许芸前期的主要压力源。
// 许芸与卞大伟:受害者与加害者,卞大伟是许芸人身安全的直接威胁。
// 许芸与徐清遥(真千金):身份互换的复杂关系,前期存在潜在对立,后期因共同遭遇(被卞大伟侵害威胁)而产生微妙共鸣。
// 程景与夏颜:同学关系,夏颜单恋程景,并因嫉妒成为许芸与程景关系中的短暂阻碍。
// ###核心矛盾
// 许芸渴望看见并拥抱用生命守护她的程景,却受系统规则所限,无法感知他的存在;程景深爱许芸,愿为她付出一切,却因自卑、身体受损(以为不育)及系统限制,一度选择逃避和“分手”。两人在“看不见的爱”与“触不到的恋人”困境中,进行一场关于勇气、坚持与相互救赎的博弈。
// ###主体列表
// 许芸-高中生(重生初期):17岁,面容清丽但带着重生后的疲惫与警觉。身形纤细,常穿着洗得发白的校服或简单卫衣,眼神时而脆弱时而坚定。
// 许芸-大学生/成年:20-30岁,气质逐渐成熟温婉,衣着简约大方,笑容增多,眼中有了被爱滋养的光彩。
// 程景-隐形状态(高中至大学):观众可见其模糊轮廓或行动痕迹(如血脚印、空气波动),实际为18-22岁,身高约185cm,肩宽腿长,习惯穿干净的白衬衫或棉质T恤,身上有淡淡的皂角清香。
// 程景-显形状态(大学后):面容英俊,眉眼深邃,鼻梁高挺,下颌线清晰。身材挺拔,有清晰的肌肉线条。初期显形时略带羞涩与不安,后期沉稳深情。
// 卞大伟:20岁出头,身材粗壮,皮肤油腻,小眼睛透着阴沉与邪气,常穿脏旧的背心或夹克。
// 徐母:40多岁,面容刻薄,身材微胖,烫着过时的卷发,穿着艳俗的碎花衬衫,眼神势利。
// 徐父:40多岁,身材佝偻,面容愁苦,穿着沾满污渍的工装,眼神怯懦。
// 夏颜:17-20岁,齐肩短发,长相清秀,穿着得体,前期是开朗学霸,后期面对程景时眼神充满倾慕与嫉妒。
// 徐清遥:17岁,与许芸有几分相似,但气质更冷冽倔强,穿着普通但整洁。
// ###美术风格
// 基础画风风格词:现实主义的细腻光影与超现实元素结合
// 整体采用现实主义基调,色彩饱和度较低,前期以灰蓝、暗绿色调为主,渲染重生后的压抑感与危机感。许芸在徐家的场景光线昏暗、拥挤;程景的阁楼则色调偏暖黄,营造狭小但安全的港湾感。“隐形”程景存在的场景,通过细微的空气扭曲、光影异常、物品无故移动等超现实特效来表现。后期两人相见后,画面色彩逐渐明亮、温暖,尤其婚后生活场景,充满柔和的日光与温馨的家居细节。人物造型追求真实感,服装、环境道具都需体现年代与阶层特征。
// ###场景列表
// 徐家馄饨店:老旧居民楼底层店铺,门面窄小,招牌褪色。店内狭窄拥挤,墙壁被油烟熏得发黄,摆放着简陋的桌椅和包馄饨的案板。光线主要来自门口和一台昏黄的吊灯,整体氛围压抑脏乱。
// 程景的阁楼:位于老式居民楼顶层,空间低矮狭小,斜屋顶开着一扇小窗。室内仅有一张单人床、一张旧书桌和一个矮几。书桌上堆满高中课本与试卷,床单整洁但洗得发白。阳光透过小窗形成光柱,灰尘在光中飞舞,环境简陋但充满生活气息与书卷气。
// 学校走廊与教室:典型的县级高中环境,走廊墙壁斑驳,贴满励志标语。教室拥挤,课桌堆满书本。重点班教室略显整洁,氛围紧张有序。
// 街边与巷口:许芸被卞大伟堵截的地点,位于老旧街区,墙壁布满涂鸦和小广告,路边有臭水沟,环境杂乱,危机四伏。
// 医院病房:多人间病房,设施陈旧,光线苍白冰冷。2号病床空无一物(观众可见程景,许芸看不见),与周围形成诡异对比,突出许芸的孤独与无助。
// 婚后家居:十年后,许芸与程景的家。宽敞明亮的客厅,装修简约温馨,有绿植和照片墙,阳光充足,与之前的压抑环境形成鲜明对比,象征苦尽甘来。 ';
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = handleScriptContent($fullContent);
}
// 替换美术风格
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
// 新建动漫
$anime_name = getProp($script_arr, 'script_name');
if (!$anime_name) {
yield [
'type' => 'done',
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => '未能识别到剧本名称,请确认内容是否符合要求'
];
}
// 确认对话名称唯一性
if (DB::table('mp_animes')->where('anime_name', $anime_name)->value('id')) {
$anime_name = $anime_name . '_' . date('YmdHis');
}
$table_data = [
'user_id' => $uid,
'anime_name' => $anime_name,
'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '',
'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '',
'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '',
'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '',
'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '',
'status' => '解析完成',
'content' => $content,
// 'start_episode_sequence' => 1,
// 'end_episode_sequence' => count($script_arr['episodes']),
// 'episode_num' => count($script_arr['episodes']),
'generate_status' => '待执行',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
try {
DB::beginTransaction();
// 新建动漫大纲
$anime_id = DB::table('mp_animes')->insertGetId($table_data);
if (!$anime_id) {
dLog('deepseek')->info('对话保存失败', $table_data);
Utils::throwError('20003:对话保存失败');
}
// 保存对话记录
$recrods = [
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 0,
'role' => 'user',
'content' => "用户问题:\n
{$prompt}\n\n
以下是文档内容:\n
{$content}",
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
],
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 0,
'role' => 'assistant',
'content' => $fullContent,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]
];
// 保存对话记录
$boolen3 = DB::table('mp_anime_records')->insert($recrods);
if (!$boolen3) {
Utils::throwError('20003:对话记录保存失败');
}
}catch (\Exception $e) {
DB::rollBack();
dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage());
yield [
'type' => 'done',
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => $e->getMessage(),
];
}
DB::commit();
$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']);
yield [
'type' => 'done',
'script' => $table_data,
'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);
$anime_id = getProp($data, 'anime_id');
$anime = DB::table('mp_animes')->where('id', $anime_id)->first();
if (!$anime) {
Utils::throwError('20003:该对话不存在');
}
$question =getProp($data, 'question', "请根据实例格式完成剧本大纲");
$prompt = getProp($data, 'prompt', '帮我根据给出的内容生成短剧大纲');
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
// 提取文件内容
if ($content) {
$content = filterContent($content);
} elseif ($file) {
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
} elseif ($bid) {
$content = $this->getContentByBid($bid);
if (!$content) {
Utils::throwError('20003:无法获取书籍内容,请检查书籍');
}
} else {
$content = filterContent(getProp($anime, 'content'));
}
// 美术风格
$input_art_style = getProp($data, 'art_style');
$system_message = [
'role' => 'system',
'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n
2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述\n
3.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答\n
示例如下:\n\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。"
];
// 如果提示词$prompt有类似于改成N集或调整成N集这类的文字信息,则提取出N这个集数
$episode_count = $this->extractEpisodeNumber($prompt);
if ($episode_count) {
$question = "请根据实例格式完成{$episode_count}集剧本大纲";
$system_message = [
'role' => 'system',
'content' => '你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集详细内容>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n
2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述\n
3.<分集详细内容>中每一集的内容需尽可能的详细,保留对话内容(使用原文内容,不得自己生成),一般每集字数尽量不低于500字,每集结尾需以##第N章 作为章节标题\n
4.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答\n
示例如下:\n\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。
###分集详细内容
##第1章 重生
我重生了。
源于一个男人偏执的暗恋。
##第2章 相救
我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我,问我夜不归宿死哪儿去了。
我眼睛扫向站在锅炉后的卞大伟。'
];
// 构建消息
$messages = [
$system_message,
[
'role' => 'user',
'content' => "以下是文档内容:
{$content}
用户问题:
{$question}"
]
];
}else {
if (strstr($prompt, '重新生成') || strstr($prompt, '再生成')) {
// 构建消息
$messages = [
$system_message,
[
'role' => 'user',
'content' => "以下是文档内容:
{$content}
用户问题:
{$question}"
]
];
}else {
$messages = DB::table('mp_anime_records')->where('anime_id', $anime_id)->where('sequence', 0)
->orderBy('id')->select('role', 'content')->get()->map(function ($value) {
return (array)$value;
})->toArray();
array_unshift($messages, $system_message);
$messages[] = [
'role' => 'user',
'content' => $prompt
];
}
}
$post_data = [
'model' => $model,
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 0.7,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'stream' => true // 启用流式输出
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$response = $client->post($this->url, [
'json' => $post_data,
'headers' => $this->headers,
'stream' => true // 启用流式响应
]);
$body = $response->getBody();
$fullContent = '';
$fullReasoningContent = '';
$usage = [];
$buffer = '';
dLog('deepseek')->info('开始读取 DeepSeek 流式响应');
// 逐行读取流式响应
while (!$body->eof()) {
$chunk = $body->read(1024); // 每次读取 1KB
$buffer .= $chunk;
// 按行分割
$lines = explode("\n", $buffer);
// 保留最后一个不完整的行
$buffer = array_pop($lines);
foreach ($lines as $line) {
$line = trim($line);
// 跳过空行
if (empty($line)) {
continue;
}
// dLog('deepseek')->info('收到原始行数据', ['line' => $line]);
// 检查是否是 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;
}
// dLog('deepseek')->info('成功解析 JSON', ['has_choices' => isset($json['choices'])]);
if (isset($json['choices'][0]['delta'])) {
$delta = $json['choices'][0]['delta'];
// 处理思维链内容(仅 deepseek-reasoner 模型)
// 注释掉以下内容则不返回d思维链内容
if (isset($delta['reasoning_content'])) {
$fullReasoningContent .= $delta['reasoning_content'];
// dLog('deepseek')->info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]);
yield [
'type' => 'reasoning',
'content' => $delta['reasoning_content'],
'full_reasoning' => $fullReasoningContent
];
}
// 处理最终回答内容
if (isset($delta['content'])) {
$fullContent .= $delta['content'];
// dLog('deepseek')->info('输出回答内容', ['length' => strlen($delta['content'])]);
yield [
'type' => 'content',
'content' => $delta['content'],
'full_content' => $fullContent
];
}
}
// 获取使用统计信息
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)
]);
dLog('deepseek')->info('完整内容: '.$fullContent);
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = handleScriptContent($fullContent);
}
// 替换美术风格
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
// // 新建动漫
// $anime_name = getProp($script_arr, 'script_name');
// if (!$anime_name) {
// yield [
// 'type' => 'done',
// 'answer' => $fullContent,
// 'reasoning' => $fullReasoningContent,
// 'usage' => $usage,
// 'error' => '未能识别到剧本名称,请确认内容是否符合要求'
// ];
// }
// // 确认对话名称唯一性
// if (DB::table('mp_animes')->where('anime_name', $anime_name)->value('id')) {
// $anime_name = $anime_name . '_' . date('YmdHis');
// }
$table_data = [
'user_id' => $uid,
// 'anime_name' => $anime_name,
'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '',
'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '',
'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '',
'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '',
'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '',
'status' => '解析完成',
'generate_status' => '待执行',
'updated_at' => date('Y-m-d H:i:s')
];
// 如果是修改集数,则将content内容更新(会改变原文)
if (isset($episode_count) && $episode_count > 0) {
if (isset($script_arr['content']) && $script_arr['content']) $table_data['content'] = $script_arr['content'];
$table_data['end_episode_sequence'] = $table_data['episode_num'] = $episode_count;
}else {
$chapters = $this->splitContent($content);
$chapter_count = count($chapters);
if ($chapter_count > 0) {
$table_data['end_episode_sequence'] = $table_data['episode_num'] = $chapter_count;
}
}
try {
DB::beginTransaction();
// 新建动漫大纲
$boolen = DB::table('mp_animes')->where('id', $anime_id)->update($table_data);
if (!$boolen) {
dLog('deepseek')->info('对话修改失败', $table_data);
Utils::throwError('20003:对话修改失败');
}
// 保存对话记录
$recrods = [
[
'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($recrods);
if (!$boolen3) {
Utils::throwError('20003:对话记录保存失败');
}
}catch (\Exception $e) {
DB::rollBack();
dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage());
yield [
'type' => 'done',
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => $e->getMessage(),
];
}
DB::commit();
$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,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
public function chat($data) {
$uid = Site::getUid();
$anime_id = getProp($data, 'anime_id');
$episode_number = getProp($data, 'episode_number', 1);
$anime = DB::table('mp_animes')->where('id', $anime_id)->select('content', 'roles', 'scenes')->first();
if (!$anime) Utils::throwError('20003:该对话不存在!');
$roles = json_decode(getProp($anime, 'roles'), true);
$scenes = json_decode(getProp($anime, 'scenes'), true);
// 转换主体列表和场景列表的格式
$roles_content = '';
foreach ($roles as $role) {
$roles_content .= getProp($role, 'role').': '.getProp($role, 'description')."\n";
}
$scenes_content = '';
foreach($scenes as $scene) {
$scenes_content .= getProp($scene, 'scene').': '.getProp($scene, 'description')."\n";
}
$prompt = getProp($data, 'prompt', '确认分镜大纲');
$demo = "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需包含以下几个板块: <剧集标题><故事梗概><美术风格><主体列表><场景列表><分镜剧本>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n\n
1.剧集标题必须与文档内容高度相关,剧集序号是{$episode_number}
2.<主体列表>必须在以下主体中选择多个(根据拆分出的分镜剧本来确认具体几个),不得自定义(需带上冒号后的具体描述):{$roles_content}\n\n
3.<场景列表>必须在以下场景中选择多个(根据拆分出的分镜剧本来确认具体几个),不得自定义(需带上冒号后的具体描述):{$scenes_content}\n\n
4.<分镜剧本>要求包含“分幕标题”以及“分镜信息”: \n\n
4.1“分幕标题”是根据章节中的场景进行拆分,类似于“第1幕 场景、时间及室内或室外”的信息,其中场景只能在所给出的场景列表中选(不带场景详细描述)。
4.2“分镜信息”需同一场景内的对话内容完成分镜(每一幕分镜尽量不得低于5个),需包含“画面描述”、“构图设计”、“运镜调度”、“配音音色”、“台词内容”和“画面类型”这几个部分,其中“画面描述”如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)。
4.3分镜序号用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨分幕中的分镜序号需连续,但跨剧集中的不需要连续,如第2集的第1幕分镜序号从1开始)。
4.4“分镜信息”中的“台词内容”需与原文中的台词完全一致,不得修改或添加任何内容。
4.5“分镜信息”中的“配音角色”需对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色),不得自己生成。
4.6“分镜信息”中的“场景”需对应本集中的<场景列表>中的一个(不带详细表述)
4.7“分镜信息”中的“出镜角色”需对应本集中的<主体列表>中的一个或多个(不带详细描述),如果没有出镜角色则填无
4.8“分镜信息”中的“尾帧描述”需根据本镜和下一镜的剧本信息进行过渡生成,用于后续转视频进行衔接上下镜头的画面
示例格式:\n\n
第1集:隐形的守护者
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###美术风格
基础画风风格词:韩漫二次元
视觉风格描述:整体采用精致的韩漫厚涂风格,以浅灰冷调为基调,营造出重生复仇的清冷与压抑感。画面强调高对比度的光影处理,网吧场景通过低饱和度的杂乱色调表现颓废感,而清晨车祸瞬间则利用强烈的动态模糊与局部高光,突显“透明守护者”存在的神秘感与宿命感。人物形象细腻,眼神充满坚毅与破碎交织的故事感,完美契合偏执救赎的主题。
###主体列表
许芸-校服装:重生后的“假千金”,清醒果敢,不再任人唯亲。她是一名高中生,身材纤细,留着乌黑齐肩短发,眼神坚定。
许芸-卫衣装:半个月后居家或外出时的装扮,在清冷的环境中透着一丝坚毅与破碎感。
程景:偏执深情的学霸,为救许芸变得“不可见”,是始终默默守护的隐形爱人。他身材高大挺拔,面容英俊。
卞大伟:许芸名义上的表哥,阴险暴戾的无业游民。他身材肥胖,面部肉感重,一双小眼睛透着阴冷。
徐母:许芸亲生母亲,重男轻女且极度刻薄,面容严厉。
###场景列表
徐家老旧厨房:这是徐母劳作和惩罚许芸的地方。空间狭窄阴暗,充满了生活琐碎的压抑感。室内有一个旧式锅炉,案板上散落着面粉和包好的馄饨,光影对比强烈,凸显出家庭环境的冷酷。
徐家简陋客厅:半个月后冲突爆发的中心。陈设简单且陈旧,只有几把木凳和一张方桌。窗帘半掩,透进微弱的冷光,营造出一种暴风雨前的宁静与不安感。
老旧公寓门口:许芸逃出家门后回望的视点。门框斑驳,透出屋内昏暗压抑的光线,与室外略显空旷的走廊形成对比,象征着逃离与未知的守护。
破败巷弄与臭水沟:家门外的外部环境。一条狭窄、潮湿的巷弄,旁边是散发着冷气的臭水沟,水面偶尔泛起涟漪。这里是“透明守护者”受伤后经过的地方,氛围凄清。
潮湿的巷弄地面:特写视角。灰色的水泥地面湿迹斑斑,这是许芸发现血脚印并进行最后告白的地方。强调地面的质感和清冷的基调。
###分镜剧本
##第1幕:徐家老旧厨房 白天 室内
分镜1
画面描述:许芸-校服装站在厨房门口,书包背在肩上,徐母手持扫帚,正对着许芸的背部猛烈挥打。卞大伟站在锅炉后方,双手抱胸,阴沉的目光落在许芸身上。
场景:徐家老旧厨房
构图设计:全景,低角度仰视
运镜调度:手持拍摄
配音角色:旁白
出镜角色:许芸-校服装、徐母
台词内容:我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我,
画面类型:普通画面
尾帧描述:手持拍摄微晃镜头,中年妇女猛力挥动扫帚击打少女背部,少女身体因冲击微颤,阴影中的肥胖男子目光冷冷跟随。
分镜2
画面描述:徐母面容扭曲,嘴唇快速开合,扫帚挥舞在空中。
场景:徐家老旧厨房
构图设计:近景,徐母面部特写
运镜调度:固定镜头
配音角色:徐母
出镜角色:徐母
台词内容:问我夜不归宿死哪儿去了。
画面类型:对口型
尾帧描述:固定镜头,中年妇女面部肌肉剧烈抖动,嘴唇快速开合进行辱骂,背景中的扫帚在空中上下挥动。
##第2幕:徐家简陋客厅 黄昏 室内
分镜3
画面描述:客厅窗户透出昏黄的夕阳余晖,空荡荡的客厅中央只有几把木凳和一张方桌,显得异常安静。
场景:徐家简陋客厅
构图设计:全景,景深虚化
运镜调度:固定镜头
配音角色:旁白
出镜角色:无
台词内容:可我没想到,半个月后,徐母居然再一次外出,
画面类型:普通画面
尾帧描述:固定镜头。昏黄的夕阳余晖在地面上缓慢移动,空气中的浮尘在光束中跳动,室内维持着死寂的安静。
分镜4
画面描述:一张泛黄的火车票特写,上面显示着隔壁市的站名和日期。
场景:徐家简陋客厅
构图设计:特写,火车票
运镜调度:固定镜头
配音角色:旁白
出镜角色:无
台词内容:这次是去隔壁市看望她生病的好姐妹。
画面类型:普通画面
尾帧描述:固定镜头。光影在火车票表面微微晃动,强调其陈旧的纸张纹理与关键的行程信息。
\n\n";
// 获取章节内容
$full_content = getProp($anime, 'content');
// 根据章节内容拆分章节
$chapters = $this->splitContent($full_content);
if ($prompt == '确认分镜大纲') {
$content = isset($chapters[($episode_number-1)]['content']) ? $chapters[($episode_number-1)]['content'] : '';
if (!$content) {
Utils::throwError('20003:章节内容无法获取,请确认');
}
$question = $demo."请根据以上要求分析本章剧情,完成分镜剧本,本章剧情内容如下: \n\n$content";
// 构建消息
$messages[] =
[
'role' => 'user',
'content' => $question
];
}else if ($prompt == '继续策划下一集') {
$content = isset($chapters[($episode_number-1)]['content']) ? $chapters[($episode_number-1)]['content'] : '';
if (!$content) {
Utils::throwError('20003:章节内容无法获取,请确认');
}
$question = $demo."请根据以上要求分析本章剧情,完成分镜剧本(需衔接上一章内容),本章剧情内容如下: \n\n$content";
// 获取上一集最后记录
$prev_sequence = $episode_number - 1;
$messages = DB::table('mp_anime_records')->where('anime_id', $anime_id)->where('sequence', $prev_sequence)
->select('role', 'content')->orderBy('created_at', 'desc')->orderBy('id')->get()->map(function($value) {
return (array)$value;
})->toArray();
// 构建消息
$messages[] =
[
'role' => 'user',
'content' => $question
];
}else {
$content = $full_content;
$question = $prompt;
$messages[] =
[
'role' => 'user',
'content' => $question
];
}
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$post_data = [
'model' => $model,
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 0.7,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'stream' => true // 启用流式输出
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$response = $client->post($this->url, [
'json' => $post_data,
'headers' => $this->headers,
'stream' => true // 启用流式响应
]);
$body = $response->getBody();
$fullContent = '';
$fullReasoningContent = '';
$usage = [];
$buffer = '';
dLog('deepseek')->info('开始读取 DeepSeek 流式响应');
// 逐行读取流式响应
while (!$body->eof()) {
$chunk = $body->read(1024); // 每次读取 1KB
$buffer .= $chunk;
// 按行分割
$lines = explode("\n", $buffer);
// 保留最后一个不完整的行
$buffer = array_pop($lines);
foreach ($lines as $line) {
$line = trim($line);
// 跳过空行
if (empty($line)) {
continue;
}
// dLog('deepseek')->info('收到原始行数据', ['line' => $line]);
// 检查是否是 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;
}
// dLog('deepseek')->info('成功解析 JSON', ['has_choices' => isset($json['choices'])]);
if (isset($json['choices'][0]['delta'])) {
$delta = $json['choices'][0]['delta'];
// 处理思维链内容(仅 deepseek-reasoner 模型)
// 注释掉以下内容则不返回d思维链内容
if (isset($delta['reasoning_content'])) {
$fullReasoningContent .= $delta['reasoning_content'];
// dLog('deepseek')->info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]);
yield [
'type' => 'reasoning',
'content' => $delta['reasoning_content'],
'full_reasoning' => $fullReasoningContent
];
}
// 处理最终回答内容
if (isset($delta['content'])) {
$fullContent .= $delta['content'];
// dLog('deepseek')->info('输出回答内容', ['length' => strlen($delta['content'])]);
yield [
'type' => 'content',
'content' => $delta['content'],
'full_content' => $fullContent
];
}
}
// 获取使用统计信息
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)
]);
// // $fullContent = '111';
// // $fullReasoningContent = [];
// // $usage = [];
// // $uid = 0;
// // $content = '';
// // $question = '';
// // $episode_number = 1;
// // $anime_id = 13;
// $fullContent = DB::table('mp_anime_records')->orderBy('id', 'desc')->value('content');
dLog('deepseek')->info('完整内容: '.$fullContent);
// 处理完整内容并返回最终结果
$episode_arr = handleEpisodeContent($fullContent);
try {
DB::beginTransaction();
// 新建动漫剧集
$episode = [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'title' => $episode_arr['episode_title'],
'content' => $content,
// 'content' => '',
'intro' => $episode_arr['intro'],
'art_style' => $episode_arr['art_style'],
'roles' => json_encode($episode_arr['roles'], 256),
'scenes' => json_encode($episode_arr['scenes'], 256),
'sequence' => 1,
'generate_status' => '待执行',
'generate_at' => date('Y-m-d H:i:s'),
'is_default' => 1,
'updated_at' => date('Y-m-d H:i:s'),
];
$current_episode = DB::table('mp_anime_episodes')->where('anime_id', $anime_id)->where('episode_number', $episode_number)->where('is_default', 1)->select('id', 'sequence')->first();
if ($current_episode) {
$episode['sequence'] = getProp($current_episode, 'sequence');
$episode_id = getProp($current_episode, 'id');
$boolen2 = DB::table('mp_anime_episodes')->where('anime_id', $anime_id)->where('episode_id', $episode_id)->update($episode);
}else {
$episode['created_at'] = date('Y-m-d H:i:s');
$episode_id = DB::table('mp_anime_episodes')->insertGetId($episode);
$boolen2 = $episode_id ? true : false;
}
if (!$boolen2) {
dLog('deepseek')->info('分集内容保存失败', $episode);
Utils::throwError('20003:分集内容保存失败');
}
// 删除历史分集内容
DB::table('mp_episode_segments')->where('anime_id', $anime_id)->where('episode_id', $episode_id)->delete();
$segments = [];
foreach($episode_arr['acts'] as $act) {
foreach($act['segments'] as $segment) {
$segments[] = [
'anime_id' => $anime_id,
'episode_id' => $episode_id,
'episode_number' => $episode_number,
'act_number' => $act['act_number'],
'act_title' => $act['act_title'],
'segment_id' => getProp($segment, 'segment_id'),
'segment_number' => getProp($segment, 'segment_number'),
'segment_content' => getProp($segment, 'segment_content'),
'description' => getProp($segment, 'description'),
'composition' => getProp($segment, 'composition'),
'camera_movement' => getProp($segment, 'camera_movement'),
'voice_actor' => getProp($segment, 'voice_actor'),
'dialogue' => getProp($segment, 'dialogue'),
'frame_type' => getProp($segment, 'frame_type'),
'scene' => getProp($segment, 'scene'),
'characters' => getProp($segment, 'characters'),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
}
}
$boolen3 = DB::table('mp_episode_segments')->insert($segments);
if (!$boolen3) {
dLog('deepseek')->info('分镜内容保存失败', $episode);
Utils::throwError('20003:分镜内容保存失败');
}
// 保存对话记录
$recrods = [
[
'uid' => $uid,
'anime_id' => $anime_id,
'role' => 'user',
'content' => $prompt,
'sequence' => $episode_number,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
],
[
'uid' => $uid,
'anime_id' => $anime_id,
'role' => 'assistant',
'content' => $fullContent,
'sequence' => $episode_number,
'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($recrods);
if (!$boolen3) {
Utils::throwError('20003:对话记录保存失败');
}
}catch (\Exception $e) {
DB::rollBack();
dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage());
yield [
'type' => 'done',
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => $e->getMessage(),
];
}
DB::commit();
// dd('end');
$episode['episode_id'] = $episode_id;
$episode['roles'] = json_decode($episode['roles'], true);
$episode['scenes'] = json_decode($episode['scenes'], true);
$episode['acts'] = json_decode($episode['acts'], true);
yield [
'type' => 'done',
'episode' => $episode,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
public function chatChangeImg($data) {
$segment = getProp($data, 'segment');
$segment_content = getProp($segment, 'segment_content');
$prompt = getProp($data, 'prompt');
$ref_img = getProp($data, 'ref_img');
$question = "请修改已有的内容{$segment_content}\n修改要求如下:\n{$prompt}";
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$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 // 是否启用流式输出
];
// 调用流式输出的deepseek
$chatResult = $this->chatOnly($post_data);
if (is_array($chatResult)) {
extract($chatResult);
}else {
Utils::throwError('20003: 接口调用失败!');
}
return [
'type' => 'done',
// 'episode' => $episode,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
// 仅调用deepseek对话接口
private function chatOnly($post_data) {
$client = new Client(['timeout' => 1200, 'verify' => false]);
$result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
dLog('deepseek')->info('deepseek请求完成', ['response' => $response_arr]);
$fullContent = '';
$fullReasoningContent = [];
$usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
$fullContent = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
$fullReasoningContent = isset($response_arr['choices'][0]['message']['reasoning_content']) ? $response_arr['choices'][0]['message']['reasoning_content'] : '';
}
return [
'fullContent' => $fullContent,
'fullReasoningContent' => $fullReasoningContent,
'usage' => $usage
];
}
private function splitContent($content) {
// 根据章节标题来拆分章节内容,返回包含标题和内容的二维数组
$chapters = [];
// 匹配不同格式的章节标题:###第X章、##第X章、第X章
$pattern = '/^(#{0,3}\s*第[^章]*章[^\n]*)\n(.*?)(?=\n#{0,3}\s*第[^章]*章|\z)/ms';
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$title = trim($match[1], "# \t\n\r\0\x0B"); // 去除标题前的#号和空白字符
$chapterContent = trim($match[2]); // 去除内容前后的空白字符
if (!empty($title) && !empty($chapterContent)) {
$chapters[] = [
'title' => $title,
'content' => $chapterContent
];
}
}
}
return $chapters;
}
public function getContentByBid($bid) {
$chapters = DB::table('chapters as c')->select('c.id', 'c.name', 'cc.content')
->leftJoin('chapter_contents as cc', 'c.chapter_content_id', '=', 'cc.id')
->where('c.bid', $bid)->where('c.is_check', 1)->where('c.is_deleted', 0)->orderBy('c.sequence')->get();
$content = '';
foreach ($chapters as $chapter) {
$content .= $chapter->content . PHP_EOL . PHP_EOL;
}
return $content;
}
/**
* 根据文件类型提取文本内容
*
* @param mixed $file 文件对象或文件路径
* @return string
*/
public function extractFileContent($file) {
// 获取文件路径和扩展名
if (is_string($file)) {
$filePath = $file;
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
} else {
// Laravel UploadedFile 对象
$filePath = $file->getRealPath();
$extension = strtolower($file->getClientOriginalExtension());
}
$content = '';
switch ($extension) {
case 'txt':
$content = $this->extractTxtContent($filePath);
break;
case 'pdf':
$content = $this->extractPdfContent($filePath);
break;
case 'doc':
case 'docx':
$content = $this->extractWordContent($filePath);
break;
// case 'jpg':
// case 'jpeg':
// case 'png':
// case 'gif':
// case 'bmp':
// case 'webp':
// $content = $this->extractImageContent($filePath);
// break;
default:
Utils::throwError('20003:不支持的文件格式,支持:txt、pdf');
}
return $content;
}
/**
* 提取 TXT 文件内容
*/
private function extractTxtContent($filePath) {
if (!file_exists($filePath)) {
Utils::throwError('20003:文件不存在');
}
$content = file_get_contents($filePath);
// 尝试检测并转换编码
$encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312', 'BIG5'], true);
if ($encoding && $encoding !== 'UTF-8') {
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
}
return trim($content);
}
/**
* 提取 PDF 文件内容
*/
private function extractPdfContent($filePath) {
if (!file_exists($filePath)) {
Utils::throwError('20003:文件不存在');
}
try {
$parser = new PdfParser();
$pdf = $parser->parseFile($filePath);
$content = $pdf->getText();
return trim($content);
} catch (\Exception $e) {
dLog('deepseek')->error('PDF解析失败: ' . $e->getMessage());
Utils::throwError('20003:PDF解析失败,请确保文件格式正确');
}
}
/**
* 提取 Word 文件内容(支持 doc 和 docx)
*/
private function extractWordContent($filePath) {
if (!file_exists($filePath)) {
Utils::throwError('20003:文件不存在');
}
try {
$phpWord = WordIOFactory::load($filePath);
$content = '';
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
$content .= $this->extractWordElementText($element);
}
}
return trim($content);
} catch (\Exception $e) {
dLog('deepseek')->error('Word解析失败: ' . $e->getMessage());
Utils::throwError('20003:Word解析失败,请确保文件格式正确');
}
}
/**
* 递归提取 Word 元素中的文本
*/
private function extractWordElementText($element) {
$text = '';
if (method_exists($element, 'getText')) {
$text .= $element->getText() . "\n";
} elseif (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $childElement) {
$text .= $this->extractWordElementText($childElement);
}
}
return $text;
}
/**
* 提取图片内容(使用火山引擎 OCR)
*/
private function extractImageContent($filePath) {
if (!file_exists($filePath)) {
Utils::throwError('20003:文件不存在');
}
try {
// 读取图片并转换为 base64
$imageData = file_get_contents($filePath);
$base64Image = base64_encode($imageData);
// 调用火山引擎 OCR 服务
$content = $this->callVolcEngineOCR($base64Image);
return trim($content);
} catch (\Exception $e) {
dLog('deepseek')->error('图片OCR识别失败: ' . $e->getMessage());
Utils::throwError('20003:图片识别失败');
}
}
/**
* 调用火山引擎 OCR 服务识别图片文字
*
* @param string $base64Image base64 编码的图片数据
* @return string 识别的文字内容
*/
private function callVolcEngineOCR($base64Image) {
$method = 'POST';
$service = 'cv';
$host = 'visual.volcengineapi.com';
$region = env('VOLC_REGION', 'cn-north-1');
$access_key = env('VOLC_AK');
$secret_key = env('VOLC_SK');
$action = 'OCRNormal';
$version = '2020-08-26';
if (!$access_key || !$secret_key) {
Utils::throwError('20003:请配置火山引擎 AK/SK');
}
// 请求体
$body = json_encode([
'image_base64' => $base64Image
]);
// 生成签名
$headers = $this->getVolcEngineSignHeaders(
$method, $service, $host, $region,
"Action={$action}&Version={$version}",
$access_key, $secret_key, $body
);
$client = new Client(['timeout' => 60, 'verify' => false]);
$response = $client->post("https://{$host}/?Action={$action}&Version={$version}", [
'headers' => $headers,
'body' => $body
]);
$response_arr = json_decode($response->getBody()->getContents(), true);
// 提取识别的文字
$textLines = [];
if (isset($response_arr['data']['line_texts'])) {
$textLines = $response_arr['data']['line_texts'];
} elseif (isset($response_arr['data']['ocr_infos'])) {
foreach ($response_arr['data']['ocr_infos'] as $info) {
if (isset($info['text'])) {
$textLines[] = $info['text'];
}
}
}
if (empty($textLines)) {
dLog('deepseek')->warning('OCR识别结果为空', ['response' => $response_arr]);
return '';
}
return implode("\n", $textLines);
}
/**
* 生成火山引擎 API 签名头(用于 OCR 等 POST 请求)
*/
private function getVolcEngineSignHeaders($method, $service, $host, $region, $queryString, $access_key, $secret_key, $body = '') {
$contentType = 'application/json';
$accept = 'application/json';
// 获取当前UTC时间
$t = new DateTime('now', new DateTimeZone('UTC'));
$xDate = $t->format('Ymd\THis\Z');
$dateStamp = $t->format('Ymd');
// 计算 body 的 sha256 哈希
$payloadHash = hash('sha256', $body);
// 1. 拼接规范请求串
$canonicalUri = '/';
$canonicalQueryString = $queryString;
$canonicalHeaders = "content-type:{$contentType}\nhost:{$host}\nx-content-sha256:{$payloadHash}\nx-date:{$xDate}\n";
$signedHeaders = 'content-type;host;x-content-sha256;x-date';
$canonicalRequest = implode("\n", [
$method,
$canonicalUri,
$canonicalQueryString,
$canonicalHeaders,
$signedHeaders,
$payloadHash
]);
// 2. 拼接待签名字符串
$algorithm = 'HMAC-SHA256';
$credentialScope = implode('/', [$dateStamp, $region, $service, 'request']);
$hashedCanonicalRequest = hash('sha256', $canonicalRequest);
$stringToSign = implode("\n", [
$algorithm,
$xDate,
$credentialScope,
$hashedCanonicalRequest
]);
// 3. 计算签名
$signingKey = $this->getSignatureKey($secret_key, $dateStamp, $region, $service);
$signature = hash_hmac('sha256', $stringToSign, $signingKey);
// 4. 添加签名到请求头
$authorizationHeader = sprintf(
'%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
$algorithm,
$access_key,
$credentialScope,
$signedHeaders,
$signature
);
return [
'Accept' => $accept,
'Content-Type' => $contentType,
'Host' => $host,
'X-Date' => $xDate,
'X-Content-Sha256'=> $payloadHash,
'Authorization' => $authorizationHeader
];
}
public function generateScriptWords($cid, $model = 'r1') {
ini_set('max_execution_time', 0);
if (!$cid) Utils::throwError('20003: 请选择章节!');
$content = DB::table('chapters as c')->leftJoin('chapter_contents as cc', 'c.chapter_content_id', '=', 'cc.id')->where('c.id', $cid)->value('cc.content');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$messages = [
[
'role' => 'system',
'content' => '下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男或女):台词{情感}”输出,需要注意以下几点要求:
1.角色名后不要加入任何其他词语,只能加不包括旁白的性别,在男或女中选
2.非对话部分请全部用旁白角色代替
3.情感必须在【开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰鼓励、咆哮、温柔、自然讲述、情感电台、磁性、广告营销、气泡音、新闻播报、娱乐八卦】中选一个,不得使用其他词语'
],
[
'role' => 'user',
'content' => $content
]
];
$post_data = [
'model' => $model, // R1模型: deepseek-reasoner V3模型: deepseek-chat
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 1, // 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。
// 'top_p' => 1, // 作为调节采样温度的替代方案(<=1),模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。
'frequency_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。
'presence_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。
'response_format' => [
'type' => 'text' // 默认值text,回答的结果输出文字(非接口返回值是text,接口返回值还是json字串),还可选:json_object,输出json格式
],
'stream' => false // 是否流式输出,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
$update_data = [];
$content = '';
if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
$content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
$update_data = [
'role' => 'assistant',
'content' => $response_arr['choices'][0]['message']['content'],
'usage' => isset($response_arr['usage']) ? $response_arr['usage'] : []
];
}
return $content;
}
/**
* 智能化解析集数信息
* 支持多种表达方式,如:改成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;
}
}