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

322 lines
13 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
/**
* 统一附件(v12):旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表。
*
* 为什么要统一:这几个模块对附件的要求其实是同一件事 ——
* "一个宿主挂多个文件、能下载、图片能预览、删宿主时别留下孤儿"。
* 各写一张表就要把"下载/预览/权限"写四遍,将来再加模块还得写第五遍。
*
* 下载地址**不落库**:一律由 media.php?k=<key> 现算(地址形态换过一次,
* 历史数据自动生效、零迁移 —— 这是 FeedLite 那边学到的最省事的一条)。
*/
declare(strict_types=1);
namespace App;
use App\Storage\Storage;
final class Attachment
{
/** 图片扩展名 → MIME(服务端自己判,不信浏览器给的类型) */
private const IMG_MIME = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'heic' => 'image/heic',
];
public const MAX_PER_HOST = 60;
/** 某个宿主的全部附件 */
public static function list(string $type, int $targetId): array
{
$st = Database::get()->prepare(
'SELECT * FROM attachments WHERE target_type = ? AND target_id = ? ORDER BY position, id'
);
$st->execute([$type, $targetId]);
return $st->fetchAll();
}
/**
* 存一个上传来的文件。
*
* @return array{ok:bool,error?:string,id?:int}
*/
public static function save(int $familyId, string $type, int $targetId, array $file, int $uploaderId = 0): array
{
if (!array_key_exists('error', $file)) {
return ['ok' => false, 'error' => ''];
}
if (is_array($file['error'])) {
return ['ok' => false, 'error' => '上传参数不对'];
}
if ($file['error'] === UPLOAD_ERR_NO_FILE) {
return ['ok' => false, 'error' => ''];
}
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')'];
}
$stc = Database::get()->prepare('SELECT COUNT(*) FROM attachments WHERE target_type = ? AND target_id = ?');
$stc->execute([$type, $targetId]);
if ((int)$stc->fetchColumn() >= self::MAX_PER_HOST) {
return ['ok' => false, 'error' => '单个条目最多 ' . self::MAX_PER_HOST . ' 个附件'];
}
$tmp = (string)$file['tmp_name'];
$size = (int)@filesize($tmp);
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
$name = self::safeName((string)($file['name'] ?? ''));
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($ext === '' && isset(self::IMG_MIME[$mime])) {
$ext = explode('/', $mime)[1] ?? '';
}
// 图片走 photo 槽位(ratio='free':不裁剪,等比缩到最长边 1600);
// 非图片(PDF/文档)原样存 —— 压缩反而会把它弄坏。
// ⚠️ 槽位键必须是 ImageSlot::SLOTS 里真实存在的那个:曾经这里写 'free'
// (把"不裁剪"的 ratio 当成槽位名用了),于是 Upload::image 直接返回
// 「上传位置配置不正确」,所有附件上传静默失败。
$isImage = strpos($mime, 'image/') === 0;
if ($isImage) {
$up = Upload::image($familyId, $file, 'photo');
if (empty($up['ok'])) {
return ['ok' => false, 'error' => (string)($up['error'] ?? '存图失败')];
}
$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'] ?? '')];
}
}
$db = Database::get();
$db->prepare(
'INSERT INTO attachments
(family_id, target_type, target_id, `key`, name, mime, size, is_image, position, uploader_id)
VALUES (?,?,?,?,?,?,?,?,?,?)'
)->execute([
$familyId, $type, $targetId, $key,
$name !== '' ? $name : null,
$mime !== '' ? $mime : null,
$size > 0 ? $size : null,
$isImage ? 1 : 0,
self::nextPosition($type, $targetId),
$uploaderId > 0 ? $uploaderId : null,
]);
return [
'ok' => true,
'id' => (int)$db->lastInsertId(),
/* 转码说明(空串 = 没转,不需要提示) */
'note' => $transcodeNote ?? '',
];
}
/** 删一个附件(连存储对象一起) */
public static function remove(int $id): void
{
$st = Database::get()->prepare('SELECT family_id, `key` FROM attachments WHERE id = ?');
$st->execute([$id]);
$r = $st->fetch();
if (!$r) {
return;
}
Database::get()->prepare('DELETE FROM attachments WHERE id = ?')->execute([$id]);
Storage::delete((int)$r['family_id'], (string)$r['key']);
}
/** 删某个宿主的全部附件(删旅程 / 删资源 / 删任务时调) */
public static function removeAll(string $type, int $targetId): void
{
foreach (self::list($type, $targetId) as $a) {
self::remove((int)$a['id']);
}
}
/**
* 删**某个宿主名下**的一颗附件(v79,App API 用)。
*
* 🔴 为什么不能直接用 `remove($id)`:那个只认附件 id,谁传一个别的任务的
* 附件 id 过来都能删掉(越权)。这里把 owner 一起放进 WHERE,
* 「不是这条任务的」一律返回 false,调用方据此回 404。
*/
public static function removeFrom(string $type, int $targetId, int $id): bool
{
if ($id <= 0) {
return false;
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?'
);
$st->execute([$id, $type, $targetId]);
if ((int)$st->fetchColumn() <= 0) {
return false;
}
self::remove($id);
return true;
}
/**
* 这个附件是「哪一类」—— 决定页面上画缩略图还是画图标、点开是预览还是下载。
*
* 返回:image / video / pdf / doc / xls / ppt / text / file
*
* ⚠️ 判据是**库里的 mime**(上传时由服务端嗅探内容得来),不看文件名后缀:
* 改个后缀骗不过它,而"文档其实是张图"这种也认得出来。
*/
public static function kind(array $a): string
{
if ((int)($a['is_image'] ?? 0) === 1) {
return 'image';
}
$mime = strtolower((string)($a['mime'] ?? ''));
$name = strtolower((string)($a['name'] ?? ''));
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($mime === '' && $ext !== '') {
$mime = self::EXT_MIME[$ext] ?? '';
}
if (strpos($mime, 'video/') === 0) {
return 'video';
}
if (strpos($mime, 'audio/') === 0) {
return 'video'; // 音频用播放器放,与视频同一条路
}
if (strpos($mime, 'pdf') !== false || $ext === 'pdf') {
return 'pdf';
}
if (strpos($mime, 'text/') === 0 || in_array($ext, ['txt', 'md', 'csv', 'log', 'json'], true)) {
return 'text';
}
// Office 的 MIME 一长串且各家写法不一(含 -msword / officedocument / opendocument),
// 扩展名反而更稳,所以这里两条路都认。
if (self::inAny($mime . ' ' . $ext, ['word', 'doc', 'rtf', 'odt', 'wps'])) {
return 'doc';
}
if (self::inAny($mime . ' ' . $ext, ['excel', 'sheet', 'xls', 'xlsx', 'csv', 'ods', 'et'])) {
return 'xls';
}
if (self::inAny($mime . ' ' . $ext, ['powerpoint', 'presentation', 'ppt', 'pps', 'odp', 'dps'])) {
return 'ppt';
}
return 'file';
}
/**
* 能在**本页**直接预览吗。
*
* 图片与视频不用说;PDF 浏览器自带阅读器,也能直接看。
* Word / Excel 之类浏览器渲染不了 —— 点了就下载(不装"点了没反应"的预览)。
*/
public static function previewable(array $a): bool
{
return in_array(self::kind($a), ['image', 'video', 'pdf'], true);
}
/** 这一类在人话里叫什么(图标上的角标、预览标题都用它) */
public static function kindWord(string $kind): string
{
return self::KIND_WORDS[$kind] ?? '文件';
}
private const KIND_WORDS = [
'image' => '图片', 'video' => '视频', 'pdf' => 'PDF',
'doc' => 'Word', 'xls' => 'Excel', 'ppt' => 'PPT',
'text' => '文本', 'file' => '文件',
];
/** 扩展名兜底用的 MIME 表(库里 mime 为空时) */
private const EXT_MIME = [
'pdf' => 'application/pdf', 'txt' => 'text/plain', 'md' => 'text/markdown',
'csv' => 'text/csv', 'json' => 'application/json',
'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm',
'mov' => 'video/quicktime', 'avi' => 'video/x-msvideo', 'mkv' => 'video/x-matroska',
'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'wav' => 'audio/wav',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed',
];
private static function inAny(string $hay, array $needles): bool
{
foreach ($needles as $n) {
if (strpos($hay, $n) !== false) {
return true;
}
}
return false;
}
/** 人话的大小 */
public static function sizeWord($bytes): string
{
$b = (int)$bytes;
if ($b <= 0) {
return '';
}
if ($b < 1024) {
return $b . ' B';
}
if ($b < 1024 * 1024) {
return round($b / 1024, 1) . ' KB';
}
return round($b / 1024 / 1024, 1) . ' MB';
}
private static function safeName(string $n): string
{
$n = str_replace(['\\', '/', ':', '*', '?', '"', '<', '>', '|'], '', $n);
$n = trim(preg_replace('/\s+/u', ' ', $n));
return mb_substr($n, 0, 180, 'UTF-8');
}
private static function nextPosition(string $type, int $targetId): int
{
$st = Database::get()->prepare(
'SELECT COALESCE(MAX(position),0)+10 FROM attachments WHERE target_type = ? AND target_id = ?'
);
$st->execute([$type, $targetId]);
return (int)$st->fetchColumn();
}
}