|
|
@@ -4116,6 +4116,640 @@ function safeDestroyImage(&$img)
|
|
|
$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)内
|
|
|
// * 目标:在尽量保留原始格式和质量的前提下进行压缩,避免内存暴涨
|