renderProject static method

Future<String> renderProject({
  1. required List<VideoEditorClip> clips,
  2. required List<VideoEditorAudioTrack> audioTracks,
  3. required List<VideoTextOverlay> textOverlays,
  4. required List<VideoEffectOverlay> effectOverlays,
  5. required VideoAspectRatio aspectRatio,
  6. bool isPreview = false,
  7. ExportSettings settings = const ExportSettings(),
  8. RenderProgressCallback? onProgress,
  9. RenderJob? job,
})

Renders the whole project to an MP4 and returns its path.

isPreview renders a small, fast proxy without text (the editor draws text live on top of it); exports honour settings. Throws RenderException on failure and RenderCancelledException when job is cancelled.

Implementation

static Future<String> renderProject({
  required List<VideoEditorClip> clips,
  required List<VideoEditorAudioTrack> audioTracks,
  required List<VideoTextOverlay> textOverlays,
  required List<VideoEffectOverlay> effectOverlays,
  required VideoAspectRatio aspectRatio,
  bool isPreview = false,
  ExportSettings settings = const ExportSettings(),
  RenderProgressCallback? onProgress,
  RenderJob? job,
}) async {
  if (clips.isEmpty) return '';

  final directory = await getTemporaryDirectory();
  final timestamp = DateTime.now().microsecondsSinceEpoch;
  final workDir = Directory('${directory.path}/render_$timestamp');
  await workDir.create(recursive: true);
  final outputPath =
      '${directory.path}/${isPreview ? "preview" : "export"}_$timestamp.mp4';

  final canvas = canvasFor(
    clips: clips,
    aspectRatio: aspectRatio,
    shortSide: isPreview ? previewShortSide : settings.resolution.height,
  );
  final fps = isPreview ? 30 : settings.frameRate.fps;

  try {
    // Text is rasterized with the same code the preview draws with, so the
    // export matches the editor exactly (fonts, styles and rotation).
    final images = <RenderImageOverlay>[];
    if (!isPreview) {
      for (var i = 0; i < textOverlays.length; i++) {
        final overlay = textOverlays[i];
        if (overlay.text.trim().isEmpty) continue;
        final raster = await TextOverlayPaint.rasterizeToPng(overlay, canvas);
        final file = File('${workDir.path}/text_$i.png');
        await file.writeAsBytes(raster.png, flush: true);
        images.add(RenderImageOverlay(
          path: file.path,
          centerX: overlay.x,
          centerY: overlay.y,
          start: overlay.startTime,
          end: overlay.effectiveEndTime,
        ));
      }
      if (settings.watermark) {
        images.add(await _watermark(canvas, workDir));
      }
    }

    // Small proxies decode much faster than 4K originals for previews.
    final sources = isPreview
        ? clips
            .map((c) => c.proxyVideoFile != null &&
                    c.proxyVideoFile!.path != c.file.path &&
                    c.proxyVideoFile!.existsSync()
                ? c.copyWith(file: c.proxyVideoFile)
                : c)
            .toList()
        : clips;

    RenderPlan planFor({required bool hardware}) => RenderGraphBuilder.build(
          clips: sources,
          audioTracks: audioTracks,
          effectOverlays: effectOverlays,
          imageOverlays: images,
          canvas: canvas,
          fps: fps,
          outputPath: outputPath,
          audioBitrate: isPreview ? '96k' : '192k',
          videoCodecArgs: videoCodecArgs(
            canvas: canvas,
            fps: fps,
            quality: settings.quality,
            preview: isPreview,
            hardware: hardware,
          ),
        );

    // The phone's hardware encoder is several times faster than x264. Some
    // devices advertise it but fail on certain sizes, so fall back once.
    final hardware = await hardwareEncoderAvailable();
    final plan = planFor(hardware: hardware);
    if (!isPreview) debugPrint('Render: ${plan.arguments.join(' ')}');
    try {
      await executePlan(plan, onProgress: onProgress, job: job);
    } on RenderException catch (e) {
      if (!hardware) rethrow;
      debugPrint('Hardware encoder failed, using x264: ${e.message}');
      _mediaCodecWorks = false;
      await executePlan(planFor(hardware: false), onProgress: onProgress, job: job);
    }

    if (!File(outputPath).existsSync()) {
      throw const RenderException('Render produced no output file');
    }
    onProgress?.call(1);
    return outputPath;
  } catch (e) {
    final partial = File(outputPath);
    if (partial.existsSync()) {
      try {
        partial.deleteSync();
      } catch (_) {}
    }
    rethrow;
  } finally {
    try {
      await workDir.delete(recursive: true);
    } catch (_) {}
  }
}