renderToSandbox function

Future<RenderManifest> renderToSandbox({
  1. required Widget composition,
  2. required Aspect aspect,
  3. required int frameCount,
  4. required RenderSandbox sandbox,
  5. required FrameCaptureService capture,
  6. required SandboxMount pumpWidget,
  7. required SandboxFramePump pumpFrame,
  8. int longEdge = VideoDefaults.longEdge,
  9. int fps = VideoDefaults.fps,
  10. String compositionKey = 'render',
  11. Export? export,
  12. int? posterFrame,
  13. ProgressCallback? onProgress,
  14. VideoEncoderService encoder = const VideoEncoderService(),
  15. List<ResolvedAudioTrack> audioTracks = const [],
  16. AudioByteLoader? loadAudioBytes,
  17. double audioMasterVolume = 1,
  18. MediaResolver? resolver,
  19. GenerativeResolver generative = const NoGenerativeResolver(),
  20. void onGenerativeProgress(
    1. GenerativeProgress progress
    )?,
  21. FrameEncoder? frameEncoder,
})

Renders composition for aspect into sandbox without touching the file system — the in-memory, dart:io-free capture-and-plan path the web encoder drives.

It mirrors the file-based render: it re-derives the canvas from aspect.sizeFor(longEdge), builds the production buildCaptureShell, pumps it through pumpWidget / pumpFrame, runs the shared runFrameCaptureLoop into sandbox's frames file, then writes manifest.json there. The caller (the web encoder) reads sandbox to feed ffmpeg.wasm and returns the encoded bytes. Rendering the same composition produces the same frames, the same as the file path.

Pass audioTracks (resolved by resolveAudioMix) plus a loadAudioBytes loader to mix audio: each track is staged into sandbox by stageResolvedAudioToSandbox and its amix plan flows into the encode args, scaled by audioMasterVolume. With no tracks (or no loader) the plan carries -an, matching a video-only render. Audio rides the MP4 export only; the other export modes ignore it.

Pass a resolver to paint declared image media: it pre-resolves every collected MediaSource before the first pump and is mounted as the ImageResolverScope, so paint reads the decoded cache synchronously. A null resolver renders a media-less composition (any declared image throws).

Implementation

Future<RenderManifest> renderToSandbox({
  required Widget composition,
  required Aspect aspect,
  required int frameCount,
  required RenderSandbox sandbox,
  required FrameCaptureService capture,
  required SandboxMount pumpWidget,
  required SandboxFramePump pumpFrame,
  int longEdge = VideoDefaults.longEdge,
  int fps = VideoDefaults.fps,
  String compositionKey = 'render',
  Export? export,
  int? posterFrame,
  ProgressCallback? onProgress,
  VideoEncoderService encoder = const VideoEncoderService(),
  List<ResolvedAudioTrack> audioTracks = const [],
  AudioByteLoader? loadAudioBytes,
  double audioMasterVolume = 1,
  MediaResolver? resolver,
  GenerativeResolver generative = const NoGenerativeResolver(),
  void Function(GenerativeProgress progress)? onGenerativeProgress,
  FrameEncoder? frameEncoder,
}) async {
  if (audioTracks.isNotEmpty && loadAudioBytes == null) {
    throw ArgumentError.value(
      audioTracks,
      'audioTracks',
      'loadAudioBytes is required to stage audio; pass one or leave audioTracks empty',
    );
  }
  final size = aspect.sizeFor(longEdge);
  final config = RenderConfig(
    width: size.width,
    height: size.height,
    fps: fps,
    frameCount: frameCount,
  );
  // Pre-resolve declared media before the first pump: the capture shell paints
  // images synchronously from the `ImageResolverScope`, so the decoded cache
  // must be warm before the tree mounts (a null resolver = 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,
    );
  }
  final controller = RenderController();
  final boundaryKey = GlobalKey();
  final shell = buildCaptureShell(
    composition: AspectScope(aspect: aspect, child: composition),
    boundaryKey: boundaryKey,
    controller: controller,
    resolver: resolver,
    generativeResolver: generative,
  );
  await pumpWidget(shell.tree);

  final digest = renderDigest(
    config: config,
    compositionKey: '$compositionKey-${aspect.name}',
    fluvieVersion: fluvieRenderVersion,
  );
  await sandbox.create();
  // The browser path (frameEncoder set) writes one bounded-size PNG per frame as
  // it is captured; every other backend appends raw RGBA to one frames stream.
  final framesArePng = frameEncoder != null;
  Future<void> pump(int frame) async {
    controller.seek(frame);
    await pumpFrame();
  }

  if (framesArePng) {
    await runFrameCaptureLoop(
      config: config,
      digest: digest,
      pump: pump,
      boundaryKey: boundaryKey,
      capture: capture,
      onFrame: (raw) async {
        final png = await frameEncoder(raw.rgba, raw.width, raw.height);
        await sandbox.writeBytes(VideoEncoderService.framesPngName(raw.frameIndex), png);
      },
      onProgress: onProgress,
    );
  } else {
    final sink = sandbox.openFrames(VideoEncoderService.framesFileName);
    try {
      await runFrameCaptureLoop(
        config: config,
        digest: digest,
        pump: pump,
        boundaryKey: boundaryKey,
        sink: sink,
        capture: capture,
        onProgress: onProgress,
      );
    } finally {
      await sink.close();
    }
  }

  final audioPlan = (audioTracks.isEmpty || loadAudioBytes == null)
      ? null
      : await stageResolvedAudioToSandbox(
          tracks: audioTracks,
          sandbox: sandbox,
          loadBytes: loadAudioBytes,
          masterVolume: audioMasterVolume,
        );

  final manifest = RenderManifest(
    width: config.width,
    height: config.height,
    fps: config.fps,
    frameCount: config.frameCount,
    framesFileName: framesArePng
        ? VideoEncoderService.framesPngPattern
        : VideoEncoderService.framesFileName,
    outputFileName: encoder.outputNameFor(export),
    renderDigest: digest,
    ffmpegArgs: encoder.planEncodeArgs(
      config,
      audio: audioPlan?.tracks ?? const [],
      amix: audioPlan?.amix,
      export: export,
      framesArePng: framesArePng,
    ),
    posterFileName: posterFrame == null ? null : VideoEncoderService.posterFileName,
    posterArgs: posterFrame == null
        ? null
        : encoder.planPosterArgs(config, posterFrame: posterFrame, framesArePng: framesArePng),
  );
  await sandbox.writeText('manifest.json', jsonEncode(manifest.toJson()));
  return manifest;
}