AIImageGenerationService.php 90 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215
  1. <?php
  2. namespace App\Services\AIGeneration;
  3. use App\Facade\Site;
  4. use App\Consts\BaseConst;
  5. use App\Libs\ScriptAnchor;
  6. use App\Models\MpGeneratePicTask;
  7. use App\Models\MpAsset;
  8. use App\Services\PointsService;
  9. use App\Services\VolcEngineService;
  10. use GuzzleHttp\Client;
  11. use GuzzleHttp\Exception\GuzzleException;
  12. use Illuminate\Support\Facades\DB;
  13. use Illuminate\Support\Facades\Log;
  14. use Illuminate\Support\Facades\Redis;
  15. class AIImageGenerationService
  16. {
  17. private $volcEngineService;
  18. private $httpClient;
  19. /** @var PointsService */
  20. private $pointsService;
  21. // 模型常量
  22. const MODEL_JIMENG_4 = 'jimeng_4.0';
  23. const MODEL_NANO_BANANA_2 = 'NanoBanana2';
  24. const MODEL_NANO_BANANA_PRO = 'NanoBananaPro';
  25. const MODEL_GPT_IMAGE_2 = 'GptImage2';
  26. // 队列派发去重相关常量
  27. /** 派发锁在 Redis 中的 key 前缀 */
  28. const DISPATCH_LOCK_PREFIX = 'ai_pic_dispatch:';
  29. /** 派发锁使用的 Redis 连接,与队列保持一致 */
  30. const DISPATCH_LOCK_CONNECTION = 'default';
  31. /** 派发锁有效期(秒):worker 长时间未消费时的兜底重派时间 */
  32. const DISPATCH_LOCK_TTL = 600;
  33. /** 每轮最多派发的任务数,避免一次性推送过多任务 */
  34. const DISPATCH_BATCH_LIMIT = 50;
  35. /** 每轮扫描的候选任务数,需大于 DISPATCH_BATCH_LIMIT,避免已入队的任务挡住后面的任务 */
  36. const DISPATCH_CANDIDATE_LIMIT = 500;
  37. /** 派发锁分组:火山API */
  38. const DISPATCH_GROUP_VOLC = 'volc_pics';
  39. /** 派发锁分组:GPT-Image2 */
  40. const DISPATCH_GROUP_GPT_IMAGE2 = 'gpt_image2_pics';
  41. public function __construct(VolcEngineService $volcEngineService, PointsService $pointsService)
  42. {
  43. $this->volcEngineService = $volcEngineService;
  44. $this->pointsService = $pointsService;
  45. $this->httpClient = new Client([
  46. 'timeout' => 300,
  47. ]);
  48. }
  49. /**
  50. * 构建图片任务计费信息
  51. *
  52. * @param array $params
  53. * @param string $model
  54. * @return array
  55. */
  56. private function buildImageChargeInfo(array $params, string $model): array
  57. {
  58. $uid = 0;
  59. try {
  60. $uid = (int)Site::getUid();
  61. } catch (\Throwable $e) {
  62. // 非请求上下文(如命令行)下可能无法获取用户ID,置为0
  63. }
  64. $width = (int)getProp($params, 'width', 1600);
  65. $height = (int)getProp($params, 'height', 2848);
  66. $chargeInfo = [
  67. 'user_id' => $uid,
  68. 'model' => $model,
  69. 'width' => $width,
  70. 'height' => $height,
  71. 'resolution' => $this->pointsService->normalizeImageResolutionKey($width, $height),
  72. 'image_num' => (int)getProp($params, 'image_num', 1),
  73. 'source' => !empty($params['alias_segment_id']) ? 'segment' : (!empty($params['alias_act_id']) ? 'act' : ''),
  74. 'alias_segment_id' => $params['alias_segment_id'] ?? '',
  75. 'created_at' => date('Y-m-d H:i:s'),
  76. ];
  77. // 锚点数据(anime_id / episode_id / script_id,以及 alias_act_id / alias_segment_id),上下文没有的不记录
  78. return ScriptAnchor::mergeIntoChargeInfo($chargeInfo, $params);
  79. }
  80. /**
  81. * 图片生成余额预检
  82. *
  83. * 有用户上下文时校验本次生成所需积分(单价 × 生成张数),不足直接抛错;
  84. * 后台命令/队列等无用户上下文的场景跳过预检。
  85. *
  86. * @param array $chargeInfo
  87. * @return void
  88. */
  89. private function preCheckImageBalance(array $chargeInfo): void
  90. {
  91. $uid = (int)($chargeInfo['user_id'] ?? 0);
  92. if (!$uid) {
  93. return;
  94. }
  95. $imageNum = (int)($chargeInfo['image_num'] ?? 1);
  96. if ($imageNum <= 0) {
  97. $imageNum = 1;
  98. }
  99. $needPoints = $this->pointsService->getImageChargePoints($chargeInfo) * $imageNum;
  100. if ($needPoints <= 0) {
  101. return;
  102. }
  103. $this->pointsService->checkUserPointsEnough($needPoints, $uid);
  104. }
  105. /**
  106. * 图片生成成功统一处理(事务保证原子性)
  107. *
  108. * 在同一事务内完成:任务状态更新 + 积分计费 + 分镜表同步;
  109. * 任一步失败整体回滚,任务保持原状态,由后续定时任务/超时重置重试。
  110. *
  111. * @param MpGeneratePicTask $task
  112. * @param array $resultUrls
  113. * @param array $resultJson
  114. * @return void
  115. */
  116. private function handleImageTaskSuccess(MpGeneratePicTask $task, array $resultUrls, array $resultJson): void
  117. {
  118. try {
  119. DB::beginTransaction();
  120. // 更新任务状态为成功
  121. $task->updateStatus(MpGeneratePicTask::STATUS_SUCCESS, [
  122. 'result_url' => $resultUrls,
  123. 'result_json' => $resultJson,
  124. ]);
  125. // 记录计费明细并扣减用户积分(真实失败则抛错回滚,避免“成功但未计费”)
  126. $chargeResult = $this->pointsService->recordImageTaskCharge($task);
  127. if (empty($chargeResult['charged'])
  128. && !in_array((string)($chargeResult['reason'] ?? ''), ['already_charged', 'no_charge_info'], true)) {
  129. throw new \Exception('图片计费失败: ' . (string)($chargeResult['reason'] ?? 'unknown'));
  130. }
  131. // 同步调整分镜图片状态和结果
  132. $segmentId = getProp($task, 'alias_segment_id');
  133. if ($segmentId) {
  134. $updateSegmentResult = DB::table('mp_episode_segments')->where('segment_id', $segmentId)->update([
  135. 'img_url' => $resultUrls[0],
  136. 'pic_task_status' => '已完成',
  137. ]);
  138. // 本应更新却未更新到(分镜被删除等),抛错触发整体回滚重试
  139. if ($updateSegmentResult === false || $updateSegmentResult === 0) {
  140. throw new \Exception('更新分镜图片状态失败: 未找到对应的分镜记录');
  141. }
  142. // 如果是首帧图ID则更新anime表
  143. if (Redis::sismember('anime_first_frame_urls', $segmentId)) {
  144. $animeId = DB::table('mp_episode_segments')->where('segment_id', $segmentId)->value('anime_id');
  145. DB::table('mp_animes')->where('id', $animeId)->update(['first_frame_url' => $resultUrls[0], 'updated_at' => date('Y-m-d H:i:s')]);
  146. Redis::srem('anime_first_frame_urls', $segmentId);
  147. }
  148. }
  149. DB::commit();
  150. } catch (\Exception $e) {
  151. DB::rollBack();
  152. dLog('generate')->error('图片任务成功处理失败,已回滚: ' . $e->getMessage(), ['task_id' => $task->id]);
  153. logDB('generate', 'error', '图片任务成功处理失败,已回滚', [
  154. 'task_id' => $task->id,
  155. 'error' => $e->getMessage(),
  156. ]);
  157. }
  158. }
  159. /**
  160. * 异步创建图片生成任务
  161. *
  162. * @param array $params
  163. * @return MpGeneratePicTask
  164. */
  165. public function createImageGenerationTask(array $params): MpGeneratePicTask
  166. {
  167. // // 获取模型类型,默认使用即梦AI 4.0
  168. // $model = $params['model'] ?? self::MODEL_JIMENG_4;
  169. // 获取模型类型,默认使用即梦seedream5.0-lite
  170. $model = $params['model'] ?? 'gpt-image-2';
  171. // 构建计费信息并余额预检(不足直接报错)
  172. $chargeInfo = $this->buildImageChargeInfo($params, $model);
  173. $this->preCheckImageBalance($chargeInfo);
  174. // 检查是否有正在处理的任务(仅即梦AI需要排队)
  175. if ($model === self::MODEL_JIMENG_4 && $this->hasProcessingTask()) {
  176. // 如果有正在处理的任务,创建排队任务
  177. return $this->createQueuedTask($params);
  178. }
  179. // 生成唯一的任务ID
  180. $taskId = 'img_gen_' . time() . '_' . uniqid();
  181. // 创建任务记录
  182. $task = MpGeneratePicTask::create([
  183. 'task_id' => $taskId,
  184. 'alias_segment_id' => $params['alias_segment_id'] ?? 0,
  185. 'prompt' => $params['prompt'] ?? '',
  186. 'width' => $params['width'] ?? 1600,
  187. 'height' => $params['height'] ?? 2848,
  188. 'image_num' => $params['image_num'] ?? 1,
  189. 'scale' => $params['scale'] ?? 50,
  190. 'ref_img_url' => $params['ref_img_urls'] ?? null,
  191. 'mask_img_url' => $params['mask_img_url'] ?? null,
  192. 'extra_params' => $params['extra_params'] ?? null,
  193. 'status' => MpGeneratePicTask::STATUS_PENDING,
  194. 'model' => $model,
  195. 'charge_info' => $chargeInfo,
  196. ]);
  197. // 即梦AI和NanoBanana系列立即提交任务,火山API在定时任务中提交
  198. if ($model === self::MODEL_JIMENG_4) {
  199. $this->submitTaskToJimengApi($task);
  200. } elseif (in_array($model, BaseConst::NANO_BANANA_MODELS)) {
  201. $this->submitTaskToNanoBananaApi($task);
  202. }
  203. return $task;
  204. }
  205. /**
  206. * 检查是否有正在处理的任务
  207. *
  208. * @return bool
  209. */
  210. private function hasProcessingTask(): bool
  211. {
  212. return MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PROCESSING)
  213. ->exists();
  214. }
  215. /**
  216. * 创建排队任务
  217. *
  218. * @param array $params
  219. * @return MpGeneratePicTask
  220. */
  221. private function createQueuedTask(array $params): MpGeneratePicTask
  222. {
  223. // 生成唯一的任务ID
  224. $taskId = 'img_gen_' . time() . '_' . uniqid();
  225. // 获取模型类型
  226. $model = $params['model'] ?? self::MODEL_JIMENG_4;
  227. // 构建计费信息并余额预检(不足直接报错)
  228. $chargeInfo = $this->buildImageChargeInfo($params, $model);
  229. $this->preCheckImageBalance($chargeInfo);
  230. // 创建排队状态的任务记录
  231. return MpGeneratePicTask::create([
  232. 'task_id' => $taskId,
  233. 'alias_segment_id' => $params['alias_segment_id'] ?? 0,
  234. 'prompt' => $params['prompt'] ?? '',
  235. 'width' => $params['width'] ?? 1600,
  236. 'height' => $params['height'] ?? 2848,
  237. 'image_num' => $params['image_num'] ?? 1,
  238. 'scale' => $params['scale'] ?? 50,
  239. 'ref_img_url' => $params['ref_img_urls'] ?? null,
  240. 'mask_img_url' => $params['mask_img_url'] ?? null,
  241. 'extra_params' => $params['extra_params'] ?? null,
  242. 'status' => MpGeneratePicTask::STATUS_PENDING,
  243. 'model' => $model,
  244. 'charge_info' => $chargeInfo,
  245. ]);
  246. }
  247. /**
  248. * 提交任务到即梦AI API
  249. *
  250. * @param MpGeneratePicTask $task
  251. * @return void
  252. */
  253. private function submitTaskToJimengApi(MpGeneratePicTask $task): void
  254. {
  255. try {
  256. // 验证环境变量配置
  257. $accessKey = env('VOLC_AI_AK');
  258. $secretKey = env('VOLC_AI_SK');
  259. if (empty($accessKey) || empty($secretKey)) {
  260. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  261. 'error_message' => '火山引擎访问密钥未配置,请检查环境变量AK和SK'
  262. ]);
  263. return;
  264. }
  265. //构建即梦AI 4.0 API请求参数
  266. $apiParams = [
  267. 'req_key' => 'jimeng_t2i_v40',
  268. 'prompt' => $task->prompt,
  269. 'scale' => $task->scale / 100, //为0-1范围
  270. ];
  271. // 添加可选参数
  272. if ($task->image_num > 1) {
  273. $apiParams['force_single'] = false;
  274. } else {
  275. $apiParams['force_single'] = true;
  276. }
  277. // 参考图片
  278. if ($task->ref_img_url) {
  279. $apiParams['image_urls'] = $task->ref_img_url;
  280. }
  281. // if ($task->mask_img_url) {
  282. // $apiParams['image_urls'] = $task->mask_img_url;
  283. // }
  284. // 尺寸参数
  285. $area = $task->width * $task->height; //计算面积
  286. if ($area >= 1024*1024 && $area <= 4096*4096) {
  287. $apiParams['size'] = (int)$area;
  288. }
  289. dLog('generate')->info('即梦AI文生图参数: ', $apiParams);
  290. logDB('generate', 'info', '即梦AI文生图任务提交', ['task_id' => $task->task_id, 'params' => $apiParams]);
  291. //调用即梦AI 4.0 API提交任务
  292. $response = $this->volcEngineService->request(
  293. 'POST',
  294. 'visual.volcengineapi.com', //即梦AI API域名
  295. '/', // 使用根路径
  296. [], // Query参数
  297. json_encode($apiParams),
  298. $accessKey,
  299. $secretKey,
  300. 'cv', // 服务标识
  301. 'cn-north-1', //区域
  302. 'CVSync2AsyncSubmitTask',
  303. '2022-08-31',
  304. 'application/json'
  305. );
  306. $responseData = json_decode($response['body'], true);
  307. dLog('generate')->info('即梦AI 4.0 API提交任务响应: ', $responseData);
  308. logDB('generate', 'info', '即梦AI 4.0 API提交任务响应', ['task_id' => $task->task_id, 'response' => $responseData]);
  309. if ($responseData['code'] !== 10000) {
  310. // API返回错误
  311. logDB('generate', 'error', '即梦AI任务提交失败', ['task_id' => $task->task_id, 'error' => $responseData['message'] ?? 'API Error']);
  312. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  313. 'error_message' => $responseData['message'] ?? 'API Error',
  314. 'extra_params' => $responseData['data'] ?? []
  315. ]);
  316. } else {
  317. // 任务提交成功,更新状态为处理中
  318. $updateData = [];
  319. //存储API返回的任务ID
  320. if (isset($responseData['data']['task_id'])) {
  321. $updateData['task_id'] = $responseData['data']['task_id'];
  322. $updateData['extra_params'] = $responseData ?? [];
  323. }
  324. $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING, $updateData);
  325. }
  326. } catch (\Exception $e) {
  327. // 记录错误
  328. logDB('generate', 'error', '即梦AI任务提交异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
  329. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  330. 'error_message' => 'API请求失败: ' . $e->getMessage()
  331. ]);
  332. }
  333. }
  334. /**
  335. * 提交任务到GPT-Image2 API
  336. *
  337. * @param MpGeneratePicTask $task
  338. * @return void
  339. */
  340. public function submitTaskToGptImage2Api(MpGeneratePicTask $task): void
  341. {
  342. try {
  343. // 验证环境变量配置
  344. $apiKey = env('GPT_IMAGE2_API_KEY');
  345. if (empty($apiKey)) {
  346. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  347. 'error_message' => 'GPT-Image2 API KEY未配置,请检查环境变量GPT_IMAGE2_API_KEY'
  348. ]);
  349. return;
  350. }
  351. // 更新状态为处理中
  352. $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING);
  353. // $api_url = 'https://token.ithinkai.cn/v1/images/generations'; // 备用地址1
  354. // $api_url = 'https://api.nonelinear.com/v1/images/generations'; // 备用地址2
  355. $api_url = 'https://ai-api.kkidc.com/v1/images/generations';
  356. $isEditMode = false;
  357. // 参考图片 - 如果有参考图则使用 /edits 接口
  358. if ($task->ref_img_url) {
  359. // $api_url = 'https://token.ithinkai.cn/v1/images/edits'; // 备用地址1
  360. // $api_url = 'https://api.nonelinear.com/v1/images/generations'; // 备用地址2
  361. $api_url = 'https://ai-api.kkidc.com/v1/images/edits';
  362. $isEditMode = true;
  363. }
  364. dLog('generate')->info('开始调用GPT-Image2 API', ['task_id' => $task->task_id, 'mode' => $isEditMode ? 'edit' : 'generation']);
  365. logDB('generate', 'info', '开始调用GPT-Image2 API', ['task_id' => $task->task_id, 'mode' => $isEditMode ? 'edit' : 'generation']);
  366. // 创建专用客户端,优化配置
  367. $gptClient = new Client([
  368. 'timeout' => 1800, // 超时
  369. 'connect_timeout' => 120, // 连接超时10秒
  370. 'http_errors' => false, // 不自动抛出HTTP错误
  371. 'verify' => false, // 禁用SSL验证(如果有证书问题)
  372. 'allow_redirects' => true,
  373. ]);
  374. // 根据是否有参考图选择不同的请求方式
  375. if ($isEditMode) {
  376. // 使用原生 cURL 处理 multipart/form-data 格式
  377. $imageUrls = is_array($task->ref_img_url) ? $task->ref_img_url : [$task->ref_img_url];
  378. // 多图上限:暂不限制张数(如需恢复限制,放开下面这行)
  379. // $imageUrls = array_slice($imageUrls, 0, 4);
  380. dLog('generate')->info('下载参考图片', ['image_urls' => $imageUrls, 'count' => count($imageUrls)]);
  381. // 单张参考图超过该大小时压缩,避免请求体过大被网关拒绝
  382. $maxRefBytes = 5 * 1024 * 1024;
  383. // 下载所有参考图片并保存为临时文件
  384. $tempFiles = [];
  385. foreach ($imageUrls as $index => $imageUrl) {
  386. $imageContent = @file_get_contents($imageUrl);
  387. if ($imageContent === false) {
  388. dLog('generate')->warning('下载参考图片失败,跳过', ['image_url' => $imageUrl]);
  389. continue;
  390. }
  391. dLog('generate')->info('参考图片下载完成', ['index' => $index, 'size' => strlen($imageContent)]);
  392. $extension = pathinfo(parse_url($imageUrl, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'png';
  393. // 超过 5MB 时压缩(公共方法保持原图比例与原格式)
  394. if (strlen($imageContent) > $maxRefBytes) {
  395. $rawSize = strlen($imageContent);
  396. $compressedData = compressRemoteImageUrlToSize($imageUrl, $maxRefBytes);
  397. if (!empty($compressedData)) {
  398. $imageContent = $compressedData;
  399. $imgInfo = @getimagesizefromstring($imageContent);
  400. $mime = strtolower($imgInfo['mime'] ?? '');
  401. $extension = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/webp' ? 'webp' : 'png');
  402. } else {
  403. // 公共方法对 JPEG 不做有损压缩,回退到本地缩放
  404. $fallbackData = $this->compressReferenceImageForEdit($imageContent, 2048, 90);
  405. if ($fallbackData !== null) {
  406. $imageContent = $fallbackData;
  407. $extension = 'jpg';
  408. }
  409. }
  410. dLog('generate')->info('参考图片压缩完成', [
  411. 'index' => $index,
  412. 'before' => $rawSize,
  413. 'after' => strlen($imageContent),
  414. 'extension' => $extension,
  415. ]);
  416. }
  417. // 保存为临时文件(cURL 需要真实文件路径)
  418. $tempFile = tempnam(sys_get_temp_dir(), 'gpt_img_') . '.' . $extension;
  419. file_put_contents($tempFile, $imageContent);
  420. $tempFiles[] = [
  421. 'path' => $tempFile,
  422. 'extension' => $extension
  423. ];
  424. dLog('generate')->info('临时文件创建成功', ['file' => $tempFile, 'size' => filesize($tempFile)]);
  425. }
  426. if (empty($tempFiles)) {
  427. throw new \Exception('所有参考图片下载失败');
  428. }
  429. // 构建 POST 字段(文本字段)
  430. $textFields = [
  431. 'prompt' => $task->prompt,
  432. 'model' => 'gpt-image-2',
  433. 'n' => (string)$task->image_num, // 新接口要求字符串类型
  434. 'response_format' => 'b64_json', // 新接口必填参数
  435. ];
  436. // 尺寸参数(必填)
  437. if ($task->width && $task->height) {
  438. $textFields['size'] = $task->width . 'x' . $task->height;
  439. } else {
  440. $textFields['size'] = '1024x1024'; // 默认尺寸
  441. }
  442. // 多张参考图需要在 multipart 中出现重复的 image[] 字段;
  443. // PHP 数组无法承载重复 key(同名会覆盖),因此手动拼装请求体。
  444. // 单张参考图沿用 image 字段,避免影响已有单图任务。
  445. $fileFieldName = count($tempFiles) > 1 ? 'image[]' : 'image';
  446. $boundary = '----MpGptImage' . md5(uniqid('', true));
  447. $postFields = $this->buildMultipartBody($textFields, $tempFiles, $boundary, $fileFieldName);
  448. dLog('generate')->info('GPT-Image2 API参数(编辑模式-cURL)', [
  449. 'api_url' => $api_url,
  450. 'prompt' => $task->prompt,
  451. 'size' => $textFields['size'],
  452. 'n' => $textFields['n'],
  453. 'response_format' => $textFields['response_format'],
  454. 'file_field' => $fileFieldName,
  455. 'body_size' => strlen($postFields),
  456. 'image_count' => count($tempFiles)
  457. ]);
  458. logDB('generate', 'info', 'GPT-Image2任务提交(编辑模式)', [
  459. 'task_id' => $task->task_id,
  460. 'api_url' => $api_url,
  461. 'image_urls' => $imageUrls,
  462. 'prompt' => $task->prompt
  463. ]);
  464. $startTime = microtime(true);
  465. // 使用原生 cURL
  466. $ch = curl_init();
  467. curl_setopt_array($ch, [
  468. CURLOPT_URL => $api_url,
  469. CURLOPT_RETURNTRANSFER => true,
  470. CURLOPT_POST => true,
  471. CURLOPT_POSTFIELDS => $postFields,
  472. CURLOPT_HTTPHEADER => [
  473. 'Authorization: Bearer ' . $apiKey,
  474. 'Accept: application/json',
  475. 'Content-Type: multipart/form-data; boundary=' . $boundary,
  476. 'Content-Length: ' . strlen($postFields),
  477. 'Expect:', // 移除 Expect: 100-continue,避免等待
  478. ],
  479. CURLOPT_TIMEOUT => 600,
  480. CURLOPT_CONNECTTIMEOUT => 30,
  481. CURLOPT_SSL_VERIFYPEER => false,
  482. CURLOPT_SSL_VERIFYHOST => false,
  483. CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, // 强制使用 HTTP/1.1
  484. ]);
  485. dLog('generate')->info('开始发送cURL请求');
  486. $responseBody = curl_exec($ch);
  487. $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  488. $curlError = curl_error($ch);
  489. $curlErrno = curl_errno($ch);
  490. curl_close($ch);
  491. // 删除所有临时文件
  492. foreach ($tempFiles as $tempFile) {
  493. @unlink($tempFile['path']);
  494. }
  495. $elapsed = round(microtime(true) - $startTime, 2);
  496. dLog('generate')->info('cURL请求完成', ['elapsed' => $elapsed . 's', 'http_code' => $statusCode, 'curl_errno' => $curlErrno]);
  497. if ($curlErrno !== 0) {
  498. throw new \Exception('cURL错误: ' . $curlError . ' (错误码: ' . $curlErrno . ')');
  499. }
  500. if (empty($responseBody)) {
  501. throw new \Exception('API返回空响应,HTTP状态码: ' . $statusCode);
  502. }
  503. } else {
  504. // 使用 JSON 格式 (原有的文生图模式)
  505. $apiParams = [
  506. 'model' => 'gpt-image-2',
  507. 'prompt' => $task->prompt,
  508. 'n' => (int)$task->image_num,
  509. 'quality' => 'auto', // 新接口必填参数:图像质量等级
  510. 'response_format' => 'b64_json', // 新接口必填参数:返回格式
  511. ];
  512. // 尺寸参数
  513. if ($task->width && $task->height) {
  514. $apiParams['size'] = $task->width . 'x' . $task->height;
  515. } else {
  516. $apiParams['size'] = 'auto'; // 新接口必填参数
  517. }
  518. dLog('generate')->info('GPT-Image2 API参数', $apiParams);
  519. logDB('generate', 'info', 'GPT-Image2任务提交', ['task_id' => $task->task_id, 'api_url' => $api_url, 'params' => $apiParams]);
  520. $startTime = microtime(true);
  521. // 调用GPT-Image2 API
  522. $response = $gptClient->post($api_url, [
  523. 'headers' => [
  524. 'Authorization' => 'Bearer ' . $apiKey,
  525. 'Content-Type' => 'application/json',
  526. ],
  527. 'json' => $apiParams,
  528. ]);
  529. $statusCode = $response->getStatusCode();
  530. $responseBody = $response->getBody()->getContents();
  531. $elapsed = round(microtime(true) - $startTime, 2);
  532. dLog('generate')->info('Guzzle请求完成', ['elapsed' => $elapsed . 's', 'http_code' => $statusCode]);
  533. if (empty($responseBody)) {
  534. throw new \Exception('API返回空响应,HTTP状态码: ' . $statusCode);
  535. }
  536. }
  537. // 解析响应
  538. dLog('generate')->info('响应数据长度', ['length' => strlen($responseBody)]);
  539. $responseData = json_decode($responseBody, true);
  540. if (json_last_error() !== JSON_ERROR_NONE) {
  541. dLog('generate')->error('JSON解析失败', ['error' => json_last_error_msg(), 'body' => substr($responseBody, 0, 500)]);
  542. throw new \Exception('JSON解析失败: ' . json_last_error_msg());
  543. }
  544. // 移除 b64_json 字段用于日志记录,避免生成超大日志
  545. $logResponseData = $this->removeB64JsonFromResponse($responseData);
  546. dLog('generate')->info('GPT-Image2 API响应', $logResponseData);
  547. logDB('generate', 'info', 'GPT-Image2 API响应', ['task_id' => $task->task_id, 'response' => $logResponseData]);
  548. if (isset($responseData['error'])) {
  549. // API返回错误
  550. logDB('generate', 'error', 'GPT-Image2任务失败', ['task_id' => $task->task_id, 'error' => $responseData['error']['message'] ?? 'API Error']);
  551. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  552. 'error_message' => $responseData['error']['message'] ?? 'API Error',
  553. 'result_json' => $logResponseData
  554. ]);
  555. } else {
  556. // 任务提交成功,直接处理结果
  557. dLog('generate')->info('开始处理GPT-Image2响应结果');
  558. $this->processGptImage2ApiResponse($task, $responseData);
  559. }
  560. } catch (\Exception $e) {
  561. // 记录错误
  562. dLog('generate')->error('GPT-Image2 API请求失败: ' . $e->getMessage(), [
  563. 'task_id' => $task->task_id,
  564. 'trace' => $e->getTraceAsString()
  565. ]);
  566. logDB('generate', 'error', 'GPT-Image2任务异常', [
  567. 'task_id' => $task->task_id,
  568. 'error' => $e->getMessage(),
  569. 'file' => $e->getFile(),
  570. 'line' => $e->getLine()
  571. ]);
  572. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  573. 'error_message' => 'API请求失败: ' . $e->getMessage()
  574. ]);
  575. }
  576. }
  577. /**
  578. * 压缩参考图片(兜底方案,用于公共压缩方法不处理的场景,如大尺寸 JPEG)
  579. *
  580. * 缩放到长边不超过 $maxSide,并统一输出 JPEG(透明背景填白)。
  581. *
  582. * @param string $imageStream 图片二进制数据
  583. * @param int $maxSide 长边像素上限
  584. * @param int $quality JPEG 质量
  585. * @return string|null 压缩后的二进制数据,失败返回 null
  586. */
  587. private function compressReferenceImageForEdit(string $imageStream, int $maxSide = 1536, int $quality = 90): ?string
  588. {
  589. try {
  590. $imgInfo = @getimagesizefromstring($imageStream);
  591. if (!$imgInfo) {
  592. return null;
  593. }
  594. $srcImg = @imagecreatefromstring($imageStream);
  595. if (!$srcImg) {
  596. return null;
  597. }
  598. $origW = imagesx($srcImg);
  599. $origH = imagesy($srcImg);
  600. $scale = $maxSide / max($origW, $origH);
  601. if ($scale < 1) {
  602. $newW = max(1, (int)round($origW * $scale));
  603. $newH = max(1, (int)round($origH * $scale));
  604. $resizedImg = imagecreatetruecolor($newW, $newH);
  605. // JPEG 不支持透明通道,用白底填充
  606. $white = imagecolorallocate($resizedImg, 255, 255, 255);
  607. imagefill($resizedImg, 0, 0, $white);
  608. imagecopyresampled($resizedImg, $srcImg, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
  609. imagedestroy($srcImg);
  610. $srcImg = $resizedImg;
  611. } elseif ($imgInfo['mime'] !== 'image/jpeg') {
  612. // 未缩放但非 JPEG:铺白底再输出,避免透明区域变黑
  613. $flatImg = imagecreatetruecolor($origW, $origH);
  614. $white = imagecolorallocate($flatImg, 255, 255, 255);
  615. imagefill($flatImg, 0, 0, $white);
  616. imagecopy($flatImg, $srcImg, 0, 0, 0, 0, $origW, $origH);
  617. imagedestroy($srcImg);
  618. $srcImg = $flatImg;
  619. }
  620. ob_start();
  621. imagejpeg($srcImg, null, $quality);
  622. $compressed = ob_get_clean();
  623. imagedestroy($srcImg);
  624. return ($compressed === false || $compressed === '') ? null : $compressed;
  625. } catch (\Throwable $e) {
  626. dLog('generate')->warning('参考图片压缩失败,使用原图', ['error' => $e->getMessage()]);
  627. return null;
  628. }
  629. }
  630. /**
  631. * 构建 multipart/form-data 请求体
  632. *
  633. * 参考图字段需要在请求体中出现多次(image[]),而 PHP 数组同一个 key 只能保留最后一个值,
  634. * 用 CURLOPT_POSTFIELDS 传数组会导致多张参考图互相覆盖,所以这里手动拼装请求体字符串。
  635. *
  636. * @param array $textFields 普通文本字段 [name => value]
  637. * @param array $files 文件字段,每项含 path、extension
  638. * @param string $boundary multipart 分隔符
  639. * @param string $fileFieldName 文件字段名,多图时传 image[],单图传 image
  640. * @return string
  641. */
  642. private function buildMultipartBody(array $textFields, array $files, string $boundary, string $fileFieldName = 'image[]'): string
  643. {
  644. $eol = "\r\n";
  645. $body = '';
  646. foreach ($textFields as $name => $value) {
  647. $body .= '--' . $boundary . $eol;
  648. $body .= 'Content-Disposition: form-data; name="' . $name . '"' . $eol . $eol;
  649. $body .= (string)$value . $eol;
  650. }
  651. foreach ($files as $index => $file) {
  652. $extension = $file['extension'];
  653. $filename = 'image' . ($index + 1) . '.' . $extension;
  654. $mime = strtolower($extension) === 'jpg' ? 'image/jpeg' : 'image/' . $extension;
  655. $content = @file_get_contents($file['path']);
  656. if ($content === false) {
  657. throw new \Exception('读取参考图片临时文件失败: ' . $file['path']);
  658. }
  659. $body .= '--' . $boundary . $eol;
  660. $body .= 'Content-Disposition: form-data; name="' . $fileFieldName . '"; filename="' . $filename . '"' . $eol;
  661. $body .= 'Content-Type: ' . $mime . $eol . $eol;
  662. $body .= $content . $eol;
  663. unset($content);
  664. }
  665. $body .= '--' . $boundary . '--' . $eol;
  666. return $body;
  667. }
  668. /**
  669. * 处理GPT-Image2 API响应结果
  670. *
  671. * @param MpGeneratePicTask $task
  672. * @param array $responseData
  673. * @return void
  674. */
  675. private function processGptImage2ApiResponse(MpGeneratePicTask $task, array $responseData): void
  676. {
  677. try {
  678. if (!isset($responseData['data']) || empty($responseData['data'])) {
  679. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  680. 'error_message' => '未返回图片数据',
  681. 'result_json' => $responseData
  682. ]);
  683. return;
  684. }
  685. $result_urls = [];
  686. foreach ($responseData['data'] as $index => $imageData) {
  687. // 优先处理 b64_json 格式(新接口返回格式)
  688. if (isset($imageData['b64_json']) && !empty($imageData['b64_json'])) {
  689. $base64Data = $imageData['b64_json'];
  690. $imageStream = base64_decode($base64Data);
  691. if ($imageStream === false) {
  692. dLog('generate')->error('Base64解码失败', ['task_id' => $task->task_id, 'index' => $index]);
  693. continue;
  694. }
  695. $pic_name = 'ai_generation_gpt_' . time() . '_' . uniqid() . '.png';
  696. // 压缩PNG图片流
  697. $compressedData = $this->compressImageStream($imageStream);
  698. $url = uploadStreamByTos('image', $compressedData, $pic_name);
  699. $result_urls[] = $url;
  700. // 记录优化后的提示词(如果有)
  701. if (isset($imageData['revised_prompt'])) {
  702. dLog('generate')->info('图片生成优化提示词', [
  703. 'task_id' => $task->task_id,
  704. 'index' => $index,
  705. 'revised_prompt' => $imageData['revised_prompt']
  706. ]);
  707. }
  708. } elseif (isset($imageData['url']) && !empty($imageData['url'])) {
  709. // 兼容旧的 url 格式(如果接口仍返回)
  710. $url = $imageData['url'];
  711. $pic_name = 'ai_generation_gpt_' . time() . '_' . uniqid();
  712. $pic_ext = getImgExtFromUrl($url);
  713. $pic_name = $pic_name . $pic_ext;
  714. if ($pic_ext === '.png') {
  715. $comporessed_data = compressRemoteImageUrlToSize($url);
  716. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  717. } else {
  718. $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
  719. }
  720. $result_urls[] = $url;
  721. }
  722. }
  723. if (empty($result_urls)) {
  724. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  725. 'error_message' => '所有图片生成失败',
  726. 'result_json' => $responseData
  727. ]);
  728. return;
  729. }
  730. // 移除 b64_json 字段以避免存储大量 Base64 数据
  731. $cleanedResponseData = $responseData;
  732. if (isset($cleanedResponseData['data'])) {
  733. foreach ($cleanedResponseData['data'] as &$imageData) {
  734. unset($imageData['b64_json']); // 移除 Base64 数据
  735. }
  736. unset($imageData); // 解除引用
  737. }
  738. // 图片生成成功统一处理(事务:状态更新 + 计费 + 分镜同步)
  739. $this->handleImageTaskSuccess($task, $result_urls, $cleanedResponseData);
  740. } catch (\Exception $e) {
  741. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  742. 'error_message' => '处理响应失败: ' . $e->getMessage()
  743. ]);
  744. }
  745. }
  746. /**
  747. * 提交任务到火山图片生成API
  748. *
  749. * @param MpGeneratePicTask $task
  750. * @return void
  751. */
  752. public function submitTaskToVolcApi(MpGeneratePicTask $task): void
  753. {
  754. try {
  755. // 验证环境变量配置
  756. $apiKey = env('VOLC_AI_API_KEY');
  757. if (empty($apiKey)) {
  758. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  759. 'error_message' => '火山引擎API KEY未配置,请检查环境变量VOLC_AI_API_KEY'
  760. ]);
  761. return;
  762. }
  763. // 更新状态为处理中
  764. $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING);
  765. // 构建火山图片生成API请求参数
  766. $apiParams = [
  767. 'model' => isset(BaseConst::MODELS_MAP[$task->model]) ? BaseConst::MODELS_MAP[$task->model] : $task->model,
  768. 'prompt' => $task->prompt,
  769. 'watermark' => false,
  770. ];
  771. // 尺寸参数
  772. if ($task->width && $task->height) {
  773. $apiParams['size'] = $task->width . 'x' . $task->height;
  774. }
  775. // 参考图片
  776. if ($task->ref_img_url) {
  777. if (is_array($task->ref_img_url)) {
  778. // $apiParams['image'] = count($task->ref_img_url) === 1 ? $task->ref_img_url[0] : $task->ref_img_url;
  779. $apiParams['image'] = $task->ref_img_url;
  780. } else {
  781. $apiParams['image'] = $task->ref_img_url;
  782. }
  783. }
  784. // 生成多图或者单图
  785. $imageNum = (int)$task->image_num;
  786. if ($imageNum === 1) {
  787. $apiParams['sequential_image_generation'] = 'disabled';
  788. }else if ($imageNum > 1) {
  789. // 组图模式下由模型决定实际生成张数,这里在提示词末尾明确要求生成对应数量,并将组图上限设置为目标张数
  790. $apiParams['prompt'] = rtrim((string)$task->prompt) . "\n生成{$imageNum}张图片";
  791. $apiParams['sequential_image_generation'] = 'auto';
  792. $apiParams['sequential_image_generation_options']['max_images'] = $imageNum;
  793. }
  794. dLog('generate')->info('火山图片生成API参数: ', $apiParams);
  795. logDB('generate', 'info', '火山图片生成任务提交', ['task_id' => $task->task_id, 'params' => $apiParams]);
  796. // 调用火山图片生成API
  797. $response = $this->httpClient->post('https://ark.cn-beijing.volces.com/api/v3/images/generations', [
  798. 'headers' => [
  799. 'Authorization' => 'Bearer ' . $apiKey,
  800. 'Content-Type' => 'application/json',
  801. ],
  802. 'json' => $apiParams,
  803. ]);
  804. $responseData = json_decode($response->getBody()->getContents(), true);
  805. dLog('generate')->info('火山图片生成API响应: ', $responseData);
  806. logDB('generate', 'info', '火山图片生成API响应', ['task_id' => $task->task_id, 'response' => $responseData]);
  807. if (isset($responseData['error'])) {
  808. // API返回错误
  809. logDB('generate', 'error', '火山图片生成任务失败', ['task_id' => $task->task_id, 'error' => $responseData['error']['message'] ?? 'API Error']);
  810. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  811. 'error_message' => $responseData['error']['message'] ?? 'API Error',
  812. 'result_json' => $responseData
  813. ]);
  814. } else {
  815. // 任务提交成功,直接处理结果
  816. $this->processVolcApiResponse($task, $responseData);
  817. }
  818. } catch (\Exception $e) {
  819. // 记录错误
  820. dLog('generate')->error('火山API请求失败: ' . $e->getMessage());
  821. logDB('generate', 'error', '火山图片生成任务异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
  822. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  823. 'error_message' => 'API请求失败: ' . $e->getMessage()
  824. ]);
  825. }
  826. }
  827. /**
  828. * 处理火山API响应结果
  829. *
  830. * @param MpGeneratePicTask $task
  831. * @param array $responseData
  832. * @return void
  833. */
  834. private function processVolcApiResponse(MpGeneratePicTask $task, array $responseData): void
  835. {
  836. try {
  837. if (!isset($responseData['data']) || empty($responseData['data'])) {
  838. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  839. 'error_message' => '未返回图片数据',
  840. 'result_json' => $responseData
  841. ]);
  842. return;
  843. }
  844. $result_urls = [];
  845. foreach ($responseData['data'] as $imageData) {
  846. if (isset($imageData['error'])) {
  847. // 单张图片生成失败
  848. dLog('generate')->warning('单张图片生成失败: ', $imageData['error']);
  849. continue;
  850. }
  851. if (isset($imageData['url'])) {
  852. $url = $imageData['url'];
  853. $pic_name = 'ai_generation_' . time() . '_' . uniqid();
  854. $pic_ext = getImgExtFromUrl($url);
  855. $pic_name = $pic_name . $pic_ext;
  856. if ($pic_ext === '.png') {
  857. $comporessed_data = compressRemoteImageUrlToSize($url);
  858. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  859. } else {
  860. // 将图片另存到tos
  861. $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
  862. }
  863. $result_urls[] = $url;
  864. }
  865. }
  866. if (empty($result_urls)) {
  867. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  868. 'error_message' => '所有图片生成失败',
  869. 'result_json' => $responseData
  870. ]);
  871. return;
  872. }
  873. // 图片生成成功统一处理(事务:状态更新 + 计费 + 分镜同步)
  874. $this->handleImageTaskSuccess($task, $result_urls, $responseData);
  875. } catch (\Exception $e) {
  876. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  877. 'error_message' => '处理响应失败: ' . $e->getMessage()
  878. ]);
  879. }
  880. }
  881. public function getTaskList($data)
  882. {
  883. $task_id = getProp($data, 'task_id');
  884. $status = getProp($data, 'status');
  885. $query = MpGeneratePicTask::select('*');
  886. if ($task_id) {
  887. $query->where('id', $task_id);
  888. }
  889. if ($status) {
  890. $query->where('status', $status);
  891. }
  892. return $query->orderBy('created_at', 'desc')->paginate();
  893. }
  894. /**
  895. * 查询任务状态
  896. *
  897. * @param MpGeneratePicTask $task
  898. * @return array
  899. */
  900. public function queryTaskStatus(MpGeneratePicTask $task): array
  901. {
  902. // 根据模型类型调用不同的查询方法
  903. if ($task->model === self::MODEL_JIMENG_4) {
  904. return $this->queryJimengTaskStatus($task);
  905. } elseif (in_array($task->model, BaseConst::NANO_BANANA_MODELS)) {
  906. return $this->queryNanoBananaTaskStatus($task);
  907. } else {
  908. // 火山API是同步返回结果,不需要查询
  909. return [
  910. 'status' => $task->status === MpGeneratePicTask::STATUS_SUCCESS ? 'success' : 'failed',
  911. 'error_message' => $task->error_message,
  912. 'result_url' => $task->result_url,
  913. 'result_json' => $task->result_json ?? []
  914. ];
  915. }
  916. }
  917. /**
  918. * 查询即梦AI任务状态
  919. *
  920. * @param MpGeneratePicTask $task
  921. * @return array
  922. */
  923. private function queryJimengTaskStatus(MpGeneratePicTask $task): array
  924. {
  925. try {
  926. // 获取API任务ID
  927. $apiTaskId = $task->task_id ?? null;
  928. if (!$apiTaskId) {
  929. return [
  930. 'status' => 'failed',
  931. 'error_message' => 'API任务ID不存在',
  932. 'result_url' => null
  933. ];
  934. }
  935. //构建查询参数
  936. $apiParams = [
  937. 'req_key' => 'jimeng_t2i_v40',
  938. 'task_id' => $apiTaskId,
  939. 'req_json' => '{"return_url":true}' // 返回URL格式
  940. ];
  941. //调用即梦AI 4.0 API查询任务状态
  942. $response = $this->volcEngineService->request(
  943. 'POST',
  944. 'visual.volcengineapi.com',
  945. '/', // 使用根路径
  946. [], // Query参数
  947. json_encode($apiParams),
  948. env('VOLC_AI_AK'),
  949. env('VOLC_AI_SK'),
  950. 'cv',
  951. 'cn-north-1',
  952. 'CVSync2AsyncGetResult',
  953. '2022-08-31',
  954. 'application/json'
  955. );
  956. $responseData = json_decode($response['body'], true);
  957. dLog('generate')->info('即梦AI 4.0 API查询任务状态响应: ', $responseData);
  958. logDB('generate', 'info', '即梦AI任务状态查询', ['task_id' => $apiTaskId, 'response' => $responseData]);
  959. if ($responseData['code'] !== 10000) {
  960. // API返回错误
  961. logDB('generate', 'error', '即梦AI任务状态查询失败', ['task_id' => $apiTaskId, 'error' => $responseData['message'] ?? 'API Error']);
  962. return [
  963. 'status' => 'failed',
  964. 'error_message' => $responseData['message'] ?? 'API Error',
  965. 'result_url' => null,
  966. 'result_json' => $responseData ?? []
  967. ];
  968. }
  969. // 解析API响应
  970. $result = $responseData['data'] ?? [];
  971. $taskStatus = $result['status'] ?? 'failed';
  972. $returnData = [
  973. 'status' => $taskStatus,
  974. 'error_message' => null,
  975. 'result_url' => null,
  976. 'result_json' => $responseData ?? []
  977. ];
  978. // 如果任务成功,获取结果URL
  979. if ($taskStatus === 'done' && isset($result['image_urls']) && count($result['image_urls']) > 0) {
  980. $result_urls = [];
  981. foreach ($result['image_urls'] as $url) {
  982. $pic_name = 'ai_generation_' . time() . '_' . uniqid();
  983. $pic_ext = getImgExtFromUrl($url);
  984. $pic_name = $pic_name . $pic_ext;
  985. if ($pic_ext === '.png') {
  986. $comporessed_data = compressRemoteImageUrlToSize($url);
  987. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  988. }else {
  989. // 将图片另存到tos
  990. $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
  991. }
  992. $result_urls[] = $url;
  993. }
  994. $returnData['result_url'] = $result_urls;
  995. $returnData['status'] = 'success';
  996. } elseif (in_array($taskStatus, ['not_found', 'expired'])) {
  997. $returnData['status'] = 'failed';
  998. $returnData['error_message'] = '任务未找到或已过期';
  999. } elseif ($taskStatus === 'failed') {
  1000. $returnData['status'] = 'failed';
  1001. $returnData['error_message'] = '任务执行失败';
  1002. }
  1003. // in_queue 或 generating状态保持原status
  1004. return $returnData;
  1005. } catch (\Exception $e) {
  1006. return [
  1007. 'status' => 'failed',
  1008. 'error_message' => $e->getMessage(),
  1009. 'result_url' => null
  1010. ];
  1011. }
  1012. }
  1013. /**
  1014. * 提交任务到NanoBanana API
  1015. *
  1016. * @param MpGeneratePicTask $task
  1017. * @return void
  1018. */
  1019. private function submitTaskToNanoBananaApi(MpGeneratePicTask $task): void
  1020. {
  1021. try {
  1022. // 验证环境变量配置
  1023. $apiKey = env('NANO_BANANA_API_KEY');
  1024. if (empty($apiKey)) {
  1025. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1026. 'error_message' => 'NanoBanana API KEY未配置,请检查环境变量NANO_BANANA_API_KEY'
  1027. ]);
  1028. return;
  1029. }
  1030. // 根据模型选择API地址
  1031. switch ($task->model) {
  1032. case self::MODEL_NANO_BANANA_2:
  1033. $apiUrl = 'https://api.wuyinkeji.com/api/async/image_nanoBanana2';
  1034. break;
  1035. case self::MODEL_NANO_BANANA_PRO:
  1036. $apiUrl = 'https://api.wuyinkeji.com/api/async/image_nanoBanana_pro';
  1037. break;
  1038. case self::MODEL_GPT_IMAGE_2:
  1039. $apiUrl = 'https://api.wuyinkeji.com/api/async/image_gpt';
  1040. break;
  1041. default:
  1042. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1043. 'error_message' => '不支持的模型类型: ' . $task->model
  1044. ]);
  1045. logDB('generate', 'error', '模型类型错误', ['task_id' => $task->task_id, 'model' => $task->model]);
  1046. return;
  1047. }
  1048. // 构建API请求参数
  1049. $apiParams = [
  1050. 'key' => $apiKey,
  1051. 'prompt' => $task->prompt,
  1052. ];
  1053. if ($task->model == self::MODEL_GPT_IMAGE_2) {
  1054. // 该模型下,size需设置为9:16类似的数值,请根据如下比例反推
  1055. $apiParams['size'] = $this->getRatioFromSize($task->width, $task->height);
  1056. }else {
  1057. // 尺寸参数(根据宽高判断)
  1058. $area = $task->width * $task->height;
  1059. if ($area >= 1024*1024 && $area < 2048*2048) {
  1060. $apiParams['size'] = '1k';
  1061. } elseif ($area >= 2048*2048) {
  1062. $apiParams['size'] = '2k';
  1063. } else {
  1064. $apiParams['size'] = '1k'; // 默认1k
  1065. }
  1066. // 画面比例
  1067. if ($task->width && $task->height) {
  1068. $ratio = $task->width . ':' . $task->height;
  1069. // 简化比例
  1070. $gcd = $this->gcd($task->width, $task->height);
  1071. $ratio = ($task->width / $gcd) . ':' . ($task->height / $gcd);
  1072. $apiParams['aspectRatio'] = $ratio;
  1073. }
  1074. }
  1075. // 参考图片
  1076. if ($task->ref_img_url) {
  1077. $apiParams['urls'] = is_array($task->ref_img_url) ? $task->ref_img_url : [$task->ref_img_url];
  1078. }
  1079. dLog('generate')->info('NanoBanana API参数: ', $apiParams);
  1080. logDB('generate', 'info', 'NanoBanana任务提交', ['task_id' => $task->task_id, 'model' => $task->model, 'params' => $apiParams]);
  1081. // 调用NanoBanana API
  1082. $response = $this->httpClient->post($apiUrl, [
  1083. 'query' => ['key' => $apiKey],
  1084. 'json' => $apiParams,
  1085. ]);
  1086. $responseData = json_decode($response->getBody()->getContents(), true);
  1087. dLog('generate')->info('NanoBanana API响应: ', $responseData);
  1088. logDB('generate', 'info', 'NanoBanana API响应', ['task_id' => $task->task_id, 'response' => $responseData]);
  1089. if ($responseData['code'] !== 200) {
  1090. // API返回错误
  1091. logDB('generate', 'error', 'NanoBanana任务提交失败', ['task_id' => $task->task_id, 'error' => $responseData['msg'] ?? 'API Error']);
  1092. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1093. 'error_message' => $responseData['msg'] ?? 'API Error',
  1094. 'extra_params' => $responseData
  1095. ]);
  1096. } else {
  1097. // 任务提交成功,更新状态为处理中
  1098. $updateData = [];
  1099. // 存储API返回的任务ID
  1100. if (isset($responseData['data']['id'])) {
  1101. $updateData['task_id'] = $responseData['data']['id'];
  1102. $updateData['extra_params'] = $responseData;
  1103. }
  1104. $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING, $updateData);
  1105. }
  1106. } catch (\Exception $e) {
  1107. // 记录错误
  1108. logDB('generate', 'error', 'NanoBanana任务提交异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
  1109. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1110. 'error_message' => 'API请求失败: ' . $e->getMessage()
  1111. ]);
  1112. }
  1113. }
  1114. /**
  1115. * 查询NanoBanana任务状态
  1116. *
  1117. * @param MpGeneratePicTask $task
  1118. * @return array
  1119. */
  1120. private function queryNanoBananaTaskStatus(MpGeneratePicTask $task): array
  1121. {
  1122. try {
  1123. // 获取API任务ID
  1124. $apiTaskId = $task->task_id ?? null;
  1125. $apiKey = env('NANO_BANANA_API_KEY');
  1126. if (!$apiTaskId) {
  1127. return [
  1128. 'status' => 'failed',
  1129. 'error_message' => 'API任务ID不存在',
  1130. 'result_url' => null
  1131. ];
  1132. }
  1133. if (empty($apiKey)) {
  1134. return [
  1135. 'status' => 'failed',
  1136. 'error_message' => 'NanoBanana API KEY未配置',
  1137. 'result_url' => null
  1138. ];
  1139. }
  1140. // 调用统一查询接口
  1141. $response = $this->httpClient->get('https://api.wuyinkeji.com/api/async/detail', [
  1142. 'query' => [
  1143. 'key' => $apiKey,
  1144. 'id' => $apiTaskId
  1145. ]
  1146. ]);
  1147. $responseData = json_decode($response->getBody()->getContents(), true);
  1148. dLog('generate')->info('NanoBanana任务状态查询响应: ', $responseData);
  1149. logDB('generate', 'info', 'NanoBanana任务状态查询', ['task_id' => $apiTaskId, 'response' => $responseData]);
  1150. if ($responseData['code'] !== 200) {
  1151. // API返回错误
  1152. logDB('generate', 'error', 'NanoBanana任务状态查询失败', ['task_id' => $apiTaskId, 'error' => $responseData['msg'] ?? 'API Error']);
  1153. return [
  1154. 'status' => 'failed',
  1155. 'error_message' => $responseData['msg'] ?? 'API Error',
  1156. 'result_url' => null,
  1157. 'result_json' => $responseData
  1158. ];
  1159. }
  1160. // 解析API响应
  1161. $data = $responseData['data'] ?? [];
  1162. $taskStatus = $data['status'] ?? 0;
  1163. $returnData = [
  1164. 'status' => 'processing',
  1165. 'error_message' => null,
  1166. 'result_url' => null,
  1167. 'result_json' => $responseData
  1168. ];
  1169. // status: 2表示成功
  1170. if ($taskStatus === 2 && isset($data['result']) && !empty($data['result'])) {
  1171. $result_urls = [];
  1172. foreach ($data['result'] as $url) {
  1173. $pic_name = 'ai_generation_nanobanana_' . time() . '_' . uniqid();
  1174. $pic_ext = getImgExtFromUrl($url);
  1175. $pic_name = $pic_name . $pic_ext;
  1176. if ($pic_ext === '.png') {
  1177. $comporessed_data = compressRemoteImageUrlToSize($url);
  1178. $url = uploadStreamByTos('image', $comporessed_data, $pic_name);
  1179. } else {
  1180. // 将图片另存到tos
  1181. $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
  1182. }
  1183. $result_urls[] = $url;
  1184. }
  1185. $returnData['result_url'] = $result_urls;
  1186. $returnData['status'] = 'success';
  1187. } elseif ($taskStatus === 3) {
  1188. // status: 3表示失败
  1189. $returnData['status'] = 'failed';
  1190. $returnData['error_message'] = $data['message'] ?? '任务执行失败';
  1191. }
  1192. // status: 0或1表示处理中,保持processing状态
  1193. return $returnData;
  1194. } catch (\Exception $e) {
  1195. return [
  1196. 'status' => 'failed',
  1197. 'error_message' => $e->getMessage(),
  1198. 'result_url' => null
  1199. ];
  1200. }
  1201. }
  1202. /**
  1203. * 计算最大公约数(用于简化比例)
  1204. *
  1205. * @param int $a
  1206. * @param int $b
  1207. * @return int
  1208. */
  1209. private function gcd(int $a, int $b): int
  1210. {
  1211. while ($b != 0) {
  1212. $temp = $b;
  1213. $b = $a % $b;
  1214. $a = $temp;
  1215. }
  1216. return $a;
  1217. }
  1218. /**
  1219. * 创建素材
  1220. *
  1221. * @param array $params
  1222. * @return array
  1223. */
  1224. public function createAsset(array $params): array
  1225. {
  1226. try {
  1227. // 验证必填参数
  1228. if (empty($params['url'])) {
  1229. return [
  1230. 'code' => 1,
  1231. 'msg' => '素材URL不能为空',
  1232. 'data' => null
  1233. ];
  1234. }
  1235. // 先获取表中是否已有该url(仅限智帧AI来源的素材)
  1236. $asset = DB::table('mp_assets')->where('url', $params['url'])->where('source_type', 'zhizhen')->first();
  1237. if ($asset) {
  1238. return [
  1239. 'code' => 0,
  1240. 'msg' => '创建成功',
  1241. 'data' => [
  1242. 'id' => $asset->id,
  1243. 'asset_id' => $asset->asset_id,
  1244. 'group_id' => $asset->group_id,
  1245. 'group_name' => $asset->group_name,
  1246. ]
  1247. ];
  1248. }
  1249. // 获取API配置
  1250. $apiKey = env('ZHIZHEN_AI_SK');
  1251. $apiUrl = 'https://model-relay.zzengine.net';
  1252. if (empty($apiKey)) {
  1253. return [
  1254. 'code' => 1002,
  1255. 'msg' => '智帧AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
  1256. 'data' => null
  1257. ];
  1258. }
  1259. // 构建请求参数
  1260. $requestData = [
  1261. 'url' => $params['url'],
  1262. 'asset_type' => $params['asset_type'] ?? MpAsset::TYPE_IMAGE,
  1263. ];
  1264. // 可选参数
  1265. if (!empty($params['name'])) {
  1266. $requestData['name'] = $params['name'];
  1267. }
  1268. if (!empty($params['group_id'])) {
  1269. $requestData['group_id'] = $params['group_id'];
  1270. }
  1271. dLog('generate')->info('智帧AI创建素材请求参数: ', $requestData);
  1272. // 调用智帧AI API
  1273. $response = $this->httpClient->post($apiUrl . '/api/v1/asset/create', [
  1274. 'headers' => [
  1275. 'X-Api-Key' => $apiKey,
  1276. 'Content-Type' => 'application/json',
  1277. ],
  1278. 'json' => $requestData,
  1279. ]);
  1280. $responseData = json_decode($response->getBody()->getContents(), true);
  1281. dLog('generate')->info('智帧AI创建素材响应: ', $responseData);
  1282. logDB('generate', 'info', '智帧AI创建素材', ['params' => $requestData, 'response' => $responseData]);
  1283. if ($responseData['code'] !== 0) {
  1284. return [
  1285. 'code' => $responseData['code'],
  1286. 'msg' => $responseData['msg'] ?? '创建素材失败',
  1287. 'data' => null
  1288. ];
  1289. }
  1290. // 保存素材记录到数据库
  1291. $assetData = $responseData['data'] ?? [];
  1292. $asset = MpAsset::create([
  1293. 'asset_id' => $assetData['asset_id'] ?? '',
  1294. 'group_id' => $assetData['group_id'] ?? null,
  1295. 'group_name' => $assetData['group_name'] ?? null,
  1296. 'name' => $params['name'] ?? null,
  1297. 'url' => $params['url'],
  1298. 'asset_type' => $params['asset_type'] ?? MpAsset::TYPE_IMAGE,
  1299. 'source_type' => 'zhizhen',
  1300. 'status' => MpAsset::STATUS_PROCESSING,
  1301. 'extra_params' => $responseData,
  1302. ]);
  1303. return [
  1304. 'code' => 0,
  1305. 'msg' => '创建成功',
  1306. 'data' => [
  1307. 'id' => $asset->id,
  1308. 'asset_id' => $asset->asset_id,
  1309. 'group_id' => $asset->group_id,
  1310. 'group_name' => $asset->group_name,
  1311. ]
  1312. ];
  1313. } catch (\Exception $e) {
  1314. dLog('generate')->error('创建素材异常: ' . $e->getMessage());
  1315. logDB('generate', 'error', '创建素材异常', ['params' => $params, 'error' => $e->getMessage()]);
  1316. return [
  1317. 'code' => 20003,
  1318. 'msg' => '创建素材失败: ' . $e->getMessage(),
  1319. 'data' => null
  1320. ];
  1321. }
  1322. }
  1323. /**
  1324. * 查询素材
  1325. *
  1326. * @param string $assetId
  1327. * @return array
  1328. */
  1329. public function getAsset(string $assetId): array
  1330. {
  1331. try {
  1332. // 验证参数
  1333. if (empty($assetId)) {
  1334. return [
  1335. 'code' => 20003,
  1336. 'msg' => '素材ID不能为空',
  1337. 'data' => null
  1338. ];
  1339. }
  1340. // 先获取表中是否已有该素材(仅限智帧AI来源的素材)
  1341. $assetInfo = DB::table('mp_assets')->where('asset_id', $assetId)->where('source_type', 'zhizhen')->first();
  1342. if ($assetInfo) {
  1343. $assetInfo = (array)$assetInfo;
  1344. $status = getProp($assetInfo, 'status');
  1345. if (in_array($status, ['Active', 'Failed'])) {
  1346. $assetInfo['id'] = $assetInfo['asset_id'];
  1347. return [
  1348. 'code' => 0,
  1349. 'msg' => '查询成功',
  1350. 'data' => [
  1351. 'asset' => $assetInfo
  1352. ]
  1353. ];
  1354. }
  1355. }
  1356. // 获取API配置
  1357. $apiKey = env('ZHIZHEN_AI_SK');
  1358. $apiUrl = 'https://model-relay.zzengine.net';
  1359. if (empty($apiKey)) {
  1360. return [
  1361. 'code' => 1002,
  1362. 'msg' => '智帧AI API KEY未配置,请检查环境变量ZHIZHEN_AI_SK',
  1363. 'data' => null
  1364. ];
  1365. }
  1366. dLog('generate')->info('智帧AI查询素材: ' . $assetId);
  1367. // 调用智帧AI API
  1368. $response = $this->httpClient->post($apiUrl . '/api/v1/asset/get', [
  1369. 'headers' => [
  1370. 'X-Api-Key' => $apiKey,
  1371. 'Content-Type' => 'application/json',
  1372. ],
  1373. 'json' => [
  1374. 'asset_id' => $assetId,
  1375. ],
  1376. ]);
  1377. $responseData = json_decode($response->getBody()->getContents(), true);
  1378. dLog('generate')->info('智帧AI查询素材响应: ', $responseData);
  1379. logDB('generate', 'info', '智帧AI查询素材', ['asset_id' => $assetId, 'response' => $responseData]);
  1380. if ($responseData['code'] !== 0) {
  1381. return [
  1382. 'code' => $responseData['code'],
  1383. 'msg' => $responseData['msg'] ?? '查询素材失败',
  1384. 'data' => null
  1385. ];
  1386. }
  1387. $assetInfo = $responseData['data']['asset'] ?? [];
  1388. // 更新本地数据库记录
  1389. $localAsset = MpAsset::where('asset_id', $assetId)->first();
  1390. if ($localAsset) {
  1391. $localAsset->update([
  1392. 'status' => $assetInfo['status'] ?? MpAsset::STATUS_PROCESSING,
  1393. 'cover_url' => $assetInfo['url'] ?? null,
  1394. 'extra_params' => $responseData,
  1395. ]);
  1396. }
  1397. return [
  1398. 'code' => 0,
  1399. 'msg' => '查询成功',
  1400. 'data' => [
  1401. 'asset' => $assetInfo
  1402. ]
  1403. ];
  1404. } catch (\Exception $e) {
  1405. dLog('generate')->error('查询素材异常: ' . $e->getMessage());
  1406. logDB('generate', 'error', '查询素材异常', ['asset_id' => $assetId, 'error' => $e->getMessage()]);
  1407. return [
  1408. 'code' => 20003,
  1409. 'msg' => '查询素材失败: ' . $e->getMessage(),
  1410. 'data' => null
  1411. ];
  1412. }
  1413. }
  1414. /**
  1415. * 更新所有待处理任务的状态
  1416. *
  1417. * @return void
  1418. */
  1419. public function updatePendingTasks(): void
  1420. {
  1421. dLog('generate')->info('更新图片状态ing');
  1422. // 1. 处理火山API的pending任务(批量提交到队列)
  1423. $volcPendingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
  1424. ->whereIn('model', BaseConst::VOLC_PIC_MODELS)
  1425. ->orderBy('created_at', 'asc')
  1426. ->limit(self::DISPATCH_CANDIDATE_LIMIT) // 候选窗口需大于每轮派发上限,避免已入队的任务挡住后面的任务
  1427. ->get();
  1428. $volcDispatched = 0;
  1429. foreach ($volcPendingTasks as $task) {
  1430. if ($volcDispatched >= self::DISPATCH_BATCH_LIMIT) {
  1431. break;
  1432. }
  1433. // 同一任务已在队列中或正在执行时跳过,避免重复入队
  1434. if (!$this->dispatchTaskOnce(self::DISPATCH_GROUP_VOLC, (int)$task->id, \App\Jobs\ProcessVolcImageGenerationJob::class)) {
  1435. continue;
  1436. }
  1437. dLog('generate')->info('开始分发火山API任务到队列: ' . $task->task_id);
  1438. $volcDispatched++;
  1439. }
  1440. // 2. 处理GPT-Image2的pending任务(批量提交到队列)
  1441. $gptImage2PendingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
  1442. ->whereIn('model', BaseConst::GPT_IMAGE2_MODELS)
  1443. ->orderBy('created_at', 'asc')
  1444. ->limit(self::DISPATCH_CANDIDATE_LIMIT) // 候选窗口需大于每轮派发上限,避免已入队的任务挡住后面的任务
  1445. ->get();
  1446. $gptImage2Dispatched = 0;
  1447. foreach ($gptImage2PendingTasks as $task) {
  1448. if ($gptImage2Dispatched >= self::DISPATCH_BATCH_LIMIT) {
  1449. break;
  1450. }
  1451. // 同一任务已在队列中或正在执行时跳过,避免重复入队
  1452. if (!$this->dispatchTaskOnce(self::DISPATCH_GROUP_GPT_IMAGE2, (int)$task->id, \App\Jobs\ProcessGptImage2GenerationJob::class)) {
  1453. continue;
  1454. }
  1455. dLog('generate')->info('开始分发GPT-Image2任务到队列: ' . $task->task_id);
  1456. $gptImage2Dispatched++;
  1457. }
  1458. // 3. 获取所有处理中的任务(即梦AI和NanoBanana系列需要查询状态)
  1459. $processingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PROCESSING)
  1460. ->whereIn('model', array_merge([self::MODEL_JIMENG_4], BaseConst::NANO_BANANA_MODELS))
  1461. ->get();
  1462. foreach ($processingTasks as $task) {
  1463. $statusInfo = $this->queryTaskStatus($task);
  1464. if ($statusInfo['status'] === 'success') {
  1465. logDB('generate', 'info', '即梦AI图片生成任务成功', ['task_id' => $task->task_id, 'result_url' => $statusInfo['result_url']]);
  1466. // 图片生成成功统一处理(事务:状态更新 + 计费 + 分镜同步)
  1467. $this->handleImageTaskSuccess($task, $statusInfo['result_url'], $statusInfo['result_json'] ?? []);
  1468. } elseif ($statusInfo['status'] === 'failed') {
  1469. logDB('generate', 'error', '即梦AI图片生成任务失败', ['task_id' => $task->task_id, 'error' => $statusInfo['error_message']]);
  1470. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1471. 'error_message' => $statusInfo['error_message'],
  1472. 'result_json' => $statusInfo['result_json'] ?? []
  1473. ]);
  1474. if (getProp($task, 'alias_segment_id')) {
  1475. DB::table('mp_episode_segments')->where('segment_id', getProp($task, 'alias_segment_id'))->update([
  1476. 'pic_task_status' => '失败',
  1477. ]);
  1478. }
  1479. }
  1480. // 如果仍然是处理中状态,不做任何操作
  1481. //处理:如果任务处理超过12小时,标记为失败
  1482. $processingTime = now()->diffInHours($task->created_at);
  1483. if ($processingTime > 12) {
  1484. logDB('generate', 'warning', '即梦AI图片生成任务超时', ['task_id' => $task->task_id, 'processing_hours' => $processingTime]);
  1485. $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
  1486. 'error_message' => '任务处理超时(超过12小时)'
  1487. ]);
  1488. }
  1489. }
  1490. // 4. 检查是否有任务完成,如果有则处理下一个排队任务(仅即梦AI)
  1491. $this->processNextQueuedTask();
  1492. }
  1493. /**
  1494. * 派发单个任务到队列(带派发去重)
  1495. *
  1496. * 同一任务在「已入队等待执行」或「正在执行」期间不会重复入队,
  1497. * 避免每轮扫描(约3秒一次)把同一条任务重复推送进队列。
  1498. *
  1499. * @param string $group 派发分组,用于区分同一任务ID在不同业务下的锁
  1500. * @param int $taskId 图片任务ID
  1501. * @param string $jobClass 队列任务类
  1502. * @return bool 本次是否成功入队;false 表示已有同任务的 job 在队列中或执行中
  1503. */
  1504. private function dispatchTaskOnce(string $group, int $taskId, string $jobClass): bool
  1505. {
  1506. if (!self::acquireTaskDispatchLock($group, $taskId)) {
  1507. return false;
  1508. }
  1509. try {
  1510. $jobClass::dispatch($taskId)->onConnection('redis');
  1511. } catch (\Throwable $e) {
  1512. // 入队失败必须释放锁,否则该任务在锁过期前无法再次派发
  1513. self::releaseTaskDispatchLock($group, $taskId);
  1514. throw $e;
  1515. }
  1516. return true;
  1517. }
  1518. /**
  1519. * 获取任务派发锁
  1520. *
  1521. * 在队列所在的 Redis 连接上执行原生 `SET key value EX ttl NX` 原子命令,
  1522. * 不依赖 Lua 脚本(部分 Redis 会禁用 EVAL)也不需要额外的缓存 store 配置。
  1523. *
  1524. * @param string $group
  1525. * @param int $taskId
  1526. * @return bool true 表示获取成功(可以入队)
  1527. */
  1528. public static function acquireTaskDispatchLock(string $group, int $taskId): bool
  1529. {
  1530. try {
  1531. $result = Redis::connection(self::DISPATCH_LOCK_CONNECTION)->set(
  1532. self::buildTaskDispatchLockKey($group, $taskId),
  1533. 1,
  1534. 'EX',
  1535. self::DISPATCH_LOCK_TTL,
  1536. 'NX'
  1537. );
  1538. // NX 未命中时:predis 返回 null,phpredis 返回 false
  1539. return $result !== null && $result !== false;
  1540. } catch (\Throwable $e) {
  1541. // 锁不可用时降级为不去重(与旧逻辑一致),避免任务因此完全无法派发
  1542. dLog('generate')->warning('获取图片任务派发锁失败,本次不拦截重复派发', [
  1543. 'group' => $group,
  1544. 'task_id' => $taskId,
  1545. 'error' => $e->getMessage(),
  1546. ]);
  1547. return true;
  1548. }
  1549. }
  1550. /**
  1551. * 释放任务派发锁
  1552. *
  1553. * 在任务执行结束、任务已由其它流程处理、任务最终失败时调用;
  1554. * 未释放的锁会在 DISPATCH_LOCK_TTL 后自动过期,保证任务仍能被重新派发。
  1555. *
  1556. * @param string $group
  1557. * @param int $taskId
  1558. * @return void
  1559. */
  1560. public static function releaseTaskDispatchLock(string $group, int $taskId): void
  1561. {
  1562. try {
  1563. Redis::connection(self::DISPATCH_LOCK_CONNECTION)->del(
  1564. self::buildTaskDispatchLockKey($group, $taskId)
  1565. );
  1566. } catch (\Throwable $e) {
  1567. // 释放失败不影响主流程,锁会由 TTL 自动过期
  1568. dLog('generate')->warning('释放图片任务派发锁失败', [
  1569. 'group' => $group,
  1570. 'task_id' => $taskId,
  1571. 'error' => $e->getMessage(),
  1572. ]);
  1573. }
  1574. }
  1575. /**
  1576. * 构建任务派发锁 key
  1577. *
  1578. * @param string $group
  1579. * @param int $taskId
  1580. * @return string
  1581. */
  1582. public static function buildTaskDispatchLockKey(string $group, int $taskId): string
  1583. {
  1584. return self::DISPATCH_LOCK_PREFIX . $group . ':' . $taskId;
  1585. }
  1586. /**
  1587. * 处理下一个排队的任务
  1588. *
  1589. * @return void
  1590. */
  1591. public function processNextQueuedTask(): void
  1592. {
  1593. // 检查是否还有正在处理的任务(仅即梦AI)
  1594. if ($this->hasProcessingTask()) {
  1595. return; // 如果还有处理中的任务,不处理新任务
  1596. }
  1597. // 获取最早的待处理任务(仅即梦AI)
  1598. $nextTask = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
  1599. ->where('model', self::MODEL_JIMENG_4)
  1600. ->orderBy('created_at', 'asc')
  1601. ->first();
  1602. if ($nextTask) {
  1603. dLog('generate')->info('开始处理排队任务: ' . $nextTask->task_id);
  1604. logDB('generate', 'info', '开始处理即梦AI排队任务', ['task_id' => $nextTask->task_id]);
  1605. $this->submitTaskToJimengApi($nextTask);
  1606. }
  1607. }
  1608. /**
  1609. * 获取任务队列状态
  1610. *
  1611. * @return array
  1612. */
  1613. public function getQueueStatus(): array
  1614. {
  1615. $processingCount = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PROCESSING)->count();
  1616. $pendingCount = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)->count();
  1617. return [
  1618. 'processing_count' => $processingCount,
  1619. 'pending_count' => $pendingCount,
  1620. 'can_submit_new_task' => $processingCount === 0
  1621. ];
  1622. }
  1623. /**
  1624. * 处理智帧AI的参考图片转素材
  1625. *
  1626. * @param array $referenceImages 参考图片URL数组
  1627. * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败图片的标记)
  1628. * @return array 返回素材ID数组
  1629. */
  1630. public function processReferenceImagesToAssets(array $referenceImages, string &$fullPrompt): array
  1631. {
  1632. return $this->processReferenceAssetsToZhizhenAssets($referenceImages, MpAsset::TYPE_IMAGE, '图片', '图片', $fullPrompt);
  1633. }
  1634. /**
  1635. * 处理智帧AI的参考视频转素材
  1636. *
  1637. * @param array $referenceVideos 参考视频URL数组
  1638. * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败视频的标记)
  1639. * @return array 返回素材ID数组
  1640. */
  1641. public function processReferenceVideosToAssets(array $referenceVideos, string &$fullPrompt): array
  1642. {
  1643. return $this->processReferenceAssetsToZhizhenAssets($referenceVideos, MpAsset::TYPE_VIDEO, '视频', '视频', $fullPrompt);
  1644. }
  1645. /**
  1646. * 处理智帧AI的参考音频转素材
  1647. *
  1648. * @param array $referenceAudios 参考音频URL数组
  1649. * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败音频的标记)
  1650. * @return array 返回素材ID数组
  1651. */
  1652. public function processReferenceAudiosToAssets(array $referenceAudios, string &$fullPrompt): array
  1653. {
  1654. return $this->processReferenceAssetsToZhizhenAssets($referenceAudios, MpAsset::TYPE_AUDIO, '音频', '音频', $fullPrompt);
  1655. }
  1656. /**
  1657. * 处理智帧AI的参考素材转素材(图片/视频/音频通用方法)
  1658. *
  1659. * @param array $urls 参考素材URL数组
  1660. * @param string $assetType 素材类型(Image/Video/Audio)
  1661. * @param string $label 素材中文名(图片/视频/音频),用于日志与素材命名
  1662. * @param string $tagName 提示词标记名(图片/视频/音频),用于 <{tagName}N> 标记的清理
  1663. * @param string &$fullPrompt 完整提示词(引用传递,用于删除失败素材的标记)
  1664. * @return array 返回素材ID数组(asset://格式)
  1665. */
  1666. private function processReferenceAssetsToZhizhenAssets(array $urls, string $assetType, string $label, string $tagName, string &$fullPrompt): array
  1667. {
  1668. $assetIds = [];
  1669. $failedIndexes = []; // 记录失败的素材序号
  1670. foreach ($urls as $index => $url) {
  1671. $mediaIndex = $index + 1; // 素材序号从1开始
  1672. try {
  1673. dLog('generate')->info("开始处理参考{$label} {$mediaIndex}: {$url}");
  1674. // 1. 创建素材
  1675. $createResult = $this->createAsset([
  1676. 'url' => $url,
  1677. 'asset_type' => $assetType,
  1678. 'name' => "参考{$label}_{$mediaIndex}_" . time(),
  1679. ]);
  1680. if ($createResult['code'] !== 0) {
  1681. dLog('generate')->warning("{$label} {$mediaIndex} 创建素材失败: " . $createResult['msg']);
  1682. logDB('generate', 'warning', '创建素材失败', [
  1683. 'url' => $url,
  1684. 'asset_type' => $assetType,
  1685. 'index' => $mediaIndex,
  1686. 'error' => $createResult['msg']
  1687. ]);
  1688. $failedIndexes[] = $mediaIndex;
  1689. continue;
  1690. }
  1691. $assetId = $createResult['data']['asset_id'];
  1692. dLog('generate')->info("{$label} {$mediaIndex} 素材创建成功,asset_id: {$assetId},开始轮询查询状态");
  1693. // 2. 轮询查询素材状态(每3秒查询一次,最多查询100次即5分钟)
  1694. $maxAttempts = 100;
  1695. $attempt = 0;
  1696. $assetStatus = null;
  1697. while ($attempt < $maxAttempts) {
  1698. sleep(3); // 等待3秒
  1699. $attempt++;
  1700. $getResult = $this->getAsset($assetId);
  1701. if ($getResult['code'] !== 0) {
  1702. dLog('generate')->warning("{$label} {$mediaIndex} 查询素材状态失败: " . $getResult['msg']);
  1703. continue;
  1704. }
  1705. $assetStatus = $getResult['data']['asset']['status'] ?? null;
  1706. dLog('generate')->info("{$label} {$mediaIndex} 素材状态查询第 {$attempt} 次,状态: {$assetStatus}");
  1707. if ($assetStatus === 'Active') {
  1708. // 素材可用
  1709. $assetIds[] = $assetId;
  1710. dLog('generate')->info("{$label} {$mediaIndex} 素材处理成功,asset_id: {$assetId}");
  1711. logDB('generate', 'info', '素材转换成功', [
  1712. 'url' => $url,
  1713. 'asset_type' => $assetType,
  1714. 'index' => $mediaIndex,
  1715. 'asset_id' => $assetId
  1716. ]);
  1717. break;
  1718. } elseif ($assetStatus === 'Failed') {
  1719. // 素材处理失败
  1720. dLog('generate')->warning("{$label} {$mediaIndex} 素材处理失败");
  1721. logDB('generate', 'warning', '素材处理失败', [
  1722. 'url' => $url,
  1723. 'asset_type' => $assetType,
  1724. 'index' => $mediaIndex,
  1725. 'asset_id' => $assetId
  1726. ]);
  1727. $failedIndexes[] = $mediaIndex;
  1728. break;
  1729. }
  1730. // 其他状态(Processing等)继续等待
  1731. }
  1732. // 如果超时还未成功
  1733. if ($attempt >= $maxAttempts && $assetStatus !== 'Active') {
  1734. dLog('generate')->warning("{$label} {$mediaIndex} 素材处理超时");
  1735. logDB('generate', 'warning', '素材处理超时', [
  1736. 'url' => $url,
  1737. 'asset_type' => $assetType,
  1738. 'index' => $mediaIndex,
  1739. 'asset_id' => $assetId,
  1740. 'last_status' => $assetStatus
  1741. ]);
  1742. $failedIndexes[] = $mediaIndex;
  1743. }
  1744. } catch (\Exception $e) {
  1745. dLog('generate')->error("{$label} {$mediaIndex} 处理异常: " . $e->getMessage());
  1746. logDB('generate', 'error', '素材转换异常', [
  1747. 'url' => $url,
  1748. 'asset_type' => $assetType,
  1749. 'index' => $mediaIndex,
  1750. 'error' => $e->getMessage()
  1751. ]);
  1752. $failedIndexes[] = $mediaIndex;
  1753. }
  1754. }
  1755. // 处理失败的素材:从 fullPrompt 中删除对应的标记并调整序号
  1756. if (!empty($failedIndexes)) {
  1757. dLog('generate')->info("开始处理失败{$label}的提示词调整,失败序号: " . implode(',', $failedIndexes));
  1758. // 按序号从大到小排序,避免删除时影响后续序号
  1759. rsort($failedIndexes);
  1760. foreach ($failedIndexes as $failedIndex) {
  1761. // 删除失败素材的标记,例如 <图片1>、<视频2>、<音频3> 等
  1762. $pattern = "/<{$tagName}{$failedIndex}>/u";
  1763. $fullPrompt = preg_replace($pattern, '', $fullPrompt);
  1764. dLog('generate')->info("已从提示词中删除 <{$tagName}{$failedIndex}>");
  1765. }
  1766. // 重新调整剩余素材的序号
  1767. // 例如:如果素材2失败,素材3、素材4需要改为素材2、素材3
  1768. $needAdjust = false;
  1769. foreach ($failedIndexes as $failedIndex) {
  1770. $needAdjust = true;
  1771. // 将所有大于失败序号的素材标记序号减1
  1772. for ($i = $failedIndex + 1; $i <= count($urls); $i++) {
  1773. $oldPattern = "/<{$tagName}{$i}>/u";
  1774. $newTag = "<{$tagName}" . ($i - 1) . ">";
  1775. $fullPrompt = preg_replace($oldPattern, $newTag, $fullPrompt);
  1776. }
  1777. }
  1778. if ($needAdjust) {
  1779. dLog('generate')->info("提示词{$label}序号调整完成");
  1780. logDB('generate', 'info', '提示词调整', [
  1781. 'failed_indexes' => $failedIndexes,
  1782. 'adjusted_prompt' => $fullPrompt
  1783. ]);
  1784. }
  1785. }
  1786. dLog('generate')->info("参考{$label}转素材完成,成功数量: " . count($assetIds) . ',失败数量: ' . count($failedIndexes));
  1787. // 格式化为可直接在API接口中使用的数组
  1788. foreach ($assetIds as &$assetId) {
  1789. $assetId = "asset://{$assetId}";
  1790. }
  1791. unset($assetId);
  1792. return $assetIds;
  1793. }
  1794. /**
  1795. * 根据宽高反推图片比例
  1796. * @param int $width 宽度
  1797. * @param int $height 高度
  1798. * @return string 比例字符串,如 "9:16",匹配不到则返回 "auto"
  1799. */
  1800. private function getRatioFromSize($width, $height)
  1801. {
  1802. if (!$width || !$height) {
  1803. return 'auto';
  1804. }
  1805. // 遍历所有预定义的比例,查找匹配的
  1806. foreach (BaseConst::IMAGE_RATIOS as $ratio => $size) {
  1807. if ($size['width'] == $width && $size['height'] == $height) {
  1808. return $ratio;
  1809. }
  1810. }
  1811. // 没有精确匹配,尝试通过计算比例匹配
  1812. $gcd = $this->gcd($width, $height);
  1813. $simplifiedRatio = ($width / $gcd) . ':' . ($height / $gcd);
  1814. // 检查简化后的比例是否在预定义列表中
  1815. if (isset(BaseConst::IMAGE_RATIOS[$simplifiedRatio])) {
  1816. return $simplifiedRatio;
  1817. }
  1818. return 'auto';
  1819. }
  1820. /**
  1821. * 压缩图片流(针对PNG格式)
  1822. *
  1823. * @param string $imageStream 图片二进制数据
  1824. * @param int $maxBytes 最大字节数(默认3MB)
  1825. * @return string|null 压缩后的图片二进制数据,失败时返回原数据
  1826. */
  1827. private function compressImageStream(string $imageStream, int $maxBytes = 3 * 1024 * 1024): ?string
  1828. {
  1829. try {
  1830. // 检查图片类型
  1831. $imgInfo = @getimagesizefromstring($imageStream);
  1832. if (!$imgInfo) {
  1833. dLog('generate')->warning('无法识别图片类型,返回原始数据');
  1834. return $imageStream;
  1835. }
  1836. $mime = $imgInfo['mime'] ?? '';
  1837. // 只处理PNG,JPEG不做有损压缩
  1838. if ($mime === 'image/jpeg') {
  1839. return $imageStream;
  1840. }
  1841. // 如果已经小于最大限制,直接返回
  1842. if (strlen($imageStream) <= $maxBytes) {
  1843. return $imageStream;
  1844. }
  1845. // 载入图像对象
  1846. $srcImg = @imagecreatefromstring($imageStream);
  1847. if (!$srcImg) {
  1848. dLog('generate')->warning('无法创建图像资源,返回原始数据');
  1849. return $imageStream;
  1850. }
  1851. $origW = imagesx($srcImg);
  1852. $origH = imagesy($srcImg);
  1853. // 逐步降低质量直到满足大小要求
  1854. $quality = 9; // PNG压缩等级 0-9
  1855. $scaleFactor = 1.0;
  1856. $maxAttempts = 10;
  1857. $attempt = 0;
  1858. $compressedData = null;
  1859. while ($attempt < $maxAttempts) {
  1860. $attempt++;
  1861. // 如果需要缩放
  1862. if ($scaleFactor < 1.0) {
  1863. $newW = (int)($origW * $scaleFactor);
  1864. $newH = (int)($origH * $scaleFactor);
  1865. $resizedImg = imagecreatetruecolor($newW, $newH);
  1866. // 保持透明度
  1867. imagealphablending($resizedImg, false);
  1868. imagesavealpha($resizedImg, true);
  1869. imagecopyresampled($resizedImg, $srcImg, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
  1870. $targetImg = $resizedImg;
  1871. } else {
  1872. $targetImg = $srcImg;
  1873. }
  1874. // 输出到内存
  1875. ob_start();
  1876. imagepng($targetImg, null, $quality);
  1877. $compressedData = ob_get_clean();
  1878. if ($scaleFactor < 1.0) {
  1879. imagedestroy($targetImg);
  1880. }
  1881. // 检查大小
  1882. $size = strlen($compressedData);
  1883. dLog('generate')->info('图片压缩尝试', [
  1884. 'attempt' => $attempt,
  1885. 'quality' => $quality,
  1886. 'scale' => $scaleFactor,
  1887. 'size' => $size,
  1888. 'target' => $maxBytes
  1889. ]);
  1890. if ($size <= $maxBytes) {
  1891. break;
  1892. }
  1893. // 调整参数
  1894. if ($quality > 0) {
  1895. $quality--;
  1896. } else {
  1897. $scaleFactor *= 0.9; // 每次缩小10%
  1898. }
  1899. }
  1900. imagedestroy($srcImg);
  1901. return $compressedData ?: $imageStream;
  1902. } catch (\Exception $e) {
  1903. dLog('generate')->error('图片压缩失败', ['error' => $e->getMessage()]);
  1904. return $imageStream;
  1905. }
  1906. }
  1907. /**
  1908. * 从响应数据中移除 b64_json 字段
  1909. * 避免在日志和数据库中存储大量 Base64 数据
  1910. *
  1911. * @param array $responseData
  1912. * @return array
  1913. */
  1914. private function removeB64JsonFromResponse(array $responseData): array
  1915. {
  1916. $cleanedData = $responseData;
  1917. if (isset($cleanedData['data']) && is_array($cleanedData['data'])) {
  1918. foreach ($cleanedData['data'] as &$imageData) {
  1919. if (isset($imageData['b64_json'])) {
  1920. // 移除 b64_json 字段,可选择性保留数据长度信息
  1921. $b64Length = strlen($imageData['b64_json']);
  1922. unset($imageData['b64_json']);
  1923. $imageData['b64_json_removed'] = true;
  1924. $imageData['b64_json_length'] = $b64Length;
  1925. }
  1926. }
  1927. unset($imageData); // 解除引用
  1928. }
  1929. return $cleanedData;
  1930. }
  1931. }