pro_image_editor icon indicating copy to clipboard operation
pro_image_editor copied to clipboard

[Bug]: Crop Editor returns zero bytes

Open thanglq1 opened this issue 1 year ago • 15 comments

Package Version

4.2.5

Flutter Version

3.22.2

Platforms

Android, iOS

How to reproduce?

Hi @hm21. I'm using the standalone Crop Editor, but it returns zero bytes (I guess the issue occurs with images that have a vertical ratio). It's working with images that have a horizontal ratio.

This is the video of the error (Image size: 1.7MB)

https://github.com/user-attachments/assets/98992dc0-a2c7-49e3-be46-9e3424a087c0

This is a video showing it working (Image size: 2.8MB)

https://github.com/user-attachments/assets/b2489ee6-9d32-47f9-8bfa-1792f43ce1ca

Original Image URL: https://images.unsplash.com/photo-1534794420636-dbc13b8a48d2?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTJ8fGJlYXV0aWZ1bCUyMGdpcmx8ZW58MHx8MHx8fDA%3D

Image file: lerone-pieters-fX-j33IiF3Q-unsplash

Logs (optional)

No response

Example code (optional)

// Flutter imports:
import 'dart:async';
import 'dart:io';
import 'dart:math';

import 'package:example/pages/pick_image_example.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

// Package imports:
import 'package:pro_image_editor/pro_image_editor.dart';

// Project imports:
import '../utils/example_helper.dart';

class StandaloneExample extends StatefulWidget {
  const StandaloneExample({super.key});

  @override
  State<StandaloneExample> createState() => _StandaloneExampleState();
}

class _StandaloneExampleState extends State<StandaloneExample>
    with ExampleHelperState<StandaloneExample> {
  void _openPicker(ImageSource source) async {
    final ImagePicker picker = ImagePicker();
    final XFile? image = await picker.pickImage(source: source);

    if (image == null) return;

    String? path;
    Uint8List? bytes;

    if (kIsWeb) {
      bytes = await image.readAsBytes();

      if (!mounted) return;
      await precacheImage(MemoryImage(bytes), context);
    } else {
      path = image.path;
      if (!mounted) return;
      await precacheImage(FileImage(File(path)), context);
    }

    if (!mounted) return;
    if (kIsWeb ||
        (!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS)) {
      Navigator.pop(context);
    }

    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => StandaloneCropRotateScreen(
          imagePath: path!,
        ),
      ),
    );
  }

  void _chooseCameraOrGallery() async {
    /// Open directly the gallery if the camera is not supported
    if (!kIsWeb &&
        (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
      _openPicker(ImageSource.gallery);
      return;
    }

    if (!kIsWeb && Platform.isIOS) {
      showCupertinoModalPopup(
        context: context,
        builder: (BuildContext context) => CupertinoTheme(
          data: const CupertinoThemeData(),
          child: CupertinoActionSheet(
            actions: <CupertinoActionSheetAction>[
              CupertinoActionSheetAction(
                onPressed: () => _openPicker(ImageSource.camera),
                child: const Wrap(
                  spacing: 7,
                  runAlignment: WrapAlignment.center,
                  children: [
                    Icon(CupertinoIcons.photo_camera),
                    Text('Camera'),
                  ],
                ),
              ),
              CupertinoActionSheetAction(
                onPressed: () => _openPicker(ImageSource.gallery),
                child: const Wrap(
                  spacing: 7,
                  runAlignment: WrapAlignment.center,
                  children: [
                    Icon(CupertinoIcons.photo),
                    Text('Gallery'),
                  ],
                ),
              ),
            ],
            cancelButton: CupertinoActionSheetAction(
              isDefaultAction: true,
              onPressed: () {
                Navigator.pop(context);
              },
              child: const Text('Cancel'),
            ),
          ),
        ),
      );
    } else {
      showModalBottomSheet(
        context: context,
        showDragHandle: true,
        constraints: BoxConstraints(
          minWidth: min(MediaQuery.of(context).size.width, 360),
        ),
        builder: (context) {
          return Material(
            color: Colors.transparent,
            child: SingleChildScrollView(
              child: Padding(
                padding: const EdgeInsets.only(bottom: 24, left: 16, right: 16),
                child: Wrap(
                  spacing: 45,
                  runSpacing: 30,
                  crossAxisAlignment: WrapCrossAlignment.center,
                  runAlignment: WrapAlignment.center,
                  alignment: WrapAlignment.spaceAround,
                  children: [
                    MaterialIconActionButton(
                      primaryColor: const Color(0xFFEC407A),
                      secondaryColor: const Color(0xFFD3396D),
                      icon: Icons.photo_camera,
                      text: 'Camera',
                      onTap: () => _openPicker(ImageSource.camera),
                    ),
                    MaterialIconActionButton(
                      primaryColor: const Color(0xFFBF59CF),
                      secondaryColor: const Color(0xFFAC44CF),
                      icon: Icons.image,
                      text: 'Gallery',
                      onTap: () => _openPicker(ImageSource.gallery),
                    ),
                  ],
                ),
              ),
            ),
          );
        },
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        showModalBottomSheet(
          context: context,
          builder: (BuildContext _) {
            return ListView(
              shrinkWrap: true,
              padding: const EdgeInsets.symmetric(vertical: 20),
              children: <Widget>[
                const Padding(
                  padding: EdgeInsets.symmetric(horizontal: 16),
                  child: Text('Editor',
                      style:
                          TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
                ),
                ListTile(
                  leading: const Icon(Icons.crop_rotate_rounded),
                  title: const Text('Crop-Rotate-Editor'),
                  trailing: const Icon(Icons.chevron_right),
                  onTap: () async {
                    _chooseCameraOrGallery();
                  },
                ),
              ],
            );
          },
        );
      },
      leading: const Icon(Icons.view_in_ar_outlined),
      title: const Text('Standalone Sub-Editor'),
      trailing: const Icon(Icons.chevron_right),
    );
  }
}

class StandaloneCropRotateScreen extends StatefulWidget {
  final String imagePath;

  const StandaloneCropRotateScreen({
    super.key,
    required this.imagePath,
  });

  @override
  State<StandaloneCropRotateScreen> createState() =>
      _StandaloneCropRotateScreenState();
}

class _StandaloneCropRotateScreenState
    extends State<StandaloneCropRotateScreen> {
  final _cropRotateEditorKey = GlobalKey<CropRotateEditorState>();

  late StreamController _updateUIStream;

  Future<void> _onImageEditingComplete(bytes) async {
    Navigator.of(context).pop(bytes);
  }

  /// Undo icon button
  Widget _undoButton({
    required bool? canUndo,
    required VoidCallback? onPressed,
  }) =>
      IconButton(
        tooltip: 'Undo',
        icon: const Icon(Icons.undo_outlined),
        color: (canUndo ?? false)
            ? Theme.of(context).colorScheme.primary
            : Theme.of(context).colorScheme.primary.withAlpha(80),
        onPressed: onPressed,
      );

  /// Redo icon button
  Widget _redoButton({
    required bool? canUndo,
    required VoidCallback? onPressed,
  }) =>
      IconButton(
        tooltip: 'Redo',
        icon: const Icon(Icons.redo_outlined),
        color: (canUndo ?? false)
            ? Theme.of(context).colorScheme.primary
            : Theme.of(context).colorScheme.primary.withAlpha(80),
        onPressed: onPressed,
      );

  /// Back icon button
  Widget _backButton({
    required VoidCallback? onPressed,
  }) =>
      IconButton(
        tooltip: 'Cancel',
        icon: Icon(Platform.isAndroid
            ? Icons.arrow_back_outlined
            : Icons.arrow_back_ios_new_outlined),
        color: Theme.of(context).colorScheme.primary,
        onPressed: onPressed,
      );

  /// Done icon button
  Widget _doneButton({
    required VoidCallback? onPressed,
    IconData icon = Icons.done,
  }) =>
      IconButton(
        tooltip: 'Done',
        icon: Icon(icon),
        color: Theme.of(context).colorScheme.primary,
        onPressed: onPressed,
      );

  /// AppBar Crop Editor
  AppBar _buildAppBarCropEditor(CropRotateEditorState cropRotateEditorState) {
    return AppBar(
      automaticallyImplyLeading: false,
      backgroundColor: Theme.of(context).colorScheme.surface,
      foregroundColor: Theme.of(context).colorScheme.surface,
      actions: [
        StreamBuilder(
          stream: _updateUIStream.stream,
          builder: (_, __) {
            return _backButton(
              onPressed: cropRotateEditorState.close,
            );
          },
        ),
        const Spacer(),
        StreamBuilder(
            stream: _updateUIStream.stream,
            builder: (_, __) {
              return _undoButton(
                canUndo: cropRotateEditorState.canUndo,
                onPressed: cropRotateEditorState.undoAction,
              );
            }),
        StreamBuilder(
          stream: _updateUIStream.stream,
          builder: (_, __) {
            return _redoButton(
              canUndo: cropRotateEditorState.canRedo,
              onPressed: cropRotateEditorState.redoAction,
            );
          },
        ),
        StreamBuilder(
          stream: _updateUIStream.stream,
          builder: (_, __) {
            return _doneButton(onPressed: cropRotateEditorState.done);
          },
        ),
      ],
    );
  }

  /// BottomBar Crop Editor
  Widget _buildBottomBarCropEditor(
      CropRotateEditorState cropRotateEditorState) {
    return StreamBuilder(
      stream: _updateUIStream.stream,
      builder: (_, __) {
        return BottomAppBar(
          height: kBottomNavigationBarHeight,
          color: Theme.of(context).colorScheme.surface,
          padding: EdgeInsets.zero,
          child: ListView(
            scrollDirection: Axis.horizontal,
            physics: const BouncingScrollPhysics(),
            padding: const EdgeInsets.symmetric(
              horizontal: 12,
            ),
            shrinkWrap: true,
            children: [
              /// Rotate
              FlatIconTextButton(
                key: const ValueKey('crop-rotate-editor-rotate-btn'),
                label: const Text('Rotate'),
                icon: const Icon(Icons.rotate_90_degrees_ccw_outlined),
                onPressed: () {
                  cropRotateEditorState.rotate();
                },
              ),

              /// Flip
              FlatIconTextButton(
                key: const ValueKey('crop-rotate-editor-flip-btn'),
                label: const Text('Flip'),
                icon: const Icon(Icons.flip_outlined),
                onPressed: () {
                  cropRotateEditorState.flip();
                },
              ),

              /// Ratio
              FlatIconTextButton(
                key: const ValueKey('crop-rotate-editor-ratio-btn'),
                label: const Text('Ratio'),
                icon: const Icon(Icons.crop_outlined),
                onPressed: () {
                  cropRotateEditorState.openAspectRatioOptions();
                },
              ),

              /// Reset
              FlatIconTextButton(
                key: const ValueKey('crop-rotate-editor-reset-btn'),
                label: const Text('Reset'),
                icon: const Icon(Icons.replay_outlined),
                onPressed: () {
                  cropRotateEditorState.reset();
                },
              ),
            ],
          ),
        );
      },
    );
  }

  @override
  void initState() {
    super.initState();
    _updateUIStream = StreamController.broadcast();
  }

  @override
  void dispose() {
    _updateUIStream.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CropRotateEditor.file(
      File(widget.imagePath),
      key: _cropRotateEditorKey,
      initConfigs: CropRotateEditorInitConfigs(
        convertToUint8List: true,
        configs: ProImageEditorConfigs(
          icons: ImageEditorIcons(
            backButton:
                Platform.isIOS ? Icons.arrow_back_ios : Icons.arrow_back,
          ),
          customWidgets: ImageEditorCustomWidgets(
            cropRotateEditor: CustomWidgetsCropRotateEditor(
              appBar: (cropRotateEditor, rebuildStream) => ReactiveCustomAppbar(
                stream: rebuildStream,
                builder: (_) => _buildAppBarCropEditor(cropRotateEditor),
              ),
              bottomBar: (cropRotateEditor, rebuildStream) =>
                  ReactiveCustomWidget(
                stream: rebuildStream,
                builder: (_) => _buildBottomBarCropEditor(cropRotateEditor),
              ),
            ),
          ),
        ),
        onImageEditingComplete: _onImageEditingComplete,
        theme: ThemeData.dark(),
      ),
    );
  }
}

Device Model (optional)

No response

thanglq1 avatar Jul 12 '24 09:07 thanglq1

I just tried your code, but I can't reproduce it. I used many different image ratios, but in my case it always works. Actually, it should not matter what kind of ratio the image has. However, can you try for me with an image that is in “JPEG” format that has the same ratio as the one that is causing a problem?

The point is, currently I think there is an issue with decoding your image. I saw in the example URL which you posted it automatically choose the format. Keep in mind that Flutter can make problems by decoding more modern image formats like “AVIF” so I recommend you to use jpeg/png. Alternative you can download it in a modern format, but before you insert it in the image editor you use an external package that converts it in a format that Flutter can decode.

In the case, the same issue is also with the format “JPEG” do you get any error in the console when you open the image? And on which exactly device you test it? Happen the issue also on other devices and also on the platforms windows and web?

hm21 avatar Jul 13 '24 06:07 hm21

You're right. The image ratio doesn't matter. I've tried several images with different ratios, and sometimes it works, sometimes it doesn't. The image format is .jpg. I've tested on both an iPhone 15 simulator (iOS 17) and a Samsung Galaxy A71 (Android 13. Real device). The error occurs on both devices. Currently, I haven't found a pattern because sometimes the same image works without any error. It also doesn't throw any exceptions.

thanglq1 avatar Jul 13 '24 08:07 thanglq1

Sometimes the captureFinalScreenshot method in content_recorder_controller.dart returns null.

if (activeScreenshotGeneration) {
  // Get screenshot from isolated generated thread.
  bytes = await backgroundScreenshot.completer.future;
} else {
  // Capture a new screenshot if the current screenshot is broken or
  // doesn't exist.
  bytes = widget == null
      ? await _capture(
          id: id,
          imageInfos: imageInfos,
        )
      : await captureFromWidget(
          widget,
          id: id,
          targetSize: targetSize,
          imageInfos: imageInfos,
        );
}

Sometimes it goes to the 'if' block, sometimes to the 'else' block, and returns null. So in the following code:

Uint8List? bytes = await screenshotCtrl.captureFinalScreenshot(
  imageInfos: imageInfos!,
  context: context,
  widget: _screenshotWidget(transformC),
  targetSize:
      _rotated90deg ? imageInfos!.rawSize.flipped : imageInfos!.rawSize,
  backgroundScreenshot:
      screenshotHistoryPosition >= screenshotHistory.length
          ? null
          : screenshotHistory[screenshotHistoryPosition],
);

bytes is null in the done() method in crop_rotate_editor.dart. That's why it returns zero bytes.

Here's the corrected version:

STEP is the same video. Just pick an image and then click the Done button on the top right. Don't do anything else. Don't click Flip, Ratio

thanglq1 avatar Jul 15 '24 03:07 thanglq1

Thank you for this information, that's good to know and help me to fix it. Can you also answer me the questions below that will help me to fix it.

  1. When you do any interaction like flip it always works correctly, right?
  2. When you open the editor and wait a longer time (about 10 seconds) before you press “done” it also works correctly, right?
  3. In the case you answered question 2 with yes, can you check here if this method is always called when you open the editor? And if yes, can you check if this method is also triggered?

Currently, from that what you describe, it sounds to me that the editor didn't capture the image. I'm not sure if the problem is that the editor didn't await to finish the capture progress, or it never started, but I think more that the problem is the editor didn't start to capture it.

Thanks for your help.

hm21 avatar Jul 15 '24 07:07 hm21

  1. Yes
  2. If you wait a longer time (> 10 seconds), it works more than 90%. Sometimes it doesn't work.
  3. Both methods you mentioned hideFakeHero and takeScreenshot are called.

thanglq1 avatar Jul 15 '24 08:07 thanglq1

Okay, got it. When you print the parameter screenshotHistoryPosition and screenshotHistory.length here which values does it show you?

hm21 avatar Jul 15 '24 15:07 hm21

Its always are screenshotHistoryPosition = 0 and screenshotHistory.length = 1

This is screenshotHistory value when its working. Screenshot 2024-07-16 at 06 51 15

This is screenshotHistory value when its error (case return zero bytes). Screenshot 2024-07-16 at 06 49 27

thanglq1 avatar Jul 15 '24 23:07 thanglq1

Ah okay, that's interesting, something seems to fail in the background generation that the state broken is true. Actually, in this case the editor should re-capture an image when you press “done”, but it seems like that also fails. I'm not sure, but maybe something failed in the generation of the plain-transformed image. Can you check here if the size is correct when you press done?

hm21 avatar Jul 17 '24 05:07 hm21

The size is correct even if it returns data or returns zero bytes.

thanglq1 avatar Jul 17 '24 07:07 thanglq1

Okay, that's interesting. The problem is that I still can't reproduce it even after trying it on many mobile devices, so I don't think I can resolve this problem until I can reproduce it by myself.

hm21 avatar Jul 18 '24 07:07 hm21

I hope you can fix it soon. Thank you!

thanglq1 avatar Jul 18 '24 13:07 thanglq1

Hi @hm21. I can not reopen this issue. Please help reopen.

thanglq1 avatar Jul 26 '24 01:07 thanglq1

This issue needs time to fix.

thanglq1 avatar Aug 07 '24 01:08 thanglq1

@thanglq1 I have occasionally been able to reproduce the issue, though it happens very rarely. It appears that there is a problem, particularly in debug mode, where the widget tree isn't correctly built to capture the background. I've added a loop that retries a few times if the widget tree is not ready in version 5.1.3. When you have time, could you please test it to see if this resolves the issue on your end as well?

hm21 avatar Aug 20 '24 08:08 hm21

Hi @hm21. Will check when I have time. Thank you so much for your effort.

thanglq1 avatar Aug 20 '24 14:08 thanglq1

Hi @thanglq1, I will close this issue now as I believe it has been resolved. If the issue reappears after your testing, please feel free to reopen it.

hm21 avatar Aug 24 '24 18:08 hm21