AIVideoGenerationService.php 138 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239
  1. <?php
  2. namespace App\Services\AIGeneration;
  3. use App\Libs\Utils;
  4. use App\Models\MpGenerateVideoTask;
  5. use App\Models\AIElement;
  6. use App\Services\VolcEngineService;
  7. use GuzzleHttp\Client;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Redis;
  10. class AIVideoGenerationService
  11. {
  12. private $volcEngineService;
  13. public function __construct(VolcEngineService $volcEngineService)
  14. {
  15. $this->volcEngineService = $volcEngineService;
  16. }
  17. /**
  18. * 异步创建视频生成任务
  19. *
  20. * @param array $params
  21. * @return MpGenerateVideoTask
  22. */
  23. public function createJimengTask(array $params): MpGenerateVideoTask
  24. {
  25. // 生成唯一的任务ID
  26. $taskId = 'vid_gen_' . time() . '_' . uniqid();
  27. // 创建任务记录
  28. $task = MpGenerateVideoTask::create([
  29. 'task_id' => $taskId,
  30. 'prompt' => $params['prompt'] ?? '',
  31. 'first_frame_url' => $params['first_frame_url'] ?? null,
  32. 'tail_frame_url' => $params['tail_frame_url'] ?? null,
  33. 'seed' => $params['seed'] ?? -1,
  34. 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : 121,
  35. 'video_resolution' => $params['video_resolution'] ?? '720P',
  36. 'status' => MpGenerateVideoTask::STATUS_PENDING,
  37. 'api_type' => 'jimeng',
  38. ]);
  39. // 发送异步请求到即梦AI API创建任务
  40. $this->submitTaskToApi($task);
  41. return $task;
  42. }
  43. /**
  44. * 提交任务到即梦AI API
  45. *
  46. * @param MpGenerateVideoTask $task
  47. * @return void
  48. */
  49. private function submitTaskToApi(MpGenerateVideoTask $task): void
  50. {
  51. try {
  52. // 验证环境变量配置
  53. $accessKey = env('VOLC_AI_AK');
  54. $secretKey = env('VOLC_AI_SK');
  55. if (empty($accessKey) || empty($secretKey)) {
  56. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  57. 'error_message' => '火山引擎访问密钥未配置,请检查环境变量AK和SK'
  58. ]);
  59. return;
  60. }
  61. // 根据任务类型选择对应的req_key
  62. if ($task->first_frame_url && $task->tail_frame_url) {
  63. $req_key = $task->video_resolution == '720P' ? 'jimeng_i2v_first_tail_v30' : 'jimeng_i2v_first_tail_v30_1080';
  64. }else if($task->first_frame_url){
  65. $req_key = $task->video_resolution == '720P' ? 'jimeng_i2v_first_v30' : 'jimeng_i2v_first_v30_1080';
  66. }else{
  67. $req_key = $task->video_resolution == '720P' ? 'jimeng_t2v_v30' : 'jimeng_t2v_v30_1080p';
  68. }
  69. // 构建即梦AI 3.0 视频生成API请求参数
  70. $apiParams = [
  71. 'req_key' => $req_key,
  72. 'prompt' => $task->prompt,
  73. 'seed' => (int)$task->seed,
  74. 'frames' => $task->frames,
  75. ];
  76. // 添加图片URL参数
  77. if ($task->first_frame_url) {
  78. $apiParams['image_urls'] = [
  79. $task->first_frame_url
  80. ];
  81. if ($task->tail_frame_url) $apiParams['image_urls'][] = $task->tail_frame_url;
  82. }
  83. dLog('generate')->info('图生视频参数: ', $apiParams);
  84. logDB('generate', 'info', '即梦AI视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]);
  85. // 调用即梦AI 3.0 视频生成API提交任务
  86. $response = $this->volcEngineService->request(
  87. 'POST',
  88. 'visual.volcengineapi.com', // 即梦AI API域名
  89. '/', // 使用根路径
  90. [], // Query参数
  91. json_encode($apiParams),
  92. $accessKey,
  93. $secretKey,
  94. 'cv', // 服务标识
  95. 'cn-north-1', // 区域
  96. 'CVSync2AsyncSubmitTask',
  97. '2022-08-31',
  98. 'application/json'
  99. );
  100. $responseData = json_decode($response['body'], true);
  101. dLog('generate')->info('即梦AI 3.0 视频生成API提交任务响应: ', $responseData);
  102. logDB('generate', 'info', '即梦AI视频生成API响应', ['id' => $task->id, 'response' => $responseData]);
  103. if ($responseData['code'] !== 10000) {
  104. // API返回错误
  105. logDB('generate', 'error', '即梦AI视频生成任务提交失败', ['id' => $task->id, 'error' => $responseData['message'] ?? 'API Error']);
  106. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  107. 'error_message' => $responseData['message'] ?? 'API Error',
  108. 'extra_params' => $responseData['data'] ?? []
  109. ]);
  110. } else {
  111. // 任务提交成功,更新状态为处理中
  112. $updateData = [];
  113. // 存储API返回的任务ID
  114. if (isset($responseData['data']['task_id'])) {
  115. $updateData['task_id'] = $responseData['data']['task_id'];
  116. $updateData['extra_params'] = $responseData['data'] ?? [];
  117. $updateData['extra_params']['req_key'] = $req_key;
  118. $updateData['extra_params']['request_id'] = isset($responseData['request_id']) ? $responseData['request_id'] : '';
  119. }
  120. $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData);
  121. }
  122. } catch (\Exception $e) {
  123. // 记录错误
  124. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  125. 'error_message' => 'API请求失败: ' . $e->getMessage()
  126. ]);
  127. Utils::throwError('1001:'.$e->getMessage());
  128. }
  129. }
  130. /**
  131. * 查询任务状态(即梦AI)
  132. *
  133. * @param MpGenerateVideoTask $task
  134. * @return array
  135. */
  136. public function queryJimengTaskStatus(MpGenerateVideoTask $task): array
  137. {
  138. try {
  139. // 获取API任务ID
  140. $apiTaskId = $task->task_id ?? null;
  141. if (!$apiTaskId) {
  142. return [
  143. 'status' => 'failed',
  144. 'error_message' => 'API任务ID不存在',
  145. 'result_url' => null
  146. ];
  147. }
  148. // 根据任务类型选择对应的req_key
  149. if ($task->first_frame_url && $task->tail_frame_url) {
  150. $req_key = $task->video_resolution == '720P' ? 'jimeng_i2v_first_tail_v30' : 'jimeng_i2v_first_tail_v30_1080';
  151. }else if($task->first_frame_url){
  152. $req_key = $task->video_resolution == '720P' ? 'jimeng_i2v_first_v30' : 'jimeng_i2v_first_v30_1080';
  153. }else{
  154. $req_key = $task->video_resolution == '720P' ? 'jimeng_t2v_v30' : 'jimeng_t2v_v30_1080p';
  155. }
  156. // 构建查询参数
  157. $apiParams = [
  158. 'req_key' => $req_key,
  159. 'task_id' => $apiTaskId,
  160. // 'req_json' => '{"return_url":true}' // 返回URL格式
  161. ];
  162. // 调用即梦AI 3.0 视频生成API查询任务状态
  163. $response = $this->volcEngineService->request(
  164. 'POST',
  165. 'visual.volcengineapi.com',
  166. '/', // 使用根路径
  167. [], // Query参数
  168. json_encode($apiParams),
  169. env('VOLC_AI_AK'),
  170. env('VOLC_AI_SK'),
  171. 'cv',
  172. 'cn-north-1',
  173. 'CVSync2AsyncGetResult',
  174. '2022-08-31',
  175. 'application/json'
  176. );
  177. $responseData = json_decode($response['body'], true);
  178. dLog('generate')->info('即梦AI 3.0 视频生成API查询任务状态响应: ', $responseData);
  179. logDB('generate', 'info', '即梦AI视频任务状态查询', ['id' => $task->id, 'response' => $responseData]);
  180. if ($responseData['code'] !== 10000) {
  181. // API返回错误
  182. logDB('generate', 'error', '即梦AI视频任务状态查询失败', ['id' => $task->id, 'error' => $responseData['message'] ?? 'API Error']);
  183. return [
  184. 'status' => 'failed',
  185. 'error_message' => $responseData['message'] ?? 'API Error',
  186. 'result_url' => null,
  187. 'result_json' => $responseData ?? []
  188. ];
  189. }
  190. // 解析API响应
  191. $result = $responseData['data'] ?? [];
  192. $taskStatus = $result['status'] ?? 'failed';
  193. $returnData = [
  194. 'status' => $taskStatus,
  195. 'error_message' => null,
  196. 'result_url' => null,
  197. 'result_json' => $responseData ?? []
  198. ];
  199. // 如果任务成功,获取结果URL
  200. if ($taskStatus === 'done' && isset($result['video_url'])) {
  201. if (isset($result['video_url'])) {
  202. $video_name = 'ai_generation_' . time() . '_' . uniqid();
  203. $video_ext = getVideoExtFromUrl($result['video_url']);
  204. $video_name = $video_name . $video_ext;
  205. // 将视频另存到tos
  206. $url = uploadStreamByTos('video', file_get_contents($result['video_url']), $video_name);
  207. $returnData['result_url'] = $url;
  208. // 压缩视频
  209. $compressed_video_url = compressVideo($url);
  210. $returnData['compressed_url'] = $compressed_video_url ?: $url;
  211. }
  212. // 计算视频时长
  213. $video_duration = 0;
  214. if (isset($result['duration'])) {
  215. $video_duration = (int)$result['duration'];
  216. } elseif (isset($result['frames']) && isset($result['framespersecond'])) {
  217. $frames = (int)$result['frames'];
  218. $fps = (int)$result['framespersecond'];
  219. if ($fps > 0) {
  220. $video_duration = (int)floor($frames / $fps);
  221. }
  222. }
  223. // 如果没有计算出时长,使用任务表中的设定值
  224. if ($video_duration <= 0 && !empty($task->extra_params)) {
  225. $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true);
  226. if (isset($extraParams['duration'])) {
  227. $video_duration = (int)$extraParams['duration'];
  228. }
  229. }
  230. // 只有当video_duration > 0时才添加到返回数据中
  231. if ($video_duration > 0) {
  232. $returnData['video_duration'] = $video_duration;
  233. $returnData['video_time_point_start'] = 0;
  234. $returnData['video_time_point_end'] = $video_duration;
  235. }
  236. $returnData['status'] = 'success';
  237. } elseif (in_array($taskStatus, ['not_found', 'expired'])) {
  238. $returnData['status'] = 'failed';
  239. $returnData['error_message'] = '任务未找到或已过期';
  240. } elseif ($taskStatus === 'failed') {
  241. $returnData['status'] = 'failed';
  242. $returnData['error_message'] = '任务执行失败';
  243. }
  244. // in_queue 或 generating状态保持原status
  245. return $returnData;
  246. } catch (\Exception $e) {
  247. return [
  248. 'status' => 'failed',
  249. 'error_message' => $e->getMessage(),
  250. 'result_url' => null
  251. ];
  252. }
  253. }
  254. /**
  255. * 更新所有待处理任务的状态
  256. *
  257. * @return void
  258. */
  259. public function updatePendingTasks(): void
  260. {
  261. // 获取所有处理中的任务
  262. $tasks = MpGenerateVideoTask::where('status', MpGenerateVideoTask::STATUS_PROCESSING)
  263. ->get();
  264. foreach ($tasks as $task) {
  265. $api_type = getProp($task, 'api_type');
  266. switch($api_type) {
  267. case 'seedance':
  268. $this->updateSeedanceTask($task);
  269. break;
  270. case 'jimeng':
  271. $this->updateJimengTask($task);
  272. break;
  273. case 'keling':
  274. $this->updateKelingOmniTask($task);
  275. break;
  276. case 'zzengine':
  277. $this->updateUnifiedApiTask($task);
  278. break;
  279. case 'video_enhance':
  280. $this->updateVideoEnhanceTask($task);
  281. break;
  282. default:
  283. break;
  284. }
  285. }
  286. }
  287. // 更新即梦AI视频任务状态
  288. private function updateJimengTask($task) {
  289. $statusInfo = $this->queryJimengTaskStatus($task);
  290. if (!isset($statusInfo['status'])) return;
  291. if ($statusInfo['status'] === 'success') {
  292. logDB('generate', 'info', '即梦AI视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]);
  293. // 同步调整分镜视频状态和结果(兼容全能模式)
  294. $segment_id = $this->getSegmentIdFromTask($task);
  295. if ($segment_id && isset($statusInfo['result_url'])) {
  296. try {
  297. DB::beginTransaction();
  298. // 在事务中更新任务状态
  299. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  300. $now = date('Y-m-d H:i:s');
  301. // 构建更新数据
  302. $segmentUpdateData = [
  303. 'origin_video_url' => $statusInfo['result_url'],
  304. 'video_task_status' => '已完成',
  305. 'current_type' => 2,
  306. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  307. 'updated_at' => $now
  308. ];
  309. // 使用查询任务时已压缩的视频URL,避免重复压缩
  310. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  311. // 只有当video_duration存在且大于0时才更新
  312. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  313. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  314. $segmentUpdateData['video_time_point_start'] = 0;
  315. $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null);
  316. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  317. }
  318. // 更新分镜表(兼容act模式)
  319. $this->updateSegmentTable($task, $segmentUpdateData);
  320. // 添加视频生成对话记录
  321. $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']);
  322. DB::commit();
  323. } catch (\Exception $e) {
  324. DB::rollBack();
  325. dLog('generate')->error('即梦视频任务处理失败', [
  326. 'segment_id' => $segment_id,
  327. 'task_id' => $task->id,
  328. 'error' => $e->getMessage()
  329. ]);
  330. }
  331. } else {
  332. // 没有segment_id的情况下,直接更新任务状态
  333. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  334. }
  335. } elseif ($statusInfo['status'] === 'failed') {
  336. logDB('generate', 'error', '即梦AI视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]);
  337. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, $statusInfo);
  338. // 同步更新分镜表状态为失败(兼容全能模式)
  339. $segment_id = $this->getSegmentIdFromTask($task);
  340. if ($segment_id) {
  341. $this->updateSegmentTable($task, [
  342. 'video_task_status' => '失败',
  343. 'updated_at' => date('Y-m-d H:i:s')
  344. ]);
  345. }
  346. }
  347. // 如果仍然是处理中状态,不做任何操作
  348. // 处理:如果任务处理超过12小时,标记为失败
  349. $processingTime = now()->diffInHours($task->created_at);
  350. if ($processingTime > 12) {
  351. logDB('generate', 'warning', '即梦AI视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]);
  352. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  353. 'error_message' => '任务处理超时(超过12小时)'
  354. ]);
  355. // 同步更新分镜表状态为失败(兼容全能模式)
  356. $segment_id = $this->getSegmentIdFromTask($task);
  357. if ($segment_id) {
  358. $this->updateSegmentTable($task, [
  359. 'video_task_status' => '失败',
  360. 'updated_at' => date('Y-m-d H:i:s')
  361. ]);
  362. }
  363. }
  364. }
  365. public function getTaskList($data)
  366. {
  367. $task_id = getProp($data, 'task_id');
  368. $status = getProp($data, 'status');
  369. $query = MpGenerateVideoTask::select('*');
  370. if ($task_id) {
  371. $query->where('id', $task_id);
  372. }
  373. if ($status) {
  374. $query->where('status', $status);
  375. }
  376. return $query->orderBy('created_at', 'desc')->paginate();
  377. }
  378. /**
  379. * 创建 Seedance 1.5 Pro 视频生成任务
  380. *
  381. * @param array $params
  382. * @return MpGenerateVideoTask
  383. */
  384. public function createSeedanceTask(array $params): MpGenerateVideoTask
  385. {
  386. // 生成唯一的任务ID
  387. $taskId = 'seedance_' . time() . '_' . uniqid();
  388. // 创建任务记录,复用现有表结构
  389. $task = MpGenerateVideoTask::create([
  390. 'task_id' => $taskId,
  391. 'alias_segment_id' => $params['alias_segment_id'] ?? '',
  392. 'alias_act_id' => $params['alias_act_id'] ?? '',
  393. 'prompt' => $params['prompt'] ?? '',
  394. 'first_frame_url' => $params['first_frame_url'] ?? '',
  395. 'tail_frame_url' => $params['tail_frame_url'] ?? '',
  396. 'seed' => $params['seed'] ?? -1,
  397. 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null,
  398. 'video_resolution' => $params['video_resolution'] ?? '720P',
  399. 'status' => MpGenerateVideoTask::STATUS_PENDING,
  400. 'api_type' => 'seedance', // 标识为 Seedance 1.5 Pro API
  401. 'extra_params' => [
  402. 'model' => $params['model'] ?? 'doubao-seedance-1-5-pro-251215',
  403. 'content' => $params['content'],
  404. 'callback_url' => $params['callback_url'] ?? '',
  405. 'return_last_frame' => $params['return_last_frame'] ?? true,
  406. 'service_tier' => $params['service_tier'] ?? 'default',
  407. 'execution_expires_after' => $params['execution_expires_after'] ?? 172800,
  408. 'video_resolution' => $params['video_resolution'] ?? '720P',
  409. 'generate_audio' => $params['generate_audio'] ?? false,
  410. 'draft' => $params['draft'] ?? false,
  411. 'ratio' => $params['ratio'] ?? '9:16',
  412. 'duration' => $params['video_duration'] ?? 5,
  413. 'camera_fixed' => $params['camera_fixed'] ?? false,
  414. 'watermark' => $params['watermark'] ?? false,
  415. ]
  416. ]);
  417. // 提交任务到火山引擎 API
  418. $this->submitSeedanceTaskToApi($task);
  419. return $task;
  420. }
  421. /**
  422. * 提交 Seedance 任务到火山引擎 API
  423. *
  424. * @param MpGenerateVideoTask $task
  425. * @return void
  426. */
  427. private function submitSeedanceTaskToApi(MpGenerateVideoTask $task): void
  428. {
  429. $client = new Client(['verify'=>false,'timeout'=>120]);
  430. try {
  431. // 验证环境变量配置
  432. $apiKey = env('VOLC_AI_API_KEY');
  433. if (empty($apiKey)) {
  434. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  435. 'error_message' => '火山引擎访问密钥未配置,请检查环境变量API Key'
  436. ]);
  437. return;
  438. }
  439. $extraParams = $task->extra_params;
  440. // 构建火山引擎 Seedance API 请求参数
  441. $apiParams = [
  442. 'model' => isset(\App\Consts\BaseConst::MODELS_MAP[$extraParams['model']]) ? \App\Consts\BaseConst::MODELS_MAP[$extraParams['model']] : $extraParams['model'],
  443. 'content' => $extraParams['content'],
  444. ];
  445. // 添加可选参数
  446. if (!empty($extraParams['callback_url'])) {
  447. $apiParams['callback_url'] = $extraParams['callback_url'];
  448. }
  449. if (isset($extraParams['return_last_frame'])) {
  450. $apiParams['return_last_frame'] = $extraParams['return_last_frame'];
  451. }
  452. if (!empty($extraParams['service_tier']) && !in_array($extraParams['model'], ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'])) {
  453. $apiParams['service_tier'] = $extraParams['service_tier'];
  454. }
  455. if (!empty($extraParams['execution_expires_after'])) {
  456. $apiParams['execution_expires_after'] = $extraParams['execution_expires_after'];
  457. }
  458. if (isset($extraParams['generate_audio'])) {
  459. $apiParams['generate_audio'] = $extraParams['generate_audio'];
  460. }
  461. if (isset($extraParams['draft'])) {
  462. $apiParams['draft'] = $extraParams['draft'];
  463. }
  464. if (!empty($extraParams['video_resolution'])) {
  465. $apiParams['resolution'] = strtolower($task->video_resolution);
  466. }
  467. if (!empty($extraParams['ratio'])) {
  468. $apiParams['ratio'] = $extraParams['ratio'];
  469. }
  470. if (!empty($extraParams['duration'])) {
  471. $apiParams['duration'] = (int)$extraParams['duration'];
  472. }
  473. if (isset($task->frames)) {
  474. // $apiParams['frames'] = $task->frames;
  475. }
  476. if ($task->seed !== -1) {
  477. $apiParams['seed'] = (int)$task->seed;
  478. }
  479. if (isset($extraParams['camera_fixed'])) {
  480. $apiParams['camera_fixed'] = $extraParams['camera_fixed'];
  481. }
  482. if (isset($extraParams['watermark'])) {
  483. $apiParams['watermark'] = $extraParams['watermark'];
  484. }
  485. dLog('generate')->info('Seedance 1.5 Pro 视频生成参数: ', $apiParams);
  486. logDB('generate', 'info', 'Seedance视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]);
  487. // 调用火山引擎 Seedance API
  488. $response = $client->post('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', [
  489. 'headers' => [
  490. 'Authorization' => 'Bearer ' . $apiKey,
  491. 'Content-Type' => 'application/json',
  492. ],
  493. 'json' => $apiParams
  494. ]);
  495. $responseData = json_decode($response->getBody(), true);
  496. dLog('generate')->info('Seedance 1.5 Pro API 响应: ', $responseData);
  497. logDB('generate', 'info', 'Seedance视频生成API响应', ['id' => $task->id, 'response' => $responseData]);
  498. if (isset($responseData['error'])) {
  499. // API返回错误
  500. logDB('generate', 'error', 'Seedance视频生成任务提交失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? 'API Error']);
  501. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  502. 'error_message' => $responseData['error']['message'] ?? 'API Error',
  503. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  504. ]);
  505. } elseif (isset($responseData['id'])) {
  506. // 任务提交成功,更新状态为处理中
  507. $updateData = [
  508. 'task_id' => $responseData['id'], // 使用 API 返回的任务 ID
  509. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  510. ];
  511. $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData);
  512. } else {
  513. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  514. 'error_message' => '未知的 API 响应格式',
  515. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  516. ]);
  517. }
  518. } catch (\Exception $e) {
  519. // 记录错误
  520. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  521. 'error_message' => 'API请求失败: ' . $e->getMessage()
  522. ]);
  523. Utils::throwError('1001:'.$e->getMessage());
  524. }
  525. }
  526. // 更新Seedance视频任务状态
  527. private function updateSeedanceTask($task) {
  528. $statusInfo = $this->querySeedanceTaskStatus($task);
  529. if (!isset($statusInfo['status'])) return;
  530. if ($statusInfo['status'] === 'success') {
  531. logDB('generate', 'info', 'Seedance视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]);
  532. // 同步调整分镜视频状态和结果(兼容全能模式)
  533. $segment_id = $this->getSegmentIdFromTask($task);
  534. if ($segment_id && isset($statusInfo['result_url'])) {
  535. try {
  536. DB::beginTransaction();
  537. // 在事务中更新任务状态
  538. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  539. $now = date('Y-m-d H:i:s');
  540. // 构建更新数据
  541. $segmentUpdateData = [
  542. 'origin_video_url' => $statusInfo['result_url'],
  543. 'video_task_status' => '已完成',
  544. 'current_type' => 2,
  545. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  546. 'updated_at' => $now
  547. ];
  548. // 使用查询任务时已压缩的视频URL,避免重复压缩
  549. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  550. // 只有当video_duration存在且大于0时才更新
  551. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  552. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  553. $segmentUpdateData['video_time_point_start'] = 0;
  554. // 获取video_time_point_end(兼容全能模式)
  555. $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null);
  556. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  557. }
  558. // 更新分镜表(兼容act模式)
  559. $this->updateSegmentTable($task, $segmentUpdateData);
  560. // 添加视频生成对话记录
  561. $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']);
  562. DB::commit();
  563. } catch (\Exception $e) {
  564. DB::rollBack();
  565. dLog('generate')->error('Seedance视频任务处理失败', [
  566. 'segment_id' => $segment_id,
  567. 'task_id' => $task->id,
  568. 'error' => $e->getMessage()
  569. ]);
  570. }
  571. } else {
  572. // 没有segment_id的情况下,直接更新任务状态
  573. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  574. }
  575. } elseif ($statusInfo['status'] === 'failed') {
  576. logDB('generate', 'error', 'Seedance视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]);
  577. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  578. 'error_message' => $statusInfo['error_message'],
  579. 'result_json' => $statusInfo['result_json'] ?? []
  580. ]);
  581. // 同步更新分镜表状态为失败(兼容全能模式)
  582. $segment_id = $this->getSegmentIdFromTask($task);
  583. if ($segment_id) {
  584. $this->updateSegmentTable($task, [
  585. 'video_task_status' => '失败',
  586. 'updated_at' => date('Y-m-d H:i:s')
  587. ]);
  588. }
  589. }
  590. // 如果仍然是处理中状态,不做任何操作
  591. // 处理:如果任务处理超过48小时,标记为失败
  592. $processingTime = now()->diffInHours($task->created_at);
  593. if ($processingTime > 48) {
  594. logDB('generate', 'warning', 'Seedance视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]);
  595. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  596. 'error_message' => '任务处理超时(超过48小时)'
  597. ]);
  598. // 同步更新分镜表状态为失败(兼容全能模式)
  599. $segment_id = $this->getSegmentIdFromTask($task);
  600. if ($segment_id) {
  601. $this->updateSegmentTable($task, [
  602. 'video_task_status' => '失败',
  603. 'updated_at' => date('Y-m-d H:i:s')
  604. ]);
  605. }
  606. }
  607. }
  608. /**
  609. * 查询 Seedance 任务状态
  610. *
  611. * @param MpGenerateVideoTask $task
  612. * @return array
  613. */
  614. public function querySeedanceTaskStatus(MpGenerateVideoTask $task): array
  615. {
  616. $client = new Client(['verify'=>false,'timeout'=>120]);
  617. try {
  618. // 如果任务已完成,直接返回结果
  619. if (in_array($task->status, [MpGenerateVideoTask::STATUS_SUCCESS, MpGenerateVideoTask::STATUS_FAILED])) {
  620. return [
  621. 'success' => true,
  622. 'data' => [
  623. 'id' => $task->id,
  624. 'status' => $this->mapTaskStatus($task->status),
  625. 'result_url' => $task->result_url,
  626. 'error_message' => $task->error_message,
  627. 'created_at' => $task->created_at,
  628. 'started_at' => $task->started_at,
  629. 'completed_at' => $task->completed_at
  630. ]
  631. ];
  632. }
  633. // 调用火山引擎 API 查询任务状态
  634. $apiKey = env('VOLC_AI_API_KEY');
  635. if (empty($apiKey)) {
  636. return [
  637. 'status' => MpGenerateVideoTask::STATUS_FAILED,
  638. 'result_json' => [],
  639. 'error_message' => '火山引擎访问密钥未配置'
  640. ];
  641. }
  642. // 构建查询 URL
  643. $url = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/' . $task->task_id;
  644. $response = $client->get($url, [
  645. 'headers' => [
  646. 'Authorization' => 'Bearer ' . $apiKey,
  647. 'Content-Type' => 'application/json',
  648. ]
  649. ]);
  650. $responseData = json_decode($response->getBody(), true);
  651. dLog('generate')->info('Seedance 任务状态查询响应: ', $responseData);
  652. logDB('generate', 'info', 'Seedance任务状态查询', ['id' => $task->id, 'response' => $responseData]);
  653. if (isset($responseData['error'])) {
  654. logDB('generate', 'error', 'Seedance任务状态查询失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? 'API查询失败']);
  655. return [
  656. 'status' => MpGenerateVideoTask::STATUS_FAILED,
  657. 'result_json' => $responseData,
  658. 'error_message' => $responseData['error']['message'] ?? 'API查询失败'
  659. ];
  660. }
  661. // 解析任务状态
  662. $apiStatus = $responseData['status'] ?? 'unknown';
  663. $taskStatus = $this->mapApiStatusToTaskStatus($apiStatus);
  664. $updateData = ['result_json' => $responseData];
  665. if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) {
  666. $updateData['status'] = MpGenerateVideoTask::STATUS_SUCCESS;
  667. // 任务成功,处理结果
  668. if (isset($responseData['content']['video_url'])) {
  669. $video_url = $responseData['content']['video_url'];
  670. // 下载并保存视频
  671. $video_name = 'ai_generation_' . time() . '_' . uniqid();
  672. $video_ext = getVideoExtFromUrl($video_url);
  673. $video_name = $video_name . $video_ext;
  674. $url = uploadStreamByTos('video', file_get_contents($video_url), $video_name);
  675. $updateData['result_url'] = $url;
  676. // 压缩视频
  677. $compressed_video_url = compressVideo($url);
  678. $updateData['compressed_url'] = $compressed_video_url ?: $url;
  679. }
  680. // 计算视频时长
  681. $video_duration = 0;
  682. if (isset($responseData['duration'])) {
  683. // 如果直接返回duration,使用该值
  684. $video_duration = (int)$responseData['duration'];
  685. } elseif (isset($responseData['frames']) && isset($responseData['framespersecond'])) {
  686. // 如果返回frames和framespersecond,计算时长
  687. $frames = (int)$responseData['frames'];
  688. $fps = (int)$responseData['framespersecond'];
  689. if ($fps > 0) {
  690. $video_duration = (int)floor($frames / $fps);
  691. }
  692. }
  693. // 如果没有计算出时长,使用任务表中的设定值
  694. if ($video_duration <= 0 && !empty($task->extra_params)) {
  695. $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true);
  696. if (isset($extraParams['duration'])) {
  697. $video_duration = (int)$extraParams['duration'];
  698. }
  699. }
  700. // 只有当video_duration > 0时才添加到更新数据中
  701. if ($video_duration > 0) {
  702. $updateData['video_duration'] = $video_duration;
  703. $updateData['video_time_point_start'] = 0;
  704. $updateData['video_time_point_end'] = $video_duration;
  705. }
  706. // 处理尾帧图片(如果启用了 return_last_frame)
  707. if (isset($responseData['content']['last_frame_url']) && !empty($responseData['content']['last_frame_url'])) {
  708. $pic_url = $responseData['content']['last_frame_url'];
  709. $pic_name = 'ai_generation_' . time() . '_' . uniqid();
  710. $pic_ext = getImgExtFromUrl($pic_url);
  711. $pic_name = $pic_name . $pic_ext;
  712. if ($pic_ext === '.png') {
  713. $comporessed_data = compressRemoteImageUrlToSize($pic_url);
  714. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  715. }else {
  716. // 将图片另存到tos
  717. $url = uploadStreamByTos('image', file_get_contents($pic_url), $pic_name);
  718. }
  719. $updateData['last_frame_url'] = $url;
  720. }
  721. return $updateData;
  722. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) {
  723. $updateData['status'] = MpGenerateVideoTask::STATUS_FAILED;
  724. $updateData['error_message'] = $responseData['error_message'] ?? '任务执行失败';
  725. return $updateData;
  726. }
  727. // 其他状态保持不变
  728. return [
  729. 'success' => true,
  730. 'data' => [
  731. 'id' => $task->id,
  732. 'status' => $this->mapTaskStatus($task->status),
  733. 'result_url' => $task->result_url,
  734. 'error_message' => $task->error_message,
  735. 'created_at' => $task->created_at,
  736. 'started_at' => $task->started_at,
  737. 'completed_at' => $task->completed_at,
  738. 'api_response' => $responseData
  739. ]
  740. ];
  741. } catch (\Exception $e) {
  742. dLog('generate')->info('Seedance 任务状态查询异常: '.$e->getMessage());
  743. return [
  744. 'success' => false,
  745. 'message' => '查询失败: ' . $e->getMessage()
  746. ];
  747. }
  748. }
  749. /**
  750. * 映射 API 状态到任务状态
  751. *
  752. * @param string $apiStatus
  753. * @return string
  754. */
  755. private function mapApiStatusToTaskStatus(string $apiStatus): string
  756. {
  757. switch ($apiStatus) {
  758. case 'queued':
  759. case 'running':
  760. return MpGenerateVideoTask::STATUS_PROCESSING;
  761. case 'succeeded':
  762. return MpGenerateVideoTask::STATUS_SUCCESS;
  763. case 'failed':
  764. case 'expired':
  765. return MpGenerateVideoTask::STATUS_FAILED;
  766. default:
  767. return MpGenerateVideoTask::STATUS_PROCESSING;
  768. }
  769. }
  770. public function seedanceCallback($data) {
  771. try {
  772. dLog('generate')->info('Seedance 回调数据: ', $data);
  773. logDB('generate', 'info', 'Seedance回调接收', ['task_id' => $data['id'] ?? null, 'status' => $data['status'] ?? null]);
  774. // 从回调数据中获取任务ID
  775. $taskId = $data['id'] ?? null;
  776. if (!$taskId) {
  777. dLog('generate')->error('Seedance 回调缺少任务ID');
  778. logDB('generate', 'error', 'Seedance回调缺少任务ID', ['data' => $data]);
  779. return ['success' => false, 'message' => '缺少任务ID'];
  780. }
  781. // 查找对应的任务记录
  782. $task = MpGenerateVideoTask::where('task_id', $taskId)->first();
  783. if (!$task) {
  784. dLog('generate')->error('Seedance 回调找不到任务: ' . $taskId);
  785. logDB('generate', 'error', 'Seedance回调找不到任务', ['task_id' => $taskId]);
  786. return ['success' => false, 'message' => '任务不存在'];
  787. }
  788. // 解析任务状态
  789. $apiStatus = $data['status'] ?? 'unknown';
  790. $taskStatus = $this->mapApiStatusToTaskStatus($apiStatus);
  791. // 准备更新数据
  792. $updateData = [
  793. 'result_json' => $data,
  794. 'updated_at' => now()
  795. ];
  796. if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) {
  797. $updateData['status'] = MpGenerateVideoTask::STATUS_SUCCESS;
  798. $updateData['completed_at'] = now();
  799. // 处理视频结果
  800. if (isset($data['content']['video_url'])) {
  801. $video_url = $data['content']['video_url'];
  802. try {
  803. // 下载并保存视频
  804. $video_name = 'ai_generation_' . time() . '_' . uniqid();
  805. $video_ext = getVideoExtFromUrl($video_url);
  806. $video_name = $video_name . $video_ext;
  807. $url = uploadStreamByTos('video', file_get_contents($video_url), $video_name);
  808. $updateData['result_url'] = $url;
  809. $compressed_video_url = compressVideo($url);
  810. $updateData['compressed_url'] = $compressed_video_url ?: $url;
  811. dLog('generate')->info('Seedance 视频保存成功: ' . $url);
  812. } catch (\Exception $e) {
  813. dLog('generate')->error('Seedance 视频保存失败: ' . $e->getMessage());
  814. // 如果保存失败,直接使用原始URL
  815. $updateData['result_url'] = $video_url;
  816. }
  817. }
  818. // 处理尾帧图片(如果启用了 return_last_frame)
  819. if (isset($data['content']['last_frame_url']) && !empty($data['content']['last_frame_url'])) {
  820. $pic_url = $data['content']['last_frame_url'];
  821. try {
  822. $pic_name = 'ai_generation_' . time() . '_' . uniqid();
  823. $pic_ext = getImgExtFromUrl($pic_url);
  824. $pic_name = $pic_name . $pic_ext;
  825. if ($pic_ext === '.png') {
  826. $compressed_data = compressRemoteImageUrlToSize($pic_url);
  827. $url = uploadStreamByTos('image', $compressed_data, $pic_name);
  828. } else {
  829. $url = uploadStreamByTos('image', file_get_contents($pic_url), $pic_name);
  830. }
  831. $updateData['last_frame_url'] = $url;
  832. dLog('generate')->info('Seedance 尾帧图片保存成功: ' . $url);
  833. } catch (\Exception $e) {
  834. dLog('generate')->error('Seedance 尾帧图片保存失败: ' . $e->getMessage());
  835. // 如果保存失败,直接使用原始URL
  836. $updateData['last_frame_url'] = $pic_url;
  837. }
  838. }
  839. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) {
  840. $updateData['status'] = MpGenerateVideoTask::STATUS_FAILED;
  841. $updateData['completed_at'] = now();
  842. $updateData['error_message'] = $data['error_message'] ?? $data['failure_reason'] ?? '任务执行失败';
  843. } else {
  844. // 处理中状态
  845. $updateData['status'] = $taskStatus;
  846. if ($taskStatus === MpGenerateVideoTask::STATUS_PROCESSING && !$task->started_at) {
  847. $updateData['started_at'] = now();
  848. }
  849. }
  850. // 更新任务记录
  851. $task->update($updateData);
  852. // 如果任务成功,更新分镜表
  853. if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS && isset($updateData['result_url'])) {
  854. try {
  855. DB::beginTransaction();
  856. $now = date('Y-m-d H:i:s');
  857. // 获取分镜ID(兼容分镜模式和全能模式)
  858. $segment = DB::table('mp_episode_segments')
  859. ->where('video_task_id', $task->id)
  860. ->first();
  861. if ($segment) {
  862. // 兼容两种模式
  863. $recordId = $segment->id;
  864. $segmentId = $segment->segment_id ?: $segment->id;
  865. $anime_id = $segment->anime_id;
  866. $episode_number = $segment->episode_number;
  867. $tail_frame = $segment->tail_frame;
  868. $uid = $segment->uid ?? 0;
  869. // 更新分镜表
  870. $segmentUpdateData = [
  871. 'origin_video_url' => $updateData['result_url'],
  872. 'video_task_status' => '已完成',
  873. 'last_frame_url' => $updateData['last_frame_url'] ?? '',
  874. 'current_type' => 2,
  875. 'updated_at' => $now
  876. ];
  877. $segmentUpdateData['video_url'] = $updateData['compressed_url'] ?? $updateData['result_url'];
  878. // 只有当video_duration存在且大于0时才更新
  879. if (isset($data['content']['video_duration']) && $data['content']['video_duration'] > 0) {
  880. $segmentUpdateData['video_duration'] = $data['content']['video_duration'];
  881. $segmentUpdateData['video_time_point_start'] = 0;
  882. $audio_duration = $this->getAudioDuration($task, $segmentId, $segmentUpdateData['video_duration']);
  883. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  884. }
  885. // 使用主键id更新(兼容两种模式)
  886. $updateResult = DB::table('mp_episode_segments')
  887. ->where('id', $recordId)
  888. ->update($segmentUpdateData);
  889. if (!$updateResult) {
  890. throw new \Exception('更新分镜表失败');
  891. }
  892. // 更新分镜视频成功后新增对话记录
  893. $records = [
  894. [
  895. 'uid' => $uid,
  896. 'anime_id' => $anime_id,
  897. 'sequence' => $episode_number,
  898. 'role' => 'user',
  899. 'content' => '图片转视频',
  900. 'segment_id' => $segmentId,
  901. 'video_url' => '',
  902. 'created_at' => $now,
  903. 'updated_at' => $now
  904. ],
  905. [
  906. 'uid' => $uid,
  907. 'anime_id' => $anime_id,
  908. 'sequence' => $episode_number,
  909. 'role' => 'assistant',
  910. 'content' => $tail_frame,
  911. 'segment_id' => $segmentId,
  912. 'video_url' => $updateData['result_url'],
  913. 'created_at' => $now,
  914. 'updated_at' => $now
  915. ]
  916. ];
  917. DB::table('mp_anime_records')->insert($records);
  918. dLog('generate')->info('Seedance 分镜表更新成功: ' . $segmentId);
  919. }
  920. DB::commit();
  921. } catch (\Exception $e) {
  922. DB::rollBack();
  923. $logData = [
  924. 'task_id' => $task->id,
  925. 'error' => $e->getMessage()
  926. ];
  927. dLog('generate')->error('Seedance 分镜表更新失败', $logData);
  928. logDB('generate', 'error', 'Seedance分镜表更新失败', $logData);
  929. }
  930. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) {
  931. // 任务失败,更新分镜表状态
  932. DB::table('mp_episode_segments')
  933. ->where('video_task_id', $task->id)
  934. ->update([
  935. 'video_task_status' => '失败',
  936. 'updated_at' => date('Y-m-d H:i:s')
  937. ]);
  938. }
  939. dLog('generate')->info('Seedance 回调处理完成: ' . $taskId . ', 状态: ' . $taskStatus);
  940. logDB('generate', 'info', 'Seedance回调处理完成', ['task_id' => $taskId, 'status' => $taskStatus]);
  941. return [
  942. 'success' => true,
  943. 'message' => '回调处理成功',
  944. 'task_id' => $taskId,
  945. 'status' => $taskStatus
  946. ];
  947. } catch (\Exception $e) {
  948. dLog('generate')->error('Seedance 回调处理异常: ' . $e->getMessage());
  949. logDB('generate', 'error', 'Seedance回调处理异常', ['error' => $e->getMessage()]);
  950. return [
  951. 'success' => false,
  952. 'message' => '回调处理失败: ' . $e->getMessage()
  953. ];
  954. }
  955. }
  956. /**
  957. * 更新分镜表的视频任务状态
  958. *
  959. * @param MpGenerateVideoTask $task
  960. * @param string $taskStatus
  961. * @param array $updateData
  962. */
  963. private function updateSegmentVideoStatus(MpGenerateVideoTask $task, string $taskStatus, array $updateData) {
  964. try {
  965. // 查找关联的分镜记录
  966. $segment = DB::table('mp_episode_segments')
  967. ->where('video_task_id', $task->id)
  968. ->first();
  969. if (!$segment) {
  970. dLog('generate')->info('未找到关联的分镜记录: ' . $task->id);
  971. return;
  972. }
  973. $segmentUpdateData = ['updated_at' => date('Y-m-d H:i:s')];
  974. if (isset($updateData['last_frame_url']) && $updateData['last_frame_url']) {
  975. $segmentUpdateData['last_frame_url'] = $updateData['last_frame_url'];
  976. }
  977. if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) {
  978. $segmentUpdateData['video_task_status'] = '已完成';
  979. if (isset($updateData['result_url'])) {
  980. $segmentUpdateData['origin_video_url'] = $updateData['result_url'];
  981. // 如果有压缩视频URL,使用压缩后的
  982. if (isset($updateData['compressed_url'])) {
  983. $segmentUpdateData['video_url'] = $updateData['compressed_url'];
  984. } else {
  985. $segmentUpdateData['video_url'] = $updateData['result_url'];
  986. }
  987. }
  988. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) {
  989. $segmentUpdateData['video_task_status'] = '失败';
  990. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_PROCESSING) {
  991. $segmentUpdateData['video_task_status'] = '生成中';
  992. }
  993. // 更新分镜表
  994. DB::table('mp_episode_segments')
  995. ->where('video_task_id', $task->id)
  996. ->update($segmentUpdateData);
  997. dLog('generate')->info('分镜视频状态更新成功: ' . $segment->segment_id . ', 状态: ' . ($segmentUpdateData['video_task_status'] ?? '未知'));
  998. } catch (\Exception $e) {
  999. dLog('generate')->error('更新分镜视频状态失败: ' . $e->getMessage());
  1000. }
  1001. }
  1002. /**
  1003. * 生成可灵AI的JWT Token
  1004. *
  1005. * @return string
  1006. */
  1007. private function generateKelingJwtToken(): string
  1008. {
  1009. $ak = env('KELING_AK');
  1010. $sk = env('KELING_SK');
  1011. if (empty($ak) || empty($sk)) {
  1012. throw new \Exception('可灵AI访问密钥未配置,请检查环境变量KELING_AK和KELING_SK');
  1013. }
  1014. // JWT头部
  1015. $header = [
  1016. 'alg' => 'HS256',
  1017. 'typ' => 'JWT'
  1018. ];
  1019. // JWT载荷
  1020. $payload = [
  1021. 'iss' => $ak, // 签发者,使用Access Key
  1022. 'exp' => time() + 1800, // 过期时间,30分钟后过期
  1023. 'nbf' => time() - 5 // 生效时间,5秒前开始生效
  1024. ];
  1025. // Base64URL编码
  1026. $headerEncoded = $this->base64UrlEncode(json_encode($header));
  1027. $payloadEncoded = $this->base64UrlEncode(json_encode($payload));
  1028. // 创建签名
  1029. $signature = hash_hmac('sha256', $headerEncoded . '.' . $payloadEncoded, $sk, true);
  1030. $signatureEncoded = $this->base64UrlEncode($signature);
  1031. // 返回完整的JWT token
  1032. return $headerEncoded . '.' . $payloadEncoded . '.' . $signatureEncoded;
  1033. }
  1034. /**
  1035. * Base64URL编码(JWT标准编码)
  1036. *
  1037. * @param string $data
  1038. * @return string
  1039. */
  1040. private function base64UrlEncode(string $data): string
  1041. {
  1042. return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
  1043. }
  1044. /**
  1045. * 创建可灵AI Omni视频生成任务
  1046. *
  1047. * @param array $params
  1048. * @return MpGenerateVideoTask
  1049. */
  1050. public function createKelingOmniTask(array $params): MpGenerateVideoTask
  1051. {
  1052. // 生成唯一的任务ID
  1053. $taskId = 'keling_omni_' . time() . '_' . uniqid();
  1054. // 创建任务记录,复用现有表结构
  1055. $task = MpGenerateVideoTask::create([
  1056. 'task_id' => $taskId,
  1057. 'prompt' => $params['prompt'] ?? '',
  1058. 'first_frame_url' => $params['first_frame_url'] ?? '',
  1059. 'tail_frame_url' => $params['tail_frame_url'] ?? '',
  1060. 'seed' => $params['seed'] ?? -1,
  1061. 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null,
  1062. 'video_resolution' => $params['video_resolution'] ?? '720P',
  1063. 'status' => MpGenerateVideoTask::STATUS_PENDING,
  1064. 'api_type' => 'keling', // 标识为可灵AI Omni API
  1065. 'extra_params' => [
  1066. 'api_type' => 'keling',
  1067. 'model' => $params['model'] ?? 'kling-video-o1',
  1068. 'prompt' => $params['prompt'] ?? '',
  1069. 'multi_prompt' => $params['multi_prompt'] ?? [],
  1070. 'shot_type' => $params['shot_type'] ?? 'customize',
  1071. 'image_list' => $params['image_list'] ?? [],
  1072. 'element_list' => $params['element_list'] ?? [],
  1073. 'sound' => $params['sound'] ?? 'off',
  1074. // 'aspect_ratio' => $params['aspect_ratio'] ?? '16:9',
  1075. 'duration' => $params['video_duration'] ?? 5,
  1076. 'mode' => $params['mode'] ?? 'std',
  1077. 'callback_url' => $params['callback_url'] ?? '',
  1078. ]
  1079. ]);
  1080. // 提交任务到可灵AI Omni API
  1081. $this->submitKelingOmniTaskToApi($task);
  1082. return $task;
  1083. }
  1084. /**
  1085. * 提交可灵AI Omni任务到API
  1086. *
  1087. * @param MpGenerateVideoTask $task
  1088. * @return void
  1089. */
  1090. private function submitKelingOmniTaskToApi(MpGenerateVideoTask $task): void
  1091. {
  1092. $client = new Client(['verify'=>false,'timeout'=>120]);
  1093. try {
  1094. // 生成JWT token
  1095. $jwtToken = $this->generateKelingJwtToken();
  1096. $extraParams = $task->extra_params;
  1097. // 构建可灵AI Omni API请求参数
  1098. $apiParams = [
  1099. 'model_name' => $extraParams['model'],
  1100. 'prompt' => $extraParams['prompt'],
  1101. 'mode' => $extraParams['mode'],
  1102. // 'aspect_ratio' => $extraParams['aspect_ratio'],
  1103. 'duration' => (int)$extraParams['duration'],
  1104. ];
  1105. // 添加可选参数
  1106. if (!empty($extraParams['multi_prompt'])) {
  1107. $apiParams['multi_prompt'] = $extraParams['multi_prompt'];
  1108. }
  1109. if (!empty($extraParams['shot_type'])) {
  1110. $apiParams['shot_type'] = $extraParams['shot_type'];
  1111. }
  1112. if (!empty($extraParams['image_list'])) {
  1113. $apiParams['image_list'] = $extraParams['image_list'];
  1114. }
  1115. if (!empty($extraParams['element_list'])) {
  1116. $apiParams['element_list'] = json_decode($extraParams['element_list'], true);
  1117. }
  1118. if (isset($extraParams['sound'])) {
  1119. $apiParams['sound'] = $extraParams['sound'];
  1120. }
  1121. // if (!empty($extraParams['callback_url'])) {
  1122. // $apiParams['callback_url'] = $extraParams['callback_url'];
  1123. // }
  1124. dLog('generate')->info('可灵AI Omni视频生成参数: ', $apiParams);
  1125. logDB('generate', 'info', '可灵AI Omni视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]);
  1126. // 调用可灵AI Omni API
  1127. $response = $client->post('https://api-beijing.klingai.com/v1/videos/omni-video', [
  1128. 'headers' => [
  1129. 'Authorization' => 'Bearer ' . $jwtToken,
  1130. 'Content-Type' => 'application/json',
  1131. ],
  1132. 'json' => $apiParams
  1133. ]);
  1134. $responseData = json_decode($response->getBody(), true);
  1135. dLog('generate')->info('可灵AI Omni API响应: ', $responseData);
  1136. logDB('generate', 'info', '可灵AI Omni API响应', ['id' => $task->id, 'response' => $responseData]);
  1137. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1138. // API返回错误
  1139. $errorMessage = $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error';
  1140. logDB('generate', 'error', '可灵AI Omni任务提交失败', ['id' => $task->id, 'error' => $errorMessage]);
  1141. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1142. 'error_message' => $errorMessage,
  1143. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  1144. ]);
  1145. } elseif (isset($responseData['data']['task_id'])) {
  1146. // 任务提交成功,更新状态为处理中
  1147. $updateData = [
  1148. 'task_id' => $responseData['data']['task_id'], // 使用API返回的任务ID
  1149. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  1150. ];
  1151. $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData);
  1152. } else {
  1153. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1154. 'error_message' => '未知的API响应格式',
  1155. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  1156. ]);
  1157. }
  1158. } catch (\Exception $e) {
  1159. // 记录错误
  1160. logDB('generate', 'error', '可灵AI Omni任务异常', ['id' => $task->id, 'error' => $e->getMessage()]);
  1161. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1162. 'error_message' => 'API请求失败: ' . $e->getMessage()
  1163. ]);
  1164. dLog('generate')->error('可灵AI Omni API请求异常: ' . $e->getMessage());
  1165. }
  1166. }
  1167. /**
  1168. * 查询可灵AI Omni任务状态
  1169. *
  1170. * @param MpGenerateVideoTask $task
  1171. * @return array
  1172. */
  1173. public function queryKelingOmniTaskStatus(MpGenerateVideoTask $task): array
  1174. {
  1175. $client = new Client(['verify'=>false,'timeout'=>120]);
  1176. try {
  1177. // 如果任务已完成,直接返回结果
  1178. if (in_array($task->status, [MpGenerateVideoTask::STATUS_SUCCESS, MpGenerateVideoTask::STATUS_FAILED])) {
  1179. return [
  1180. 'success' => true,
  1181. 'data' => [
  1182. 'task_id' => $task->task_id,
  1183. 'task_status' => $this->mapTaskStatusToKelingStatus($task->status),
  1184. 'result_url' => $task->result_url,
  1185. 'error_message' => $task->error_message,
  1186. 'created_at' => $task->created_at,
  1187. 'updated_at' => $task->updated_at
  1188. ]
  1189. ];
  1190. }
  1191. // 生成JWT token
  1192. $jwtToken = $this->generateKelingJwtToken();
  1193. // 构建查询URL
  1194. $url = 'https://api-beijing.klingai.com/v1/videos/omni-video/' . $task->task_id;
  1195. $response = $client->get($url, [
  1196. 'headers' => [
  1197. 'Authorization' => 'Bearer ' . $jwtToken,
  1198. 'Content-Type' => 'application/json',
  1199. ]
  1200. ]);
  1201. $responseData = json_decode($response->getBody(), true);
  1202. dLog('generate')->info('可灵AI Omni任务状态查询响应: ', $responseData);
  1203. logDB('generate', 'info', '可灵AI Omni任务状态查询', ['id' => $task->id, 'response' => $responseData]);
  1204. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1205. logDB('generate', 'error', '可灵AI Omni任务状态查询失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败']);
  1206. return [
  1207. 'status' => 'failed',
  1208. 'error_message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败',
  1209. 'result_json' => $responseData
  1210. ];
  1211. }
  1212. // 解析任务状态
  1213. $taskData = $responseData['data'] ?? [];
  1214. $taskStatus = $taskData['task_status'] ?? 'unknown';
  1215. $updateData = ['result_json' => $responseData];
  1216. if ($taskStatus === 'succeed') {
  1217. $updateData['status'] = 'success';
  1218. // 任务成功,处理结果
  1219. if (isset($taskData['task_result']['videos']) && !empty($taskData['task_result']['videos'])) {
  1220. $video = $taskData['task_result']['videos'][0];
  1221. $video_url = $video['url'];
  1222. // 下载并保存视频
  1223. $video_name = 'ai_generation_' . time() . '_' . uniqid();
  1224. $video_ext = getVideoExtFromUrl($video_url);
  1225. $video_name = $video_name . $video_ext;
  1226. $url = uploadStreamByTos('video', file_get_contents($video_url), $video_name);
  1227. $updateData['result_url'] = $url;
  1228. // 压缩视频
  1229. $compressed_video_url = compressVideo($url);
  1230. $updateData['compressed_url'] = $compressed_video_url ?: $url;
  1231. // 计算视频时长
  1232. $video_duration = 0;
  1233. if (isset($video['duration'])) {
  1234. $video_duration = (int)$video['duration'];
  1235. } elseif (isset($taskData['duration'])) {
  1236. $video_duration = (int)$taskData['duration'];
  1237. } elseif (isset($video['frames']) && isset($video['framespersecond'])) {
  1238. $frames = (int)$video['frames'];
  1239. $fps = (int)$video['framespersecond'];
  1240. if ($fps > 0) {
  1241. $video_duration = (int)floor($frames / $fps);
  1242. }
  1243. }
  1244. // 如果没有计算出时长,使用任务表中的设定值
  1245. if ($video_duration <= 0 && !empty($task->extra_params)) {
  1246. $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true);
  1247. if (isset($extraParams['duration'])) {
  1248. $video_duration = (int)$extraParams['duration'];
  1249. }
  1250. }
  1251. // 只有当video_duration > 0时才添加到更新数据中
  1252. if ($video_duration > 0) {
  1253. $updateData['video_duration'] = $video_duration;
  1254. $updateData['video_time_point_start'] = 0;
  1255. $updateData['video_time_point_end'] = $video_duration;
  1256. }
  1257. }
  1258. return $updateData;
  1259. } elseif ($taskStatus === 'failed') {
  1260. $updateData = [
  1261. 'status' => 'failed',
  1262. 'error_message' => '任务执行失败',
  1263. 'result_json' => $responseData
  1264. ];
  1265. return $updateData;
  1266. }
  1267. // 其他状态(processing等)保持不变
  1268. return [
  1269. 'success' => true,
  1270. 'data' => [
  1271. 'task_id' => $task->task_id,
  1272. 'task_status' => $taskStatus,
  1273. 'result_url' => $task->result_url,
  1274. 'error_message' => $task->error_message,
  1275. 'created_at' => $task->created_at,
  1276. 'updated_at' => $task->updated_at,
  1277. 'api_response' => $responseData
  1278. ]
  1279. ];
  1280. } catch (\Exception $e) {
  1281. dLog('generate')->error('可灵AI Omni任务状态查询异常: ' . $e->getMessage());
  1282. return [
  1283. 'success' => false,
  1284. 'message' => '查询失败: ' . $e->getMessage()
  1285. ];
  1286. }
  1287. }
  1288. /**
  1289. * 映射任务状态到可灵AI状态格式
  1290. *
  1291. * @param string $taskStatus
  1292. * @return string
  1293. */
  1294. private function mapTaskStatusToKelingStatus(string $taskStatus): string
  1295. {
  1296. switch ($taskStatus) {
  1297. case MpGenerateVideoTask::STATUS_PENDING:
  1298. return 'submitted';
  1299. case MpGenerateVideoTask::STATUS_PROCESSING:
  1300. return 'processing';
  1301. case MpGenerateVideoTask::STATUS_SUCCESS:
  1302. return 'succeed';
  1303. case MpGenerateVideoTask::STATUS_FAILED:
  1304. return 'failed';
  1305. default:
  1306. return 'unknown';
  1307. }
  1308. }
  1309. /**
  1310. * 更新可灵AI Omni视频任务状态
  1311. *
  1312. * @param MpGenerateVideoTask $task
  1313. * @return void
  1314. */
  1315. private function updateKelingOmniTask($task): void
  1316. {
  1317. $statusInfo = $this->queryKelingOmniTaskStatus($task);
  1318. if (!isset($statusInfo['status'])) return;
  1319. if ($statusInfo['status'] === 'success') {
  1320. logDB('generate', 'info', '可灵AI Omni视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]);
  1321. // 同步调整分镜视频状态和结果(兼容全能模式)
  1322. $segment_id = $this->getSegmentIdFromTask($task);
  1323. if ($segment_id && isset($statusInfo['result_url'])) {
  1324. try {
  1325. DB::beginTransaction();
  1326. // 在事务中更新任务状态
  1327. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  1328. $now = date('Y-m-d H:i:s');
  1329. // 构建更新数据
  1330. $segmentUpdateData = [
  1331. 'origin_video_url' => $statusInfo['result_url'],
  1332. 'video_task_status' => '已完成',
  1333. 'current_type' => 2,
  1334. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  1335. 'updated_at' => $now
  1336. ];
  1337. // 使用查询任务时已压缩的视频URL,避免重复压缩
  1338. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  1339. // 只有当video_duration存在且大于0时才更新
  1340. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  1341. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  1342. $segmentUpdateData['video_time_point_start'] = 0;
  1343. // 获取video_time_point_end(兼容全能模式)
  1344. $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration']);
  1345. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  1346. }
  1347. // 更新分镜表(兼容act模式)
  1348. $this->updateSegmentTable($task, $segmentUpdateData);
  1349. // 添加视频生成对话记录
  1350. $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']);
  1351. DB::commit();
  1352. } catch (\Exception $e) {
  1353. DB::rollBack();
  1354. dLog('generate')->error('可灵视频任务处理失败', [
  1355. 'segment_id' => $segment_id,
  1356. 'task_id' => $task->id,
  1357. 'error' => $e->getMessage()
  1358. ]);
  1359. }
  1360. } else {
  1361. // 没有segment_id的情况下,直接更新任务状态
  1362. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  1363. }
  1364. } elseif ($statusInfo['status'] === 'failed') {
  1365. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1366. 'error_message' => $statusInfo['error_message'],
  1367. 'result_json' => $statusInfo['result_json'] ?? []
  1368. ]);
  1369. // 同步更新分镜表状态为失败(兼容全能模式)
  1370. $segment_id = $this->getSegmentIdFromTask($task);
  1371. if ($segment_id) {
  1372. $this->updateSegmentTable($task, [
  1373. 'video_task_status' => '失败',
  1374. 'updated_at' => date('Y-m-d H:i:s')
  1375. ]);
  1376. }
  1377. }
  1378. // 如果仍然是处理中状态,不做任何操作
  1379. // 处理:如果任务处理超过24小时,标记为失败
  1380. $processingTime = now()->diffInHours($task->created_at);
  1381. if ($processingTime > 24) {
  1382. logDB('generate', 'warning', '可灵AI Omni视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]);
  1383. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1384. 'error_message' => '任务处理超时(超过24小时)'
  1385. ]);
  1386. // 同步更新分镜表状态为失败(兼容全能模式)
  1387. $segment_id = $this->getSegmentIdFromTask($task);
  1388. if ($segment_id) {
  1389. $this->updateSegmentTable($task, [
  1390. 'video_task_status' => '失败',
  1391. 'updated_at' => date('Y-m-d H:i:s')
  1392. ]);
  1393. }
  1394. }
  1395. }
  1396. /**
  1397. * 映射任务状态到前端显示状态
  1398. *
  1399. * @param string $taskStatus
  1400. * @return string
  1401. */
  1402. private function mapTaskStatus(string $taskStatus): string
  1403. {
  1404. switch ($taskStatus) {
  1405. case MpGenerateVideoTask::STATUS_PENDING:
  1406. return 'queued';
  1407. case MpGenerateVideoTask::STATUS_PROCESSING:
  1408. return 'running';
  1409. case MpGenerateVideoTask::STATUS_SUCCESS:
  1410. return 'succeeded';
  1411. case MpGenerateVideoTask::STATUS_FAILED:
  1412. return 'failed';
  1413. default:
  1414. return 'unknown';
  1415. }
  1416. }
  1417. // ==================== 主体管理相关方法 ====================
  1418. /**
  1419. * 创建主体
  1420. *
  1421. * @param array $params
  1422. * @return AIElement
  1423. */
  1424. public function createElement(array $params): AIElement
  1425. {
  1426. // 生成唯一的任务ID
  1427. $taskId = 'element_' . time() . '_' . uniqid();
  1428. // 创建主体记录
  1429. $element = AIElement::create([
  1430. 'task_id' => $taskId,
  1431. 'external_task_id' => $params['external_task_id'] ?? null,
  1432. 'element_name' => $params['element_name'],
  1433. 'element_description' => $params['element_description'],
  1434. 'reference_type' => $params['reference_type'],
  1435. 'element_image_list' => $params['element_image_list'] ?? null,
  1436. 'element_video_list' => $params['element_video_list'] ?? null,
  1437. 'element_voice_id' => $params['element_voice_id'] ?? null,
  1438. 'tag_list' => $params['tag_list'] ?? null,
  1439. 'callback_url' => $params['callback_url'] ?? null,
  1440. 'task_status' => AIElement::STATUS_SUBMITTED,
  1441. ]);
  1442. // 提交到可灵AI API
  1443. $this->submitElementToKelingApi($element);
  1444. return $element;
  1445. }
  1446. /**
  1447. * 提交主体创建任务到可灵AI API
  1448. *
  1449. * @param AIElement $element
  1450. * @return void
  1451. */
  1452. private function submitElementToKelingApi(AIElement $element): void
  1453. {
  1454. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1455. try {
  1456. // 生成JWT token
  1457. $jwtToken = $this->generateKelingJwtToken();
  1458. // 构建API请求参数
  1459. $apiParams = [
  1460. 'element_name' => $element->element_name,
  1461. 'element_description' => $element->element_description,
  1462. 'reference_type' => $element->reference_type,
  1463. ];
  1464. // 添加图片参考
  1465. if ($element->reference_type === AIElement::REFERENCE_TYPE_IMAGE && $element->element_image_list) {
  1466. $apiParams['element_image_list'] = $element->element_image_list;
  1467. }
  1468. // 添加视频参考
  1469. if ($element->reference_type === AIElement::REFERENCE_TYPE_VIDEO && $element->element_video_list) {
  1470. $apiParams['element_video_list'] = $element->element_video_list;
  1471. }
  1472. // 添加音色ID
  1473. if ($element->element_voice_id) {
  1474. $apiParams['element_voice_id'] = $element->element_voice_id;
  1475. }
  1476. // 添加标签
  1477. if ($element->tag_list) {
  1478. $apiParams['tag_list'] = $element->tag_list;
  1479. }
  1480. // 添加回调URL
  1481. if ($element->callback_url) {
  1482. $apiParams['callback_url'] = $element->callback_url;
  1483. }
  1484. // 添加外部任务ID
  1485. if ($element->external_task_id) {
  1486. $apiParams['external_task_id'] = $element->external_task_id;
  1487. }
  1488. dLog('generate')->info('可灵AI创建主体参数: ', $apiParams);
  1489. // 调用可灵AI创建主体API
  1490. $response = $client->post('https://api-beijing.klingai.com/v1/general/advanced-custom-elements', [
  1491. 'headers' => [
  1492. 'Authorization' => 'Bearer ' . $jwtToken,
  1493. 'Content-Type' => 'application/json',
  1494. ],
  1495. 'json' => $apiParams
  1496. ]);
  1497. $responseData = json_decode($response->getBody(), true);
  1498. dLog('generate')->info('可灵AI创建主体API响应: ', $responseData);
  1499. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1500. // API返回错误
  1501. $errorMessage = $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error';
  1502. $element->update([
  1503. 'task_status' => AIElement::STATUS_FAILED,
  1504. 'task_status_msg' => $errorMessage,
  1505. ]);
  1506. } elseif (isset($responseData['data']['task_id'])) {
  1507. // 任务提交成功,更新状态为处理中
  1508. $element->update([
  1509. 'task_id' => $responseData['data']['task_id'], // 使用API返回的任务ID
  1510. 'task_status' => AIElement::STATUS_PROCESSING,
  1511. 'created_at' => isset($responseData['data']['created_at']) ?
  1512. date('Y-m-d H:i:s', $responseData['data']['created_at'] / 1000) : now(),
  1513. 'updated_at' => isset($responseData['data']['updated_at']) ?
  1514. date('Y-m-d H:i:s', $responseData['data']['updated_at'] / 1000) : now(),
  1515. ]);
  1516. } else {
  1517. $element->update([
  1518. 'task_status' => AIElement::STATUS_FAILED,
  1519. 'task_status_msg' => '未知的API响应格式',
  1520. ]);
  1521. }
  1522. } catch (\Exception $e) {
  1523. // 记录错误
  1524. $element->update([
  1525. 'task_status' => AIElement::STATUS_FAILED,
  1526. 'task_status_msg' => 'API请求失败: ' . $e->getMessage(),
  1527. ]);
  1528. dLog('generate')->error('可灵AI创建主体API请求异常: ' . $e->getMessage());
  1529. }
  1530. }
  1531. /**
  1532. * 查询单个主体状态
  1533. *
  1534. * @param string $taskId
  1535. * @param string|null $externalTaskId
  1536. * @return array
  1537. */
  1538. public function queryElementStatus(string $taskId, string $externalTaskId = null): array
  1539. {
  1540. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1541. try {
  1542. // 生成JWT token
  1543. $jwtToken = $this->generateKelingJwtToken();
  1544. // 构建查询URL
  1545. $url = 'https://api-beijing.klingai.com/v1/general/advanced-custom-elements/' . $taskId;
  1546. $queryParams = [];
  1547. if ($externalTaskId) {
  1548. $queryParams['external_task_id'] = $externalTaskId;
  1549. }
  1550. if (!empty($queryParams)) {
  1551. $url .= '?' . http_build_query($queryParams);
  1552. }
  1553. $response = $client->get($url, [
  1554. 'headers' => [
  1555. 'Authorization' => 'Bearer ' . $jwtToken,
  1556. 'Content-Type' => 'application/json',
  1557. ]
  1558. ]);
  1559. $responseData = json_decode($response->getBody(), true);
  1560. dLog('generate')->info('可灵AI查询主体状态响应: ', $responseData);
  1561. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1562. return [
  1563. 'success' => false,
  1564. 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败',
  1565. 'data' => null
  1566. ];
  1567. }
  1568. // 更新本地数据库记录
  1569. if (isset($responseData['data'])) {
  1570. $this->updateLocalElementFromApi($responseData['data']);
  1571. }
  1572. return [
  1573. 'success' => true,
  1574. 'data' => $responseData['data']
  1575. ];
  1576. } catch (\Exception $e) {
  1577. dLog('generate')->error('可灵AI查询主体状态异常: ' . $e->getMessage());
  1578. return [
  1579. 'success' => false,
  1580. 'message' => '查询失败: ' . $e->getMessage(),
  1581. 'data' => null
  1582. ];
  1583. }
  1584. }
  1585. /**
  1586. * 根据API响应更新本地主体记录
  1587. *
  1588. * @param array $apiData
  1589. * @return void
  1590. */
  1591. private function updateLocalElementFromApi(array $apiData): void
  1592. {
  1593. $element = AIElement::where('task_id', $apiData['task_id'])->first();
  1594. if (!$element) {
  1595. return;
  1596. }
  1597. $updateData = [
  1598. 'task_status' => $apiData['task_status'],
  1599. 'task_status_msg' => $apiData['task_status_msg'] ?? null,
  1600. 'final_unit_deduction' => $apiData['final_unit_deduction'] ?? null,
  1601. 'updated_at' => isset($apiData['updated_at']) ?
  1602. date('Y-m-d H:i:s', $apiData['updated_at'] / 1000) : now(),
  1603. ];
  1604. // 如果任务成功,更新主体信息
  1605. if ($apiData['task_status'] === AIElement::STATUS_SUCCEED && isset($apiData['task_result']['elements'])) {
  1606. $elementData = $apiData['task_result']['elements'][0];
  1607. $updateData = array_merge($updateData, [
  1608. 'element_id' => $elementData['element_id'],
  1609. 'element_voice_info' => $elementData['element_voice_info'] ?? null,
  1610. 'owned_by' => $elementData['owned_by'] ?? 'custom',
  1611. 'status' => $elementData['status'] ?? AIElement::ELEMENT_STATUS_SUCCEED,
  1612. ]);
  1613. }
  1614. $element->update($updateData);
  1615. }
  1616. /**
  1617. * 查询自定义主体列表
  1618. *
  1619. * @param int $pageNum
  1620. * @param int $pageSize
  1621. * @return array
  1622. */
  1623. public function queryCustomElementsList(int $pageNum = 1, int $pageSize = 30): array
  1624. {
  1625. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1626. try {
  1627. // 生成JWT token
  1628. $jwtToken = $this->generateKelingJwtToken();
  1629. // 构建查询URL
  1630. $url = 'https://api-beijing.klingai.com/v1/general/advanced-custom-elements';
  1631. $queryParams = [
  1632. 'pageNum' => $pageNum,
  1633. 'pageSize' => $pageSize,
  1634. ];
  1635. $url .= '?' . http_build_query($queryParams);
  1636. $response = $client->get($url, [
  1637. 'headers' => [
  1638. 'Authorization' => 'Bearer ' . $jwtToken,
  1639. 'Content-Type' => 'application/json',
  1640. ]
  1641. ]);
  1642. $responseData = json_decode($response->getBody(), true);
  1643. dLog('generate')->info('可灵AI查询自定义主体列表响应: ', $responseData);
  1644. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1645. return [
  1646. 'success' => false,
  1647. 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败',
  1648. 'data' => []
  1649. ];
  1650. }
  1651. // 更新本地数据库记录
  1652. if (isset($responseData['data']) && is_array($responseData['data'])) {
  1653. foreach ($responseData['data'] as $elementData) {
  1654. $this->updateLocalElementFromApi($elementData);
  1655. }
  1656. }
  1657. return [
  1658. 'success' => true,
  1659. 'data' => $responseData['data'] ?? []
  1660. ];
  1661. } catch (\Exception $e) {
  1662. dLog('generate')->error('可灵AI查询自定义主体列表异常: ' . $e->getMessage());
  1663. return [
  1664. 'success' => false,
  1665. 'message' => '查询失败: ' . $e->getMessage(),
  1666. 'data' => []
  1667. ];
  1668. }
  1669. }
  1670. /**
  1671. * 查询官方主体列表
  1672. *
  1673. * @param int $pageNum
  1674. * @param int $pageSize
  1675. * @return array
  1676. */
  1677. public function queryOfficialElementsList(int $pageNum = 1, int $pageSize = 30): array
  1678. {
  1679. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1680. try {
  1681. // 生成JWT token
  1682. $jwtToken = $this->generateKelingJwtToken();
  1683. // 构建查询URL
  1684. $url = 'https://api-beijing.klingai.com/v1/general/advanced-presets-elements';
  1685. $queryParams = [
  1686. 'pageNum' => $pageNum,
  1687. 'pageSize' => $pageSize,
  1688. ];
  1689. $url .= '?' . http_build_query($queryParams);
  1690. $response = $client->get($url, [
  1691. 'headers' => [
  1692. 'Authorization' => 'Bearer ' . $jwtToken,
  1693. 'Content-Type' => 'application/json',
  1694. ]
  1695. ]);
  1696. $responseData = json_decode($response->getBody(), true);
  1697. dLog('generate')->info('可灵AI查询官方主体列表响应: ', $responseData);
  1698. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1699. return [
  1700. 'success' => false,
  1701. 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败',
  1702. 'data' => []
  1703. ];
  1704. }
  1705. return [
  1706. 'success' => true,
  1707. 'data' => $responseData['data'] ?? []
  1708. ];
  1709. } catch (\Exception $e) {
  1710. dLog('generate')->error('可灵AI查询官方主体列表异常: ' . $e->getMessage());
  1711. return [
  1712. 'success' => false,
  1713. 'message' => '查询失败: ' . $e->getMessage(),
  1714. 'data' => []
  1715. ];
  1716. }
  1717. }
  1718. /**
  1719. * 删除自定义主体
  1720. *
  1721. * @param string $elementId
  1722. * @return array
  1723. */
  1724. public function deleteElement(string $elementId): array
  1725. {
  1726. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1727. try {
  1728. // 生成JWT token
  1729. $jwtToken = $this->generateKelingJwtToken();
  1730. // 构建API请求参数
  1731. $apiParams = [
  1732. 'element_id' => $elementId,
  1733. ];
  1734. dLog('generate')->info('可灵AI删除主体参数: ', $apiParams);
  1735. // 调用可灵AI删除主体API
  1736. $response = $client->post('https://api-beijing.klingai.com/v1/general/delete-elements', [
  1737. 'headers' => [
  1738. 'Authorization' => 'Bearer ' . $jwtToken,
  1739. 'Content-Type' => 'application/json',
  1740. ],
  1741. 'json' => $apiParams
  1742. ]);
  1743. $responseData = json_decode($response->getBody(), true);
  1744. dLog('generate')->info('可灵AI删除主体API响应: ', $responseData);
  1745. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1746. // API返回错误
  1747. return [
  1748. 'success' => false,
  1749. 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error'
  1750. ];
  1751. } else {
  1752. // 删除成功,更新本地记录状态
  1753. AIElement::where('element_id', $elementId)->update([
  1754. 'status' => AIElement::ELEMENT_STATUS_DELETED,
  1755. 'updated_at' => now(),
  1756. ]);
  1757. return [
  1758. 'success' => true,
  1759. 'message' => '主体删除成功',
  1760. 'data' => $responseData['data'] ?? []
  1761. ];
  1762. }
  1763. } catch (\Exception $e) {
  1764. dLog('generate')->error('可灵AI删除主体API请求异常: ' . $e->getMessage());
  1765. return [
  1766. 'success' => false,
  1767. 'message' => 'API请求失败: ' . $e->getMessage()
  1768. ];
  1769. }
  1770. }
  1771. /**
  1772. * 获取本地主体列表(包含自定义和官方)
  1773. *
  1774. * @param array $filters
  1775. * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
  1776. */
  1777. public function getLocalElementsList(array $filters = [])
  1778. {
  1779. $query = AIElement::query();
  1780. // 筛选条件
  1781. if (isset($filters['owned_by'])) {
  1782. $query->where('owned_by', $filters['owned_by']);
  1783. }
  1784. if (isset($filters['reference_type'])) {
  1785. $query->where('reference_type', $filters['reference_type']);
  1786. }
  1787. if (isset($filters['status'])) {
  1788. $query->where('status', $filters['status']);
  1789. }
  1790. if (isset($filters['task_status'])) {
  1791. $query->where('task_status', $filters['task_status']);
  1792. }
  1793. // 搜索关键词
  1794. if (isset($filters['keyword']) && !empty($filters['keyword'])) {
  1795. $query->where(function ($q) use ($filters) {
  1796. $q->where('element_name', 'like', '%' . $filters['keyword'] . '%')
  1797. ->orWhere('element_description', 'like', '%' . $filters['keyword'] . '%');
  1798. });
  1799. }
  1800. return $query->orderBy('created_at', 'desc')
  1801. ->paginate($filters['page_size'] ?? 20);
  1802. }
  1803. /**
  1804. * 根据task获取segment_id或act_id(兼容act模式)
  1805. * 分镜模式:返回segment_id用于查询
  1806. * 全能模式:返回act_id用于查询(因为segment_id为空)
  1807. */
  1808. private function getSegmentIdFromTask($task)
  1809. {
  1810. $segment_id = getProp($task, 'alias_segment_id');
  1811. // 如果有segment_id,直接返回(分镜模式)
  1812. if (!empty($segment_id)) {
  1813. return $segment_id;
  1814. }
  1815. // 如果有alias_act_id,返回act_id(全能模式,segment_id为空)
  1816. $act_id = getProp($task, 'alias_act_id');
  1817. if (!empty($act_id)) {
  1818. return $act_id; // 直接返回act_id,不需要查segment_id
  1819. }
  1820. return null;
  1821. }
  1822. /**
  1823. * 更新分镜表(兼容act模式)
  1824. * 分镜模式:使用segment_id字段更新
  1825. * 全能模式:使用id字段更新
  1826. */
  1827. private function updateSegmentTable($task, $updateData)
  1828. {
  1829. $segment_id = getProp($task, 'alias_segment_id');
  1830. $act_id = getProp($task, 'alias_act_id');
  1831. if (!empty($segment_id)) {
  1832. // 分镜模式:使用segment_id更新
  1833. return DB::table('mp_episode_segments')->where('segment_id', $segment_id)->update($updateData);
  1834. } elseif (!empty($act_id)) {
  1835. // 全能模式(全能模式):使用id更新
  1836. return DB::table('mp_episode_segments')->where('id', $act_id)->update($updateData);
  1837. }
  1838. return false;
  1839. }
  1840. /**
  1841. * 获取audio_duration(兼容act模式)
  1842. * 分镜模式:使用segment_id查询
  1843. * 全能模式:使用id查询
  1844. * @param array $task 任务数据
  1845. * @param string|int $segmentIdOrActId segment_id或act_id
  1846. * @param float|null $video_duration 视频时长(如果传入,全能模式直接返回此值)
  1847. * @return float
  1848. */
  1849. private function getAudioDuration($task, $segmentIdOrActId, $video_duration = null)
  1850. {
  1851. $segment_id = getProp($task, 'alias_segment_id');
  1852. $act_id = getProp($task, 'alias_act_id');
  1853. if (!empty($segment_id)) {
  1854. // 分镜模式:使用segment_id查询
  1855. return DB::table('mp_episode_segments')->where('segment_id', $segmentIdOrActId)->value('audio_duration');
  1856. } elseif (!empty($act_id)) {
  1857. // 全能模式:如果传入了video_duration直接返回,否则查询audio_duration
  1858. if ($video_duration !== null && $video_duration > 0) {
  1859. return $video_duration;
  1860. }
  1861. return DB::table('mp_episode_segments')->where('id', $segmentIdOrActId)->value('audio_duration');
  1862. }
  1863. return 0;
  1864. }
  1865. /**
  1866. * 添加视频生成对话记录
  1867. * @param array $task 任务数据
  1868. * @param string|int $segmentIdOrActId segment_id(分镜模式)或 act_id(全能模式)
  1869. * @param string $videoUrl 视频URL
  1870. * @return void
  1871. */
  1872. private function addVideoGenerationRecords($task, $segmentIdOrActId, $videoUrl)
  1873. {
  1874. try {
  1875. $segment_id = getProp($task, 'alias_segment_id');
  1876. $act_id = getProp($task, 'alias_act_id');
  1877. $reference_images = getProp($task, 'ref_image_url');
  1878. if (!$reference_images) $reference_images = json_encode([], 256);
  1879. if (!empty($segment_id)) {
  1880. // 分镜模式:使用segment_id查询
  1881. $segment = DB::table('mp_episode_segments as a')->leftJoin('mp_animes as b', 'a.anime_id', 'b.id')
  1882. ->where('a.segment_id', $segmentIdOrActId)
  1883. ->select('b.user_id as uid', 'a.anime_id', 'a.episode_number', 'a.tail_frame', 'a.segment_id')
  1884. ->first();
  1885. } elseif (!empty($act_id)) {
  1886. // 全能模式:使用id查询
  1887. $segment = DB::table('mp_episode_segments as a')->leftJoin('mp_animes as b', 'a.anime_id', 'b.id')
  1888. ->where('a.id', $segmentIdOrActId)
  1889. ->select('b.user_id as uid', 'a.anime_id', 'a.episode_number', 'a.act_content', 'a.act_show_content', 'a.id as act_id')
  1890. ->first();
  1891. } else {
  1892. dLog('generate')->warning('无法确定查询模式,任务缺少segment_id和act_id', ['segment_id_or_act_id' => $segmentIdOrActId]);
  1893. return;
  1894. }
  1895. if (!$segment) {
  1896. dLog('generate')->warning('分镜信息不存在,无法添加对话记录', ['segment_id_or_act_id' => $segmentIdOrActId]);
  1897. return;
  1898. }
  1899. $now = date('Y-m-d H:i:s');
  1900. // 根据模式确定保存的字段和值
  1901. if (!empty($segment_id)) {
  1902. // 分镜模式:保存segment_id字段
  1903. $recordField = 'segment_id';
  1904. $recordValue = $segmentIdOrActId;
  1905. $userContent = '图片转视频';
  1906. $assistantContent = $segment->tail_frame ?: '';
  1907. } else {
  1908. // 全能模式:保存act_id字段
  1909. $recordField = 'act_id';
  1910. $recordValue = $segmentIdOrActId;
  1911. $userContent = '片段转视频';
  1912. $assistantContent = $segment->act_show_content ? $segment->act_show_content : $segment->act_content;
  1913. if (!$assistantContent) $assistantContent = '';
  1914. }
  1915. // 保存对话记录
  1916. $records = [
  1917. [
  1918. 'uid' => $segment->uid,
  1919. 'anime_id' => $segment->anime_id,
  1920. 'sequence' => $segment->episode_number,
  1921. 'role' => 'user',
  1922. 'content' => $userContent,
  1923. $recordField => $recordValue,
  1924. 'video_url' => '',
  1925. 'reference_images' => json_encode([], 256),
  1926. 'created_at' => $now,
  1927. 'updated_at' => $now
  1928. ],
  1929. [
  1930. 'uid' => $segment->uid,
  1931. 'anime_id' => $segment->anime_id,
  1932. 'sequence' => $segment->episode_number,
  1933. 'role' => 'assistant',
  1934. 'content' => $assistantContent,
  1935. $recordField => $recordValue,
  1936. 'video_url' => $videoUrl,
  1937. 'reference_images' => $reference_images,
  1938. 'created_at' => $now,
  1939. 'updated_at' => $now
  1940. ]
  1941. ];
  1942. DB::table('mp_anime_records')->insert($records);
  1943. dLog('generate')->info('视频生成对话记录添加成功', [
  1944. 'segment_id_or_act_id' => $segmentIdOrActId,
  1945. 'video_url' => $videoUrl,
  1946. 'uid' => $segment->uid,
  1947. 'anime_id' => $segment->anime_id
  1948. ]);
  1949. } catch (\Exception $e) {
  1950. dLog('generate')->error('添加视频生成对话记录失败: ' . $e->getMessage(), [
  1951. 'segment_id' => $segmentIdOrActId,
  1952. 'video_url' => $videoUrl,
  1953. 'error' => $e->getMessage()
  1954. ]);
  1955. }
  1956. }
  1957. /**
  1958. * 创建统一API视频生成任务(智帧20等新模型)
  1959. *
  1960. * @param array $params
  1961. * @return MpGenerateVideoTask
  1962. */
  1963. public function createUnifiedApiTask(array $params): MpGenerateVideoTask
  1964. {
  1965. // 生成唯一的任务ID
  1966. $taskId = 'unified_' . time() . '_' . uniqid();
  1967. // 创建任务记录
  1968. $task = MpGenerateVideoTask::create([
  1969. 'task_id' => $taskId,
  1970. 'alias_segment_id' => $params['alias_segment_id'] ?? '',
  1971. 'alias_act_id' => $params['alias_act_id'] ?? '',
  1972. 'prompt' => $params['prompt'] ?? '',
  1973. 'first_frame_url' => $params['first_frame_url'] ?? '',
  1974. 'tail_frame_url' => $params['last_frame_url'] ?? '',
  1975. 'ref_image_url' => $params['ref_image_url'] ?? json_encode([], 256),
  1976. 'seed' => $params['seed'] ?? -1,
  1977. 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null,
  1978. 'video_resolution' => $params['video_resolution'] ?? '720p',
  1979. 'status' => MpGenerateVideoTask::STATUS_PENDING,
  1980. 'api_type' => 'zzengine',
  1981. 'extra_params' => [
  1982. 'task_type' => 'video',
  1983. 'model_code' => $params['model_code'] ?? 'zhizhen-20',
  1984. 'callback_url' => $params['callback_url'] ?? '',
  1985. 'video_duration' => $params['video_duration'] ?? 5,
  1986. 'video_ratio' => $params['video_ratio'] ?? '9:16',
  1987. 'parameters' => $params['parameters'] ?? [],
  1988. ]
  1989. ]);
  1990. // 提交任务到统一API
  1991. $this->submitUnifiedApiTaskToApi($task);
  1992. return $task;
  1993. }
  1994. /**
  1995. * 提交任务到统一API
  1996. *
  1997. * @param MpGenerateVideoTask $task
  1998. * @return void
  1999. */
  2000. private function submitUnifiedApiTaskToApi(MpGenerateVideoTask $task): void
  2001. {
  2002. $client = new Client(['verify' => false, 'timeout' => 1200]);
  2003. try {
  2004. // 验证环境变量配置
  2005. $apiKey = env('ZHIZHEN_AI_SK');
  2006. $apiUrl = 'https://model-relay.zzengine.net/api/v1/task/submit';
  2007. if (empty($apiKey)) {
  2008. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2009. 'error_message' => '统一视频API密钥未配置,请检查环境变量UNIFIED_VIDEO_API_KEY'
  2010. ]);
  2011. return;
  2012. }
  2013. $extraParams = $task->extra_params;
  2014. // 构建统一API请求参数
  2015. $apiParams = [
  2016. 'task_type' => 'video',
  2017. 'model_code' => $extraParams['model_code'] ?? 'zhizhen-20',
  2018. 'prompt' => $task->prompt,
  2019. 'video_duration' => $extraParams['video_duration'] ?? 5,
  2020. 'video_resolution' => $task->video_resolution,
  2021. 'video_ratio' => $extraParams['video_ratio'] ?? '9:16',
  2022. ];
  2023. // 添加回调地址
  2024. if (!empty($extraParams['callback_url'])) {
  2025. $apiParams['callback_url'] = $extraParams['callback_url'];
  2026. }
  2027. // 添加parameters参数
  2028. if (!empty($extraParams['parameters'])) {
  2029. $apiParams['parameters'] = $extraParams['parameters'];
  2030. }
  2031. dLog('generate')->info('统一API视频生成参数: ', $apiParams);
  2032. logDB('generate', 'info', '统一API视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]);
  2033. // 调用统一API
  2034. $response = $client->post($apiUrl, [
  2035. 'headers' => [
  2036. 'X-Api-Key' => $apiKey,
  2037. 'Content-Type' => 'application/json',
  2038. ],
  2039. 'json' => $apiParams
  2040. ]);
  2041. $responseData = json_decode($response->getBody(), true);
  2042. dLog('generate')->info('统一API响应: ', $responseData);
  2043. logDB('generate', 'info', '统一API视频生成API响应', ['id' => $task->id, 'response' => $responseData]);
  2044. if (isset($responseData['error']) || !isset($responseData['data']['task_uid'])) {
  2045. // API返回错误
  2046. $errorMsg = $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error';
  2047. logDB('generate', 'error', '统一API视频生成任务提交失败', ['id' => $task->id, 'error' => $errorMsg]);
  2048. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2049. 'error_message' => $errorMsg,
  2050. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  2051. ]);
  2052. } elseif (isset($responseData['data']['task_uid'])) {
  2053. // 任务提交成功,更新状态为处理中
  2054. $updateData = [
  2055. 'task_id' => $responseData['data']['task_uid'], // 使用API返回的task_uid
  2056. 'extra_params' => array_merge($task->extra_params, [
  2057. 'task_uid' => $responseData['data']['task_uid'],
  2058. 'estimated_amount' => $responseData['data']['estimated_amount'] ?? 0,
  2059. 'account_uid' => $responseData['data']['account_uid'] ?? '',
  2060. 'key_uid' => $responseData['data']['key_uid'] ?? '',
  2061. 'api_response' => $responseData
  2062. ])
  2063. ];
  2064. $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData);
  2065. } else {
  2066. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2067. 'error_message' => '未知的API响应格式',
  2068. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  2069. ]);
  2070. }
  2071. } catch (\GuzzleHttp\Exception\RequestException $e) {
  2072. $errorMsg = 'API请求失败';
  2073. if ($e->hasResponse()) {
  2074. $errorResponse = json_decode($e->getResponse()->getBody()->getContents(), true);
  2075. $errorMsg .= ': ' . ($errorResponse['message'] ?? $e->getMessage());
  2076. logDB('generate', 'error', '统一API请求失败', ['id' => $task->id, 'error' => $errorResponse]);
  2077. } else {
  2078. $errorMsg .= ': ' . $e->getMessage();
  2079. logDB('generate', 'error', '统一API请求失败', ['id' => $task->id, 'error' => $e->getMessage()]);
  2080. }
  2081. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2082. 'error_message' => $errorMsg
  2083. ]);
  2084. } catch (\Exception $e) {
  2085. logDB('generate', 'error', '统一API任务提交异常', ['id' => $task->id, 'error' => $e->getMessage()]);
  2086. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2087. 'error_message' => 'API请求失败: ' . $e->getMessage()
  2088. ]);
  2089. }
  2090. }
  2091. /**
  2092. * 查询统一API任务状态
  2093. *
  2094. * @param MpGenerateVideoTask $task
  2095. * @return array
  2096. */
  2097. public function queryUnifiedApiTaskStatus(MpGenerateVideoTask $task): array
  2098. {
  2099. $client = new Client(['verify' => false, 'timeout' => 1200]);
  2100. try {
  2101. // 获取API任务UID
  2102. $extraParams = $task->extra_params;
  2103. $taskUid = $extraParams['task_uid'] ?? $task->task_id;
  2104. if (!$taskUid) {
  2105. return [
  2106. 'status' => 'failed',
  2107. 'error_message' => 'API任务UID不存在',
  2108. 'result_url' => null
  2109. ];
  2110. }
  2111. // 验证环境变量配置
  2112. $apiKey = env('ZHIZHEN_AI_SK');
  2113. $apiUrl = 'https://model-relay.zzengine.net';
  2114. $queryUrl = rtrim($apiUrl, '/') . '/api/v1/task/query';
  2115. if (empty($apiKey)) {
  2116. return [
  2117. 'status' => 'failed',
  2118. 'error_message' => '统一视频API密钥未配置',
  2119. 'result_url' => null
  2120. ];
  2121. }
  2122. dLog('generate')->info('查询统一API任务状态: ', ['task_uid' => $taskUid]);
  2123. // 调用统一API查询接口(GET请求)
  2124. $response = $client->get($queryUrl, [
  2125. 'headers' => [
  2126. 'X-Api-Key' => $apiKey,
  2127. ],
  2128. 'query' => [
  2129. 'task_uid' => $taskUid
  2130. ]
  2131. ]);
  2132. $responseData = json_decode($response->getBody(), true);
  2133. dLog('generate')->info('统一API任务状态查询响应: ', $responseData);
  2134. // 检查响应code
  2135. if (!isset($responseData['code']) || $responseData['code'] !== 0) {
  2136. $errorMsg = $responseData['msg'] ?? $responseData['message'] ?? 'API查询失败';
  2137. logDB('generate', 'error', '统一API任务状态查询失败', ['id' => $task->id, 'error' => $errorMsg]);
  2138. return [
  2139. 'status' => 'failed',
  2140. 'error_message' => $errorMsg,
  2141. 'result_url' => null,
  2142. 'result_json' => $responseData ?? []
  2143. ];
  2144. }
  2145. // 解析API响应
  2146. $taskData = $responseData['data']['task'] ?? [];
  2147. if (empty($taskData)) {
  2148. return [
  2149. 'status' => 'failed',
  2150. 'error_message' => '任务数据为空',
  2151. 'result_url' => null,
  2152. 'result_json' => $responseData ?? []
  2153. ];
  2154. }
  2155. // 任务状态映射:0=排队中,1=执行中,2=成功,3=失败
  2156. $taskStatus = $taskData['status'] ?? 3;
  2157. $failReason = $taskData['fail_reason'] ?? '';
  2158. $returnData = [
  2159. 'status' => 'processing',
  2160. 'error_message' => null,
  2161. 'result_url' => null,
  2162. 'result_json' => $responseData ?? []
  2163. ];
  2164. // 根据status判断任务状态
  2165. if ($taskStatus === 2) {
  2166. // 任务成功
  2167. $detail = $taskData['detail'] ?? [];
  2168. $result = $detail['result'] ?? [];
  2169. // 获取视频URL
  2170. $videoUrl = $result['video'] ?? null;
  2171. if (!$videoUrl && isset($result['videos']) && is_array($result['videos']) && count($result['videos']) > 0) {
  2172. $videoUrl = $result['videos'][0];
  2173. }
  2174. if ($videoUrl) {
  2175. // 保存视频到TOS并超分
  2176. try {
  2177. // 下载视频内容
  2178. $videoContent = file_get_contents($videoUrl);
  2179. // 使用upgrade480pTo720p方法进行超分(传入视频内容)
  2180. dLog('generate')->info('开始对视频进行480p->720p超分', [
  2181. 'original_url' => $videoUrl,
  2182. 'size' => round(strlen($videoContent) / 1024 / 1024, 2) . 'MB'
  2183. ]);
  2184. $url = upgrade480pTo720p($videoContent, 'video', true);
  2185. if ($url) {
  2186. // 超分成功
  2187. dLog('generate')->info('视频超分成功', ['enhanced_url' => $url]);
  2188. $returnData['result_url'] = $url;
  2189. // 压缩视频
  2190. $compressed_video_url = compressVideo($url);
  2191. $returnData['compressed_url'] = $compressed_video_url ?: $url;
  2192. } else {
  2193. // 超分失败,使用原视频
  2194. dLog('generate')->warning('视频超分失败,使用原视频');
  2195. $video_name = 'ai_generation_' . time() . '_' . uniqid();
  2196. $video_ext = getVideoExtFromUrl($videoUrl);
  2197. $video_name = $video_name . $video_ext;
  2198. $url = uploadStreamByTos('video', $videoContent, $video_name);
  2199. $returnData['result_url'] = $url;
  2200. // 压缩视频
  2201. $compressed_video_url = compressVideo($url);
  2202. $returnData['compressed_url'] = $compressed_video_url ?: $url;
  2203. }
  2204. } catch (\Exception $e) {
  2205. dLog('generate')->error('保存视频到TOS失败', ['error' => $e->getMessage()]);
  2206. $returnData['result_url'] = $videoUrl; // 如果保存失败,使用原URL
  2207. }
  2208. } else {
  2209. $returnData['status'] = 'failed';
  2210. $returnData['error_message'] = '视频URL不存在';
  2211. return $returnData;
  2212. }
  2213. // 获取视频时长
  2214. $video_duration = $detail['duration'] ?? 0;
  2215. if ($video_duration > 0) {
  2216. $returnData['video_duration'] = (int)$video_duration;
  2217. $returnData['video_time_point_start'] = 0;
  2218. $returnData['video_time_point_end'] = (int)$video_duration;
  2219. }
  2220. // 获取尾帧
  2221. if (isset($result['last_frame_url'])) {
  2222. $returnData['last_frame_url'] = $result['last_frame_url'];
  2223. }
  2224. $returnData['status'] = 'success';
  2225. } elseif ($taskStatus === 3) {
  2226. // 任务失败
  2227. $returnData['status'] = 'failed';
  2228. $returnData['error_message'] = $failReason ?: '任务执行失败';
  2229. } elseif (in_array($taskStatus, [0, 1])) {
  2230. // 0=排队中,1=执行中
  2231. $returnData['status'] = 'processing';
  2232. } else {
  2233. $returnData['status'] = 'failed';
  2234. $returnData['error_message'] = '未知的任务状态: ' . $taskStatus;
  2235. }
  2236. return $returnData;
  2237. } catch (\GuzzleHttp\Exception\RequestException $e) {
  2238. $errorMsg = 'API查询失败';
  2239. if ($e->hasResponse()) {
  2240. $errorResponse = json_decode($e->getResponse()->getBody()->getContents(), true);
  2241. $errorMsg .= ': ' . ($errorResponse['msg'] ?? $errorResponse['message'] ?? $e->getMessage());
  2242. } else {
  2243. $errorMsg .= ': ' . $e->getMessage();
  2244. }
  2245. return [
  2246. 'status' => 'processing',
  2247. 'error_message' => $errorMsg,
  2248. 'result_url' => null
  2249. ];
  2250. } catch (\Exception $e) {
  2251. return [
  2252. 'status' => 'processing',
  2253. 'error_message' => $e->getMessage(),
  2254. 'result_url' => null
  2255. ];
  2256. }
  2257. }
  2258. /**
  2259. * 更新统一API视频任务状态
  2260. *
  2261. * @param MpGenerateVideoTask $task
  2262. * @return void
  2263. */
  2264. private function updateUnifiedApiTask($task): void
  2265. {
  2266. $statusInfo = $this->queryUnifiedApiTaskStatus($task);
  2267. if (!isset($statusInfo['status'])) return;
  2268. if ($statusInfo['status'] === 'success') {
  2269. logDB('generate', 'info', '统一API视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]);
  2270. // // 判断是否为480p视频,如果是则触发超分逻辑
  2271. // if ($task->video_resolution === '480P') {
  2272. // logDB('video_enhance', 'info', '检测到480p视频生成成功,准备自动触发超分', [
  2273. // 'task_id' => $task->id
  2274. // ]);
  2275. // // 先更新任务状态为成功
  2276. // $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2277. // // 调用超分任务提交方法
  2278. // $enhanceResult = $this->submitVideoEnhanceTask($task->id);
  2279. // if ($enhanceResult['success']) {
  2280. // logDB('video_enhance', 'info', '480p视频超分任务已自动提交', [
  2281. // 'original_task_id' => $task->id,
  2282. // 'enhance_task_id' => $enhanceResult['enhance_task_id']
  2283. // ]);
  2284. // } else {
  2285. // logDB('video_enhance', 'warning', '480p视频超分任务提交失败,保持原480p视频', [
  2286. // 'task_id' => $task->id,
  2287. // 'error' => $enhanceResult['message']
  2288. // ]);
  2289. // // 超分失败时,仍然更新分镜表为480p视频
  2290. // $this->update480pVideoToSegment($task, $statusInfo);
  2291. // }
  2292. // // 480p视频已触发超分,直接返回,不继续后续更新操作
  2293. // return;
  2294. // }
  2295. $segment_id = $this->getSegmentIdFromTask($task);
  2296. if ($segment_id && isset($statusInfo['result_url'])) {
  2297. try {
  2298. DB::beginTransaction();
  2299. // 在事务中更新任务状态
  2300. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2301. $now = date('Y-m-d H:i:s');
  2302. // 构建更新数据
  2303. $segmentUpdateData = [
  2304. 'origin_video_url' => $statusInfo['result_url'],
  2305. 'video_task_status' => '已完成',
  2306. 'current_type' => 2,
  2307. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  2308. 'updated_at' => $now
  2309. ];
  2310. // 使用查询任务时已压缩的视频URL,避免重复压缩
  2311. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  2312. // 只有当video_duration存在且大于0时才更新
  2313. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  2314. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  2315. $segmentUpdateData['video_time_point_start'] = 0;
  2316. // 获取audio_duration(兼容全能模式)
  2317. $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null);
  2318. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  2319. }
  2320. // 更新分镜表(兼容act模式)
  2321. $this->updateSegmentTable($task, $segmentUpdateData);
  2322. // 添加视频生成对话记录
  2323. $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']);
  2324. // 如果是全能模式的首帧图,则提取视频第一帧并更新anime表
  2325. $act_id = getProp($task, 'alias_act_id');
  2326. if (!empty($act_id) && Redis::sismember('anime_act_first_frame_urls', $act_id)) {
  2327. $anime_id = DB::table('mp_episode_segments')->where('id', $act_id)->value('anime_id');
  2328. if ($anime_id) {
  2329. // 提取视频第一帧
  2330. $videoUrl = $segmentUpdateData['video_url'];
  2331. $firstFrameUrl = getVideoFirstFrame($videoUrl, 'anime_first_frames', 'tos');
  2332. if ($firstFrameUrl) {
  2333. // 更新anime表
  2334. DB::table('mp_animes')->where('id', $anime_id)->update([
  2335. 'first_frame_url' => $firstFrameUrl,
  2336. 'updated_at' => date('Y-m-d H:i:s')
  2337. ]);
  2338. dLog('video_frame')->info('全能模式首帧图更新成功', [
  2339. 'act_id' => $act_id,
  2340. 'anime_id' => $anime_id,
  2341. 'first_frame_url' => $firstFrameUrl
  2342. ]);
  2343. }
  2344. }
  2345. }
  2346. DB::commit();
  2347. // 从Redis中移除
  2348. Redis::srem('anime_act_first_frame_urls', $act_id);
  2349. } catch (\Exception $e) {
  2350. DB::rollBack();
  2351. dLog('generate')->error('统一API视频任务处理失败', [
  2352. 'segment_id' => $segment_id,
  2353. 'task_id' => $task->id,
  2354. 'error' => $e->getMessage()
  2355. ]);
  2356. }
  2357. } else {
  2358. // 没有segment_id的情况下,直接更新任务状态
  2359. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2360. }
  2361. } elseif ($statusInfo['status'] === 'failed') {
  2362. logDB('generate', 'error', '统一API视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]);
  2363. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2364. 'error_message' => $statusInfo['error_message'],
  2365. 'result_json' => $statusInfo['result_json'] ?? []
  2366. ]);
  2367. // 同步更新分镜表状态为失败(兼容全能模式)
  2368. $segment_id = $this->getSegmentIdFromTask($task);
  2369. if ($segment_id) {
  2370. $this->updateSegmentTable($task, [
  2371. 'video_task_status' => '失败',
  2372. 'updated_at' => date('Y-m-d H:i:s')
  2373. ]);
  2374. }
  2375. }
  2376. // 如果仍然是处理中状态,不做任何操作
  2377. // 处理:如果任务处理超过48小时,标记为失败
  2378. $processingTime = now()->diffInHours($task->created_at);
  2379. if ($processingTime > 48) {
  2380. logDB('generate', 'warning', '统一API视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]);
  2381. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2382. 'error_message' => '任务处理超时(超过48小时)'
  2383. ]);
  2384. // 同步更新分镜表状态为失败(兼容全能模式)
  2385. $segment_id = $this->getSegmentIdFromTask($task);
  2386. if ($segment_id) {
  2387. $this->updateSegmentTable($task, [
  2388. 'video_task_status' => '失败',
  2389. 'updated_at' => date('Y-m-d H:i:s')
  2390. ]);
  2391. }
  2392. }
  2393. }
  2394. /**
  2395. * 更新480p视频到分镜表(超分失败时的降级处理)
  2396. *
  2397. * @param MpGenerateVideoTask $task
  2398. * @param array $statusInfo
  2399. * @return void
  2400. */
  2401. private function update480pVideoToSegment($task, $statusInfo): void
  2402. {
  2403. $segment_id = $this->getSegmentIdFromTask($task);
  2404. if (!$segment_id || !isset($statusInfo['result_url'])) {
  2405. return;
  2406. }
  2407. try {
  2408. DB::beginTransaction();
  2409. $now = date('Y-m-d H:i:s');
  2410. // 构建更新数据
  2411. $segmentUpdateData = [
  2412. 'origin_video_url' => $statusInfo['result_url'],
  2413. 'video_task_status' => '已完成',
  2414. 'current_type' => 2,
  2415. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  2416. 'updated_at' => $now
  2417. ];
  2418. // 使用查询任务时已压缩的视频URL,避免重复压缩
  2419. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  2420. // 只有当video_duration存在且大于0时才更新
  2421. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  2422. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  2423. $segmentUpdateData['video_time_point_start'] = 0;
  2424. // 获取audio_duration(兼容全能模式)
  2425. $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null);
  2426. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  2427. }
  2428. // 更新分镜表(兼容act模式)
  2429. $this->updateSegmentTable($task, $segmentUpdateData);
  2430. // 添加视频生成对话记录
  2431. $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']);
  2432. DB::commit();
  2433. } catch (\Exception $e) {
  2434. DB::rollBack();
  2435. dLog('generate')->error('480p视频更新分镜表失败', [
  2436. 'segment_id' => $segment_id,
  2437. 'task_id' => $task->id,
  2438. 'error' => $e->getMessage()
  2439. ]);
  2440. }
  2441. }
  2442. /**
  2443. * 提交视频超分任务(480p -> 720p)
  2444. *
  2445. * @param int $taskId mp_generate_video_tasks表的任务ID
  2446. * @return array 包含成功状态和新任务ID或错误信息
  2447. */
  2448. public function submitVideoEnhanceTask(int $taskId): array
  2449. {
  2450. try {
  2451. // 查询原任务
  2452. $originalTask = MpGenerateVideoTask::find($taskId);
  2453. if (!$originalTask) {
  2454. return [
  2455. 'success' => false,
  2456. 'message' => '原任务不存在'
  2457. ];
  2458. }
  2459. // 检查任务是否已完成
  2460. if ($originalTask->status !== MpGenerateVideoTask::STATUS_SUCCESS) {
  2461. return [
  2462. 'success' => false,
  2463. 'message' => '只能对已成功完成的任务进行超分处理'
  2464. ];
  2465. }
  2466. // 检查分辨率是否为480p
  2467. if ($originalTask->video_resolution !== '480P') {
  2468. return [
  2469. 'success' => false,
  2470. 'message' => '只支持480P视频超分到720P'
  2471. ];
  2472. }
  2473. // 检查是否有视频URL
  2474. if (empty($originalTask->result_url)) {
  2475. return [
  2476. 'success' => false,
  2477. 'message' => '原任务没有视频URL'
  2478. ];
  2479. }
  2480. // 获取API Key
  2481. $apiKey = env('VOLC_AI_MEDIAKIT_KEY');
  2482. if (empty($apiKey)) {
  2483. return [
  2484. 'success' => false,
  2485. 'message' => '火山引擎 MediaKit API Key 未配置'
  2486. ];
  2487. }
  2488. // 构建请求参数
  2489. $requestParams = [
  2490. 'video_url' => $originalTask->result_url,
  2491. 'resolution' => '720p',
  2492. 'bitrate_level' => 'medium'
  2493. ];
  2494. // 调用超分API
  2495. $client = new Client(['verify' => false, 'timeout' => 120]);
  2496. $response = $client->post('https://mediakit.cn-beijing.volces.com/api/v1/tools/enhance-video-fast', [
  2497. 'headers' => [
  2498. 'Authorization' => 'Bearer ' . $apiKey,
  2499. 'Content-Type' => 'application/json',
  2500. ],
  2501. 'json' => $requestParams
  2502. ]);
  2503. $responseData = json_decode($response->getBody(), true);
  2504. dLog('generate')->info('视频超分任务提交响应', [
  2505. 'original_task_id' => $taskId,
  2506. 'response' => $responseData
  2507. ]);
  2508. // 检查API响应
  2509. if (!isset($responseData['success']) || $responseData['success'] !== true) {
  2510. $errorMsg = '超分任务提交失败';
  2511. if (isset($responseData['error'])) {
  2512. $errorMsg .= ': ' . ($responseData['error']['message'] ?? json_encode($responseData['error']));
  2513. }
  2514. logDB('video_enhance', 'error', $errorMsg, [
  2515. 'original_task_id' => $taskId,
  2516. 'response' => $responseData
  2517. ]);
  2518. return [
  2519. 'success' => false,
  2520. 'message' => $errorMsg
  2521. ];
  2522. }
  2523. // 使用事务创建新的超分任务记录并更新原任务
  2524. try {
  2525. DB::beginTransaction();
  2526. // 创建新的超分任务记录
  2527. $enhanceTask = MpGenerateVideoTask::create([
  2528. 'task_id' => $responseData['task_id'],
  2529. 'prompt' => $originalTask->prompt,
  2530. 'video_resolution' => '720P', // 目标分辨率
  2531. 'status' => MpGenerateVideoTask::STATUS_PROCESSING,
  2532. 'api_type' => 'video_enhance',
  2533. 'extra_params' => [
  2534. 'original_task_id' => $originalTask->id,
  2535. 'original_video_url' => $originalTask->result_url,
  2536. 'enhance_type' => '480p_to_720p',
  2537. 'request_id' => $responseData['request_id'] ?? '',
  2538. 'api_response' => $responseData
  2539. ]
  2540. ]);
  2541. // 更新原任务的video_enhance_id字段
  2542. $originalTask->update([
  2543. 'video_enhance_id' => $enhanceTask->id
  2544. ]);
  2545. DB::commit();
  2546. logDB('video_enhance', 'info', '视频超分任务创建成功', [
  2547. 'original_task_id' => $taskId,
  2548. 'enhance_task_id' => $enhanceTask->id,
  2549. 'api_task_id' => $responseData['task_id']
  2550. ]);
  2551. return [
  2552. 'success' => true,
  2553. 'message' => '超分任务提交成功',
  2554. 'enhance_task_id' => $enhanceTask->id,
  2555. 'api_task_id' => $responseData['task_id']
  2556. ];
  2557. } catch (\Exception $e) {
  2558. DB::rollBack();
  2559. logDB('video_enhance', 'error', '创建超分任务失败', [
  2560. 'original_task_id' => $taskId,
  2561. 'error' => $e->getMessage()
  2562. ]);
  2563. return [
  2564. 'success' => false,
  2565. 'message' => '创建超分任务失败: ' . $e->getMessage()
  2566. ];
  2567. }
  2568. } catch (\GuzzleHttp\Exception\RequestException $e) {
  2569. $errorMsg = 'API请求失败';
  2570. if ($e->hasResponse()) {
  2571. $errorResponse = json_decode($e->getResponse()->getBody()->getContents(), true);
  2572. $errorMsg .= ': ' . ($errorResponse['msg'] ?? $errorResponse['message'] ?? $e->getMessage());
  2573. } else {
  2574. $errorMsg .= ': ' . $e->getMessage();
  2575. }
  2576. logDB('video_enhance', 'error', $errorMsg, ['task_id' => $taskId]);
  2577. return [
  2578. 'success' => false,
  2579. 'message' => $errorMsg
  2580. ];
  2581. } catch (\Exception $e) {
  2582. logDB('video_enhance', 'error', '超分任务提交异常', [
  2583. 'task_id' => $taskId,
  2584. 'error' => $e->getMessage()
  2585. ]);
  2586. return [
  2587. 'success' => false,
  2588. 'message' => '系统错误: ' . $e->getMessage()
  2589. ];
  2590. }
  2591. }
  2592. /**
  2593. * 查询视频超分任务状态
  2594. *
  2595. * @param MpGenerateVideoTask $task
  2596. * @return array
  2597. */
  2598. public function queryVideoEnhanceTaskStatus(MpGenerateVideoTask $task): array
  2599. {
  2600. try {
  2601. $apiKey = env('VOLC_AI_MEDIAKIT_KEY');
  2602. if (empty($apiKey)) {
  2603. return [
  2604. 'status' => 'failed',
  2605. 'error_message' => 'API Key 未配置'
  2606. ];
  2607. }
  2608. // 调用查询任务信息API
  2609. $client = new Client(['verify' => false, 'timeout' => 120]);
  2610. $response = $client->get('https://mediakit.cn-beijing.volces.com/api/v1/tasks/' . $task->task_id, [
  2611. 'headers' => [
  2612. 'Authorization' => 'Bearer ' . $apiKey,
  2613. ]
  2614. ]);
  2615. $responseData = json_decode($response->getBody(), true);
  2616. dLog('generate')->info('视频超分任务状态查询', [
  2617. 'task_id' => $task->id,
  2618. 'api_task_id' => $task->task_id,
  2619. 'response' => $responseData
  2620. ]);
  2621. // 检查响应
  2622. if (!isset($responseData['success']) || $responseData['success'] !== true) {
  2623. return [
  2624. 'status' => 'failed',
  2625. 'error_message' => isset($responseData['error']) ?
  2626. ($responseData['error']['message'] ?? '查询失败') : '查询失败'
  2627. ];
  2628. }
  2629. $taskData = $responseData;
  2630. $taskStatus = $taskData['status'] ?? '';
  2631. $returnData = [
  2632. 'status' => 'processing',
  2633. 'error_message' => null,
  2634. 'result_url' => null,
  2635. 'result_json' => $responseData
  2636. ];
  2637. // 解析任务状态
  2638. if ($taskStatus === 'completed' && isset($taskData['result']['video_url'])) {
  2639. // 任务成功,保存视频
  2640. $videoUrl = $taskData['result']['video_url'];
  2641. // 将视频保存到TOS
  2642. $videoName = 'enhanced_video_' . time() . '_' . uniqid() . '.mp4';
  2643. $savedUrl = uploadStreamByTos('video', file_get_contents($videoUrl), $videoName);
  2644. $returnData['status'] = 'success';
  2645. $returnData['result_url'] = $savedUrl;
  2646. // 视频时长
  2647. if (isset($taskData['result']['duration'])) {
  2648. $returnData['video_duration'] = (int)$taskData['result']['duration'];
  2649. }
  2650. // 压缩视频(可选)
  2651. $compressedUrl = compressVideo($savedUrl);
  2652. $returnData['compressed_url'] = $compressedUrl ?: $savedUrl;
  2653. } elseif ($taskStatus === 'failed') {
  2654. $returnData['status'] = 'failed';
  2655. $returnData['error_message'] = '视频超分失败';
  2656. } elseif (in_array($taskStatus, ['pending', 'processing', 'queued'])) {
  2657. $returnData['status'] = 'processing';
  2658. }
  2659. return $returnData;
  2660. } catch (\GuzzleHttp\Exception\RequestException $e) {
  2661. $errorMsg = 'API查询失败';
  2662. if ($e->hasResponse()) {
  2663. $errorResponse = json_decode($e->getResponse()->getBody()->getContents(), true);
  2664. $errorMsg .= ': ' . ($errorResponse['msg'] ?? $errorResponse['message'] ?? $e->getMessage());
  2665. } else {
  2666. $errorMsg .= ': ' . $e->getMessage();
  2667. }
  2668. return [
  2669. 'status' => 'processing',
  2670. 'error_message' => $errorMsg
  2671. ];
  2672. } catch (\Exception $e) {
  2673. return [
  2674. 'status' => 'processing',
  2675. 'error_message' => $e->getMessage()
  2676. ];
  2677. }
  2678. }
  2679. /**
  2680. * 更新视频超分任务状态
  2681. *
  2682. * @param MpGenerateVideoTask $task
  2683. * @return void
  2684. */
  2685. private function updateVideoEnhanceTask($task): void
  2686. {
  2687. $statusInfo = $this->queryVideoEnhanceTaskStatus($task);
  2688. if (!isset($statusInfo['status'])) return;
  2689. if ($statusInfo['status'] === 'success') {
  2690. logDB('video_enhance', 'info', '视频超分任务成功', [
  2691. 'id' => $task->id,
  2692. 'result_url' => $statusInfo['result_url']
  2693. ]);
  2694. // 获取原始480p任务
  2695. $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true);
  2696. $originalTaskId = $extraParams['original_task_id'] ?? null;
  2697. if (!$originalTaskId) {
  2698. // 如果没有原始任务ID,只更新超分任务本身
  2699. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2700. return;
  2701. }
  2702. $originalTask = MpGenerateVideoTask::find($originalTaskId);
  2703. if (!$originalTask) {
  2704. logDB('video_enhance', 'warning', '原始480p任务不存在', [
  2705. 'enhance_task_id' => $task->id,
  2706. 'original_task_id' => $originalTaskId
  2707. ]);
  2708. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2709. return;
  2710. }
  2711. // 使用与updateUnifiedApiTask完全一致的逻辑
  2712. $segment_id = $this->getSegmentIdFromTask($originalTask);
  2713. if ($segment_id && isset($statusInfo['result_url'])) {
  2714. try {
  2715. DB::beginTransaction();
  2716. // 更新超分任务状态
  2717. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2718. // 更新原始480p任务状态
  2719. $originalTask->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2720. $now = date('Y-m-d H:i:s');
  2721. // 构建更新数据(使用超分后的720p视频)
  2722. $segmentUpdateData = [
  2723. 'origin_video_url' => $statusInfo['result_url'],
  2724. 'video_task_status' => '已完成',
  2725. 'current_type' => 2,
  2726. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  2727. 'updated_at' => $now
  2728. ];
  2729. // 使用查询任务时已压缩的视频URL,避免重复压缩
  2730. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  2731. // 只有当video_duration存在且大于0时才更新
  2732. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  2733. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  2734. $segmentUpdateData['video_time_point_start'] = 0;
  2735. // 获取audio_duration(兼容全能模式)
  2736. $audio_duration = $this->getAudioDuration($originalTask, $segment_id, $statusInfo['video_duration'] ?? null);
  2737. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  2738. }
  2739. // 更新分镜表(兼容act模式)
  2740. $this->updateSegmentTable($originalTask, $segmentUpdateData);
  2741. // 添加视频生成对话记录
  2742. $this->addVideoGenerationRecords($originalTask, $segment_id, $statusInfo['result_url']);
  2743. // 如果是全能模式的首帧图,则提取视频第一帧并更新anime表
  2744. $act_id = getProp($originalTask, 'alias_act_id');
  2745. if (!empty($act_id) && Redis::sismember('anime_act_first_frame_urls', $act_id)) {
  2746. $anime_id = DB::table('mp_episode_segments')->where('id', $act_id)->value('anime_id');
  2747. if ($anime_id) {
  2748. // 提取视频第一帧
  2749. $videoUrl = $segmentUpdateData['video_url'];
  2750. $firstFrameUrl = getVideoFirstFrame($videoUrl, 'anime_first_frames', 'tos');
  2751. if ($firstFrameUrl) {
  2752. // 更新anime表
  2753. DB::table('mp_animes')->where('id', $anime_id)->update([
  2754. 'first_frame_url' => $firstFrameUrl,
  2755. 'updated_at' => date('Y-m-d H:i:s')
  2756. ]);
  2757. dLog('video_frame')->info('全能模式首帧图更新成功(超分任务)', [
  2758. 'act_id' => $act_id,
  2759. 'anime_id' => $anime_id,
  2760. 'first_frame_url' => $firstFrameUrl
  2761. ]);
  2762. }
  2763. }
  2764. }
  2765. DB::commit();
  2766. // 从Redis中移除
  2767. Redis::srem('anime_act_first_frame_urls', $act_id);
  2768. } catch (\Exception $e) {
  2769. DB::rollBack();
  2770. dLog('video_enhance')->error('超分任务处理失败', [
  2771. 'segment_id' => $segment_id,
  2772. 'enhance_task_id' => $task->id,
  2773. 'original_task_id' => $originalTaskId,
  2774. 'error' => $e->getMessage()
  2775. ]);
  2776. }
  2777. } else {
  2778. // 没有segment_id的情况下,只更新任务状态
  2779. try {
  2780. DB::beginTransaction();
  2781. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2782. $originalTask->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2783. DB::commit();
  2784. } catch (\Exception $e) {
  2785. DB::rollBack();
  2786. dLog('video_enhance')->error('超分任务状态更新失败', [
  2787. 'enhance_task_id' => $task->id,
  2788. 'original_task_id' => $originalTaskId,
  2789. 'error' => $e->getMessage()
  2790. ]);
  2791. }
  2792. }
  2793. } elseif ($statusInfo['status'] === 'failed') {
  2794. logDB('video_enhance', 'error', '视频超分任务失败', [
  2795. 'id' => $task->id,
  2796. 'error' => $statusInfo['error_message']
  2797. ]);
  2798. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2799. 'error_message' => $statusInfo['error_message'],
  2800. 'result_json' => $statusInfo['result_json'] ?? []
  2801. ]);
  2802. // 不更新原始任务状态,保留480p任务的成功状态
  2803. }
  2804. // 超时检查:48小时
  2805. $processingTime = now()->diffInHours($task->created_at);
  2806. if ($processingTime > 48 && $task->status === MpGenerateVideoTask::STATUS_PROCESSING) {
  2807. logDB('video_enhance', 'warning', '视频超分任务超时', [
  2808. 'id' => $task->id,
  2809. 'processing_hours' => $processingTime
  2810. ]);
  2811. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2812. 'error_message' => '任务处理超时(超过48小时)'
  2813. ]);
  2814. }
  2815. }
  2816. }