用户:确认一下观影照片是不是允许上传视频,如果不允许需要修改, 要求能上传视频、视频需要转码,参考旅程中的行程做法。 事实核查:原来不允许 —— POST /movies/<id>/photos 走 Upload::image(), 那条管线只收 JPG/PNG/GIF/WebP(要裁剪缩转运 JPEG),传视频直接拒。 旅程走 Attachment::save(),内部对 video/* 调 Video::toH264()。 Schema VERSION 35→36: - event_photos 加 kind / thumb_key / mime 三列; kind 默认 image —— 老数据全是照片,默认值即正确,不需要回填 UPDATE; - 建表语句同步改(新库直接是新结构)。 Video.php: - 新增 poster() 抽首帧。取第 1 秒(手机视频开头常是黑场/对焦), 宽 480 足够列表缩略;失败返回空 path 且**不阻断上传**。 ApiHandler.php 上传分支按 MIME 分流: - 视频 → toH264 转码 + poster 抽封面 + Storage 落盘 videos/ 与 thumbs/; 转码失败不阻断(存原视频,至少能下载后用本地播放器看); - 图片 → 仍走 Upload::image(),裁剪压缩行为完全不变。 - 返回值加了 kind / thumb / mime,端上靠它画播放按钮。 Movie.php: - photos() 带出 kind/thumb_key/mime; - addPhotos() 兼容裸键数组与结构化数组,只有一条 INSERT 路径; - deletePhoto() / destroy() 补删 thumb_key,否则删视频留孤儿封面。 验证(服务器实测):造 H.265 视频 → needsTranscode=true,toH264 成功 转成 h264/yuv420p,poster 抽出 13KB 封面;完整上传后接口返回 5 条 (4 image + 1 video 带 thumb)。迁移已跑(35→36),字段与数据核实无误, 测试视频及封面已清理,videos/ 与 thumbs/ 目录确认为空。
242 lines
9.3 KiB
PHP
242 lines
9.3 KiB
PHP
<?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'] . ')',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 抽一帧做封面(2026-11-06,观影照片支持视频)。
|
||
*
|
||
* 为什么要抽首帧:列表/网格里不可能为每个视频实时解码 —— 一屏 9 个视频,
|
||
* 端上要同时挂 9 个解码器,必然卡死。必须服务端抽一帧存成**普通图片**,
|
||
* 列表只显示这张图,点开才真播。
|
||
*
|
||
* ⚠️ 取第 **1 秒**而不是第 0 帧:
|
||
* 手机视频开头经常是黑场/对焦过程(`-ss 0` 抽出来是一片黑),
|
||
* 第 1 秒通常已经有内容了。
|
||
* ⚠️ **失败不阻断**(返回空 path):
|
||
* 抽帧失败不影响视频本身能用;端上对没有封面的视频显示一个通用图标即可。
|
||
* 让"没有封面"变成上传失败是最不能接受的取舍。
|
||
*
|
||
* @return array{ok:bool, path:string, error:string}
|
||
*/
|
||
public static function poster(string $src): array
|
||
{
|
||
if (!self::available()) {
|
||
return ['ok' => false, 'path' => '', 'error' => '服务器没有 ffmpeg'];
|
||
}
|
||
$dst = $src . '.poster.jpg';
|
||
/* 宽度 480 足够列表缩略(端上最大也就显示 100vp 左右)。
|
||
等比、-2 保证偶数。`-frames:v 1` 只出这一帧。 */
|
||
$cmd = self::FFMPEG . ' -hide_banner -loglevel error -y'
|
||
. ' -ss 1 -i ' . escapeshellarg($src)
|
||
. ' -frames:v 1 -vf ' . escapeshellarg("scale=480:-2")
|
||
. ' -q:v 4'
|
||
. ' ' . escapeshellarg($dst)
|
||
. ' 2>&1';
|
||
|
||
$out = [];
|
||
$code = 1;
|
||
/* 抽帧很快,给 30 秒足够;超时按失败处理,不拖住上传 */
|
||
@exec('timeout 30 ' . $cmd, $out, $code);
|
||
|
||
if ($code !== 0 || !is_file($dst) || (int)@filesize($dst) === 0) {
|
||
@unlink($dst);
|
||
$msg = trim(implode(' ', array_slice($out, 0, 2)));
|
||
return ['ok' => false, 'path' => '', 'error' => '抽帧失败' . ($msg !== '' ? ':' . $msg : '')];
|
||
}
|
||
return ['ok' => true, 'path' => $dst, 'error' => ''];
|
||
}
|
||
} |