From c6db901a6575d25733550de6e044bfa6d3fdcedd Mon Sep 17 00:00:00 2001 From: hm21 Date: Fri, 31 Jul 2026 12:53:12 +0200 Subject: [PATCH 1/2] feat(layers): rasterize layers outside a live editor session Add `LayerRasterizer` and `LayerRasterizerHost`. `Layer.captureAsPng` returns `null` for an unmounted layer, so layers restored from an exported state history could not be baked into a render. The host mounts them behind its own child - painted, never visible - so `capture()` returns the same `ExportedLayer`s a live session would. Layers whose content loads asynchronously (network images, decoded assets, custom `WidgetLayer`s) are handled via `awaitContentReady`. Includes an example page that renders a persisted history without ever opening the editor. --- CHANGELOG.md | 3 + .../lib/features/layer/layer_group_page.dart | 7 + .../layer/layer_rasterizer_example.dart | 334 ++++++++++++++++++ lib/pro_image_editor.dart | 2 + .../layer_rasterizer/layer_rasterizer.dart | 199 +++++++++++ .../layer_rasterizer_host.dart | 108 ++++++ pubspec.yaml | 2 +- .../layer_rasterizer_test.dart | 152 ++++++++ 8 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 example/lib/features/layer/layer_rasterizer_example.dart create mode 100644 lib/shared/services/layer_rasterizer/layer_rasterizer.dart create mode 100644 lib/shared/services/layer_rasterizer/layer_rasterizer_host.dart create mode 100644 test/shared/services/layer_rasterizer/layer_rasterizer_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f3b35dc..7c3e9308c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 13.3.0 +- **FEAT**(layers): Rasterize layers outside a live editor session via `LayerRasterizer` and `LayerRasterizerHost`. `Layer.captureAsPng` returns `null` for an unmounted layer, so layers restored from an exported state history could not be baked into a render. The host mounts them behind its own child — painted, never visible — so `capture()` returns the same `ExportedLayer`s a live session would. Use `awaitContentReady` for layers whose content loads asynchronously (network images, decoded assets, custom `WidgetLayer`s). + ## 13.2.3 - **FIX**(main-editor): A layer sharing its position with others (a stack of overlapping layers) can now be dragged away instead of being trapped by their coincident alignment guides. Snapping re-arms once the layer moves clear. diff --git a/example/lib/features/layer/layer_group_page.dart b/example/lib/features/layer/layer_group_page.dart index 43f5cc0a7..c50a3cdf8 100644 --- a/example/lib/features/layer/layer_group_page.dart +++ b/example/lib/features/layer/layer_group_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '/features/layer/layer_export_example.dart'; import '/features/layer/layer_grouping_example.dart'; +import '/features/layer/layer_rasterizer_example.dart'; import '/features/layer/layer_select_design_example.dart'; import '/features/layer/selectable_layer_example.dart'; @@ -49,6 +50,12 @@ class _LayerGroupPageState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _openExample(const LayerExportExample()), ), + ListTile( + leading: const Icon(Icons.motion_photos_on_outlined), + title: const Text('Rasterize Layers without Editor'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openExample(const LayerRasterizerExample()), + ), ], ), ); diff --git a/example/lib/features/layer/layer_rasterizer_example.dart b/example/lib/features/layer/layer_rasterizer_example.dart new file mode 100644 index 000000000..4a2a2c4e1 --- /dev/null +++ b/example/lib/features/layer/layer_rasterizer_example.dart @@ -0,0 +1,334 @@ +// Flutter imports: +import 'package:flutter/material.dart'; + +// Package imports: +import 'package:pro_image_editor/pro_image_editor.dart'; + +// Project imports: +import '/core/constants/example_constants.dart'; +import '/core/constants/history_demo/import_history_6_3_0_minified.dart'; +import '/shared/widgets/paragraph_info_widget.dart'; +import '/shared/widgets/pixel_transparent_painter.dart'; + +/// Demonstrates how to rasterize layers **without** a running editor session. +/// +/// A layer can only be captured while it is mounted — [Layer.captureAsPng] +/// returns `null` for a layer that was never laid out. Layers restored from an +/// exported state history are exactly that case: they deserialize fine, but +/// they have never been on screen, so they cannot be baked into a render. +/// +/// [LayerRasterizer] closes that gap. [LayerRasterizerHost] mounts the layers +/// behind its own child — painted, never visible — so [LayerRasterizer.capture] +/// returns the same [ExportedLayer]s a live session would. +class LayerRasterizerExample extends StatefulWidget { + /// Creates a new [LayerRasterizerExample] widget. + const LayerRasterizerExample({super.key}); + + @override + State createState() => _LayerRasterizerExampleState(); +} + +class _LayerRasterizerExampleState extends State { + final _rasterizer = LayerRasterizer(); + + /// A persisted editor session. Nothing here ever opens the editor — the + /// history is parsed and its layers go straight to the rasterizer. + final _history = ImportStateHistory.fromMap( + kImportHistoryDemoData, + configs: ImportEditorConfigs( + /// Only the main-editor import path recalculates offsets against the + /// current image size. Standalone we keep the original values and lay + /// the layers out against the size they were exported with. + recalculateSizeAndPosition: false, + widgetLoader: (String id, {Map? meta}) { + switch (id) { + case 'my-special-container': + return Container( + width: 100, + height: 100, + color: Colors.amber, + ); + } + throw ArgumentError('No widget found for the given id: $id'); + }, + ), + ); + + List? _captured; + bool _isCapturing = false; + String? _error; + int? _durationInMs; + + /// The body size the layers were laid out against in the original session. + /// Layer offsets are relative to its center, so passing it is what makes the + /// capture match that session. + Size get _editorBodySize => _history.lastRenderedImgSize; + + List get _layers { + final states = _history.stateHistory; + if (states.isEmpty) return const []; + final position = _history.editorPosition.clamp(0, states.length - 1); + return states[position].layers; + } + + @override + void dispose() { + _rasterizer.dispose(); + super.dispose(); + } + + Future _rasterize() async { + setState(() { + _isCapturing = true; + _error = null; + }); + + final stopwatch = Stopwatch()..start(); + + try { + final captured = await _rasterizer.capture( + layers: _layers, + editorBodySize: _editorBodySize, + + /// The history contains a widget layer that loads a network image. One + /// frame after mounting it is still blank, so we hold the capture + /// until every image the import flagged is resolved. + awaitContentReady: _precacheLayerImages, + ); + if (!mounted) return; + + setState(() { + _captured = captured; + _durationInMs = stopwatch.elapsedMilliseconds; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _isCapturing = false); + } + } + + /// Resolves the images the imported layers depend on. + /// + /// [ImportStateHistory.requirePrecacheList] collects every network, asset, + /// file or memory image referenced by an imported widget layer. + Future _precacheLayerImages() async { + if (!mounted) return; + await Future.wait( + _history.requirePrecacheList.toSet().map( + (image) => precacheImage(image.toImageProvider(), context), + ), + ); + } + + @override + Widget build(BuildContext context) { + /// The host must be mounted before `capture` runs — it is the widget tree + /// the layers get mounted into. In a real app it usually goes around the + /// whole app instead of a single page: + /// + /// ```dart + /// MaterialApp( + /// builder: (context, child) => LayerRasterizerHost( + /// rasterizer: rasterizer, + /// child: child!, + /// ), + /// ); + /// ``` + return LayerRasterizerHost( + rasterizer: _rasterizer, + child: Scaffold( + appBar: AppBar(title: const Text('Layer Rasterizer')), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), + children: [ + _buildInfo(), + const SizedBox(height: 20), + _buildActions(), + if (_error != null) ...[ + const SizedBox(height: 16), + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + if (_captured != null) ...[ + const SizedBox(height: 24), + _buildSectionTitle('Composed render'), + const SizedBox(height: 8), + _buildComposite(_captured!), + const SizedBox(height: 24), + _buildSectionTitle('Captured layers'), + const SizedBox(height: 8), + _buildLayerStrip(_captured!), + ], + ], + ), + ), + ); + } + + Widget _buildInfo() { + return ParagraphInfoWidget( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + const Text( + 'This page never opens the editor. It parses a persisted state ' + 'history and hands its layers to a `LayerRasterizer`, which mounts ' + 'them inside the `LayerRasterizerHost` below the visible content, ' + 'waits for them to paint and captures each one as a PNG.', + ), + Text( + 'Layers in the history: ${_layers.length} • ' + 'Original body size: ${_editorBodySize.width.round()}' + ' × ${_editorBodySize.height.round()}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + Widget _buildActions() { + final captured = _captured; + + return Row( + spacing: 16, + children: [ + FilledButton.icon( + onPressed: _isCapturing ? null : _rasterize, + icon: _isCapturing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome_motion_outlined), + label: Text( + captured == null ? 'Rasterize layers' : 'Rasterize again', + ), + ), + if (captured != null && !_isCapturing) + Expanded( + child: Text( + '${captured.length} of ${_layers.length} layers captured ' + 'in $_durationInMs ms', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } + + Widget _buildSectionTitle(String title) { + return Text(title, style: Theme.of(context).textTheme.titleMedium); + } + + /// Places the captured PNGs back over the background image. + /// + /// Each layer offset is relative to the center of the editor body, and + /// `applyTransforms` already baked rotation and flip into the bytes, so the + /// bytes only need to be centered on that offset. + Widget _buildComposite(List layers) { + final size = _editorBodySize; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 420), + child: AspectRatio( + aspectRatio: size.width / size.height, + child: FittedBox( + child: SizedBox.fromSize( + size: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Image.asset( + kImageEditorExampleAssetPath, + fit: BoxFit.cover, + ), + ), + for (final item in layers) + Positioned( + left: item.layer.offset.dx + size.width / 2, + top: item.layer.offset.dy + size.height / 2, + child: FractionalTranslation( + translation: const Offset(-0.5, -0.5), + child: Image.memory( + item.bytes, + width: item.logicalSize.width, + height: item.logicalSize.height, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// Shows every captured layer on a transparency grid so the alpha channel + /// of the exported bytes is visible. + Widget _buildLayerStrip(List layers) { + if (layers.isEmpty) { + return const Text('No layer could be captured.'); + } + + return SizedBox( + height: 168, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: layers.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (_, index) { + final item = layers[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CustomPaint( + painter: const PixelTransparentPainter( + primary: Color(0xFF9E9E9E), + secondary: Color(0xFF757575), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Image.memory(item.bytes, width: 120), + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + _layerLabel(item.layer), + style: Theme.of(context).textTheme.labelMedium, + ), + Text( + '${item.logicalSize.width.round()}' + ' × ${item.logicalSize.height.round()} px', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + }, + ), + ); + } + + String _layerLabel(Layer layer) { + if (layer is TextLayer) return 'Text'; + if (layer is EmojiLayer) return 'Emoji'; + if (layer is PaintLayer) return 'Paint'; + if (layer is WidgetLayer) return 'Widget'; + return 'Layer'; + } +} diff --git a/lib/pro_image_editor.dart b/lib/pro_image_editor.dart index 61212ce54..c6dafaa74 100644 --- a/lib/pro_image_editor.dart +++ b/lib/pro_image_editor.dart @@ -40,6 +40,8 @@ export 'core/models/init_configs/tune_editor_init_configs.dart'; export '/core/models/complete_parameters.dart'; export 'core/models/layers/layer.dart'; export 'core/models/layers/exported_layer.dart'; +export 'shared/services/layer_rasterizer/layer_rasterizer.dart'; +export 'shared/services/layer_rasterizer/layer_rasterizer_host.dart'; export 'core/models/custom_widgets/layer_interaction_widgets.dart'; export 'features/blur_editor/blur_editor.dart'; export 'features/crop_rotate_editor/crop_rotate_editor.dart'; diff --git a/lib/shared/services/layer_rasterizer/layer_rasterizer.dart b/lib/shared/services/layer_rasterizer/layer_rasterizer.dart new file mode 100644 index 000000000..a1ed43d59 --- /dev/null +++ b/lib/shared/services/layer_rasterizer/layer_rasterizer.dart @@ -0,0 +1,199 @@ +// Dart imports: +import 'dart:async'; +import 'dart:ui' as ui; + +// Flutter imports: +import 'package:flutter/widgets.dart'; + +// Project imports: +import '/core/models/editor_configs/pro_image_editor_configs.dart'; +import '/core/models/layers/exported_layer.dart'; +import '/core/models/layers/layer.dart'; +import 'layer_rasterizer_host.dart'; + +/// A pending rasterization, handed to a [LayerRasterizerHost] so it can mount +/// the layers that [LayerRasterizer.capture] is waiting on. +@immutable +class LayerRasterizationRequest { + /// Creates a request for [layers] laid out against [editorBodySize]. + const LayerRasterizationRequest({ + required this.layers, + required this.editorBodySize, + required this.configs, + }); + + /// The layers to mount. + final List layers; + + /// The editor body size the layers were originally laid out against. + /// + /// Layer offsets are relative to this size, so passing the size the editor + /// used is what makes the capture match the original session. + final Size editorBodySize; + + /// The editor configuration used to build the layer widgets. + final ProImageEditorConfigs configs; +} + +/// Rasterizes [Layer]s into [ExportedLayer]s outside of a live editor session. +/// +/// A layer can only be captured while it is mounted — [Layer.captureAsPng] +/// returns `null` when the layer's repaint boundary has no context. That makes +/// captured layers unavailable to anyone restoring a session from an exported +/// state history: the layers deserialize fine, but they have never been laid +/// out, so they cannot be baked into a render. +/// +/// This controller closes that gap. [capture] mounts the given layers in the +/// [LayerRasterizerHost] that carries it, waits for them to paint, and returns +/// the result. The host paints them behind its own child, so they never become +/// visible. +/// +/// Mount exactly one host per rasterizer, above anything that captures: +/// +/// ```dart +/// final rasterizer = LayerRasterizer(); +/// +/// MaterialApp( +/// builder: (context, child) => LayerRasterizerHost( +/// rasterizer: rasterizer, +/// child: child!, +/// ), +/// ); +/// +/// final history = ImportStateHistory.fromMap(persistedHistory); +/// final captured = await rasterizer.capture( +/// layers: history.stateHistory[history.editorPosition].layers, +/// editorBodySize: persistedBodySize, +/// configs: myEditorConfigs, +/// ); +/// ``` +/// +/// Concurrent [capture] calls are serialized: only one set of layers is +/// mounted at a time, so captures cannot read each other's repaint boundaries. +class LayerRasterizer extends ChangeNotifier { + LayerRasterizationRequest? _request; + + int _hostCount = 0; + + Future _queue = Future.value(); + + /// The layers currently waiting to be captured, or `null` when idle. + /// + /// Read by [LayerRasterizerHost]; not intended for other callers. + LayerRasterizationRequest? get request => _request; + + /// Whether a [LayerRasterizerHost] is mounted for this rasterizer. + /// + /// [capture] throws without one, because there would be no widget tree to + /// mount the layers into. + bool get hasHost => _hostCount > 0; + + /// Registers a mounted host. Called by [LayerRasterizerHost]. + void attachHost() => _hostCount++; + + /// Unregisters a disposed host. Called by [LayerRasterizerHost]. + void detachHost() => _hostCount--; + + /// Captures [layers] and returns their rendered bytes with layout metadata. + /// + /// [editorBodySize] must be the body size the layers were laid out against + /// in the original session — offsets are relative to it. + /// + /// [pixelRatio] and [basePixelRatio] control the output resolution and are + /// forwarded to [Layer.captureAllLayers]. Pass the same `basePixelRatio` the + /// export path uses (typically `configs.imageGeneration.customPixelRatio`) + /// so a captured layer matches the resolution of a live-session export. + /// + /// Layers whose content loads asynchronously — network images, decoded + /// assets, custom [WidgetLayer]s — are not painted yet one frame after + /// mounting, and would be captured blank. Pass [awaitContentReady] to hold + /// the capture until that content is resolved; it runs after the layers are + /// mounted and is followed by another frame before the capture. Only the + /// caller knows what its layers load, so there is no useful default. + /// + /// Returns an empty list when [layers] is empty. Throws a [StateError] when + /// no [LayerRasterizerHost] is mounted, and must not be called during a + /// build — mounting the layers rebuilds the host. + Future> capture({ + required List layers, + required Size editorBodySize, + ProImageEditorConfigs configs = const ProImageEditorConfigs(), + double? pixelRatio, + double? basePixelRatio, + bool applyTransforms = true, + ui.ImageByteFormat format = ui.ImageByteFormat.png, + Future Function()? awaitContentReady, + }) { + final result = _queue.then( + (_) => _capture( + layers: layers, + editorBodySize: editorBodySize, + configs: configs, + pixelRatio: pixelRatio, + basePixelRatio: basePixelRatio, + applyTransforms: applyTransforms, + format: format, + awaitContentReady: awaitContentReady, + ), + ); + // Keep the chain alive after a failed capture so one error does not block + // every later capture. + _queue = result.then((_) {}, onError: (_, _) {}); + return result; + } + + Future> _capture({ + required List layers, + required Size editorBodySize, + required ProImageEditorConfigs configs, + required double? pixelRatio, + required double? basePixelRatio, + required bool applyTransforms, + required ui.ImageByteFormat format, + required Future Function()? awaitContentReady, + }) async { + if (layers.isEmpty) return const []; + + if (!hasHost) { + throw StateError( + 'LayerRasterizer.capture was called without a mounted ' + 'LayerRasterizerHost. Layers can only be captured while they are in ' + 'the widget tree, so a host carrying this rasterizer must be mounted ' + 'above the call site.', + ); + } + + _request = LayerRasterizationRequest( + layers: layers, + editorBodySize: editorBodySize, + configs: configs, + ); + notifyListeners(); + + try { + // The first frame mounts and lays the layers out, the second guarantees + // they have been painted — a repaint boundary has no image until then. + await WidgetsBinding.instance.endOfFrame; + await WidgetsBinding.instance.endOfFrame; + + if (awaitContentReady != null) { + await awaitContentReady(); + // Resolving content typically lands through a `setState`, so give it + // the same build-then-paint pair the initial mount gets. + await WidgetsBinding.instance.endOfFrame; + await WidgetsBinding.instance.endOfFrame; + } + + return await Layer.captureAllLayers( + layers: layers, + pixelRatio: pixelRatio, + basePixelRatio: basePixelRatio, + applyTransforms: applyTransforms, + format: format, + ); + } finally { + _request = null; + notifyListeners(); + } + } +} diff --git a/lib/shared/services/layer_rasterizer/layer_rasterizer_host.dart b/lib/shared/services/layer_rasterizer/layer_rasterizer_host.dart new file mode 100644 index 000000000..057e6c25d --- /dev/null +++ b/lib/shared/services/layer_rasterizer/layer_rasterizer_host.dart @@ -0,0 +1,108 @@ +// Flutter imports: +import 'package:flutter/widgets.dart'; + +// Project imports: +import '/shared/widgets/layer/layer_widget.dart'; +import 'layer_rasterizer.dart'; + +/// Hosts the layers a [LayerRasterizer] is capturing. +/// +/// Mount this above anything that captures — typically once around the whole +/// app — and pass the same [rasterizer] to [LayerRasterizer.capture]: +/// +/// ```dart +/// MaterialApp( +/// builder: (context, child) => LayerRasterizerHost( +/// rasterizer: rasterizer, +/// child: child!, +/// ), +/// ); +/// ``` +/// +/// While idle the host adds a [Stack] around [child] and nothing else. During +/// a capture it renders the requested layers *behind* [child]: they have to be +/// painted for their repaint boundaries to hold an image, but [child] covers +/// them so they never reach the user. This mirrors how the editor's own +/// screenshot capture stays invisible. +class LayerRasterizerHost extends StatefulWidget { + /// Creates a host that renders whatever [rasterizer] is capturing. + const LayerRasterizerHost({ + super.key, + required this.rasterizer, + required this.child, + }); + + /// The rasterizer whose captures this host renders. + final LayerRasterizer rasterizer; + + /// The regular application content, painted over any captured layers. + final Widget child; + + @override + State createState() => _LayerRasterizerHostState(); +} + +class _LayerRasterizerHostState extends State { + @override + void initState() { + super.initState(); + widget.rasterizer.attachHost(); + } + + @override + void didUpdateWidget(covariant LayerRasterizerHost oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.rasterizer, widget.rasterizer)) { + oldWidget.rasterizer.detachHost(); + widget.rasterizer.attachHost(); + } + } + + @override + void dispose() { + widget.rasterizer.detachHost(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.rasterizer, + builder: (context, child) { + final request = widget.rasterizer.request; + return Stack( + clipBehavior: Clip.none, + children: [ + if (request != null) + // Positioned so the layers are laid out at the editor's body + // size rather than stretched to the host's constraints, which + // would move every layer offset. + Positioned( + left: 0, + top: 0, + width: request.editorBodySize.width, + height: request.editorBodySize.height, + child: IgnorePointer( + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + for (final layer in request.layers) + LayerWidget( + layer: layer, + configs: request.configs, + editorBodySize: request.editorBodySize, + ), + ], + ), + ), + ), + child!, + ], + ); + }, + child: widget.child, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 628c33555..016b5f88a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: pro_image_editor description: "A Flutter image editor: Seamlessly enhance your images with user-friendly editing features." -version: 13.2.3 +version: 13.3.0 homepage: https://github.com/hm21/pro_image_editor/ repository: https://github.com/hm21/pro_image_editor/ documentation: https://github.com/hm21/pro_image_editor/ diff --git a/test/shared/services/layer_rasterizer/layer_rasterizer_test.dart b/test/shared/services/layer_rasterizer/layer_rasterizer_test.dart new file mode 100644 index 000000000..04ed92a34 --- /dev/null +++ b/test/shared/services/layer_rasterizer/layer_rasterizer_test.dart @@ -0,0 +1,152 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:pro_image_editor/shared/widgets/layer/layer_widget.dart'; + +import '../../../mock/layers/text_layer_mock.dart'; + +void main() { + group(LayerRasterizer, () { + const bodySize = Size(300, 500); + + Widget buildHost(LayerRasterizer rasterizer) { + return MaterialApp( + home: LayerRasterizerHost( + rasterizer: rasterizer, + // Opaque and full-bleed: the host paints captured layers behind this + // child, and the child is what keeps them off screen. + child: const ColoredBox( + color: Colors.white, + child: SizedBox.expand(), + ), + ), + ); + } + + test( + 'returns an empty list without mounting anything when layers is empty', + () async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + expect(rasterizer.hasHost, isFalse); + await expectLater( + rasterizer.capture(layers: const [], editorBodySize: bodySize), + completion(isEmpty), + ); + }, + ); + + test('throws a StateError when no host is mounted', () async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + await expectLater( + rasterizer.capture(layers: [textLayerMock], editorBodySize: bodySize), + throwsA(isStateError), + ); + }); + + testWidgets('reports a host while one is mounted', (tester) async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget(buildHost(rasterizer)); + expect(rasterizer.hasHost, isTrue); + + await tester.pumpWidget(const SizedBox.shrink()); + expect(rasterizer.hasHost, isFalse); + }); + + testWidgets('renders no layers while idle', (tester) async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget(buildHost(rasterizer)); + + expect(find.byType(LayerWidget), findsNothing); + }); + + testWidgets('mounts the requested layers for the duration of the capture', ( + tester, + ) async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget(buildHost(rasterizer)); + + final capture = rasterizer.capture( + layers: [textLayerMock], + editorBodySize: bodySize, + // rawRgba encodes on the main thread; PNG would route through the + // isolate-backed recorder, which a widget test cannot drive. + format: ui.ImageByteFormat.rawRgba, + ); + + await tester.pump(); + expect( + find.byType(LayerWidget), + findsOneWidget, + reason: 'the layer must be in the tree for its boundary to paint', + ); + + await tester.pump(); + final captured = await tester.runAsync(() => capture); + + expect(captured, hasLength(1)); + expect(captured!.single.layer, same(textLayerMock)); + expect( + captured.single.bytes, + isNotEmpty, + reason: 'an unmounted layer captures as null and is dropped', + ); + expect(captured.single.logicalSize.isEmpty, isFalse); + + await tester.pump(); + expect( + find.byType(LayerWidget), + findsNothing, + reason: 'the host must stop rendering once the capture is done', + ); + }); + + testWidgets('awaits awaitContentReady before capturing', (tester) async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget(buildHost(rasterizer)); + + var readyCalled = false; + var layerWasMountedWhenReadyRan = false; + + final capture = rasterizer.capture( + layers: [textLayerMock], + editorBodySize: bodySize, + format: ui.ImageByteFormat.rawRgba, + awaitContentReady: () async { + readyCalled = true; + layerWasMountedWhenReadyRan = find + .byType(LayerWidget) + .evaluate() + .isNotEmpty; + }, + ); + + // Pump generously rather than matching the rasterizer's exact frame + // count: extra frames are harmless, and a missing one would hang. + for (var i = 0; i < 6; i++) { + await tester.pump(); + } + await tester.runAsync(() => capture); + + expect(readyCalled, isTrue); + expect( + layerWasMountedWhenReadyRan, + isTrue, + reason: 'the hook exists to resolve content of already-mounted layers', + ); + }); + }); +} From 675742f9237afa2d7712256a20627ff4b3e91475 Mon Sep 17 00:00:00 2001 From: hm21 Date: Fri, 31 Jul 2026 14:11:58 +0200 Subject: [PATCH 2/2] fix(layers): correct LayerRasterizer capture invariants Queued captures shared the host's `LayerWidget` element: the host never rebuilds with an empty request between two captures, and `LayerWidget` resolves its layer type only in `initState`. A follow-up capture of a different layer type therefore built the previous type and was dropped (`EmojiLayer is not a subtype of TextLayer`). Key the widgets by layer identity. Disposing the rasterizer mid-capture replaced a finished result with "used after being disposed", because `notifyListeners` throws once `dispose` ran. The host mounted layers without the `Theme` and `Material` the editor supplies, so layer content inherited MaterialApp's error text style and Material-based widget layers asserted. Both paths now share the editor's fallback theme. Also assert the one-host and not-mounted-elsewhere rules that duplicate layer GlobalKeys, throw instead of returning an empty list when the host stops rendering mid-capture, default `basePixelRatio` to `configs.imageGeneration.customPixelRatio`, keep captured layers out of the semantics tree, and document that `configs` must match the session that created the layers. --- CHANGELOG.md | 2 +- .../layer/layer_rasterizer_example.dart | 27 +++- lib/features/main_editor/main_editor.dart | 11 +- .../layer_rasterizer/layer_rasterizer.dart | 136 ++++++++++++++-- .../layer_rasterizer_host.dart | 49 ++++-- lib/shared/utils/default_editor_theme.dart | 18 +++ .../layer_rasterizer_host_test.dart | 127 +++++++++++++++ .../layer_rasterizer_test.dart | 151 ++++++++++++++++++ 8 files changed, 477 insertions(+), 44 deletions(-) create mode 100644 lib/shared/utils/default_editor_theme.dart create mode 100644 test/shared/services/layer_rasterizer/layer_rasterizer_host_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3e9308c..cb1f7bee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## 13.3.0 -- **FEAT**(layers): Rasterize layers outside a live editor session via `LayerRasterizer` and `LayerRasterizerHost`. `Layer.captureAsPng` returns `null` for an unmounted layer, so layers restored from an exported state history could not be baked into a render. The host mounts them behind its own child — painted, never visible — so `capture()` returns the same `ExportedLayer`s a live session would. Use `awaitContentReady` for layers whose content loads asynchronously (network images, decoded assets, custom `WidgetLayer`s). +- **FEAT**(layers): Rasterize layers outside a live editor session with `LayerRasterizer` and `LayerRasterizerHost`. ## 13.2.3 - **FIX**(main-editor): A layer sharing its position with others (a stack of overlapping layers) can now be dragged away instead of being trapped by their coincident alignment guides. Snapping re-arms once the layer moves clear. diff --git a/example/lib/features/layer/layer_rasterizer_example.dart b/example/lib/features/layer/layer_rasterizer_example.dart index 4a2a2c4e1..9a26172b5 100644 --- a/example/lib/features/layer/layer_rasterizer_example.dart +++ b/example/lib/features/layer/layer_rasterizer_example.dart @@ -31,6 +31,14 @@ class LayerRasterizerExample extends StatefulWidget { class _LayerRasterizerExampleState extends State { final _rasterizer = LayerRasterizer(); + /// The configuration of the session that produced the layers. + /// + /// Not optional in practice: text, emoji and widget layer sizes are derived + /// from it (`textEditor.initFontSize * layer.scale`, + /// `stickerEditor.initWidth`), so rasterizing with a different configuration + /// than the editor used silently rescales those layers. + final _configs = const ProImageEditorConfigs(); + /// A persisted editor session. Nothing here ever opens the editor — the /// history is parsed and its layers go straight to the rasterizer. final _history = ImportStateHistory.fromMap( @@ -59,9 +67,13 @@ class _LayerRasterizerExampleState extends State { String? _error; int? _durationInMs; - /// The body size the layers were laid out against in the original session. - /// Layer offsets are relative to its center, so passing it is what makes the - /// capture match that session. + /// The size the image was rendered at in the original session. + /// + /// Layer offsets and scales are stored relative to it — that is the size the + /// import path rescales against — and they are measured from its center, so + /// laying the layers out in a box of exactly this size reproduces the + /// original session. It is not the editor's body size, which also covers the + /// letterboxing around the image. Size get _editorBodySize => _history.lastRenderedImgSize; List get _layers { @@ -89,6 +101,7 @@ class _LayerRasterizerExampleState extends State { final captured = await _rasterizer.capture( layers: _layers, editorBodySize: _editorBodySize, + configs: _configs, /// The history contains a widget layer that loads a network image. One /// frame after mounting it is still blank, so we hold the capture @@ -183,7 +196,7 @@ class _LayerRasterizerExampleState extends State { ), Text( 'Layers in the history: ${_layers.length} • ' - 'Original body size: ${_editorBodySize.width.round()}' + 'Original rendered image size: ${_editorBodySize.width.round()}' ' × ${_editorBodySize.height.round()}', style: Theme.of(context).textTheme.bodySmall, ), @@ -215,7 +228,11 @@ class _LayerRasterizerExampleState extends State { Expanded( child: Text( '${captured.length} of ${_layers.length} layers captured ' - 'in $_durationInMs ms', + 'in $_durationInMs ms' + // `capture` drops layers it could not render instead of + // returning a placeholder, so a short result is worth naming. + '${captured.length < _layers.length ? ' — the rest could not ' + 'be rendered' : ''}', style: Theme.of(context).textTheme.bodySmall, ), ), diff --git a/lib/features/main_editor/main_editor.dart b/lib/features/main_editor/main_editor.dart index a0dadcbe9..3db314ce5 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -25,6 +25,7 @@ import '/shared/mixins/editor_zoom.mixin.dart'; import '/shared/services/content_recorder/widgets/content_recorder.dart'; import '/shared/services/import_export/export_state_history.dart'; import '/shared/services/layer_transform_generator.dart'; +import '/shared/utils/default_editor_theme.dart'; import '/shared/utils/file_constructor_utils.dart'; import '/shared/utils/transparent_image_generator_utils.dart'; import '/shared/widgets/adaptive_dialog.dart'; @@ -3165,15 +3166,7 @@ class ProImageEditorState extends State @override Widget build(BuildContext context) { - _theme = - configs.theme ?? - ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue.shade800, - brightness: Brightness.dark, - ), - ); + _theme = configs.theme ?? defaultEditorTheme(); return RecordInvisibleWidget( controller: _controllers.screenshot, diff --git a/lib/shared/services/layer_rasterizer/layer_rasterizer.dart b/lib/shared/services/layer_rasterizer/layer_rasterizer.dart index a1ed43d59..b93e623e3 100644 --- a/lib/shared/services/layer_rasterizer/layer_rasterizer.dart +++ b/lib/shared/services/layer_rasterizer/layer_rasterizer.dart @@ -63,20 +63,35 @@ class LayerRasterizationRequest { /// final history = ImportStateHistory.fromMap(persistedHistory); /// final captured = await rasterizer.capture( /// layers: history.stateHistory[history.editorPosition].layers, -/// editorBodySize: persistedBodySize, +/// editorBodySize: history.lastRenderedImgSize, /// configs: myEditorConfigs, /// ); /// ``` /// /// Concurrent [capture] calls are serialized: only one set of layers is /// mounted at a time, so captures cannot read each other's repaint boundaries. +/// +/// The layers passed to [capture] must not be mounted anywhere else while they +/// are captured — no running editor, no `LayerStack` preview showing the same +/// [Layer] instances. Every layer carries [GlobalKey]s (and a [Hero] tag) that +/// `LayerWidget` attaches, so mounting it twice makes Flutter move the existing +/// element into the host: the visible copy loses its content, and release +/// builds do not report it. Layers already on screen need no host at all — +/// call [Layer.captureAllLayers] on them directly. Both this and the one-host +/// rule are asserted in debug mode. class LayerRasterizer extends ChangeNotifier { LayerRasterizationRequest? _request; int _hostCount = 0; + bool _isDisposed = false; + Future _queue = Future.value(); + /// The layers of the preceding capture. They stay mounted until the host + /// repaints, so a follow-up capture must not mistake them for a conflict. + List _previousLayers = const []; + /// The layers currently waiting to be captured, or `null` when idle. /// /// Read by [LayerRasterizerHost]; not intended for other callers. @@ -88,21 +103,55 @@ class LayerRasterizer extends ChangeNotifier { /// mount the layers into. bool get hasHost => _hostCount > 0; - /// Registers a mounted host. Called by [LayerRasterizerHost]. + /// Registers a mounted host. Called by [LayerRasterizerHost] and not intended + /// for other callers — faking a host makes [capture] wait for layers that are + /// never mounted. void attachHost() => _hostCount++; - /// Unregisters a disposed host. Called by [LayerRasterizerHost]. - void detachHost() => _hostCount--; + /// Unregisters a disposed host. Called by [LayerRasterizerHost] and not + /// intended for other callers. + void detachHost() { + _hostCount--; + assert( + _hostCount >= 0, + 'detachHost() was called more often than attachHost(). Both are managed ' + 'by LayerRasterizerHost and must not be called directly.', + ); + } + + @override + void dispose() { + _isDisposed = true; + super.dispose(); + } + + /// Notifies listeners unless this rasterizer is already disposed. + /// + /// A capture can outlive the widget that owns the rasterizer (navigating away + /// mid-capture), and [ChangeNotifier.notifyListeners] throws once [dispose] + /// ran. Without this guard the notification in `_capture`'s `finally` would + /// replace a successful result with that error. + void _notify() { + if (!_isDisposed) notifyListeners(); + } /// Captures [layers] and returns their rendered bytes with layout metadata. /// /// [editorBodySize] must be the body size the layers were laid out against /// in the original session — offsets are relative to it. /// + /// [configs] must be the configuration of the session that created the + /// layers, not just any configuration: the size of text, emoji and widget + /// layers is derived from it (`textEditor.initFontSize * layer.scale`, + /// `stickerEditor.initWidth`), and `configs.theme` decides which text theme + /// layer content inherits. Capturing with the default configuration rescales + /// every such layer without reporting anything. + /// /// [pixelRatio] and [basePixelRatio] control the output resolution and are - /// forwarded to [Layer.captureAllLayers]. Pass the same `basePixelRatio` the - /// export path uses (typically `configs.imageGeneration.customPixelRatio`) - /// so a captured layer matches the resolution of a live-session export. + /// forwarded to [Layer.captureAllLayers]. [basePixelRatio] defaults to + /// `configs.imageGeneration.customPixelRatio` — the value the editor's own + /// export path passes — so a captured layer matches the resolution of a + /// live-session export. /// /// Layers whose content loads asynchronously — network images, decoded /// assets, custom [WidgetLayer]s — are not painted yet one frame after @@ -111,9 +160,13 @@ class LayerRasterizer extends ChangeNotifier { /// mounted and is followed by another frame before the capture. Only the /// caller knows what its layers load, so there is no useful default. /// - /// Returns an empty list when [layers] is empty. Throws a [StateError] when - /// no [LayerRasterizerHost] is mounted, and must not be called during a - /// build — mounting the layers rebuilds the host. + /// Returns an empty list when [layers] is empty. A layer that cannot be + /// captured is dropped by [Layer.captureAllLayers], so the result can be + /// shorter than [layers]; compare `ExportedLayer.layer` against the input to + /// find out which ones. Throws a [StateError] when no [LayerRasterizerHost] + /// is mounted, or when the host disappears before the layers are captured, + /// and must not be called during a build — mounting the layers rebuilds the + /// host. Future> capture({ required List layers, required Size editorBodySize, @@ -124,6 +177,12 @@ class LayerRasterizer extends ChangeNotifier { ui.ImageByteFormat format = ui.ImageByteFormat.png, Future Function()? awaitContentReady, }) { + // Cheap enough to answer before queueing, so an empty request does not wait + // behind an unrelated capture. + if (layers.isEmpty) { + return Future>.value(const []); + } + final result = _queue.then( (_) => _capture( layers: layers, @@ -152,8 +211,6 @@ class LayerRasterizer extends ChangeNotifier { required ui.ImageByteFormat format, required Future Function()? awaitContentReady, }) async { - if (layers.isEmpty) return const []; - if (!hasHost) { throw StateError( 'LayerRasterizer.capture was called without a mounted ' @@ -162,13 +219,21 @@ class LayerRasterizer extends ChangeNotifier { 'above the call site.', ); } + assert( + _hostCount == 1, + 'LayerRasterizer.capture found $_hostCount mounted ' + 'LayerRasterizerHosts. Every host renders the requested layers, so the ' + 'GlobalKeys a layer carries would be mounted once per host. Mount ' + 'exactly one host per rasterizer.', + ); + assert(_debugLayersAreFree(layers)); _request = LayerRasterizationRequest( layers: layers, editorBodySize: editorBodySize, configs: configs, ); - notifyListeners(); + _notify(); try { // The first frame mounts and lays the layers out, the second guarantees @@ -184,16 +249,57 @@ class LayerRasterizer extends ChangeNotifier { await WidgetsBinding.instance.endOfFrame; } + // The host can be gone by now — navigating away while the capture waits + // for frames unmounts it. Without this every layer would come back null + // and the caller would receive a silently empty list. + if (!hasHost || + layers.every( + (layer) => layer.repaintBoundaryKey.currentContext == null, + )) { + throw StateError( + 'The LayerRasterizerHost holding this rasterizer stopped rendering ' + 'the layers before they were captured. Keep the host mounted and ' + 'visible for the whole capture — a host that is unmounted, offstage ' + 'or inside an invisible subtree never paints the layers.', + ); + } + return await Layer.captureAllLayers( layers: layers, pixelRatio: pixelRatio, - basePixelRatio: basePixelRatio, + basePixelRatio: + basePixelRatio ?? configs.imageGeneration.customPixelRatio, applyTransforms: applyTransforms, format: format, ); } finally { _request = null; - notifyListeners(); + _previousLayers = layers; + _notify(); + } + } + + /// Verifies that none of [layers] is already mounted somewhere else. + /// + /// A mounted layer already owns its [GlobalKey]s, so rendering it a second + /// time inside the host steals them from the visible copy. Layers of the + /// preceding capture are exempt: the host has not repainted since that + /// capture cleared the request, so they are still mounted in the host itself + /// and the rebuild that mounts this request replaces them. + bool _debugLayersAreFree(List layers) { + for (final layer in layers) { + if (layer.repaintBoundaryKey.currentContext == null) continue; + if (_previousLayers.any((other) => identical(other, layer))) continue; + + throw FlutterError( + 'LayerRasterizer.capture was given a layer that is already mounted.\n' + 'Layer ${layer.id} is in the widget tree — an open editor, a ' + 'LayerStack preview or another rasterizer host. Mounting it again ' + 'moves the GlobalKeys it carries into this host, which empties the ' + 'visible copy. Layers that are already mounted need no host: call ' + 'Layer.captureAllLayers on them directly.', + ); } + return true; } } diff --git a/lib/shared/services/layer_rasterizer/layer_rasterizer_host.dart b/lib/shared/services/layer_rasterizer/layer_rasterizer_host.dart index 057e6c25d..c8472499c 100644 --- a/lib/shared/services/layer_rasterizer/layer_rasterizer_host.dart +++ b/lib/shared/services/layer_rasterizer/layer_rasterizer_host.dart @@ -1,7 +1,8 @@ // Flutter imports: -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; // Project imports: +import '/shared/utils/default_editor_theme.dart'; import '/shared/widgets/layer/layer_widget.dart'; import 'layer_rasterizer.dart'; @@ -23,7 +24,9 @@ import 'layer_rasterizer.dart'; /// a capture it renders the requested layers *behind* [child]: they have to be /// painted for their repaint boundaries to hold an image, but [child] covers /// them so they never reach the user. This mirrors how the editor's own -/// screenshot capture stays invisible. +/// screenshot capture stays invisible — which also means [child] has to be +/// opaque and cover the host, otherwise the layers show through for the two +/// frames a capture takes. class LayerRasterizerHost extends StatefulWidget { /// Creates a host that renders whatever [rasterizer] is capturing. const LayerRasterizerHost({ @@ -82,19 +85,37 @@ class _LayerRasterizerHostState extends State { top: 0, width: request.editorBodySize.width, height: request.editorBodySize.height, - child: IgnorePointer( - child: Stack( - fit: StackFit.expand, - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - for (final layer in request.layers) - LayerWidget( - layer: layer, - configs: request.configs, - editorBodySize: request.editorBodySize, + child: ExcludeSemantics( + child: IgnorePointer( + // The editor wraps its layers in this theme and a Material + // (through Scaffold). Layer content reads both — emoji + // metrics come from the text theme, and a WidgetLayer built + // from Material widgets asserts on a missing Material — so + // without them the capture differs from the live session. + child: Theme( + data: request.configs.theme ?? defaultEditorTheme(), + child: Material( + type: MaterialType.transparency, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + for (final layer in request.layers) + LayerWidget( + // Keyed by layer identity: without it the + // element of a queued capture is reused for the + // next one, and LayerWidget resolves the layer + // type only in initState. + key: ObjectKey(layer), + layer: layer, + configs: request.configs, + editorBodySize: request.editorBodySize, + ), + ], ), - ], + ), + ), ), ), ), diff --git a/lib/shared/utils/default_editor_theme.dart b/lib/shared/utils/default_editor_theme.dart new file mode 100644 index 000000000..1bb0e422d --- /dev/null +++ b/lib/shared/utils/default_editor_theme.dart @@ -0,0 +1,18 @@ +// Flutter imports: +import 'package:flutter/material.dart'; + +/// The theme the editor falls back to when `ProImageEditorConfigs.theme` is +/// `null`. +/// +/// Layer content inherits its text theme from the surrounding [Theme], so +/// anything that renders layers outside the editor has to install the same +/// fallback to get the same result. +ThemeData defaultEditorTheme() { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue.shade800, + brightness: Brightness.dark, + ), + ); +} diff --git a/test/shared/services/layer_rasterizer/layer_rasterizer_host_test.dart b/test/shared/services/layer_rasterizer/layer_rasterizer_host_test.dart new file mode 100644 index 000000000..f3431fc68 --- /dev/null +++ b/test/shared/services/layer_rasterizer/layer_rasterizer_host_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:pro_image_editor/shared/utils/default_editor_theme.dart'; +import 'package:pro_image_editor/shared/widgets/layer/layer_widget.dart'; + +/// Drives the host directly so a request can be replaced without the frames a +/// real capture waits for — which is exactly what two queued captures do. +class _ManualRasterizer extends LayerRasterizer { + LayerRasterizationRequest? _request; + + @override + LayerRasterizationRequest? get request => _request; + + void emit(LayerRasterizationRequest? value) { + _request = value; + notifyListeners(); + } +} + +void main() { + group(LayerRasterizerHost, () { + const bodySize = Size(300, 500); + + LayerRasterizationRequest requestFor(List layers) { + return LayerRasterizationRequest( + layers: layers, + editorBodySize: bodySize, + configs: const ProImageEditorConfigs(), + ); + } + + Widget buildHost(LayerRasterizer rasterizer) { + return MaterialApp( + home: LayerRasterizerHost( + rasterizer: rasterizer, + child: const ColoredBox( + color: Colors.white, + child: SizedBox.expand(), + ), + ), + ); + } + + testWidgets('rebuilds layers of a follow-up request from scratch', ( + tester, + ) async { + final rasterizer = _ManualRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget(buildHost(rasterizer)); + + rasterizer.emit(requestFor([TextLayer(text: 'hello')])); + await tester.pump(); + expect(tester.takeException(), isNull); + + // A queued capture replaces the layers without the host ever rebuilding + // with no request in between. Unkeyed, the LayerWidget element would be + // reused and keep the layer type it resolved in `initState`. + rasterizer.emit(requestFor([EmojiLayer(emoji: '😀')])); + await tester.pump(); + + expect( + tester.takeException(), + isNull, + reason: 'the emoji layer must not be built as the previous text layer', + ); + expect(find.byType(LayerWidget), findsOneWidget); + }); + + testWidgets('gives the layers the editor theme and a Material ancestor', ( + tester, + ) async { + final rasterizer = _ManualRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget(buildHost(rasterizer)); + rasterizer.emit(requestFor([TextLayer(text: 'hello')])); + await tester.pump(); + + final context = tester.element(find.byType(LayerWidget)); + expect( + Material.maybeOf(context), + isNotNull, + reason: 'widget layers built from Material widgets assert without one', + ); + expect( + Theme.of(context).brightness, + defaultEditorTheme().brightness, + reason: 'emoji metrics are read from the editor theme, not the app one', + ); + expect( + DefaultTextStyle.of(context).style.fontFamily, + isNot('monospace'), + reason: "MaterialApp's 48px error text style must not leak into layers", + ); + }); + + testWidgets('hides the captured layers from the semantics tree', ( + tester, + ) async { + final rasterizer = _ManualRasterizer(); + addTearDown(rasterizer.dispose); + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildHost(rasterizer)); + // IgnorePointer only blocks user actions, it keeps the subtree in the + // semantics tree — so a screen reader would announce layers the user + // cannot even see. + rasterizer.emit( + requestFor([ + WidgetLayer( + widget: Semantics( + label: 'secret', + child: const SizedBox.square(dimension: 20), + ), + ), + ]), + ); + await tester.pump(); + + expect(find.byType(LayerWidget), findsOneWidget); + expect(find.bySemanticsLabel('secret'), findsNothing); + handle.dispose(); + }); + }); +} diff --git a/test/shared/services/layer_rasterizer/layer_rasterizer_test.dart b/test/shared/services/layer_rasterizer/layer_rasterizer_test.dart index 04ed92a34..f8971b842 100644 --- a/test/shared/services/layer_rasterizer/layer_rasterizer_test.dart +++ b/test/shared/services/layer_rasterizer/layer_rasterizer_test.dart @@ -112,6 +112,157 @@ void main() { ); }); + testWidgets('keeps its result when disposed mid-capture', (tester) async { + // No `addTearDown(dispose)`: this test disposes it itself. + final rasterizer = LayerRasterizer(); + + await tester.pumpWidget(buildHost(rasterizer)); + + final capture = rasterizer.capture( + layers: [textLayerMock], + editorBodySize: bodySize, + format: ui.ImageByteFormat.rawRgba, + ); + + await tester.pump(); + await tester.pump(); + // Navigating away disposes the rasterizer while the capture runs. The + // notification the capture ends with must not turn the result into a + // "used after being disposed" error. + rasterizer.dispose(); + + final captured = await tester.runAsync(() => capture); + expect(captured, hasLength(1)); + }); + + testWidgets('throws when the host stops rendering mid-capture', ( + tester, + ) async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget(buildHost(rasterizer)); + + final capture = rasterizer.capture( + layers: [textLayerMock], + editorBodySize: bodySize, + format: ui.ImageByteFormat.rawRgba, + ); + + await tester.pump(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + + await expectLater( + capture, + throwsA(isStateError), + reason: + 'an unmounted host captures nothing, so failing beats ' + 'returning an empty list', + ); + }); + + testWidgets('asserts when two hosts share one rasterizer', (tester) async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget( + MaterialApp( + home: LayerRasterizerHost( + rasterizer: rasterizer, + child: LayerRasterizerHost( + rasterizer: rasterizer, + child: const ColoredBox( + color: Colors.white, + child: SizedBox.expand(), + ), + ), + ), + ), + ); + + // Both hosts would render the same layer, so its GlobalKeys would be + // mounted twice. + await expectLater( + rasterizer.capture(layers: [textLayerMock], editorBodySize: bodySize), + throwsAssertionError, + ); + }); + + testWidgets('asserts when a layer is already mounted elsewhere', ( + tester, + ) async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget( + MaterialApp( + home: LayerRasterizerHost( + rasterizer: rasterizer, + child: Stack( + children: [ + LayerWidget( + layer: textLayerMock, + configs: const ProImageEditorConfigs(), + editorBodySize: bodySize, + ), + ], + ), + ), + ), + ); + + await expectLater( + rasterizer.capture(layers: [textLayerMock], editorBodySize: bodySize), + throwsAssertionError, + ); + }); + + testWidgets('stays usable after a failed capture', (tester) async { + final rasterizer = LayerRasterizer(); + addTearDown(rasterizer.dispose); + + await tester.pumpWidget(buildHost(rasterizer)); + + final failing = rasterizer.capture( + layers: [textLayerMock], + editorBodySize: bodySize, + format: ui.ImageByteFormat.rawRgba, + awaitContentReady: () async => throw StateError('content failed'), + ); + + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + await expectLater(failing, throwsStateError); + + await tester.pump(); + expect( + find.byType(LayerWidget), + findsNothing, + reason: 'a failed capture must still unmount its layers', + ); + + final second = rasterizer.capture( + layers: [textLayerMock], + editorBodySize: bodySize, + format: ui.ImageByteFormat.rawRgba, + ); + + // The queued capture only starts once the failed one settled, so it takes + // a frame more than a capture that runs on its own. + await tester.pump(); + await tester.pump(); + expect( + find.byType(LayerWidget), + findsOneWidget, + reason: 'the queue must not stay blocked by the failed capture', + ); + + await tester.pump(); + expect(await tester.runAsync(() => second), hasLength(1)); + }); + testWidgets('awaits awaitContentReady before capturing', (tester) async { final rasterizer = LayerRasterizer(); addTearDown(rasterizer.dispose);