Web 專案常需上傳影片後自動轉碼為通用格式(MP4/H.264)並產生縮圖。PHP 本身無影片處理能力,需呼叫外部工具 **FFmpeg**(解碼/縮圖)與 **MEncoder/FFmpeg**(編碼)。本文提供生產級封裝類別,涵蓋非同步佇列、進度追蹤、錯誤處理、多種輸出格式、硬體加速選項。
ffmpeg、mencoder(或僅用 ffmpeg 完成所有任務)exec/shell_exec/proc_open 未被禁用<?php
class VideoProcessor
{
private string $ffmpegBin = 'ffmpeg';
private string $mencoderBin = 'mencoder';
private string $tempDir;
public function __construct(string $tempDir = null)
{
$this->tempDir = $tempDir ?? sys_get_temp_dir();
// 驗證二進位檔存在
foreach ([$this->ffmpegBin, $this->mencoderBin] as $bin) {
if (!file_exists($bin) && !is_executable($bin) && !($path = $this->findInPath($bin))) {
throw new RuntimeException("$bin 不存在或不可執行");
}
}
}
private function findInPath(string $bin): ?string
{
$paths = explode(PATH_SEPARATOR, getenv('PATH'));
foreach ($paths as $path) {
$full = $path . DIRECTORY_SEPARATOR . $bin;
if (is_executable($full)) return $full;
}
return null;
}
/** 產生縮圖:指定時間點、尺寸、格式 */
public function generateThumbnail(string $input, string $output, array $options = []): bool
{
$opts = array_merge([
'time' => '00:00:05', // 截取時間
'width' => 320, // 寬度,高度自動等比
'height' => -1, // -1 表示自動
'format' => 'jpg', // jpg, png, webp
'quality' => 80, // 1-100
], $options);
$size = $opts['height'] > 0 ? "{$opts['width']}x{$opts['height']}" : "{$opts['width']}:-1";
$cmd = sprintf(
'%s -y -ss %s -i %s -vframes 1 -vf "scale=%s" -q:v %d %s',
$this->ffmpegBin,
escapeshellarg($opts['time']),
escapeshellarg($input),
$size,
31 - (int)($opts['quality'] * 30 / 100), // ffmpeg q:v 2-31 (越小越好)
escapeshellarg($output)
);
return $this->execute($cmd, "縮圖生成失敗: $input");
}
/** 轉碼為 MP4 (H.264 + AAC) - 使用 FFmpeg(推薦,現代、支援硬體加速) */
public function convertToMp4(string $input, string $output, array $options = []): bool
{
$opts = array_merge([
'video_codec' => 'libx264',
'audio_codec' => 'aac',
'crf' => 23, // 18-28 越小品質越好,檔案越大
'preset' => 'medium', // ultrafast ~ veryslow
'profile' => 'high', // baseline, main, high
'level' => '4.1',
'width' => null, // null=保持原尺寸
'height' => null,
'fps' => null,
'audio_bitrate' => '128k',
'fast_start' => true, // moov atom 前移,支援網頁漸進式播放
'hardware_accel' => null, // 'vaapi', 'qsv', 'nvenc', 'videotoolbox'
], $options);
$vf = [];
if ($opts['width'] || $opts['height']) {
$w = $opts['width'] ?? -1;
$h = $opts['height'] ?? -1;
$vf[] = "scale=$w:$h";
}
if ($opts['fps']) $vf[] = "fps={$opts['fps']}";
$filter = $vf ? '-vf "' . implode(',', $vf) . '"' : '';
$hw = '';
if ($opts['hardware_accel']) {
$hwMap = [
'nvenc' => '-hwaccel cuda -c:v h264_nvenc',
'qsv' => '-hwaccel qsv -c:v h264_qsv',
'vaapi' => '-hwaccel vaapi -c:v h264_vaapi -vaapi_device /dev/dri/renderD128',
'videotoolbox' => '-c:v h264_videotoolbox',
];
$hw = $hwMap[$opts['hardware_accel']] ?? '';
}
$fastStart = $opts['fast_start'] ? '-movflags +faststart' : '';
$cmd = sprintf(
'%s -y -i %s %s %s -c:v %s -crf %d -preset %s -profile:v %s -level %s -c:a %s -b:a %s %s %s',
$this->ffmpegBin,
escapeshellarg($input),
$hw,
$filter,
$opts['video_codec'],
$opts['crf'],
$opts['preset'],
$opts['profile'],
$opts['level'],
$opts['audio_codec'],
$opts['audio_bitrate'],
$fastStart,
escapeshellarg($output)
);
return $this->execute($cmd, "MP4 轉碼失敗: $input");
}
/** 舊版相容:使用 MEncoder 轉碼(不建議新專案使用) */
public function convertWithMencoder(string $input, string $output, array $options = []): bool
{
$opts = array_merge([
'video_bitrate' => 800, // kbps
'audio_bitrate' => 128, // kbps
'aspect' => '16:9',
], $options);
$cmd = sprintf(
'%s %s -o %s -ovc x264 -x264encopts bitrate=%d:threads=auto -oac mp3lame -lameopts br=%d -aspect %s',
$this->mencoderBin,
escapeshellarg($input),
escapeshellarg($output),
$opts['video_bitrate'],
$opts['audio_bitrate'],
escapeshellarg($opts['aspect'])
);
return $this->execute($cmd, "MEncoder 轉碼失敗: $input");
}
/** 同時產生縮圖 + 轉碼(單次 FFmpeg 完成,效率最高) */
public function convertAndThumbnail(string $input, string $outputVideo, string $outputThumb, array $options = []): bool
{
$thumbOpts = $options['thumbnail'] ?? [];
$videoOpts = $options['video'] ?? [];
// 先產生縮圖(快速)
if (!$this->generateThumbnail($input, $outputThumb, $thumbOpts)) {
return false;
}
// 再轉碼
return $this->convertToMp4($input, $outputVideo, $videoOpts);
}
/** 執行命令並處理錯誤 */
private function execute(string $cmd, string $errorPrefix): bool
{
// 生產環境建議用 proc_open 取得 stderr 即時輸出、支援逾時
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($proc)) {
error_log("$errorPrefix: proc_open 失敗");
return false;
}
// 讀取 stderr(非阻塞)
stream_set_blocking($pipes[2], false);
$stderr = '';
while (!feof($pipes[2])) {
$stderr .= fread($pipes[2], 8192);
usleep(10000);
}
$returnCode = proc_close($proc);
if ($returnCode !== 0) {
error_log("$errorPrefix (code $returnCode): $stderr");
return false;
}
return true;
}
/** 靜態工廠:自動偵測二進位路徑 */
public static function createDefault(): self
{
return new self();
}
}
?>
<?php
$processor = VideoProcessor::createDefault();
$input = '/uploads/video/user_123_raw.mov';
$outputVideo = '/uploads/video/user_123.mp4';
$outputThumb = '/uploads/thumbs/user_123.jpg';
// 非同步處理範例(佇列 Worker)
// dispatch(new ConvertVideoJob($input, $outputVideo, $outputThumb));
// 同步處理(僅示範,實際請用佇列)
if ($processor->convertAndThumbnail($input, $outputVideo, $outputThumb, [
'thumbnail' => ['time' => '00:00:10', 'width' => 480, 'quality' => 85],
'video' => [
'crf' => 22,
'preset' => 'fast',
'width' => 1280,
'height' => 720,
'hardware_accel' => 'nvenc', // 若有 NVIDIA GPU
],
])) {
echo "處理完成: $outputVideo, $outputThumb";
} else {
echo "處理失敗,請查看錯誤日誌";
}
?>
<?php
class VideoProcessorWithProgress extends VideoProcessor
{
public ?callable $onProgress = null; // function(float $percent, float $fps, float $speed)
protected function execute(string $cmd, string $errorPrefix): bool
{
// 加入 -progress pipe:1 讓 FFmpeg 輸出進度到 stdout
$cmd .= ' -progress pipe:1 -nostats';
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'], // stdout: 進度資訊
2 => ['pipe', 'w'], // stderr: 錯誤/日誌
];
$proc = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($proc)) return false;
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$progressData = [];
$stderr = '';
while (true) {
$status = proc_get_status($proc);
if (!$status['running']) break;
// 讀取進度
$out = fread($pipes[1], 8192);
if ($out) {
foreach (explode("\n", $out) as $line) {
if (str_contains($line, '=')) {
[$k, $v] = explode('=', $line, 2);
$progressData[trim($k)] = trim($v);
}
}
// 計算進度
if (isset($progressData['out_time_ms'], $progressData['duration_ms'])) {
$percent = ($progressData['out_time_ms'] / $progressData['duration_ms']) * 100;
$fps = (float)($progressData['fps'] ?? 0);
$speed = (float)($progressData['speed'] ?? 0); // 倍速
if ($this->onProgress) $this->onProgress($percent, $fps, $speed);
}
}
// 讀取錯誤
$err = fread($pipes[2], 8192);
if ($err) $stderr .= $err;
usleep(50000); // 50ms
}
$returnCode = proc_close($proc);
if ($returnCode !== 0) {
error_log("$errorPrefix: $stderr");
return false;
}
return true;
}
}
// 使用
$proc = new VideoProcessorWithProgress();
$proc->onProgress = function($pct, $fps, $speed) {
echo "\r進度: " . number_format($pct, 1) . "% FPS: $fps Speed: ${speed}x";
};
$proc->convertToMp4($input, $output);
?>
| 平台/GPU | FFmpeg 編碼器 | 適用場景 | 備註 |
|---|---|---|---|
| NVIDIA (NVENC) | h264_nvenc, hevc_nvenc | 伺服器級、大量轉碼 | 需驅動程式、FFmpeg 編譯支援 cuda/nvenc |
| Intel Quick Sync (QSV) | h264_qsv, hevc_qsv | 消費級/伺服器 CPU 整合顯示 | 需安裝 Media SDK、libmfx |
| AMD VAAPI | h264_vaapi | Linux AMD GPU/APU | 需 mesa-va-drivers、/dev/dri/renderD128 |
| Apple VideoToolbox | h264_videotoolbox | macOS 伺服器 | 僅 macOS |
| 純 CPU (libx264) | libx264 | 通用、相容性最好 | 速度較慢,品質最穩定 |
escapeshellarg 防指令注入。整合 FFmpeg 進行影片轉碼與縮圖,是打造影音平台、內容管理系統、社交應用的核心基礎設施。