AIVideoGenerationService.php 115 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682
  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. default:
  280. break;
  281. }
  282. }
  283. }
  284. // 更新即梦AI视频任务状态
  285. private function updateJimengTask($task) {
  286. $statusInfo = $this->queryJimengTaskStatus($task);
  287. if (!isset($statusInfo['status'])) return;
  288. if ($statusInfo['status'] === 'success') {
  289. logDB('generate', 'info', '即梦AI视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]);
  290. // 同步调整分镜视频状态和结果(兼容全能模式)
  291. $segment_id = $this->getSegmentIdFromTask($task);
  292. if ($segment_id && isset($statusInfo['result_url'])) {
  293. try {
  294. DB::beginTransaction();
  295. // 在事务中更新任务状态
  296. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  297. $now = date('Y-m-d H:i:s');
  298. // 构建更新数据
  299. $segmentUpdateData = [
  300. 'origin_video_url' => $statusInfo['result_url'],
  301. 'video_task_status' => '已完成',
  302. 'current_type' => 2,
  303. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  304. 'updated_at' => $now
  305. ];
  306. // 使用查询任务时已压缩的视频URL,避免重复压缩
  307. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  308. // 只有当video_duration存在且大于0时才更新
  309. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  310. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  311. $segmentUpdateData['video_time_point_start'] = 0;
  312. $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null);
  313. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  314. }
  315. // 更新分镜表(兼容act模式)
  316. $this->updateSegmentTable($task, $segmentUpdateData);
  317. // 添加视频生成对话记录
  318. $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']);
  319. DB::commit();
  320. } catch (\Exception $e) {
  321. DB::rollBack();
  322. dLog('generate')->error('即梦视频任务处理失败', [
  323. 'segment_id' => $segment_id,
  324. 'task_id' => $task->id,
  325. 'error' => $e->getMessage()
  326. ]);
  327. }
  328. } else {
  329. // 没有segment_id的情况下,直接更新任务状态
  330. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  331. }
  332. } elseif ($statusInfo['status'] === 'failed') {
  333. logDB('generate', 'error', '即梦AI视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]);
  334. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, $statusInfo);
  335. // 同步更新分镜表状态为失败(兼容全能模式)
  336. $segment_id = $this->getSegmentIdFromTask($task);
  337. if ($segment_id) {
  338. $this->updateSegmentTable($task, [
  339. 'video_task_status' => '失败',
  340. 'updated_at' => date('Y-m-d H:i:s')
  341. ]);
  342. }
  343. }
  344. // 如果仍然是处理中状态,不做任何操作
  345. // 处理:如果任务处理超过12小时,标记为失败
  346. $processingTime = now()->diffInHours($task->created_at);
  347. if ($processingTime > 12) {
  348. logDB('generate', 'warning', '即梦AI视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]);
  349. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  350. 'error_message' => '任务处理超时(超过12小时)'
  351. ]);
  352. // 同步更新分镜表状态为失败(兼容全能模式)
  353. $segment_id = $this->getSegmentIdFromTask($task);
  354. if ($segment_id) {
  355. $this->updateSegmentTable($task, [
  356. 'video_task_status' => '失败',
  357. 'updated_at' => date('Y-m-d H:i:s')
  358. ]);
  359. }
  360. }
  361. }
  362. public function getTaskList($data)
  363. {
  364. $task_id = getProp($data, 'task_id');
  365. $status = getProp($data, 'status');
  366. $query = MpGenerateVideoTask::select('*');
  367. if ($task_id) {
  368. $query->where('id', $task_id);
  369. }
  370. if ($status) {
  371. $query->where('status', $status);
  372. }
  373. return $query->orderBy('created_at', 'desc')->paginate();
  374. }
  375. /**
  376. * 创建 Seedance 1.5 Pro 视频生成任务
  377. *
  378. * @param array $params
  379. * @return MpGenerateVideoTask
  380. */
  381. public function createSeedanceTask(array $params): MpGenerateVideoTask
  382. {
  383. // 生成唯一的任务ID
  384. $taskId = 'seedance_' . time() . '_' . uniqid();
  385. // 创建任务记录,复用现有表结构
  386. $task = MpGenerateVideoTask::create([
  387. 'task_id' => $taskId,
  388. 'alias_segment_id' => $params['alias_segment_id'] ?? '',
  389. 'alias_act_id' => $params['alias_act_id'] ?? '',
  390. 'prompt' => $params['prompt'] ?? '',
  391. 'first_frame_url' => $params['first_frame_url'] ?? '',
  392. 'tail_frame_url' => $params['tail_frame_url'] ?? '',
  393. 'seed' => $params['seed'] ?? -1,
  394. 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null,
  395. 'video_resolution' => $params['video_resolution'] ?? '720P',
  396. 'status' => MpGenerateVideoTask::STATUS_PENDING,
  397. 'api_type' => 'seedance', // 标识为 Seedance 1.5 Pro API
  398. 'extra_params' => [
  399. 'model' => $params['model'] ?? 'doubao-seedance-1-5-pro-251215',
  400. 'content' => $params['content'],
  401. 'callback_url' => $params['callback_url'] ?? '',
  402. 'return_last_frame' => $params['return_last_frame'] ?? true,
  403. 'service_tier' => $params['service_tier'] ?? 'default',
  404. 'execution_expires_after' => $params['execution_expires_after'] ?? 172800,
  405. 'generate_audio' => $params['generate_audio'] ?? false,
  406. 'draft' => $params['draft'] ?? false,
  407. 'ratio' => $params['ratio'] ?? '9:16',
  408. 'duration' => $params['video_duration'] ?? 5,
  409. 'camera_fixed' => $params['camera_fixed'] ?? false,
  410. 'watermark' => $params['watermark'] ?? false,
  411. ]
  412. ]);
  413. // 提交任务到火山引擎 API
  414. $this->submitSeedanceTaskToApi($task);
  415. return $task;
  416. }
  417. /**
  418. * 提交 Seedance 任务到火山引擎 API
  419. *
  420. * @param MpGenerateVideoTask $task
  421. * @return void
  422. */
  423. private function submitSeedanceTaskToApi(MpGenerateVideoTask $task): void
  424. {
  425. $client = new Client(['verify'=>false,'timeout'=>120]);
  426. try {
  427. // 验证环境变量配置
  428. $apiKey = env('VOLC_AI_API_KEY');
  429. if (empty($apiKey)) {
  430. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  431. 'error_message' => '火山引擎访问密钥未配置,请检查环境变量API Key'
  432. ]);
  433. return;
  434. }
  435. $extraParams = $task->extra_params;
  436. // 构建火山引擎 Seedance API 请求参数
  437. $apiParams = [
  438. 'model' => isset(\App\Consts\BaseConst::MODELS_MAP[$extraParams['model']]) ? \App\Consts\BaseConst::MODELS_MAP[$extraParams['model']] : $extraParams['model'],
  439. 'content' => $extraParams['content'],
  440. ];
  441. // 添加可选参数
  442. if (!empty($extraParams['callback_url'])) {
  443. $apiParams['callback_url'] = $extraParams['callback_url'];
  444. }
  445. if (isset($extraParams['return_last_frame'])) {
  446. $apiParams['return_last_frame'] = $extraParams['return_last_frame'];
  447. }
  448. if (!empty($extraParams['service_tier']) && !in_array($extraParams['model'], ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'])) {
  449. $apiParams['service_tier'] = $extraParams['service_tier'];
  450. }
  451. if (!empty($extraParams['execution_expires_after'])) {
  452. $apiParams['execution_expires_after'] = $extraParams['execution_expires_after'];
  453. }
  454. if (isset($extraParams['generate_audio'])) {
  455. $apiParams['generate_audio'] = $extraParams['generate_audio'];
  456. }
  457. if (isset($extraParams['draft'])) {
  458. $apiParams['draft'] = $extraParams['draft'];
  459. }
  460. if (!empty($extraParams['video_resolution'])) {
  461. $apiParams['resolution'] = strtolower($task->video_resolution);
  462. }
  463. if (!empty($extraParams['ratio'])) {
  464. $apiParams['ratio'] = $extraParams['ratio'];
  465. }
  466. if (!empty($extraParams['duration'])) {
  467. $apiParams['duration'] = (int)$extraParams['duration'];
  468. }
  469. if (isset($task->frames)) {
  470. // $apiParams['frames'] = $task->frames;
  471. }
  472. if ($task->seed !== -1) {
  473. $apiParams['seed'] = (int)$task->seed;
  474. }
  475. if (isset($extraParams['camera_fixed'])) {
  476. $apiParams['camera_fixed'] = $extraParams['camera_fixed'];
  477. }
  478. if (isset($extraParams['watermark'])) {
  479. $apiParams['watermark'] = $extraParams['watermark'];
  480. }
  481. dLog('generate')->info('Seedance 1.5 Pro 视频生成参数: ', $apiParams);
  482. logDB('generate', 'info', 'Seedance视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]);
  483. // 调用火山引擎 Seedance API
  484. $response = $client->post('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', [
  485. 'headers' => [
  486. 'Authorization' => 'Bearer ' . $apiKey,
  487. 'Content-Type' => 'application/json',
  488. ],
  489. 'json' => $apiParams
  490. ]);
  491. $responseData = json_decode($response->getBody(), true);
  492. dLog('generate')->info('Seedance 1.5 Pro API 响应: ', $responseData);
  493. logDB('generate', 'info', 'Seedance视频生成API响应', ['id' => $task->id, 'response' => $responseData]);
  494. if (isset($responseData['error'])) {
  495. // API返回错误
  496. logDB('generate', 'error', 'Seedance视频生成任务提交失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? 'API Error']);
  497. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  498. 'error_message' => $responseData['error']['message'] ?? 'API Error',
  499. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  500. ]);
  501. } elseif (isset($responseData['id'])) {
  502. // 任务提交成功,更新状态为处理中
  503. $updateData = [
  504. 'task_id' => $responseData['id'], // 使用 API 返回的任务 ID
  505. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  506. ];
  507. $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData);
  508. } else {
  509. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  510. 'error_message' => '未知的 API 响应格式',
  511. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  512. ]);
  513. }
  514. } catch (\Exception $e) {
  515. // 记录错误
  516. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  517. 'error_message' => 'API请求失败: ' . $e->getMessage()
  518. ]);
  519. Utils::throwError('1001:'.$e->getMessage());
  520. }
  521. }
  522. // 更新Seedance视频任务状态
  523. private function updateSeedanceTask($task) {
  524. $statusInfo = $this->querySeedanceTaskStatus($task);
  525. if (!isset($statusInfo['status'])) return;
  526. if ($statusInfo['status'] === 'success') {
  527. logDB('generate', 'info', 'Seedance视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]);
  528. // 同步调整分镜视频状态和结果(兼容全能模式)
  529. $segment_id = $this->getSegmentIdFromTask($task);
  530. if ($segment_id && isset($statusInfo['result_url'])) {
  531. try {
  532. DB::beginTransaction();
  533. // 在事务中更新任务状态
  534. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  535. $now = date('Y-m-d H:i:s');
  536. // 构建更新数据
  537. $segmentUpdateData = [
  538. 'origin_video_url' => $statusInfo['result_url'],
  539. 'video_task_status' => '已完成',
  540. 'current_type' => 2,
  541. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  542. 'updated_at' => $now
  543. ];
  544. // 使用查询任务时已压缩的视频URL,避免重复压缩
  545. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  546. // 只有当video_duration存在且大于0时才更新
  547. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  548. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  549. $segmentUpdateData['video_time_point_start'] = 0;
  550. // 获取video_time_point_end(兼容全能模式)
  551. $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null);
  552. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  553. }
  554. // 更新分镜表(兼容act模式)
  555. $this->updateSegmentTable($task, $segmentUpdateData);
  556. // 添加视频生成对话记录
  557. $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']);
  558. DB::commit();
  559. } catch (\Exception $e) {
  560. DB::rollBack();
  561. dLog('generate')->error('Seedance视频任务处理失败', [
  562. 'segment_id' => $segment_id,
  563. 'task_id' => $task->id,
  564. 'error' => $e->getMessage()
  565. ]);
  566. }
  567. } else {
  568. // 没有segment_id的情况下,直接更新任务状态
  569. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  570. }
  571. } elseif ($statusInfo['status'] === 'failed') {
  572. logDB('generate', 'error', 'Seedance视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]);
  573. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  574. 'error_message' => $statusInfo['error_message'],
  575. 'result_json' => $statusInfo['result_json'] ?? []
  576. ]);
  577. // 同步更新分镜表状态为失败(兼容全能模式)
  578. $segment_id = $this->getSegmentIdFromTask($task);
  579. if ($segment_id) {
  580. $this->updateSegmentTable($task, [
  581. 'video_task_status' => '失败',
  582. 'updated_at' => date('Y-m-d H:i:s')
  583. ]);
  584. }
  585. }
  586. // 如果仍然是处理中状态,不做任何操作
  587. // 处理:如果任务处理超过48小时,标记为失败
  588. $processingTime = now()->diffInHours($task->created_at);
  589. if ($processingTime > 48) {
  590. logDB('generate', 'warning', 'Seedance视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]);
  591. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  592. 'error_message' => '任务处理超时(超过48小时)'
  593. ]);
  594. // 同步更新分镜表状态为失败(兼容全能模式)
  595. $segment_id = $this->getSegmentIdFromTask($task);
  596. if ($segment_id) {
  597. $this->updateSegmentTable($task, [
  598. 'video_task_status' => '失败',
  599. 'updated_at' => date('Y-m-d H:i:s')
  600. ]);
  601. }
  602. }
  603. }
  604. /**
  605. * 查询 Seedance 任务状态
  606. *
  607. * @param MpGenerateVideoTask $task
  608. * @return array
  609. */
  610. public function querySeedanceTaskStatus(MpGenerateVideoTask $task): array
  611. {
  612. $client = new Client(['verify'=>false,'timeout'=>120]);
  613. try {
  614. // 如果任务已完成,直接返回结果
  615. if (in_array($task->status, [MpGenerateVideoTask::STATUS_SUCCESS, MpGenerateVideoTask::STATUS_FAILED])) {
  616. return [
  617. 'success' => true,
  618. 'data' => [
  619. 'id' => $task->id,
  620. 'status' => $this->mapTaskStatus($task->status),
  621. 'result_url' => $task->result_url,
  622. 'error_message' => $task->error_message,
  623. 'created_at' => $task->created_at,
  624. 'started_at' => $task->started_at,
  625. 'completed_at' => $task->completed_at
  626. ]
  627. ];
  628. }
  629. // 调用火山引擎 API 查询任务状态
  630. $apiKey = env('VOLC_AI_API_KEY');
  631. if (empty($apiKey)) {
  632. return [
  633. 'status' => MpGenerateVideoTask::STATUS_FAILED,
  634. 'result_json' => [],
  635. 'error_message' => '火山引擎访问密钥未配置'
  636. ];
  637. }
  638. // 构建查询 URL
  639. $url = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/' . $task->task_id;
  640. $response = $client->get($url, [
  641. 'headers' => [
  642. 'Authorization' => 'Bearer ' . $apiKey,
  643. 'Content-Type' => 'application/json',
  644. ]
  645. ]);
  646. $responseData = json_decode($response->getBody(), true);
  647. dLog('generate')->info('Seedance 任务状态查询响应: ', $responseData);
  648. logDB('generate', 'info', 'Seedance任务状态查询', ['id' => $task->id, 'response' => $responseData]);
  649. if (isset($responseData['error'])) {
  650. logDB('generate', 'error', 'Seedance任务状态查询失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? 'API查询失败']);
  651. return [
  652. 'status' => MpGenerateVideoTask::STATUS_FAILED,
  653. 'result_json' => $responseData,
  654. 'error_message' => $responseData['error']['message'] ?? 'API查询失败'
  655. ];
  656. }
  657. // 解析任务状态
  658. $apiStatus = $responseData['status'] ?? 'unknown';
  659. $taskStatus = $this->mapApiStatusToTaskStatus($apiStatus);
  660. $updateData = ['result_json' => $responseData];
  661. if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) {
  662. $updateData['status'] = MpGenerateVideoTask::STATUS_SUCCESS;
  663. // 任务成功,处理结果
  664. if (isset($responseData['content']['video_url'])) {
  665. $video_url = $responseData['content']['video_url'];
  666. // 下载并保存视频
  667. $video_name = 'ai_generation_' . time() . '_' . uniqid();
  668. $video_ext = getVideoExtFromUrl($video_url);
  669. $video_name = $video_name . $video_ext;
  670. $url = uploadStreamByTos('video', file_get_contents($video_url), $video_name);
  671. $updateData['result_url'] = $url;
  672. // 压缩视频
  673. $compressed_video_url = compressVideo($url);
  674. $updateData['compressed_url'] = $compressed_video_url ?: $url;
  675. }
  676. // 计算视频时长
  677. $video_duration = 0;
  678. if (isset($responseData['duration'])) {
  679. // 如果直接返回duration,使用该值
  680. $video_duration = (int)$responseData['duration'];
  681. } elseif (isset($responseData['frames']) && isset($responseData['framespersecond'])) {
  682. // 如果返回frames和framespersecond,计算时长
  683. $frames = (int)$responseData['frames'];
  684. $fps = (int)$responseData['framespersecond'];
  685. if ($fps > 0) {
  686. $video_duration = (int)floor($frames / $fps);
  687. }
  688. }
  689. // 如果没有计算出时长,使用任务表中的设定值
  690. if ($video_duration <= 0 && !empty($task->extra_params)) {
  691. $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true);
  692. if (isset($extraParams['duration'])) {
  693. $video_duration = (int)$extraParams['duration'];
  694. }
  695. }
  696. // 只有当video_duration > 0时才添加到更新数据中
  697. if ($video_duration > 0) {
  698. $updateData['video_duration'] = $video_duration;
  699. $updateData['video_time_point_start'] = 0;
  700. $updateData['video_time_point_end'] = $video_duration;
  701. }
  702. // 处理尾帧图片(如果启用了 return_last_frame)
  703. if (isset($responseData['content']['last_frame_url']) && !empty($responseData['content']['last_frame_url'])) {
  704. $pic_url = $responseData['content']['last_frame_url'];
  705. $pic_name = 'ai_generation_' . time() . '_' . uniqid();
  706. $pic_ext = getImgExtFromUrl($pic_url);
  707. $pic_name = $pic_name . $pic_ext;
  708. if ($pic_ext === '.png') {
  709. $comporessed_data = compressRemoteImageUrlToSize($pic_url);
  710. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  711. }else {
  712. // 将图片另存到tos
  713. $url = uploadStreamByTos('image', file_get_contents($pic_url), $pic_name);
  714. }
  715. $updateData['last_frame_url'] = $url;
  716. }
  717. return $updateData;
  718. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) {
  719. $updateData['status'] = MpGenerateVideoTask::STATUS_FAILED;
  720. $updateData['error_message'] = $responseData['error_message'] ?? '任务执行失败';
  721. return $updateData;
  722. }
  723. // 其他状态保持不变
  724. return [
  725. 'success' => true,
  726. 'data' => [
  727. 'id' => $task->id,
  728. 'status' => $this->mapTaskStatus($task->status),
  729. 'result_url' => $task->result_url,
  730. 'error_message' => $task->error_message,
  731. 'created_at' => $task->created_at,
  732. 'started_at' => $task->started_at,
  733. 'completed_at' => $task->completed_at,
  734. 'api_response' => $responseData
  735. ]
  736. ];
  737. } catch (\Exception $e) {
  738. dLog('generate')->info('Seedance 任务状态查询异常: '.$e->getMessage());
  739. return [
  740. 'success' => false,
  741. 'message' => '查询失败: ' . $e->getMessage()
  742. ];
  743. }
  744. }
  745. /**
  746. * 映射 API 状态到任务状态
  747. *
  748. * @param string $apiStatus
  749. * @return string
  750. */
  751. private function mapApiStatusToTaskStatus(string $apiStatus): string
  752. {
  753. switch ($apiStatus) {
  754. case 'queued':
  755. case 'running':
  756. return MpGenerateVideoTask::STATUS_PROCESSING;
  757. case 'succeeded':
  758. return MpGenerateVideoTask::STATUS_SUCCESS;
  759. case 'failed':
  760. case 'expired':
  761. return MpGenerateVideoTask::STATUS_FAILED;
  762. default:
  763. return MpGenerateVideoTask::STATUS_PROCESSING;
  764. }
  765. }
  766. public function seedanceCallback($data) {
  767. try {
  768. dLog('generate')->info('Seedance 回调数据: ', $data);
  769. logDB('generate', 'info', 'Seedance回调接收', ['task_id' => $data['id'] ?? null, 'status' => $data['status'] ?? null]);
  770. // 从回调数据中获取任务ID
  771. $taskId = $data['id'] ?? null;
  772. if (!$taskId) {
  773. dLog('generate')->error('Seedance 回调缺少任务ID');
  774. logDB('generate', 'error', 'Seedance回调缺少任务ID', ['data' => $data]);
  775. return ['success' => false, 'message' => '缺少任务ID'];
  776. }
  777. // 查找对应的任务记录
  778. $task = MpGenerateVideoTask::where('task_id', $taskId)->first();
  779. if (!$task) {
  780. dLog('generate')->error('Seedance 回调找不到任务: ' . $taskId);
  781. logDB('generate', 'error', 'Seedance回调找不到任务', ['task_id' => $taskId]);
  782. return ['success' => false, 'message' => '任务不存在'];
  783. }
  784. // 解析任务状态
  785. $apiStatus = $data['status'] ?? 'unknown';
  786. $taskStatus = $this->mapApiStatusToTaskStatus($apiStatus);
  787. // 准备更新数据
  788. $updateData = [
  789. 'result_json' => $data,
  790. 'updated_at' => now()
  791. ];
  792. if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) {
  793. $updateData['status'] = MpGenerateVideoTask::STATUS_SUCCESS;
  794. $updateData['completed_at'] = now();
  795. // 处理视频结果
  796. if (isset($data['content']['video_url'])) {
  797. $video_url = $data['content']['video_url'];
  798. try {
  799. // 下载并保存视频
  800. $video_name = 'ai_generation_' . time() . '_' . uniqid();
  801. $video_ext = getVideoExtFromUrl($video_url);
  802. $video_name = $video_name . $video_ext;
  803. $url = uploadStreamByTos('video', file_get_contents($video_url), $video_name);
  804. $updateData['result_url'] = $url;
  805. $compressed_video_url = compressVideo($url);
  806. $updateData['compressed_url'] = $compressed_video_url ?: $url;
  807. dLog('generate')->info('Seedance 视频保存成功: ' . $url);
  808. } catch (\Exception $e) {
  809. dLog('generate')->error('Seedance 视频保存失败: ' . $e->getMessage());
  810. // 如果保存失败,直接使用原始URL
  811. $updateData['result_url'] = $video_url;
  812. }
  813. }
  814. // 处理尾帧图片(如果启用了 return_last_frame)
  815. if (isset($data['content']['last_frame_url']) && !empty($data['content']['last_frame_url'])) {
  816. $pic_url = $data['content']['last_frame_url'];
  817. try {
  818. $pic_name = 'ai_generation_' . time() . '_' . uniqid();
  819. $pic_ext = getImgExtFromUrl($pic_url);
  820. $pic_name = $pic_name . $pic_ext;
  821. if ($pic_ext === '.png') {
  822. $compressed_data = compressRemoteImageUrlToSize($pic_url);
  823. $url = uploadStreamByTos('image', $compressed_data, $pic_name);
  824. } else {
  825. $url = uploadStreamByTos('image', file_get_contents($pic_url), $pic_name);
  826. }
  827. $updateData['last_frame_url'] = $url;
  828. dLog('generate')->info('Seedance 尾帧图片保存成功: ' . $url);
  829. } catch (\Exception $e) {
  830. dLog('generate')->error('Seedance 尾帧图片保存失败: ' . $e->getMessage());
  831. // 如果保存失败,直接使用原始URL
  832. $updateData['last_frame_url'] = $pic_url;
  833. }
  834. }
  835. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) {
  836. $updateData['status'] = MpGenerateVideoTask::STATUS_FAILED;
  837. $updateData['completed_at'] = now();
  838. $updateData['error_message'] = $data['error_message'] ?? $data['failure_reason'] ?? '任务执行失败';
  839. } else {
  840. // 处理中状态
  841. $updateData['status'] = $taskStatus;
  842. if ($taskStatus === MpGenerateVideoTask::STATUS_PROCESSING && !$task->started_at) {
  843. $updateData['started_at'] = now();
  844. }
  845. }
  846. // 更新任务记录
  847. $task->update($updateData);
  848. // 如果任务成功,更新分镜表
  849. if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS && isset($updateData['result_url'])) {
  850. try {
  851. DB::beginTransaction();
  852. $now = date('Y-m-d H:i:s');
  853. // 获取分镜ID(兼容分镜模式和全能模式)
  854. $segment = DB::table('mp_episode_segments')
  855. ->where('video_task_id', $task->id)
  856. ->first();
  857. if ($segment) {
  858. // 兼容两种模式
  859. $recordId = $segment->id;
  860. $segmentId = $segment->segment_id ?: $segment->id;
  861. $anime_id = $segment->anime_id;
  862. $episode_number = $segment->episode_number;
  863. $tail_frame = $segment->tail_frame;
  864. $uid = $segment->uid ?? 0;
  865. // 更新分镜表
  866. $segmentUpdateData = [
  867. 'origin_video_url' => $updateData['result_url'],
  868. 'video_task_status' => '已完成',
  869. 'last_frame_url' => $updateData['last_frame_url'] ?? '',
  870. 'current_type' => 2,
  871. 'updated_at' => $now
  872. ];
  873. $segmentUpdateData['video_url'] = $updateData['compressed_url'] ?? $updateData['result_url'];
  874. // 只有当video_duration存在且大于0时才更新
  875. if (isset($data['content']['video_duration']) && $data['content']['video_duration'] > 0) {
  876. $segmentUpdateData['video_duration'] = $data['content']['video_duration'];
  877. $segmentUpdateData['video_time_point_start'] = 0;
  878. $audio_duration = $this->getAudioDuration($task, $segmentId, $segmentUpdateData['video_duration']);
  879. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  880. }
  881. // 使用主键id更新(兼容两种模式)
  882. $updateResult = DB::table('mp_episode_segments')
  883. ->where('id', $recordId)
  884. ->update($segmentUpdateData);
  885. if (!$updateResult) {
  886. throw new \Exception('更新分镜表失败');
  887. }
  888. // 更新分镜视频成功后新增对话记录
  889. $records = [
  890. [
  891. 'uid' => $uid,
  892. 'anime_id' => $anime_id,
  893. 'sequence' => $episode_number,
  894. 'role' => 'user',
  895. 'content' => '图片转视频',
  896. 'segment_id' => $segmentId,
  897. 'video_url' => '',
  898. 'created_at' => $now,
  899. 'updated_at' => $now
  900. ],
  901. [
  902. 'uid' => $uid,
  903. 'anime_id' => $anime_id,
  904. 'sequence' => $episode_number,
  905. 'role' => 'assistant',
  906. 'content' => $tail_frame,
  907. 'segment_id' => $segmentId,
  908. 'video_url' => $updateData['result_url'],
  909. 'created_at' => $now,
  910. 'updated_at' => $now
  911. ]
  912. ];
  913. DB::table('mp_anime_records')->insert($records);
  914. dLog('generate')->info('Seedance 分镜表更新成功: ' . $segmentId);
  915. }
  916. DB::commit();
  917. } catch (\Exception $e) {
  918. DB::rollBack();
  919. $logData = [
  920. 'task_id' => $task->id,
  921. 'error' => $e->getMessage()
  922. ];
  923. dLog('generate')->error('Seedance 分镜表更新失败', $logData);
  924. logDB('generate', 'error', 'Seedance分镜表更新失败', $logData);
  925. }
  926. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) {
  927. // 任务失败,更新分镜表状态
  928. DB::table('mp_episode_segments')
  929. ->where('video_task_id', $task->id)
  930. ->update([
  931. 'video_task_status' => '失败',
  932. 'updated_at' => date('Y-m-d H:i:s')
  933. ]);
  934. }
  935. dLog('generate')->info('Seedance 回调处理完成: ' . $taskId . ', 状态: ' . $taskStatus);
  936. logDB('generate', 'info', 'Seedance回调处理完成', ['task_id' => $taskId, 'status' => $taskStatus]);
  937. return [
  938. 'success' => true,
  939. 'message' => '回调处理成功',
  940. 'task_id' => $taskId,
  941. 'status' => $taskStatus
  942. ];
  943. } catch (\Exception $e) {
  944. dLog('generate')->error('Seedance 回调处理异常: ' . $e->getMessage());
  945. logDB('generate', 'error', 'Seedance回调处理异常', ['error' => $e->getMessage()]);
  946. return [
  947. 'success' => false,
  948. 'message' => '回调处理失败: ' . $e->getMessage()
  949. ];
  950. }
  951. }
  952. /**
  953. * 更新分镜表的视频任务状态
  954. *
  955. * @param MpGenerateVideoTask $task
  956. * @param string $taskStatus
  957. * @param array $updateData
  958. */
  959. private function updateSegmentVideoStatus(MpGenerateVideoTask $task, string $taskStatus, array $updateData) {
  960. try {
  961. // 查找关联的分镜记录
  962. $segment = DB::table('mp_episode_segments')
  963. ->where('video_task_id', $task->id)
  964. ->first();
  965. if (!$segment) {
  966. dLog('generate')->info('未找到关联的分镜记录: ' . $task->id);
  967. return;
  968. }
  969. $segmentUpdateData = ['updated_at' => date('Y-m-d H:i:s')];
  970. if (isset($updateData['last_frame_url']) && $updateData['last_frame_url']) {
  971. $segmentUpdateData['last_frame_url'] = $updateData['last_frame_url'];
  972. }
  973. if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) {
  974. $segmentUpdateData['video_task_status'] = '已完成';
  975. if (isset($updateData['result_url'])) {
  976. $segmentUpdateData['origin_video_url'] = $updateData['result_url'];
  977. // 如果有压缩视频URL,使用压缩后的
  978. if (isset($updateData['compressed_url'])) {
  979. $segmentUpdateData['video_url'] = $updateData['compressed_url'];
  980. } else {
  981. $segmentUpdateData['video_url'] = $updateData['result_url'];
  982. }
  983. }
  984. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_FAILED) {
  985. $segmentUpdateData['video_task_status'] = '失败';
  986. } elseif ($taskStatus === MpGenerateVideoTask::STATUS_PROCESSING) {
  987. $segmentUpdateData['video_task_status'] = '生成中';
  988. }
  989. // 更新分镜表
  990. DB::table('mp_episode_segments')
  991. ->where('video_task_id', $task->id)
  992. ->update($segmentUpdateData);
  993. dLog('generate')->info('分镜视频状态更新成功: ' . $segment->segment_id . ', 状态: ' . ($segmentUpdateData['video_task_status'] ?? '未知'));
  994. } catch (\Exception $e) {
  995. dLog('generate')->error('更新分镜视频状态失败: ' . $e->getMessage());
  996. }
  997. }
  998. /**
  999. * 生成可灵AI的JWT Token
  1000. *
  1001. * @return string
  1002. */
  1003. private function generateKelingJwtToken(): string
  1004. {
  1005. $ak = env('KELING_AK');
  1006. $sk = env('KELING_SK');
  1007. if (empty($ak) || empty($sk)) {
  1008. throw new \Exception('可灵AI访问密钥未配置,请检查环境变量KELING_AK和KELING_SK');
  1009. }
  1010. // JWT头部
  1011. $header = [
  1012. 'alg' => 'HS256',
  1013. 'typ' => 'JWT'
  1014. ];
  1015. // JWT载荷
  1016. $payload = [
  1017. 'iss' => $ak, // 签发者,使用Access Key
  1018. 'exp' => time() + 1800, // 过期时间,30分钟后过期
  1019. 'nbf' => time() - 5 // 生效时间,5秒前开始生效
  1020. ];
  1021. // Base64URL编码
  1022. $headerEncoded = $this->base64UrlEncode(json_encode($header));
  1023. $payloadEncoded = $this->base64UrlEncode(json_encode($payload));
  1024. // 创建签名
  1025. $signature = hash_hmac('sha256', $headerEncoded . '.' . $payloadEncoded, $sk, true);
  1026. $signatureEncoded = $this->base64UrlEncode($signature);
  1027. // 返回完整的JWT token
  1028. return $headerEncoded . '.' . $payloadEncoded . '.' . $signatureEncoded;
  1029. }
  1030. /**
  1031. * Base64URL编码(JWT标准编码)
  1032. *
  1033. * @param string $data
  1034. * @return string
  1035. */
  1036. private function base64UrlEncode(string $data): string
  1037. {
  1038. return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
  1039. }
  1040. /**
  1041. * 创建可灵AI Omni视频生成任务
  1042. *
  1043. * @param array $params
  1044. * @return MpGenerateVideoTask
  1045. */
  1046. public function createKelingOmniTask(array $params): MpGenerateVideoTask
  1047. {
  1048. // 生成唯一的任务ID
  1049. $taskId = 'keling_omni_' . time() . '_' . uniqid();
  1050. // 创建任务记录,复用现有表结构
  1051. $task = MpGenerateVideoTask::create([
  1052. 'task_id' => $taskId,
  1053. 'prompt' => $params['prompt'] ?? '',
  1054. 'first_frame_url' => $params['first_frame_url'] ?? '',
  1055. 'tail_frame_url' => $params['tail_frame_url'] ?? '',
  1056. 'seed' => $params['seed'] ?? -1,
  1057. 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null,
  1058. 'video_resolution' => $params['video_resolution'] ?? '720P',
  1059. 'status' => MpGenerateVideoTask::STATUS_PENDING,
  1060. 'api_type' => 'keling', // 标识为可灵AI Omni API
  1061. 'extra_params' => [
  1062. 'api_type' => 'keling',
  1063. 'model' => $params['model'] ?? 'kling-video-o1',
  1064. 'prompt' => $params['prompt'] ?? '',
  1065. 'multi_prompt' => $params['multi_prompt'] ?? [],
  1066. 'shot_type' => $params['shot_type'] ?? 'customize',
  1067. 'image_list' => $params['image_list'] ?? [],
  1068. 'element_list' => $params['element_list'] ?? [],
  1069. 'sound' => $params['sound'] ?? 'off',
  1070. // 'aspect_ratio' => $params['aspect_ratio'] ?? '16:9',
  1071. 'duration' => $params['video_duration'] ?? 5,
  1072. 'mode' => $params['mode'] ?? 'std',
  1073. 'callback_url' => $params['callback_url'] ?? '',
  1074. ]
  1075. ]);
  1076. // 提交任务到可灵AI Omni API
  1077. $this->submitKelingOmniTaskToApi($task);
  1078. return $task;
  1079. }
  1080. /**
  1081. * 提交可灵AI Omni任务到API
  1082. *
  1083. * @param MpGenerateVideoTask $task
  1084. * @return void
  1085. */
  1086. private function submitKelingOmniTaskToApi(MpGenerateVideoTask $task): void
  1087. {
  1088. $client = new Client(['verify'=>false,'timeout'=>120]);
  1089. try {
  1090. // 生成JWT token
  1091. $jwtToken = $this->generateKelingJwtToken();
  1092. $extraParams = $task->extra_params;
  1093. // 构建可灵AI Omni API请求参数
  1094. $apiParams = [
  1095. 'model_name' => $extraParams['model'],
  1096. 'prompt' => $extraParams['prompt'],
  1097. 'mode' => $extraParams['mode'],
  1098. // 'aspect_ratio' => $extraParams['aspect_ratio'],
  1099. 'duration' => (int)$extraParams['duration'],
  1100. ];
  1101. // 添加可选参数
  1102. if (!empty($extraParams['multi_prompt'])) {
  1103. $apiParams['multi_prompt'] = $extraParams['multi_prompt'];
  1104. }
  1105. if (!empty($extraParams['shot_type'])) {
  1106. $apiParams['shot_type'] = $extraParams['shot_type'];
  1107. }
  1108. if (!empty($extraParams['image_list'])) {
  1109. $apiParams['image_list'] = $extraParams['image_list'];
  1110. }
  1111. if (!empty($extraParams['element_list'])) {
  1112. $apiParams['element_list'] = json_decode($extraParams['element_list'], true);
  1113. }
  1114. if (isset($extraParams['sound'])) {
  1115. $apiParams['sound'] = $extraParams['sound'];
  1116. }
  1117. // if (!empty($extraParams['callback_url'])) {
  1118. // $apiParams['callback_url'] = $extraParams['callback_url'];
  1119. // }
  1120. dLog('generate')->info('可灵AI Omni视频生成参数: ', $apiParams);
  1121. logDB('generate', 'info', '可灵AI Omni视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]);
  1122. // 调用可灵AI Omni API
  1123. $response = $client->post('https://api-beijing.klingai.com/v1/videos/omni-video', [
  1124. 'headers' => [
  1125. 'Authorization' => 'Bearer ' . $jwtToken,
  1126. 'Content-Type' => 'application/json',
  1127. ],
  1128. 'json' => $apiParams
  1129. ]);
  1130. $responseData = json_decode($response->getBody(), true);
  1131. dLog('generate')->info('可灵AI Omni API响应: ', $responseData);
  1132. logDB('generate', 'info', '可灵AI Omni API响应', ['id' => $task->id, 'response' => $responseData]);
  1133. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1134. // API返回错误
  1135. $errorMessage = $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error';
  1136. logDB('generate', 'error', '可灵AI Omni任务提交失败', ['id' => $task->id, 'error' => $errorMessage]);
  1137. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1138. 'error_message' => $errorMessage,
  1139. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  1140. ]);
  1141. } elseif (isset($responseData['data']['task_id'])) {
  1142. // 任务提交成功,更新状态为处理中
  1143. $updateData = [
  1144. 'task_id' => $responseData['data']['task_id'], // 使用API返回的任务ID
  1145. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  1146. ];
  1147. $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData);
  1148. } else {
  1149. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1150. 'error_message' => '未知的API响应格式',
  1151. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  1152. ]);
  1153. }
  1154. } catch (\Exception $e) {
  1155. // 记录错误
  1156. logDB('generate', 'error', '可灵AI Omni任务异常', ['id' => $task->id, 'error' => $e->getMessage()]);
  1157. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1158. 'error_message' => 'API请求失败: ' . $e->getMessage()
  1159. ]);
  1160. dLog('generate')->error('可灵AI Omni API请求异常: ' . $e->getMessage());
  1161. }
  1162. }
  1163. /**
  1164. * 查询可灵AI Omni任务状态
  1165. *
  1166. * @param MpGenerateVideoTask $task
  1167. * @return array
  1168. */
  1169. public function queryKelingOmniTaskStatus(MpGenerateVideoTask $task): array
  1170. {
  1171. $client = new Client(['verify'=>false,'timeout'=>120]);
  1172. try {
  1173. // 如果任务已完成,直接返回结果
  1174. if (in_array($task->status, [MpGenerateVideoTask::STATUS_SUCCESS, MpGenerateVideoTask::STATUS_FAILED])) {
  1175. return [
  1176. 'success' => true,
  1177. 'data' => [
  1178. 'task_id' => $task->task_id,
  1179. 'task_status' => $this->mapTaskStatusToKelingStatus($task->status),
  1180. 'result_url' => $task->result_url,
  1181. 'error_message' => $task->error_message,
  1182. 'created_at' => $task->created_at,
  1183. 'updated_at' => $task->updated_at
  1184. ]
  1185. ];
  1186. }
  1187. // 生成JWT token
  1188. $jwtToken = $this->generateKelingJwtToken();
  1189. // 构建查询URL
  1190. $url = 'https://api-beijing.klingai.com/v1/videos/omni-video/' . $task->task_id;
  1191. $response = $client->get($url, [
  1192. 'headers' => [
  1193. 'Authorization' => 'Bearer ' . $jwtToken,
  1194. 'Content-Type' => 'application/json',
  1195. ]
  1196. ]);
  1197. $responseData = json_decode($response->getBody(), true);
  1198. dLog('generate')->info('可灵AI Omni任务状态查询响应: ', $responseData);
  1199. logDB('generate', 'info', '可灵AI Omni任务状态查询', ['id' => $task->id, 'response' => $responseData]);
  1200. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1201. logDB('generate', 'error', '可灵AI Omni任务状态查询失败', ['id' => $task->id, 'error' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败']);
  1202. return [
  1203. 'status' => 'failed',
  1204. 'error_message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败',
  1205. 'result_json' => $responseData
  1206. ];
  1207. }
  1208. // 解析任务状态
  1209. $taskData = $responseData['data'] ?? [];
  1210. $taskStatus = $taskData['task_status'] ?? 'unknown';
  1211. $updateData = ['result_json' => $responseData];
  1212. if ($taskStatus === 'succeed') {
  1213. $updateData['status'] = 'success';
  1214. // 任务成功,处理结果
  1215. if (isset($taskData['task_result']['videos']) && !empty($taskData['task_result']['videos'])) {
  1216. $video = $taskData['task_result']['videos'][0];
  1217. $video_url = $video['url'];
  1218. // 下载并保存视频
  1219. $video_name = 'ai_generation_' . time() . '_' . uniqid();
  1220. $video_ext = getVideoExtFromUrl($video_url);
  1221. $video_name = $video_name . $video_ext;
  1222. $url = uploadStreamByTos('video', file_get_contents($video_url), $video_name);
  1223. $updateData['result_url'] = $url;
  1224. // 压缩视频
  1225. $compressed_video_url = compressVideo($url);
  1226. $updateData['compressed_url'] = $compressed_video_url ?: $url;
  1227. // 计算视频时长
  1228. $video_duration = 0;
  1229. if (isset($video['duration'])) {
  1230. $video_duration = (int)$video['duration'];
  1231. } elseif (isset($taskData['duration'])) {
  1232. $video_duration = (int)$taskData['duration'];
  1233. } elseif (isset($video['frames']) && isset($video['framespersecond'])) {
  1234. $frames = (int)$video['frames'];
  1235. $fps = (int)$video['framespersecond'];
  1236. if ($fps > 0) {
  1237. $video_duration = (int)floor($frames / $fps);
  1238. }
  1239. }
  1240. // 如果没有计算出时长,使用任务表中的设定值
  1241. if ($video_duration <= 0 && !empty($task->extra_params)) {
  1242. $extraParams = is_array($task->extra_params) ? $task->extra_params : json_decode($task->extra_params, true);
  1243. if (isset($extraParams['duration'])) {
  1244. $video_duration = (int)$extraParams['duration'];
  1245. }
  1246. }
  1247. // 只有当video_duration > 0时才添加到更新数据中
  1248. if ($video_duration > 0) {
  1249. $updateData['video_duration'] = $video_duration;
  1250. $updateData['video_time_point_start'] = 0;
  1251. $updateData['video_time_point_end'] = $video_duration;
  1252. }
  1253. }
  1254. return $updateData;
  1255. } elseif ($taskStatus === 'failed') {
  1256. $updateData = [
  1257. 'status' => 'failed',
  1258. 'error_message' => '任务执行失败',
  1259. 'result_json' => $responseData
  1260. ];
  1261. return $updateData;
  1262. }
  1263. // 其他状态(processing等)保持不变
  1264. return [
  1265. 'success' => true,
  1266. 'data' => [
  1267. 'task_id' => $task->task_id,
  1268. 'task_status' => $taskStatus,
  1269. 'result_url' => $task->result_url,
  1270. 'error_message' => $task->error_message,
  1271. 'created_at' => $task->created_at,
  1272. 'updated_at' => $task->updated_at,
  1273. 'api_response' => $responseData
  1274. ]
  1275. ];
  1276. } catch (\Exception $e) {
  1277. dLog('generate')->error('可灵AI Omni任务状态查询异常: ' . $e->getMessage());
  1278. return [
  1279. 'success' => false,
  1280. 'message' => '查询失败: ' . $e->getMessage()
  1281. ];
  1282. }
  1283. }
  1284. /**
  1285. * 映射任务状态到可灵AI状态格式
  1286. *
  1287. * @param string $taskStatus
  1288. * @return string
  1289. */
  1290. private function mapTaskStatusToKelingStatus(string $taskStatus): string
  1291. {
  1292. switch ($taskStatus) {
  1293. case MpGenerateVideoTask::STATUS_PENDING:
  1294. return 'submitted';
  1295. case MpGenerateVideoTask::STATUS_PROCESSING:
  1296. return 'processing';
  1297. case MpGenerateVideoTask::STATUS_SUCCESS:
  1298. return 'succeed';
  1299. case MpGenerateVideoTask::STATUS_FAILED:
  1300. return 'failed';
  1301. default:
  1302. return 'unknown';
  1303. }
  1304. }
  1305. /**
  1306. * 更新可灵AI Omni视频任务状态
  1307. *
  1308. * @param MpGenerateVideoTask $task
  1309. * @return void
  1310. */
  1311. private function updateKelingOmniTask($task): void
  1312. {
  1313. $statusInfo = $this->queryKelingOmniTaskStatus($task);
  1314. if (!isset($statusInfo['status'])) return;
  1315. if ($statusInfo['status'] === 'success') {
  1316. logDB('generate', 'info', '可灵AI Omni视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]);
  1317. // 同步调整分镜视频状态和结果(兼容全能模式)
  1318. $segment_id = $this->getSegmentIdFromTask($task);
  1319. if ($segment_id && isset($statusInfo['result_url'])) {
  1320. try {
  1321. DB::beginTransaction();
  1322. // 在事务中更新任务状态
  1323. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  1324. $now = date('Y-m-d H:i:s');
  1325. // 构建更新数据
  1326. $segmentUpdateData = [
  1327. 'origin_video_url' => $statusInfo['result_url'],
  1328. 'video_task_status' => '已完成',
  1329. 'current_type' => 2,
  1330. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  1331. 'updated_at' => $now
  1332. ];
  1333. // 使用查询任务时已压缩的视频URL,避免重复压缩
  1334. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  1335. // 只有当video_duration存在且大于0时才更新
  1336. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  1337. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  1338. $segmentUpdateData['video_time_point_start'] = 0;
  1339. // 获取video_time_point_end(兼容全能模式)
  1340. $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration']);
  1341. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  1342. }
  1343. // 更新分镜表(兼容act模式)
  1344. $this->updateSegmentTable($task, $segmentUpdateData);
  1345. // 添加视频生成对话记录
  1346. $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']);
  1347. DB::commit();
  1348. } catch (\Exception $e) {
  1349. DB::rollBack();
  1350. dLog('generate')->error('可灵视频任务处理失败', [
  1351. 'segment_id' => $segment_id,
  1352. 'task_id' => $task->id,
  1353. 'error' => $e->getMessage()
  1354. ]);
  1355. }
  1356. } else {
  1357. // 没有segment_id的情况下,直接更新任务状态
  1358. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  1359. }
  1360. } elseif ($statusInfo['status'] === 'failed') {
  1361. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1362. 'error_message' => $statusInfo['error_message'],
  1363. 'result_json' => $statusInfo['result_json'] ?? []
  1364. ]);
  1365. // 同步更新分镜表状态为失败(兼容全能模式)
  1366. $segment_id = $this->getSegmentIdFromTask($task);
  1367. if ($segment_id) {
  1368. $this->updateSegmentTable($task, [
  1369. 'video_task_status' => '失败',
  1370. 'updated_at' => date('Y-m-d H:i:s')
  1371. ]);
  1372. }
  1373. }
  1374. // 如果仍然是处理中状态,不做任何操作
  1375. // 处理:如果任务处理超过24小时,标记为失败
  1376. $processingTime = now()->diffInHours($task->created_at);
  1377. if ($processingTime > 24) {
  1378. logDB('generate', 'warning', '可灵AI Omni视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]);
  1379. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  1380. 'error_message' => '任务处理超时(超过24小时)'
  1381. ]);
  1382. // 同步更新分镜表状态为失败(兼容全能模式)
  1383. $segment_id = $this->getSegmentIdFromTask($task);
  1384. if ($segment_id) {
  1385. $this->updateSegmentTable($task, [
  1386. 'video_task_status' => '失败',
  1387. 'updated_at' => date('Y-m-d H:i:s')
  1388. ]);
  1389. }
  1390. }
  1391. }
  1392. /**
  1393. * 映射任务状态到前端显示状态
  1394. *
  1395. * @param string $taskStatus
  1396. * @return string
  1397. */
  1398. private function mapTaskStatus(string $taskStatus): string
  1399. {
  1400. switch ($taskStatus) {
  1401. case MpGenerateVideoTask::STATUS_PENDING:
  1402. return 'queued';
  1403. case MpGenerateVideoTask::STATUS_PROCESSING:
  1404. return 'running';
  1405. case MpGenerateVideoTask::STATUS_SUCCESS:
  1406. return 'succeeded';
  1407. case MpGenerateVideoTask::STATUS_FAILED:
  1408. return 'failed';
  1409. default:
  1410. return 'unknown';
  1411. }
  1412. }
  1413. // ==================== 主体管理相关方法 ====================
  1414. /**
  1415. * 创建主体
  1416. *
  1417. * @param array $params
  1418. * @return AIElement
  1419. */
  1420. public function createElement(array $params): AIElement
  1421. {
  1422. // 生成唯一的任务ID
  1423. $taskId = 'element_' . time() . '_' . uniqid();
  1424. // 创建主体记录
  1425. $element = AIElement::create([
  1426. 'task_id' => $taskId,
  1427. 'external_task_id' => $params['external_task_id'] ?? null,
  1428. 'element_name' => $params['element_name'],
  1429. 'element_description' => $params['element_description'],
  1430. 'reference_type' => $params['reference_type'],
  1431. 'element_image_list' => $params['element_image_list'] ?? null,
  1432. 'element_video_list' => $params['element_video_list'] ?? null,
  1433. 'element_voice_id' => $params['element_voice_id'] ?? null,
  1434. 'tag_list' => $params['tag_list'] ?? null,
  1435. 'callback_url' => $params['callback_url'] ?? null,
  1436. 'task_status' => AIElement::STATUS_SUBMITTED,
  1437. ]);
  1438. // 提交到可灵AI API
  1439. $this->submitElementToKelingApi($element);
  1440. return $element;
  1441. }
  1442. /**
  1443. * 提交主体创建任务到可灵AI API
  1444. *
  1445. * @param AIElement $element
  1446. * @return void
  1447. */
  1448. private function submitElementToKelingApi(AIElement $element): void
  1449. {
  1450. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1451. try {
  1452. // 生成JWT token
  1453. $jwtToken = $this->generateKelingJwtToken();
  1454. // 构建API请求参数
  1455. $apiParams = [
  1456. 'element_name' => $element->element_name,
  1457. 'element_description' => $element->element_description,
  1458. 'reference_type' => $element->reference_type,
  1459. ];
  1460. // 添加图片参考
  1461. if ($element->reference_type === AIElement::REFERENCE_TYPE_IMAGE && $element->element_image_list) {
  1462. $apiParams['element_image_list'] = $element->element_image_list;
  1463. }
  1464. // 添加视频参考
  1465. if ($element->reference_type === AIElement::REFERENCE_TYPE_VIDEO && $element->element_video_list) {
  1466. $apiParams['element_video_list'] = $element->element_video_list;
  1467. }
  1468. // 添加音色ID
  1469. if ($element->element_voice_id) {
  1470. $apiParams['element_voice_id'] = $element->element_voice_id;
  1471. }
  1472. // 添加标签
  1473. if ($element->tag_list) {
  1474. $apiParams['tag_list'] = $element->tag_list;
  1475. }
  1476. // 添加回调URL
  1477. if ($element->callback_url) {
  1478. $apiParams['callback_url'] = $element->callback_url;
  1479. }
  1480. // 添加外部任务ID
  1481. if ($element->external_task_id) {
  1482. $apiParams['external_task_id'] = $element->external_task_id;
  1483. }
  1484. dLog('generate')->info('可灵AI创建主体参数: ', $apiParams);
  1485. // 调用可灵AI创建主体API
  1486. $response = $client->post('https://api-beijing.klingai.com/v1/general/advanced-custom-elements', [
  1487. 'headers' => [
  1488. 'Authorization' => 'Bearer ' . $jwtToken,
  1489. 'Content-Type' => 'application/json',
  1490. ],
  1491. 'json' => $apiParams
  1492. ]);
  1493. $responseData = json_decode($response->getBody(), true);
  1494. dLog('generate')->info('可灵AI创建主体API响应: ', $responseData);
  1495. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1496. // API返回错误
  1497. $errorMessage = $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error';
  1498. $element->update([
  1499. 'task_status' => AIElement::STATUS_FAILED,
  1500. 'task_status_msg' => $errorMessage,
  1501. ]);
  1502. } elseif (isset($responseData['data']['task_id'])) {
  1503. // 任务提交成功,更新状态为处理中
  1504. $element->update([
  1505. 'task_id' => $responseData['data']['task_id'], // 使用API返回的任务ID
  1506. 'task_status' => AIElement::STATUS_PROCESSING,
  1507. 'created_at' => isset($responseData['data']['created_at']) ?
  1508. date('Y-m-d H:i:s', $responseData['data']['created_at'] / 1000) : now(),
  1509. 'updated_at' => isset($responseData['data']['updated_at']) ?
  1510. date('Y-m-d H:i:s', $responseData['data']['updated_at'] / 1000) : now(),
  1511. ]);
  1512. } else {
  1513. $element->update([
  1514. 'task_status' => AIElement::STATUS_FAILED,
  1515. 'task_status_msg' => '未知的API响应格式',
  1516. ]);
  1517. }
  1518. } catch (\Exception $e) {
  1519. // 记录错误
  1520. $element->update([
  1521. 'task_status' => AIElement::STATUS_FAILED,
  1522. 'task_status_msg' => 'API请求失败: ' . $e->getMessage(),
  1523. ]);
  1524. dLog('generate')->error('可灵AI创建主体API请求异常: ' . $e->getMessage());
  1525. }
  1526. }
  1527. /**
  1528. * 查询单个主体状态
  1529. *
  1530. * @param string $taskId
  1531. * @param string|null $externalTaskId
  1532. * @return array
  1533. */
  1534. public function queryElementStatus(string $taskId, string $externalTaskId = null): array
  1535. {
  1536. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1537. try {
  1538. // 生成JWT token
  1539. $jwtToken = $this->generateKelingJwtToken();
  1540. // 构建查询URL
  1541. $url = 'https://api-beijing.klingai.com/v1/general/advanced-custom-elements/' . $taskId;
  1542. $queryParams = [];
  1543. if ($externalTaskId) {
  1544. $queryParams['external_task_id'] = $externalTaskId;
  1545. }
  1546. if (!empty($queryParams)) {
  1547. $url .= '?' . http_build_query($queryParams);
  1548. }
  1549. $response = $client->get($url, [
  1550. 'headers' => [
  1551. 'Authorization' => 'Bearer ' . $jwtToken,
  1552. 'Content-Type' => 'application/json',
  1553. ]
  1554. ]);
  1555. $responseData = json_decode($response->getBody(), true);
  1556. dLog('generate')->info('可灵AI查询主体状态响应: ', $responseData);
  1557. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1558. return [
  1559. 'success' => false,
  1560. 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败',
  1561. 'data' => null
  1562. ];
  1563. }
  1564. // 更新本地数据库记录
  1565. if (isset($responseData['data'])) {
  1566. $this->updateLocalElementFromApi($responseData['data']);
  1567. }
  1568. return [
  1569. 'success' => true,
  1570. 'data' => $responseData['data']
  1571. ];
  1572. } catch (\Exception $e) {
  1573. dLog('generate')->error('可灵AI查询主体状态异常: ' . $e->getMessage());
  1574. return [
  1575. 'success' => false,
  1576. 'message' => '查询失败: ' . $e->getMessage(),
  1577. 'data' => null
  1578. ];
  1579. }
  1580. }
  1581. /**
  1582. * 根据API响应更新本地主体记录
  1583. *
  1584. * @param array $apiData
  1585. * @return void
  1586. */
  1587. private function updateLocalElementFromApi(array $apiData): void
  1588. {
  1589. $element = AIElement::where('task_id', $apiData['task_id'])->first();
  1590. if (!$element) {
  1591. return;
  1592. }
  1593. $updateData = [
  1594. 'task_status' => $apiData['task_status'],
  1595. 'task_status_msg' => $apiData['task_status_msg'] ?? null,
  1596. 'final_unit_deduction' => $apiData['final_unit_deduction'] ?? null,
  1597. 'updated_at' => isset($apiData['updated_at']) ?
  1598. date('Y-m-d H:i:s', $apiData['updated_at'] / 1000) : now(),
  1599. ];
  1600. // 如果任务成功,更新主体信息
  1601. if ($apiData['task_status'] === AIElement::STATUS_SUCCEED && isset($apiData['task_result']['elements'])) {
  1602. $elementData = $apiData['task_result']['elements'][0];
  1603. $updateData = array_merge($updateData, [
  1604. 'element_id' => $elementData['element_id'],
  1605. 'element_voice_info' => $elementData['element_voice_info'] ?? null,
  1606. 'owned_by' => $elementData['owned_by'] ?? 'custom',
  1607. 'status' => $elementData['status'] ?? AIElement::ELEMENT_STATUS_SUCCEED,
  1608. ]);
  1609. }
  1610. $element->update($updateData);
  1611. }
  1612. /**
  1613. * 查询自定义主体列表
  1614. *
  1615. * @param int $pageNum
  1616. * @param int $pageSize
  1617. * @return array
  1618. */
  1619. public function queryCustomElementsList(int $pageNum = 1, int $pageSize = 30): array
  1620. {
  1621. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1622. try {
  1623. // 生成JWT token
  1624. $jwtToken = $this->generateKelingJwtToken();
  1625. // 构建查询URL
  1626. $url = 'https://api-beijing.klingai.com/v1/general/advanced-custom-elements';
  1627. $queryParams = [
  1628. 'pageNum' => $pageNum,
  1629. 'pageSize' => $pageSize,
  1630. ];
  1631. $url .= '?' . http_build_query($queryParams);
  1632. $response = $client->get($url, [
  1633. 'headers' => [
  1634. 'Authorization' => 'Bearer ' . $jwtToken,
  1635. 'Content-Type' => 'application/json',
  1636. ]
  1637. ]);
  1638. $responseData = json_decode($response->getBody(), true);
  1639. dLog('generate')->info('可灵AI查询自定义主体列表响应: ', $responseData);
  1640. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1641. return [
  1642. 'success' => false,
  1643. 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败',
  1644. 'data' => []
  1645. ];
  1646. }
  1647. // 更新本地数据库记录
  1648. if (isset($responseData['data']) && is_array($responseData['data'])) {
  1649. foreach ($responseData['data'] as $elementData) {
  1650. $this->updateLocalElementFromApi($elementData);
  1651. }
  1652. }
  1653. return [
  1654. 'success' => true,
  1655. 'data' => $responseData['data'] ?? []
  1656. ];
  1657. } catch (\Exception $e) {
  1658. dLog('generate')->error('可灵AI查询自定义主体列表异常: ' . $e->getMessage());
  1659. return [
  1660. 'success' => false,
  1661. 'message' => '查询失败: ' . $e->getMessage(),
  1662. 'data' => []
  1663. ];
  1664. }
  1665. }
  1666. /**
  1667. * 查询官方主体列表
  1668. *
  1669. * @param int $pageNum
  1670. * @param int $pageSize
  1671. * @return array
  1672. */
  1673. public function queryOfficialElementsList(int $pageNum = 1, int $pageSize = 30): array
  1674. {
  1675. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1676. try {
  1677. // 生成JWT token
  1678. $jwtToken = $this->generateKelingJwtToken();
  1679. // 构建查询URL
  1680. $url = 'https://api-beijing.klingai.com/v1/general/advanced-presets-elements';
  1681. $queryParams = [
  1682. 'pageNum' => $pageNum,
  1683. 'pageSize' => $pageSize,
  1684. ];
  1685. $url .= '?' . http_build_query($queryParams);
  1686. $response = $client->get($url, [
  1687. 'headers' => [
  1688. 'Authorization' => 'Bearer ' . $jwtToken,
  1689. 'Content-Type' => 'application/json',
  1690. ]
  1691. ]);
  1692. $responseData = json_decode($response->getBody(), true);
  1693. dLog('generate')->info('可灵AI查询官方主体列表响应: ', $responseData);
  1694. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1695. return [
  1696. 'success' => false,
  1697. 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API查询失败',
  1698. 'data' => []
  1699. ];
  1700. }
  1701. return [
  1702. 'success' => true,
  1703. 'data' => $responseData['data'] ?? []
  1704. ];
  1705. } catch (\Exception $e) {
  1706. dLog('generate')->error('可灵AI查询官方主体列表异常: ' . $e->getMessage());
  1707. return [
  1708. 'success' => false,
  1709. 'message' => '查询失败: ' . $e->getMessage(),
  1710. 'data' => []
  1711. ];
  1712. }
  1713. }
  1714. /**
  1715. * 删除自定义主体
  1716. *
  1717. * @param string $elementId
  1718. * @return array
  1719. */
  1720. public function deleteElement(string $elementId): array
  1721. {
  1722. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1723. try {
  1724. // 生成JWT token
  1725. $jwtToken = $this->generateKelingJwtToken();
  1726. // 构建API请求参数
  1727. $apiParams = [
  1728. 'element_id' => $elementId,
  1729. ];
  1730. dLog('generate')->info('可灵AI删除主体参数: ', $apiParams);
  1731. // 调用可灵AI删除主体API
  1732. $response = $client->post('https://api-beijing.klingai.com/v1/general/delete-elements', [
  1733. 'headers' => [
  1734. 'Authorization' => 'Bearer ' . $jwtToken,
  1735. 'Content-Type' => 'application/json',
  1736. ],
  1737. 'json' => $apiParams
  1738. ]);
  1739. $responseData = json_decode($response->getBody(), true);
  1740. dLog('generate')->info('可灵AI删除主体API响应: ', $responseData);
  1741. if (isset($responseData['error']) || (isset($responseData['code']) && $responseData['code'] !== 0)) {
  1742. // API返回错误
  1743. return [
  1744. 'success' => false,
  1745. 'message' => $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error'
  1746. ];
  1747. } else {
  1748. // 删除成功,更新本地记录状态
  1749. AIElement::where('element_id', $elementId)->update([
  1750. 'status' => AIElement::ELEMENT_STATUS_DELETED,
  1751. 'updated_at' => now(),
  1752. ]);
  1753. return [
  1754. 'success' => true,
  1755. 'message' => '主体删除成功',
  1756. 'data' => $responseData['data'] ?? []
  1757. ];
  1758. }
  1759. } catch (\Exception $e) {
  1760. dLog('generate')->error('可灵AI删除主体API请求异常: ' . $e->getMessage());
  1761. return [
  1762. 'success' => false,
  1763. 'message' => 'API请求失败: ' . $e->getMessage()
  1764. ];
  1765. }
  1766. }
  1767. /**
  1768. * 获取本地主体列表(包含自定义和官方)
  1769. *
  1770. * @param array $filters
  1771. * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
  1772. */
  1773. public function getLocalElementsList(array $filters = [])
  1774. {
  1775. $query = AIElement::query();
  1776. // 筛选条件
  1777. if (isset($filters['owned_by'])) {
  1778. $query->where('owned_by', $filters['owned_by']);
  1779. }
  1780. if (isset($filters['reference_type'])) {
  1781. $query->where('reference_type', $filters['reference_type']);
  1782. }
  1783. if (isset($filters['status'])) {
  1784. $query->where('status', $filters['status']);
  1785. }
  1786. if (isset($filters['task_status'])) {
  1787. $query->where('task_status', $filters['task_status']);
  1788. }
  1789. // 搜索关键词
  1790. if (isset($filters['keyword']) && !empty($filters['keyword'])) {
  1791. $query->where(function ($q) use ($filters) {
  1792. $q->where('element_name', 'like', '%' . $filters['keyword'] . '%')
  1793. ->orWhere('element_description', 'like', '%' . $filters['keyword'] . '%');
  1794. });
  1795. }
  1796. return $query->orderBy('created_at', 'desc')
  1797. ->paginate($filters['page_size'] ?? 20);
  1798. }
  1799. /**
  1800. * 根据task获取segment_id或act_id(兼容act模式)
  1801. * 分镜模式:返回segment_id用于查询
  1802. * 全能模式:返回act_id用于查询(因为segment_id为空)
  1803. */
  1804. private function getSegmentIdFromTask($task)
  1805. {
  1806. $segment_id = getProp($task, 'alias_segment_id');
  1807. // 如果有segment_id,直接返回(分镜模式)
  1808. if (!empty($segment_id)) {
  1809. return $segment_id;
  1810. }
  1811. // 如果有alias_act_id,返回act_id(全能模式,segment_id为空)
  1812. $act_id = getProp($task, 'alias_act_id');
  1813. if (!empty($act_id)) {
  1814. return $act_id; // 直接返回act_id,不需要查segment_id
  1815. }
  1816. return null;
  1817. }
  1818. /**
  1819. * 更新分镜表(兼容act模式)
  1820. * 分镜模式:使用segment_id字段更新
  1821. * 全能模式:使用id字段更新
  1822. */
  1823. private function updateSegmentTable($task, $updateData)
  1824. {
  1825. $segment_id = getProp($task, 'alias_segment_id');
  1826. $act_id = getProp($task, 'alias_act_id');
  1827. if (!empty($segment_id)) {
  1828. // 分镜模式:使用segment_id更新
  1829. return DB::table('mp_episode_segments')->where('segment_id', $segment_id)->update($updateData);
  1830. } elseif (!empty($act_id)) {
  1831. // 全能模式(全能模式):使用id更新
  1832. return DB::table('mp_episode_segments')->where('id', $act_id)->update($updateData);
  1833. }
  1834. return false;
  1835. }
  1836. /**
  1837. * 获取audio_duration(兼容act模式)
  1838. * 分镜模式:使用segment_id查询
  1839. * 全能模式:使用id查询
  1840. * @param array $task 任务数据
  1841. * @param string|int $segmentIdOrActId segment_id或act_id
  1842. * @param float|null $video_duration 视频时长(如果传入,全能模式直接返回此值)
  1843. * @return float
  1844. */
  1845. private function getAudioDuration($task, $segmentIdOrActId, $video_duration = null)
  1846. {
  1847. $segment_id = getProp($task, 'alias_segment_id');
  1848. $act_id = getProp($task, 'alias_act_id');
  1849. if (!empty($segment_id)) {
  1850. // 分镜模式:使用segment_id查询
  1851. return DB::table('mp_episode_segments')->where('segment_id', $segmentIdOrActId)->value('audio_duration');
  1852. } elseif (!empty($act_id)) {
  1853. // 全能模式:如果传入了video_duration直接返回,否则查询audio_duration
  1854. if ($video_duration !== null && $video_duration > 0) {
  1855. return $video_duration;
  1856. }
  1857. return DB::table('mp_episode_segments')->where('id', $segmentIdOrActId)->value('audio_duration');
  1858. }
  1859. return 0;
  1860. }
  1861. /**
  1862. * 添加视频生成对话记录
  1863. * @param array $task 任务数据
  1864. * @param string|int $segmentIdOrActId segment_id(分镜模式)或 act_id(全能模式)
  1865. * @param string $videoUrl 视频URL
  1866. * @return void
  1867. */
  1868. private function addVideoGenerationRecords($task, $segmentIdOrActId, $videoUrl)
  1869. {
  1870. try {
  1871. $segment_id = getProp($task, 'alias_segment_id');
  1872. $act_id = getProp($task, 'alias_act_id');
  1873. $reference_images = getProp($task, 'ref_image_url');
  1874. if (!$reference_images) $reference_images = json_encode([], 256);
  1875. if (!empty($segment_id)) {
  1876. // 分镜模式:使用segment_id查询
  1877. $segment = DB::table('mp_episode_segments as a')->leftJoin('mp_animes as b', 'a.anime_id', 'b.id')
  1878. ->where('a.segment_id', $segmentIdOrActId)
  1879. ->select('b.user_id as uid', 'a.anime_id', 'a.episode_number', 'a.tail_frame', 'a.segment_id')
  1880. ->first();
  1881. } elseif (!empty($act_id)) {
  1882. // 全能模式:使用id查询
  1883. $segment = DB::table('mp_episode_segments as a')->leftJoin('mp_animes as b', 'a.anime_id', 'b.id')
  1884. ->where('a.id', $segmentIdOrActId)
  1885. ->select('b.user_id as uid', 'a.anime_id', 'a.episode_number', 'a.act_content', 'a.act_show_content', 'a.id as act_id')
  1886. ->first();
  1887. } else {
  1888. dLog('generate')->warning('无法确定查询模式,任务缺少segment_id和act_id', ['segment_id_or_act_id' => $segmentIdOrActId]);
  1889. return;
  1890. }
  1891. if (!$segment) {
  1892. dLog('generate')->warning('分镜信息不存在,无法添加对话记录', ['segment_id_or_act_id' => $segmentIdOrActId]);
  1893. return;
  1894. }
  1895. $now = date('Y-m-d H:i:s');
  1896. // 根据模式确定保存的字段和值
  1897. if (!empty($segment_id)) {
  1898. // 分镜模式:保存segment_id字段
  1899. $recordField = 'segment_id';
  1900. $recordValue = $segmentIdOrActId;
  1901. $userContent = '图片转视频';
  1902. $assistantContent = $segment->tail_frame ?: '';
  1903. } else {
  1904. // 全能模式:保存act_id字段
  1905. $recordField = 'act_id';
  1906. $recordValue = $segmentIdOrActId;
  1907. $userContent = '片段转视频';
  1908. $assistantContent = $segment->act_show_content ? $segment->act_show_content : $segment->act_content;
  1909. if (!$assistantContent) $assistantContent = '';
  1910. }
  1911. // 保存对话记录
  1912. $records = [
  1913. [
  1914. 'uid' => $segment->uid,
  1915. 'anime_id' => $segment->anime_id,
  1916. 'sequence' => $segment->episode_number,
  1917. 'role' => 'user',
  1918. 'content' => $userContent,
  1919. $recordField => $recordValue,
  1920. 'video_url' => '',
  1921. 'reference_images' => json_encode([], 256),
  1922. 'created_at' => $now,
  1923. 'updated_at' => $now
  1924. ],
  1925. [
  1926. 'uid' => $segment->uid,
  1927. 'anime_id' => $segment->anime_id,
  1928. 'sequence' => $segment->episode_number,
  1929. 'role' => 'assistant',
  1930. 'content' => $assistantContent,
  1931. $recordField => $recordValue,
  1932. 'video_url' => $videoUrl,
  1933. 'reference_images' => $reference_images,
  1934. 'created_at' => $now,
  1935. 'updated_at' => $now
  1936. ]
  1937. ];
  1938. DB::table('mp_anime_records')->insert($records);
  1939. dLog('generate')->info('视频生成对话记录添加成功', [
  1940. 'segment_id_or_act_id' => $segmentIdOrActId,
  1941. 'video_url' => $videoUrl,
  1942. 'uid' => $segment->uid,
  1943. 'anime_id' => $segment->anime_id
  1944. ]);
  1945. } catch (\Exception $e) {
  1946. dLog('generate')->error('添加视频生成对话记录失败: ' . $e->getMessage(), [
  1947. 'segment_id' => $segmentIdOrActId,
  1948. 'video_url' => $videoUrl,
  1949. 'error' => $e->getMessage()
  1950. ]);
  1951. }
  1952. }
  1953. /**
  1954. * 创建统一API视频生成任务(智帧20等新模型)
  1955. *
  1956. * @param array $params
  1957. * @return MpGenerateVideoTask
  1958. */
  1959. public function createUnifiedApiTask(array $params): MpGenerateVideoTask
  1960. {
  1961. // 生成唯一的任务ID
  1962. $taskId = 'unified_' . time() . '_' . uniqid();
  1963. // 创建任务记录
  1964. $task = MpGenerateVideoTask::create([
  1965. 'task_id' => $taskId,
  1966. 'alias_segment_id' => $params['alias_segment_id'] ?? '',
  1967. 'alias_act_id' => $params['alias_act_id'] ?? '',
  1968. 'prompt' => $params['prompt'] ?? '',
  1969. 'first_frame_url' => $params['first_frame_url'] ?? '',
  1970. 'tail_frame_url' => $params['last_frame_url'] ?? '',
  1971. 'ref_image_url' => $params['ref_image_url'] ?? json_encode([], 256),
  1972. 'seed' => $params['seed'] ?? -1,
  1973. 'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : null,
  1974. 'video_resolution' => $params['video_resolution'] ?? '720p',
  1975. 'status' => MpGenerateVideoTask::STATUS_PENDING,
  1976. 'api_type' => 'zzengine',
  1977. 'extra_params' => [
  1978. 'task_type' => 'video',
  1979. 'model_code' => $params['model_code'] ?? 'zhizhen-20-mini',
  1980. 'callback_url' => $params['callback_url'] ?? '',
  1981. 'video_duration' => $params['video_duration'] ?? 5,
  1982. 'video_ratio' => $params['video_ratio'] ?? '9:16',
  1983. 'parameters' => $params['parameters'] ?? [],
  1984. ]
  1985. ]);
  1986. // 提交任务到统一API
  1987. $this->submitUnifiedApiTaskToApi($task);
  1988. return $task;
  1989. }
  1990. /**
  1991. * 提交任务到统一API
  1992. *
  1993. * @param MpGenerateVideoTask $task
  1994. * @return void
  1995. */
  1996. private function submitUnifiedApiTaskToApi(MpGenerateVideoTask $task): void
  1997. {
  1998. $client = new Client(['verify' => false, 'timeout' => 1200]);
  1999. try {
  2000. // 验证环境变量配置
  2001. $apiKey = env('ZHIZHEN_AI_SK');
  2002. $apiUrl = 'https://model-relay.zzengine.net/api/v1/task/submit';
  2003. if (empty($apiKey)) {
  2004. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2005. 'error_message' => '统一视频API密钥未配置,请检查环境变量UNIFIED_VIDEO_API_KEY'
  2006. ]);
  2007. return;
  2008. }
  2009. $extraParams = $task->extra_params;
  2010. // 构建统一API请求参数
  2011. $apiParams = [
  2012. 'task_type' => 'video',
  2013. 'model_code' => $extraParams['model_code'] ?? 'zhizhen-20-mini',
  2014. 'prompt' => $task->prompt,
  2015. 'video_duration' => $extraParams['video_duration'] ?? 5,
  2016. 'video_resolution' => $task->video_resolution,
  2017. 'video_ratio' => $extraParams['video_ratio'] ?? '9:16',
  2018. ];
  2019. // 添加回调地址
  2020. if (!empty($extraParams['callback_url'])) {
  2021. $apiParams['callback_url'] = $extraParams['callback_url'];
  2022. }
  2023. // 添加parameters参数
  2024. if (!empty($extraParams['parameters'])) {
  2025. $apiParams['parameters'] = $extraParams['parameters'];
  2026. }
  2027. dLog('generate')->info('统一API视频生成参数: ', $apiParams);
  2028. logDB('generate', 'info', '统一API视频生成任务提交', ['id' => $task->id, 'params' => $apiParams]);
  2029. // 调用统一API
  2030. $response = $client->post($apiUrl, [
  2031. 'headers' => [
  2032. 'X-Api-Key' => $apiKey,
  2033. 'Content-Type' => 'application/json',
  2034. ],
  2035. 'json' => $apiParams
  2036. ]);
  2037. $responseData = json_decode($response->getBody(), true);
  2038. dLog('generate')->info('统一API响应: ', $responseData);
  2039. logDB('generate', 'info', '统一API视频生成API响应', ['id' => $task->id, 'response' => $responseData]);
  2040. if (isset($responseData['error']) || !isset($responseData['data']['task_uid'])) {
  2041. // API返回错误
  2042. $errorMsg = $responseData['error']['message'] ?? $responseData['message'] ?? 'API Error';
  2043. logDB('generate', 'error', '统一API视频生成任务提交失败', ['id' => $task->id, 'error' => $errorMsg]);
  2044. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2045. 'error_message' => $errorMsg,
  2046. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  2047. ]);
  2048. } elseif (isset($responseData['data']['task_uid'])) {
  2049. // 任务提交成功,更新状态为处理中
  2050. $updateData = [
  2051. 'task_id' => $responseData['data']['task_uid'], // 使用API返回的task_uid
  2052. 'extra_params' => array_merge($task->extra_params, [
  2053. 'task_uid' => $responseData['data']['task_uid'],
  2054. 'estimated_amount' => $responseData['data']['estimated_amount'] ?? 0,
  2055. 'account_uid' => $responseData['data']['account_uid'] ?? '',
  2056. 'key_uid' => $responseData['data']['key_uid'] ?? '',
  2057. 'api_response' => $responseData
  2058. ])
  2059. ];
  2060. $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData);
  2061. } else {
  2062. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2063. 'error_message' => '未知的API响应格式',
  2064. 'extra_params' => array_merge($task->extra_params, ['api_response' => $responseData])
  2065. ]);
  2066. }
  2067. } catch (\GuzzleHttp\Exception\RequestException $e) {
  2068. $errorMsg = 'API请求失败';
  2069. if ($e->hasResponse()) {
  2070. $errorResponse = json_decode($e->getResponse()->getBody()->getContents(), true);
  2071. $errorMsg .= ': ' . ($errorResponse['message'] ?? $e->getMessage());
  2072. logDB('generate', 'error', '统一API请求失败', ['id' => $task->id, 'error' => $errorResponse]);
  2073. } else {
  2074. $errorMsg .= ': ' . $e->getMessage();
  2075. logDB('generate', 'error', '统一API请求失败', ['id' => $task->id, 'error' => $e->getMessage()]);
  2076. }
  2077. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2078. 'error_message' => $errorMsg
  2079. ]);
  2080. } catch (\Exception $e) {
  2081. logDB('generate', 'error', '统一API任务提交异常', ['id' => $task->id, 'error' => $e->getMessage()]);
  2082. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2083. 'error_message' => 'API请求失败: ' . $e->getMessage()
  2084. ]);
  2085. }
  2086. }
  2087. /**
  2088. * 查询统一API任务状态
  2089. *
  2090. * @param MpGenerateVideoTask $task
  2091. * @return array
  2092. */
  2093. public function queryUnifiedApiTaskStatus(MpGenerateVideoTask $task): array
  2094. {
  2095. $client = new Client(['verify' => false, 'timeout' => 1200]);
  2096. try {
  2097. // 获取API任务UID
  2098. $extraParams = $task->extra_params;
  2099. $taskUid = $extraParams['task_uid'] ?? $task->task_id;
  2100. if (!$taskUid) {
  2101. return [
  2102. 'status' => 'failed',
  2103. 'error_message' => 'API任务UID不存在',
  2104. 'result_url' => null
  2105. ];
  2106. }
  2107. // 验证环境变量配置
  2108. $apiKey = env('ZHIZHEN_AI_SK');
  2109. $apiUrl = 'https://model-relay.zzengine.net';
  2110. $queryUrl = rtrim($apiUrl, '/') . '/api/v1/task/query';
  2111. if (empty($apiKey)) {
  2112. return [
  2113. 'status' => 'failed',
  2114. 'error_message' => '统一视频API密钥未配置',
  2115. 'result_url' => null
  2116. ];
  2117. }
  2118. dLog('generate')->info('查询统一API任务状态: ', ['task_uid' => $taskUid]);
  2119. // 调用统一API查询接口(GET请求)
  2120. $response = $client->get($queryUrl, [
  2121. 'headers' => [
  2122. 'X-Api-Key' => $apiKey,
  2123. ],
  2124. 'query' => [
  2125. 'task_uid' => $taskUid
  2126. ]
  2127. ]);
  2128. $responseData = json_decode($response->getBody(), true);
  2129. dLog('generate')->info('统一API任务状态查询响应: ', $responseData);
  2130. // 检查响应code
  2131. if (!isset($responseData['code']) || $responseData['code'] !== 0) {
  2132. $errorMsg = $responseData['msg'] ?? $responseData['message'] ?? 'API查询失败';
  2133. logDB('generate', 'error', '统一API任务状态查询失败', ['id' => $task->id, 'error' => $errorMsg]);
  2134. return [
  2135. 'status' => 'failed',
  2136. 'error_message' => $errorMsg,
  2137. 'result_url' => null,
  2138. 'result_json' => $responseData ?? []
  2139. ];
  2140. }
  2141. // 解析API响应
  2142. $taskData = $responseData['data']['task'] ?? [];
  2143. if (empty($taskData)) {
  2144. return [
  2145. 'status' => 'failed',
  2146. 'error_message' => '任务数据为空',
  2147. 'result_url' => null,
  2148. 'result_json' => $responseData ?? []
  2149. ];
  2150. }
  2151. // 任务状态映射:0=排队中,1=执行中,2=成功,3=失败
  2152. $taskStatus = $taskData['status'] ?? 3;
  2153. $failReason = $taskData['fail_reason'] ?? '';
  2154. $returnData = [
  2155. 'status' => 'processing',
  2156. 'error_message' => null,
  2157. 'result_url' => null,
  2158. 'result_json' => $responseData ?? []
  2159. ];
  2160. // 根据status判断任务状态
  2161. if ($taskStatus === 2) {
  2162. // 任务成功
  2163. $detail = $taskData['detail'] ?? [];
  2164. $result = $detail['result'] ?? [];
  2165. // 获取视频URL
  2166. $videoUrl = $result['video'] ?? null;
  2167. if (!$videoUrl && isset($result['videos']) && is_array($result['videos']) && count($result['videos']) > 0) {
  2168. $videoUrl = $result['videos'][0];
  2169. }
  2170. if ($videoUrl) {
  2171. // 保存视频到TOS
  2172. try {
  2173. $video_name = 'ai_generation_' . time() . '_' . uniqid();
  2174. $video_ext = getVideoExtFromUrl($videoUrl);
  2175. $video_name = $video_name . $video_ext;
  2176. $url = uploadStreamByTos('video', file_get_contents($videoUrl), $video_name);
  2177. $returnData['result_url'] = $url;
  2178. // 压缩视频
  2179. $compressed_video_url = compressVideo($url);
  2180. $returnData['compressed_url'] = $compressed_video_url ?: $url;
  2181. } catch (\Exception $e) {
  2182. dLog('generate')->error('保存视频到TOS失败', ['error' => $e->getMessage()]);
  2183. $returnData['result_url'] = $videoUrl; // 如果保存失败,使用原URL
  2184. }
  2185. } else {
  2186. $returnData['status'] = 'failed';
  2187. $returnData['error_message'] = '视频URL不存在';
  2188. return $returnData;
  2189. }
  2190. // 获取视频时长
  2191. $video_duration = $detail['duration'] ?? 0;
  2192. if ($video_duration > 0) {
  2193. $returnData['video_duration'] = (int)$video_duration;
  2194. $returnData['video_time_point_start'] = 0;
  2195. $returnData['video_time_point_end'] = (int)$video_duration;
  2196. }
  2197. // 获取尾帧
  2198. if (isset($result['last_frame_url'])) {
  2199. $returnData['last_frame_url'] = $result['last_frame_url'];
  2200. }
  2201. $returnData['status'] = 'success';
  2202. } elseif ($taskStatus === 3) {
  2203. // 任务失败
  2204. $returnData['status'] = 'failed';
  2205. $returnData['error_message'] = $failReason ?: '任务执行失败';
  2206. } elseif (in_array($taskStatus, [0, 1])) {
  2207. // 0=排队中,1=执行中
  2208. $returnData['status'] = 'processing';
  2209. } else {
  2210. $returnData['status'] = 'failed';
  2211. $returnData['error_message'] = '未知的任务状态: ' . $taskStatus;
  2212. }
  2213. return $returnData;
  2214. } catch (\GuzzleHttp\Exception\RequestException $e) {
  2215. $errorMsg = 'API查询失败';
  2216. if ($e->hasResponse()) {
  2217. $errorResponse = json_decode($e->getResponse()->getBody()->getContents(), true);
  2218. $errorMsg .= ': ' . ($errorResponse['msg'] ?? $errorResponse['message'] ?? $e->getMessage());
  2219. } else {
  2220. $errorMsg .= ': ' . $e->getMessage();
  2221. }
  2222. return [
  2223. 'status' => 'processing',
  2224. 'error_message' => $errorMsg,
  2225. 'result_url' => null
  2226. ];
  2227. } catch (\Exception $e) {
  2228. return [
  2229. 'status' => 'processing',
  2230. 'error_message' => $e->getMessage(),
  2231. 'result_url' => null
  2232. ];
  2233. }
  2234. }
  2235. /**
  2236. * 更新统一API视频任务状态
  2237. *
  2238. * @param MpGenerateVideoTask $task
  2239. * @return void
  2240. */
  2241. private function updateUnifiedApiTask($task): void
  2242. {
  2243. $statusInfo = $this->queryUnifiedApiTaskStatus($task);
  2244. if (!isset($statusInfo['status'])) return;
  2245. if ($statusInfo['status'] === 'success') {
  2246. logDB('generate', 'info', '统一API视频生成任务成功', ['id' => $task->id, 'result_url' => $statusInfo['result_url']]);
  2247. // 同步调整分镜视频状态和结果(兼容全能模式)
  2248. $segment_id = $this->getSegmentIdFromTask($task);
  2249. if ($segment_id && isset($statusInfo['result_url'])) {
  2250. try {
  2251. DB::beginTransaction();
  2252. // 在事务中更新任务状态
  2253. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2254. $now = date('Y-m-d H:i:s');
  2255. // 构建更新数据
  2256. $segmentUpdateData = [
  2257. 'origin_video_url' => $statusInfo['result_url'],
  2258. 'video_task_status' => '已完成',
  2259. 'current_type' => 2,
  2260. 'last_frame_url' => $statusInfo['last_frame_url'] ?? '',
  2261. 'updated_at' => $now
  2262. ];
  2263. // 使用查询任务时已压缩的视频URL,避免重复压缩
  2264. $segmentUpdateData['video_url'] = $statusInfo['compressed_url'] ?? $statusInfo['result_url'];
  2265. // 只有当video_duration存在且大于0时才更新
  2266. if (isset($statusInfo['video_duration']) && $statusInfo['video_duration'] > 0) {
  2267. $segmentUpdateData['video_duration'] = $statusInfo['video_duration'];
  2268. $segmentUpdateData['video_time_point_start'] = 0;
  2269. // 获取audio_duration(兼容全能模式)
  2270. $audio_duration = $this->getAudioDuration($task, $segment_id, $statusInfo['video_duration'] ?? null);
  2271. $segmentUpdateData['video_time_point_end'] = $audio_duration ?? 0;
  2272. }
  2273. // 更新分镜表(兼容act模式)
  2274. $this->updateSegmentTable($task, $segmentUpdateData);
  2275. // 添加视频生成对话记录
  2276. $this->addVideoGenerationRecords($task, $segment_id, $statusInfo['result_url']);
  2277. // 如果是全能模式的首帧图,则提取视频第一帧并更新anime表
  2278. $act_id = getProp($task, 'alias_act_id');
  2279. if (!empty($act_id) && Redis::sismember('anime_act_first_frame_urls', $act_id)) {
  2280. $anime_id = DB::table('mp_episode_segments')->where('id', $act_id)->value('anime_id');
  2281. if ($anime_id) {
  2282. // 提取视频第一帧
  2283. $videoUrl = $segmentUpdateData['video_url'];
  2284. $firstFrameUrl = getVideoFirstFrame($videoUrl, 'anime_first_frames', 'tos');
  2285. if ($firstFrameUrl) {
  2286. // 更新anime表
  2287. DB::table('mp_animes')->where('id', $anime_id)->update([
  2288. 'first_frame_url' => $firstFrameUrl,
  2289. 'updated_at' => date('Y-m-d H:i:s')
  2290. ]);
  2291. dLog('video_frame')->info('全能模式首帧图更新成功', [
  2292. 'act_id' => $act_id,
  2293. 'anime_id' => $anime_id,
  2294. 'first_frame_url' => $firstFrameUrl
  2295. ]);
  2296. }
  2297. }
  2298. }
  2299. DB::commit();
  2300. // 从Redis中移除
  2301. Redis::srem('anime_act_first_frame_urls', $act_id);
  2302. } catch (\Exception $e) {
  2303. DB::rollBack();
  2304. dLog('generate')->error('统一API视频任务处理失败', [
  2305. 'segment_id' => $segment_id,
  2306. 'task_id' => $task->id,
  2307. 'error' => $e->getMessage()
  2308. ]);
  2309. }
  2310. } else {
  2311. // 没有segment_id的情况下,直接更新任务状态
  2312. $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
  2313. }
  2314. } elseif ($statusInfo['status'] === 'failed') {
  2315. logDB('generate', 'error', '统一API视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]);
  2316. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2317. 'error_message' => $statusInfo['error_message'],
  2318. 'result_json' => $statusInfo['result_json'] ?? []
  2319. ]);
  2320. // 同步更新分镜表状态为失败(兼容全能模式)
  2321. $segment_id = $this->getSegmentIdFromTask($task);
  2322. if ($segment_id) {
  2323. $this->updateSegmentTable($task, [
  2324. 'video_task_status' => '失败',
  2325. 'updated_at' => date('Y-m-d H:i:s')
  2326. ]);
  2327. }
  2328. }
  2329. // 如果仍然是处理中状态,不做任何操作
  2330. // 处理:如果任务处理超过48小时,标记为失败
  2331. $processingTime = now()->diffInHours($task->created_at);
  2332. if ($processingTime > 48) {
  2333. logDB('generate', 'warning', '统一API视频生成任务超时', ['id' => $task->id, 'processing_hours' => $processingTime]);
  2334. $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
  2335. 'error_message' => '任务处理超时(超过48小时)'
  2336. ]);
  2337. // 同步更新分镜表状态为失败(兼容全能模式)
  2338. $segment_id = $this->getSegmentIdFromTask($task);
  2339. if ($segment_id) {
  2340. $this->updateSegmentTable($task, [
  2341. 'video_task_status' => '失败',
  2342. 'updated_at' => date('Y-m-d H:i:s')
  2343. ]);
  2344. }
  2345. }
  2346. }
  2347. }