VideoGenerationController.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758
  1. <?php
  2. namespace App\Http\Controllers\AIGeneration;
  3. use Illuminate\Routing\Controller as BaseController;
  4. use App\Libs\Utils;
  5. use App\Services\AIGeneration\AIVideoGenerationService;
  6. use App\Transformer\AIGeneration\AIGenerationTransformer;
  7. use Illuminate\Http\Request;
  8. use Illuminate\Http\JsonResponse;
  9. use Illuminate\Support\Facades\Validator;
  10. use App\Libs\ApiResponse;
  11. class VideoGenerationController extends BaseController
  12. {
  13. use ApiResponse;
  14. private $aiVideoGenerationService;
  15. public function __construct(
  16. AIVideoGenerationService $aiVideoGenerationService
  17. ) {
  18. $this->aiVideoGenerationService = $aiVideoGenerationService;
  19. }
  20. /**
  21. * 创建视频生成任务(即梦AI)
  22. *
  23. * @param Request $request
  24. * @return JsonResponse
  25. */
  26. public function createJimengTask(Request $request): JsonResponse
  27. {
  28. $data = $request->all();
  29. // 首先验证基本参数
  30. $baseRules = [
  31. 'prompt' => 'required|string|max:1000',
  32. 'seed' => 'required|integer',
  33. 'video_duration' => 'required|in:5,10',
  34. 'video_resolution' => 'required|in:720P,1080P'
  35. ];
  36. $baseMessages = [
  37. 'prompt.required' => '提示词不能为空',
  38. 'prompt.max' => '提示词不能超过1000个字符',
  39. 'seed.required' => '随机种子不能为空',
  40. 'seed.integer' => '随机种子必须是整数',
  41. 'video_duration.required' => '请选择视频时长',
  42. 'video_duration.in' => '视频时长只能是5s或10s',
  43. 'video_resolution.required' => '请选择视频分辨率',
  44. 'video_resolution.in' => '视频分辨率只能是720P或1080P',
  45. ];
  46. // 动态构建验证规则
  47. $validationRules = $baseRules;
  48. $validationMessages = $baseMessages;
  49. // 检查是否有首帧URL
  50. if (isset($data['first_frame_url'])) {
  51. $validationRules['first_frame_url'] = 'url';
  52. $validationMessages['first_frame_url.url'] = '首帧图片URL格式不正确';
  53. }
  54. // 检查是否有尾帧URL
  55. if (isset($data['tail_frame_url'])) {
  56. $validationRules['tail_frame_url'] = 'url';
  57. $validationMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确';
  58. }
  59. // 检查是否有首帧文件
  60. if ($request->hasFile('first_frame_file')) {
  61. $validationRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  62. $validationMessages = array_merge($validationMessages, [
  63. 'first_frame_file.image' => '首帧必须是图片文件',
  64. 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式',
  65. 'first_frame_file.max' => '首帧图片大小不能超过4.7MB',
  66. ]);
  67. }
  68. // 检查是否有尾帧文件
  69. if ($request->hasFile('tail_frame_file')) {
  70. $validationRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  71. $validationMessages = array_merge($validationMessages, [
  72. 'tail_frame_file.image' => '尾帧必须是图片文件',
  73. 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式',
  74. 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB',
  75. ]);
  76. }
  77. if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) {
  78. Utils::throwError('1002:提供尾帧URL时必须提供首帧URL');
  79. }
  80. if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) {
  81. Utils::throwError('1002:提供尾帧文件时必须提供首帧文件');
  82. }
  83. $validator = Validator::make($data, $validationRules, $validationMessages);
  84. if ($validator->fails()) {
  85. Utils::throwError('1002:'.$validator->errors()->first());
  86. }
  87. $firstFrame = null;
  88. $tailFrame = null;
  89. // 处理首帧图片(文件上传方式)
  90. if ($request->hasFile('first_frame_file')) {
  91. $firstFrameFile = $request->file('first_frame_file');
  92. $firstFrame = $this->processFrameFile($firstFrameFile, 'first');
  93. $data['first_frame_url'] = $firstFrame['url'];
  94. }
  95. // 处理首帧图片(URL方式)
  96. elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) {
  97. $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true);
  98. if (!$firstFrame) {
  99. Utils::throwError('1003:首帧图片获取失败');
  100. }
  101. }
  102. // 处理尾帧图片(文件上传方式)
  103. if ($request->hasFile('tail_frame_file')) {
  104. $tailFrameFile = $request->file('tail_frame_file');
  105. $tailFrame = $this->processFrameFile($tailFrameFile, 'tail');
  106. $data['tail_frame_url'] = $tailFrame['url'];
  107. }
  108. // 处理尾帧图片(URL方式)
  109. elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) {
  110. $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true);
  111. if (!$tailFrame) {
  112. Utils::throwError('1003:尾帧图片获取失败');
  113. }
  114. }
  115. // 验证首帧图片规格(如果存在)
  116. if ($firstFrame) {
  117. $this->validateFrameSpecs($firstFrame, '首帧');
  118. }
  119. // 验证尾帧图片规格(如果存在)
  120. if ($tailFrame) {
  121. $this->validateFrameSpecs($tailFrame, '尾帧');
  122. }
  123. // 如果首尾帧都存在,验证比例是否相同
  124. if ($firstFrame && $tailFrame) {
  125. $firstRatio = round($firstFrame['width'] / $firstFrame['height'], 2);
  126. $tailRatio = round($tailFrame['width'] / $tailFrame['height'], 2);
  127. if ($firstRatio != $tailRatio) {
  128. Utils::throwError('1003:尾帧图片与首帧图片比例必须相同');
  129. }
  130. }
  131. $task = $this->aiVideoGenerationService->createJimengTask($data);
  132. return $this->success(['task_id' => $task->task_id, 'status' => $task->status]);
  133. }
  134. /**
  135. * 查询视频任务状态
  136. *
  137. * @param string $taskId
  138. * @return JsonResponse
  139. */
  140. public function taskStatus(string $taskId): JsonResponse
  141. {
  142. $task = \App\Models\MpGenerateVideoTask::where('task_id', $taskId)->first();
  143. if (!$task) {
  144. return response()->json([
  145. 'success' => false,
  146. 'message' => '任务不存在'
  147. ], 404);
  148. }
  149. return response()->json([
  150. 'success' => true,
  151. 'data' => [
  152. 'task_id' => $task->task_id,
  153. 'status' => $task->status,
  154. 'result_url' => $task->result_url,
  155. 'error_message' => $task->error_message,
  156. 'created_at' => $task->created_at,
  157. 'started_at' => $task->started_at,
  158. 'completed_at' => $task->completed_at
  159. ]
  160. ]);
  161. }
  162. /**
  163. * 处理帧图片文件
  164. *
  165. * @param \Illuminate\Http\UploadedFile $file
  166. * @param string $frameType 帧类型(first/tail)
  167. * @return array
  168. */
  169. private function processFrameFile($file, string $frameType): array
  170. {
  171. // 验证图片信息
  172. $frameInfo = @getimagesize($file->getPathname());
  173. if (!$frameInfo) {
  174. Utils::throwError('1003:' . ($frameType === 'first' ? '首帧' : '尾帧') . '图片格式无效');
  175. }
  176. $frameData = [
  177. 'width' => $frameInfo[0],
  178. 'height' => $frameInfo[1],
  179. 'size' => $file->getSize(),
  180. ];
  181. // 上传图片到服务器
  182. try {
  183. $frameData['url'] = uploadFileByTos('image', $file);
  184. } catch (\Exception $e) {
  185. Utils::throwError('1003:' . ($frameType === 'first' ? '首帧' : '尾帧') . '图片上传失败:' . $e->getMessage());
  186. }
  187. return $frameData;
  188. }
  189. /**
  190. * 验证帧图片规格
  191. *
  192. * @param array $frame
  193. * @param string $frameName
  194. * @return void
  195. */
  196. private function validateFrameSpecs(array $frame, string $frameName): void
  197. {
  198. // 1.图片文件最大4.7MB
  199. if ($frame['size'] > 4.7 * 1024 * 1024) {
  200. Utils::throwError('1003:' . $frameName . '图片大小不能超过4.7MB');
  201. }
  202. // 2.图片分辨率最大: 4096*4096, 最短边不低于320
  203. $minDimension = min($frame['width'], $frame['height']);
  204. $maxDimension = max($frame['width'], $frame['height']);
  205. if ($maxDimension > 4096 || $minDimension < 320) {
  206. Utils::throwError('1003:' . $frameName . '图片分辨率不能超过4096*4096,且最短边不能低于320');
  207. }
  208. // 3.图片长边与短边比例在3以内
  209. if ($maxDimension / $minDimension > 3) {
  210. Utils::throwError('1003:' . $frameName . '图片长边与短边比例不能超过3');
  211. }
  212. }
  213. /**
  214. * 创建 Seedance 1.5 Pro 视频生成任务
  215. *
  216. * @param Request $request
  217. * @return JsonResponse
  218. */
  219. public function createSeedanceTask(Request $request): JsonResponse
  220. {
  221. $data = $request->all();
  222. // 验证基本参数
  223. $baseRules = [
  224. 'model' => 'required|string|in:doubao-seedance-1-5-pro-251215,doubao-seedance-1-0-pro-250528,doubao-seedance-1-0-pro-fast-251015,doubao-seedance-1-0-lite-t2v-250428,doubao-seedance-1-0-lite-i2v-250428',
  225. 'prompt' => 'required|max:1000',
  226. // 'content' => 'required|array|min:1',
  227. ];
  228. $baseMessages = [
  229. 'model.required' => '模型参数不能为空',
  230. 'model.in' => '模型只支持 doubao-seedance-1-5-pro-251215、doubao-seedance-1-0-pro-250528、doubao-seedance-1-0-pro-fast-251015、doubao-seedance-1-0-lite-t2v-250428、doubao-seedance-1-0-lite-i2v-250428',
  231. 'prompt.required' => '提示词不能为空',
  232. 'prompt.max' => '提示词不能超过1000个字符',
  233. // 'content.required' => '内容参数不能为空',
  234. // 'content.array' => '内容参数必须是数组',
  235. // 'content.min' => '内容参数至少包含一个元素',
  236. ];
  237. // 可选参数验证
  238. $optionalRules = [
  239. 'callback_url' => 'nullable|url',
  240. 'return_last_frame' => 'nullable|boolean',
  241. 'service_tier' => 'nullable|in:default,flex',
  242. 'execution_expires_after' => 'nullable|integer|min:3600|max:259200',
  243. 'generate_audio' => 'nullable|boolean',
  244. 'draft' => 'nullable|boolean',
  245. 'video_resolution' => 'required|in:480P,720P,1080P',
  246. 'ratio' => 'nullable|in:16:9,4:3,1:1,3:4,9:16,21:9,adaptive',
  247. 'video_duration' => 'required|integer|min:2|max:12',
  248. 'frames' => 'nullable|integer|min:29|max:289',
  249. 'seed' => 'nullable|integer|min:-1|max:4294967295',
  250. 'camera_fixed' => 'nullable|boolean',
  251. 'watermark' => 'nullable|boolean',
  252. ];
  253. $optionalMessages = [
  254. 'callback_url.url' => '回调地址格式不正确',
  255. 'service_tier.in' => '服务等级只能是 default 或 flex',
  256. 'execution_expires_after.integer' => '任务超时时间必须是整数',
  257. 'execution_expires_after.min' => '任务超时时间不能少于3600秒',
  258. 'execution_expires_after.max' => '任务超时时间不能超过259200秒',
  259. 'video_resolution.required' => '请选择视频分辨率',
  260. 'video_resolution.in' => '分辨率只能是 480P、720P 或 1080P',
  261. 'ratio.in' => '宽高比参数不正确',
  262. 'video_duration.required' => '请填写视频时长',
  263. 'video_duration.integer' => '视频时长必须是整数',
  264. 'video_duration.min' => '视频时长不能少于2秒',
  265. 'video_duration.max' => '视频时长不能超过12秒',
  266. 'frames.integer' => '帧数必须是整数',
  267. 'frames.min' => '帧数不能少于29',
  268. 'frames.max' => '帧数不能超过289',
  269. 'seed.integer' => '随机种子必须是整数',
  270. 'seed.min' => '随机种子不能小于-1',
  271. 'seed.max' => '随机种子不能超过4294967295',
  272. ];
  273. $validationRules = array_merge($baseRules, $optionalRules);
  274. $validationMessages = array_merge($baseMessages, $optionalMessages);
  275. // 检查是否有首帧URL
  276. if (isset($data['first_frame_url'])) {
  277. $optionalRules['first_frame_url'] = 'url';
  278. $optionalMessages['first_frame_url.url'] = '首帧图片URL格式不正确';
  279. }
  280. // 检查是否有尾帧URL
  281. if (isset($data['tail_frame_url'])) {
  282. $optionalRules['tail_frame_url'] = 'url';
  283. $optionalMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确';
  284. }
  285. // 检查是否有首帧文件
  286. if ($request->hasFile('first_frame_file')) {
  287. $optionalRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  288. $optionalMessages = array_merge($optionalMessages, [
  289. 'first_frame_file.image' => '首帧必须是图片文件',
  290. 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式',
  291. 'first_frame_file.max' => '首帧图片大小不能超过4.7MB',
  292. ]);
  293. }
  294. // 检查是否有尾帧文件
  295. if ($request->hasFile('tail_frame_file')) {
  296. $optionalRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  297. $optionalMessages = array_merge($optionalMessages, [
  298. 'tail_frame_file.image' => '尾帧必须是图片文件',
  299. 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式',
  300. 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB',
  301. ]);
  302. }
  303. if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) {
  304. Utils::throwError('1002:提供尾帧URL时必须提供首帧URL');
  305. }
  306. if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) {
  307. Utils::throwError('1002:提供尾帧文件时必须提供首帧文件');
  308. }
  309. $validator = Validator::make($data, $validationRules, $validationMessages);
  310. if ($validator->fails()) {
  311. Utils::throwError('1002:'.$validator->errors()->first());
  312. }
  313. $data['content'] = [
  314. [
  315. 'type' => 'text',
  316. 'text' => $data['prompt'],
  317. ],
  318. ];
  319. $firstFrame = null;
  320. $tailFrame = null;
  321. // 以下首尾帧做模型兼容:
  322. // doubao-seedance-1-0-lite-t2v-250428只支持文生视频
  323. // doubao-seedance-1-0-pro-fast-251015只支持文生视频、图生视频-首帧
  324. // 处理首帧图片(文件上传方式)
  325. if ($request->hasFile('first_frame_file')) {
  326. $firstFrameFile = $request->file('first_frame_file');
  327. $firstFrame = $this->processFrameFile($firstFrameFile, 'first');
  328. if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428'])) {
  329. $data['first_frame_url'] = $firstFrame['url'];
  330. $data['content'][] = [
  331. 'type' => 'image_url',
  332. 'image_url' => [
  333. 'url' => $data['first_frame_url'],
  334. ],
  335. 'role' => 'first_frame',
  336. ];
  337. }
  338. }
  339. // 处理首帧图片(URL方式)
  340. elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) {
  341. $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true);
  342. if (!$firstFrame) {
  343. Utils::throwError('1003:首帧图片获取失败');
  344. }
  345. if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428'])) {
  346. $data['content'][] = [
  347. 'type' => 'image_url',
  348. 'image_url' => [
  349. 'url' => $data['first_frame_url'],
  350. ],
  351. 'role' => 'first_frame',
  352. ];
  353. }else {
  354. $data['first_frame_url'] = '';
  355. }
  356. }
  357. // 处理尾帧图片(文件上传方式)
  358. if ($request->hasFile('tail_frame_file')) {
  359. $tailFrameFile = $request->file('tail_frame_file');
  360. $tailFrame = $this->processFrameFile($tailFrameFile, 'tail');
  361. if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428', 'doubao-seedance-1-0-pro-fast-251015'])) {
  362. $data['tail_frame_url'] = $tailFrame['url'];
  363. $data['content'][] = [
  364. 'type' => 'image_url',
  365. 'image_url' => [
  366. 'url' => $data['tail_frame_url'],
  367. ],
  368. 'role' => 'last_frame',
  369. ];
  370. }
  371. }
  372. // 处理尾帧图片(URL方式)
  373. elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) {
  374. $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true);
  375. if (!$tailFrame) {
  376. Utils::throwError('1003:尾帧图片获取失败');
  377. }
  378. if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428', 'doubao-seedance-1-0-pro-fast-251015'])) {
  379. $data['content'][] = [
  380. 'type' => 'image_url',
  381. 'image_url' => [
  382. 'url' => $data['tail_frame_url'],
  383. ],
  384. 'role' => 'last_frame',
  385. ];
  386. }else {
  387. $data['tail_frame_url'] = '';
  388. }
  389. }
  390. // 验证模型参数
  391. if (!in_array($data['model'], ['doubao-seedance-1-5-pro-251215'])) {
  392. $data['generate_audio'] = false;
  393. $data['draft'] = false;
  394. }
  395. if (in_array($data['model'], ['doubao-seedance-1-5-pro-251215'])) {
  396. if ($data['video_duration'] < 4) $data['video_duration'] = 4;
  397. if (!isset($data['ratio'])) $data['ratio'] = 'adaptive';
  398. }
  399. // 验证 content 数组内容
  400. $this->validateSeedanceContent($data['content']);
  401. $task = $this->aiVideoGenerationService->createSeedanceTask($data);
  402. return $this->success(['task_id' => $task->task_id, 'status' => $task->status]);
  403. }
  404. /**
  405. * 查询 Seedance 1.5 Pro 任务状态
  406. *
  407. * @param string $taskId
  408. * @return JsonResponse
  409. */
  410. public function getSeedanceTaskStatus(string $taskId): JsonResponse
  411. {
  412. try {
  413. $task = \App\Models\MpGenerateVideoTask::where('task_id', $taskId)->first();
  414. if (!$task) {
  415. return response()->json([
  416. 'success' => false,
  417. 'message' => '任务不存在'
  418. ], 404);
  419. }
  420. $result = $this->aiVideoGenerationService->querySeedanceTaskStatus($task);
  421. return $this->success($result);
  422. } catch (\Exception $e) {
  423. Utils::throwError('1001:'.$e->getMessage());
  424. }
  425. }
  426. /**
  427. * 验证 Seedance content 内容
  428. *
  429. * @param array $content
  430. * @return void
  431. */
  432. private function validateSeedanceContent(array $content): void
  433. {
  434. $hasText = false;
  435. $hasFirstFrame = false;
  436. $hasLastFrame = false;
  437. $hasReferenceImage = false;
  438. $hasDraftTask = false;
  439. foreach ($content as $index => $item) {
  440. if (!isset($item['type'])) {
  441. Utils::throwError('1002:content[' . $index . '] 缺少 type 字段');
  442. }
  443. switch ($item['type']) {
  444. case 'text':
  445. if (!isset($item['text']) || empty($item['text'])) {
  446. Utils::throwError('1002:text 类型的 content 必须包含非空的 text 字段');
  447. }
  448. if (mb_strlen($item['text']) > 1000) {
  449. Utils::throwError('1002:文本提示词不能超过1000个字符');
  450. }
  451. $hasText = true;
  452. break;
  453. case 'image_url':
  454. if (!isset($item['image_url']['url']) || empty($item['image_url']['url'])) {
  455. Utils::throwError('1002:image_url 类型的 content 必须包含 image_url.url 字段');
  456. }
  457. $role = $item['role'] ?? null;
  458. if ($role === 'first_frame') {
  459. $hasFirstFrame = true;
  460. } elseif ($role === 'last_frame') {
  461. $hasLastFrame = true;
  462. } elseif ($role === 'reference_image') {
  463. $hasReferenceImage = true;
  464. } elseif (empty($role)) {
  465. // 默认为首帧
  466. $hasFirstFrame = true;
  467. }
  468. break;
  469. case 'draft_task':
  470. if (!isset($item['draft_task']['id']) || empty($item['draft_task']['id'])) {
  471. Utils::throwError('1002:draft_task 类型的 content 必须包含 draft_task.id 字段');
  472. }
  473. $hasDraftTask = true;
  474. break;
  475. default:
  476. Utils::throwError('1002:不支持的 content type: ' . $item['type']);
  477. }
  478. }
  479. // 验证组合规则
  480. if ($hasLastFrame && !$hasFirstFrame) {
  481. Utils::throwError('1002:使用尾帧时必须同时提供首帧');
  482. }
  483. if ($hasReferenceImage && ($hasFirstFrame || $hasLastFrame)) {
  484. Utils::throwError('1002:参考图模式不能与首帧/尾帧模式混用');
  485. }
  486. if ($hasDraftTask && ($hasText || $hasFirstFrame || $hasLastFrame || $hasReferenceImage)) {
  487. Utils::throwError('1002:使用样片任务时不能同时提供其他内容');
  488. }
  489. }
  490. /**
  491. * 创建可灵AI Omni视频生成任务
  492. *
  493. * @param Request $request
  494. * @return JsonResponse
  495. */
  496. public function createKelingTask(Request $request): JsonResponse
  497. {
  498. $data = $request->all();
  499. // 验证基本参数
  500. $baseRules = [
  501. 'model' => 'required|string|in:kling-video-o1,kling-v3-omni',
  502. 'prompt' => 'required|max:2500',
  503. 'mode' => 'nullable|in:pro,std',
  504. 'aspect_ratio' => 'nullable|in:16:9,4:3,1:1,3:4,9:16,21:9',
  505. 'video_duration' => 'required|integer|min:3|max:15',
  506. ];
  507. $baseMessages = [
  508. 'model.required' => '模型参数不能为空',
  509. 'model.in' => '模型只支持 kling-video-o1、kling-v3-omni',
  510. 'prompt.required' => '提示词不能为空',
  511. 'prompt.max' => '提示词不能超过2500个字符',
  512. 'mode.in' => '模式只能是 pro 或 std',
  513. 'aspect_ratio.in' => '宽高比只能是 16:9、4:3、1:1、3:4、9:16、21:9',
  514. 'video_duration.required' => '请填写视频时长',
  515. 'video_duration.integer' => '视频时长必须是整数',
  516. 'video_duration.min' => '视频时长不能少于3秒',
  517. 'video_duration.max' => '视频时长不能超过15秒',
  518. ];
  519. // 可选参数验证
  520. $optionalRules = [
  521. 'multi_prompt' => 'nullable|array',
  522. 'multi_prompt.*' => 'string|max:2500',
  523. 'shot_type' => 'nullable|in:customize,single_shot,multi_shot',
  524. 'generate_audio' => 'nullable|boolean',
  525. 'webhook_url' => 'nullable|url',
  526. 'seed' => 'nullable|integer|min:-1|max:4294967295',
  527. 'video_resolution' => 'nullable|in:480P,720P,1080P',
  528. ];
  529. $optionalMessages = [
  530. 'multi_prompt.array' => '多段提示词必须是数组',
  531. 'multi_prompt.*.string' => '每段提示词必须是字符串',
  532. 'multi_prompt.*.max' => '每段提示词不能超过2500个字符',
  533. 'shot_type.in' => '镜头类型只能是 customize、single_shot、multi_shot',
  534. 'webhook_url.url' => '回调地址格式不正确',
  535. 'seed.integer' => '随机种子必须是整数',
  536. 'seed.min' => '随机种子不能小于-1',
  537. 'seed.max' => '随机种子不能超过4294967295',
  538. 'video_resolution.in' => '分辨率只能是 480P、720P 或 1080P',
  539. ];
  540. // 检查是否有首帧URL
  541. if (isset($data['first_frame_url'])) {
  542. $optionalRules['first_frame_url'] = 'url';
  543. $optionalMessages['first_frame_url.url'] = '首帧图片URL格式不正确';
  544. }
  545. // 检查是否有尾帧URL
  546. if (isset($data['tail_frame_url'])) {
  547. $optionalRules['tail_frame_url'] = 'url';
  548. $optionalMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确';
  549. }
  550. // 检查是否有首帧文件
  551. if ($request->hasFile('first_frame_file')) {
  552. $optionalRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  553. $optionalMessages = array_merge($optionalMessages, [
  554. 'first_frame_file.image' => '首帧必须是图片文件',
  555. 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式',
  556. 'first_frame_file.max' => '首帧图片大小不能超过4.7MB',
  557. ]);
  558. }
  559. // 检查是否有尾帧文件
  560. if ($request->hasFile('tail_frame_file')) {
  561. $optionalRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  562. $optionalMessages = array_merge($optionalMessages, [
  563. 'tail_frame_file.image' => '尾帧必须是图片文件',
  564. 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式',
  565. 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB',
  566. ]);
  567. }
  568. if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) {
  569. Utils::throwError('1002:提供尾帧URL时必须提供首帧URL');
  570. }
  571. if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) {
  572. Utils::throwError('1002:提供尾帧文件时必须提供首帧文件');
  573. }
  574. $validationRules = array_merge($baseRules, $optionalRules);
  575. $validationMessages = array_merge($baseMessages, $optionalMessages);
  576. // 处理图片相关参数
  577. $validator = Validator::make($data, $validationRules, $validationMessages);
  578. if ($validator->fails()) {
  579. Utils::throwError('1002:'.$validator->errors()->first());
  580. }
  581. $firstFrame = null;
  582. $tailFrame = null;
  583. $data['image_list'] = [];
  584. // 处理首帧图片(文件上传方式)
  585. if ($request->hasFile('first_frame_file')) {
  586. $firstFrameFile = $request->file('first_frame_file');
  587. $firstFrame = $this->processFrameFile($firstFrameFile, 'first');
  588. $data['first_frame_url'] = $firstFrame['url'];
  589. $data['image_list'][] = [
  590. 'image_url' => $data['first_frame_url'],
  591. 'type' => 'first_frame',
  592. ];
  593. }
  594. // 处理首帧图片(URL方式)
  595. elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) {
  596. $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true);
  597. if (!$firstFrame) {
  598. Utils::throwError('1003:首帧图片获取失败');
  599. }
  600. $data['image_list'][] = [
  601. 'image_url' => $data['first_frame_url'],
  602. 'type' => 'first_frame',
  603. ];
  604. }
  605. // 处理尾帧图片(文件上传方式)
  606. if ($request->hasFile('tail_frame_file')) {
  607. $tailFrameFile = $request->file('tail_frame_file');
  608. $tailFrame = $this->processFrameFile($tailFrameFile, 'tail');
  609. $data['tail_frame_url'] = $tailFrame['url'];
  610. $data['image_list'][] = [
  611. 'image_url' => $data['tail_frame_url'],
  612. 'type' => 'end_frame',
  613. ];
  614. }
  615. // 处理尾帧图片(URL方式)
  616. elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) {
  617. $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true);
  618. if (!$tailFrame) {
  619. Utils::throwError('1003:尾帧图片获取失败');
  620. }
  621. $data['image_list'][] = [
  622. 'image_url' => $data['tail_frame_url'],
  623. 'type' => 'end_frame',
  624. ];
  625. }
  626. $task = $this->aiVideoGenerationService->createKelingOmniTask($data);
  627. return $this->success(['task_id' => $task->task_id, 'status' => $task->status]);
  628. }
  629. /**
  630. * 查询可灵AI Omni任务状态
  631. *
  632. * @param string $taskId
  633. * @return JsonResponse
  634. */
  635. public function getKelingTaskStatus(string $taskId): JsonResponse
  636. {
  637. try {
  638. $task = \App\Models\MpGenerateVideoTask::where('task_id', $taskId)->first();
  639. if (!$task) {
  640. return response()->json([
  641. 'success' => false,
  642. 'message' => '任务不存在'
  643. ], 404);
  644. }
  645. $result = $this->aiVideoGenerationService->queryKelingOmniTaskStatus($task);
  646. return $this->success($result);
  647. } catch (\Exception $e) {
  648. Utils::throwError('1001:'.$e->getMessage());
  649. }
  650. }
  651. /**
  652. * 获取视频任务列表
  653. *
  654. * @param Request $request
  655. * @return mixed
  656. */
  657. public function taskList(Request $request)
  658. {
  659. $data = $request->all();
  660. $result = $this->aiVideoGenerationService->getTaskList($data);
  661. return $this->success($result, [new AIGenerationTransformer(), 'newBuildTaskList']);
  662. }
  663. }