renderVideo function
- 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 onProgress()?,
- void onCacheReport()?,
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();
}
}