feat(ffmpeg): dynamic build discovery + capabilities, wire ffmpeg_gpu, drop 4.0 hardcoding

Discover the bundled FFmpeg builds from bin/ffmpeg_bin/<version>/ instead of a
hardcoded version list, and expose what each build can do.

- FfmpegBinaries (new): scans real version folders (skips *.backup.*), probes each
  binary's hardware encoders (nvenc/qsv/vaapi/…), flags GPU capability, caches the
  result to tmp/ffmpeg_capabilities.json keyed by a folder-mtime fingerprint. UI-only.
- FfmpegPaths::resolve() rewritten: builds paths dynamically from the version string
  (is_file check, no shell — stays on the bootstrap hot path) and takes a separate GPU
  version; the switch and the per-version constants are gone. Unknown CPU version falls
  back to the legacy 4.0 build; an empty/invalid GPU version falls back to the CPU build.
- Wire the previously-dead ffmpeg_gpu column: LegacyInitializer passes it to resolve();
  settings.php lists versions from the scanner and adds an ffmpeg_gpu <select> limited to
  GPU-capable builds ("Same as CPU" = empty). No migration — the column already exists.
- Info tab: a per-build FFmpeg table (version, build banner, GPU badge, hw encoders).
- De-hardcode: drop the unused FFMPEG_BIN_71/80 + FFPROBE_BIN_71/80 constants; keep only
  the 4.0 anchor (DTS override in StreamProcess + FfmpegPaths fallback). Llod/Loopback/
  Record now use FfmpegPaths::cpu()/probe() (config-selected version) with the 4.0
  constant as fallback, matching Scanner/Created/Thumbnail.
This commit is contained in:
Divarion_D
2026-08-30 14:05:34 +03:00
parent 47200c1322
commit 0e2d64cdfd
8 changed files with 307 additions and 39 deletions
+2 -2
View File
@@ -56,8 +56,8 @@ class LlodCommand implements CommandInterface {
if (!defined('STREAMS_PATH')) define('STREAMS_PATH', MAIN_HOME . 'content/streams/');
if (!defined('CACHE_TMP_PATH')) define('CACHE_TMP_PATH', MAIN_HOME . 'tmp/cache/');
if (!defined('CONS_TMP_PATH')) define('CONS_TMP_PATH', MAIN_HOME . 'tmp/opened_cons/');
if (!defined('FFMPEG')) define('FFMPEG', MAIN_HOME . 'bin/ffmpeg_bin/4.0/ffmpeg');
if (!defined('FFPROBE')) define('FFPROBE', MAIN_HOME . 'bin/ffmpeg_bin/4.0/ffprobe');
if (!defined('FFMPEG')) define('FFMPEG', \XcVm\Streaming\Codec\FfmpegPaths::cpu() ?: FFMPEG_BIN_40);
if (!defined('FFPROBE')) define('FFPROBE', \XcVm\Streaming\Codec\FfmpegPaths::probe() ?: FFPROBE_BIN_40);
if (!defined('PACKET_SIZE')) define('PACKET_SIZE', 188);
if (!defined('BUFFER_SIZE')) define('BUFFER_SIZE', 12032);
if (!defined('TIMEOUT')) define('TIMEOUT', 20);
+2 -2
View File
@@ -44,8 +44,8 @@ class LoopbackCommand implements CommandInterface {
if (!defined('MAIN_HOME')) define('MAIN_HOME', '/home/xc_vm/');
if (!defined('STREAMS_PATH')) define('STREAMS_PATH', MAIN_HOME . 'content/streams/');
if (!defined('FFMPEG')) define('FFMPEG', MAIN_HOME . 'bin/ffmpeg_bin/4.0/ffmpeg');
if (!defined('FFPROBE')) define('FFPROBE', MAIN_HOME . 'bin/ffmpeg_bin/4.0/ffprobe');
if (!defined('FFMPEG')) define('FFMPEG', \XcVm\Streaming\Codec\FfmpegPaths::cpu() ?: FFMPEG_BIN_40);
if (!defined('FFPROBE')) define('FFPROBE', \XcVm\Streaming\Codec\FfmpegPaths::probe() ?: FFPROBE_BIN_40);
if (!defined('CACHE_TMP_PATH')) define('CACHE_TMP_PATH', MAIN_HOME . 'tmp/cache/');
if (!defined('CONFIG_PATH')) define('CONFIG_PATH', MAIN_HOME . 'config/');
// PAT_HEADER restored to the real PAT header bytes (0xB0 0x0D) derived from the stream.
+1 -1
View File
@@ -186,7 +186,7 @@ class RecordCommand implements CommandInterface {
}
$rInsertID = $db->last_insert_id();
shell_exec(FFMPEG_BIN_40 . " -i '" . ARCHIVE_PATH . $recordingID . '.ts' . "' -c:v copy -c:a copy '" . VOD_PATH . $rInsertID . '.mp4' . "'");
shell_exec((\XcVm\Streaming\Codec\FfmpegPaths::cpu() ?: FFMPEG_BIN_40) . " -i '" . ARCHIVE_PATH . $recordingID . '.ts' . "' -c:v copy -c:a copy '" . VOD_PATH . $rInsertID . '.mp4' . "'");
@unlink(ARCHIVE_PATH . $recordingID . '.ts');
if (!file_exists(VOD_PATH . $rInsertID . '.mp4')) {
+7 -8
View File
@@ -24,12 +24,11 @@ define('GEOLITE2_BIN', BIN_PATH . 'maxmind/GeoLite2-Country.mmdb');
define('GEOLITE2C_BIN', BIN_PATH . 'maxmind/GeoLite2-City.mmdb');
define('GEOISP_BIN', BIN_PATH . 'maxmind/GeoIP2-ISP.mmdb');
// ── FFmpeg (по версиям) ───────────────────────────────────────
define('FFMPEG_BIN_40', BIN_PATH . 'ffmpeg_bin/4.0/ffmpeg');
define('FFMPEG_BIN_71', BIN_PATH . 'ffmpeg_bin/7.1/ffmpeg');
define('FFMPEG_BIN_80', BIN_PATH . 'ffmpeg_bin/8.0/ffmpeg');
// ── FFprobe (по версиям) ──────────────────────────────────────
// ── FFmpeg/FFprobe — legacy 4.0 "DTS" build anchor ────────────
// Only the 4.0 build is pinned as a constant: it is the legacy/DTS-safe binary
// (StreamProcess `-nofix_dts`, RecordCommand, and the FfmpegPaths fallback). Every
// other version is resolved dynamically from bin/ffmpeg_bin/<version>/ by
// FfmpegPaths (paths) and FfmpegBinaries (discovery + capabilities) — no more
// per-version constants to keep in sync with what actually ships in the folder.
define('FFMPEG_BIN_40', BIN_PATH . 'ffmpeg_bin/4.0/ffmpeg');
define('FFPROBE_BIN_40', BIN_PATH . 'ffmpeg_bin/4.0/ffprobe');
define('FFPROBE_BIN_71', BIN_PATH . 'ffmpeg_bin/7.1/ffprobe');
define('FFPROBE_BIN_80', BIN_PATH . 'ffmpeg_bin/8.0/ffprobe');
+2 -2
View File
@@ -67,7 +67,7 @@ class LegacyInitializer {
SettingsManager::update('on_demand_wait_time', 15);
}
\XcVm\Streaming\Codec\FfmpegPaths::resolve(SettingsManager::get('ffmpeg_cpu'));
\XcVm\Streaming\Codec\FfmpegPaths::resolve(SettingsManager::get('ffmpeg_cpu'), SettingsManager::get('ffmpeg_gpu'));
if (!$rUseCache) {
\XcVm\Domain\Server\ServerRepository::getAll();
@@ -150,7 +150,7 @@ class LegacyInitializer {
$GLOBALS['rSettings']['on_demand_wait_time'] = 15;
}
\XcVm\Streaming\Codec\FfmpegPaths::resolve($GLOBALS['rSettings']['ffmpeg_cpu']);
\XcVm\Streaming\Codec\FfmpegPaths::resolve($GLOBALS['rSettings']['ffmpeg_cpu'], $GLOBALS['rSettings']['ffmpeg_gpu'] ?? null);
$GLOBALS['rCached'] = \XcVm\Infrastructure\Cache\CacheReader::isReady($GLOBALS['rSettings']);
$GLOBALS['rServers'] = \XcVm\Infrastructure\Cache\CacheReader::get('servers') ?: array();
+71 -4
View File
@@ -6,7 +6,8 @@ use XcVm\Core\Config\SettingsManager;
use XcVm\Core\GeoIP\MaxMindUpdater;
use XcVm\Core\Localization\Translator;
use XcVm\Core\Util\AdminHelpers;
use XcVm\Domain\Stream\StreamConfigRepository;// Code reconstruction by Squallp
use XcVm\Domain\Stream\StreamConfigRepository;
use XcVm\Streaming\Codec\FfmpegBinaries;// Code reconstruction by Squallp
if (!isset($__settingsViewMode)):
@@ -1954,16 +1955,43 @@ $isAjaxRequest = (
</div>
</div>
<?php
$rFfmpegGpuBuilds = FfmpegBinaries::gpuCapable();
$rCpuOpts = array_keys(FfmpegBinaries::available()) ?: ["8.0", "7.1", "4.0"];
if (!empty($rSettings["ffmpeg_cpu"]) && !in_array($rSettings["ffmpeg_cpu"], $rCpuOpts, true)) {
$rCpuOpts[] = $rSettings["ffmpeg_cpu"];
}
$rGpuOpts = array_keys($rFfmpegGpuBuilds);
if (!empty($rSettings["ffmpeg_gpu"]) && !in_array($rSettings["ffmpeg_gpu"], $rGpuOpts, true)) {
$rGpuOpts[] = $rSettings["ffmpeg_gpu"];
}
?>
<div class="form-group row mb-4">
<label class="col-md-4 col-form-label" for="ffmpeg_cpu">
FFMPEG Version
FFMPEG Version (CPU)
<i title="<?= $language::get('which_version_of_ffmpeg_to_tooltip') ?>" class="tooltip text-secondary far fa-circle"></i>
</label>
<div class="col-md-2">
<select name="ffmpeg_cpu" id="ffmpeg_cpu" class="form-control" data-toggle="select2">
<?php foreach (["8.0", "7.1", "4.0"] as $rValue): ?>
<?php foreach ($rCpuOpts as $rValue): ?>
<option value="<?= $rValue ?>" <?= $rSettings["ffmpeg_cpu"] == $rValue ? ' selected' : '' ?>>
v<?= $rValue ?><?= isset($rFfmpegGpuBuilds[$rValue]) ? ' (GPU)' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<label class="col-md-4 col-form-label" for="ffmpeg_gpu">
FFMPEG Version (GPU)
<i title="<?= $language::get('which_version_of_ffmpeg_to_tooltip') ?>" class="tooltip text-secondary far fa-circle"></i>
</label>
<div class="col-md-2">
<select name="ffmpeg_gpu" id="ffmpeg_gpu" class="form-control" data-toggle="select2">
<option value="" <?= empty($rSettings["ffmpeg_gpu"]) ? ' selected' : '' ?>>Same as CPU</option>
<?php foreach ($rGpuOpts as $rValue): ?>
<option value="<?= $rValue ?>" <?= ($rSettings["ffmpeg_gpu"] ?? '') === $rValue ? ' selected' : '' ?>>
v<?= $rValue ?>
</option>
<?php endforeach; ?>
@@ -2741,7 +2769,46 @@ $isAjaxRequest = (
</tbody>
</table>
<h4 class="card-title mb-4"><?= $language::get('support_project') ?></h4>
<h4 class="card-title mb-4">FFmpeg</h4>
<table class="table table-striped table-bordered">
<thead class="thead-light">
<tr>
<th class="text-center" style="font-size: 0.85rem;">Version</th>
<th class="text-center" style="font-size: 0.85rem;">Build</th>
<th class="text-center" style="font-size: 0.85rem;">GPU</th>
<th class="text-center" style="font-size: 0.85rem;">Hardware encoders</th>
</tr>
</thead>
<tbody>
<?php $rFfmpegBuilds = FfmpegBinaries::available(); ?>
<?php if (empty($rFfmpegBuilds)): ?>
<tr>
<td colspan="4" class="text-center" style="font-size: 0.85rem;">N/A</td>
</tr>
<?php else: ?>
<?php foreach ($rFfmpegBuilds as $rVer => $rInfo): ?>
<tr>
<td class="text-center" style="font-size: 0.85rem;">
<button type="button" class="btn btn-info btn-sm" style="font-size: 0.85rem;">v<?= htmlspecialchars($rVer) ?></button>
</td>
<td class="text-center" style="font-size: 0.8rem;"><?= htmlspecialchars($rInfo['banner']) ?></td>
<td class="text-center">
<?php if ($rInfo['gpu']): ?>
<span class="badge badge-success">GPU</span>
<?php else: ?>
<span class="badge badge-secondary">CPU</span>
<?php endif; ?>
</td>
<td class="text-center text-monospace small"><?= $rInfo['encoders'] ? htmlspecialchars(implode(', ', $rInfo['encoders'])) : '—' ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<h4 class="card-title mb-4"><?= $language::get('support_project') ?></h4>
<table class="table table-striped table-bordered text-center">
<thead class="thead-light">
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace XcVm\Streaming\Codec;
/**
* FfmpegBinaries — discovery + capability probe for the bundled FFmpeg builds.
*
* Scans BIN_PATH/ffmpeg_bin/<version>/ for real version directories (named
* `<major>.<minor>`, so `*.backup.*` folders are ignored) that ship an
* executable `ffmpeg`, and records per build: the version banner, whether it can
* encode on the GPU, and the list of hardware encoders it exposes.
*
* Probing shells out to each binary (`-hwaccels`, `-encoders`), which is slow, so
* the result is cached to a JSON file keyed by a fingerprint of the directory set
* and their mtimes — a binary swap (e.g. by BinariesCommand) changes the mtime and
* transparently invalidates the cache. This class is UI-only (settings page); the
* streaming hot path resolves paths cheaply via {@see FfmpegPaths} without scanning.
*
* @package XC_VM_Streaming_Codec
* @author Divarion_D <https://github.com/Divarion-D>
* @copyright 2025-2026 Vateron Media
* @link https://github.com/Vateron-Media/XC_VM
* @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html
*/
class FfmpegBinaries {
/** Encoder-name substrings that mark a hardware (GPU/accelerated) encoder. */
private const HW_ENCODER_MARKERS = ['_nvenc', 'nvenc_', '_qsv', '_vaapi', '_amf', '_videotoolbox', '_mf'];
/** @var array<string, array{version: string, gpu: bool, encoders: string[], banner: string}>|null */
private static ?array $cache = null;
/** Base directory holding the per-version ffmpeg build folders. */
private static function baseDir(): string {
return BIN_PATH . 'ffmpeg_bin/';
}
/** Absolute path to the capabilities cache file. */
private static function cacheFile(): string {
return (defined('TMP_PATH') ? TMP_PATH : sys_get_temp_dir() . '/') . 'ffmpeg_capabilities.json';
}
/**
* All discovered builds, newest version first.
*
* @return array<string, array{version: string, gpu: bool, encoders: string[], banner: string}>
* Keyed by version string (the folder name, e.g. '8.0').
*/
public static function available(): array {
if (self::$cache !== null) {
return self::$cache;
}
$rFingerprint = self::fingerprint();
$rCached = self::readCache();
if ($rCached !== null && ($rCached['fingerprint'] ?? null) === $rFingerprint && isset($rCached['versions']) && is_array($rCached['versions'])) {
return self::$cache = $rCached['versions'];
}
$rVersions = self::scan();
self::writeCache($rFingerprint, $rVersions);
return self::$cache = $rVersions;
}
/**
* Only the builds that can encode on the GPU, newest first.
*
* @return array<string, array{version: string, gpu: bool, encoders: string[], banner: string}>
*/
public static function gpuCapable(): array {
return array_filter(self::available(), static fn (array $rInfo): bool => $rInfo['gpu']);
}
/** Whether a given version folder exists with an executable ffmpeg. */
public static function has(string $rVersion): bool {
return isset(self::available()[$rVersion]);
}
/**
* Cheap fingerprint of the build folders (names + mtimes) — no shelling out.
* A binary swap bumps the folder mtime and changes this, invalidating the cache.
*/
private static function fingerprint(): string {
$rParts = array();
foreach (self::versionDirs() as $rVersion => $rDir) {
$rParts[] = $rVersion . ':' . (@filemtime($rDir . 'ffmpeg') ?: 0);
}
sort($rParts);
return md5(implode('|', $rParts));
}
/**
* Version folders (name matches `<int>.<int>`) that ship an executable ffmpeg.
*
* @return array<string, string> version => absolute directory path (trailing slash)
*/
private static function versionDirs(): array {
$rBase = self::baseDir();
$rOut = array();
foreach (glob($rBase . '*', GLOB_ONLYDIR) ?: array() as $rDir) {
$rVersion = basename($rDir);
if (!preg_match('/^\d+\.\d+$/', $rVersion)) {
continue; // skip backups / non-version folders
}
$rBinary = $rDir . '/ffmpeg';
if (is_file($rBinary)) {
$rOut[$rVersion] = $rDir . '/';
}
}
uksort($rOut, static fn (string $a, string $b): int => version_compare($b, $a));
return $rOut;
}
/**
* Probe every build's capabilities by shelling out. Slow — callers cache it.
*
* @return array<string, array{version: string, gpu: bool, encoders: string[], banner: string}>
*/
private static function scan(): array {
$rOut = array();
foreach (self::versionDirs() as $rVersion => $rDir) {
$rBinary = $rDir . 'ffmpeg';
$rEncoders = self::hwEncoders($rBinary);
$rOut[$rVersion] = array(
'version' => $rVersion,
'gpu' => count($rEncoders) > 0,
'encoders' => $rEncoders,
'banner' => self::banner($rBinary),
);
}
return $rOut;
}
/** First line of `ffmpeg -version` (the human-readable build banner). */
private static function banner(string $rBinary): string {
$rOut = (string) @shell_exec('timeout 5 ' . escapeshellarg($rBinary) . ' -hide_banner -version 2>/dev/null');
$rLine = trim(strtok($rOut, "\n") ?: '');
return $rLine !== '' ? $rLine : 'ffmpeg';
}
/**
* Hardware encoder names exposed by a build (nvenc/qsv/vaapi/amf/…).
*
* @return string[]
*/
private static function hwEncoders(string $rBinary): array {
$rOut = (string) @shell_exec('timeout 5 ' . escapeshellarg($rBinary) . ' -hide_banner -encoders 2>/dev/null');
$rEncoders = array();
foreach (explode("\n", $rOut) as $rRow) {
// ` V....D h264_nvenc NVIDIA NVENC H.264 encoder`
if (!preg_match('/^\s*[VASFXBD.]{6}\s+(\S+)/', $rRow, $rMatch)) {
continue;
}
$rName = $rMatch[1];
foreach (self::HW_ENCODER_MARKERS as $rMarker) {
if (strpos($rName, $rMarker) !== false) {
$rEncoders[] = $rName;
break;
}
}
}
sort($rEncoders);
return array_values(array_unique($rEncoders));
}
/** @return array{fingerprint?: string, versions?: array}|null */
private static function readCache(): ?array {
$rFile = self::cacheFile();
if (!is_file($rFile)) {
return null;
}
$rDecoded = json_decode((string) @file_get_contents($rFile), true);
return is_array($rDecoded) ? $rDecoded : null;
}
/** Best-effort atomic cache write; failure is non-fatal (rescans next time). */
private static function writeCache(string $rFingerprint, array $rVersions): void {
$rJson = json_encode(array('fingerprint' => $rFingerprint, 'versions' => $rVersions), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($rJson === false) {
return;
}
$rFile = self::cacheFile();
$rTmp = $rFile . '.tmp';
if (@file_put_contents($rTmp, $rJson, LOCK_EX) !== false) {
@rename($rTmp, $rFile);
}
}
}
+34 -20
View File
@@ -21,36 +21,50 @@ class FfmpegPaths {
private static $resolved = false;
/**
* Resolve paths from an ffmpeg_cpu version string.
* Resolve the CPU/GPU/probe binary paths from the configured version strings.
*
* Paths are built dynamically from BIN_PATH/ffmpeg_bin/<version>/ so any build
* dropped into that folder is usable without touching this class — no version
* switch to maintain. This runs on the bootstrap hot path, so it stays cheap:
* a format check plus an is_file() stat, never a shell probe (that lives in
* {@see FfmpegBinaries}). An unknown/missing CPU version falls back to the
* legacy 4.0 build; the GPU binary falls back to the resolved CPU binary when
* ffmpeg_gpu is empty or points at a missing build.
*
* Called once during bootstrap; subsequent calls are no-ops.
*
* @param string $version e.g. '8.0', '7.1', '4.0'
* @param string $cpuVersion e.g. '8.0', '7.1', '4.0'
* @param string|null $gpuVersion GPU ffmpeg version, or null to reuse the CPU build
*/
public static function resolve($version) {
public static function resolve($cpuVersion, $gpuVersion = null) {
if (self::$resolved) {
return;
}
switch ($version) {
case '8.0':
self::$cpu = FFMPEG_BIN_80;
self::$probe = FFPROBE_BIN_80;
self::$gpu = FFMPEG_BIN_80;
break;
case '7.1':
self::$cpu = FFMPEG_BIN_71;
self::$probe = FFPROBE_BIN_71;
self::$gpu = FFMPEG_BIN_71;
break;
default:
self::$cpu = FFMPEG_BIN_40;
self::$probe = FFPROBE_BIN_40;
self::$gpu = FFMPEG_BIN_40;
break;
}
self::$cpu = self::binary($cpuVersion, 'ffmpeg') ?? FFMPEG_BIN_40;
self::$probe = self::binary($cpuVersion, 'ffprobe') ?? FFPROBE_BIN_40;
$rGpu = ($gpuVersion !== null && $gpuVersion !== '') ? self::binary($gpuVersion, 'ffmpeg') : null;
self::$gpu = $rGpu ?? self::$cpu;
self::$resolved = true;
}
/**
* Build a binary path for a version folder, or null when the version string is
* malformed or the binary is absent.
*
* @param string $version Version folder name (e.g. '8.0')
* @param string $name 'ffmpeg' or 'ffprobe'
*/
private static function binary($version, $name): ?string {
if (!is_string($version) || !preg_match('/^\d+\.\d+$/', $version)) {
return null;
}
$rPath = BIN_PATH . 'ffmpeg_bin/' . $version . '/' . $name;
return is_file($rPath) ? $rPath : null;
}
/** @return string Path to CPU-optimized ffmpeg binary */
public static function cpu() {
return self::$cpu;