|
|
@@ -430,11 +430,14 @@ class AIImageGenerationService
|
|
|
// 使用原生 cURL 处理 multipart/form-data 格式
|
|
|
$imageUrls = is_array($task->ref_img_url) ? $task->ref_img_url : [$task->ref_img_url];
|
|
|
|
|
|
- // 限制最多4张参考图
|
|
|
- $imageUrls = array_slice($imageUrls, 0, 4);
|
|
|
+ // 多图上限:暂不限制张数(如需恢复限制,放开下面这行)
|
|
|
+ // $imageUrls = array_slice($imageUrls, 0, 4);
|
|
|
|
|
|
dLog('generate')->info('下载参考图片', ['image_urls' => $imageUrls, 'count' => count($imageUrls)]);
|
|
|
|
|
|
+ // 单张参考图超过该大小时压缩,避免请求体过大被网关拒绝
|
|
|
+ $maxRefBytes = 5 * 1024 * 1024;
|
|
|
+
|
|
|
// 下载所有参考图片并保存为临时文件
|
|
|
$tempFiles = [];
|
|
|
foreach ($imageUrls as $index => $imageUrl) {
|
|
|
@@ -446,8 +449,36 @@ class AIImageGenerationService
|
|
|
|
|
|
dLog('generate')->info('参考图片下载完成', ['index' => $index, 'size' => strlen($imageContent)]);
|
|
|
|
|
|
- // 保存为临时文件(cURL 需要真实文件路径)
|
|
|
$extension = pathinfo(parse_url($imageUrl, PHP_URL_PATH), PATHINFO_EXTENSION) ?: 'png';
|
|
|
+
|
|
|
+ // 超过 5MB 时压缩(公共方法保持原图比例与原格式)
|
|
|
+ if (strlen($imageContent) > $maxRefBytes) {
|
|
|
+ $rawSize = strlen($imageContent);
|
|
|
+ $compressedData = compressRemoteImageUrlToSize($imageUrl, $maxRefBytes);
|
|
|
+
|
|
|
+ if (!empty($compressedData)) {
|
|
|
+ $imageContent = $compressedData;
|
|
|
+ $imgInfo = @getimagesizefromstring($imageContent);
|
|
|
+ $mime = strtolower($imgInfo['mime'] ?? '');
|
|
|
+ $extension = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/webp' ? 'webp' : 'png');
|
|
|
+ } else {
|
|
|
+ // 公共方法对 JPEG 不做有损压缩,回退到本地缩放
|
|
|
+ $fallbackData = $this->compressReferenceImageForEdit($imageContent, 2048, 90);
|
|
|
+ if ($fallbackData !== null) {
|
|
|
+ $imageContent = $fallbackData;
|
|
|
+ $extension = 'jpg';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ dLog('generate')->info('参考图片压缩完成', [
|
|
|
+ 'index' => $index,
|
|
|
+ 'before' => $rawSize,
|
|
|
+ 'after' => strlen($imageContent),
|
|
|
+ 'extension' => $extension,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保存为临时文件(cURL 需要真实文件路径)
|
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'gpt_img_') . '.' . $extension;
|
|
|
file_put_contents($tempFile, $imageContent);
|
|
|
|
|
|
@@ -463,8 +494,8 @@ class AIImageGenerationService
|
|
|
throw new \Exception('所有参考图片下载失败');
|
|
|
}
|
|
|
|
|
|
- // 构建 POST 字段
|
|
|
- $postFields = [
|
|
|
+ // 构建 POST 字段(文本字段)
|
|
|
+ $textFields = [
|
|
|
'prompt' => $task->prompt,
|
|
|
'model' => 'gpt-image-2',
|
|
|
'n' => (string)$task->image_num, // 新接口要求字符串类型
|
|
|
@@ -473,27 +504,26 @@ class AIImageGenerationService
|
|
|
|
|
|
// 尺寸参数(必填)
|
|
|
if ($task->width && $task->height) {
|
|
|
- $postFields['size'] = $task->width . 'x' . $task->height;
|
|
|
+ $textFields['size'] = $task->width . 'x' . $task->height;
|
|
|
} else {
|
|
|
- $postFields['size'] = '1024x1024'; // 默认尺寸
|
|
|
+ $textFields['size'] = '1024x1024'; // 默认尺寸
|
|
|
}
|
|
|
|
|
|
- // 添加参考图片文件(支持多图,新接口要求使用 image 字段名)
|
|
|
- foreach ($tempFiles as $index => $tempFile) {
|
|
|
- $fieldName = 'image'; // 新接口统一使用 image 字段名
|
|
|
- $postFields[$fieldName] = new \CURLFile(
|
|
|
- $tempFile['path'],
|
|
|
- 'image/' . $tempFile['extension'],
|
|
|
- 'image' . ($index + 1) . '.' . $tempFile['extension']
|
|
|
- );
|
|
|
- }
|
|
|
+ // 多张参考图需要在 multipart 中出现重复的 image[] 字段;
|
|
|
+ // PHP 数组无法承载重复 key(同名会覆盖),因此手动拼装请求体。
|
|
|
+ // 单张参考图沿用 image 字段,避免影响已有单图任务。
|
|
|
+ $fileFieldName = count($tempFiles) > 1 ? 'image[]' : 'image';
|
|
|
+ $boundary = '----MpGptImage' . md5(uniqid('', true));
|
|
|
+ $postFields = $this->buildMultipartBody($textFields, $tempFiles, $boundary, $fileFieldName);
|
|
|
|
|
|
dLog('generate')->info('GPT-Image2 API参数(编辑模式-cURL)', [
|
|
|
'api_url' => $api_url,
|
|
|
'prompt' => $task->prompt,
|
|
|
- 'size' => $postFields['size'],
|
|
|
- 'n' => $postFields['n'],
|
|
|
- 'response_format' => $postFields['response_format'],
|
|
|
+ 'size' => $textFields['size'],
|
|
|
+ 'n' => $textFields['n'],
|
|
|
+ 'response_format' => $textFields['response_format'],
|
|
|
+ 'file_field' => $fileFieldName,
|
|
|
+ 'body_size' => strlen($postFields),
|
|
|
'image_count' => count($tempFiles)
|
|
|
]);
|
|
|
logDB('generate', 'info', 'GPT-Image2任务提交(编辑模式)', [
|
|
|
@@ -515,6 +545,8 @@ class AIImageGenerationService
|
|
|
CURLOPT_HTTPHEADER => [
|
|
|
'Authorization: Bearer ' . $apiKey,
|
|
|
'Accept: application/json',
|
|
|
+ 'Content-Type: multipart/form-data; boundary=' . $boundary,
|
|
|
+ 'Content-Length: ' . strlen($postFields),
|
|
|
'Expect:', // 移除 Expect: 100-continue,避免等待
|
|
|
],
|
|
|
CURLOPT_TIMEOUT => 600,
|
|
|
@@ -635,6 +667,111 @@ class AIImageGenerationService
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
+ * 压缩参考图片(兜底方案,用于公共压缩方法不处理的场景,如大尺寸 JPEG)
|
|
|
+ *
|
|
|
+ * 缩放到长边不超过 $maxSide,并统一输出 JPEG(透明背景填白)。
|
|
|
+ *
|
|
|
+ * @param string $imageStream 图片二进制数据
|
|
|
+ * @param int $maxSide 长边像素上限
|
|
|
+ * @param int $quality JPEG 质量
|
|
|
+ * @return string|null 压缩后的二进制数据,失败返回 null
|
|
|
+ */
|
|
|
+ private function compressReferenceImageForEdit(string $imageStream, int $maxSide = 1536, int $quality = 90): ?string
|
|
|
+ {
|
|
|
+ try {
|
|
|
+ $imgInfo = @getimagesizefromstring($imageStream);
|
|
|
+ if (!$imgInfo) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $srcImg = @imagecreatefromstring($imageStream);
|
|
|
+ if (!$srcImg) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $origW = imagesx($srcImg);
|
|
|
+ $origH = imagesy($srcImg);
|
|
|
+ $scale = $maxSide / max($origW, $origH);
|
|
|
+
|
|
|
+ if ($scale < 1) {
|
|
|
+ $newW = max(1, (int)round($origW * $scale));
|
|
|
+ $newH = max(1, (int)round($origH * $scale));
|
|
|
+ $resizedImg = imagecreatetruecolor($newW, $newH);
|
|
|
+ // JPEG 不支持透明通道,用白底填充
|
|
|
+ $white = imagecolorallocate($resizedImg, 255, 255, 255);
|
|
|
+ imagefill($resizedImg, 0, 0, $white);
|
|
|
+ imagecopyresampled($resizedImg, $srcImg, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
|
|
|
+ imagedestroy($srcImg);
|
|
|
+ $srcImg = $resizedImg;
|
|
|
+ } elseif ($imgInfo['mime'] !== 'image/jpeg') {
|
|
|
+ // 未缩放但非 JPEG:铺白底再输出,避免透明区域变黑
|
|
|
+ $flatImg = imagecreatetruecolor($origW, $origH);
|
|
|
+ $white = imagecolorallocate($flatImg, 255, 255, 255);
|
|
|
+ imagefill($flatImg, 0, 0, $white);
|
|
|
+ imagecopy($flatImg, $srcImg, 0, 0, 0, 0, $origW, $origH);
|
|
|
+ imagedestroy($srcImg);
|
|
|
+ $srcImg = $flatImg;
|
|
|
+ }
|
|
|
+
|
|
|
+ ob_start();
|
|
|
+ imagejpeg($srcImg, null, $quality);
|
|
|
+ $compressed = ob_get_clean();
|
|
|
+ imagedestroy($srcImg);
|
|
|
+
|
|
|
+ return ($compressed === false || $compressed === '') ? null : $compressed;
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ dLog('generate')->warning('参考图片压缩失败,使用原图', ['error' => $e->getMessage()]);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建 multipart/form-data 请求体
|
|
|
+ *
|
|
|
+ * 参考图字段需要在请求体中出现多次(image[]),而 PHP 数组同一个 key 只能保留最后一个值,
|
|
|
+ * 用 CURLOPT_POSTFIELDS 传数组会导致多张参考图互相覆盖,所以这里手动拼装请求体字符串。
|
|
|
+ *
|
|
|
+ * @param array $textFields 普通文本字段 [name => value]
|
|
|
+ * @param array $files 文件字段,每项含 path、extension
|
|
|
+ * @param string $boundary multipart 分隔符
|
|
|
+ * @param string $fileFieldName 文件字段名,多图时传 image[],单图传 image
|
|
|
+ * @return string
|
|
|
+ */
|
|
|
+ private function buildMultipartBody(array $textFields, array $files, string $boundary, string $fileFieldName = 'image[]'): string
|
|
|
+ {
|
|
|
+ $eol = "\r\n";
|
|
|
+ $body = '';
|
|
|
+
|
|
|
+ foreach ($textFields as $name => $value) {
|
|
|
+ $body .= '--' . $boundary . $eol;
|
|
|
+ $body .= 'Content-Disposition: form-data; name="' . $name . '"' . $eol . $eol;
|
|
|
+ $body .= (string)$value . $eol;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach ($files as $index => $file) {
|
|
|
+ $extension = $file['extension'];
|
|
|
+ $filename = 'image' . ($index + 1) . '.' . $extension;
|
|
|
+ $mime = strtolower($extension) === 'jpg' ? 'image/jpeg' : 'image/' . $extension;
|
|
|
+
|
|
|
+ $content = @file_get_contents($file['path']);
|
|
|
+ if ($content === false) {
|
|
|
+ throw new \Exception('读取参考图片临时文件失败: ' . $file['path']);
|
|
|
+ }
|
|
|
+
|
|
|
+ $body .= '--' . $boundary . $eol;
|
|
|
+ $body .= 'Content-Disposition: form-data; name="' . $fileFieldName . '"; filename="' . $filename . '"' . $eol;
|
|
|
+ $body .= 'Content-Type: ' . $mime . $eol . $eol;
|
|
|
+ $body .= $content . $eol;
|
|
|
+
|
|
|
+ unset($content);
|
|
|
+ }
|
|
|
+
|
|
|
+ $body .= '--' . $boundary . '--' . $eol;
|
|
|
+
|
|
|
+ return $body;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
* 处理GPT-Image2 API响应结果
|
|
|
*
|
|
|
* @param MpGeneratePicTask $task
|