使用 Flutter/Dart 压缩大视频并将其上传到 Google 云存储

Zel*_*elf 6 dart flutter

pub.dev 上有几个提供视频压缩的著名软件包。我已经尝试过它们以及其他粗略的软件包,但一旦视频达到 300MB 左右,没有一个可以很好地工作。它们在各种平台和硬件上崩溃或存在其他问题。即视频压缩器和光压缩器。GH 提交和支持也与我在 pub.dev 中看到的视频压缩包有关。PR 没有被拉进来,问题也没有及时解决,对于最近的 Android APK 更新来说,有些问题相当严重。所以这不是我想要的依赖堆栈中的东西。

\n

我正在使用 FlutterFire 上传到 Google Cloud Storage。虽然我的代码确实使用FireBaseStorage 上传任务进行上传,但它无法在客户端压缩或在应用程序关闭时处理后台上传。

\n

因此,目前在服务器端,我有一个在文件上传时触发的 GCF。然后我使用nodejs ffmpeg,它被烘焙到GCF中以压缩服务器端并转换为H264。最后删除原来上传的大视频并将压缩视频保存到存储中。

\n

这个解决方案是有效的,但是根据用户的连接以及他们是否在wifi上,可能需要很长的时间,并且当它失败或用户关闭应用程序时,我当前的解决方案是无用的。

\n

我希望 Android 和 iOS 上有一个可靠的本机库,我可以利用它,自信地执行从任何格式到 H264 的压缩和转换,并且无论我的应用程序是关闭的还是在后台,都允许上传到 GC 存储。有什么想法吗?我希望这是 FlutterFire 云存储处理的标准!

\n

我还没有测试flutter_ffmpeg,但这只是因为有些人说它在客户端上运行得很慢。再说一次,Flutter/Dart 可以访问原生编写的代码,但我不知道在 Android/iOS 上从哪里开始才能以正确的方式做到这一点。我知道这就是某些软件包正在做的事情,但它们不适用于大型视频,所以我希望有人能在 Android 和 iOS 上为我指明正确的方向。

\n

我的代码用于处理上传任务到 GC 存储。

\n
 \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xe2\x80\x8bFuture<\xe2\x80\x8bvoid\xe2\x80\x8b>\xe2\x80\x8b\xc2\xa0\xe2\x80\x8buploadTask\xe2\x80\x8b({ \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8brequired\xe2\x80\x8b\xc2\xa0\xe2\x80\x8bWidgetRef\xe2\x80\x8b\xc2\xa0ref, \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8brequired\xe2\x80\x8b\xc2\xa0\xe2\x80\x8bFile\xe2\x80\x8b\xc2\xa0file, \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8brequired\xe2\x80\x8b\xc2\xa0\xe2\x80\x8bString\xe2\x80\x8b\xc2\xa0objectPath, \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8bSettableMetadata\xe2\x80\x8b?\xe2\x80\x8b\xc2\xa0metaData, \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8bbool\xe2\x80\x8b\xc2\xa0deleteAfterUpload\xc2\xa0\xe2\x80\x8b=\xe2\x80\x8b\xc2\xa0\xe2\x80\x8bfalse\xe2\x80\x8b, \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8bbool\xe2\x80\x8b\xc2\xa0displayProgressNotification\xc2\xa0\xe2\x80\x8b=\xe2\x80\x8b\xc2\xa0\xe2\x80\x8bfalse\xe2\x80\x8b, \n \xe2\x80\x8b\xc2\xa0\xc2\xa0})\xc2\xa0\xe2\x80\x8basync\xe2\x80\x8b\xc2\xa0{ \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8bString\xe2\x80\x8b\xc2\xa0filePath\xc2\xa0\xe2\x80\x8b=\xe2\x80\x8b\xc2\xa0file.path; \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0filename\xc2\xa0\xe2\x80\x8b=\xe2\x80\x8b\xc2\xa0\xe2\x80\x8bbasename\xe2\x80\x8b(file.path); \n  \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8b///\xc2\xa0Remove\xc2\xa0any\xc2\xa0instances\xc2\xa0of\xc2\xa0\'//\'\xc2\xa0from\xc2\xa0the\xc2\xa0path. \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8bfinal\xe2\x80\x8b\xc2\xa0\xe2\x80\x8bString\xe2\x80\x8b\xc2\xa0path\xc2\xa0\xe2\x80\x8b=\xe2\x80\x8b\xc2\xa0\xe2\x80\x8b\'$\xe2\x80\x8bstoragePath\xe2\x80\x8b/$\xe2\x80\x8bobjectPath\xe2\x80\x8b\'\xe2\x80\x8b.\xe2\x80\x8breplaceAll\xe2\x80\x8b(\xe2\x80\x8b\'//\'\xe2\x80\x8b,\xc2\xa0\xe2\x80\x8b\'/\'\xe2\x80\x8b); \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8bUploadTask\xe2\x80\x8b\xc2\xa0task\xc2\xa0\xe2\x80\x8b=\xe2\x80\x8b\xc2\xa0storage.\xe2\x80\x8bref\xe2\x80\x8b(path).\xe2\x80\x8bputFile\xe2\x80\x8b( \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8bFile\xe2\x80\x8b(filePath), \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0metaData, \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0); \n  \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0\xe2\x80\x8b///\xc2\xa0Store\xc2\xa0UploadTask\xc2\xa0in\xc2\xa0StateNotifierProvider\xc2\xa0to\xc2\xa0monitor\xc2\xa0progress. \n \xe2\x80\x8b\xc2\xa0\xc2\xa0\xc2\xa0\xc2\xa0ref.\xe2\x80\x8bread\xe2\x80\x8b(uploadingStateProvider.notifier).\xe2\x80\x8bmyUploadTask\xe2\x80\x8b(task); \n \xe2\x80\x8b\xc2\xa0\xc2\xa0}\n
Run Code Online (Sandbox Code Playgroud)\n

Zel*_*elf 3

我确实通过在客户端使用ffmpeg_kit_flutter_full_gpl包,然后在服务器端的 GCF 中再次使用 ffmpeg,在某种程度上解决了我原来帖子的问题和挫败感。总之:

  • 现在,我可以在 60 秒内将 2 分钟的视频压缩 90%,然后再上传到 Firebase 存储。
  • 通过在服务器端使用onFinalizeGCF,我在上传的视频上再次运行 ffmpeg,服务器端的文件大小又减少了 77%,而视频质量没有任何损失。
  • 当应用程序关闭时,我的解决方案尚未上传。
  • 在客户端,此解决方案需要将相机设置ResolutionPresethigh(720p),而不是max,最低可以为 1080p,并设置 ffmpeg-preset veryfast而不是medium默认值。

相机和 ffmpeg 解决方案设置:

2 分钟视频的转码结果统计:

  • 转码前:255MB
  • 客户端转码后:25MB(上传前大小减少 90%)
  • 转码时间:60秒
  • onFinalizedGCF ffmpeg 转码:19MB(大小减少 77%)
  • 尺寸总共减少了 93%,同时保留了高质量的 720p 视频。

flutter_ffmpeg已存档,新的 ffmpeg flutter 包为ffmpeg_kit_flutter

话虽这么说,我使用ffmpeg_kit_flutter在客户端而不是服务器端构建我的解决方案,并在上传之前对视频进行转码。

缺点

  1. 使用 ffmpeg 将我的应用程序大小增加了一倍,因为我需要访问这两个库lamex264因此我必须安装完整的 gpl 包才能访问这些库。
  2. 两分钟的视频转码最多可能需要 60 秒。

优点

  1. 视频尺寸缩小 90% 后,低带宽连接的运行效果会更好。
  2. 大视频将进行转码,并且 ffmpegkit 不会像我尝试过的其他 flutter 包那样崩溃。
  3. 在 GCF 上使用 ffmpeg 的第二遍,大小又减少了 77%,最终交付的视频大小从 100 MB 减少到最大 10-20 MB。
  4. 前端和后端成本较低。

因此,您必须决定是否利大于弊,以及 720p 的质量是否足以播放。对我来说,720p 看起来非常适合在手机上播放视频,而 1080p 或更高版本则显得有些过分了。

我提供了示例代码(不是完整的类),供任何想要实施我的解决方案的人尝试。由于转码时间较长,显示进度表变得非常重要,这样用户就不会放弃该过程。您将看到我显示转码进度的简单解决方案。

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
Run Code Online (Sandbox Code Playgroud)

RiverpodStateNotifier类将进度更新发送到 ui,以进行转码和上传到 firebase 存储。假设用户熟悉 Riverpod 和 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();
});
Run Code Online (Sandbox Code Playgroud)

ffmpegkit 包运行 ffmpeg 命令并将转码统计信息发送到 StateNotifier。(缺少相当多的代码,但用伪代码来演示。)

/// 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');
});
Run Code Online (Sandbox Code Playgroud)

使用 Riverpod Consumer通过观察 StateNotifier 并在 UI 中显示更新的状态来更新 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();
    },
)
Run Code Online (Sandbox Code Playgroud)