| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543 |
- <?php
- namespace App\Services\DeepSeek;
- use App\Consts\ErrorConst;
- use App\Facade\Site;
- use App\Libs\Utils;
- use GuzzleHttp\Client;
- use DateTime;
- use DateTimeZone;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Log;
- use Illuminate\Support\Facades\Redis;
- use OSS\Core\OssException;
- use OSS\OssClient;
- use Smalot\PdfParser\Parser as PdfParser;
- use TCPDF;
- use PhpOffice\PhpWord\IOFactory as WordIOFactory;
- class DeepSeekService
- {
- private $url;
- private $api_key;
- private $sys_message;
- private $test_content;
- private $headers;
- public function __construct() {
- $this->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;
- }
- // 与推理模型对话
- 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 = '<style>
- body { font-family: "SimSun", "宋体", sans-serif; font-size: 12px; line-height: 1.6; }
- h1 { font-size: 18px; text-align: center; margin-bottom: 20px; }
- h2 { font-size: 16px; margin-top: 20px; margin-bottom: 10px; border-bottom: 1px solid #ccc; }
- h3 { font-size: 14px; margin-top: 15px; margin-bottom: 8px; }
- .info { margin-bottom: 8px; }
- .episode { margin-bottom: 20px; page-break-inside: avoid; }
- .segment { margin-bottom: 10px; margin-left: 20px; }
- </style>';
-
- // 标题
- $html .= '<h1>' . htmlspecialchars($script['script_name']) . '</h1>';
-
- // 剧本简介
- if (!empty($script['intro'])) {
- $html .= '<h2>剧本简介</h2>';
- $html .= '<p>' . nl2br(htmlspecialchars($script['intro'])) . '</p>';
- }
-
- // 亮点
- if (!empty($script['highlights'])) {
- $html .= '<h2>亮点</h2>';
- $html .= '<p>' . nl2br(htmlspecialchars($script['highlights'])) . '</p>';
- }
-
- // 核心矛盾
- if (!empty($script['core_contradiction'])) {
- $html .= '<h2>核心矛盾</h2>';
- $html .= '<p>' . nl2br(htmlspecialchars($script['core_contradiction'])) . '</p>';
- }
-
- // 艺术风格
- if (!empty($script['art_style'])) {
- $html .= '<h2>艺术风格</h2>';
- $html .= '<p>' . nl2br(htmlspecialchars($script['art_style'])) . '</p>';
- }
-
- // 角色列表
- if (!empty($script['roles']) && is_array($script['roles'])) {
- $html .= '<h2>角色列表</h2>';
- $html .= '<ul>';
- foreach ($script['roles'] as $role) {
- if (is_array($role) || is_object($role)) {
- $html .= '<li>' . htmlspecialchars(getProp($role, 'role', '')) . ':' . htmlspecialchars(getProp($role, 'description', '')) . '</li>';
- } else {
- $html .= '<li>' . htmlspecialchars($role) . '</li>';
- }
- }
- $html .= '</ul>';
- }
-
- // 角色关系
- if (!empty($script['role_relationship'])) {
- $html .= '<h2>角色关系</h2>';
- $html .= '<p>' . nl2br(htmlspecialchars($script['role_relationship'])) . '</p>';
- }
-
- // 场景列表
- if (!empty($script['scenes']) && is_array($script['scenes'])) {
- $html .= '<h2>场景列表</h2>';
- $html .= '<ul>';
- foreach ($script['scenes'] as $scene) {
- if (is_array($scene) || is_object($scene)) {
- $html .= '<li>' . htmlspecialchars(getProp($scene, 'scene', '')) . ':' . htmlspecialchars(getProp($scene, 'description', '')) . '</li>';
- } else {
- $html .= '<li>' . htmlspecialchars($scene) . '</li>';
- }
- }
- $html .= '</ul>';
- }
-
- // 分集内容
- if (!empty($script['episodes']) && is_array($script['episodes'])) {
- $html .= '<h2>分集内容</h2>';
-
- foreach ($script['episodes'] as $episode) {
- $html .= '<div class="episode">';
- $html .= '<h3>第' . htmlspecialchars($episode['episode_number']) . '集';
- if (!empty($episode['title'])) {
- $html .= ':' . htmlspecialchars($episode['title']);
- }
- $html .= '</h3>';
-
- // 处理分段内容
- if (!empty($episode['segments']) && is_array($episode['segments'])) {
- foreach ($episode['segments'] as $segment) {
- if (!empty($segment['segment_content'])) {
- $html .= '<div class="segment">';
- if (!empty($segment['segment_number'])) {
- $html .= '<strong>第' . htmlspecialchars($segment['segment_number']) . '段</strong><br>';
- }
- $html .= nl2br(htmlspecialchars($segment['segment_content']));
- $html .= '</div>';
- }
- }
- }
-
- $html .= '</div>';
- }
- }
-
- 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', "请根据实例完成一个剧本分析,要求: 1.从第1集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.要求一定要给出我需要的分镜集数,不能少7.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。");
- $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:请上传文件或选择书籍或输入内容');
- }
- // 构建消息
- $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)
- ]);
- $fullContent = $this->test_content;
- // $fullReasoningContent = [];
- // $usage = [];
- // $uid = 0;
- // $content = '';
- // $question = '';
- // $anime_id = 10;
- // 处理完整内容并返回最终结果
- $script_arr = [];
- if ($fullContent) {
- $script_arr = handleScriptContent($fullContent);
- }
- // 新建动漫
- $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' => '解析完成',
- 'start_episode_sequence' => 1,
- 'end_episode_sequence' => count($script_arr['episodes']),
- 'episode_num' => count($script_arr['episodes']),
- '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:对话保存失败');
- }
- // 新建动漫剧集
- // 删除历史分集内容
- $boolen1 = DB::table('mp_anime_episodes')->where('anime_id', $anime_id)->delete();
-
- $episodes = [];
- $sequence = 1;
- foreach ($script_arr['episodes'] as $episode) {
- $segment_number = 1;
- foreach($episode['segments'] as $segment) {
- $episodes[] = [
- 'anime_id' => $anime_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_anime_episodes')->insert($episodes);
- if (!$boolen2) {
- dLog('deepseek')->info('分集内容保存失败', $episodes);
- Utils::throwError('20003:分集内容保存失败');
- }
- // 保存对话记录
- $recrods = [
- [
- 'uid' => $uid,
- 'anime_id' => $anime_id,
- 'role' => 'user',
- 'content' => "以下是文档内容:\n\n{$content}
- \n\n用户问题:{$question}",
- '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,
- '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 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) {
- Log::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) {
- Log::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) {
- Log::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)) {
- Log::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;
- }
-
- }
|