renderVideo function

Future<RenderManifest> renderVideo({
  1. required Video video,
  2. required Directory outDir,
  3. required ShellMount pumpWidget,
  4. required ShellFramePump pumpFrame,
  5. required SetViewSize setViewSize,
  6. ShellRunAsync runAsync = runAsyncDirectly,
  7. String compositionKey = 'render',
  8. int? frameCountOverride,
  9. bool cacheEnabled = false,
  10. Directory? cacheRoot,
  11. Aspect? aspect,
  12. Quality? quality,
  13. Export? export,
  14. Time? posterTime,
  15. MediaResolver? resolver,
  16. SnapshotService? snapshotService,
  17. BeatDetectionService? beatDetector,
  18. FrequencyAnalyzer? analyzer,
  19. String? defaultFontFamily,
  20. FrameCaptureService capture = const RepaintBoundaryCaptureService(),
  21. void onProgress(
    1. int completed,
    2. int total
    )?,
  22. void onCacheReport(
    1. int completed,
    2. int total
    )?,
})

Captures video into outDir (frames.rgba + manifest.json, manifest written last) — the one capture path every Fluvie renderer drives.

This is the whole render, in order: resolve media (images, then clip frames), rasterize any Snapshot subtree, parse captions, analyse reactive audio, mount the production capture shell, then loop the frames. Everything a render needs is derived from video itself, so the caller supplies no registry, no media list, and no geometry.

The host owns only the mechanics it alone can provide: pumpWidget mounts a tree, pumpFrame advances one frame, setViewSize points the view at the canvas, and runAsync escapes fake async for real IO. A flutter_test host passes tester.pumpWidget, tester.pump, its view setters, and tester.runAsync; that is the only reason a capture runs under a test binding at all.

Geometry: with aspect null the video's own declared size wins; an explicit aspect re-derives the canvas from aspect.sizeFor(longEdge) (the video's longer side) and mounts an AspectScope, so every Adaptive branches for it. Aspect has four families, so a size is never mapped back to one implicitly.

Media: with resolver null a real one is built through resolverScope (the platform default — rootBundle plus the ffmpeg probe and frame extractor) and disposed here, but only when video actually declares media, so a media-less composition renders with no ffmpeg on PATH. Pass a resolver to inject a fake; the caller then keeps ownership and it is never disposed.

Encoding is not part of this: the returned RenderManifest carries the complete ffmpeg argument array for a caller to run.

Implementation

Future<RenderManifest> renderVideo({
  required Video video,
  required Directory outDir,
  required ShellMount pumpWidget,
  required ShellFramePump pumpFrame,
  required SetViewSize setViewSize,
  ShellRunAsync runAsync = runAsyncDirectly,
  String compositionKey = 'render',
  int? frameCountOverride,
  bool cacheEnabled = false,
  Directory? cacheRoot,
  Aspect? aspect,
  Quality? quality,
  Export? export,
  Time? posterTime,
  MediaResolver? resolver,
  SnapshotService? snapshotService,
  BeatDetectionService? beatDetector,
  FrequencyAnalyzer? analyzer,
  String? defaultFontFamily,
  FrameCaptureService capture = const RepaintBoundaryCaptureService(),
  void Function(int completed, int total)? onProgress,
  void Function(int hits, int total)? onCacheReport,
}) async {
  // An aspect re-derives the canvas from aspect.sizeFor(longEdge), where longEdge
  // is the video's longer side; absent, the declared size wins (so a plain render
  // is byte-identical). Only layout branches per aspect.
  final size = aspect?.sizeFor(video.width > video.height ? video.width : video.height);
  final width = size?.width ?? video.width;
  final height = size?.height ?? video.height;
  final frameCount = frameCountOverride ?? video.totalFrames;
  // The one chokepoint every render passes with its FINAL, post-aspect canvas and
  // frame count. An untrusted render's size is runtime-derived, so this is the
  // only place a bound can be enforced for every path.
  _guardUntrustedRender(width: width, height: height, frameCount: frameCount);
  setViewSize(width, height);

  final config = RenderConfig(
    width: width,
    height: height,
    fps: video.fps,
    frameCount: frameCount,
    cacheEnabled: cacheEnabled,
    quality: quality ?? Quality.high,
  );
  // A poster Time resolves to an absolute frame against the render fps.
  final posterFrame = posterTime?.resolveFrames(
    TimeScopeData(fps: config.fps, startFrame: 0, durationFrames: config.frameCount),
  );

  final mediaSources = collectMediaSources(video.scenes);
  final snapshotSources = collectSnapshotSources(video.scenes);
  final captionSource = collectCaptionSource(video);
  final reactiveTracks = collectReactiveTracks(video);
  final declaresMedia =
      mediaSources.isNotEmpty ||
      snapshotSources.isNotEmpty ||
      captionSource != null ||
      reactiveTracks.allSources.isNotEmpty;

  // A media-less composition (text, shapes, charts, gradients) never builds a
  // resolver, so it renders with no ffmpeg and no bundle reads.
  final owned = resolver == null && declaresMedia ? resolverScope(null) : null;
  final active = resolver ?? owned?.resolver;

  SnapshotCaptureScope? snapshotScope;
  try {
    // Real IO — ffmpeg probes and extracts, the bundle reads, `toImage` reads back
    // — needs a real event loop, which fake async never pumps.
    await runAsync(() async {
      if (active != null) {
        // Images first: a clip source is content-hashed here before it is probed.
        await active.preResolveAll(mediaSources);
        await preResolveCompositionClips(
          composition: video,
          resolver: active,
          totalFrames: frameCount,
        );
        if (snapshotSources.isNotEmpty) {
          await active.preResolveSnapshots(
            snapshotSources,
            snapshotService ?? _missingSnapshotService(),
          );
        }
        if (captionSource != null) await active.preResolveCaptions(captionSource);
        if (reactiveTracks.allSources.isNotEmpty) {
          await active.preResolveReactive(
            reactiveTracks.allSources,
            beatDetector: beatDetector ?? SpectralBeatDetectionService(),
            analyzer: analyzer ?? SpectralFrequencyAnalyzer(),
            fps: config.fps,
            totalFrames: frameCount,
          );
        }
      }
      // The in-process Snapshot subtree-capture pre-pass: rasterize every Snapshot
      // child once under the resolver (so an inner Image/Clip paints from the
      // decoded cache), then mount the result above the composition. Without it a
      // Snapshot in capture finds no scope and re-rasterizes every frame.
      //
      // Outside the resolver branch deliberately: a `Snapshot` over plain widgets
      // declares no MediaSource and no SnapshotSource, so it needs no resolver at
      // all, but it still needs its raster before frame 0.
      snapshotScope = await _captureSnapshotScope(
        scenes: video.scenes,
        resolver: active,
        width: width,
        height: height,
        pumpWidget: pumpWidget,
        setViewSize: setViewSize,
      );
      return null;
    });
    // The pre-pass repointed the view while rasterizing; restore the render size.
    setViewSize(width, height);

    // `flutter test` renders text that names no family with the Ahem test font
    // (every glyph a filled box). A host that loaded real fonts names a family
    // here so unstyled text picks it up.
    Widget composition = video;
    if (aspect != null) composition = AspectScope(aspect: aspect, child: composition);
    // The capture shell mounts no app, so Text would throw
    // debugCheckHasDirectionality without this.
    composition = Directionality(textDirection: TextDirection.ltr, child: composition);
    if (defaultFontFamily != null) {
      composition = DefaultTextStyle.merge(
        style: TextStyle(fontFamily: defaultFontFamily),
        child: composition,
      );
    }

    final controller = RenderController();
    final boundaryKey = GlobalKey();
    final shell = buildCaptureShell(
      composition: composition,
      boundaryKey: boundaryKey,
      controller: controller,
      resolver: active,
      snapshotScope: snapshotScope,
      reactiveTracks: active == null ? noReactiveTracks : reactiveTracks,
    );

    final preparer = active != null && active is ClipFramePreparer
        ? active as ClipFramePreparer
        : null;
    // Warm the first frame's clip window before the tree mounts: the initial
    // pumpWidget builds at frame 0, before the capture loop's first decode-ahead,
    // so without this the first build's synchronous clip lookup would miss (that
    // build is not captured — the loop re-pumps frame 0 — but it would still throw).
    await runAsync(() async {
      await preparer?.prepareClipFrames(0);
      return null;
    });
    await pumpWidget(shell.tree);

    final counting = _CountingCaptureService(capture);
    // The resolver is what serves media during the loop AND what the audio mix
    // stages through, so the service is built here, after it exists — its `media`
    // is final at construction.
    final service = RenderService(
      capture: counting,
      cache: FrameCache(cacheRoot ?? FrameCache.defaultRoot()),
      media: active ?? const NoMediaResolver(),
    );
    final audio = _audioFor(video, resolver: active, fps: config.fps, totalFrames: frameCount);

    final mountedScope = shell.mountedSnapshotScope;
    late final RenderManifest manifest;
    await runAsync(() async {
      manifest = await service.captureToDirectory(
        config: config,
        outDir: outDir,
        pump: (frame) async {
          // Decode-ahead: a streaming resolver warms just the clip frames this
          // composition frame paints before the tree builds, so paint's
          // synchronous clip lookup is a hit without holding every frame in memory.
          await preparer?.prepareClipFrames(frame);
          controller.seek(frame);
          // Restart the Snapshot order cursor so the n-th unkeyed Snapshot reads
          // index n this frame, not n + (frame * count) — otherwise the indices
          // drift and a later frame throws on a missing raster.
          mountedScope?.resetCursor();
          await pumpFrame();
        },
        boundaryKey: boundaryKey,
        compositionKey: compositionKey,
        audioSources: audio.audioSources,
        stageAudio: audio.stageAudio,
        export: export,
        posterFrame: posterFrame,
        onProgress: onProgress,
        // Every source was pre-resolved above (images and clip frames alike); the
        // resolver is idempotent, so the service needs no re-pass — and re-passing
        // clip sources would try to decode a video as an image.
      );
      return null;
    });

    onCacheReport?.call(
      cacheEnabled ? config.frameCount - counting.captures : 0,
      config.frameCount,
    );
    return manifest;
  } finally {
    for (final image in snapshotScope?.images.values ?? const <ui.Image>[]) {
      image.dispose();
    }
    final release = owned?.dispose;
    if (release != null) await release();
  }
}