视频转码:上传时自动转 H.264,已有视频批量转换 (#57)

用户 2026-11-05:「以后所有视频全部转成H.264」

新增 app/Video.php:

- isVideo / available / probe / needsTranscode / toH264
- 判据:编码不是 h264 或像素格式不是 yuv420p/nv12 就要转
  (10bit、4:2:2 的 h264 浏览器也不认,光看编码不够)
- 参数:libx264 veryfast crf23 + yuv420p + aac 128k + faststart
  + scale=min(1920,iw):-2(4K 缩到 1080p,-2 保证高度偶数)
- 转完自检编码与像素格式,不对就不采用

Attachment::save() 接入:

- needsTranscode() 先判,已合规的不重复转(不白等几十秒)
- 转码失败不阻断上传,原样保存 + error_log 记原因
- 转完改 mime/ext 为 video/mp4,否则前端判断不出来
- 转码产物是临时文件,必须清理(否则塞满 /tmp)

批量转换已有视频(15 个):

- 13 个已是 h264 跳过,2 个 hevc 转成功
- 原地覆盖同 key,数据库引用全不断
- 原文件备份到 storage/_hevc_backup/

验证:4 个抽样全部 h264/yuv420p 或 yuvj420p,浏览器可播。
This commit is contained in:
2026-11-05 13:01:39 +08:00
parent f58a128816
commit 3c9fb22def
2 changed files with 241 additions and 1 deletions
+43 -1
View File
@@ -83,8 +83,45 @@ final class Attachment
} }
$key = (string)$up['file']; $key = (string)$up['file'];
} else { } else {
/* 🔴🔴 #57(用户 2026-11-05:「以后所有视频全部转成 H.264」)
视频先转码再存。
为什么必须转:用户的视频是手机拍的 H.265/HEVC,
Chrome/Firefox **无法解码**(不存在能解码 HEVC 的前端 JS 库)。
只能在服务端转成 H.264。
⚠️ `needsTranscode()` 先判:已经符合 H.264 + yuv420p 的**不重复转**
—— 用户传合规视频时不该白等几十秒。
⚠️ **转码失败不阻断上传**:原样存。转码可能因磁盘满/编码异常失败,
不该因此让用户传不了;原文件还能下载后用本地播放器看。
⚠️ 转完要改 `$mime` / `$ext`,否则入库的 MIME 还是 video/quicktime
之类,前端又判断不出来。
⚠️ 转码产物是临时文件,**必须清掉**(撒了会塞满 /tmp)。 */
$transcodeNote = '';
if (Video::isVideo($mime) && Video::needsTranscode($tmp)) {
$tr = Video::toH264($tmp);
if (!empty($tr['ok'])) {
$newPath = (string)$tr['path'];
@unlink($tmp); // 原文件不要了
$tmp = $newPath;
$mime = 'video/mp4';
$ext = 'mp4';
$transcodeNote = (string)($tr['note'] ?? '');
$size = (int)@filesize($tmp);
} else {
/* 转码失败:保留原文件,记一笔原因(不阻断上传) */
$transcodeNote = '转码失败,已保存原文件:' . (string)($tr['error'] ?? '');
error_log('[Attachment] 视频转码失败 target=' . $type . '/' . $targetId
. ' err=' . (string)($tr['error'] ?? ''));
}
}
$key = 'files/' . bin2hex(random_bytes(12)) . ($ext !== '' ? '.' . preg_replace('/[^a-z0-9]/', '', $ext) : ''); $key = 'files/' . bin2hex(random_bytes(12)) . ($ext !== '' ? '.' . preg_replace('/[^a-z0-9]/', '', $ext) : '');
$res = Storage::putFile($familyId, $tmp, $key, $mime !== '' ? $mime : 'application/octet-stream'); $res = Storage::putFile($familyId, $tmp, $key, $mime !== '' ? $mime : 'application/octet-stream');
/* 转码产物用完即删 —— 它是临时文件,不能留在 /tmp */
if ($transcodeNote !== '' && strpos($tmp, '.h264.mp4') !== false) {
@unlink($tmp);
}
if (empty($res['ok'])) { if (empty($res['ok'])) {
return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')]; return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')];
} }
@@ -104,7 +141,12 @@ final class Attachment
self::nextPosition($type, $targetId), self::nextPosition($type, $targetId),
$uploaderId > 0 ? $uploaderId : null, $uploaderId > 0 ? $uploaderId : null,
]); ]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()]; return [
'ok' => true,
'id' => (int)$db->lastInsertId(),
/* 转码说明(空串 = 没转,不需要提示) */
'note' => $transcodeNote ?? '',
];
} }
/** 删一个附件(连存储对象一起) */ /** 删一个附件(连存储对象一起) */
+198
View File
@@ -0,0 +1,198 @@
<?php
/**
* 视频转码(H.265/HEVC → H.264)。
*
* 🔴 为什么必须转(2026-11-05 用户问「网页端播放视频的问题解决了吗?」):
*
* 用户的视频是手机拍的 H.265/HEVC(`hvc1`)。浏览器支持现状:
*
* | 浏览器 | HEVC |
* |---|---|
* | Safari | ✅ 系统解码器 |
* | Chrome Windows | ⚠️ 需装微软商店付费扩展 |
* | Chrome macOS/Linux | ❌ |
* | Firefox | ❌ |
* | Edge | ⚠️ 同 Chrome |
*
* **不存在能解码 HEVC 的前端 JS 库** —— hls.js / flv.js / video.js
* 都只是传输层封装,不提供编解码器。所以只能在服务端转。
*
* ## 转码参数为什么这样选
*
* -c:v libx264 -preset veryfast -crf 23
* 兼容性最好的 H.264 编码器。`veryfast` 在服务器上速度可接受,
* `crf 23` 是视觉无损区间(18 更大更清,28 更小更糊)。
* -pix_fmt yuv420p
* 必须显式指定:手机视频常是 yuv420p10le(10bit)或 4:2:2,
* 这类像素格式 **Chrome 不认**,不转成 8bit 4:2:0 会照样黑屏。
* -c:a aac -b:a 128k
* 音频统一 AAC。HEVC 视频的音频常是 PCM/AC3,浏览器也不支持。
* -movflags +faststart
* 把 moov 移到文件头 ⇒ 可边下边播。原来有些视频 moov 在尾部,
* 浏览器得下完整个文件才能开始播。
* -vf scale='min(1920,iw)':-2
* 宽度超过 1920 才缩,等比(-2 保证高度是偶数 —— yuv420 要求)。
* 手机 4K 视频转完还是有 1080p,体积能小一半以上。
*
* ## 失败时的策略
*
* **转码失败不阻断上传** —— 原样保存。理由:
* · 转码失败可能是 ffmpeg 挂了、磁盘满、编码异常,不该因此让用户传不了
* · 原文件至少能下载后用本地播放器看(HEVC 系统播放器都支持)
* · 失败原因写进 error 返回,界面能提示
*/
declare(strict_types=1);
namespace App;
final class Video
{
/** ffmpeg 可执行文件(PATH 里有就直接写 ffmpeg) */
private const FFMPEG = 'ffmpeg';
/** ffprobe */
private const FFPROBE = 'ffprobe';
/** 转码超时(秒)。15MB 的视频 veryfast 大约 10~30 秒,给足余量。 */
private const TIMEOUT = 300;
/**
* 这个 MIME 是视频吗。
*/
public static function isVideo(string $mime): bool
{
return strpos(strtolower($mime), 'video/') === 0;
}
/**
* ffmpeg 是否可用(缓存一次,避免每次上传都 shell 一次)。
*
* ⚠️ 用静态变量缓存:一个请求里可能传多个附件,不必重复探测。
*/
public static function available(): bool
{
static $ok = null;
if ($ok !== null) {
return $ok;
}
$out = [];
$code = 1;
@exec(self::FFMPEG . ' -version 2>&1', $out, $code);
$ok = $code === 0;
return $ok;
}
/**
* 探测视频的编码信息。
*
* @return array{codec:string,pix_fmt:string,width:int,height:int,duration:float}
*/
public static function probe(string $path): array
{
$empty = ['codec' => '', 'pix_fmt' => '', 'width' => 0, 'height' => 0, 'duration' => 0.0];
$cmd = self::FFPROBE . ' -v error -select_streams v:0'
. ' -show_entries stream=codec_name,pix_fmt,width,height'
. ' -show_entries format=duration'
. ' -of default=noprint_wrappers=1 '
. escapeshellarg($path) . ' 2>&1';
$out = [];
$code = 1;
@exec($cmd, $out, $code);
if ($code !== 0) {
return $empty;
}
$kv = [];
foreach ($out as $line) {
$p = strpos($line, '=');
if ($p > 0) {
$kv[substr($line, 0, $p)] = substr($line, $p + 1);
}
}
return [
'codec' => strtolower((string)($kv['codec_name'] ?? '')),
'pix_fmt' => strtolower((string)($kv['pix_fmt'] ?? '')),
'width' => (int)($kv['width'] ?? 0),
'height' => (int)($kv['height'] ?? 0),
'duration' => (float)($kv['duration'] ?? 0),
];
}
/**
* 这个视频需要转码吗。
*
* 判据(任一成立就要转):
* ① 编码不是 h264 —— HEVC/VP9/AV1 在部分浏览器播不了
* ② 像素格式不是 yuv420p/yuvj420p —— 10bit / 4:2:2 浏览器不认
*
* ⚠️ 已经符合的**不重复转**:转码要 CPU 也要时间,
* 用户传 H.264 视频时不该白等。
*/
public static function needsTranscode(string $path): bool
{
$p = self::probe($path);
if ($p['codec'] === '') {
/* 探测失败(文件损坏/ffprobe 缺失)⇒ 不转,原样存,
至少还能下载。硬转可能把好文件弄坏。 */
return false;
}
if ($p['codec'] !== 'h264') {
return true;
}
$okPix = ['yuv420p', 'yuvj420p', 'nv12'];
return !in_array($p['pix_fmt'], $okPix, true);
}
/**
* 转成 H.264。
*
* @return array{ok:bool,path?:string,error?:string,note?:string}
* ok=true 时 `path` 是**新文件**的临时路径(调用方负责清理),
* `note` 是给用户看的一句话(如「已从 H.265 转成 H.264」)。
*/
public static function toH264(string $src): array
{
if (!self::available()) {
return ['ok' => false, 'error' => '服务器没有 ffmpeg,视频无法转码'];
}
$before = self::probe($src);
$dst = $src . '.h264.mp4';
/* 缩放:宽超过 1920 才缩;-2 保证高度偶数(yuv420 要求) */
$scale = "scale='min(1920,iw)':-2";
$cmd = self::FFMPEG . ' -hide_banner -loglevel error -y'
. ' -i ' . escapeshellarg($src)
. ' -c:v libx264 -preset veryfast -crf 23'
. ' -pix_fmt yuv420p'
. ' -vf ' . escapeshellarg($scale)
. ' -c:a aac -b:a 128k'
. ' -movflags +faststart'
. ' ' . escapeshellarg($dst)
. ' 2>&1';
$out = [];
$code = 1;
@exec('timeout ' . self::TIMEOUT . ' ' . $cmd, $out, $code);
if ($code !== 0 || !is_file($dst) || (int)@filesize($dst) === 0) {
@unlink($dst);
$msg = trim(implode(' ', array_slice($out, 0, 3)));
return ['ok' => false, 'error' => '转码失败' . ($msg !== '' ? ':' . $msg : '')];
}
$after = self::probe($dst);
/* 转完自检:必须真的是 h264 + yuv420p,否则别拿去用 */
if ($after['codec'] !== 'h264' || !in_array($after['pix_fmt'], ['yuv420p', 'yuvj420p'], true)) {
@unlink($dst);
return ['ok' => false, 'error' => '转码结果异常(' . $after['codec'] . '/' . $after['pix_fmt'] . ')'];
}
$fromName = $before['codec'] !== '' ? strtoupper($before['codec']) : '原格式';
return [
'ok' => true,
'path' => $dst,
'note' => '已从 ' . $fromName . ' 转成 H.264(' . $before['width'] . 'x' . $before['height']
. ' → ' . $after['width'] . 'x' . $after['height'] . ')',
];
}
}