Custom FFmpeg Provider¶
Implement platform-specific FFmpeg integrations
Fluvie uses FFmpeg for video encoding. The FFmpeg provider abstraction allows different implementations for different platforms.
Table of Contents¶
- Overview
- FFmpegProvider Interface
- ProcessFFmpegProvider
- Mobile FFmpegKit
- Custom Implementation
- Testing Providers
Overview¶
Fluvie abstracts FFmpeg access through the FFmpegProvider interface:
┌─────────────────────────────────────────────┐
│ RenderService │
├─────────────────────────────────────────────┤
│ FFmpegProvider (abstract) │
├─────────────┬─────────────┬─────────────────┤
│ Process │ FFmpegKit │ Custom │
│ (Desktop) │ (Mobile) │ (Your impl) │
└─────────────┴─────────────┴─────────────────┘
This allows: - Desktop: Direct FFmpeg process execution - Mobile: FFmpegKit integration - Custom: Server-based encoding, WASM, etc.
FFmpegProvider Interface¶
Interface Definition¶
abstract class FFmpegProvider {
/// Check if FFmpeg is available
Future<bool> isAvailable();
/// Get FFmpeg version string
Future<String> getVersion();
/// Execute an FFmpeg command
/// Returns exit code (0 = success)
Future<int> execute(List<String> arguments);
/// Execute with progress callback
Future<int> executeWithProgress(
List<String> arguments, {
void Function(double progress)? onProgress,
void Function(String line)? onOutput,
});
/// Cancel any running execution
Future<void> cancel();
/// Probe a media file for metadata
Future<MediaInfo?> probe(String filePath);
}
class MediaInfo {
final Duration duration;
final int width;
final int height;
final double fps;
final String? videoCodec;
final String? audioCodec;
final int? bitrate;
const MediaInfo({
required this.duration,
required this.width,
required this.height,
required this.fps,
this.videoCodec,
this.audioCodec,
this.bitrate,
});
}
ProcessFFmpegProvider¶
The default desktop implementation uses Dart's Process API:
class ProcessFFmpegProvider implements FFmpegProvider {
final String ffmpegPath;
final String ffprobePath;
Process? _currentProcess;
ProcessFFmpegProvider({
this.ffmpegPath = 'ffmpeg',
this.ffprobePath = 'ffprobe',
});
@override
Future<bool> isAvailable() async {
try {
final result = await Process.run(ffmpegPath, ['-version']);
return result.exitCode == 0;
} catch (e) {
return false;
}
}
@override
Future<String> getVersion() async {
final result = await Process.run(ffmpegPath, ['-version']);
final output = result.stdout as String;
// Parse version from first line
final match = RegExp(r'ffmpeg version (\S+)').firstMatch(output);
return match?.group(1) ?? 'unknown';
}
@override
Future<int> execute(List<String> arguments) async {
_currentProcess = await Process.start(ffmpegPath, arguments);
return await _currentProcess!.exitCode;
}
@override
Future<int> executeWithProgress(
List<String> arguments, {
void Function(double progress)? onProgress,
void Function(String line)? onOutput,
}) async {
// Add progress output
final args = ['-progress', 'pipe:1', '-y', ...arguments];
_currentProcess = await Process.start(ffmpegPath, args);
// Parse progress from stdout
_currentProcess!.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
onOutput?.call(line);
// Parse progress
if (line.startsWith('out_time_ms=')) {
final ms = int.tryParse(line.split('=')[1]) ?? 0;
// Calculate progress based on expected duration
// (This is simplified - real implementation needs total duration)
onProgress?.call(ms / 1000000); // Convert to seconds
}
});
_currentProcess!.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
onOutput?.call(line);
});
return await _currentProcess!.exitCode;
}
@override
Future<void> cancel() async {
_currentProcess?.kill(ProcessSignal.sigterm);
_currentProcess = null;
}
@override
Future<MediaInfo?> probe(String filePath) async {
final result = await Process.run(ffprobePath, [
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
filePath,
]);
if (result.exitCode != 0) return null;
final json = jsonDecode(result.stdout as String);
// Parse MediaInfo from JSON
return _parseMediaInfo(json);
}
MediaInfo _parseMediaInfo(Map<String, dynamic> json) {
final streams = json['streams'] as List;
final videoStream = streams.firstWhere(
(s) => s['codec_type'] == 'video',
orElse: () => null,
);
final audioStream = streams.firstWhere(
(s) => s['codec_type'] == 'audio',
orElse: () => null,
);
final format = json['format'] as Map<String, dynamic>?;
// Parse frame rate
final fpsStr = videoStream?['r_frame_rate'] as String? ?? '30/1';
final fpsParts = fpsStr.split('/');
final fps = int.parse(fpsParts[0]) / int.parse(fpsParts[1]);
return MediaInfo(
duration: Duration(
microseconds: (double.parse(format?['duration'] ?? '0') * 1000000).round(),
),
width: videoStream?['width'] ?? 0,
height: videoStream?['height'] ?? 0,
fps: fps,
videoCodec: videoStream?['codec_name'],
audioCodec: audioStream?['codec_name'],
bitrate: int.tryParse(format?['bit_rate'] ?? ''),
);
}
}
Mobile FFmpegKit¶
For mobile platforms, use FFmpegKit:
import 'package:ffmpeg_kit_flutter/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter/ffprobe_kit.dart';
import 'package:ffmpeg_kit_flutter/return_code.dart';
class FFmpegKitProvider implements FFmpegProvider {
@override
Future<bool> isAvailable() async {
// FFmpegKit is always available when the package is included
return true;
}
@override
Future<String> getVersion() async {
final info = await FFmpegKitConfig.getFFmpegVersion();
return info ?? 'unknown';
}
@override
Future<int> execute(List<String> arguments) async {
final command = arguments.join(' ');
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
return ReturnCode.isSuccess(returnCode) ? 0 : 1;
}
@override
Future<int> executeWithProgress(
List<String> arguments, {
void Function(double progress)? onProgress,
void Function(String line)? onOutput,
}) async {
final command = arguments.join(' ');
final session = await FFmpegKit.executeAsync(
command,
(session) async {
// Completion callback
},
(log) {
// Log callback
onOutput?.call(log.getMessage());
},
(statistics) {
// Statistics callback
final time = statistics.getTime();
if (time > 0) {
// Calculate progress (need to know total duration)
onProgress?.call(time / 1000); // time is in ms
}
},
);
// Wait for completion
await session.getReturnCode();
final returnCode = await session.getReturnCode();
return ReturnCode.isSuccess(returnCode) ? 0 : 1;
}
@override
Future<void> cancel() async {
await FFmpegKit.cancel();
}
@override
Future<MediaInfo?> probe(String filePath) async {
final session = await FFprobeKit.getMediaInformation(filePath);
final info = session.getMediaInformation();
if (info == null) return null;
final streams = info.getStreams();
final videoStream = streams?.firstWhere(
(s) => s.getType() == 'video',
orElse: () => null,
);
return MediaInfo(
duration: Duration(
milliseconds: (info.getDuration() ?? 0).toInt(),
),
width: videoStream?.getWidth() ?? 0,
height: videoStream?.getHeight() ?? 0,
fps: _parseFps(videoStream?.getRealFrameRate()),
videoCodec: videoStream?.getCodec(),
audioCodec: streams?.firstWhere(
(s) => s.getType() == 'audio',
orElse: () => null,
)?.getCodec(),
bitrate: int.tryParse(info.getBitrate() ?? ''),
);
}
double _parseFps(String? fpsStr) {
if (fpsStr == null) return 30.0;
final parts = fpsStr.split('/');
if (parts.length != 2) return 30.0;
return int.parse(parts[0]) / int.parse(parts[1]);
}
}
pubspec.yaml for Mobile¶
dependencies:
ffmpeg_kit_flutter: ^6.0.0
# Or specific package for features needed:
# ffmpeg_kit_flutter_full: ^6.0.0 # All codecs
# ffmpeg_kit_flutter_min: ^6.0.0 # Minimal
Custom Implementation¶
Server-Based Provider¶
For cloud/server encoding:
class ServerFFmpegProvider implements FFmpegProvider {
final String serverUrl;
final http.Client _client;
String? _currentJobId;
ServerFFmpegProvider({
required this.serverUrl,
http.Client? client,
}) : _client = client ?? http.Client();
@override
Future<bool> isAvailable() async {
try {
final response = await _client.get(
Uri.parse('$serverUrl/health'),
);
return response.statusCode == 200;
} catch (e) {
return false;
}
}
@override
Future<int> execute(List<String> arguments) async {
return executeWithProgress(arguments);
}
@override
Future<int> executeWithProgress(
List<String> arguments, {
void Function(double progress)? onProgress,
void Function(String line)? onOutput,
}) async {
// Upload frames/assets first
final uploadedFiles = await _uploadAssets(arguments);
// Start job
final response = await _client.post(
Uri.parse('$serverUrl/jobs'),
body: jsonEncode({
'arguments': _remapPaths(arguments, uploadedFiles),
}),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode != 201) {
return 1;
}
final job = jsonDecode(response.body);
_currentJobId = job['id'];
// Poll for progress
while (true) {
final statusResponse = await _client.get(
Uri.parse('$serverUrl/jobs/$_currentJobId'),
);
final status = jsonDecode(statusResponse.body);
if (status['progress'] != null) {
onProgress?.call(status['progress'] as double);
}
if (status['status'] == 'completed') {
// Download result
await _downloadResult(status['outputUrl']);
return 0;
} else if (status['status'] == 'failed') {
onOutput?.call('Job failed: ${status['error']}');
return 1;
}
await Future.delayed(const Duration(seconds: 1));
}
}
@override
Future<void> cancel() async {
if (_currentJobId != null) {
await _client.delete(
Uri.parse('$serverUrl/jobs/$_currentJobId'),
);
_currentJobId = null;
}
}
@override
Future<MediaInfo?> probe(String filePath) async {
// Upload file and probe on server
final uploadUrl = await _uploadFile(filePath);
final response = await _client.post(
Uri.parse('$serverUrl/probe'),
body: jsonEncode({'url': uploadUrl}),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode != 200) return null;
final data = jsonDecode(response.body);
return MediaInfo(
duration: Duration(milliseconds: data['duration']),
width: data['width'],
height: data['height'],
fps: data['fps'],
videoCodec: data['videoCodec'],
audioCodec: data['audioCodec'],
bitrate: data['bitrate'],
);
}
Future<Map<String, String>> _uploadAssets(List<String> arguments) async {
// Implementation: upload local files to server
throw UnimplementedError();
}
List<String> _remapPaths(
List<String> arguments,
Map<String, String> uploadedFiles,
) {
// Replace local paths with server URLs
throw UnimplementedError();
}
Future<void> _downloadResult(String url) async {
// Download encoded video from server
throw UnimplementedError();
}
Future<String> _uploadFile(String path) async {
// Upload single file
throw UnimplementedError();
}
}
WASM Provider (Web)¶
For browser-based encoding using FFmpeg.wasm:
@JS()
library ffmpeg_wasm;
import 'package:js/js.dart';
@JS('FFmpeg')
class FFmpegWasm {
external FFmpegWasm();
external Promise<void> load();
external Promise<void> run(List<String> args);
external void writeFile(String name, Uint8List data);
external Uint8List readFile(String name);
}
class WasmFFmpegProvider implements FFmpegProvider {
final FFmpegWasm _ffmpeg = FFmpegWasm();
bool _loaded = false;
Future<void> _ensureLoaded() async {
if (!_loaded) {
await promiseToFuture(_ffmpeg.load());
_loaded = true;
}
}
@override
Future<bool> isAvailable() async {
try {
await _ensureLoaded();
return true;
} catch (e) {
return false;
}
}
@override
Future<int> execute(List<String> arguments) async {
await _ensureLoaded();
try {
await promiseToFuture(_ffmpeg.run(arguments));
return 0;
} catch (e) {
return 1;
}
}
// ... other methods
}
Testing Providers¶
Mock Provider for Testing¶
class MockFFmpegProvider implements FFmpegProvider {
final List<List<String>> executedCommands = [];
int nextExitCode = 0;
MediaInfo? mockMediaInfo;
@override
Future<bool> isAvailable() async => true;
@override
Future<String> getVersion() async => '5.0.0';
@override
Future<int> execute(List<String> arguments) async {
executedCommands.add(arguments);
return nextExitCode;
}
@override
Future<int> executeWithProgress(
List<String> arguments, {
void Function(double progress)? onProgress,
void Function(String line)? onOutput,
}) async {
executedCommands.add(arguments);
// Simulate progress
for (var i = 0; i <= 10; i++) {
await Future.delayed(const Duration(milliseconds: 10));
onProgress?.call(i / 10);
}
return nextExitCode;
}
@override
Future<void> cancel() async {}
@override
Future<MediaInfo?> probe(String filePath) async => mockMediaInfo;
}
Testing with Mock¶
void main() {
group('RenderService', () {
late MockFFmpegProvider mockProvider;
setUp(() {
mockProvider = MockFFmpegProvider();
FFmpegProviderRegistry.setProvider(mockProvider);
});
test('executes correct FFmpeg command', () async {
await RenderService.execute(
composition: testVideo,
outputPath: 'output.mp4',
);
expect(mockProvider.executedCommands, isNotEmpty);
final command = mockProvider.executedCommands.first;
expect(command, contains('-r'));
expect(command, contains('30')); // fps
expect(command, contains('output.mp4'));
});
test('handles FFmpeg failure', () async {
mockProvider.nextExitCode = 1;
expect(
() => RenderService.execute(
composition: testVideo,
outputPath: 'output.mp4',
),
throwsA(isA<EncodingException>()),
);
});
});
}
Provider Registration¶
Global Provider¶
class FFmpegProviderRegistry {
static FFmpegProvider? _provider;
static FFmpegProvider get provider {
return _provider ?? _defaultProvider();
}
static void setProvider(FFmpegProvider provider) {
_provider = provider;
}
static FFmpegProvider _defaultProvider() {
if (kIsWeb) {
return WasmFFmpegProvider();
} else if (Platform.isAndroid || Platform.isIOS) {
return FFmpegKitProvider();
} else {
return ProcessFFmpegProvider();
}
}
}
Usage in RenderService¶
class RenderService {
static Future<void> execute({
required Video composition,
required String outputPath,
FFmpegProvider? ffmpegProvider,
}) async {
final provider = ffmpegProvider ?? FFmpegProviderRegistry.provider;
// Check availability
if (!await provider.isAvailable()) {
throw FFmpegNotAvailableException();
}
// Build command
final arguments = _buildCommand(composition, outputPath);
// Execute
final exitCode = await provider.execute(arguments);
if (exitCode != 0) {
throw EncodingException('FFmpeg exited with code $exitCode');
}
}
}
Related¶
- Encoding Settings - Quality settings
- Custom Render Pipeline - Rendering customization
- Server Mode - Server-based rendering
- Platform Setup - Platform installation