FLV (Flash Video) 格式雖已淡出主流,但在舊系統遷移、影片資產審計、轉碼前置檢查場景仍需解析時長。本文實現純 PHP 解析 FLV Header 與 Tag 結構,提取 duration (毫秒),無需 FFmpeg 等外部工具。
[FLV Header: 9-13 bytes]
Signature: 'FLV' (3 bytes)
Version: 1 byte
Flags: 1 byte (audio/video)
DataOffset: 4 bytes (UI32)
[Previous Tag Size: 4 bytes] = 0
[Tag 1]
Tag Header: 11 bytes
Type: 1 byte (8=audio, 9=video, 18=script)
DataSize: 3 bytes (UI24)
Timestamp: 3 bytes (UI24)
TimestampExtended: 1 byte
StreamID: 3 bytes (always 0)
Tag Data: DataSize bytes
[Previous Tag Size: 4 bytes] = Tag Header + Tag Data size
[Tag 2] ...
...
[EOF]
duration、width、height、framerate、videodatarate 等Timestamp 取最大值<?php
/**
* FLV 時長解析器
* 純 PHP 實現,無外部依賴
*/
class FLVDurationParser
{
private string $filePath;
private resource $fp;
private int $fileSize;
private array $header = [];
public function __construct(string $filePath)
{
$this->filePath = $filePath;
}
public function getDuration(): float
{
if (!file_exists($this->filePath)) {
throw new Exception("File not found: {$this->filePath}");
}
$this->fileSize = filesize($this->filePath);
$this->fp = fopen($this->filePath, 'rb');
if (!$this->fp) {
throw new Exception("Cannot open file: {$this->filePath}");
}
try {
$this->parseHeader();
$duration = $this->parseTags();
return $duration / 1000.0; // 返回秒數 (float)
} finally {
fclose($this->fp);
}
}
private function parseHeader(): void
{
$header = fread($this->fp, 13);
if (strlen($header) < 9) throw new Exception('Invalid FLV header');
$signature = substr($header, 0, 3);
if ($signature !== 'FLV') throw new Exception('Not an FLV file');
$this->header['version'] = ord($header[3]);
$this->header['flags'] = ord($header[4]);
$this->header['data_offset'] = $this->readUI32(substr($header, 5, 4));
// 移至第一個 Tag (跳過 PreviousTagSize0 = 4 bytes)
fseek($this->fp, $this->header['data_offset'], SEEK_SET);
}
private function parseTags(): int
{
$maxTimestamp = 0;
$foundMetadata = false;
while (!feof($this->fp) && ftell($this->fp) < $this->fileSize) {
$tagHeader = fread($this->fp, 11);
if (strlen($tagHeader) < 11) break;
$tagType = ord($tagHeader[0]);
$dataSize = $this->readUI24(substr($tagHeader, 1, 3));
$timestamp = $this->readUI24(substr($tagHeader, 4, 3));
$timestampExt = ord($tagHeader[7]);
$timestamp = ($timestampExt << 24) | $timestamp; // UI32
$streamId = $this->readUI24(substr($tagHeader, 8, 3)); // 應為 0
$tagDataStart = ftell($this->fp);
$tagDataEnd = $tagDataStart + $dataSize;
// Script Data Tag (18) - 嘗試提取 onMetaData duration
if ($tagType === 18 && !$foundMetadata) {
$metadataDuration = $this->parseScriptData($dataSize);
if ($metadataDuration > 0) {
fclose($this->fp);
return $metadataDuration; // 直接返回,最準確
}
$foundMetadata = true;
}
// Video Tag (9) - 記錄最大時間戳作為備選
if ($tagType === 9) {
if ($timestamp > $maxTimestamp) $maxTimestamp = $timestamp;
}
// Audio Tag (8) - 同理
if ($tagType === 8) {
if ($timestamp > $maxTimestamp) $maxTimestamp = $timestamp;
}
// 跳過 Tag Data
fseek($this->fp, $tagDataEnd, SEEK_SET);
// 跳過 Previous Tag Size (4 bytes)
fseek($this->fp, 4, SEEK_CUR);
}
return $maxTimestamp; // 毫秒
}
/**
* 解析 Script Data (AMF0) 尋找 duration
* 簡易實現:尋找 "duration" 字串後的 8 字節 double
*/
private function parseScriptData(int $dataSize): int
{
$data = fread($this->fp, $dataSize);
if (strlen($data) !== $dataSize) return 0;
// 尋找 "onMetaData" 字串 (AMF0 字串格式: 0x02 + length(UI16) + bytes)
// 簡化:直接搜尋 "duration" 關鍵字
$pos = strpos($data, 'duration');
if ($pos !== false) {
// duration 通常緊接著:字串結束後為 AMF0 Number (0x00 + 8 bytes double)
$numPos = $pos + 8; // "duration" 長度 8
// 往後找 0x00 (AMF0 Number 標識)
for ($i = $numPos; $i < min($numPos + 10, $dataSize); $i++) {
if (ord($data[$i]) === 0x00) {
// 讀取 8 字節 double (大端序)
if ($i + 8 < $dataSize) {
$doubleBytes = substr($data, $i + 1, 8);
$duration = $this->readDoubleBE($doubleBytes);
return (int)round($duration * 1000); // 秒轉毫秒
}
}
}
}
// 兜底:尋找第一個 double 值 (假設為 duration)
// 實際 AMF0 解析較複雜,建議生產環境用 php-amf 或 ffprobe
return 0;
}
// 工具方法:大端序讀取
private function readUI24(string $bytes): int
{
return (ord($bytes[0]) << 16) | (ord($bytes[1]) << 8) | ord($bytes[2]);
}
private function readUI32(string $bytes): int
{
return (ord($bytes[0]) << 24) | (ord($bytes[1]) << 16) | (ord($bytes[2]) << 8) | ord($bytes[3]);
}
private function readDoubleBE(string $bytes): float
{
// PHP unpack 大端序 double
$arr = unpack('d', strrev($bytes)); // x86 小端序,需反轉
return $arr[1] ?? 0.0;
}
// 靜態快捷方法
public static function getDuration(string $filePath): float
{
return (new self($filePath))->getDuration();
}
public static function getFormattedDuration(string $filePath): string
{
$seconds = self::getDuration($filePath);
$h = (int)($seconds / 3600);
$m = (int)(($seconds % 3600) / 60);
$s = (int)($seconds % 60);
return sprintf('%02d:%02d:%02d', $h, $m, $s);
}
}
<?php
require 'FLVDurationParser.php';
$flvFile = 'video/sample.flv';
try {
// 獲取秒數 (float)
$durationSec = FLVDurationParser::getDuration($flvFile);
echo "Duration: {$durationSec} 秒\n";
// 獲取格式化 HH:MM:SS
$formatted = FLVDurationParser::getFormattedDuration($flvFile);
echo "Formatted: {$formatted}\n";
// 批次處理
$files = glob('videos/*.flv');
foreach ($files as $file) {
$dur = FLVDurationParser::getFormattedDuration($file);
echo basename($file) . " => $dur\n";
}
} catch (Exception $e) {
echo "Error: " . $e->getMessage();
}
| 指標 | 表現 |
|---|---|
| 解析速度 | ~5-20 MB/s (純 PHP 循環),100MB 檔案約 5-10 秒 |
| 記憶體 | 常數級 (僅緩衝 Tag Data),不載入整檔 |
| 準確度 | 有 onMetaData 時極高;無則依賴最大 Timestamp (可能比實際略長) |
| 支援格式 | 標準 FLV (H.263/VP6/H.264 + MP3/AAC) |
| 不支援 | 加密 FLV、F4V (MP4 容器)、Live FLV (無限流) |
function getDurationFFprobe(string $file): float
{
$cmd = "ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 " . escapeshellarg($file);
$output = shell_exec($cmd);
return (float)trim($output);
}
// 或使用 PHP-FFmpeg 庫
// $probe = FFMpeg\FFProbe::create();
// $duration = $probe->format($file)->get('duration');
優勢:支援所有容器格式、準確度 100%、解析速度快 (只讀 Header)、C 語言實現穩定。
FLV 解析是理解二進制容器格式、AMF 編碼、大端序處理的優秀練習。但實際專案中,**FFprobe 是標準答案**,純 PHP 實現僅作為受限環境的備選或教學用途。