Merge pull request #233 from ZhuJHua/develop

feat: optimize image processing capabilities
This commit is contained in:
住京华
2025-04-17 11:51:40 +08:00
committed by GitHub
37 changed files with 1277 additions and 614 deletions

2
.fvmrc
View File

@@ -1,3 +1,3 @@
{
"flutter": "3.29.2"
"flutter": "3.29.3"
}

View File

@@ -38,6 +38,13 @@ jobs:
with:
flutter-version: ${{ env.flutter-version }}
- name: Set Up Rust
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: nightly
override: true
- name: Install Dependencies
run: flutter pub get

28
.github/workflows/cargo-ci.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Cargo CI
on:
workflow_dispatch:
pull_request:
branches:
- master
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: nightly
override: true
- name: Run cargo test
working-directory: rust
run: cargo test
- name: Fail on Errors
if: failure()
run: exit 1

View File

@@ -16,3 +16,7 @@ analyzer:
- "**/*.gr.dart"
- "**/*.mocks.dart"
- "rust_builder/**"
- "ios/**"
- "android/**"
- "windows/**"
- "macos/**"

View File

@@ -1,70 +1,322 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:moodiary/utils/media_util.dart';
import 'package:moodiary/utils/cache_util.dart';
import 'package:moodiary/utils/log_util.dart';
class ThumbnailImage extends StatelessWidget {
final kTransparentImage = Uint8List.fromList(<int>[
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x63,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0D,
0x0A,
0x2D,
0xB4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
0x42,
0x60,
0x82,
]);
enum _ImageLoadState { loading, error, success }
class _ImageState {
final int width;
final int height;
final String path;
final double aspectRatio;
_ImageState({
required this.width,
required this.height,
required this.path,
required this.aspectRatio,
});
}
class MoodiaryImage extends StatefulWidget {
final String imagePath;
final int size;
final BoxFit? fit;
final VoidCallback? onTap;
final String? heroTag;
final BorderRadius? borderRadius;
final bool showBorder;
final EdgeInsets? padding;
final String heroTag;
const ThumbnailImage({
const MoodiaryImage({
super.key,
required this.imagePath,
required this.size,
this.fit,
this.onTap,
required this.heroTag,
this.heroTag,
this.borderRadius,
this.showBorder = false,
this.padding,
});
Widget _buildImage({
required double aspectRatio,
required ImageProvider imageProvider,
required double pixelRatio,
}) {
final image = Image(
key: ValueKey(imagePath),
image: ResizeImage(
imageProvider,
width: aspectRatio < 1.0 ? (size * pixelRatio).toInt() : null,
height: aspectRatio >= 1.0 ? (size * pixelRatio).toInt() : null,
@override
State<MoodiaryImage> createState() => _MoodiaryImageState();
}
class _MoodiaryImageState extends State<MoodiaryImage> {
final Rx<_ImageLoadState> _loadState = Rx<_ImageLoadState>(
_ImageLoadState.loading,
);
late _ImageState _imageState;
@override
void initState() {
super.initState();
_loadImage();
}
@override
void dispose() {
_loadState.close();
super.dispose();
}
@override
void didUpdateWidget(covariant MoodiaryImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.imagePath != oldWidget.imagePath ||
widget.size != oldWidget.size) {
_loadImage();
}
}
void _loadImage() async {
_loadState.value = _ImageLoadState.loading;
try {
logger.d('Image loaded from path: ${widget.imagePath}');
final imageAspect = await ImageCacheUtil().getImageAspectRatioWithCache(
imagePath: widget.imagePath,
);
final imageSize = widget.size;
final width =
imageAspect < 1.0 ? imageSize : (imageSize * imageAspect).ceil();
final height =
imageAspect >= 1.0 ? imageSize : (imageSize / imageAspect).ceil();
final path = await ImageCacheUtil().getLocalImagePathWithCache(
imagePath: widget.imagePath,
imageWidth: width * 2,
imageHeight: height * 2,
imageAspectRatio: imageAspect,
);
_imageState = _ImageState(
width: width,
height: height,
path: path,
aspectRatio: imageAspect,
);
_loadState.value = _ImageLoadState.success;
} catch (e) {
_loadState.value = _ImageLoadState.error;
}
}
BorderRadius _shrinkBorderRadius(BorderRadius radius, double amount) {
return BorderRadius.only(
topLeft: Radius.elliptical(
(radius.topLeft.x - amount).clamp(0, double.infinity),
(radius.topLeft.y - amount).clamp(0, double.infinity),
),
topRight: Radius.elliptical(
(radius.topRight.x - amount).clamp(0, double.infinity),
(radius.topRight.y - amount).clamp(0, double.infinity),
),
bottomLeft: Radius.elliptical(
(radius.bottomLeft.x - amount).clamp(0, double.infinity),
(radius.bottomLeft.y - amount).clamp(0, double.infinity),
),
bottomRight: Radius.elliptical(
(radius.bottomRight.x - amount).clamp(0, double.infinity),
(radius.bottomRight.y - amount).clamp(0, double.infinity),
),
fit: fit ?? BoxFit.cover,
);
return GestureDetector(
onTap: onTap,
child: Hero(tag: heroTag, child: image),
);
}
@override
Widget build(BuildContext context) {
final fileImage = FileImage(File(imagePath));
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
const borderWidth = 1.0;
final Future getAspectRatio = MediaUtil.getImageAspectRatio(fileImage);
final loading = ColoredBox(
color: context.theme.colorScheme.surfaceContainer,
child: const Center(child: Icon(Icons.image_search_rounded)),
);
return FutureBuilder(
future: getAspectRatio,
builder: (context, snapshot) {
return switch (snapshot.connectionState) {
ConnectionState.none => loading,
ConnectionState.waiting => loading,
ConnectionState.active => loading,
ConnectionState.done => _buildImage(
aspectRatio: snapshot.data as double,
imageProvider: fileImage,
pixelRatio: devicePixelRatio,
),
};
},
final outerRadius = widget.borderRadius ?? BorderRadius.zero;
final innerRadius =
widget.showBorder
? _shrinkBorderRadius(outerRadius, borderWidth)
: outerRadius;
return Container(
decoration: BoxDecoration(
borderRadius: outerRadius,
border:
widget.showBorder
? Border.all(
color: context.theme.colorScheme.outline.withValues(
alpha: 0.6,
),
width: borderWidth,
)
: null,
),
margin: widget.padding,
child: ClipRRect(
borderRadius: innerRadius,
child: AnimatedSwitcher(
duration: Durations.short3,
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: Obx(() {
switch (_loadState.value) {
case _ImageLoadState.loading:
return const _LoadingPlaceholder(key: ValueKey('loading'));
case _ImageLoadState.error:
return const _ErrorPlaceholder(key: ValueKey('error'));
case _ImageLoadState.success:
final imagePath = _imageState.path;
final width = _imageState.width;
final height = _imageState.height;
return GestureDetector(
key: const ValueKey('image'),
onTap:
widget.onTap != null
? () async {
if (widget.heroTag != null) {
await precacheImage(
FileImage(File(widget.imagePath)),
context,
);
}
widget.onTap?.call();
}
: null,
behavior: HitTestBehavior.translucent,
child: HeroMode(
enabled: widget.heroTag != null,
child: Hero(
tag: widget.heroTag ?? '',
child: FadeInImage(
key: ValueKey(imagePath),
image: FileImage(File(imagePath)),
placeholder: MemoryImage(kTransparentImage),
fadeInDuration: Durations.short2,
fadeOutDuration: Durations.short1,
fit: widget.fit ?? BoxFit.cover,
width: width.toDouble(),
height: height.toDouble(),
imageErrorBuilder: (_, __, ___) {
return const _ErrorPlaceholder(
key: ValueKey('image_error'),
);
},
),
),
),
);
}
}),
),
),
);
}
}
class _ErrorPlaceholder extends StatelessWidget {
const _ErrorPlaceholder({super.key});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: context.theme.colorScheme.errorContainer,
child: Center(
child: Icon(
Icons.error_rounded,
color: context.theme.colorScheme.onErrorContainer,
),
),
);
}
}
class _LoadingPlaceholder extends StatelessWidget {
const _LoadingPlaceholder({super.key});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: context.theme.colorScheme.surfaceContainer,
child: Center(
child: Icon(
Icons.image_search_rounded,
color: context.theme.colorScheme.onSurfaceVariant,
),
),
);
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
@@ -7,6 +5,7 @@ import 'package:intl/intl.dart';
import 'package:moodiary/common/models/isar/diary.dart';
import 'package:moodiary/common/values/border.dart';
import 'package:moodiary/common/values/diary_type.dart';
import 'package:moodiary/components/base/image.dart';
import 'package:moodiary/components/base/text.dart';
import 'package:moodiary/components/diary_card/basic_card_logic.dart';
import 'package:moodiary/utils/file_util.dart';
@@ -18,33 +17,23 @@ class CalendarDiaryCardComponent extends StatelessWidget with BasicCardLogic {
@override
Widget build(BuildContext context) {
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
Widget buildImage() {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 4.0,
spacing: 8.0,
children: List.generate(diary.imageName.length, (index) {
return SizedBox(
height: 100,
width: 100,
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: ResizeImage(
FileImage(
File(
FileUtil.getRealPath('image', diary.imageName[index]),
),
),
width: (100 * pixelRatio).toInt(),
),
fit: BoxFit.cover,
),
border: Border.all(color: context.theme.colorScheme.outline),
borderRadius: AppBorderRadius.smallBorderRadius,
height: 100,
child: MoodiaryImage(
imagePath: FileUtil.getRealPath(
'image',
diary.imageName[index],
),
borderRadius: AppBorderRadius.smallBorderRadius,
showBorder: true,
size: 100,
),
);
}),

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
@@ -7,6 +5,7 @@ import 'package:intl/intl.dart';
import 'package:moodiary/common/models/isar/diary.dart';
import 'package:moodiary/common/values/border.dart';
import 'package:moodiary/common/values/diary_type.dart';
import 'package:moodiary/components/base/image.dart';
import 'package:moodiary/components/base/text.dart';
import 'package:moodiary/components/diary_card/basic_card_logic.dart';
import 'package:moodiary/utils/file_util.dart';
@@ -18,22 +17,15 @@ class GirdDiaryCardComponent extends StatelessWidget with BasicCardLogic {
@override
Widget build(BuildContext context) {
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
Widget buildImage() {
return Container(
height: 154.0,
decoration: BoxDecoration(
image: DecorationImage(
image: ResizeImage(
FileImage(
File(FileUtil.getRealPath('image', diary.imageName.first)),
),
width: (250 * pixelRatio).toInt(),
),
fit: BoxFit.cover,
),
return SizedBox(
height: 154,
child: ClipRRect(
borderRadius: AppBorderRadius.mediumBorderRadius,
child: MoodiaryImage(
imagePath: FileUtil.getRealPath('image', diary.imageName.first),
size: 250,
),
),
);
}

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
@@ -7,6 +5,7 @@ import 'package:intl/intl.dart';
import 'package:moodiary/common/models/isar/diary.dart';
import 'package:moodiary/common/values/border.dart';
import 'package:moodiary/common/values/diary_type.dart';
import 'package:moodiary/components/base/image.dart';
import 'package:moodiary/components/base/text.dart';
import 'package:moodiary/components/diary_card/basic_card_logic.dart';
import 'package:moodiary/utils/file_util.dart';
@@ -24,22 +23,14 @@ class ListDiaryCardComponent extends StatelessWidget with BasicCardLogic {
@override
Widget build(BuildContext context) {
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
Widget buildImage() {
return AspectRatio(
aspectRatio: 1.0,
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: ResizeImage(
FileImage(
File(FileUtil.getRealPath('image', diary.imageName.first)),
),
width: (132 * pixelRatio).toInt(),
),
fit: BoxFit.cover,
),
borderRadius: AppBorderRadius.mediumBorderRadius,
child: ClipRRect(
borderRadius: AppBorderRadius.mediumBorderRadius,
child: MoodiaryImage(
imagePath: FileUtil.getRealPath('image', diary.imageName.first),
size: 132,
),
),
);

View File

@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:moodiary/common/values/border.dart';
import 'package:moodiary/components/base/image.dart';
import 'package:moodiary/pages/image/image_view.dart';
import 'package:moodiary/utils/file_util.dart';
import 'package:uuid/uuid.dart';
@@ -20,24 +20,23 @@ class MarkdownImageEmbed extends StatelessWidget {
final imagePath =
isEdit ? imageName : FileUtil.getRealPath('image', imageName);
final heroPrefix = const Uuid().v4();
final image = MoodiaryImage(
imagePath: imagePath,
size: 300,
heroTag: '${heroPrefix}0',
borderRadius: AppBorderRadius.mediumBorderRadius,
showBorder: true,
padding: const EdgeInsets.all(8.0),
onTap: () {
if (!isEdit) {
showImageView(context, [imagePath], 0, heroTagPrefix: heroPrefix);
}
},
);
return Center(
child: GestureDetector(
onTap: () {
if (!isEdit) {
showImageView(context, [imagePath], 0, heroTagPrefix: heroPrefix);
}
},
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: Hero(
tag: '${heroPrefix}0',
child: Card.outlined(
clipBehavior: Clip.hardEdge,
color: Colors.transparent,
child: Image.file(File(imagePath)),
),
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: image,
),
);
}

View File

@@ -53,7 +53,7 @@ class MediaImageComponent extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemBuilder: (context, index) {
final image = ThumbnailImage(
final image = MoodiaryImage(
imagePath: imageList[index],
size: 120,
heroTag: '$heroPrefix$index',
@@ -66,17 +66,7 @@ class MediaImageComponent extends StatelessWidget {
);
},
);
return GestureDetector(
onTap: () async {
await showImageView(
context,
imageList,
index,
heroTagPrefix: heroPrefix,
);
},
child: image,
);
return image;
},
itemCount: imageList.length,
),

View File

@@ -61,31 +61,29 @@ class MediaVideoComponent extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () async {
await showVideoView(
context,
videoList,
index,
heroTagPrefix: '$heroPrefix$index',
);
},
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: ThumbnailImage(
imagePath: thumbnailList[index],
heroTag: '$heroPrefix$index',
size: 120,
),
return Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: MoodiaryImage(
imagePath: thumbnailList[index],
heroTag: '$heroPrefix$index',
onTap: () async {
await showVideoView(
context,
videoList,
index,
heroTagPrefix: '$heroPrefix$index',
);
},
size: 120,
),
const FrostedGlassButton(
size: 32,
child: Center(child: Icon(Icons.play_arrow_rounded)),
),
],
),
),
const FrostedGlassButton(
size: 32,
child: Center(child: Icon(Icons.play_arrow_rounded)),
),
],
);
},
itemCount: thumbnailList.length,

View File

@@ -1,8 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:moodiary/common/values/border.dart';
import 'package:moodiary/components/base/image.dart';
import 'package:moodiary/pages/image/image_view.dart';
import 'package:moodiary/utils/file_util.dart';
@@ -35,31 +34,28 @@ class ImageEmbedBuilder extends EmbedBuilder {
isEdit
? imageEmbed.name
: FileUtil.getRealPath('image', imageEmbed.name);
final image = MoodiaryImage(
imagePath: imagePath,
size: 300,
heroTag: '${imageEmbed.name}0',
borderRadius: AppBorderRadius.mediumBorderRadius,
showBorder: true,
padding: const EdgeInsets.all(8.0),
onTap: () {
if (!isEdit) {
showImageView(
context,
[imagePath],
0,
heroTagPrefix: imageEmbed.name,
);
}
},
);
return Center(
child: GestureDetector(
onTap: () {
if (!isEdit) {
showImageView(
context,
[imagePath],
0,
heroTagPrefix: imageEmbed.name,
);
}
},
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: Card.outlined(
color: Colors.transparent,
child: Hero(
tag: '${imageEmbed.name}0',
child: ClipRRect(
borderRadius: AppBorderRadius.mediumBorderRadius,
child: Image.file(File(imagePath)),
),
),
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: image,
),
);
}

View File

@@ -1,5 +1,6 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint

View File

@@ -18,6 +18,7 @@ import 'package:moodiary/components/window_buttons/window_buttons.dart';
import 'package:moodiary/config/env.dart';
import 'package:moodiary/l10n/app_localizations.dart';
import 'package:moodiary/l10n/l10n.dart';
import 'package:moodiary/persistence/hive.dart';
import 'package:moodiary/persistence/isar.dart';
import 'package:moodiary/persistence/pref.dart';
import 'package:moodiary/router/app_pages.dart';
@@ -31,11 +32,12 @@ import 'package:video_player_media_kit/video_player_media_kit.dart';
Future<void> _initSystem() async {
WidgetsFlutterBinding.ensureInitialized();
await RustLib.init();
await PrefUtil.initPref();
await IsarUtil.initIsar();
await ThemeUtil().buildTheme();
await WebDavUtil().initWebDav();
await HiveUtil().init();
unawaited(RustLib.init());
unawaited(_platFormOption());
WebDavUtil().initWebDav();
VideoPlayerMediaKit.ensureInitialized(windows: true);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
@@ -44,7 +46,7 @@ Future<void> _initSystem() async {
systemNavigationBarContrastEnforced: false,
),
);
await _platFormOption();
await ThemeUtil().buildTheme();
}
Future<Locale> _findLanguage() async {

View File

@@ -15,6 +15,12 @@ class ImageLogic extends GetxController {
@override
void onInit() {
pageController = PageController(initialPage: state.imageIndex.value);
pageController.addListener(() {
final index = pageController.page?.round() ?? 0;
if (state.imageIndex.value != index) {
state.imageIndex.value = index;
}
});
super.onInit();
}

View File

@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:get/get.dart';
import 'package:moodiary/persistence/pref.dart';
import 'package:moodiary/utils/aes_util.dart';
import 'package:moodiary/utils/cache_util.dart';
import 'package:moodiary/utils/file_util.dart';
import 'package:moodiary/utils/notice_util.dart';
import 'package:share_plus/share_plus.dart';
@@ -75,4 +76,13 @@ class LaboratoryLogic extends GetxController {
final decrypted = await AesUtil.decrypt(key: key, encryptedData: encrypted);
return decrypted == 'Hello World';
}
Future<bool> clearImageThumbnail() async {
try {
await ImageCacheUtil().clearImageCache();
return true;
} catch (e) {
return false;
}
}
}

View File

@@ -96,6 +96,18 @@ class LaboratoryPage extends StatelessWidget {
},
title: const Text('加密测试'),
),
const Gap(12),
ListTile(
onTap: () async {
final res = await logic.clearImageThumbnail();
if (res) {
toast.success(message: '清理成功');
} else {
toast.error(message: '清理失败');
}
},
title: const Text('清理图片缩略图缓存'),
),
],
);
},

37
lib/persistence/hive.dart Normal file
View File

@@ -0,0 +1,37 @@
import 'package:hive_ce/hive.dart';
import 'package:hive_ce_flutter/adapters.dart';
import 'package:moodiary/utils/file_util.dart';
class HiveUtil {
HiveUtil._();
static final HiveUtil _instance = HiveUtil._();
factory HiveUtil() => _instance;
late LazyBox<bool> _imageCacheBox;
late LazyBox<double> _imageAspectBox;
LazyBox<bool> get imageCacheBox => _imageCacheBox;
LazyBox<double> get imageAspectBox => _imageAspectBox;
Future<void> init() async {
Hive.init(
FileUtil.getRealPath('hive', ''),
backendPreference: HiveStorageBackendPreference.native,
);
_imageCacheBox = await Hive.openLazyBox<bool>('image_cache');
_imageAspectBox = await Hive.openLazyBox<double>('image_aspect');
}
Future<void> clear() async {
await _imageCacheBox.clear();
await _imageAspectBox.clear();
}
Future<void> close() async {
await Hive.close();
}
}

View File

@@ -213,7 +213,7 @@ class MoodiaryFadeInPageRoute<T> extends PageRoute<T>
required this.builder,
super.settings,
super.requestFocus,
this.maintainState = true,
this.maintainState = false,
super.fullscreenDialog,
super.allowSnapshotting = true,
super.barrierDismissible = false,

View File

@@ -9,6 +9,7 @@ import '../frb_generated.dart';
import 'constants.dart';
// These functions are ignored because they are not marked as `pub`: `calculate_target_dimensions`, `load_image`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `ResizeOptions`
Future<Uint8List> compress({
required DynamicImage img,
@@ -29,15 +30,23 @@ abstract class DynamicImage implements RustOpaqueInterface {}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<ImageCompress>>
abstract class ImageCompress implements RustOpaqueInterface {
static Future<Uint8List> contain({
static Future<Uint8List> containWithOptions({
required String filePath,
CompressFormat? compressFormat,
int? targetWidth,
int? targetHeight,
int? minWidth,
int? minHeight,
int? maxWidth,
int? maxHeight,
int? quality,
}) => RustLib.instance.api.crateApiCompressImageCompressContain(
}) => RustLib.instance.api.crateApiCompressImageCompressContainWithOptions(
filePath: filePath,
compressFormat: compressFormat,
targetWidth: targetWidth,
targetHeight: targetHeight,
minWidth: minWidth,
minHeight: minHeight,
maxWidth: maxWidth,
maxHeight: maxHeight,
quality: quality,

View File

@@ -16,7 +16,7 @@ import 'api/kmp.dart';
import 'api/zip.dart';
import 'frb_generated.dart';
import 'frb_generated.io.dart'
if (dart.library.js_interop) 'frb_generated.webated.dart';
if (dart.library.js_interop) 'frb_generated.webated.dart';
/// Main entrypoint of the Rust API
class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
@@ -69,7 +69,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.9.0';
@override
int get rustContentHash => 1427840433;
int get rustContentHash => 199828844;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -103,9 +103,13 @@ abstract class RustLibApi extends BaseApi {
required String ttfFilePath,
});
Future<Uint8List> crateApiCompressImageCompressContain({
Future<Uint8List> crateApiCompressImageCompressContainWithOptions({
required String filePath,
CompressFormat? compressFormat,
int? targetWidth,
int? targetHeight,
int? minWidth,
int? minHeight,
int? maxWidth,
int? maxHeight,
int? quality,
@@ -389,9 +393,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
@override
Future<Uint8List> crateApiCompressImageCompressContain({
Future<Uint8List> crateApiCompressImageCompressContainWithOptions({
required String filePath,
CompressFormat? compressFormat,
int? targetWidth,
int? targetHeight,
int? minWidth,
int? minHeight,
int? maxWidth,
int? maxHeight,
int? quality,
@@ -405,8 +413,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
compressFormat,
serializer,
);
sse_encode_opt_box_autoadd_i_32(maxWidth, serializer);
sse_encode_opt_box_autoadd_i_32(maxHeight, serializer);
sse_encode_opt_box_autoadd_u_32(targetWidth, serializer);
sse_encode_opt_box_autoadd_u_32(targetHeight, serializer);
sse_encode_opt_box_autoadd_u_32(minWidth, serializer);
sse_encode_opt_box_autoadd_u_32(minHeight, serializer);
sse_encode_opt_box_autoadd_u_32(maxWidth, serializer);
sse_encode_opt_box_autoadd_u_32(maxHeight, serializer);
sse_encode_opt_box_autoadd_u_8(quality, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
@@ -419,19 +431,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
decodeSuccessData: sse_decode_list_prim_u_8_strict,
decodeErrorData: sse_decode_AnyhowException,
),
constMeta: kCrateApiCompressImageCompressContainConstMeta,
argValues: [filePath, compressFormat, maxWidth, maxHeight, quality],
constMeta: kCrateApiCompressImageCompressContainWithOptionsConstMeta,
argValues: [
filePath,
compressFormat,
targetWidth,
targetHeight,
minWidth,
minHeight,
maxWidth,
maxHeight,
quality,
],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiCompressImageCompressContainConstMeta =>
TaskConstMeta get kCrateApiCompressImageCompressContainWithOptionsConstMeta =>
const TaskConstMeta(
debugName: "ImageCompress_contain",
debugName: "ImageCompress_contain_with_options",
argNames: [
"filePath",
"compressFormat",
"targetWidth",
"targetHeight",
"minWidth",
"minHeight",
"maxWidth",
"maxHeight",
"quality",
@@ -994,7 +1020,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
@protected
int dco_decode_box_autoadd_i_32(dynamic raw) {
int dco_decode_box_autoadd_u_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as int;
}
@@ -1078,9 +1104,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw) {
int? dco_decode_opt_box_autoadd_u_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_box_autoadd_i_32(raw);
return raw == null ? null : dco_decode_box_autoadd_u_32(raw);
}
@protected
@@ -1337,9 +1363,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer) {
int sse_decode_box_autoadd_u_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_i_32(deserializer));
return (sse_decode_u_32(deserializer));
}
@protected
@@ -1465,11 +1491,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer) {
int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_box_autoadd_i_32(deserializer));
return (sse_decode_box_autoadd_u_32(deserializer));
} else {
return null;
}
@@ -1757,9 +1783,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer) {
void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self, serializer);
sse_encode_u_32(self, serializer);
}
@protected
@@ -1887,12 +1913,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer) {
void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_box_autoadd_i_32(self, serializer);
sse_encode_box_autoadd_u_32(self, serializer);
}
}

View File

@@ -143,7 +143,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
CompressFormat dco_decode_box_autoadd_compress_format(dynamic raw);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
int dco_decode_box_autoadd_u_32(dynamic raw);
@protected
int dco_decode_box_autoadd_u_8(dynamic raw);
@@ -185,7 +185,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
CompressFormat? dco_decode_opt_box_autoadd_compress_format(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
int? dco_decode_opt_box_autoadd_u_32(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_u_8(dynamic raw);
@@ -311,7 +311,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
int sse_decode_box_autoadd_u_32(SseDeserializer deserializer);
@protected
int sse_decode_box_autoadd_u_8(SseDeserializer deserializer);
@@ -359,7 +359,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer);
@protected
int? sse_decode_opt_box_autoadd_u_8(SseDeserializer deserializer);
@@ -509,7 +509,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_u_8(int self, SseSerializer serializer);
@@ -569,7 +569,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_u_8(int? self, SseSerializer serializer);

View File

@@ -145,7 +145,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
CompressFormat dco_decode_box_autoadd_compress_format(dynamic raw);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
int dco_decode_box_autoadd_u_32(dynamic raw);
@protected
int dco_decode_box_autoadd_u_8(dynamic raw);
@@ -187,7 +187,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
CompressFormat? dco_decode_opt_box_autoadd_compress_format(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
int? dco_decode_opt_box_autoadd_u_32(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_u_8(dynamic raw);
@@ -313,7 +313,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
int sse_decode_box_autoadd_u_32(SseDeserializer deserializer);
@protected
int sse_decode_box_autoadd_u_8(SseDeserializer deserializer);
@@ -361,7 +361,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer);
@protected
int? sse_decode_opt_box_autoadd_u_8(SseDeserializer deserializer);
@@ -511,7 +511,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_u_8(int self, SseSerializer serializer);
@@ -571,7 +571,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_u_8(int? self, SseSerializer serializer);

View File

@@ -1,6 +1,14 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:moodiary/persistence/hive.dart';
import 'package:moodiary/persistence/pref.dart';
import 'package:moodiary/utils/file_util.dart';
import 'package:moodiary/utils/log_util.dart';
import 'package:moodiary/utils/media_util.dart';
import 'package:path/path.dart';
class CacheUtil {
static Future<List<String>?> getCacheList(
@@ -39,3 +47,97 @@ class CacheUtil {
}
}
}
class ImageCacheUtil {
ImageCacheUtil._();
static final ImageCacheUtil _instance = ImageCacheUtil._();
factory ImageCacheUtil() => _instance;
late final _imageCacheBox = HiveUtil().imageCacheBox;
late final _imageAspectBox = HiveUtil().imageAspectBox;
Future<void> close() async {
await _imageCacheBox.close();
}
Future<void> clearImageCache() async {
await _imageCacheBox.clear();
await _imageAspectBox.clear();
await FileUtil.deleteDir(FileUtil.getRealPath('image_thumbnail', ''));
}
Future<String> getLocalImagePathWithCache({
required String imagePath,
required int imageWidth,
required int imageHeight,
required double imageAspectRatio,
}) async {
final int minSize = min(imageWidth, imageHeight);
final int rangeStart = (minSize ~/ 100) * 100;
final int rangeEnd = rangeStart + 100;
final int baseMinSize = ((rangeStart + rangeEnd) / 2).round();
final bool isWidthMin = imageWidth < imageHeight;
final int standardWidth =
isWidthMin ? baseMinSize : (baseMinSize * imageAspectRatio).round();
final int standardHeight =
isWidthMin ? (baseMinSize / imageAspectRatio).round() : baseMinSize;
final cachedImageName =
'resized_w${standardWidth}_h${standardHeight}_${basename(imagePath)}';
final cachedImagePath = FileUtil.getRealPath(
'image_thumbnail',
cachedImageName,
);
final cachedImageFile = File(cachedImagePath);
final bool isCached =
(await _imageCacheBox.get(cachedImageName)) == true &&
await cachedImageFile.exists();
if (isCached) {
//logger.i('Image cache hit at $cachedImageName');
return cachedImagePath;
}
try {
final compressedImage = await MediaUtil.compressImageData(
imagePath: imagePath,
size: baseMinSize,
imageAspectRatio: imageAspectRatio,
);
if (compressedImage != null) {
final newFile = await cachedImageFile.create(recursive: true);
await newFile.writeAsBytes(compressedImage);
await _imageCacheBox.put(cachedImageName, true);
//logger.i('Image cached at $cachedImageName');
return cachedImagePath;
}
} catch (e) {
logger.d('Error compressing image: $e');
}
return imagePath;
}
Future<double> getImageAspectRatioWithCache({
required String imagePath,
}) async {
final cachedAspectRatio = await (_imageAspectBox.get(basename(imagePath)));
if (cachedAspectRatio != null) return cachedAspectRatio;
try {
final aspectRatio = await MediaUtil.getImageAspectRatio(
FileImage(File(imagePath)),
);
await _imageAspectBox.put(basename(imagePath), aspectRatio);
return aspectRatio;
} catch (e) {
logger.d('Error getting image aspect ratio: $e');
rethrow;
}
}
}

View File

@@ -19,11 +19,11 @@ class LogUtil {
);
void e(message, {required Object error, StackTrace? stackTrace}) {
_logger.e(message, error: error, stackTrace: stackTrace);
if (kDebugMode) _logger.e(message, error: error, stackTrace: stackTrace);
}
void f(message, {required Object error, StackTrace? stackTrace}) {
_logger.f(message, error: error, stackTrace: stackTrace);
if (kDebugMode) _logger.f(message, error: error, stackTrace: stackTrace);
}
void i(message) {

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:fc_native_video_thumbnail/fc_native_video_thumbnail.dart';
import 'package:flutter/material.dart';
@@ -13,6 +14,7 @@ import 'package:moodiary/common/values/media_type.dart';
import 'package:moodiary/persistence/pref.dart';
import 'package:moodiary/src/rust/api/compress.dart';
import 'package:moodiary/src/rust/api/constants.dart' as r_type;
import 'package:moodiary/utils/cache_util.dart';
import 'package:moodiary/utils/file_util.dart';
import 'package:moodiary/utils/log_util.dart';
import 'package:moodiary/utils/lru.dart';
@@ -20,6 +22,34 @@ import 'package:moodiary/utils/notice_util.dart';
import 'package:path/path.dart';
import 'package:uuid/uuid.dart';
enum ImageFormat {
jpeg(extension: '.jpg'),
png(extension: '.png'),
heic(extension: '.heic'),
webp(extension: '.webp');
final String extension;
const ImageFormat({required this.extension});
static ImageFormat getImageFormat(String imagePath) {
final mimeType = lookupMimeType(imagePath);
if (mimeType == null) return ImageFormat.png;
switch (mimeType) {
case 'image/jpeg':
return ImageFormat.jpeg;
case 'image/png':
return ImageFormat.png;
case 'image/heic':
return ImageFormat.heic;
case 'image/webp':
return ImageFormat.webp;
default:
return ImageFormat.png;
}
}
}
class MediaUtil {
static final _picker = ImagePicker();
@@ -37,14 +67,6 @@ class MediaUtil {
}
}
// 定义 MIME 类型到扩展名和压缩格式的映射
static final _compressConfig = {
'image/jpeg': ['.jpg', r_type.CompressFormat.jpeg],
'image/png': ['.png', r_type.CompressFormat.png],
'image/heic': ['.heic', CompressFormat.heic],
'image/webp': ['.webp', r_type.CompressFormat.webP],
};
/// 保存图片
/// 返回值:
/// keyXFile 文件的临时目录
@@ -59,15 +81,10 @@ class MediaUtil {
imageNameMap[imageFile.path] = basename(imageFile.path);
return;
}
final mimeType =
lookupMimeType(imageFile.path) ?? 'image/png'; // 默认使用 PNG
final config =
_compressConfig[mimeType] ?? ['.png', r_type.CompressFormat.png];
final extension = config[0] as String;
final format = config[1];
final imageName = 'image-${const Uuid().v7()}$extension';
final imageFormat = ImageFormat.getImageFormat(imageFile.path);
final imageName = 'image-${const Uuid().v7()}${imageFormat.extension}';
final outputPath = FileUtil.getRealPath('image', imageName);
await _compressImage(imageFile, outputPath, format);
await compressAndSaveImage(imageFile, outputPath, imageFormat);
imageNameMap[imageFile.path] = imageName;
}),
);
@@ -114,19 +131,8 @@ class MediaUtil {
// 保存视频文件
await videoFile.saveTo(FileUtil.getRealPath('video', videoName));
// 获取缩略图
final tempThumbnailPath = FileUtil.getCachePath(
'${const Uuid().v7()}.jpeg',
);
final tempThumbnailPath = FileUtil.getRealPath('thumbnail', videoName);
await _getVideoThumbnail(videoFile, tempThumbnailPath);
// 压缩缩略图并保存
final compressedPath = FileUtil.getRealPath('thumbnail', videoName);
await _compressRust(
XFile(tempThumbnailPath),
compressedPath,
r_type.CompressFormat.jpeg,
);
// 清理临时文件
await File(tempThumbnailPath).delete();
}),
);
@@ -154,23 +160,8 @@ class MediaUtil {
logger.d("Thumbnail missing for $videoName. Regenerating...");
try {
// 生成临时缩略图路径
final tempThumbnailPath = FileUtil.getCachePath(
'${const Uuid().v7()}.jpeg',
);
// 获取视频缩略图
await _getVideoThumbnail(XFile(videoFile.path), tempThumbnailPath);
// 压缩并保存缩略图
await _compressRust(
XFile(tempThumbnailPath),
thumbnailPath,
r_type.CompressFormat.jpeg,
);
// 删除临时文件
await File(tempThumbnailPath).delete();
await _getVideoThumbnail(XFile(videoFile.path), thumbnailPath);
logger.d("Thumbnail regenerated for $videoName.");
} catch (e) {
@@ -209,13 +200,24 @@ class MediaUtil {
final completer = Completer<double>();
final imageStream = imageProvider.resolve(const ImageConfiguration());
imageStream.addListener(
ImageStreamListener((ImageInfo info, bool _) async {
final aspectRatio = info.image.width / info.image.height;
await _imageAspectRatioCache.put(key, aspectRatio);
completer.complete(aspectRatio);
}),
ImageStreamListener(
(ImageInfo info, bool _) async {
final aspectRatio = info.image.width / info.image.height;
await _imageAspectRatioCache.put(key, aspectRatio);
if (!completer.isCompleted) {
completer.complete(aspectRatio);
}
},
onError: (Object error, StackTrace? stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
},
),
);
return completer.future;
}
@@ -256,72 +258,144 @@ class MediaUtil {
}
// 通用压缩逻辑
static Future<void> _compressImage(
static Future<Uint8List?> compressImageData({
required String imagePath,
ImageFormat? imageFormat,
int? size,
double? imageAspectRatio,
}) async {
final imageFormat_ = imageFormat ?? ImageFormat.getImageFormat(imagePath);
return await switch (imageFormat_) {
ImageFormat.jpeg => _compressRust(
imagePath,
r_type.CompressFormat.jpeg,
size: size,
imageAspectRatio: imageAspectRatio,
),
ImageFormat.png => _compressRust(
imagePath,
r_type.CompressFormat.png,
size: size,
imageAspectRatio: imageAspectRatio,
),
ImageFormat.heic => _compressNative(
imagePath,
CompressFormat.heic,
size: size,
imageAspectRatio: imageAspectRatio,
),
ImageFormat.webp => _compressRust(
imagePath,
r_type.CompressFormat.webP,
size: size,
imageAspectRatio: imageAspectRatio,
),
};
}
/// 压缩图片并保存到指定路径
static Future<void> compressAndSaveImage(
XFile imageFile,
String outputPath,
dynamic format,
ImageFormat imageFormat,
) async {
// 如果选择了原图,则直接复制文件
if (PrefUtil.getValue<int>('quality') == 3) {
await imageFile.saveTo(outputPath);
return;
}
if (format == CompressFormat.heic) {
await _compressNative(imageFile, outputPath, format);
final newImage = await compressImageData(
imagePath: imageFile.path,
imageFormat: imageFormat,
);
if (newImage != null) {
await File(outputPath).writeAsBytes(newImage);
} else {
await _compressRust(imageFile, outputPath, format);
await imageFile.saveTo(outputPath);
}
}
static Future<void> _compressRust(
XFile oldImage,
String targetPath,
r_type.CompressFormat format,
) async {
final quality = switch (PrefUtil.getValue<int>('quality')) {
0 => 720,
1 => 1080,
2 => 1440,
_ => 1080,
};
final oldPath = oldImage.path;
final newImage = await ImageCompress.contain(
filePath: oldPath,
maxWidth: quality,
maxHeight: quality,
compressFormat: format,
);
await File(targetPath).writeAsBytes(newImage);
static Future<Uint8List?> _compressRust(
String imagePath,
r_type.CompressFormat format, {
int? size,
double? imageAspectRatio,
}) async {
final imageSize =
size ??
switch (PrefUtil.getValue<int>('quality')) {
0 => 720,
1 => 1080,
2 => 1440,
_ => 1080,
};
final oldPath = imagePath;
Uint8List? newImage;
try {
final imageAspect =
imageAspectRatio ??
await ImageCacheUtil().getImageAspectRatioWithCache(
imagePath: imagePath,
);
/// 计算新的宽高
/// 对于横图,高度为 size宽度按比例缩放
/// 对于竖图,宽度为 size高度按比例缩放
final width =
imageAspect < 1.0 ? imageSize : (imageSize * imageAspect).ceil();
final height =
imageAspect >= 1.0 ? imageSize : (imageSize / imageAspect).ceil();
newImage = await ImageCompress.containWithOptions(
filePath: oldPath,
targetHeight: height,
targetWidth: width,
compressFormat: format,
);
} catch (e) {
logger.d('Image compression failed: $e');
newImage = null;
}
return newImage;
}
//图片压缩
static Future<void> _compressNative(
XFile oldImage,
String targetPath,
CompressFormat format,
) async {
static Future<Uint8List?> _compressNative(
String imagePath,
CompressFormat format, {
int? size,
double? imageAspectRatio,
}) async {
if (Platform.isWindows) {
oldImage.saveTo(targetPath);
return;
return null;
}
final quality = PrefUtil.getValue<int>('quality');
final height = switch (quality) {
0 => 720,
1 => 1080,
2 => 1440,
_ => 1080,
};
final imageSize =
size ??
switch (quality) {
0 => 720,
1 => 1080,
2 => 1440,
_ => 1080,
};
final imageAspect =
imageAspectRatio ??
await ImageCacheUtil().getImageAspectRatioWithCache(
imagePath: imagePath,
);
/// 计算新的宽高
/// 对于横图,宽度为 size高度按比例缩放
/// 对于竖图,高度为 size宽度按比例缩放
final width =
imageAspect < 1.0 ? imageSize : (imageSize * imageAspect).ceil();
final height =
imageAspect >= 1.0 ? imageSize : (imageSize / imageAspect).ceil();
final newImage = await FlutterImageCompress.compressWithFile(
oldImage.path,
imagePath,
minHeight: height,
minWidth: height,
minWidth: width,
format: format,
);
if (newImage == null) {
oldImage.saveTo(targetPath);
return;
}
await File(targetPath).writeAsBytes(newImage);
return newImage;
}
//获取视频缩略图

View File

@@ -33,7 +33,7 @@ class WebDavUtil {
factory WebDavUtil() => _instance;
Future<void> initWebDav() async {
void initWebDav() {
final webDavOption = options;
if (webDavOption.isEmpty) {
_client = null;
@@ -104,7 +104,7 @@ class WebDavUtil {
required String password,
}) async {
await PrefUtil.setValue('webDavOption', [baseUrl, username, password]);
await initWebDav();
initWebDav();
}
Future<void> removeWebDavOption() async {

View File

@@ -198,6 +198,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.2.0"
auto_injector:
dependency: transitive
description:
name: auto_injector
sha256: ad7a95d7c381363d48b54e00cb680f024fd97009067244454e9b4850337608e8
url: "https://pub.dev"
source: hosted
version: "2.1.0"
auto_size_text_field:
dependency: "direct main"
description:
@@ -754,10 +762,10 @@ packages:
dependency: "direct main"
description:
name: fl_chart
sha256: "5276944c6ffc975ae796569a826c38a62d2abcf264e26b88fa6f482e107f4237"
sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
url: "https://pub.dev"
source: hosted
version: "0.70.2"
version: "0.71.0"
flutter:
dependency: "direct main"
description: flutter
@@ -772,7 +780,7 @@ packages:
source: hosted
version: "0.3.2"
flutter_cache_manager:
dependency: transitive
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
@@ -1048,6 +1056,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
flutter_modular:
dependency: "direct main"
description:
name: flutter_modular
sha256: bc17a1eb1da676b9111e59d27834fb6673bdea01aead12f0803a0847ff9d451c
url: "https://pub.dev"
source: hosted
version: "6.3.4"
flutter_native_splash:
dependency: "direct main"
description:
@@ -1170,14 +1186,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fluttertoast:
dependency: "direct main"
description:
name: fluttertoast
sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1"
url: "https://pub.dev"
source: hosted
version: "8.2.12"
font_awesome_flutter:
dependency: "direct main"
description:
@@ -1238,18 +1246,18 @@ packages:
dependency: "direct main"
description:
name: geolocator
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
sha256: e7ebfa04ce451daf39b5499108c973189a71a919aa53c1204effda1c5b93b822
url: "https://pub.dev"
source: hosted
version: "13.0.4"
version: "14.0.0"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
sha256: "114072db5d1dce0ec0b36af2697f55c133bc89a2c8dd513e137c0afe59696ed4"
url: "https://pub.dev"
source: hosted
version: "4.6.2"
version: "5.0.1+1"
geolocator_apple:
dependency: transitive
description:
@@ -1355,13 +1363,21 @@ packages:
source: hosted
version: "0.7.0"
hive_ce:
dependency: transitive
dependency: "direct main"
description:
name: hive_ce
sha256: ac66daee46ad46486a1ed12cf91e9d7479c875fb46889be8d2c96b557406647f
url: "https://pub.dev"
source: hosted
version: "2.10.1"
hive_ce_flutter:
dependency: "direct main"
description:
name: hive_ce_flutter
sha256: "74c1d5f10d803446b4e7913bb272137e2724ba8a56465444f9e7713aeb60a877"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
html:
dependency: transitive
description:
@@ -1803,6 +1819,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
modular_core:
dependency: transitive
description:
name: modular_core
sha256: bd60317c81cff3a510aca19d6ddd661c7c79e3cba97b9f39e9ad199156ff255d
url: "https://pub.dev"
source: hosted
version: "3.3.3"
moodiary_rust:
dependency: "direct main"
description:
@@ -2266,6 +2290,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
result_dart:
dependency: transitive
description:
name: result_dart
sha256: "3c69c864a08df0f413a86be211d07405e9a53cc1ac111e3cc8365845a0fb5288"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
retry:
dependency: transitive
description:
@@ -2434,14 +2466,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sheet:
dependency: "direct main"
description:
name: sheet
sha256: f7619b2bd5e031f206d8c22228ef2b12794192dea25b5af0862fa5e37fe6e36d
url: "https://pub.dev"
source: hosted
version: "1.0.0"
shelf:
dependency: "direct main"
description:
@@ -2635,18 +2659,18 @@ packages:
dependency: transitive
description:
name: syncfusion_flutter_core
sha256: a9b203c3b4ad5c3bfed3e82c3f43f6861c4f2e8a12dca43b8abb8f128ffe42b5
sha256: "98580db6186b46aae965d3f480f7830b0ee6f58c4e1bbbb3fb3de6a121f61836"
url: "https://pub.dev"
source: hosted
version: "29.1.37"
version: "29.1.38"
syncfusion_flutter_sliders:
dependency: "direct main"
description:
name: syncfusion_flutter_sliders
sha256: be2d61f6376be8eb3639537f73c2e69eb5837554d1ee369f82a4d1ed9adcaeb7
sha256: a0d479786701f505b3c29f48c1c7934e2e0ffe682adb72ed95b39918914eeea7
url: "https://pub.dev"
source: hosted
version: "29.1.37"
version: "29.1.38"
synchronized:
dependency: "direct main"
description:
@@ -2707,10 +2731,10 @@ packages:
dependency: "direct main"
description:
name: tutorial_coach_mark
sha256: "2c77c0b00bbe7d5b8a6d31cb9e03d44bf77dfe7ba6514cc2b546886d024a9945"
sha256: "9cdb721165d1cfb6e9b1910a1af1b3570fa6caa5059cf1506fcbd00bf7102abf"
url: "https://pub.dev"
source: hosted
version: "1.2.13"
version: "1.3.0"
typed_data:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: moodiary
description: "A fully open source cross-platform diary app written by flutter and rust."
publish_to: 'none'
version: 2.7.3+73
version: 2.7.3+74
environment:
sdk: '>=3.7.0'
@@ -19,7 +19,7 @@ dependencies:
logger: 2.5.0
flutter_drawing_board: 0.9.8
flutter_displaymode: 0.6.0
fl_chart: 0.70.2
fl_chart: 0.71.0
file_picker: 10.1.2
local_auth: 2.3.0
local_auth_android: 1.0.48
@@ -35,14 +35,13 @@ dependencies:
crypto: 3.0.6
markdown_widget: 2.3.2+6
flutter_colorpicker: 1.1.0
geolocator: 13.0.4
geolocator: 14.0.0
shared_preferences: 2.5.3
isar: 4.0.0-dev.14
isar_flutter_libs:
git:
url: https://github.com/ZhuJHua/isar
path: packages/isar_flutter_libs
fluttertoast: 8.2.12
cached_network_image: 3.4.1
audioplayers: 6.4.0
record: 6.0.0
@@ -76,7 +75,7 @@ dependencies:
encrypt: 5.0.3
faker: 2.2.0
flutter_rust_bridge: 2.9.0
syncfusion_flutter_sliders: 29.1.37
syncfusion_flutter_sliders: 29.1.38
flutter_quill_extensions: 11.0.0
connectivity_plus: 6.1.3
image_picker_android: 0.8.12+22
@@ -99,8 +98,7 @@ dependencies:
moodiary_rust:
path: rust_builder
modal_bottom_sheet: 3.0.0
sheet: 1.0.0
tutorial_coach_mark: 1.2.13
tutorial_coach_mark: 1.3.0
adaptive_dialog: 2.4.1
flutter_inappwebview: 6.1.5
markdown: 7.3.0
@@ -127,6 +125,10 @@ dependencies:
mobile_scanner: 7.0.0-beta.9
throttling: 2.0.1
gap: 3.0.1
flutter_cache_manager: 3.4.1
hive_ce: 2.10.1
hive_ce_flutter: 2.2.0
flutter_modular: 6.3.4
dev_dependencies:
flutter_test:

401
rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,12 @@ crate-type = ["cdylib", "staticlib"]
[dependencies]
flutter_rust_bridge = "=2.9.0"
image = "0.25.5"
fast_image_resize = { version = "5.1.2", features = ["image"] }
anyhow = "1.0.97"
image = "0.25.6"
fast_image_resize = { version = "5.1.3", features = ["image"] }
anyhow = "1.0.98"
ttf-parser = { git = "https://github.com/ZhuJHua/ttf-parser", branch = "fvar" }
ring = "0.17.14"
zip = "2.4.2"
zip = "2.6.1"
walkdir = "2.5.0"
[lints.rust]

View File

@@ -66,7 +66,6 @@ pub fn compress(
}
}
// 返回压缩结果
Ok(result_buf.into_inner()?)
}
@@ -74,11 +73,15 @@ pub fn compress(
pub struct ImageCompress;
impl ImageCompress {
pub fn contain(
pub fn contain_with_options(
file_path: String,
compress_format: Option<CompressFormat>,
max_width: Option<i32>,
max_height: Option<i32>,
target_width: Option<u32>,
target_height: Option<u32>,
min_width: Option<u32>,
min_height: Option<u32>,
max_width: Option<u32>,
max_height: Option<u32>,
quality: Option<u8>,
) -> Result<Vec<u8>> {
let src_img = Self::load_image(&file_path)?;
@@ -89,41 +92,64 @@ impl ImageCompress {
let (dst_width, dst_height) = Self::calculate_target_dimensions(
img_width,
img_height,
max_width.unwrap_or(1024) as u32,
max_height.unwrap_or(1024) as u32,
&ResizeOptions {
target_width,
target_height,
min_width,
min_height,
max_width,
max_height,
},
);
compress(&src_img, dst_height, dst_width, compress_format, quality)
}
fn calculate_target_dimensions(
img_width: u32,
img_height: u32,
options: &ResizeOptions,
) -> (u32, u32) {
if let (Some(w), Some(h)) = (options.target_width, options.target_height) {
return (w, h);
}
let aspect_ratio = img_width as f64 / img_height as f64;
if let Some(min_w) = options.min_width {
let ratio = min_w as f64 / img_width as f64;
return (min_w, (img_height as f64 * ratio).round() as u32);
}
if let Some(min_h) = options.min_height {
let ratio = min_h as f64 / img_height as f64;
return ((img_width as f64 * ratio).round() as u32, min_h);
}
let max_width = options.max_width.unwrap_or(1024);
let max_height = options.max_height.unwrap_or(1024);
if aspect_ratio > 1.0 {
let ratio = max_height as f64 / img_height as f64;
((img_width as f64 * ratio).round() as u32, max_height)
} else {
let ratio = max_width as f64 / img_width as f64;
(max_width, (img_height as f64 * ratio).round() as u32)
}
}
fn load_image(file_path: &str) -> Result<DynamicImage> {
ImageReader::open(file_path)?
.with_guessed_format()?
.decode()
.map_err(|e| anyhow::anyhow!("Failed to decode image: {}", e))
}
fn calculate_target_dimensions(
img_width: u32,
img_height: u32,
max_width: u32,
max_height: u32,
) -> (u32, u32) {
// 确保浮点计算
let aspect_ratio = img_width as f64 / img_height as f64;
if aspect_ratio > 1.0 {
// 横图,根据 max_height 缩放
let ratio = max_height as f64 / img_height as f64;
let dst_width = (img_width as f64 * ratio).round() as u32;
let dst_height = max_height;
(dst_width, dst_height)
} else {
// 竖图,根据 max_width 缩放
let ratio = max_width as f64 / img_width as f64;
let dst_width = max_width;
let dst_height = (img_height as f64 * ratio).round() as u32;
(dst_width, dst_height)
}
}
}
pub struct ResizeOptions {
pub target_width: Option<u32>,
pub target_height: Option<u32>,
pub min_width: Option<u32>,
pub min_height: Option<u32>,
pub max_width: Option<u32>,
pub max_height: Option<u32>,
}

View File

@@ -42,7 +42,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.9.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1427840433;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 199828844;
// Section: executor
@@ -230,7 +230,7 @@ fn wire__crate__api__font__FontReader_get_wght_axis_from_vf_font_impl(
},
)
}
fn wire__crate__api__compress__ImageCompress_contain_impl(
fn wire__crate__api__compress__ImageCompress_contain_with_options_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
@@ -238,7 +238,7 @@ fn wire__crate__api__compress__ImageCompress_contain_impl(
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "ImageCompress_contain",
debug_name: "ImageCompress_contain_with_options",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
@@ -255,16 +255,24 @@ fn wire__crate__api__compress__ImageCompress_contain_impl(
let api_file_path = <String>::sse_decode(&mut deserializer);
let api_compress_format =
<Option<crate::api::constants::CompressFormat>>::sse_decode(&mut deserializer);
let api_max_width = <Option<i32>>::sse_decode(&mut deserializer);
let api_max_height = <Option<i32>>::sse_decode(&mut deserializer);
let api_target_width = <Option<u32>>::sse_decode(&mut deserializer);
let api_target_height = <Option<u32>>::sse_decode(&mut deserializer);
let api_min_width = <Option<u32>>::sse_decode(&mut deserializer);
let api_min_height = <Option<u32>>::sse_decode(&mut deserializer);
let api_max_width = <Option<u32>>::sse_decode(&mut deserializer);
let api_max_height = <Option<u32>>::sse_decode(&mut deserializer);
let api_quality = <Option<u8>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || {
let output_ok = crate::api::compress::ImageCompress::contain(
let output_ok = crate::api::compress::ImageCompress::contain_with_options(
api_file_path,
api_compress_format,
api_target_width,
api_target_height,
api_min_width,
api_min_height,
api_max_width,
api_max_height,
api_quality,
@@ -1013,11 +1021,11 @@ impl SseDecode for Option<crate::api::constants::CompressFormat> {
}
}
impl SseDecode for Option<i32> {
impl SseDecode for Option<u32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<i32>::sse_decode(deserializer));
return Some(<u32>::sse_decode(deserializer));
} else {
return None;
}
@@ -1112,7 +1120,7 @@ fn pde_ffi_dispatcher_primary_impl(
rust_vec_len,
data_len,
),
6 => wire__crate__api__compress__ImageCompress_contain_impl(
6 => wire__crate__api__compress__ImageCompress_contain_with_options_impl(
port,
ptr,
rust_vec_len,
@@ -1493,12 +1501,12 @@ impl SseEncode for Option<crate::api::constants::CompressFormat> {
}
}
impl SseEncode for Option<i32> {
impl SseEncode for Option<u32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<i32>::sse_encode(value, serializer);
<u32>::sse_encode(value, serializer);
}
}
}

View File

@@ -1,2 +1,3 @@
pub mod api;
mod test;
mod frb_generated;

35
rust/src/test/aes_test.rs Normal file
View File

@@ -0,0 +1,35 @@
#[cfg(test)]
mod aes_test {
use crate::api::aes::AesEncryption;
#[test]
fn test_derive_key_consistency() {
let salt = "salt123".to_string();
let user_key = "password456".to_string();
let key1 = AesEncryption::derive_key(salt.clone(), user_key.clone()).unwrap();
let key2 = AesEncryption::derive_key(salt, user_key).unwrap();
assert_eq!(key1, key2);
}
#[test]
fn test_encrypt_decrypt() {
let salt = "testsalt".to_string();
let user_key = "testpassword".to_string();
let key = AesEncryption::derive_key(salt, user_key).unwrap();
let original_data = b"Hello Flutter Rust Bridge!".to_vec();
let encrypted = AesEncryption::encrypt(key.clone(), original_data.clone()).unwrap();
let decrypted = AesEncryption::decrypt(key, encrypted).unwrap();
assert_eq!(original_data, decrypted);
}
#[test]
fn test_decrypt_invalid_data() {
let key = vec![0u8; 32]; // dummy key
let bad_data = vec![1, 2, 3]; // too short
let result = AesEncryption::decrypt(key, bad_data);
assert!(result.is_err());
}
}

73
rust/src/test/kmp_test.rs Normal file
View File

@@ -0,0 +1,73 @@
#[cfg(test)]
mod kmp_test {
use crate::api::kmp::{kmp_search, Kmp};
use std::collections::HashMap;
#[test]
fn test_kmp_basic_match() {
let text = "ababcabcababc";
let pattern = "abc";
let matches = kmp_search(text, pattern);
assert_eq!(matches, vec![2, 5, 10]);
}
#[test]
fn test_replace_with_kmp_single() {
let text = "hello world, hello rust";
let mut replacements = HashMap::new();
replacements.insert("hello".to_string(), "hi".to_string());
let result = Kmp::replace_with_kmp(text.to_string(), replacements);
assert_eq!(result, "hi world, hi rust");
}
#[test]
fn test_replace_with_kmp_multiple_overlap() {
let text = "abcde";
let mut replacements = HashMap::new();
replacements.insert("abc".to_string(), "123".to_string());
replacements.insert("bcd".to_string(), "234".to_string());
let result = Kmp::replace_with_kmp(text.to_string(), replacements);
// 应优先替换更长的匹配从左到右“abc”会匹配成功然后跳过“bcd”
assert_eq!(result, "123de");
}
#[test]
fn test_replace_with_kmp_unicode() {
let text = "你好世界,世界你好";
let mut replacements = HashMap::new();
replacements.insert("世界".to_string(), "🌍".to_string());
let result = Kmp::replace_with_kmp(text.to_string(), replacements);
assert_eq!(result, "你好🌍,🌍你好");
}
#[test]
fn test_find_matches() {
let text = "flutter and rust are cool";
let patterns = vec![
"flutter".to_string(),
"rust".to_string(),
"dart".to_string(),
];
let result = Kmp::find_matches(text, patterns);
assert_eq!(result, vec!["flutter".to_string(), "rust".to_string()]);
}
#[test]
fn test_find_matches_empty() {
let text = "no match here";
let patterns = vec!["something".to_string(), "nothing".to_string()];
let result = Kmp::find_matches(text, patterns);
assert!(result.is_empty());
}
#[test]
fn test_empty_replacements() {
let text = "keep this";
let replacements = HashMap::new();
let result = Kmp::replace_with_kmp(text.to_string(), replacements);
assert_eq!(result, "keep this");
}
}

2
rust/src/test/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
mod aes_test;
mod kmp_test;