diff --git a/app/Attachment.php b/app/Attachment.php index 3e82c66..0362d84 100644 --- a/app/Attachment.php +++ b/app/Attachment.php @@ -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 ?? '', + ]; } /** 删一个附件(连存储对象一起) */ diff --git a/app/Video.php b/app/Video.php new file mode 100644 index 0000000..bc7621f --- /dev/null +++ b/app/Video.php @@ -0,0 +1,198 @@ +&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'] . ')', + ]; + } +} \ No newline at end of file