Procházet zdrojové kódy

Merge branch 'test' into check_points

lh před 1 měsícem
rodič
revize
638bf94edf

+ 116 - 0
app/Http/Controllers/AIGeneration/ImageGenerationController.php

@@ -175,6 +175,122 @@ class ImageGenerationController extends BaseController
     }
     }
 
 
     /**
     /**
+     * 图片超清(超分辨率)放大
+     *
+     * 支持传入图片URL或上传图片文件,放大到 1080p / 2k / 4k 或指定宽高。
+     * 默认使用 ffmpeg(本地环境或未安装时自动回退 gd)。
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function upscaleImage(Request $request): JsonResponse
+    {
+        $data = $request->all();
+
+        // 参数验证
+        $rules = [
+            'image_url'      => 'nullable|string',
+            'image_file'     => 'nullable|image|mimes:jpeg,png,webp|max:13312',
+            'target_size'    => 'nullable|string|in:1080p,2k,4k',
+            'target_width'   => 'nullable|numeric|between:1,8192',
+            'target_height'  => 'nullable|numeric|between:1,8192',
+            'format'         => 'nullable|string|in:jpg,jpeg,png,webp',
+            'quality'        => 'nullable|numeric|between:1,100',
+            'sharpen'        => 'nullable|boolean',
+            'sharpen_amount' => 'nullable|numeric|between:0,2',
+            'driver'         => 'nullable|string|in:gd,ffmpeg',
+        ];
+
+        $messages = [
+            'image_url.string'         => '图片URL格式不正确',
+            'image_file.image'         => '上传文件必须是图片',
+            'image_file.mimes'         => '图片格式必须是jpeg、png或webp',
+            'image_file.max'           => '图片大小不能超过13MB',
+            'target_size.in'           => '目标档位必须是1080p、2k或4k',
+            'target_width.numeric'     => '宽度必须是数字',
+            'target_width.between'     => '宽度必须在1到8192之间',
+            'target_height.numeric'    => '高度必须是数字',
+            'target_height.between'    => '高度必须在1到8192之间',
+            'format.in'                => '输出格式必须是jpg、png或webp',
+            'quality.numeric'          => '输出质量必须是数字',
+            'quality.between'          => '输出质量必须在1到100之间',
+            'sharpen.boolean'          => 'sharpen必须是布尔值',
+            'sharpen_amount.numeric'   => '锐化强度必须是数字',
+            'sharpen_amount.between'   => '锐化强度必须在0到2之间',
+            'driver.in'                => '处理驱动必须是gd或ffmpeg',
+        ];
+
+        $validator = Validator::make($data, $rules, $messages);
+        if ($validator->fails()) {
+            Utils::throwError('1002:'.$validator->errors()->first());
+        }
+
+        // 获取图片URL:优先使用上传文件,其次使用传入URL
+        $imageUrl = trim((string)($data['image_url'] ?? ''));
+        if ($request->hasFile('image_file')) {
+            $imageFile = $request->file('image_file');
+            $imageInfo = @getimagesize($imageFile->getPathname());
+            if (!$imageInfo) {
+                Utils::throwError('1003:图片格式无效');
+            }
+
+            // 复用已有图片规格校验
+            $this->validateImageSpecs([
+                'width'  => $imageInfo[0],
+                'height' => $imageInfo[1],
+                'size'   => $imageFile->getSize(),
+            ], 1);
+
+            $filename = randStr(10) . '.' . $imageFile->getClientOriginalExtension();
+            $imageUrl = uploadStreamByTos('image', file_get_contents($imageFile->getPathname()), $filename);
+            if (!$imageUrl) {
+                Utils::throwError('1003:图片保存失败');
+            }
+        }
+
+        if ($imageUrl === '') {
+            Utils::throwError('1002:image_url或image_file不能为空');
+        }
+        if (!filter_var($imageUrl, FILTER_VALIDATE_URL)) {
+            Utils::throwError('1002:图片URL格式无效');
+        }
+        // 仅允许 http/https,防止通过API读取服务器本地文件
+        $scheme = strtolower((string)parse_url($imageUrl, PHP_URL_SCHEME));
+        if (!in_array($scheme, ['http', 'https'], true)) {
+            Utils::throwError('1002:图片URL格式无效');
+        }
+
+        // 组装超分参数(null 自动走默认值)
+        $upscaleParams = [
+            'image_url'      => $imageUrl,
+            'target'         => $data['target_size'] ?? null,
+            'width'          => isset($data['target_width']) ? (int)$data['target_width'] : null,
+            'height'         => isset($data['target_height']) ? (int)$data['target_height'] : null,
+            'format'         => $data['format'] ?? null,
+            'quality'        => isset($data['quality']) ? (int)$data['quality'] : null,
+            'sharpen'        => isset($data['sharpen']) ? filter_var($data['sharpen'], FILTER_VALIDATE_BOOLEAN) : null,
+            'sharpen_amount' => isset($data['sharpen_amount']) ? (float)$data['sharpen_amount'] : null,
+            'driver'         => $data['driver'] ?? null,
+        ];
+
+        try {
+            $result = upscaleImage($upscaleParams);
+
+            return $this->success([
+                'url'        => $result['url'],
+                'width'      => $result['width'],
+                'height'     => $result['height'],
+                'src_width'  => $result['src_width'],
+                'src_height' => $result['src_height'],
+                'upscaled'   => $result['upscaled'],
+                'driver'     => $result['driver'],
+            ]);
+        } catch (\Exception $e) {
+            Utils::throwError('1001:'.$e->getMessage());
+        }
+    }
+
+    /**
      * 验证图片规格
      * 验证图片规格
      * 
      * 
      * @param array $refImg
      * @param array $refImg

+ 634 - 0
app/Libs/Helpers.php

@@ -4116,6 +4116,640 @@ function safeDestroyImage(&$img)
     $img = null;
     $img = null;
 }
 }
 
 
+/**
+ * 图片超清(超分辨率)放大
+ *
+ * 默认使用 FFmpeg(Lanczos + unsharp)进行放大,画质优于 GD;
+ * 本地环境(APP_ENV=local)或服务器未安装 FFmpeg 时自动回退到 GD 插值放大,
+ * 使用 GD 前会先检查 GD 扩展是否可用,避免报错。
+ * 说明:插值放大不会像 AI 超分那样生成全新细节,但零外部 API、可实时完成。
+ *
+ * 参数:
+ *  - image_url      string  必填 原图 URL(https)或服务器本地绝对路径
+ *  - target         string  可选 目标档位:1080p / 2k / 4k,默认 4k(按长边放大)
+ *  - width          int     可选 指定目标宽度(与 height 同时传时优先于 target)
+ *  - height         int     可选 指定目标高度
+ *  - format         string  可选 输出格式:jpg / png / webp,默认保持原图格式
+ *                            (gif 转 png;webp 带透明转 png、无透明转 jpg)
+ *  - quality        int     可选 输出质量 1-100,默认 95(jpg/webp 生效)
+ *  - sharpen        bool    可选 是否锐化,默认 true
+ *  - sharpen_amount float   可选 锐化强度 0-2,默认 0.35
+ *  - upload         bool    可选 是否上传 TOS 返回 URL,默认 true;false 时返回 base64
+ *  - driver         string  可选 处理驱动:ffmpeg(默认)/ gd;本地环境强制跳过 ffmpeg
+ *
+ * @param array $params
+ * @return array
+ * @throws \InvalidArgumentException|\RuntimeException
+ */
+function upscaleImage(array $params): array
+{
+    $imageUrl = trim((string)($params['image_url'] ?? ''));
+    if ($imageUrl === '') {
+        throw new \InvalidArgumentException('image_url不能为空');
+    }
+
+    // 读取图片二进制(支持远程URL和本地路径)
+    $binary = @file_get_contents($imageUrl);
+    if ($binary === false || strlen($binary) === 0) {
+        throw new \RuntimeException('图片读取失败: ' . $imageUrl);
+    }
+
+    $info = @getimagesizefromstring($binary);
+    if ($info === false) {
+        throw new \RuntimeException('无法识别的图片格式: ' . $imageUrl);
+    }
+
+    $srcWidth = max(1, (int)$info[0]);
+    $srcHeight = max(1, (int)$info[1]);
+    $srcType = (int)($info[2] ?? IMAGETYPE_PNG);
+    // WebP 默认输出格式取决于是否带透明通道(有透明转PNG保留透明,无透明转JPG控制体积)
+    $srcHasAlpha = $srcType === IMAGETYPE_WEBP && upscaleImageHasAlpha($binary);
+
+    // 计算目标尺寸
+    [$dstWidth, $dstHeight] = upscaleImageResolveTargetDimensions($srcWidth, $srcHeight, $params);
+
+    // 目标尺寸不比原图大时不降采样,直接返回原图
+    if ($dstWidth <= $srcWidth && $dstHeight <= $srcHeight) {
+        return [
+            'url' => $imageUrl,
+            'base64' => null,
+            'width' => $srcWidth,
+            'height' => $srcHeight,
+            'src_width' => $srcWidth,
+            'src_height' => $srcHeight,
+            'upscaled' => false,
+            'driver' => 'original',
+        ];
+    }
+
+    // 驱动选择:默认 ffmpeg;本地环境跳过 ffmpeg;ffmpeg 不可用时回退 gd;
+    // 使用 gd 前先检查 GD 扩展是否可用,防止报错
+    $driver = strtolower((string)($params['driver'] ?? 'ffmpeg'));
+    if (!in_array($driver, ['ffmpeg', 'gd'], true)) {
+        throw new \InvalidArgumentException('不支持的driver: ' . $driver . '(可选: gd / ffmpeg)');
+    }
+
+    $isLocal = env('APP_ENV') === 'local';
+    $useFfmpeg = false;
+    $useGd = false;
+
+    if ($driver === 'ffmpeg') {
+        if ($isLocal) {
+            // 本地环境跳过 ffmpeg,使用 GD 超分
+            $useGd = true;
+        } elseif (upscaleImageIsFfmpegAvailable((string)env('FFMPEG_PATH', 'ffmpeg'))) {
+            $useFfmpeg = true;
+        } else {
+            // 线上未安装 ffmpeg,回退 GD 超分
+            $useGd = true;
+        }
+    } else {
+        $useGd = true;
+    }
+
+    if ($useGd && !upscaleImageGdAvailable()) {
+        $reason = $isLocal ? '本地环境已跳过FFmpeg' : 'FFmpeg不可用';
+        throw new \RuntimeException($reason . ',且当前PHP环境缺少GD扩展,无法进行图片超分');
+    }
+
+    if ($useFfmpeg) {
+        return upscaleImageWithFfmpeg(
+            $params,
+            $binary,
+            $srcType,
+            $srcHasAlpha,
+            $srcWidth,
+            $srcHeight,
+            $dstWidth,
+            $dstHeight
+        );
+    }
+
+    return upscaleImageWithGd(
+        $params,
+        $binary,
+        $srcType,
+        $srcHasAlpha,
+        $srcWidth,
+        $srcHeight,
+        $dstWidth,
+        $dstHeight
+    );
+}
+
+/**
+ * 使用 GD 进行图片超分放大(Blackman/Bicubic 插值 + 卷积锐化)
+ *
+ * @param array $params
+ * @param string $binary 图片二进制内容
+ * @param int $srcType 原图类型(IMAGETYPE_*)
+ * @param bool $srcHasAlpha 原图是否带透明通道(WebP 默认格式判断用)
+ * @param int $srcWidth
+ * @param int $srcHeight
+ * @param int $dstWidth
+ * @param int $dstHeight
+ * @return array
+ * @throws \RuntimeException
+ */
+function upscaleImageWithGd(
+    array $params,
+    string $binary,
+    int $srcType,
+    bool $srcHasAlpha,
+    int $srcWidth,
+    int $srcHeight,
+    int $dstWidth,
+    int $dstHeight
+): array {
+    // 4K 放大峰值内存较高:目标图(4B/像素) + 锐化卷积临时缓冲(4B/像素) + 源图 + 编码缓冲,
+    // 按需临时提升内存限制,避免超过默认 128M 报 FatalError
+    upscaleImageEnsureMemory(
+        $dstWidth * $dstHeight * 8 + $srcWidth * $srcHeight * 4 + 96 * 1024 * 1024
+    );
+
+    $srcImg = @imagecreatefromstring($binary);
+    if (!$srcImg) {
+        throw new \RuntimeException('图片解码失败');
+    }
+
+    $dstImg = null;
+    try {
+        // 高质量插值放大(Blackman 优于 Bicubic,PHP7.4 低版本需回退)
+        $filter = defined('IMG_BLACKMAN') ? IMG_BLACKMAN : IMG_BICUBIC;
+        $dstImg = imagescale($srcImg, $dstWidth, $dstHeight, $filter);
+        if (!$dstImg) {
+            throw new \RuntimeException('图片放大失败');
+        }
+
+        // 缩放完成后原图不再需要,提前释放以降低内存峰值
+        safeDestroyImage($srcImg);
+
+        // 可选锐化,弥补插值放大后的发虚感
+        $sharpen = (bool)($params['sharpen'] ?? true);
+        $sharpenAmount = (float)($params['sharpen_amount'] ?? 0.35);
+        if ($sharpen && $sharpenAmount > 0.01 && function_exists('imageconvolution')) {
+            upscaleImageSharpen($dstImg, min(2.0, max(0.01, $sharpenAmount)));
+        }
+
+        // 确定输出格式并编码
+        $format = upscaleImageResolveOutputFormat($params, $srcType, $srcHasAlpha);
+        $quality = max(1, min(100, (int)($params['quality'] ?? 95)));
+
+        $encoded = upscaleImageEncode($dstImg, $format, $quality);
+        if ($encoded === null) {
+            throw new \RuntimeException('图片编码失败,不支持的输出格式: ' . $format);
+        }
+
+        return upscaleImageBuildResult(
+            $params,
+            $encoded,
+            $format,
+            $dstWidth,
+            $dstHeight,
+            $srcWidth,
+            $srcHeight,
+            'gd'
+        );
+    } finally {
+        safeDestroyImage($srcImg);
+        safeDestroyImage($dstImg);
+    }
+}
+
+/**
+ * 使用 FFmpeg 进行图片超分放大(Lanczos 插值 + unsharp 锐化)
+ *
+ * 与视频超分保持一致的 ffmpeg 用法,仅当 ffmpeg 可用时进入。
+ *
+ * @param array $params
+ * @param string $binary 图片二进制内容
+ * @param int $srcType 原图类型(IMAGETYPE_*)
+ * @param bool $srcHasAlpha 原图是否带透明通道(WebP 默认格式判断用)
+ * @param int $srcWidth
+ * @param int $srcHeight
+ * @param int $dstWidth
+ * @param int $dstHeight
+ * @return array
+ * @throws \RuntimeException
+ */
+function upscaleImageWithFfmpeg(
+    array $params,
+    string $binary,
+    int $srcType,
+    bool $srcHasAlpha,
+    int $srcWidth,
+    int $srcHeight,
+    int $dstWidth,
+    int $dstHeight
+): array {
+    $ffmpegPath = (string)env('FFMPEG_PATH', 'ffmpeg');
+
+    // 创建临时目录
+    $tempDir = storage_path('app/temp/images');
+    if (!is_dir($tempDir)) {
+        @mkdir($tempDir, 0775, true);
+    }
+
+    $format = upscaleImageResolveOutputFormat($params, $srcType, $srcHasAlpha);
+    $ext = $format === 'jpeg' ? 'jpg' : $format;
+    $quality = max(1, min(100, (int)($params['quality'] ?? 95)));
+
+    $uniqueId = 'img_upscale_' . date('YmdHis') . '_' . uniqid() . '_' . bin2hex(random_bytes(4));
+    $inputFile = $tempDir . '/' . $uniqueId . '_input';
+    $outputFile = $tempDir . '/' . $uniqueId . '_output.' . $ext;
+
+    try {
+        if (file_put_contents($inputFile, $binary) === false) {
+            throw new \RuntimeException('图片写入临时文件失败');
+        }
+
+        // 构建滤镜链:Lanczos 缩放 + unsharp 锐化(亮度通道)
+        $filters = ['scale=' . $dstWidth . ':' . $dstHeight . ':flags=lanczos'];
+        $sharpen = (bool)($params['sharpen'] ?? true);
+        if ($sharpen) {
+            $sharpenAmount = (float)($params['sharpen_amount'] ?? 0.35);
+            $luma = number_format(min(2.0, max(0.1, $sharpenAmount * 2)), 2, '.', '');
+            $filters[] = 'unsharp=5:5:' . $luma . ':5:5:0.0';
+        }
+
+        $encodeArgs = upscaleImageBuildFfmpegEncodeArgs($format, $quality);
+        $ffmpegCmd = escapeshellarg($ffmpegPath)
+            . ' -y -i ' . escapeshellarg($inputFile)
+            . ' -vf ' . escapeshellarg(implode(',', $filters))
+            . ' ' . $encodeArgs
+            . ' ' . escapeshellarg($outputFile)
+            . ' 2>&1';
+
+        dLog('generate')->info('FFmpeg图片放大命令', ['command' => $ffmpegCmd]);
+
+        $output = shell_exec($ffmpegCmd);
+
+        if (!file_exists($outputFile) || filesize($outputFile) === 0) {
+            throw new \RuntimeException('FFmpeg图片放大失败: ' . trim((string)$output));
+        }
+
+        $encoded = file_get_contents($outputFile);
+        if ($encoded === false || strlen($encoded) === 0) {
+            throw new \RuntimeException('FFmpeg放大结果读取失败');
+        }
+
+        return upscaleImageBuildResult(
+            $params,
+            $encoded,
+            $format,
+            $dstWidth,
+            $dstHeight,
+            $srcWidth,
+            $srcHeight,
+            'ffmpeg'
+        );
+    } finally {
+        @unlink($inputFile);
+        @unlink($outputFile);
+    }
+}
+
+/**
+ * 检查 GD 扩展是否可用于图片超分
+ *
+ * @return bool
+ */
+function upscaleImageGdAvailable(): bool
+{
+    return extension_loaded('gd')
+        && function_exists('imagecreatefromstring')
+        && function_exists('imagescale');
+}
+
+/**
+ * 获取当前 PHP 内存限制(字节),-1 表示无限制
+ *
+ * @return int
+ */
+function upscaleImageMemoryLimitBytes(): int
+{
+    $val = trim((string)ini_get('memory_limit'));
+    if ($val === '' || $val === '-1') {
+        return -1;
+    }
+    $unit = strtoupper(substr($val, -1));
+    $num = (float)$val;
+    switch ($unit) {
+        case 'G':
+            return (int)($num * 1024 * 1024 * 1024);
+        case 'M':
+            return (int)($num * 1024 * 1024);
+        case 'K':
+            return (int)($num * 1024);
+        default:
+            return (int)$num;
+    }
+}
+
+/**
+ * 确保 PHP 内存限制满足图片超分需要(只升不降;已是无限制时不做处理)
+ *
+ * 4K 放大时源图解码、目标图、锐化卷积缓冲同时存在,峰值内存较大,
+ * 按目标尺寸估算并临时调高 memory_limit,避免 FatalError OOM。
+ *
+ * @param int $neededBytes 预估需要的内存字节数
+ * @return void
+ */
+function upscaleImageEnsureMemory(int $neededBytes): void
+{
+    $current = upscaleImageMemoryLimitBytes();
+    if ($current > 0 && $neededBytes > $current) {
+        @ini_set('memory_limit', (string)(int)ceil($neededBytes / (1024 * 1024)) . 'M');
+    }
+}
+
+/**
+ * 检查 ffmpeg 是否可用
+ *
+ * @param string $ffmpegPath
+ * @return bool
+ */
+function upscaleImageIsFfmpegAvailable(string $ffmpegPath): bool
+{
+    // 配置了绝对路径时直接检查文件
+    if (strpos($ffmpegPath, '/') !== false || strpos($ffmpegPath, '\\') !== false) {
+        return file_exists($ffmpegPath);
+    }
+
+    $cmd = (PHP_OS_FAMILY === 'Windows')
+        ? 'where ' . escapeshellarg($ffmpegPath) . ' 2>NUL'
+        : 'command -v ' . escapeshellarg($ffmpegPath) . ' 2>/dev/null';
+    $output = @shell_exec($cmd);
+    return !empty(trim((string)$output));
+}
+
+/**
+ * 构建 ffmpeg 图片输出编码参数
+ *
+ * @param string $format
+ * @param int $quality
+ * @return string
+ */
+function upscaleImageBuildFfmpegEncodeArgs(string $format, int $quality): string
+{
+    switch ($format) {
+        case 'jpg':
+        case 'jpeg':
+            // mjpeg 质量范围 2-31,2 最佳
+            return '-frames:v 1 -q:v ' . (2 + (int)round((100 - $quality) / 100 * 29));
+        case 'png':
+            return '-frames:v 1 -compression_level ' . max(0, min(9, (int)round((100 - $quality) / 10)));
+        case 'webp':
+            return '-frames:v 1 -q:v ' . (2 + (int)round((100 - $quality) / 100 * 29));
+    }
+    return '-frames:v 1';
+}
+
+/**
+ * 确定放大后的输出格式
+ *
+ * @param array $params
+ * @param int $srcType
+ * @param bool $srcHasAlpha 原图是否带透明通道
+ * @return string
+ */
+function upscaleImageResolveOutputFormat(array $params, int $srcType, bool $srcHasAlpha = false): string
+{
+    $format = strtolower((string)($params['format'] ?? ''));
+    if (in_array($format, ['jpg', 'jpeg', 'png', 'webp'], true)) {
+        return $format;
+    }
+    return upscaleImageGuessFormatByType($srcType, $srcHasAlpha);
+}
+
+/**
+ * 根据图片类型推断默认输出格式
+ *
+ * @param int $srcType
+ * @param bool $srcHasAlpha 原图是否带透明通道
+ * @return string
+ */
+function upscaleImageGuessFormatByType(int $srcType, bool $srcHasAlpha = false): string
+{
+    switch ($srcType) {
+        case IMAGETYPE_PNG:
+            return 'png';
+        case IMAGETYPE_WEBP:
+            // 带透明通道的 WebP 转 PNG 保留透明,否则转 JPG 控制体积
+            return $srcHasAlpha ? 'png' : 'jpg';
+        case IMAGETYPE_GIF:
+            return 'png'; // GIF 转 PNG,保留透明通道
+        case IMAGETYPE_JPEG:
+        default:
+            return 'jpg';
+    }
+}
+
+/**
+ * 检测图片二进制是否带透明通道(目前用于 WebP 默认输出格式判断)
+ *
+ * 通过解析容器头判断,不整图解码,速度快:
+ *  - WebP: ALPH 块存在、VP8X 扩展头的 ALPHA 标志位、VP8L 无损格式的 alpha 标志
+ *
+ * @param string $binary
+ * @return bool
+ */
+function upscaleImageHasAlpha(string $binary): bool
+{
+    if (strlen($binary) < 16 || substr($binary, 0, 4) !== 'RIFF' || substr($binary, 8, 4) !== 'WEBP') {
+        return false;
+    }
+
+    $offset = 12;
+    $len = strlen($binary);
+    while ($offset + 8 <= $len) {
+        $fourcc = substr($binary, $offset, 4);
+        $chunkSize = (int)unpack('V', substr($binary, $offset + 4, 4))[1];
+
+        if ($fourcc === 'ALPH') {
+            return true;
+        }
+        if ($fourcc === 'VP8X' && $chunkSize >= 1) {
+            // VP8X 扩展头标志字节:bit4(0x10)=ALPHA
+            return (ord($binary[$offset + 8]) & 0x10) !== 0;
+        }
+        if ($fourcc === 'VP8L' && $chunkSize >= 1) {
+            // VP8L 无损格式:首字节 bit2=alpha_is_used
+            return ((ord($binary[$offset + 8]) >> 2) & 1) !== 0;
+        }
+        if ($chunkSize <= 0) {
+            break;
+        }
+        // RIFF 块数据为奇数长度时补 1 字节对齐
+        $offset += 8 + $chunkSize + ($chunkSize % 2);
+    }
+
+    return false;
+}
+
+/**
+ * 使用卷积矩阵对图片进行锐化(C级实现,速度快)
+ *
+ * @param resource|\GdImage $img
+ * @param float $amount 锐化强度 0-2
+ * @return void
+ */
+function upscaleImageSharpen($img, float $amount): void
+{
+    $neighbor = -$amount;
+    $center = 1 + 4 * $amount;
+    imageconvolution($img, [
+        [0, $neighbor, 0],
+        [$neighbor, $center, $neighbor],
+        [0, $neighbor, 0],
+    ], 1, 0);
+}
+
+/**
+ * 将 GD 图像编码为指定格式的二进制
+ *
+ * @param resource|\GdImage $img
+ * @param string $format
+ * @param int $quality
+ * @return string|null 失败返回 null
+ */
+function upscaleImageEncode($img, string $format, int $quality): ?string
+{
+    $encoded = null;
+    ob_start();
+    try {
+        switch ($format) {
+            case 'jpg':
+            case 'jpeg':
+                imagejpeg($img, null, $quality);
+                break;
+            case 'png':
+                // 显式开启 alpha 通道保存,否则透明区域会丢失
+                imagesavealpha($img, true);
+                // quality 100-1 映射到 PNG 压缩级别 0-9
+                imagepng($img, null, (int)round((100 - $quality) / 10));
+                break;
+            case 'webp':
+                if (!function_exists('imagewebp')) {
+                    throw new \RuntimeException('GD扩展不支持WebP输出');
+                }
+                imagesavealpha($img, true);
+                imagewebp($img, null, $quality);
+                break;
+            default:
+                throw new \RuntimeException('不支持的输出格式: ' . $format);
+        }
+        $encoded = ob_get_clean();
+    } catch (\Throwable $e) {
+        ob_end_clean();
+        $encoded = null;
+    }
+    return $encoded;
+}
+
+/**
+ * 组装放大结果:上传 TOS 返回 URL,或返回 base64
+ *
+ * @param array $params
+ * @param string $encoded
+ * @param string $format
+ * @param int $dstWidth
+ * @param int $dstHeight
+ * @param int $srcWidth
+ * @param int $srcHeight
+ * @param string $driver
+ * @return array
+ * @throws \RuntimeException
+ */
+function upscaleImageBuildResult(
+    array $params,
+    string $encoded,
+    string $format,
+    int $dstWidth,
+    int $dstHeight,
+    int $srcWidth,
+    int $srcHeight,
+    string $driver
+): array {
+    $upload = (bool)($params['upload'] ?? true);
+    if ($upload) {
+        $ext = $format === 'jpeg' ? 'jpg' : $format;
+        $filename = 'upscale_' . date('YmdHis') . '_' . uniqid() . '.' . $ext;
+        $url = uploadStreamByTos('image', $encoded, $filename);
+        if (!$url) {
+            throw new \RuntimeException('放大图片上传TOS失败');
+        }
+
+        return [
+            'url' => $url,
+            'base64' => null,
+            'width' => $dstWidth,
+            'height' => $dstHeight,
+            'src_width' => $srcWidth,
+            'src_height' => $srcHeight,
+            'upscaled' => true,
+            'driver' => $driver,
+        ];
+    }
+
+    $mime = $format === 'jpg' || $format === 'jpeg' ? 'image/jpeg' : 'image/' . $format;
+    return [
+        'url' => null,
+        'base64' => 'data:' . $mime . ';base64,' . base64_encode($encoded),
+        'width' => $dstWidth,
+        'height' => $dstHeight,
+        'src_width' => $srcWidth,
+        'src_height' => $srcHeight,
+        'upscaled' => true,
+        'driver' => $driver,
+    ];
+}
+
+/**
+ * 计算放大后的目标尺寸
+ *
+ * @param int $srcWidth
+ * @param int $srcHeight
+ * @param array $params
+ * @return array [width, height]
+ */
+function upscaleImageResolveTargetDimensions(int $srcWidth, int $srcHeight, array $params): array
+{
+    $width = (int)($params['width'] ?? 0);
+    $height = (int)($params['height'] ?? 0);
+
+    // 显式指定宽高时,按指定尺寸处理(支持只传其中一个,另一个按比例计算)
+    if ($width > 0 || $height > 0) {
+        $ratio = $srcWidth / $srcHeight;
+        if ($width > 0 && $height > 0) {
+            return [$width, $height];
+        }
+        return $width > 0
+            ? [$width, max(1, (int)round($width / $ratio))]
+            : [max(1, (int)round($height * $ratio)), $height];
+    }
+
+    // 预设档位:按长边放大(1080p=1920,2K=2560,4K=3840)
+    $presets = [
+        '1080p' => 1920,
+        '2k' => 2560,
+        '4k' => 3840,
+    ];
+    $target = strtolower((string)($params['target'] ?? '4k'));
+    $maxEdge = $presets[$target] ?? $presets['4k'];
+
+    $ratio = $srcWidth / $srcHeight;
+    if ($srcWidth >= $srcHeight) {
+        $dstWidth = $maxEdge;
+        $dstHeight = max(1, (int)round($maxEdge / $ratio));
+    } else {
+        $dstHeight = $maxEdge;
+        $dstWidth = max(1, (int)round($maxEdge * $ratio));
+    }
+
+    return [$dstWidth, $dstHeight];
+}
+
 // /**
 // /**
 //  * 使用 Imagick 将远程图片压缩至不超过 maxBytes(默认3MB)内
 //  * 使用 Imagick 将远程图片压缩至不超过 maxBytes(默认3MB)内
 //  * 目标:在尽量保留原始格式和质量的前提下进行压缩,避免内存暴涨
 //  * 目标:在尽量保留原始格式和质量的前提下进行压缩,避免内存暴涨

+ 9 - 3
app/Services/DeepSeek/DeepSeekService.php

@@ -281,10 +281,16 @@ class DeepSeekService
         }
         }
         
         
         // 获取原始文件名(不含扩展名)作为script_name
         // 获取原始文件名(不含扩展名)作为script_name
+        // 注意:不能用pathinfo()取FILENAME,PHP在部分Linux环境下对中文等
+        // 多字节文件名会解析为空(PHP Bug #47954 / #30014),这里改为按扩展名
+        // 字节安全剥离,不依赖服务器locale设置
         $originalName = $file->getClientOriginalName();
         $originalName = $file->getClientOriginalName();
-        $script_name = pathinfo($originalName, PATHINFO_FILENAME);
-        
-        if (!$script_name) {
+        $extension = $file->getClientOriginalExtension();
+        $script_name = $extension !== '' && substr($originalName, -strlen($extension) - 1) === '.' . $extension
+            ? substr($originalName, 0, -strlen($extension) - 1)
+            : $originalName;
+
+        if ($script_name === '') {
             Utils::throwError('20003:未识别到剧本名,请联系管理员');
             Utils::throwError('20003:未识别到剧本名,请联系管理员');
         }
         }
         
         

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
resources/views/animeIndex.blade.php


+ 1 - 0
routes/api.php

@@ -128,6 +128,7 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
         Route::group(['prefix' => 'AIGeneration'], function () {
         Route::group(['prefix' => 'AIGeneration'], function () {
             // 图片生成相关路由
             // 图片生成相关路由
             Route::post('image/createTask', [ImageGenerationController::class, 'createTask']);
             Route::post('image/createTask', [ImageGenerationController::class, 'createTask']);
+            Route::post('image/upscale', [ImageGenerationController::class, 'upscaleImage']);     // 图片超清(超分辨率)放大
             Route::get('image/taskStatus', [ImageGenerationController::class, 'taskStatus']);
             Route::get('image/taskStatus', [ImageGenerationController::class, 'taskStatus']);
             Route::get('image/taskList', [ImageGenerationController::class, 'taskList']);
             Route::get('image/taskList', [ImageGenerationController::class, 'taskList']);