render function

Future<RenderAspectResult> render({
  1. required Widget composition,
  2. required Aspect aspect,
  3. required int frameCount,
  4. required Directory outDir,
  5. required RenderService service,
  6. required ShellMount pumpWidget,
  7. required ShellFramePump pumpFrame,
  8. int longEdge = VideoDefaults.longEdge,
  9. int fps = VideoDefaults.fps,
  10. String compositionKey = 'render',
  11. bool cacheEnabled = false,
  12. AudioMixStager? stageAudio,
  13. Iterable<AudioSource>? audioSources,
  14. MediaResolver? resolver,
  15. GenerativeResolver generative = const NoGenerativeResolver(),
  16. void onGenerativeProgress(
    1. GenerativeProgress progress
    )?,
})

Renders composition for a single aspect — the canonical multi-aspect entry.

It re-derives the canvas size from aspect.sizeFor(longEdge) (ignoring any size the composition declares for itself), mounts an AspectScope over the composition so every Adaptive and AspectScope.of(context) branch lays out for this aspect, builds the production buildCaptureShell around it, and runs the shell once through RenderService.captureToDirectory. Timing and animations resolve identically across aspects — the same plan resolves; only layout branches — so the per-aspect renders share a clock and differ only in shape.

This is distinct from RenderService.render, the instance method that captures and encodes one fixed-size composition: render is the free function that re-derives the size per aspect and drives that same service. (It is also distinct from renderTemplate, which renders a parameterized VideoTemplate — Dart has no overloading, so each is its own free function.)

The host owns the pumping mechanics through pumpWidget and pumpFrame, which keeps this offline and gate-runnable (the example and goldens pass a flutter_test pump; the CLI passes its binding's). To encode the result, run the returned manifest's ffmpeg args through an FfmpegRunner.

When composition is a Video that declares Audio, its tracks are staged into the encoder mix by default: the resulting file is not silent. Pass stageAudio (with audioSources) to override the default — for example when the audio-bearing Video is wrapped in another widget the auto-collect cannot reach. A Video with no audio (or a non-Video composition) stages nothing, so the encoder's -an path stands.

Pass a resolver to render declared media: its sources are collected from composition and pre-resolved before the first pump, and it is mounted as the ImageResolverScope the elements paint from. A null resolver keeps the media-less path (a composition that declares an Image/Clip then throws a FluvieRenderException naming the missing pre-resolution).

Each aspect re-derives its size from longEdge and renders independently, so the same composition renders the same frames for that aspect; the encode arg array, including the mix, is built from the plan.

Implementation

Future<RenderAspectResult> render({
  required Widget composition,
  required Aspect aspect,
  required int frameCount,
  required Directory outDir,
  required RenderService service,
  required ShellMount pumpWidget,
  required ShellFramePump pumpFrame,
  int longEdge = VideoDefaults.longEdge,
  int fps = VideoDefaults.fps,
  String compositionKey = 'render',
  bool cacheEnabled = false,
  AudioMixStager? stageAudio,
  Iterable<AudioSource>? audioSources,
  MediaResolver? resolver,
  GenerativeResolver generative = const NoGenerativeResolver(),
  void Function(GenerativeProgress progress)? onGenerativeProgress,
}) async {
  final size = aspect.sizeFor(longEdge);
  final config = RenderConfig(
    width: size.width,
    height: size.height,
    fps: fps,
    frameCount: frameCount,
    cacheEnabled: cacheEnabled,
  );
  // Pre-resolve declared and generated media before reading audio: generation
  // produces the files, the media pre-pass warms the decode cache, and the clip
  // pre-pass probes each clip — so the audio collector below knows which clips
  // actually carry an audio track. A null resolver is a media-less render.
  if (resolver != null) {
    await generative.generateAll(
      collectCompositionGenerative(composition),
      onProgress: onGenerativeProgress,
    );
    await resolver.preResolveAll(collectCompositionMedia(composition, generative: generative));
    await preResolveCompositionClips(
      composition: composition,
      resolver: resolver,
      totalFrames: frameCount,
      generative: generative,
    );
  }
  // The encoder audio mix: an explicit stager wins; otherwise a `Video`
  // composition's own `Audio` tracks (plus any generated audio and a clip's
  // embedded audio) are collected and staged so a public `render(video, aspect:)`
  // of an audio composition is not silent.
  final audio = _audioFor(
    composition,
    explicitStager: stageAudio,
    explicitSources: audioSources,
    fps: fps,
    totalFrames: frameCount,
    generative: resolver != null ? generative : null,
    resolver: resolver,
  );
  final controller = RenderController();
  final boundaryKey = GlobalKey();
  final shell = buildCaptureShell(
    composition: AspectScope(aspect: aspect, child: composition),
    boundaryKey: boundaryKey,
    controller: controller,
    resolver: resolver,
    generativeResolver: generative,
  );
  final preparer = resolver != null && resolver is ClipFramePreparer
      ? resolver 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 preparer?.prepareClipFrames(0);
  await pumpWidget(shell.tree);
  final 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);
      shell.mountedSnapshotScope?.resetCursor();
      await pumpFrame();
    },
    boundaryKey: boundaryKey,
    compositionKey: '$compositionKey-${aspect.name}',
    audioSources: audio.audioSources,
    stageAudio: audio.stageAudio,
  );
  return (manifest: manifest, config: config);
}