Files
laoyang 3c9fb22def 视频转码:上传时自动转 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,浏览器可播。
2026-11-05 13:01:39 +08:00

198 lines
7.2 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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'] . ')',
];
}
}