视频转码:上传时自动转 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'];
} 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) : '');
$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'])) {
return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')];
}
@@ -104,7 +141,12 @@ final class Attachment
self::nextPosition($type, $targetId),
$uploaderId > 0 ? $uploaderId : null,
]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
return [
'ok' => true,
'id' => (int)$db->lastInsertId(),
/* 转码说明(空串 = 没转,不需要提示) */
'note' => $transcodeNote ?? '',
];
}
/** 删一个附件(连存储对象一起) */