render function
- 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 onGenerativeProgress(
- 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);
}