Skip to content

Instantly share code, notes, and snippets.

@chaudharydeepanshu
Last active September 11, 2025 07:43
Show Gist options
  • Save chaudharydeepanshu/f842991a79bb0e72a97845c343e9a2f5 to your computer and use it in GitHub Desktop.
Save chaudharydeepanshu/f842991a79bb0e72a97845c343e9a2f5 to your computer and use it in GitHub Desktop.
Flutter widget capture implementation that bypasses GPU max texture constraints by tiling large widgets, capturing regions separately, and assembling raw pixels. Supports arbitrary widget sizes and pixel ratios.
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:image/image.dart' as img;
// import 'package:testapp/utilities/async_png_encoder.dart';
/// Widget capture utility for high-resolution image export.
///
/// Works around texture size limitations by:
/// 1. Capturing the widget in tiles, each within GPU texture limits
/// 2. Assembling raw pixels from tiles into a complete image canvas
/// 3. Encoding the canvas to PNG
///
/// Example:
/// ```dart
/// // Capture the widget offscreen
/// final Uint8List imageBytes = await WidgetCapture.captureOffscreenWidget(
/// MyWidget(),
/// context: context,
/// pixelRatio: 4.0,
/// wait: Duration(milliseconds: 100), // Optional wait for async content
/// );
///
/// // Save or share imageBytes as needed
/// final directory = await getApplicationDocumentsDirectory();
/// final pathOfImage = await File('${directory.path}/my_widget.png').create();
/// await pathOfImage.writeAsBytes(imageBytes);
/// debugPrint('Image saved to: ${pathOfImage.path}');
/// ```
class WidgetCapture {
WidgetCapture._();
// ============================================================================
// Constants
// ============================================================================
/// Cached maximum texture dimension for this device
static double? _cachedMaxDimension;
/// Safety margin for tile size calculation (80% of max to avoid edge cases)
static const double _tileSafetyFactor = 0.8;
/// Target frame time in ms to maintain 60fps
static const int _frameBudgetMs = 16;
// ============================================================================
// Public API
// ============================================================================
/// Captures a widget as PNG without requiring it in the widget tree.
///
/// Creates an isolated render tree, captures the widget, then disposes
/// everything cleanly.
static Future<Uint8List> captureOffscreenWidget(
Widget widget, {
required BuildContext context,
double pixelRatio = 4.0,
Duration? wait,
bool detectMaxSize = true,
}) async {
assert(pixelRatio > 0, 'Pixel ratio must be positive');
// Detect GPU's maximum texture size on first use
final double maxDimension = await _getMaxTextureDimension(detectMaxSize);
debugPrint(
'WidgetCapture: Using max texture: ${maxDimension.toInt()}x${maxDimension.toInt()}',
);
if (!context.mounted) throw Exception('Context is not mounted');
// Setup isolated render tree
final renderTree = await _setupRenderTree(widget, context, pixelRatio);
// Allow async content to load if requested
if (wait != null) {
await Future.delayed(wait);
renderTree.buildOwner.buildScope(renderTree.rootElement);
}
// Finalize layout
_finalizeRenderTree(renderTree);
final Size actualSize = renderTree.boundary.size;
if (actualSize.isEmpty) {
throw Exception('Widget has zero size after layout');
}
debugPrint(
'WidgetCapture: Layout: ${actualSize.width.toStringAsFixed(1)}x${actualSize.height.toStringAsFixed(1)}',
);
// Capture the widget
final Uint8List imageBytes = await _captureWidget(
renderTree.boundary,
pixelRatio: pixelRatio,
maxDimension: maxDimension,
);
debugPrint('WidgetCapture: Complete: ${imageBytes.length} bytes');
return imageBytes;
}
// ============================================================================
// GPU Texture Size Detection
// ============================================================================
/// Gets the maximum texture dimension, detecting if needed.
static Future<double> _getMaxTextureDimension(bool detect) async {
if (detect && _cachedMaxDimension == null) {
_cachedMaxDimension = await _detectMaxTextureSize();
}
return _cachedMaxDimension ?? 4096.0;
}
/// Detects GPU's maximum texture size by testing only common values.
/// 4 tests only.
static Future<double> _detectMaxTextureSize() async {
debugPrint('WidgetCapture: Fast-detecting GPU max texture size...');
// Test these in order - stop at first failure
// Covers 99.9% of all devices with just 4 tests max
const List<double> sizesToTest = [
2048, // Test 1: Older devices
4096, // Test 2: Most devices
8192, // Test 3: Modern devices
16384, // Test 4: High-end only
];
double maxSupported = 2048; // Guaranteed to work
for (final size in sizesToTest) {
try {
final ui.PictureRecorder recorder = ui.PictureRecorder();
final ui.Canvas canvas = ui.Canvas(recorder);
// Minimal draw - just one pixel
canvas.drawRect(const Rect.fromLTWH(0, 0, 1, 1), Paint());
final ui.Picture picture = recorder.endRecording();
// Test creating image at this size
final ui.Image image = await picture.toImage(size.toInt(), 1);
if (image.width == size.toInt()) {
maxSupported = size;
debugPrint('WidgetCapture: ✓ ${size.toInt()} supported');
image.dispose();
picture.dispose();
} else {
// GPU capped us - actual max is image.width
maxSupported = image.width.toDouble();
debugPrint('WidgetCapture: GPU max is ${image.width}');
image.dispose();
picture.dispose();
break;
}
} catch (e) {
// This size failed - previous size was the max
debugPrint(
'WidgetCapture: Max is ${maxSupported.toInt()} (${size.toInt()} failed)',
);
break;
}
}
debugPrint(
'WidgetCapture: Final max: ${maxSupported.toInt()}x${maxSupported.toInt()}',
);
return maxSupported;
}
// ============================================================================
// Render Tree Setup
// ============================================================================
/// Sets up an isolated render tree for the widget.
static Future<_RenderTreeInfo> _setupRenderTree(
Widget widget,
BuildContext context,
double pixelRatio,
) async {
// Preserve context properties (theme, directionality, localizations, etc.)
final contextualWidget = InheritedTheme.captureAll(
context,
MediaQuery(
data: MediaQuery.of(context),
child: Localizations(
locale: Localizations.localeOf(context),
delegates: const [
DefaultMaterialLocalizations.delegate,
DefaultWidgetsLocalizations.delegate,
],
child: Directionality(
textDirection: Directionality.of(context),
child: widget,
),
),
),
);
final boundary = RenderRepaintBoundary();
final renderView = RenderView(
view: ui.PlatformDispatcher.instance.views.first,
child: RenderPositionedBox(
alignment: Alignment.topLeft,
child: boundary,
),
configuration: ViewConfiguration(
logicalConstraints: const BoxConstraints(
minWidth: 0,
maxWidth: double.infinity,
minHeight: 0,
maxHeight: double.infinity,
),
physicalConstraints: const BoxConstraints(
minWidth: 0,
maxWidth: double.infinity,
minHeight: 0,
maxHeight: double.infinity,
),
devicePixelRatio: pixelRatio,
),
);
final pipelineOwner = PipelineOwner();
final buildOwner = BuildOwner(focusManager: FocusManager());
pipelineOwner.rootNode = renderView;
renderView.prepareInitialFrame();
final rootElement = RenderObjectToWidgetAdapter<RenderBox>(
container: boundary,
child: contextualWidget,
).attachToRenderTree(buildOwner);
buildOwner.buildScope(rootElement);
return _RenderTreeInfo(
boundary: boundary,
pipelineOwner: pipelineOwner,
buildOwner: buildOwner,
rootElement: rootElement,
);
}
/// Finalizes the render tree layout.
static void _finalizeRenderTree(_RenderTreeInfo info) {
info.buildOwner.finalizeTree();
info.pipelineOwner.flushLayout();
info.pipelineOwner.flushCompositingBits();
info.pipelineOwner.flushPaint();
}
// ============================================================================
// Widget Capture
// ============================================================================
/// Captures a widget, using tiled approach if it exceeds texture limits.
static Future<Uint8List> _captureWidget(
RenderRepaintBoundary boundary, {
required double pixelRatio,
required double maxDimension,
}) async {
final Size widgetSize = boundary.size;
final double imageWidth = widgetSize.width * pixelRatio;
final double imageHeight = widgetSize.height * pixelRatio;
// Single capture if within GPU limits
if (imageWidth <= maxDimension && imageHeight <= maxDimension) {
return await _captureSingle(boundary, pixelRatio);
}
// Multi-region capture for large widgets
return await _captureTiled(
boundary,
widgetSize,
pixelRatio,
maxDimension: maxDimension,
);
}
/// Single-pass capture for widgets within GPU texture limits.
static Future<Uint8List> _captureSingle(
RenderRepaintBoundary boundary,
double pixelRatio,
) async {
debugPrint('WidgetCapture: Single-pass capture');
final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
final ByteData? byteData = await image.toByteData(
format: ui.ImageByteFormat.png,
);
image.dispose();
if (byteData == null) {
throw Exception('Failed to encode image data');
}
return byteData.buffer.asUint8List();
}
/// Multi-region capture for widgets exceeding GPU texture limits.
static Future<Uint8List> _captureTiled(
RenderRepaintBoundary boundary,
Size widgetSize,
double pixelRatio, {
required double maxDimension,
}) async {
// Calculate tile dimensions
final double safeTileSize = maxDimension * _tileSafetyFactor;
final double logicalTileSize = safeTileSize / pixelRatio;
final int tilesX = (widgetSize.width / logicalTileSize).ceil();
final int tilesY = (widgetSize.height / logicalTileSize).ceil();
final int finalWidth = (widgetSize.width * pixelRatio).ceil();
final int finalHeight = (widgetSize.height * pixelRatio).ceil();
debugPrint(
'WidgetCapture: ${tilesX}x$tilesY tiles for ${finalWidth}x${finalHeight}px',
);
// Allocate canvas
final canvas = img.Image(
width: finalWidth,
height: finalHeight,
format: img.Format.uint8,
numChannels: 4,
);
// Capture each tile
await _captureTiles(
boundary: boundary,
canvas: canvas,
tilesX: tilesX,
tilesY: tilesY,
logicalTileSize: logicalTileSize,
safeTileSize: safeTileSize,
widgetSize: widgetSize,
pixelRatio: pixelRatio,
);
// Encode to PNG
debugPrint('WidgetCapture: Encoding final image');
// Custom async non blocking png encoder
// final Uint8List png = await AsyncPngEncoder.encode(
// canvas,
// onProgress: (p) => debugPrint('PNG encoding: ${(p * 100).toInt()}%'),
// );
// ORIGINAL ENCODER - Kept for reference and comparison
// Provides excellent compression but blocks the main thread without isolate use!
final Uint8List png = img.encodePng(
canvas,
level: 6, // 1 for fastest compression level (1-9, default is 6)
);
return png;
}
/// Captures individual tiles and assembles them.
static Future<void> _captureTiles({
required RenderRepaintBoundary boundary,
required img.Image canvas,
required int tilesX,
required int tilesY,
required double logicalTileSize,
required double safeTileSize,
required Size widgetSize,
required double pixelRatio,
}) async {
final frameTimer = Stopwatch();
for (int row = 0; row < tilesY; row++) {
for (int col = 0; col < tilesX; col++) {
frameTimer.start();
// Calculate tile bounds
final tileRect = Rect.fromLTRB(
col * logicalTileSize,
row * logicalTileSize,
math.min((col + 1) * logicalTileSize, widgetSize.width),
math.min((row + 1) * logicalTileSize, widgetSize.height),
);
// Capture tile
final tileImage = await boundary._captureRegion(tileRect, pixelRatio);
final pixelData = await tileImage.toByteData(
format: ui.ImageByteFormat.rawRgba,
);
if (pixelData == null) {
tileImage.dispose();
throw Exception('Failed to get pixel data for tile [$row,$col]');
}
// Copy to canvas
_copyPixelsToCanvas(
canvas: canvas,
pixelData: pixelData.buffer.asUint8List(),
srcWidth: tileImage.width,
srcHeight: tileImage.height,
dstX: (col * safeTileSize).round(),
dstY: (row * safeTileSize).round(),
);
tileImage.dispose();
frameTimer.stop();
// Yield to maintain UI responsiveness
if (frameTimer.elapsedMilliseconds > _frameBudgetMs ~/ 2) {
await SchedulerBinding.instance.endOfFrame;
frameTimer.reset();
}
}
}
}
/// Copies raw pixels to canvas at specified position.
static void _copyPixelsToCanvas({
required img.Image canvas,
required Uint8List pixelData,
required int srcWidth,
required int srcHeight,
required int dstX,
required int dstY,
}) {
int srcIndex = 0;
for (int y = 0; y < srcHeight; y++) {
final int canvasY = dstY + y;
if (canvasY >= canvas.height) break;
for (int x = 0; x < srcWidth; x++) {
final int canvasX = dstX + x;
if (canvasX >= canvas.width) break;
canvas.setPixelRgba(
canvasX,
canvasY,
pixelData[srcIndex++], // R
pixelData[srcIndex++], // G
pixelData[srcIndex++], // B
pixelData[srcIndex++], // A
);
}
}
}
}
// ============================================================================
// Helper Classes & Extensions
// ============================================================================
/// Holds render tree components during capture.
class _RenderTreeInfo {
_RenderTreeInfo({
required this.boundary,
required this.pipelineOwner,
required this.buildOwner,
required this.rootElement,
});
final RenderRepaintBoundary boundary;
final PipelineOwner pipelineOwner;
final BuildOwner buildOwner;
final RenderObjectToWidgetElement<RenderBox> rootElement;
}
/// Provides access to protected OffsetLayer for region capture.
extension _RenderRepaintBoundaryLayerAccess on RenderRepaintBoundary {
Future<ui.Image> _captureRegion(Rect region, double pixelRatio) async {
// ignore: invalid_use_of_protected_member
final OffsetLayer offsetLayer = layer! as OffsetLayer;
return offsetLayer.toImage(region, pixelRatio: pixelRatio);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment