From 00beacdd8ceb70f731563d99da0f2db7eda8a816 Mon Sep 17 00:00:00 2001 From: hm21 Date: Wed, 1 Apr 2026 21:09:07 +0200 Subject: [PATCH 1/5] feat(export): implement layer export functionality as PNG images --- .vscode/settings.json | 4 +- example/analysis_options.yaml | 2 + .../features/layer/layer_export_example.dart | 251 ++++++++++++++++++ .../lib/features/layer/layer_group_page.dart | 7 + .../Flutter/GeneratedPluginRegistrant.swift | 2 - .../xcshareddata/swiftpm/Package.resolved | 4 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- lib/core/models/layers/exported_layer.dart | 27 ++ lib/core/models/layers/layer.dart | 212 +++++++++++++++ lib/features/main_editor/main_editor.dart | 49 ++++ lib/pro_image_editor.dart | 1 + .../content_recorder_controller.dart | 4 + .../services/image_converter_service.dart | 34 ++- lib/shared/widgets/layer/layer_widget.dart | 2 +- 14 files changed, 588 insertions(+), 15 deletions(-) create mode 100644 example/lib/features/layer/layer_export_example.dart create mode 100644 lib/core/models/layers/exported_layer.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index b385019c9..fdd8b8593 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "Appbar", "bbox", "Bezier", "Cooldown", @@ -25,5 +26,6 @@ "plaintext": false, "markdown": false, "scminput": false - } + }, + "cSpell.diagnosticLevel": "Hint" } \ No newline at end of file diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 2565d98cf..f3369bc69 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -6,6 +6,8 @@ analyzer: strict-raw-types: true errors: close_sinks: ignore + exclude: + - build/** linter: rules: diff --git a/example/lib/features/layer/layer_export_example.dart b/example/lib/features/layer/layer_export_example.dart new file mode 100644 index 000000000..c24d469ad --- /dev/null +++ b/example/lib/features/layer/layer_export_example.dart @@ -0,0 +1,251 @@ +// Dart imports: +import 'dart:math'; + +// 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/mixin/example_helper.dart'; + +/// Demonstrates how to export individual layers as PNG images. +/// +/// Each layer has a [RepaintBoundary] with a [GlobalKey] attached, accessible +/// via [Layer.repaintBoundaryKey]. Use [Layer.captureAsPng] to capture the +/// visual content of any mounted layer. +class LayerExportExample extends StatefulWidget { + /// Creates a new [LayerExportExample] widget. + const LayerExportExample({super.key}); + + @override + State createState() => _LayerExportExampleState(); +} + +class _LayerExportExampleState extends State + with ExampleHelperState { + @override + void initState() { + super.initState(); + preCacheImage(assetPath: kImageEditorExampleAssetPath); + } + + Future _exportLayers( + ProImageEditorState editor, { + required bool overlay, + }) async { + final layers = editor.activeLayers; + if (layers.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No layers to export.')), + ); + return; + } + + final bodySize = editor.sizesManager.bodySize; + final exported = await editor.captureAllLayersWithMeta( + applyTransforms: false, + ); + + if (!mounted) return; + + if (overlay) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + pageBuilder: (_, __, ___) => _ExportedLayersOverlay( + layers: exported, + editorBodySize: bodySize, + ), + ), + ); + } else { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _ExportedLayersPreview( + layers: exported, + editorBodySize: bodySize, + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + if (!isPreCached) return const PrepareImageWidget(); + + return ProImageEditor.asset( + kImageEditorExampleAssetPath, + key: editorKey, + callbacks: ProImageEditorCallbacks( + onImageEditingStarted: onImageEditingStarted, + onImageEditingComplete: onImageEditingComplete, + onCloseEditor: (editorMode) => onCloseEditor( + editorMode: editorMode, + enablePop: !isDesktopMode(context), + ), + mainEditorCallbacks: MainEditorCallbacks( + helperLines: HelperLinesCallbacks(onLineHit: vibrateLineHit), + ), + ), + configs: ProImageEditorConfigs( + designMode: platformDesignMode, + mainEditor: MainEditorConfigs( + enableCloseButton: !isDesktopMode(context), + widgets: MainEditorWidgets( + appBar: (editor, rebuildStream) => ReactiveAppbar( + stream: rebuildStream, + builder: (_) => AppBar( + automaticallyImplyLeading: false, + foregroundColor: + Theme.of(context).appBarTheme.foregroundColor ?? + Colors.white, + backgroundColor: Colors.black, + title: const Text('Layer Export'), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.visibility), + tooltip: 'Export all layers as PNG', + onSelected: (overlay) => + _exportLayers(editor, overlay: overlay), + itemBuilder: (_) => const [ + PopupMenuItem( + value: false, + child: Text('Preview (new page)'), + ), + PopupMenuItem( + value: true, + child: Text('Overlay (50% opacity)'), + ), + ], + ), + ], + ), + ), + ), + ), + imageGeneration: const ImageGenerationConfigs( + processorConfigs: ProcessorConfigs( + processorMode: ProcessorMode.auto, + ), + ), + ), + ); + } +} + +class _ExportedLayersOverlay extends StatelessWidget { + const _ExportedLayersOverlay({ + required this.layers, + required this.editorBodySize, + }); + final List layers; + final Size editorBodySize; + + @override + Widget build(BuildContext context) { + final halfWidth = editorBodySize.width / 2; + final halfHeight = editorBodySize.height / 2; + + return GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Scaffold( + backgroundColor: Colors.transparent, + body: Center( + child: Opacity( + opacity: 0.5, + child: SizedBox( + width: editorBodySize.width, + height: editorBodySize.height, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final item in layers) + Positioned( + left: item.layer.offset.dx + halfWidth, + top: item.layer.offset.dy + halfHeight, + child: FractionalTranslation( + translation: const Offset(-0.5, -0.5), + child: Transform( + transform: Matrix4.identity() + ..rotateX(item.layer.flipY ? pi : 0) + ..rotateY(item.layer.flipX ? pi : 0) + ..rotateZ(item.layer.rotation), + alignment: Alignment.center, + child: Image.memory( + item.bytes, + width: item.logicalSize.width, + height: item.logicalSize.height, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _ExportedLayersPreview extends StatelessWidget { + const _ExportedLayersPreview({ + required this.layers, + required this.editorBodySize, + }); + final List layers; + final Size editorBodySize; + + @override + Widget build(BuildContext context) { + final halfWidth = editorBodySize.width / 2; + final halfHeight = editorBodySize.height / 2; + + return Scaffold( + appBar: AppBar(title: const Text('Exported Layers')), + body: Center( + child: SizedBox( + width: editorBodySize.width, + height: editorBodySize.height, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Image.asset( + kImageEditorExampleAssetPath, + fit: BoxFit.contain, + ), + ), + for (final item in layers) + Positioned( + left: item.layer.offset.dx + halfWidth, + top: item.layer.offset.dy + halfHeight, + child: FractionalTranslation( + translation: const Offset(-0.5, -0.5), + child: Transform( + transform: Matrix4.identity() + ..rotateX(item.layer.flipY ? pi : 0) + ..rotateY(item.layer.flipX ? pi : 0) + ..rotateZ(item.layer.rotation), + alignment: Alignment.center, + child: Image.memory( + item.bytes, + width: item.logicalSize.width, + height: item.logicalSize.height, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/features/layer/layer_group_page.dart b/example/lib/features/layer/layer_group_page.dart index 88750cd8d..43f5cc0a7 100644 --- a/example/lib/features/layer/layer_group_page.dart +++ b/example/lib/features/layer/layer_group_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '/features/layer/layer_export_example.dart'; import '/features/layer/layer_grouping_example.dart'; import '/features/layer/layer_select_design_example.dart'; import '/features/layer/selectable_layer_example.dart'; @@ -42,6 +43,12 @@ class _LayerGroupPageState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _openExample(const SelectableLayerExample()), ), + ListTile( + leading: const Icon(Icons.image_outlined), + title: const Text('Export Layers as PNG'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openExample(const LayerExportExample()), + ), ], ), ); diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index cb482ad01..e56b69199 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -17,7 +17,6 @@ import gal import media_kit_libs_macos_video import media_kit_video import package_info_plus -import path_provider_foundation import pro_image_editor import pro_video_editor import shared_preferences_foundation @@ -39,7 +38,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ProImageEditorPlugin.register(with: registry.registrar(forPlugin: "ProImageEditorPlugin")) ProVideoEditorPlugin.register(with: registry.registrar(forPlugin: "ProVideoEditorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 894906409..210fd8f30 100644 --- a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/flutterfire", "state" : { - "revision" : "51f0bc14786bd83120d942df340722b4cef93032", - "version" : "4.5.0-firebase-core-swift" + "revision" : "a10a4148e769fadb01b1ff8d6bb76e9137f35b81", + "version" : "4.6.0-firebase-core-swift" } }, { diff --git a/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 894906409..210fd8f30 100644 --- a/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/example/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/flutterfire", "state" : { - "revision" : "51f0bc14786bd83120d942df340722b4cef93032", - "version" : "4.5.0-firebase-core-swift" + "revision" : "a10a4148e769fadb01b1ff8d6bb76e9137f35b81", + "version" : "4.6.0-firebase-core-swift" } }, { diff --git a/lib/core/models/layers/exported_layer.dart b/lib/core/models/layers/exported_layer.dart new file mode 100644 index 000000000..fd78b6c6b --- /dev/null +++ b/lib/core/models/layers/exported_layer.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'layer.dart'; + +/// Contains one exported layer with its encoded bytes and layout metadata. +class ExportedLayer { + /// Creates an [ExportedLayer] instance. + const ExportedLayer({ + required this.layer, + required this.bytes, + required this.logicalSize, + }); + + /// The source layer that was exported. + final Layer layer; + + /// Encoded image bytes for this layer. + final Uint8List bytes; + + /// The logical size of the layer's content as laid out in the widget tree. + /// + /// This already includes the layer's scale factor (text layers bake it into + /// font size, paint layers into the canvas size, etc.), so it matches the + /// unrotated size the layer occupies in the editor. + final Size logicalSize; +} diff --git a/lib/core/models/layers/layer.dart b/lib/core/models/layers/layer.dart index cf190de80..ffbc03f83 100644 --- a/lib/core/models/layers/layer.dart +++ b/lib/core/models/layers/layer.dart @@ -1,11 +1,18 @@ +// Dart imports: +import 'dart:math' as math; +import 'dart:ui' as ui; + // Flutter imports: import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import '/core/constants/int_constants.dart'; +import '/core/models/editor_configs/image_generation_configs/image_generation_configs.dart'; import '/shared/extensions/box_constraints_extension.dart'; import '/shared/extensions/export_bool_extension.dart'; import '/shared/extensions/num_extension.dart'; +import '/shared/services/content_recorder/controllers/content_recorder_controller.dart'; import '/shared/services/import_export/types/widget_loader.dart'; import '/shared/services/import_export/utils/key_minifier.dart'; import '/shared/utils/map_utils.dart'; @@ -14,6 +21,7 @@ import '/shared/utils/parser/double_parser.dart'; import '/shared/utils/unique_id_generator.dart'; import '../editor_image.dart'; import 'emoji_layer.dart'; +import 'exported_layer.dart'; import 'layer_interaction.dart'; import 'paint_layer.dart'; import 'text_layer.dart'; @@ -41,6 +49,7 @@ class Layer { this.groupId, }) : key = key ??= GlobalKey(), keyInternalSize = GlobalKey(), + repaintBoundaryKey = GlobalKey(), id = id ?? generateUniqueId(), interaction = interaction ?? LayerInteraction(); @@ -133,6 +142,12 @@ class Layer { /// A global key used to get the layer size. GlobalKey keyInternalSize; + /// A global key attached to the layer's [RepaintBoundary]. + /// + /// This key can be used to capture the layer's visual content as a PNG + /// image via [captureAsPng]. + GlobalKey repaintBoundaryKey; + /// The position offset of the widget. Offset offset; @@ -246,6 +261,203 @@ class Layer { }; } + /// Captures the visual content of this layer as a PNG-encoded byte array. + /// + /// The layer must be mounted in the widget tree with its + /// [repaintBoundaryKey] attached to a [RepaintBoundary]. The [pixelRatio] + /// controls the resolution of the output image. When `null`, it defaults + /// to `devicePixelRatio * scale` to preserve sharpness for scaled and + /// rotated layers. + /// + /// The [format] controls the output byte format and defaults to PNG for + /// backward compatibility. + /// + /// When [applyTransforms] is `true` (default), the layer's [rotation], + /// [flipX] and [flipY] are applied to the output image. Set it to `false` + /// to get the raw, un-transformed content. + /// + /// Returns `null` if the layer is not currently mounted. + Future captureAsPng({ + double? pixelRatio, + bool applyTransforms = true, + ui.ImageByteFormat format = ui.ImageByteFormat.png, + ContentRecorderController? recorder, + }) async { + final context = repaintBoundaryKey.currentContext; + if (context == null) return null; + + final dpr = MediaQuery.maybeDevicePixelRatioOf(context) ?? 3.0; + final effectivePixelRatio = pixelRatio ?? (dpr * scale); + + final boundary = context.findRenderObject() as RenderRepaintBoundary; + final rawImage = await boundary.toImage(pixelRatio: effectivePixelRatio); + + final bool needsTransform = + applyTransforms && (rotation != 0 || flipX || flipY); + if (!needsTransform) { + final bytes = await _encodeLayerImage( + rawImage, + format: format, + recorder: recorder, + ); + rawImage.dispose(); + return bytes; + } + + final double w = rawImage.width.toDouble(); + final double h = rawImage.height.toDouble(); + + final double cosR = math.cos(rotation).abs(); + final double sinR = math.sin(rotation).abs(); + final double newW = w * cosR + h * sinR; + final double newH = w * sinR + h * cosR; + + final pictureRecorder = ui.PictureRecorder(); + final canvas = Canvas(pictureRecorder, Rect.fromLTWH(0, 0, newW, newH)) + ..translate(newW / 2, newH / 2); + if (flipX) canvas.scale(-1, 1); + if (flipY) canvas.scale(1, -1); + canvas + ..rotate(rotation) + ..translate(-w / 2, -h / 2) + ..drawImage(rawImage, Offset.zero, Paint()); + + final picture = pictureRecorder.endRecording(); + final transformed = await picture.toImage(newW.ceil(), newH.ceil()); + rawImage.dispose(); + picture.dispose(); + + final bytes = await _encodeLayerImage( + transformed, + format: format, + recorder: recorder, + ); + transformed.dispose(); + + return bytes; + } + + /// Exports multiple layers in one run and reuses a single recorder for PNG + /// encoding to avoid repeatedly creating and destroying isolate resources. + /// + /// If [format] is PNG and [recorder] is not provided, this method creates + /// one recorder internally and reuses it for all layers. + static Future> captureAllLayersAsBytes({ + required List layers, + double? pixelRatio, + bool applyTransforms = true, + ui.ImageByteFormat format = ui.ImageByteFormat.png, + ContentRecorderController? recorder, + }) async { + ContentRecorderController? localRecorder; + ContentRecorderController? sharedRecorder = recorder; + + if (format == ui.ImageByteFormat.png && sharedRecorder == null) { + sharedRecorder = _createPngRecorderController(); + localRecorder = sharedRecorder; + } + + try { + final bytes = []; + for (final layer in layers) { + bytes.add( + await layer.captureAsPng( + pixelRatio: pixelRatio, + applyTransforms: applyTransforms, + format: format, + recorder: sharedRecorder, + ), + ); + } + return bytes; + } finally { + if (localRecorder != null) { + await localRecorder.destroy(); + } + } + } + + /// Exports multiple layers in one run and returns metadata per exported + /// layer. + static Future> captureAllLayers({ + required List layers, + double? pixelRatio, + bool applyTransforms = true, + ui.ImageByteFormat format = ui.ImageByteFormat.png, + ContentRecorderController? recorder, + }) async { + final logicalSizes = [ + for (final layer in layers) + (layer.repaintBoundaryKey.currentContext?.findRenderObject() + as RenderBox?) + ?.size ?? + Size.zero, + ]; + + final allBytes = await captureAllLayersAsBytes( + layers: layers, + pixelRatio: pixelRatio, + applyTransforms: applyTransforms, + format: format, + recorder: recorder, + ); + + final exported = []; + for (var i = 0; i < layers.length; i++) { + final bytes = i < allBytes.length ? allBytes[i] : null; + if (bytes == null) continue; + exported.add( + ExportedLayer( + layer: layers[i], + bytes: bytes, + logicalSize: logicalSizes[i], + ), + ); + } + + return exported; + } + + static ContentRecorderController _createPngRecorderController() { + return ContentRecorderController( + isVideoEditor: false, + configs: const ImageGenerationConfigs( + outputFormat: OutputFormat.png, + processorConfigs: ProcessorConfigs( + processorMode: ProcessorMode.minimum, + ), + ), + ); + } + + Future _encodeLayerImage( + ui.Image image, { + required ui.ImageByteFormat format, + ContentRecorderController? recorder, + }) async { + if (format != ui.ImageByteFormat.png) { + final byteData = await image.toByteData(format: format); + return byteData?.buffer.asUint8List(); + } + + ContentRecorderController? localRecorder; + final activeRecorder = + recorder ?? (localRecorder = _createPngRecorderController()); + + try { + return await activeRecorder.convertRawImageData( + image: image, + id: generateUniqueId(), + outputFormat: OutputFormat.png, + cropToDrawingBounds: false, + ); + } finally { + if (localRecorder != null) { + await localRecorder.destroy(); + } + } + } + RenderBox? get _renderBox { final renderObj = keyInternalSize.currentContext?.findRenderObject(); return renderObj is RenderBox ? renderObj : null; diff --git a/lib/features/main_editor/main_editor.dart b/lib/features/main_editor/main_editor.dart index 5699a78b5..e377ac550 100644 --- a/lib/features/main_editor/main_editor.dart +++ b/lib/features/main_editor/main_editor.dart @@ -2442,6 +2442,55 @@ class ProImageEditorState extends State Uint8List.fromList([]); } + /// Captures all active layers in one batch. + /// + /// For PNG exports, this method reuses the editor's existing + /// [ContentRecorderController] so isolate resources stay warm and can be + /// processed efficiently across all layers. + Future> captureAllLayers({ + double? pixelRatio, + bool applyTransforms = true, + ui.ImageByteFormat format = ui.ImageByteFormat.png, + }) async { + final exported = await captureAllLayersWithMeta( + pixelRatio: pixelRatio, + applyTransforms: applyTransforms, + format: format, + ); + return exported.map((e) => e.bytes).toList(); + } + + /// Captures all active layers in one batch and returns metadata per layer. + /// + /// For PNG exports, this method reuses the editor's existing + /// [ContentRecorderController] so isolate resources stay warm and can be + /// processed efficiently across all layers. + Future> captureAllLayersWithMeta({ + double? pixelRatio, + bool applyTransforms = true, + ui.ImageByteFormat format = ui.ImageByteFormat.png, + }) async { + if (isSubEditorOpen) { + Navigator.pop(context); + if (!_pageOpenCompleter.isCompleted) await _pageOpenCompleter.future; + if (!mounted) return []; + } + + // Ensure the current frame with layers is fully rendered before capture. + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return []; + + return Layer.captureAllLayers( + layers: activeLayers, + pixelRatio: pixelRatio, + applyTransforms: applyTransforms, + format: format, + recorder: format == ui.ImageByteFormat.png + ? _controllers.screenshot + : null, + ); + } + /// Closes all active sub-editors within the main editor, including paint, /// text, crop/rotate, filter, tune, and emoji editors. /// This ensures that any open sub-editor is properly closed and the main diff --git a/lib/pro_image_editor.dart b/lib/pro_image_editor.dart index f4f3c28c8..61212ce54 100644 --- a/lib/pro_image_editor.dart +++ b/lib/pro_image_editor.dart @@ -39,6 +39,7 @@ export 'core/models/init_configs/tune_editor_init_configs.dart'; /// Various export '/core/models/complete_parameters.dart'; export 'core/models/layers/layer.dart'; +export 'core/models/layers/exported_layer.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/content_recorder/controllers/content_recorder_controller.dart b/lib/shared/services/content_recorder/controllers/content_recorder_controller.dart index 17588693f..4a62c0171 100644 --- a/lib/shared/services/content_recorder/controllers/content_recorder_controller.dart +++ b/lib/shared/services/content_recorder/controllers/content_recorder_controller.dart @@ -113,10 +113,14 @@ class ContentRecorderController { Future convertRawImageData({ required ui.Image image, String? id, + OutputFormat? outputFormat, + bool? cropToDrawingBounds, }) { return _imageConverterService.convert( image: image, id: id ?? generateUniqueId(), + format: outputFormat, + cropToDrawingBounds: cropToDrawingBounds, ); } diff --git a/lib/shared/services/content_recorder/services/image_converter_service.dart b/lib/shared/services/content_recorder/services/image_converter_service.dart index dc269aef6..c0344873e 100644 --- a/lib/shared/services/content_recorder/services/image_converter_service.dart +++ b/lib/shared/services/content_recorder/services/image_converter_service.dart @@ -62,27 +62,43 @@ class ImageConverterService { required ui.Image image, required String id, OutputFormat? format, + bool? cropToDrawingBounds, }) async { format ??= configs.outputFormat; + final crop = cropToDrawingBounds ?? configs.cropToDrawingBounds; if (configs.enableIsolateGeneration) { try { /// For the case multithreading isn't supported we fall back to the /// main thread. if (!threadManager.isSupported) { - return await _convertOnMainThread(image: image); + return await _convertOnMainThread( + image: image, + cropToDrawingBounds: crop, + ); } return await threadManager.send( - await _generateSendImageData(id: id, image: image, format: format), + await _generateSendImageData( + id: id, + image: image, + format: format, + cropToDrawingBounds: crop, + ), ); } catch (e) { // Fallback to the main thread. debugPrint('Fallback to main thread: $e'); - return await _convertOnMainThread(image: image); + return await _convertOnMainThread( + image: image, + cropToDrawingBounds: crop, + ); } } else { - return await _convertOnMainThread(image: image); + return await _convertOnMainThread( + image: image, + cropToDrawingBounds: crop, + ); } } @@ -95,8 +111,11 @@ class ImageConverterService { /// /// Returns a `Uint8List` containing the converted image data or `null` /// if the conversion fails. - Future _convertOnMainThread({required ui.Image image}) async { - if (configs.cropToDrawingBounds) { + Future _convertOnMainThread({ + required ui.Image image, + required bool cropToDrawingBounds, + }) async { + if (cropToDrawingBounds) { image = await dartUiRemoveTransparentImgAreas(image) ?? image; } return await encodeImageFromThreadRequest( @@ -132,10 +151,11 @@ class ImageConverterService { required ui.Image image, required String id, required OutputFormat format, + required bool cropToDrawingBounds, }) async { return ImageConvertThreadRequest( id: id, - generateOnlyImageBounds: configs.cropToDrawingBounds, + generateOnlyImageBounds: cropToDrawingBounds, outputFormat: format, jpegChroma: configs.jpegChroma, jpegQuality: configs.jpegQuality, diff --git a/lib/shared/widgets/layer/layer_widget.dart b/lib/shared/widgets/layer/layer_widget.dart index 2d866a01f..4f5523974 100644 --- a/lib/shared/widgets/layer/layer_widget.dart +++ b/lib/shared/widgets/layer/layer_widget.dart @@ -462,7 +462,7 @@ class _LayerWidgetState extends State ); } - return content; + return RepaintBoundary(key: _layer.repaintBoundaryKey, child: content); } @override From 9523fc3dd87d7e5fe368b45463e703c7baec28ad Mon Sep 17 00:00:00 2001 From: hm21 Date: Wed, 1 Apr 2026 22:23:54 +0200 Subject: [PATCH 2/5] fix(export): await Navigator push for correct overlay navigation --- example/lib/features/layer/layer_export_example.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/lib/features/layer/layer_export_example.dart b/example/lib/features/layer/layer_export_example.dart index c24d469ad..5253717c0 100644 --- a/example/lib/features/layer/layer_export_example.dart +++ b/example/lib/features/layer/layer_export_example.dart @@ -53,7 +53,7 @@ class _LayerExportExampleState extends State if (!mounted) return; if (overlay) { - Navigator.of(context).push( + await Navigator.of(context).push( PageRouteBuilder( opaque: false, pageBuilder: (_, __, ___) => _ExportedLayersOverlay( From 9964523766e5377bd8f627135eacfdde9aa56937 Mon Sep 17 00:00:00 2001 From: hm21 Date: Wed, 1 Apr 2026 22:34:00 +0200 Subject: [PATCH 3/5] feat(video-editor): enhance export model to support audio tracks and image layers --- .../mixins/video_editor_mixin.dart | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/example/lib/features/video_examples/mixins/video_editor_mixin.dart b/example/lib/features/video_examples/mixins/video_editor_mixin.dart index bc1434a9b..d9c53b59f 100644 --- a/example/lib/features/video_examples/mixins/video_editor_mixin.dart +++ b/example/lib/features/video_examples/mixins/video_editor_mixin.dart @@ -385,11 +385,18 @@ mixin VideoEditorMixin on State { var exportModel = VideoRenderData( id: taskId, - video: useSegments ? null : video, - videoSegments: useSegments ? videoSegments : null, - imageBytes: parameters.layers.isNotEmpty ? parameters.image : null, + videoSegments: useSegments + ? videoSegments + .map((video) => + video.copyWith(volume: audioVolumes.originalVolume)) + .toList() + : [VideoSegment(video: video, volume: audioVolumes.originalVolume)], + imageLayers: [ + if (parameters.layers.isNotEmpty) + ImageLayer(image: EditorLayerImage.memory(parameters.image)) + ], blur: parameters.blur, - colorMatrixList: [parameters.colorFiltersCombined], + colorFilters: [ColorFilter(matrix: parameters.colorFiltersCombined)], startTime: useSegments ? null : parameters.startTime, endTime: useSegments ? null : parameters.endTime, transform: parameters.isTransformed @@ -406,9 +413,13 @@ mixin VideoEditorMixin on State { enableAudio: proVideoController?.isAudioEnabled ?? true, outputFormat: outputFormat, bitrate: videoMetadata.bitrate, - customAudioPath: customAudioPath, - originalAudioVolume: audioVolumes.originalVolume, - customAudioVolume: audioVolumes.customVolume, + audioTracks: [ + if (customAudioPath != null) + VideoAudioTrack( + path: customAudioPath, + volume: audioVolumes.customVolume, + ) + ], ); final now = DateTime.now().millisecondsSinceEpoch; From b823d8f948c46e39c97e099c5de6438ecf05fee8 Mon Sep 17 00:00:00 2001 From: hm21 Date: Wed, 1 Apr 2026 22:38:41 +0200 Subject: [PATCH 4/5] feat(tests): add unit tests for ExportedLayer and Layer capture functionality --- .../models/layers/exported_layer_test.dart | 87 ++++++++ .../models/layers/layer_capture_test.dart | 202 ++++++++++++++++++ .../content_recorder_controller_test.dart | 114 ++++++++++ 3 files changed, 403 insertions(+) create mode 100644 test/core/models/layers/exported_layer_test.dart create mode 100644 test/core/models/layers/layer_capture_test.dart create mode 100644 test/shared/services/content_recorder/content_recorder_controller_test.dart diff --git a/test/core/models/layers/exported_layer_test.dart b/test/core/models/layers/exported_layer_test.dart new file mode 100644 index 000000000..3a034580f --- /dev/null +++ b/test/core/models/layers/exported_layer_test.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/core/models/layers/exported_layer.dart'; +import 'package:pro_image_editor/core/models/layers/layer.dart'; +import 'package:pro_image_editor/features/paint_editor/paint_editor.dart'; + +void main() { + group('ExportedLayer', () { + test('stores all constructor parameters', () { + final layer = TextLayer(text: 'Hello'); + final bytes = Uint8List.fromList([1, 2, 3]); + const size = Size(100, 50); + + final exported = ExportedLayer( + layer: layer, + bytes: bytes, + logicalSize: size, + ); + + expect(exported.layer, same(layer)); + expect(exported.bytes, same(bytes)); + expect(exported.logicalSize, size); + }); + + test('works with EmojiLayer', () { + final layer = EmojiLayer(emoji: '😀'); + final bytes = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47]); + const size = Size(48, 48); + + final exported = ExportedLayer( + layer: layer, + bytes: bytes, + logicalSize: size, + ); + + expect(exported.layer, isA()); + expect((exported.layer as EmojiLayer).emoji, '😀'); + expect(exported.bytes.length, 4); + expect(exported.logicalSize.width, 48); + expect(exported.logicalSize.height, 48); + }); + + test('works with PaintLayer', () { + final item = PaintedModel( + mode: PaintMode.freeStyle, + offsets: [Offset.zero], + erasedOffsets: [], + color: const Color(0xFF000000), + strokeWidth: 5, + opacity: 1, + fill: false, + ); + final layer = PaintLayer( + item: item, + rawSize: const Size(200, 200), + opacity: 1.0, + ); + final bytes = Uint8List.fromList(List.filled(100, 0)); + const size = Size(200, 200); + + final exported = ExportedLayer( + layer: layer, + bytes: bytes, + logicalSize: size, + ); + + expect(exported.layer, isA()); + expect(exported.bytes.length, 100); + expect(exported.logicalSize, const Size(200, 200)); + }); + + test('preserves Size.zero for unmounted layers', () { + final layer = Layer(); + final bytes = Uint8List.fromList([0]); + + final exported = ExportedLayer( + layer: layer, + bytes: bytes, + logicalSize: Size.zero, + ); + + expect(exported.logicalSize, Size.zero); + }); + }); +} diff --git a/test/core/models/layers/layer_capture_test.dart b/test/core/models/layers/layer_capture_test.dart new file mode 100644 index 000000000..1b7e613e9 --- /dev/null +++ b/test/core/models/layers/layer_capture_test.dart @@ -0,0 +1,202 @@ +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 '../../../mock/mock_image.dart'; + +void main() { + group('Layer capture', () { + Future pumpEditor(WidgetTester tester) async { + final key = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: ProImageEditor.memory( + mockMemoryImage, + key: key, + callbacks: const ProImageEditorCallbacks(), + configs: const ProImageEditorConfigs( + progressIndicatorConfigs: ProgressIndicatorConfigs( + widgets: ProgressIndicatorWidgets( + circularProgressIndicator: SizedBox.shrink(), + ), + ), + imageGeneration: ImageGenerationConfigs( + enableBackgroundGeneration: false, + enableIsolateGeneration: false, + ), + ), + ), + ), + ); + + expect(find.byType(ProImageEditor), findsOneWidget); + return key.currentState!; + } + + test('captureAsPng returns null for unmounted layer', () async { + final layer = TextLayer(text: 'not mounted'); + final bytes = await layer.captureAsPng(); + expect(bytes, isNull); + }); + + test('captureAllLayersAsBytes returns empty list when no layers', () async { + final result = await Layer.captureAllLayersAsBytes(layers: []); + expect(result, isEmpty); + }); + + test('captureAllLayers returns empty list when no layers', () async { + final result = await Layer.captureAllLayers(layers: []); + expect(result, isEmpty); + }); + + test( + 'captureAllLayersAsBytes with unmounted layers returns nulls', + () async { + final layers = [TextLayer(text: 'a'), EmojiLayer(emoji: '😀')]; + final result = await Layer.captureAllLayersAsBytes(layers: layers); + expect(result.length, 2); + expect(result[0], isNull); + expect(result[1], isNull); + }, + ); + + test('captureAllLayers skips unmounted layers (null bytes)', () async { + final layers = [TextLayer(text: 'a'), EmojiLayer(emoji: '😀')]; + final result = await Layer.captureAllLayers(layers: layers); + // All bytes are null since layers aren't mounted → no exported layers + expect(result, isEmpty); + }); + + testWidgets('captureAsPng produces bytes for a mounted layer', ( + WidgetTester tester, + ) async { + await tester.runAsync(() async { + final editor = await pumpEditor(tester); + + final layer = EmojiLayer(emoji: '😀'); + editor.addLayer(layer); + await tester.pumpAndSettle(); + + final bytes = await layer.captureAsPng(applyTransforms: false); + expect(bytes, isNotNull); + expect(bytes!, isNotEmpty); + }); + }); + + testWidgets('captureAsPng with applyTransforms bakes rotation/flip', ( + WidgetTester tester, + ) async { + await tester.runAsync(() async { + final editor = await pumpEditor(tester); + + final layer = EmojiLayer(emoji: '🔥', rotation: 0.5, flipX: true); + editor.addLayer(layer); + await tester.pumpAndSettle(); + + final bytes = await layer.captureAsPng(applyTransforms: true); + expect(bytes, isNotNull); + expect(bytes!, isNotEmpty); + }); + }); + + testWidgets('captureAsPng with non-png format uses toByteData path', ( + WidgetTester tester, + ) async { + await tester.runAsync(() async { + final editor = await pumpEditor(tester); + + final layer = EmojiLayer(emoji: '🌟'); + editor.addLayer(layer); + await tester.pumpAndSettle(); + + final bytes = await layer.captureAsPng( + format: ui.ImageByteFormat.rawRgba, + ); + expect(bytes, isNotNull); + expect(bytes!, isNotEmpty); + }); + }); + + testWidgets('captureAllLayersAsBytes captures multiple mounted layers', ( + WidgetTester tester, + ) async { + await tester.runAsync(() async { + final editor = await pumpEditor(tester); + + final emoji = EmojiLayer(emoji: '😀'); + final text = TextLayer(text: 'Test'); + editor + ..addLayer(emoji) + ..addLayer(text); + await tester.pumpAndSettle(); + + final result = await Layer.captureAllLayersAsBytes( + layers: editor.activeLayers, + applyTransforms: false, + ); + + expect(result.length, 2); + for (final bytes in result) { + expect(bytes, isNotNull); + expect(bytes!, isNotEmpty); + } + }); + }); + + testWidgets('captureAllLayers returns ExportedLayer list with metadata', ( + WidgetTester tester, + ) async { + await tester.runAsync(() async { + final editor = await pumpEditor(tester); + + final emoji = EmojiLayer(emoji: '😀'); + final text = TextLayer(text: 'Hello'); + editor + ..addLayer(emoji) + ..addLayer(text); + await tester.pumpAndSettle(); + + final exported = await Layer.captureAllLayers( + layers: editor.activeLayers, + applyTransforms: false, + ); + + expect(exported.length, 2); + for (final e in exported) { + expect(e, isA()); + expect(e.bytes, isNotEmpty); + expect(e.logicalSize, isNot(Size.zero)); + expect(e.logicalSize.width, greaterThan(0)); + expect(e.logicalSize.height, greaterThan(0)); + } + }); + }); + + // Note: ProImageEditorState.captureAllLayersWithMeta and + // captureAllLayers use `await WidgetsBinding.instance.endOfFrame` + // which hangs in test environments. They are thin wrappers around + // Layer.captureAllLayers which is tested above. + + testWidgets('captureAsPng with explicit pixelRatio', ( + WidgetTester tester, + ) async { + await tester.runAsync(() async { + final editor = await pumpEditor(tester); + + final layer = EmojiLayer(emoji: '📏'); + editor.addLayer(layer); + await tester.pumpAndSettle(); + + final bytes = await layer.captureAsPng( + pixelRatio: 1.0, + applyTransforms: false, + ); + expect(bytes, isNotNull); + expect(bytes!, isNotEmpty); + }); + }); + }); +} diff --git a/test/shared/services/content_recorder/content_recorder_controller_test.dart b/test/shared/services/content_recorder/content_recorder_controller_test.dart new file mode 100644 index 000000000..2553768c4 --- /dev/null +++ b/test/shared/services/content_recorder/content_recorder_controller_test.dart @@ -0,0 +1,114 @@ +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pro_image_editor/core/models/editor_configs/image_generation_configs/image_generation_configs.dart'; +import 'package:pro_image_editor/shared/services/content_recorder/controllers/content_recorder_controller.dart'; + +Future _createTestImage({int width = 10, int height = 10}) async { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = const Color(0xFFFF0000), + ); + return recorder.endRecording().toImage(width, height); +} + +void main() { + group('ContentRecorderController.convertRawImageData', () { + late ContentRecorderController controller; + + setUp(() { + controller = ContentRecorderController( + isVideoEditor: false, + configs: const ImageGenerationConfigs( + outputFormat: OutputFormat.png, + enableIsolateGeneration: false, + enableBackgroundGeneration: false, + processorConfigs: ProcessorConfigs( + processorMode: ProcessorMode.minimum, + ), + ), + ); + }); + + tearDown(() async { + await controller.destroy(); + }); + + test('converts image to PNG bytes', () async { + final image = await _createTestImage(); + final bytes = await controller.convertRawImageData( + image: image, + outputFormat: OutputFormat.png, + ); + expect(bytes, isNotNull); + expect(bytes!, isNotEmpty); + // PNG magic bytes + expect(bytes[0], 0x89); + expect(bytes[1], 0x50); // 'P' + expect(bytes[2], 0x4E); // 'N' + expect(bytes[3], 0x47); // 'G' + }); + + test('converts image with cropToDrawingBounds false', () async { + final image = await _createTestImage(); + final bytes = await controller.convertRawImageData( + image: image, + outputFormat: OutputFormat.png, + cropToDrawingBounds: false, + ); + expect(bytes, isNotNull); + expect(bytes!, isNotEmpty); + }); + + test('converts image with cropToDrawingBounds true', () async { + final image = await _createTestImage(); + final bytes = await controller.convertRawImageData( + image: image, + outputFormat: OutputFormat.png, + cropToDrawingBounds: true, + ); + expect(bytes, isNotNull); + expect(bytes!, isNotEmpty); + }); + + test('converts image with explicit outputFormat override', () async { + final image = await _createTestImage(); + final bytes = await controller.convertRawImageData( + image: image, + outputFormat: OutputFormat.png, + ); + expect(bytes, isNotNull); + expect(bytes!, isNotEmpty); + }); + + test('cropToDrawingBounds false preserves full image dimensions', () async { + // Create an image with transparent borders and content in the center + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawRect( + const Rect.fromLTWH(8, 8, 4, 4), + Paint()..color = const Color(0xFFFF0000), + ); + final image = await recorder.endRecording().toImage(20, 20); + + final bytesNoCrop = await controller.convertRawImageData( + image: image, + outputFormat: OutputFormat.png, + cropToDrawingBounds: false, + ); + + final bytesCrop = await controller.convertRawImageData( + image: image, + outputFormat: OutputFormat.png, + cropToDrawingBounds: true, + ); + + expect(bytesNoCrop, isNotNull); + expect(bytesCrop, isNotNull); + // With cropping enabled, the image should be smaller (fewer bytes) + // because transparent areas are removed + expect(bytesCrop!.length, lessThan(bytesNoCrop!.length)); + }); + }); +} From ebe69d1423b4ee2b841c9a99c8b97343f38af883 Mon Sep 17 00:00:00 2001 From: hm21 Date: Wed, 1 Apr 2026 22:53:57 +0200 Subject: [PATCH 5/5] feat(release): update version to 12.1.0 and add changelog entries for layer export API and ExportedLayer model --- CHANGELOG.md | 4 ++++ pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e90a8235f..803b8dc03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 12.1.0 +- **FEAT**(layers): Add layer export API to capture individual layers as PNG images. Use `Layer.captureAsPng()` for single layers or `Layer.captureAllLayers()` for batch export with shared isolate reuse. The main editor exposes `captureAllLayers()` and `captureAllLayersWithMeta()` convenience methods. +- **FEAT**(layers): Add `ExportedLayer` model containing the source layer, encoded image bytes, and logical size metadata. + ## 12.0.13 - **FEAT**(text-editor): Add `composingTextDecoration` to `TextEditorConfigs` to control the text decoration of the IME composing region. Defaults to `TextDecoration.none` to remove the underline shown when `enableSuggestions` is active. diff --git a/pubspec.yaml b/pubspec.yaml index 4608b9d66..1d9a449a4 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: 12.0.13 +version: 12.1.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/