Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions Scripts/download-deps-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -314,22 +314,30 @@ echo ""
echo "=== Downloading FFmpeg ==="

if [ "$FORCE" = true ] || [ ! -f "$DEPS_DIR/ffmpeg/ffmpeg" ]; then
# BtbN static GPL builds include hardware encoders (x64: QSV/NVENC/AMF/VAAPI;
# arm64: NVENC/AMF/V4L2M2M) yet run software-only on machines without a GPU or
# driver — the vendor runtimes (libvpl/libnvidia-encode/libva) are dlopen'd
# lazily, so libx264/libx265 always work and absent hw encoders fail
# gracefully at init. (The previous John Van Sickle build had no hw accel.)
BTBN_BASE="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest"
if [ "$ARCH" = "x86_64" ]; then
FFMPEG_URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"
FFMPEG_URL="$BTBN_BASE/ffmpeg-n7.1-latest-linux64-gpl-7.1.tar.xz"
else
FFMPEG_URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz"
FFMPEG_URL="$BTBN_BASE/ffmpeg-n7.1-latest-linuxarm64-gpl-7.1.tar.xz"
fi

echo " Downloading static FFmpeg..."
echo " Downloading static FFmpeg (BtbN, hardware-enabled)..."
curl -L -o "$BUILD_DIR/ffmpeg.tar.xz" "$FFMPEG_URL"

echo " Extracting..."
tar -xJf "$BUILD_DIR/ffmpeg.tar.xz" -C "$BUILD_DIR"
FFMPEG_DIR=$(find "$BUILD_DIR" -maxdepth 1 -name "ffmpeg-*-static" -type d | head -1)
if [ -z "$FFMPEG_DIR" ]; then
echo " ERROR: Could not find extracted FFmpeg directory"
# BtbN tarballs lay the binaries out under <dirname>/bin/
FFMPEG_BIN=$(find "$BUILD_DIR" -maxdepth 3 -path "*/bin/ffmpeg" -type f | head -1)
if [ -z "$FFMPEG_BIN" ]; then
echo " ERROR: Could not find extracted FFmpeg binary"
exit 1
fi
FFMPEG_DIR=$(dirname "$FFMPEG_BIN")

cp "$FFMPEG_DIR/ffmpeg" "$DEPS_DIR/ffmpeg/"
cp "$FFMPEG_DIR/ffprobe" "$DEPS_DIR/ffmpeg/"
Expand Down
16 changes: 10 additions & 6 deletions app/assets/deps-version.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@
"size": 106078101
},
"linux-x64": {
"filename": "VapourBox-deps-1.3.0-linux-x64.zip",
"sha256": "431b9728b237f86b6160ef4e5a1b04bd789e3d59f91961132e2163fe7cc01797",
"size": 143784868
"version": "1.4.0",
"releaseTag": "deps-v1.4.0",
"filename": "VapourBox-deps-1.4.0-linux-x64.zip",
"sha256": null,
"size": null
},
"linux-arm64": {
"filename": "VapourBox-deps-1.3.0-linux-arm64.zip",
"sha256": "c21eb5e135c4fa6e1aa60befb577322b8cdf5ff63bc13681946e41f060b1cd36",
"size": 123911771
"version": "1.4.0",
"releaseTag": "deps-v1.4.0",
"filename": "VapourBox-deps-1.4.0-linux-arm64.zip",
"sha256": null,
"size": null
}
},
"githubRepo": "StuartCameronCode/VapourBox"
Expand Down
34 changes: 28 additions & 6 deletions app/lib/services/dependency_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,23 @@ class DepsVersionInfo {
);
}

/// The deps version expected for a platform. Platforms may override the
/// global version (e.g. when only one platform's deps change), so the app
/// can re-fetch just that platform without disturbing the others.
String versionFor(String platform) => platforms[platform]?.version ?? version;

/// The release tag a platform's zip lives under (per-platform override, else
/// the global tag).
String releaseTagFor(String platform) =>
platforms[platform]?.releaseTag ?? releaseTag;

/// Get the download URL for a specific platform's deps zip.
String getDownloadUrl(String platform) {
final platformInfo = platforms[platform];
if (platformInfo == null) {
throw StateError('No dependency info for platform: $platform');
}
return 'https://github.com/$githubRepo/releases/download/$releaseTag/${platformInfo.filename}';
return 'https://github.com/$githubRepo/releases/download/${releaseTagFor(platform)}/${platformInfo.filename}';
}
}

Expand All @@ -95,17 +105,28 @@ class PlatformDepsInfo {
final String? sha256;
final int? size;

/// Optional per-platform version override. When set, this platform's deps are
/// versioned independently of the global `version` (see [DepsVersionInfo.versionFor]).
final String? version;

/// Optional per-platform release tag override (the release the zip lives in).
final String? releaseTag;

PlatformDepsInfo({
required this.filename,
this.sha256,
this.size,
this.version,
this.releaseTag,
});

factory PlatformDepsInfo.fromJson(Map<String, dynamic> json) {
return PlatformDepsInfo(
filename: json['filename'] as String,
sha256: json['sha256'] as String?,
size: json['size'] as int?,
version: json['version'] as String?,
releaseTag: json['releaseTag'] as String?,
);
}
}
Expand Down Expand Up @@ -320,10 +341,11 @@ class DependencyManager {
return DependencyStatus.missing;
}

// Check version match
if (installed.version != expected.version) {
// Check version match (per-platform: a platform may pin its own version)
final expectedVersion = expected.versionFor(platformId);
if (installed.version != expectedVersion) {
print(
'DependencyManager: Version mismatch - installed: ${installed.version}, expected: ${expected.version}');
'DependencyManager: Version mismatch - installed: ${installed.version}, expected: $expectedVersion');
return DependencyStatus.outdated;
}

Expand Down Expand Up @@ -409,8 +431,8 @@ class DependencyManager {

await _extractZip(tempFile);

// Write version file
await _writeInstalledVersion(expected.version);
// Write version file (per-platform version, so the next check matches)
await _writeInstalledVersion(expected.versionFor(platformId));

_progressController.add(DownloadProgress(
bytesReceived: platformInfo.size ?? 0,
Expand Down
135 changes: 108 additions & 27 deletions app/lib/services/hardware_encoder_detector.dart
Original file line number Diff line number Diff line change
@@ -1,67 +1,148 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';

import '../models/video_job.dart';
import 'tool_locator.dart';

/// Detects available hardware video encoders by probing the bundled FFmpeg.
class HardwareEncoderDetector {
/// Per-codec availability state.
enum EncoderProbeState {
/// A functional probe is still running for this encoder.
probing,

/// The encoder initialized successfully on this machine.
available,

/// The encoder is not compiled into this build, or the probe failed
/// (e.g. no GPU/driver present).
unavailable,
}

/// Detects which video encoders are actually usable on this machine.
///
/// Two stages:
/// 1. `ffmpeg -encoders` — cheap; tells us which encoders are *compiled into*
/// the bundled ffmpeg. This says nothing about the GPU/driver at runtime.
/// 2. A concurrent *functional probe* per compiled-in hardware encoder — a
/// throwaway one-frame encode. Exit 0 means the encoder really initialized
/// (driver + device present); non-zero means it can't run here. ffmpeg does
/// the driver detection; we just read pass/fail.
///
/// Probes run concurrently and update state as each finishes; the detector is a
/// [ChangeNotifier] so the UI can show a busy indicator while a codec is still
/// being queried and resolve it live.
class HardwareEncoderDetector extends ChangeNotifier {
HardwareEncoderDetector._();

static final HardwareEncoderDetector instance = HardwareEncoderDetector._();

final Set<String> _availableEncoders = {};
final Set<String> _compiledIn = {};
final Map<VideoCodec, EncoderProbeState> _state = {};
bool _initialized = false;

/// Initialize by probing FFmpeg for available encoders.
/// Whether the encoder is compiled into the bundled ffmpeg. Always true for
/// non-hardware codecs (software, ProRes, lossless).
bool isCompiledIn(VideoCodec codec) =>
!codec.isHardwareEncoder || _compiledIn.contains(codec.value);

/// Current probe state for a codec. Non-hardware codecs are always available.
/// Hardware codecs default to [EncoderProbeState.probing] until resolved.
EncoderProbeState probeState(VideoCodec codec) {
if (!codec.isHardwareEncoder) return EncoderProbeState.available;
return _state[codec] ?? EncoderProbeState.probing;
}

bool isProbing(VideoCodec codec) =>
probeState(codec) == EncoderProbeState.probing;

/// Whether the codec is usable on this machine right now (non-hardware codecs,
/// or hardware codecs whose functional probe succeeded).
bool isAvailable(VideoCodec codec) =>
probeState(codec) == EncoderProbeState.available;

/// Probe the bundled ffmpeg for available encoders. Idempotent.
Future<void> initialize() async {
if (_initialized) return;
_initialized = true;

final hwCodecs =
VideoCodec.values.where((c) => c.isHardwareEncoder).toList();

final ffmpegPath = ToolLocator.instance.ffmpegPath;
if (ffmpegPath == null) {
_initialized = true;
for (final c in hwCodecs) {
_state[c] = EncoderProbeState.unavailable;
}
notifyListeners();
return;
}

// Stage 1: which encoders are compiled into the binary.
try {
final result = await Process.run(
ffmpegPath,
['-encoders', '-hide_banner'],
stdoutEncoding: const SystemEncoding(),
stderrEncoding: const SystemEncoding(),
);

if (result.exitCode == 0) {
final output = result.stdout as String;
for (final line in output.split('\n')) {
for (final line in (result.stdout as String).split('\n')) {
final trimmed = line.trim();
// Encoder lines start with a flags field like "V....D" followed by the encoder name
// Encoder lines start with a flags field like "V....D" then the name.
if (trimmed.startsWith('V')) {
final parts = trimmed.split(RegExp(r'\s+'));
if (parts.length >= 2) {
_availableEncoders.add(parts[1]);
}
if (parts.length >= 2) _compiledIn.add(parts[1]);
}
}
}
} catch (e) {
// FFmpeg not available or failed - hardware encoders won't be detected
} catch (_) {
// ffmpeg missing/failed — _compiledIn stays empty, everything hw is out.
}

final detected = VideoCodec.values.where((c) => c.isHardwareEncoder && isDetected(c)).map((c) => c.value);
print('HardwareEncoderDetector: detected encoders: ${detected.isEmpty ? "none" : detected.join(", ")}');
// Stage 2: a compiled-in hardware encoder still needs a working device.
final toProbe = <VideoCodec>[];
for (final codec in hwCodecs) {
if (_compiledIn.contains(codec.value)) {
_state[codec] = EncoderProbeState.probing;
toProbe.add(codec);
} else {
_state[codec] = EncoderProbeState.unavailable;
}
}
notifyListeners();

_initialized = true;
// Run the functional probes concurrently; each resolves independently.
for (final codec in toProbe) {
unawaited(_probe(ffmpegPath, codec));
}
}

/// Whether a hardware codec was reported by ffmpeg's `-encoders` list.
///
/// Note: ffmpeg reports all encoders *compiled into* the binary, not just
/// those supported by the current hardware. A codec reported here may still
/// fail at runtime if the required GPU/driver is not present.
/// Returns true for non-hardware codecs (software, ProRes, FFV1).
bool isDetected(VideoCodec codec) {
if (!codec.isHardwareEncoder) return true;
return _availableEncoders.contains(codec.value);
Future<void> _probe(String ffmpegPath, VideoCodec codec) async {
var ok = false;
try {
final result = await Process.run(
ffmpegPath,
[
'-hide_banner', '-loglevel', 'error',
'-f', 'lavfi', '-i', 'color=c=black:s=64x64:r=5:d=1',
'-frames:v', '1',
'-c:v', codec.value,
'-f', 'null', '-',
],
stdoutEncoding: const SystemEncoding(),
stderrEncoding: const SystemEncoding(),
);
ok = result.exitCode == 0;
} catch (_) {
ok = false;
}
_state[codec] =
ok ? EncoderProbeState.available : EncoderProbeState.unavailable;
if (kDebugMode) {
print('HardwareEncoderDetector: ${codec.value} -> '
'${ok ? "available" : "unavailable"}');
}
notifyListeners();
}

}
Loading
Loading