VideoGenerationController.php 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116
  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. use App\Models\AIElement;
  12. class VideoGenerationController extends BaseController
  13. {
  14. use ApiResponse;
  15. private $aiVideoGenerationService;
  16. public function __construct(
  17. AIVideoGenerationService $aiVideoGenerationService
  18. ) {
  19. $this->aiVideoGenerationService = $aiVideoGenerationService;
  20. }
  21. /**
  22. * 创建视频生成任务(即梦AI)
  23. *
  24. * @param Request $request
  25. * @return JsonResponse
  26. */
  27. public function createJimengTask(Request $request): JsonResponse
  28. {
  29. $data = $request->all();
  30. // 首先验证基本参数
  31. $baseRules = [
  32. 'prompt' => 'required|string|max:1000',
  33. 'seed' => 'required|integer',
  34. 'video_duration' => 'required|in:5,10',
  35. 'video_resolution' => 'required|in:720P,1080P'
  36. ];
  37. $baseMessages = [
  38. 'prompt.required' => '提示词不能为空',
  39. 'prompt.max' => '提示词不能超过1000个字符',
  40. 'seed.required' => '随机种子不能为空',
  41. 'seed.integer' => '随机种子必须是整数',
  42. 'video_duration.required' => '请选择视频时长',
  43. 'video_duration.in' => '视频时长只能是5s或10s',
  44. 'video_resolution.required' => '请选择视频分辨率',
  45. 'video_resolution.in' => '视频分辨率只能是720P或1080P',
  46. ];
  47. // 动态构建验证规则
  48. $validationRules = $baseRules;
  49. $validationMessages = $baseMessages;
  50. // 检查是否有首帧URL
  51. if (isset($data['first_frame_url'])) {
  52. $validationRules['first_frame_url'] = 'url';
  53. $validationMessages['first_frame_url.url'] = '首帧图片URL格式不正确';
  54. }
  55. // 检查是否有尾帧URL
  56. if (isset($data['tail_frame_url'])) {
  57. $validationRules['tail_frame_url'] = 'url';
  58. $validationMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确';
  59. }
  60. // 检查是否有首帧文件
  61. if ($request->hasFile('first_frame_file')) {
  62. $validationRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  63. $validationMessages = array_merge($validationMessages, [
  64. 'first_frame_file.image' => '首帧必须是图片文件',
  65. 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式',
  66. 'first_frame_file.max' => '首帧图片大小不能超过4.7MB',
  67. ]);
  68. }
  69. // 检查是否有尾帧文件
  70. if ($request->hasFile('tail_frame_file')) {
  71. $validationRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  72. $validationMessages = array_merge($validationMessages, [
  73. 'tail_frame_file.image' => '尾帧必须是图片文件',
  74. 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式',
  75. 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB',
  76. ]);
  77. }
  78. if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) {
  79. Utils::throwError('1002:提供尾帧URL时必须提供首帧URL');
  80. }
  81. if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) {
  82. Utils::throwError('1002:提供尾帧文件时必须提供首帧文件');
  83. }
  84. $validator = Validator::make($data, $validationRules, $validationMessages);
  85. if ($validator->fails()) {
  86. Utils::throwError('1002:'.$validator->errors()->first());
  87. }
  88. $firstFrame = null;
  89. $tailFrame = null;
  90. // 处理首帧图片(文件上传方式)
  91. if ($request->hasFile('first_frame_file')) {
  92. $firstFrameFile = $request->file('first_frame_file');
  93. $firstFrame = $this->processFrameFile($firstFrameFile, 'first');
  94. $data['first_frame_url'] = $firstFrame['url'];
  95. }
  96. // 处理首帧图片(URL方式)
  97. elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) {
  98. $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true);
  99. if (!$firstFrame) {
  100. Utils::throwError('1003:首帧图片获取失败');
  101. }
  102. }
  103. // 处理尾帧图片(文件上传方式)
  104. if ($request->hasFile('tail_frame_file')) {
  105. $tailFrameFile = $request->file('tail_frame_file');
  106. $tailFrame = $this->processFrameFile($tailFrameFile, 'tail');
  107. $data['tail_frame_url'] = $tailFrame['url'];
  108. }
  109. // 处理尾帧图片(URL方式)
  110. elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) {
  111. $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true);
  112. if (!$tailFrame) {
  113. Utils::throwError('1003:尾帧图片获取失败');
  114. }
  115. }
  116. // 验证首帧图片规格(如果存在)
  117. if ($firstFrame) {
  118. $this->validateFrameSpecs($firstFrame, '首帧');
  119. }
  120. // 验证尾帧图片规格(如果存在)
  121. if ($tailFrame) {
  122. $this->validateFrameSpecs($tailFrame, '尾帧');
  123. }
  124. // 如果首尾帧都存在,验证比例是否相同
  125. if ($firstFrame && $tailFrame) {
  126. $firstRatio = round($firstFrame['width'] / $firstFrame['height'], 2);
  127. $tailRatio = round($tailFrame['width'] / $tailFrame['height'], 2);
  128. if ($firstRatio != $tailRatio) {
  129. Utils::throwError('1003:尾帧图片与首帧图片比例必须相同');
  130. }
  131. }
  132. $task = $this->aiVideoGenerationService->createJimengTask($data);
  133. return $this->success(['task_id' => $task->id, 'status' => $task->status]);
  134. }
  135. /**
  136. * 查询视频任务状态
  137. *
  138. * @param string $taskId
  139. * @return JsonResponse
  140. */
  141. public function taskStatus(string $taskId): JsonResponse
  142. {
  143. $task = \App\Models\MpGenerateVideoTask::where('id', $taskId)->first();
  144. if (!$task) {
  145. return response()->json([
  146. 'success' => false,
  147. 'message' => '任务不存在'
  148. ], 404);
  149. }
  150. return response()->json([
  151. 'success' => true,
  152. 'data' => [
  153. 'task_id' => $task->id,
  154. 'status' => $task->status,
  155. 'result_url' => $task->result_url,
  156. 'error_message' => $task->error_message,
  157. 'created_at' => $task->created_at,
  158. 'started_at' => $task->started_at,
  159. 'completed_at' => $task->completed_at
  160. ]
  161. ]);
  162. }
  163. /**
  164. * 处理帧图片文件
  165. *
  166. * @param \Illuminate\Http\UploadedFile $file
  167. * @param string $frameType 帧类型(first/tail)
  168. * @return array
  169. */
  170. private function processFrameFile($file, string $frameType): array
  171. {
  172. // 验证图片信息
  173. $frameInfo = @getimagesize($file->getPathname());
  174. if (!$frameInfo) {
  175. Utils::throwError('1003:' . ($frameType === 'first' ? '首帧' : '尾帧') . '图片格式无效');
  176. }
  177. $frameData = [
  178. 'width' => $frameInfo[0],
  179. 'height' => $frameInfo[1],
  180. 'size' => $file->getSize(),
  181. ];
  182. // 上传图片到服务器
  183. try {
  184. $frameData['url'] = uploadFileByTos('image', $file);
  185. } catch (\Exception $e) {
  186. Utils::throwError('1003:' . ($frameType === 'first' ? '首帧' : '尾帧') . '图片上传失败:' . $e->getMessage());
  187. }
  188. return $frameData;
  189. }
  190. /**
  191. * 验证帧图片规格
  192. *
  193. * @param array $frame
  194. * @param string $frameName
  195. * @return void
  196. */
  197. private function validateFrameSpecs(array $frame, string $frameName): void
  198. {
  199. // 1.图片文件最大4.7MB
  200. if ($frame['size'] > 4.7 * 1024 * 1024) {
  201. Utils::throwError('1003:' . $frameName . '图片大小不能超过4.7MB');
  202. }
  203. // 2.图片分辨率最大: 4096*4096, 最短边不低于320
  204. $minDimension = min($frame['width'], $frame['height']);
  205. $maxDimension = max($frame['width'], $frame['height']);
  206. if ($maxDimension > 4096 || $minDimension < 320) {
  207. Utils::throwError('1003:' . $frameName . '图片分辨率不能超过4096*4096,且最短边不能低于320');
  208. }
  209. // 3.图片长边与短边比例在3以内
  210. if ($maxDimension / $minDimension > 3) {
  211. Utils::throwError('1003:' . $frameName . '图片长边与短边比例不能超过3');
  212. }
  213. }
  214. /**
  215. * 创建 Seedance 1.5 Pro 视频生成任务
  216. *
  217. * @param Request $request
  218. * @return JsonResponse
  219. */
  220. public function createSeedanceTask(Request $request): JsonResponse
  221. {
  222. $data = $request->all();
  223. // 验证基本参数
  224. $baseRules = [
  225. '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',
  226. 'prompt' => 'required|max:1000',
  227. // 'content' => 'required|array|min:1',
  228. ];
  229. $baseMessages = [
  230. 'model.required' => '模型参数不能为空',
  231. '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',
  232. 'prompt.required' => '提示词不能为空',
  233. 'prompt.max' => '提示词不能超过1000个字符',
  234. // 'content.required' => '内容参数不能为空',
  235. // 'content.array' => '内容参数必须是数组',
  236. // 'content.min' => '内容参数至少包含一个元素',
  237. ];
  238. // 可选参数验证
  239. $optionalRules = [
  240. 'callback_url' => 'nullable|url',
  241. 'return_last_frame' => 'nullable|boolean',
  242. 'service_tier' => 'nullable|in:default,flex',
  243. 'execution_expires_after' => 'nullable|integer|min:3600|max:259200',
  244. 'generate_audio' => 'nullable|boolean',
  245. 'draft' => 'nullable|boolean',
  246. 'video_resolution' => 'required|in:480P,720P,1080P',
  247. 'ratio' => 'nullable|in:16:9,4:3,1:1,3:4,9:16,21:9,adaptive',
  248. 'video_duration' => 'required|integer|min:2|max:12',
  249. 'frames' => 'nullable|integer|min:29|max:289',
  250. 'seed' => 'nullable|integer|min:-1|max:4294967295',
  251. 'camera_fixed' => 'nullable|boolean',
  252. 'watermark' => 'nullable|boolean',
  253. ];
  254. $optionalMessages = [
  255. 'callback_url.url' => '回调地址格式不正确',
  256. 'service_tier.in' => '服务等级只能是 default 或 flex',
  257. 'execution_expires_after.integer' => '任务超时时间必须是整数',
  258. 'execution_expires_after.min' => '任务超时时间不能少于3600秒',
  259. 'execution_expires_after.max' => '任务超时时间不能超过259200秒',
  260. 'video_resolution.required' => '请选择视频分辨率',
  261. 'video_resolution.in' => '分辨率只能是 480P、720P 或 1080P',
  262. 'ratio.in' => '宽高比参数不正确',
  263. 'video_duration.required' => '请填写视频时长',
  264. 'video_duration.integer' => '视频时长必须是整数',
  265. 'video_duration.min' => '视频时长不能少于2秒',
  266. 'video_duration.max' => '视频时长不能超过12秒',
  267. 'frames.integer' => '帧数必须是整数',
  268. 'frames.min' => '帧数不能少于29',
  269. 'frames.max' => '帧数不能超过289',
  270. 'seed.integer' => '随机种子必须是整数',
  271. 'seed.min' => '随机种子不能小于-1',
  272. 'seed.max' => '随机种子不能超过4294967295',
  273. ];
  274. $validationRules = array_merge($baseRules, $optionalRules);
  275. $validationMessages = array_merge($baseMessages, $optionalMessages);
  276. // 检查是否有首帧URL
  277. if (isset($data['first_frame_url'])) {
  278. $optionalRules['first_frame_url'] = 'url';
  279. $optionalMessages['first_frame_url.url'] = '首帧图片URL格式不正确';
  280. }
  281. // 检查是否有尾帧URL
  282. if (isset($data['tail_frame_url'])) {
  283. $optionalRules['tail_frame_url'] = 'url';
  284. $optionalMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确';
  285. }
  286. // 检查是否有首帧文件
  287. if ($request->hasFile('first_frame_file')) {
  288. $optionalRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  289. $optionalMessages = array_merge($optionalMessages, [
  290. 'first_frame_file.image' => '首帧必须是图片文件',
  291. 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式',
  292. 'first_frame_file.max' => '首帧图片大小不能超过4.7MB',
  293. ]);
  294. }
  295. // 检查是否有尾帧文件
  296. if ($request->hasFile('tail_frame_file')) {
  297. $optionalRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  298. $optionalMessages = array_merge($optionalMessages, [
  299. 'tail_frame_file.image' => '尾帧必须是图片文件',
  300. 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式',
  301. 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB',
  302. ]);
  303. }
  304. if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) {
  305. Utils::throwError('1002:提供尾帧URL时必须提供首帧URL');
  306. }
  307. if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) {
  308. Utils::throwError('1002:提供尾帧文件时必须提供首帧文件');
  309. }
  310. $validator = Validator::make($data, $validationRules, $validationMessages);
  311. if ($validator->fails()) {
  312. Utils::throwError('1002:'.$validator->errors()->first());
  313. }
  314. $data['content'] = [
  315. [
  316. 'type' => 'text',
  317. 'text' => $data['prompt'],
  318. ],
  319. ];
  320. $firstFrame = null;
  321. $tailFrame = null;
  322. // 以下首尾帧做模型兼容:
  323. // doubao-seedance-1-0-lite-t2v-250428只支持文生视频
  324. // doubao-seedance-1-0-pro-fast-251015只支持文生视频、图生视频-首帧
  325. // 处理首帧图片(文件上传方式)
  326. if ($request->hasFile('first_frame_file')) {
  327. $firstFrameFile = $request->file('first_frame_file');
  328. $firstFrame = $this->processFrameFile($firstFrameFile, 'first');
  329. if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428'])) {
  330. $data['first_frame_url'] = $firstFrame['url'];
  331. $data['content'][] = [
  332. 'type' => 'image_url',
  333. 'image_url' => [
  334. 'url' => $data['first_frame_url'],
  335. ],
  336. 'role' => 'first_frame',
  337. ];
  338. }
  339. }
  340. // 处理首帧图片(URL方式)
  341. elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) {
  342. $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true);
  343. if (!$firstFrame) {
  344. Utils::throwError('1003:首帧图片获取失败');
  345. }
  346. if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428'])) {
  347. $data['content'][] = [
  348. 'type' => 'image_url',
  349. 'image_url' => [
  350. 'url' => $data['first_frame_url'],
  351. ],
  352. 'role' => 'first_frame',
  353. ];
  354. }else {
  355. $data['first_frame_url'] = '';
  356. }
  357. }
  358. // 处理尾帧图片(文件上传方式)
  359. if ($request->hasFile('tail_frame_file')) {
  360. $tailFrameFile = $request->file('tail_frame_file');
  361. $tailFrame = $this->processFrameFile($tailFrameFile, 'tail');
  362. if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428', 'doubao-seedance-1-0-pro-fast-251015'])) {
  363. $data['tail_frame_url'] = $tailFrame['url'];
  364. $data['content'][] = [
  365. 'type' => 'image_url',
  366. 'image_url' => [
  367. 'url' => $data['tail_frame_url'],
  368. ],
  369. 'role' => 'last_frame',
  370. ];
  371. }
  372. }
  373. // 处理尾帧图片(URL方式)
  374. elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) {
  375. $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true);
  376. if (!$tailFrame) {
  377. Utils::throwError('1003:尾帧图片获取失败');
  378. }
  379. if (!in_array($data['model'], ['doubao-seedance-1-0-lite-t2v-250428', 'doubao-seedance-1-0-pro-fast-251015'])) {
  380. $data['content'][] = [
  381. 'type' => 'image_url',
  382. 'image_url' => [
  383. 'url' => $data['tail_frame_url'],
  384. ],
  385. 'role' => 'last_frame',
  386. ];
  387. }else {
  388. $data['tail_frame_url'] = '';
  389. }
  390. }
  391. // 验证模型参数
  392. if (!in_array($data['model'], ['doubao-seedance-1-5-pro-251215'])) {
  393. $data['generate_audio'] = false;
  394. $data['draft'] = false;
  395. }
  396. if (in_array($data['model'], ['doubao-seedance-1-5-pro-251215'])) {
  397. if ($data['video_duration'] < 4) $data['video_duration'] = 4;
  398. if (!isset($data['ratio'])) $data['ratio'] = 'adaptive';
  399. }
  400. // 验证 content 数组内容
  401. $this->validateSeedanceContent($data['content']);
  402. $task = $this->aiVideoGenerationService->createSeedanceTask($data);
  403. return $this->success(['task_id' => $task->id, 'status' => $task->status]);
  404. }
  405. public function seedanceCallback(Request $request) {
  406. $data = $request->all();
  407. $result = $this->aiVideoGenerationService->seedanceCallback($data);
  408. return $this->success(['success'=> $result ? 1 : 0]);
  409. }
  410. /**
  411. * 查询 Seedance 1.5 Pro 任务状态
  412. *
  413. * @param string $taskId
  414. * @return JsonResponse
  415. */
  416. public function getSeedanceTaskStatus(string $taskId): JsonResponse
  417. {
  418. try {
  419. $task = \App\Models\MpGenerateVideoTask::where('id', $taskId)->first();
  420. if (!$task) {
  421. return response()->json([
  422. 'success' => false,
  423. 'message' => '任务不存在'
  424. ], 404);
  425. }
  426. $result = $this->aiVideoGenerationService->querySeedanceTaskStatus($task);
  427. return $this->success($result);
  428. } catch (\Exception $e) {
  429. Utils::throwError('1001:'.$e->getMessage());
  430. }
  431. }
  432. /**
  433. * 验证 Seedance content 内容
  434. *
  435. * @param array $content
  436. * @return void
  437. */
  438. private function validateSeedanceContent(array $content): void
  439. {
  440. $hasText = false;
  441. $hasFirstFrame = false;
  442. $hasLastFrame = false;
  443. $hasReferenceImage = false;
  444. $hasDraftTask = false;
  445. foreach ($content as $index => $item) {
  446. if (!isset($item['type'])) {
  447. Utils::throwError('1002:content[' . $index . '] 缺少 type 字段');
  448. }
  449. switch ($item['type']) {
  450. case 'text':
  451. if (!isset($item['text']) || empty($item['text'])) {
  452. Utils::throwError('1002:text 类型的 content 必须包含非空的 text 字段');
  453. }
  454. if (mb_strlen($item['text']) > 1000) {
  455. Utils::throwError('1002:文本提示词不能超过1000个字符');
  456. }
  457. $hasText = true;
  458. break;
  459. case 'image_url':
  460. if (!isset($item['image_url']['url']) || empty($item['image_url']['url'])) {
  461. Utils::throwError('1002:image_url 类型的 content 必须包含 image_url.url 字段');
  462. }
  463. $role = $item['role'] ?? null;
  464. if ($role === 'first_frame') {
  465. $hasFirstFrame = true;
  466. } elseif ($role === 'last_frame') {
  467. $hasLastFrame = true;
  468. } elseif ($role === 'reference_image') {
  469. $hasReferenceImage = true;
  470. } elseif (empty($role)) {
  471. // 默认为首帧
  472. $hasFirstFrame = true;
  473. }
  474. break;
  475. case 'draft_task':
  476. if (!isset($item['draft_task']['id']) || empty($item['draft_task']['id'])) {
  477. Utils::throwError('1002:draft_task 类型的 content 必须包含 draft_task.id 字段');
  478. }
  479. $hasDraftTask = true;
  480. break;
  481. default:
  482. Utils::throwError('1002:不支持的 content type: ' . $item['type']);
  483. }
  484. }
  485. // 验证组合规则
  486. if ($hasLastFrame && !$hasFirstFrame) {
  487. Utils::throwError('1002:使用尾帧时必须同时提供首帧');
  488. }
  489. if ($hasReferenceImage && ($hasFirstFrame || $hasLastFrame)) {
  490. Utils::throwError('1002:参考图模式不能与首帧/尾帧模式混用');
  491. }
  492. if ($hasDraftTask && ($hasText || $hasFirstFrame || $hasLastFrame || $hasReferenceImage)) {
  493. Utils::throwError('1002:使用样片任务时不能同时提供其他内容');
  494. }
  495. }
  496. /**
  497. * 创建可灵AI Omni视频生成任务
  498. *
  499. * @param Request $request
  500. * @return JsonResponse
  501. */
  502. public function createKelingTask(Request $request): JsonResponse
  503. {
  504. $data = $request->all();
  505. // 验证基本参数
  506. $baseRules = [
  507. 'model' => 'required|string|in:kling-video-o1,kling-v3-omni',
  508. 'prompt' => 'required|max:2500',
  509. 'mode' => 'nullable|in:pro,std',
  510. 'aspect_ratio' => 'nullable|in:16:9,4:3,1:1,3:4,9:16,21:9',
  511. 'video_duration' => 'required|integer|min:3|max:15',
  512. ];
  513. $baseMessages = [
  514. 'model.required' => '模型参数不能为空',
  515. 'model.in' => '模型只支持 kling-video-o1、kling-v3-omni',
  516. 'prompt.required' => '提示词不能为空',
  517. 'prompt.max' => '提示词不能超过2500个字符',
  518. 'mode.in' => '模式只能是 pro 或 std',
  519. 'aspect_ratio.in' => '宽高比只能是 16:9、4:3、1:1、3:4、9:16、21:9',
  520. 'video_duration.required' => '请填写视频时长',
  521. 'video_duration.integer' => '视频时长必须是整数',
  522. 'video_duration.min' => '视频时长不能少于3秒',
  523. 'video_duration.max' => '视频时长不能超过15秒',
  524. ];
  525. // 可选参数验证
  526. $optionalRules = [
  527. 'multi_prompt' => 'nullable|array',
  528. 'multi_prompt.*' => 'string|max:2500',
  529. 'shot_type' => 'nullable|in:customize,single_shot,multi_shot',
  530. 'generate_audio' => 'nullable|boolean',
  531. 'webhook_url' => 'nullable|url',
  532. 'seed' => 'nullable|integer|min:-1|max:4294967295',
  533. 'video_resolution' => 'nullable|in:480P,720P,1080P',
  534. ];
  535. $optionalMessages = [
  536. 'multi_prompt.array' => '多段提示词必须是数组',
  537. 'multi_prompt.*.string' => '每段提示词必须是字符串',
  538. 'multi_prompt.*.max' => '每段提示词不能超过2500个字符',
  539. 'shot_type.in' => '镜头类型只能是 customize、single_shot、multi_shot',
  540. 'webhook_url.url' => '回调地址格式不正确',
  541. 'seed.integer' => '随机种子必须是整数',
  542. 'seed.min' => '随机种子不能小于-1',
  543. 'seed.max' => '随机种子不能超过4294967295',
  544. 'video_resolution.in' => '分辨率只能是 480P、720P 或 1080P',
  545. ];
  546. // 检查是否有首帧URL
  547. if (isset($data['first_frame_url'])) {
  548. $optionalRules['first_frame_url'] = 'url';
  549. $optionalMessages['first_frame_url.url'] = '首帧图片URL格式不正确';
  550. }
  551. // 检查是否有尾帧URL
  552. if (isset($data['tail_frame_url'])) {
  553. $optionalRules['tail_frame_url'] = 'url';
  554. $optionalMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确';
  555. }
  556. // 检查是否有首帧文件
  557. if ($request->hasFile('first_frame_file')) {
  558. $optionalRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  559. $optionalMessages = array_merge($optionalMessages, [
  560. 'first_frame_file.image' => '首帧必须是图片文件',
  561. 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式',
  562. 'first_frame_file.max' => '首帧图片大小不能超过4.7MB',
  563. ]);
  564. }
  565. // 检查是否有尾帧文件
  566. if ($request->hasFile('tail_frame_file')) {
  567. $optionalRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800';
  568. $optionalMessages = array_merge($optionalMessages, [
  569. 'tail_frame_file.image' => '尾帧必须是图片文件',
  570. 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式',
  571. 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB',
  572. ]);
  573. }
  574. if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) {
  575. Utils::throwError('1002:提供尾帧URL时必须提供首帧URL');
  576. }
  577. if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) {
  578. Utils::throwError('1002:提供尾帧文件时必须提供首帧文件');
  579. }
  580. $validationRules = array_merge($baseRules, $optionalRules);
  581. $validationMessages = array_merge($baseMessages, $optionalMessages);
  582. // 处理图片相关参数
  583. $validator = Validator::make($data, $validationRules, $validationMessages);
  584. if ($validator->fails()) {
  585. Utils::throwError('1002:'.$validator->errors()->first());
  586. }
  587. $firstFrame = null;
  588. $tailFrame = null;
  589. $data['image_list'] = [];
  590. // 处理首帧图片(文件上传方式)
  591. if ($request->hasFile('first_frame_file')) {
  592. $firstFrameFile = $request->file('first_frame_file');
  593. $firstFrame = $this->processFrameFile($firstFrameFile, 'first');
  594. $data['first_frame_url'] = $firstFrame['url'];
  595. $data['image_list'][] = [
  596. 'image_url' => $data['first_frame_url'],
  597. 'type' => 'first_frame',
  598. ];
  599. }
  600. // 处理首帧图片(URL方式)
  601. elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) {
  602. $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true);
  603. if (!$firstFrame) {
  604. Utils::throwError('1003:首帧图片获取失败');
  605. }
  606. $data['image_list'][] = [
  607. 'image_url' => $data['first_frame_url'],
  608. 'type' => 'first_frame',
  609. ];
  610. }
  611. // 处理尾帧图片(文件上传方式)
  612. if ($request->hasFile('tail_frame_file')) {
  613. $tailFrameFile = $request->file('tail_frame_file');
  614. $tailFrame = $this->processFrameFile($tailFrameFile, 'tail');
  615. $data['tail_frame_url'] = $tailFrame['url'];
  616. $data['image_list'][] = [
  617. 'image_url' => $data['tail_frame_url'],
  618. 'type' => 'end_frame',
  619. ];
  620. }
  621. // 处理尾帧图片(URL方式)
  622. elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) {
  623. $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true);
  624. if (!$tailFrame) {
  625. Utils::throwError('1003:尾帧图片获取失败');
  626. }
  627. $data['image_list'][] = [
  628. 'image_url' => $data['tail_frame_url'],
  629. 'type' => 'end_frame',
  630. ];
  631. }
  632. $task = $this->aiVideoGenerationService->createKelingOmniTask($data);
  633. return $this->success(['task_id' => $task->id, 'status' => $task->status]);
  634. }
  635. /**
  636. * 查询可灵AI Omni任务状态
  637. *
  638. * @param string $taskId
  639. * @return JsonResponse
  640. */
  641. public function getKelingTaskStatus(string $taskId): JsonResponse
  642. {
  643. try {
  644. $task = \App\Models\MpGenerateVideoTask::where('id', $taskId)->first();
  645. if (!$task) {
  646. return response()->json([
  647. 'success' => false,
  648. 'message' => '任务不存在'
  649. ], 404);
  650. }
  651. $result = $this->aiVideoGenerationService->queryKelingOmniTaskStatus($task);
  652. return $this->success($result);
  653. } catch (\Exception $e) {
  654. Utils::throwError('1001:'.$e->getMessage());
  655. }
  656. }
  657. /**
  658. * 获取视频任务列表
  659. *
  660. * @param Request $request
  661. * @return mixed
  662. */
  663. public function taskList(Request $request)
  664. {
  665. $data = $request->all();
  666. $result = $this->aiVideoGenerationService->getTaskList($data);
  667. return $this->success($result, [new AIGenerationTransformer(), 'newBuildTaskList']);
  668. }
  669. // ==================== 主体管理相关接口 ====================
  670. /**
  671. * 创建主体
  672. *
  673. * @param Request $request
  674. * @return JsonResponse
  675. */
  676. public function createElement(Request $request): JsonResponse
  677. {
  678. try {
  679. // 验证请求参数
  680. $validated = $request->validate([
  681. 'element_name' => 'required|string|max:20',
  682. 'element_description' => 'required|string|max:100',
  683. 'reference_type' => 'required|in:image_refer,video_refer',
  684. 'element_image_list' => 'required_if:reference_type,image_refer|array',
  685. 'element_image_list.frontal_image' => 'required_if:reference_type,image_refer|string',
  686. 'element_image_list.refer_images' => 'required_if:reference_type,image_refer|array|min:1|max:3',
  687. 'element_image_list.refer_images.*.image_url' => 'required|string',
  688. 'element_video_list' => 'required_if:reference_type,video_refer|array',
  689. 'element_video_list.refer_videos' => 'required_if:reference_type,video_refer|array|size:1',
  690. 'element_video_list.refer_videos.*.video_url' => 'required|string',
  691. 'element_voice_id' => 'nullable|string',
  692. 'tag_list' => 'nullable|array',
  693. 'tag_list.*.tag_id' => 'required|in:o_101,o_102,o_103,o_104,o_105,o_106,o_107,o_108',
  694. 'callback_url' => 'nullable|url',
  695. 'external_task_id' => 'nullable|string',
  696. ]);
  697. // 调用服务层创建主体
  698. $element = $this->aiVideoGenerationService->createElement($validated);
  699. return response()->json([
  700. 'code' => 0,
  701. 'message' => '主体创建任务已提交',
  702. 'request_id' => uniqid(),
  703. 'data' => [
  704. 'task_id' => $element->id,
  705. 'task_info' => [
  706. 'external_task_id' => $element->external_task_id,
  707. ],
  708. 'task_status' => $element->task_status,
  709. 'created_at' => $element->created_at->timestamp * 1000,
  710. 'updated_at' => $element->updated_at->timestamp * 1000,
  711. ]
  712. ]);
  713. } catch (\Illuminate\Validation\ValidationException $e) {
  714. return response()->json([
  715. 'code' => 400,
  716. 'message' => '参数验证失败',
  717. 'request_id' => uniqid(),
  718. 'errors' => $e->errors()
  719. ], 400);
  720. } catch (\Exception $e) {
  721. dLog('generate')->error('创建主体失败: ' . $e->getMessage());
  722. return response()->json([
  723. 'code' => 500,
  724. 'message' => '创建主体失败: ' . $e->getMessage(),
  725. 'request_id' => uniqid(),
  726. ], 500);
  727. }
  728. }
  729. /**
  730. * 查询单个主体状态
  731. *
  732. * @param string $taskId
  733. * @param Request $request
  734. * @return JsonResponse
  735. */
  736. public function getElementStatus(string $taskId, Request $request): JsonResponse
  737. {
  738. try {
  739. $externalTaskId = $request->get('external_task_id');
  740. // 先查询本地数据库
  741. $query = AIElement::where('id', $taskId);
  742. if ($externalTaskId) {
  743. $query->orWhere('external_task_id', $externalTaskId);
  744. }
  745. $localElement = $query->first();
  746. if (!$localElement) {
  747. return response()->json([
  748. 'code' => 404,
  749. 'message' => '主体任务不存在',
  750. 'request_id' => uniqid(),
  751. ], 404);
  752. }
  753. // 如果任务还在处理中,查询最新状态
  754. if ($localElement->task_status === AIElement::STATUS_PROCESSING) {
  755. $apiResult = $this->aiVideoGenerationService->queryElementStatus($taskId, $externalTaskId);
  756. if ($apiResult['success'] && isset($apiResult['data'])) {
  757. $localElement->refresh(); // 重新加载数据
  758. }
  759. }
  760. // 构建响应数据
  761. $responseData = [
  762. 'task_id' => $localElement->id,
  763. 'task_status' => $localElement->task_status,
  764. 'task_status_msg' => $localElement->task_status_msg,
  765. 'task_info' => [
  766. 'external_task_id' => $localElement->external_task_id,
  767. ],
  768. 'created_at' => $localElement->created_at->timestamp * 1000,
  769. 'updated_at' => $localElement->updated_at->timestamp * 1000,
  770. ];
  771. // 如果任务成功,添加主体结果信息
  772. if ($localElement->task_status === AIElement::STATUS_SUCCEED && $localElement->element_id) {
  773. $responseData['task_result'] = [
  774. 'elements' => [
  775. [
  776. 'element_id' => $localElement->element_id,
  777. 'element_name' => $localElement->element_name,
  778. 'element_description' => $localElement->element_description,
  779. 'reference_type' => $localElement->reference_type,
  780. 'element_image_list' => $localElement->element_image_list,
  781. 'element_video_list' => $localElement->element_video_list,
  782. 'element_voice_info' => $localElement->element_voice_info,
  783. 'tag_list' => $localElement->tag_list,
  784. 'owned_by' => $localElement->owned_by,
  785. 'status' => $localElement->status,
  786. ]
  787. ]
  788. ];
  789. $responseData['final_unit_deduction'] = $localElement->final_unit_deduction;
  790. }
  791. return response()->json([
  792. 'code' => 0,
  793. 'message' => 'success',
  794. 'request_id' => uniqid(),
  795. 'data' => $responseData
  796. ]);
  797. } catch (\Exception $e) {
  798. dLog('generate')->error('查询主体状态失败: ' . $e->getMessage());
  799. return response()->json([
  800. 'code' => 500,
  801. 'message' => '查询主体状态失败: ' . $e->getMessage(),
  802. 'request_id' => uniqid(),
  803. ], 500);
  804. }
  805. }
  806. /**
  807. * 查询所有主体列表(包含自定义和官方)
  808. *
  809. * @param Request $request
  810. * @return JsonResponse
  811. */
  812. public function getElementsList(Request $request): JsonResponse
  813. {
  814. try {
  815. $pageNum = (int)$request->get('pageNum', 1);
  816. $pageSize = (int)$request->get('pageSize', 30);
  817. $type = $request->get('type', 'all'); // all, custom, official
  818. // 限制分页参数范围
  819. $pageNum = max(1, min($pageNum, 1000));
  820. $pageSize = max(1, min($pageSize, 500));
  821. $customElements = [];
  822. $officialElements = [];
  823. // 查询自定义主体
  824. if (in_array($type, ['all', 'custom'])) {
  825. $customResult = $this->aiVideoGenerationService->queryCustomElementsList($pageNum, $pageSize);
  826. if ($customResult['success']) {
  827. $customElements = $customResult['data'];
  828. }
  829. }
  830. // 查询官方主体
  831. if (in_array($type, ['all', 'official'])) {
  832. $officialResult = $this->aiVideoGenerationService->queryOfficialElementsList($pageNum, $pageSize);
  833. if ($officialResult['success']) {
  834. $officialElements = $officialResult['data'];
  835. }
  836. }
  837. // 合并结果
  838. $allElements = array_merge($customElements, $officialElements);
  839. return response()->json([
  840. 'code' => 0,
  841. 'message' => 'success',
  842. 'request_id' => uniqid(),
  843. 'data' => $allElements
  844. ]);
  845. } catch (\Exception $e) {
  846. dLog('generate')->error('查询主体列表失败: ' . $e->getMessage());
  847. return response()->json([
  848. 'code' => 500,
  849. 'message' => '查询主体列表失败: ' . $e->getMessage(),
  850. 'request_id' => uniqid(),
  851. ], 500);
  852. }
  853. }
  854. /**
  855. * 删除自定义主体
  856. *
  857. * @param Request $request
  858. * @return JsonResponse
  859. */
  860. public function deleteElement(Request $request): JsonResponse
  861. {
  862. try {
  863. // 验证请求参数
  864. $validated = $request->validate([
  865. 'element_id' => 'required|string',
  866. ]);
  867. // 检查主体是否存在且为自定义主体
  868. $element = AIElement::where('element_id', $validated['element_id'])->first();
  869. if (!$element) {
  870. return response()->json([
  871. 'code' => 404,
  872. 'message' => '主体不存在',
  873. 'request_id' => uniqid(),
  874. ], 404);
  875. }
  876. if ($element->owned_by === 'kling') {
  877. return response()->json([
  878. 'code' => 400,
  879. 'message' => '不能删除官方主体',
  880. 'request_id' => uniqid(),
  881. ], 400);
  882. }
  883. // 调用服务层删除主体
  884. $result = $this->aiVideoGenerationService->deleteElement($validated['element_id']);
  885. if ($result['success']) {
  886. return response()->json([
  887. 'code' => 0,
  888. 'message' => $result['message'],
  889. 'request_id' => uniqid(),
  890. 'data' => $result['data']
  891. ]);
  892. } else {
  893. return response()->json([
  894. 'code' => 500,
  895. 'message' => $result['message'],
  896. 'request_id' => uniqid(),
  897. ], 500);
  898. }
  899. } catch (\Illuminate\Validation\ValidationException $e) {
  900. return response()->json([
  901. 'code' => 400,
  902. 'message' => '参数验证失败',
  903. 'request_id' => uniqid(),
  904. 'errors' => $e->errors()
  905. ], 400);
  906. } catch (\Exception $e) {
  907. dLog('generate')->error('删除主体失败: ' . $e->getMessage());
  908. return response()->json([
  909. 'code' => 500,
  910. 'message' => '删除主体失败: ' . $e->getMessage(),
  911. 'request_id' => uniqid(),
  912. ], 500);
  913. }
  914. }
  915. /**
  916. * 获取本地主体列表(用于内部管理)
  917. *
  918. * @param Request $request
  919. * @return JsonResponse
  920. */
  921. public function getLocalElementsList(Request $request): JsonResponse
  922. {
  923. try {
  924. // 构建筛选条件
  925. $filters = [
  926. 'owned_by' => $request->get('owned_by'),
  927. 'reference_type' => $request->get('reference_type'),
  928. 'status' => $request->get('status'),
  929. 'task_status' => $request->get('task_status'),
  930. 'keyword' => $request->get('keyword'),
  931. 'page_size' => (int)$request->get('page_size', 20),
  932. ];
  933. // 移除空值
  934. $filters = array_filter($filters, function($value) {
  935. return $value !== null && $value !== '';
  936. });
  937. // 调用服务层获取列表
  938. $elements = $this->aiVideoGenerationService->getLocalElementsList($filters);
  939. // 转换数据格式
  940. $data = $elements->map(function ($element) {
  941. return [
  942. 'task_id' => $element->id,
  943. 'external_task_id' => $element->external_task_id,
  944. 'task_status' => $element->task_status,
  945. 'task_status_msg' => $element->task_status_msg,
  946. 'element_id' => $element->element_id,
  947. 'element_name' => $element->element_name,
  948. 'element_description' => $element->element_description,
  949. 'reference_type' => $element->reference_type,
  950. 'element_image_list' => $element->element_image_list,
  951. 'element_video_list' => $element->element_video_list,
  952. 'element_voice_id' => $element->element_voice_id,
  953. 'element_voice_info' => $element->element_voice_info,
  954. 'tag_list' => $element->tag_list,
  955. 'tag_names' => $element->tag_names, // 使用模型的访问器
  956. 'owned_by' => $element->owned_by,
  957. 'status' => $element->status,
  958. 'final_unit_deduction' => $element->final_unit_deduction,
  959. 'created_at' => $element->created_at->format('Y-m-d H:i:s'),
  960. 'updated_at' => $element->updated_at->format('Y-m-d H:i:s'),
  961. ];
  962. });
  963. return response()->json([
  964. 'code' => 0,
  965. 'message' => 'success',
  966. 'request_id' => uniqid(),
  967. 'data' => [
  968. 'data' => $data,
  969. 'current_page' => $elements->currentPage(),
  970. 'last_page' => $elements->lastPage(),
  971. 'per_page' => $elements->perPage(),
  972. 'total' => $elements->total(),
  973. ]
  974. ]);
  975. } catch (\Exception $e) {
  976. dLog('generate')->error('获取本地主体列表失败: ' . $e->getMessage());
  977. return response()->json([
  978. 'code' => 500,
  979. 'message' => '获取本地主体列表失败: ' . $e->getMessage(),
  980. 'request_id' => uniqid(),
  981. ], 500);
  982. }
  983. }
  984. }