I did resolve, to some degree, my original post's questions and frustrations by using the ffmpeg_kit_flutter_full_gpl package on the client side, and then ffmpeg again in GCF on the server side. In summary:
- Within 60 seconds, I can now compress a 2 minute video by 90% before uploading to firebase storage.
- Using
onFinalize via GCF on the server side I run ffmpeg again on the uploaded video and gain another 77% reduction in file size on the server side without any loss in video quality.
- My solution does not yet upload while the app is closed.
- On the client side, this solution requires setting the camera
ResolutionPreset to high (720p), rather than max, which can be a minimum of 1080p, and setting the ffmpeg -preset veryfast rather than the medium default.
Camera & ffmpeg solution settings:
Transcoding results stats for 2 minute video:
- Before transcode: 255MB
- After client side transcode: 25MB (90% decrease in size before upload)
- Time to transcode: 60 seconds
onFinalized GCF ffmpeg transcode: 19MB (77% reduction in size)
- In total a 93% reduction in size while keep high quality 720p video.
flutter_ffmpeg is archived, the new ffmpeg flutter package is ffmpeg_kit_flutter.
That being said, I used ffmpeg_kit_flutter to build my solution on the client side, rather than the server side, and transcode the video before uploading.
Cons:
- Doubled my app size to use ffmpeg, because I needed access to both
lame and x264 so I had to install the full-gpl package to gain access to these libraries.
- A two minute video can take up to 60 seconds to transcode.
The pros:
- Low bandwidth connections will operate much better after a video is reduced in size by 90%.
- Large videos will transcode and ffmpegkit does not crash like other flutter packages I've tried.
- The second pass with ffmpeg on GCF gains another 77% reduction in size taking a video of 100's of MB's down to just 10-20 MB max for eventually delivery.
- Costs lower on the front and back end.
So, you'll have to decide if the pros outweighs the cons and if 720p is high enough quality for playback. For me 720p looks perfect for video playback on a mobile phone and 1080p or higher was big time overkill.
I've provided sample code (not full classes) to give anyone looking to implement my solution a try. It became very important, due to the amount of time to transcode, to display a progress meter so the user does not give up on the process. You'll see my simple solution to displaying transcoding progress.
pubspec.yaml
dependencies:
camera: ^0.9.4+12
flutter_riverpod: ^1.0.3
ffmpeg_kit_flutter_full_gpl: ^4.5.1
wakelock: ^0.5.6
Riverpod StateNotifier classes to send progress updates to the ui for both transcoding and uploading to firebase storage. Assumption that users are familiar with Riverpod and StateNotifiers.
@immutable
class TranscodeUploadMessage {
const TranscodeUploadMessage({
required this.id,
required this.statusTitle,
required this.statusMessage,
required this.uploadPercentage,
required this.isRunning,
required this.completed,
required this.showSpinner,
required this.showPercentage,
required this.showError,
});
final int id;
final String statusTitle;
final String statusMessage;
final String uploadPercentage;
final bool isRunning;
final bool completed;
final bool showSpinner;
final bool showPercentage;
final bool showError;
TranscodeUploadMessage copyWith({
int? id,
String? statusTitle,
String? statusMessage,
String? uploadPercentage,
bool? isRunning,
bool? completed,
bool? showSpinner,
bool? showPercentage,
bool? showError,
}) {
return TranscodeUploadMessage(
id: id ?? this.id,
statusTitle: statusTitle ?? this.statusTitle,
statusMessage: statusMessage ?? this.statusMessage,
uploadPercentage: uploadPercentage ?? this.uploadPercentage,
isRunning: isRunning ?? this.isRunning,
completed: completed ?? this.completed,
showSpinner: showSpinner ?? this.showSpinner,
showPercentage: showSpinner ?? this.showPercentage,
showError: showError ?? this.showError,
);
}
}
class TranscodeUploadMessageNotifier
extends StateNotifier<List<TranscodeUploadMessage>> {
TranscodeUploadMessageNotifier() : super([]);
/// Since our state is immutable, we are not allowed to do
/// `state.add(message)`. Instead, we should create a new list of messages which
/// contains the previous items and the new one.
///
/// Using Dart's spread operator here is helpful!
void set(TranscodeUploadMessage message) {
state = [...state, message];
}
/// Our state is immutable. So we're making a new list instead of changing
/// the existing list.
void remove(int id) {
state = [
for (final message in state)
if (message.id != id) message,
];
}
/// Update message. Since our state is immutable, we need to make a copy of
/// the message. We're using our `copyWith` method implemented before to help
/// with that.
void update(TranscodeUploadMessage messageUpdated) {
state = [
for (final message in state)
if (message.id == messageUpdated.id)
/// Use copyWith to update a message
message.copyWith(
statusTitle: messageUpdated.statusTitle,
statusMessage: messageUpdated.statusMessage,
uploadPercentage: messageUpdated.uploadPercentage,
isRunning: messageUpdated.isRunning,
completed: messageUpdated.completed,
showSpinner: messageUpdated.showSpinner,
showPercentage: messageUpdated.showPercentage,
showError: messageUpdated.showError,
)
else
/// other messages, which there are not any at this time, are not
/// modified
message,
];
}
}
/// Using StateNotifierProvider to allow the UI to interact with our
/// TranscodeUploadMessageNotifier class.
final transcodeMessageProvider = StateNotifierProvider.autoDispose<
TranscodeUploadMessageNotifier, List<TranscodeUploadMessage>>((ref) {
return TranscodeUploadMessageNotifier();
});
ffmpegkit package running ffmpeg commands and sending transcoding statistics to the StateNotifier. (Missing quite a bit of code, but pseudocode to demonstrate.)
/// By default, set to video ffmpeg command.
String ffmpegCommand = '-i $messageUri '
'-acodec aac '
'-vcodec libx264 '
'-f mp4 -preset veryfast '
'-movflags frag_keyframe+empty_moov '
'-crf 23 $newMessageUri';
if (_recordingType == RecordingType.audio) {
ffmpegCommand = '-vn '
'-i $messageUri '
'-y '
'-acodec libmp3lame '
'-f '
'mp3 '
'$newMessageUri';
}
/// Set the initial state notifier as we start transcoding.
ref
.read(transcodeMessageProvider.notifier)
.set(const TranscodeUploadMessage(
id: 1,
statusTitle: 'Transcoding Recording',
statusMessage: 'Your recording is being transcoded '
'before upload. Please do not navigate away from this screen.',
uploadPercentage: '0%',
isRunning: true,
completed: false,
showSpinner: false,
showPercentage: false,
showError: false,
));
await FFmpegKit.executeAsync(
ffmpegCommand,
(Session session) async {
final ReturnCode? returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) {
/// Transcoding is complete, now display uploading message
/// and spinner at 0%.
ref
.read(transcodeMessageProvider.notifier)
.update(const TranscodeUploadMessage(
id: 1,
statusTitle: 'Uploading Recording',
statusMessage:
'Your recording is now being '
'uploaded. Please do not navigate away from this screen.',
uploadPercentage: '0%',
isRunning: true,
completed: false,
showSpinner: true,
showPercentage: true,
showError: false,
));
/// Upload the now transcoded video/audio to cloud storage where
/// Use flutterfire firebase storage tasks to get upload
/// progress. Your firebase storage function can also
/// reuse the transcodeMessageProvider to send UI state
/// updates for the upload, which will happen very quickly
/// even on slow connections now that the recording size
/// is dramatically reduced.
await uploadRecordingToFirebaseCloudStorage(ref);
} else if (ReturnCode.isCancel(returnCode)) {
// Do something if canceled
} else {
// Do something with the error
}
},
(Log log) => debugPrint(log.getMessage()),
(Statistics statistic) {
/// Statistics provides a running transcoding progress meter.
int completePercentage = (statistic.getTime() * 100) ~/ _duration!;
ref
.read(transcodeMessageProvider.notifier)
.update(TranscodeUploadMessage(
id: 1,
statusTitle: 'Transcoding Recording',
statusMessage: 'Your recording is being '
'transcoded. Please do not navigate away from this screen.',
uploadPercentage: '$completePercentage%',
isRunning: true,
completed: false,
showSpinner: true,
showPercentage: true,
showError: false,
));
}).then((Session session) {
debugPrint(
'Async FFmpeg process started with sessionId ${session.getSessionId()}.');
}).catchError((error) async {
debugPrint('transcoding error: $error');
});
Use a Riverpod Consumer to update UI by watching the StateNotifier and displaying updated state in your UI.
Consumer(
builder: (context, watch, child) {
final List<TranscodeUploadMessage> messages =
ref.watch(transcodeMessageProvider);
if (messages.isEmpty) {
return const SizedBox.shrink();
}
final message = messages[0];
if (message.isRunning ||
message.completed ||
message.showError) {
// Display widgets with StateNotifier data
}
return const SizedBox.shrink();
},
)