Files
FamilyBook/app/Video.php
T
laoyang c6e4191bb3 观影照片支持视频:转 H.264 + 抽首帧封面 (#84)
用户:确认一下观影照片是不是允许上传视频,如果不允许需要修改,
要求能上传视频、视频需要转码,参考旅程中的行程做法。

事实核查:原来不允许 —— 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/ 目录确认为空。
2026-11-06 10:53:02 +08:00

242 lines
9.3 KiB
PHP
Raw 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'] . ')',
];
}
/**
* 抽一帧做封面(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' => ''];
}
}