hm21 / pro_image_editor

The pro_image_editor is a Flutter widget designed for image editing within your application. It provides a flexible and convenient way to integrate image editing capabilities into your Flutter project.
https://hm21.github.io/pro_image_editor/
BSD 3-Clause "New" or "Revised" License
77 stars 54 forks source link

[Bug]: Crop Editor returns zero bytes #158

Open thanglq1 opened 1 month ago

thanglq1 commented 1 month ago

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

hm21 commented 1 month ago

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?

thanglq1 commented 1 month ago

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 commented 1 month ago

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

hm21 commented 1 month ago

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.

thanglq1 commented 1 month ago
  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.
hm21 commented 1 month ago

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

thanglq1 commented 1 month ago

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
hm21 commented 1 month ago

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?

thanglq1 commented 1 month ago

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

hm21 commented 1 month ago

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.

thanglq1 commented 1 month ago

I hope you can fix it soon. Thank you!

thanglq1 commented 3 weeks ago

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

thanglq1 commented 1 week ago

This issue needs time to fix.