影音平台核心需求:上傳後自動產生縮圖、顯示總長度。本文示範 PHP 呼叫 FFmpeg (CLI) 非同步處理,含參數詳解、安全防命令注入、錯誤處理、佇列架構建議,並提供純 PHP 替代方案 (getID3)。
apt install ffmpeg / brew install ffmpeg / Windows 下載靜態編譯版exec()/shell_exec()/proc_open() (未被 disable_functions 限制)<?php
namespace App\Media;
class FFmpegProcessor {
private string $ffmpegPath;
private string $ffprobePath;
private int $timeout;
public function __construct(string $ffmpegPath = 'ffmpeg', string $ffprobePath = 'ffprobe', int $timeout = 300) {
$this->ffmpegPath = $ffmpegPath;
$this->ffprobePath = $ffprobePath;
$this->timeout = $timeout;
// 驗證指令存在
if (!$this->commandExists($ffmpegPath)) {
throw new RuntimeException("FFmpeg 不存在: $ffmpegPath");
}
if (!$this->commandExists($ffprobePath)) {
throw new RuntimeException("FFprobe 不存在: $ffprobePath");
}
}
private function commandExists(string $cmd): bool {
$result = @shell_exec("which " . escapeshellarg($cmd) . " 2>/dev/null");
return !empty(trim($result));
}
/**
* 取得影片資訊 (時長、解析度、編碼、位元率等)
*
* @param string $inputPath 影片絕對路徑
* @return array ['duration' => 秒數(float), 'width', 'height', 'codec', 'bitrate', 'format', ...]
* @throws RuntimeException
*/
public function getVideoInfo(string $inputPath): array {
if (!file_exists($inputPath) || !is_readable($inputPath)) {
throw new InvalidArgumentException("檔案不存在或不可讀: $inputPath");
}
// 使用 ffprobe JSON 輸出,易於解析
$cmd = sprintf(
'%s -v quiet -print_format json -show_format -show_streams %s',
escapeshellarg($this->ffprobePath),
escapeshellarg($inputPath)
);
$output = $this->executeCommand($cmd);
$data = json_decode($output, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException("ffprobe JSON 解析失敗: " . json_last_error_msg());
}
$videoStream = null;
$audioStream = null;
foreach ($data['streams'] ?? [] as $stream) {
if (($stream['codec_type'] ?? '') === 'video' && !$videoStream) {
$videoStream = $stream;
}
if (($stream['codec_type'] ?? '') === 'audio' && !$audioStream) {
$audioStream = $stream;
}
}
$format = $data['format'] ?? [];
return [
'duration' => (float)($format['duration'] ?? $videoStream['duration'] ?? 0),
'width' => (int)($videoStream['width'] ?? 0),
'height' => (int)($videoStream['height'] ?? 0),
'video_codec'=> $videoStream['codec_name'] ?? null,
'audio_codec'=> $audioStream['codec_name'] ?? null,
'bitrate' => (int)($format['bit_rate'] ?? 0),
'format' => $format['format_name'] ?? null,
'size' => (int)($format['size'] ?? 0),
'fps' => $this->parseFps($videoStream['r_frame_rate'] ?? '0/0'),
'raw' => $data, // 完整資料供進階用途
];
}
private function parseFps(string $fpsStr): float {
if (str_contains($fpsStr, '/')) {
[$num, $den] = explode('/', $fpsStr);
return $den != 0 ? (float)$num / (float)$den : 0;
}
return (float)$fpsStr;
}
/**
* 擷取縮圖 (關鍵幀)
*
* @param string $inputPath 來源影片路徑
* @param string $outputPath 輸出圖片路徑 (副檔名決定格式 .jpg/.png/.webp)
* @param float $time 擷取時間點 (秒),預設 1 秒
* @param int $width 輸出寬度 (高度自動等比),0 保持原尺寸
* @return bool 成功與否
*/
public function extractThumbnail(
string $inputPath,
string $outputPath,
float $time = 1.0,
int $width = 320
): bool {
if (!file_exists($inputPath)) {
throw new InvalidArgumentException("來源檔案不存在: $inputPath");
}
// 確保輸出目錄存在
$outDir = dirname($outputPath);
if (!is_dir($outDir)) {
mkdir($outDir, 0755, true);
}
$scaleFilter = $width > 0 ? "scale=$width:-1" : "scale=iw:ih";
$cmd = sprintf(
'%s -y -ss %.3f -i %s -vframes 1 -vf %s %s',
escapeshellarg($this->ffmpegPath),
$time,
escapeshellarg($inputPath),
escapeshellarg($scaleFilter),
escapeshellarg($outputPath)
);
$this->executeCommand($cmd);
return file_exists($outputPath) && filesize($outputPath) > 0;
}
/**
* 批次擷取多張縮圖 (如每 10 秒一張、或固定張數均勻分佈)
*/
public function extractThumbnailsInterval(
string $inputPath,
string $outputDir,
float $interval = 10.0,
int $width = 320,
string $prefix = 'thumb_'
): array {
$info = $this->getVideoInfo($inputPath);
$duration = $info['duration'];
if ($duration <= 0) return [];
$count = max(1, (int)($duration / $interval));
$outputs = [];
for ($i = 1; $i <= $count; $i++) {
$time = min($i * $interval, $duration - 0.5);
$outputPath = $outputDir . '/' . $prefix . str_pad($i, 3, '0', STR_PAD_LEFT) . '.jpg';
if ($this->extractThumbnail($inputPath, $outputPath, $time, $width)) {
$outputs[] = $outputPath;
}
}
return $outputs;
}
/**
* 執行指令並處理錯誤
*/
private function executeCommand(string $cmd): string {
// proc_open 可分離 stdout/stderr,取得 exit code
$descriptors = [
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'], // stderr
];
$proc = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($proc)) {
throw new RuntimeException("無法啟動程序: $cmd");
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$returnCode = proc_close($proc);
if ($returnCode !== 0) {
throw new RuntimeException(
"指令執行失敗 (exit $returnCode): $cmd\nStderr: $stderr"
);
}
return $stdout;
}
}
<?php
require_once 'vendor/autoload.php';
use App\Media\FFmpegProcessor;
$processor = new FFmpegProcessor('/usr/bin/ffmpeg', '/usr/bin/ffprobe');
$videoPath = '/var/www/uploads/video_123.mp4';
$thumbPath = '/var/www/uploads/thumbs/video_123.jpg';
try {
// 1. 取得資訊
$info = $processor->getVideoInfo($videoPath);
echo "時長: {$info['duration']} 秒\n";
echo "解析度: {$info['width']}x{$info['height']}\n";
echo "編碼: {$info['video_codec']}\n";
echo "位元率: " . number_format($info['bitrate'] / 1000) . " kbps\n";
// 2. 擷取縮圖 (第 5 秒,寬 480px)
if ($processor->extractThumbnail($videoPath, $thumbPath, 5.0, 480)) {
echo "縮圖產生成功: $thumbPath\n";
}
// 3. 批次擷取 (每 30 秒一張)
$thumbs = $processor->extractThumbnailsInterval($videoPath, '/var/www/uploads/thumbs/video_123/', 30.0, 320);
echo "共產生 " . count($thumbs) . " 張縮圖\n";
} catch (Exception $e) {
error_log("FFmpeg 處理失敗: " . $e->getMessage());
echo "處理失敗: " . $e->getMessage();
}
所有使用者輸入路徑/參數必須經 escapeshellarg() 處理,如上方程式碼所示。嚴禁**直接字串拼接**:
// ❌ 極度危險
$cmd = "ffmpeg -i $userInput -ss $time ...";
// ✅ 正確
$cmd = sprintf('ffmpeg -i %s -ss %s ...', escapeshellarg($userInput), escapeshellarg($time));
escapeshellarg($path)escapesharg((string)$num) 或 (float)$num 確保型別shell_exec 改用 proc_open 避免 Shell 解析| 場景 | 建議方案 |
|---|---|
| 少量上傳、即時需求 | 同步呼叫 (上方範例),設定合理 timeout |
| 大量上傳、長影片 | 佇列非同步:上傳後寫入 Redis/RabbitMQ,Worker 處理 |
| 多規格轉碼 (HLS/DASH) | FFmpeg 複雜濾鏡圖、或使用 PHP-FFMpeg 物件導向封裝 |
| 雲端儲存 (S3/OSS) | 預簽名 URL 下載 → 處理 → 上傳回雲端,或掛載 FUSE |
| 無法安裝 FFmpeg (共享主機) | 純 PHP 解析:getID3 (僅資訊,無法擷取縮圖/轉碼) |
composer require james-heinrich/getid3
<?php
require_once 'vendor/autoload.php';
$getID3 = new getID3;
$info = $getID3->analyze('/path/video.mp4');
echo "時長: " . $info['playtime_seconds'] . " 秒\n";
echo "解析度: {$info['video']['resolution_x']}x{$info['video']['resolution_y']}\n";
echo "編碼: {$info['video']['codec']}\n";
// 無法擷取縮圖、轉碼
| 錯誤 | 原因 | 解決 |
|---|---|---|
sh: ffmpeg: command not found | PATH 不含 ffmpeg、未安裝 | 指定絕對路徑 /usr/bin/ffmpeg 或安裝 |
Permission denied | PHP 使用者無執行權限 | chmod +x /usr/bin/ffmpeg、檢查 disable_functions |
| 輸出檔案 0 bytes | 時間點超過影片長度、關鍵幀間距大 | 調整 -ss 時間、嘗試 -ss 00:00:01 格式 |
| 記憶體耗盡 | 大檔案處理、佇列堆積 | 限制並發 Worker 數、分片處理 |
PHP 整合 FFmpeg 是影音處理標準做法:ffprobe 取資訊 (JSON)、ffmpeg 擷取縮圖/轉碼。關鍵在於 escapeshellarg() 防注入、proc_open 錯誤處理、佇列非同步架構。無法安裝 FFmpeg 環境可用 getID3 讀取資訊,但無法處理影音內容。建立健全的 MediaProcessor 服務類,即可支撐上傳、轉碼、縮圖、串流等完整影音工作流。