fix: сделал сжатие для фоток при отправке. 80 мб фото это нечто

This commit is contained in:
Jganenokk
2026-07-24 13:13:33 +07:00
parent 59d06d725f
commit d258f462c4
9 changed files with 269 additions and 17 deletions
+22
View File
@@ -14,6 +14,12 @@ abstract class GalleryItem {
Future<Uint8List?> thumbnail(int size);
Future<File?> originFile();
Future<(int, int)?> dimensions();
Future<Uint8List?> encodeForUpload({
required int maxDimension,
required int quality,
});
static GalleryItem fromFile(File file) => _FileGalleryItem(file);
}
class PickedPhoto {
@@ -109,6 +115,16 @@ class _AssetGalleryItem implements GalleryItem {
@override
Future<File?> originFile() => asset.file;
@override
Future<Uint8List?> encodeForUpload({
required int maxDimension,
required int quality,
}) => asset.thumbnailDataWithSize(
ThumbnailSize(maxDimension, maxDimension),
format: ThumbnailFormat.jpeg,
quality: quality,
);
@override
Future<(int, int)?> dimensions() async {
if (asset.width > 0 && asset.height > 0) {
@@ -197,6 +213,12 @@ class _FileGalleryItem implements GalleryItem {
@override
Future<File?> originFile() async => file;
@override
Future<Uint8List?> encodeForUpload({
required int maxDimension,
required int quality,
}) async => null;
@override
Future<(int, int)?> dimensions() => imageFileDimensions(file);
}
+96
View File
@@ -0,0 +1,96 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'gallery_source.dart';
const int kPhotoUploadLimitBytes = 5 * 1024 * 1024;
const int _photoMaxDimension = 2560;
const int _photoJpegQuality = 85;
const int _photoTargetBytes = 4700 * 1024;
const Set<String> _heicExtensions = {'.heic', '.heif'};
bool isHeicPath(String path) {
final dot = path.lastIndexOf('.');
if (dot < 0) return false;
return _heicExtensions.contains(path.substring(dot).toLowerCase());
}
Future<File> optimizePhotoForUpload(File source, {GalleryItem? item}) async {
final heic = isHeicPath(source.path);
var length = 0;
try {
length = await source.length();
} catch (_) {}
if (!heic && length > 0 && length <= kPhotoUploadLimitBytes) {
return source;
}
if (item != null) {
final native = await item.encodeForUpload(
maxDimension: _photoMaxDimension,
quality: _photoJpegQuality,
);
if (native != null) return writePhotoJpeg(native);
}
final fallback = await _encodeWithImagePackage(source);
return fallback ?? source;
}
Future<File?> _encodeWithImagePackage(File source) async {
Uint8List bytes;
try {
bytes = await source.readAsBytes();
} catch (_) {
return null;
}
final jpeg = await compute(_encodePhotoIsolate, (
bytes,
_photoMaxDimension,
_photoJpegQuality,
_photoTargetBytes,
));
if (jpeg == null) return null;
return writePhotoJpeg(jpeg);
}
Uint8List? _encodePhotoIsolate((Uint8List, int, int, int) args) {
final (bytes, maxDim, quality, target) = args;
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
final oriented = img.bakeOrientation(decoded);
final scaled = oriented.width > maxDim || oriented.height > maxDim
? img.copyResize(
oriented,
width: oriented.width >= oriented.height ? maxDim : null,
height: oriented.height > oriented.width ? maxDim : null,
interpolation: img.Interpolation.average,
)
: oriented;
var quality0 = quality;
var out = img.encodeJpg(scaled, quality: quality0);
while (out.lengthInBytes > target && quality0 > 40) {
quality0 -= 10;
out = img.encodeJpg(scaled, quality: quality0);
}
return out;
}
Future<File> writePhotoJpeg(Uint8List bytes) async {
final dir = await getTemporaryDirectory();
final file = File(
p.join(
dir.path,
'komet_photo_${DateTime.now().microsecondsSinceEpoch}.jpg',
),
);
await file.writeAsBytes(bytes, flush: true);
return file;
}
+48
View File
@@ -0,0 +1,48 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../core/media/gallery_source.dart';
import 'slash_command.dart';
Future<void> sendFileAsPhoto(CommandContext ctx, File file) =>
ctx.sendPhotos([PickedPhoto(item: GalleryItem.fromFile(file))], '');
Future<File> _tempFile(String extension) async {
final dir = await getTemporaryDirectory();
return File(
p.join(
dir.path,
'komet_probe_${DateTime.now().microsecondsSinceEpoch}.$extension',
),
);
}
Future<File> assetToTempFile(
String assetPath, {
required String extension,
}) async {
final data = await rootBundle.load(assetPath);
final file = await _tempFile(extension);
await file.writeAsBytes(data.buffer.asUint8List(), flush: true);
return file;
}
Future<File> buildProbeZipFile({
required String extension,
List<int>? prefix,
}) async {
final archive = Archive();
final data = utf8.encode('This is a zip, not a photo. Komet probe.');
archive.addFile(ArchiveFile('not_a_photo.txt', data.length, data));
final zip = ZipEncoder().encodeBytes(archive);
final bytes = prefix == null ? zip : <int>[...prefix, ...zip];
final file = await _tempFile(extension);
await file.writeAsBytes(bytes, flush: true);
return file;
}
@@ -0,0 +1,26 @@
import 'probe_send.dart';
import 'slash_command.dart';
Future<void> runSend1x1(CommandContext ctx) async {
try {
final file = await assetToTempFile(
'assets/debug/red_1x1.png',
extension: 'png',
);
await sendFileAsPhoto(ctx, file);
} catch (e) {
ctx.notify('Не удалось отправить 1×1: $e');
}
}
Future<void> runSend1x8192(CommandContext ctx) async {
try {
final file = await assetToTempFile(
'assets/debug/red_1x8192.png',
extension: 'png',
);
await sendFileAsPhoto(ctx, file);
} catch (e) {
ctx.notify('Не удалось отправить 1×8192: $e');
}
}
@@ -0,0 +1,21 @@
import 'probe_send.dart';
import 'slash_command.dart';
const List<int> _jpegMagic = [
0xFF, 0xD8, 0xFF, 0xE0, // SOI + APP0 marker
0x00, 0x10, // APP0 length (16)
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
0x01, 0x01, // version 1.1
0x00, // density units
0x00, 0x01, 0x00, 0x01, // X/Y density
0x00, 0x00, // thumbnail 0x0
];
Future<void> runSendFakeZipAsJpeg(CommandContext ctx) async {
try {
final file = await buildProbeZipFile(extension: 'jpeg', prefix: _jpegMagic);
await sendFileAsPhoto(ctx, file);
} catch (e) {
ctx.notify('Не удалось отправить fake jpeg: $e');
}
}
@@ -0,0 +1,14 @@
import 'probe_send.dart';
import 'slash_command.dart';
Future<void> runSendHeic(CommandContext ctx) async {
try {
final file = await assetToTempFile(
'assets/debug/red.heic',
extension: 'heic',
);
await sendFileAsPhoto(ctx, file);
} catch (e) {
ctx.notify('Не удалось отправить .heic: $e');
}
}
@@ -0,0 +1,10 @@
import 'probe_send.dart';
import 'slash_command.dart';
Future<void> runSendZipAsImage(CommandContext ctx) async {
try {
await sendFileAsPhoto(ctx, await buildProbeZipFile(extension: 'zip'));
} catch (e) {
ctx.notify('Не удалось отправить zip: $e');
}
}
+4
View File
@@ -1,4 +1,5 @@
import '../../backend/modules/messages.dart';
import '../../core/media/gallery_source.dart';
const String kAntiFloodNotification =
'Упс! МАХ сбросил соединение, кажется, тебе стоит немного помедлить с командами.';
@@ -15,6 +16,8 @@ class CommandContext {
final void Function(String message, {Duration? duration}) notify;
final Future<String> Function(String text) postMessage;
final Future<void> Function(String id, String text) updateMessage;
final Future<void> Function(List<PickedPhoto> photos, String caption)
sendPhotos;
const CommandContext({
required this.accountId,
@@ -27,6 +30,7 @@ class CommandContext {
required this.notify,
required this.postMessage,
required this.updateMessage,
required this.sendPhotos,
});
void notifyAntiFlood() =>
+28 -17
View File
@@ -14,6 +14,7 @@ import 'package:komet/backend/modules/comments.dart';
import 'package:komet/backend/modules/file_uploader.dart';
import 'package:komet/backend/modules/upload_notification_service.dart';
import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/core/media/image_optimizer.dart';
import 'package:komet/core/utils/format.dart';
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
import 'package:komet/frontend/screens/contacts/open_contact_profile.dart';
@@ -3766,6 +3767,7 @@ class _ChatScreenState extends State<ChatScreen>
},
postMessage: _postCommandMessage,
updateMessage: _updateCommandMessage,
sendPhotos: _sendPhotos,
);
Future<void> _scheduleMessage() async {
@@ -5566,7 +5568,7 @@ class _ChatScreenState extends State<ChatScreen>
}
if (photos.isEmpty) return;
final files = <File>[];
final jobs = <({File file, GalleryItem? item})>[];
final attachments = <PhotoAttachment>[];
for (final photo in photos) {
final edited = photo.editedFile;
@@ -5576,17 +5578,17 @@ class _ChatScreenState extends State<ChatScreen>
final dim = edited != null
? await imageFileDimensions(edited)
: await photo.item.dimensions();
files.add(file);
jobs.add((file: file, item: edited == null ? photo.item : null));
attachments.add(
PhotoAttachment(localPath: file.path, width: dim?.$1, height: dim?.$2),
);
}
if (files.isEmpty || !mounted) return;
if (jobs.isEmpty || !mounted) return;
final tempId = _nextTempId();
final now = DateTime.now().millisecondsSinceEpoch;
final progress = ValueNotifier<List<double>>(
List<double>.filled(files.length, 0),
List<double>.filled(jobs.length, 0),
);
_photoUploadProgress[tempId] = progress;
@@ -5608,7 +5610,7 @@ class _ChatScreenState extends State<ChatScreen>
_scrollToBottom();
try {
final tokens = await _uploadPhotos(files, progress);
final tokens = await _uploadPhotos(jobs, progress);
if (!mounted) {
_disposePhotoProgress(tempId);
return;
@@ -5618,7 +5620,7 @@ class _ChatScreenState extends State<ChatScreen>
return;
}
progress.value = List<double>.filled(files.length, 1);
progress.value = List<double>.filled(jobs.length, 1);
final serverMsg = await messagesModule.sendPhotoMessage(
widget.chatId,
@@ -5782,21 +5784,23 @@ class _ChatScreenState extends State<ChatScreen>
}
if (photos.isEmpty) return;
final files = <File>[];
final jobs = <({File file, GalleryItem? item})>[];
for (final photo in photos) {
final edited = photo.editedFile;
final file =
edited ?? photo.item.localFile ?? await photo.item.originFile();
if (file != null) files.add(file);
if (file != null) {
jobs.add((file: file, item: edited == null ? photo.item : null));
}
}
if (files.isEmpty || !mounted) return;
if (jobs.isEmpty || !mounted) return;
showCustomNotification(context, 'Загрузка…');
final progress = ValueNotifier<List<double>>(
List<double>.filled(files.length, 0),
List<double>.filled(jobs.length, 0),
);
try {
final tokens = await _uploadPhotos(files, progress);
final tokens = await _uploadPhotos(jobs, progress);
if (!mounted) return;
if (tokens.any((t) => t == null)) {
showCustomNotification(context, 'Не удалось загрузить фото');
@@ -5968,18 +5972,18 @@ class _ChatScreenState extends State<ChatScreen>
static const int _photoUploadAttempts = 3;
Future<List<String?>> _uploadPhotos(
List<File> files,
List<({File file, GalleryItem? item})> jobs,
ValueNotifier<List<double>> progress,
) async {
final tokens = List<String?>.filled(files.length, null);
final tokens = List<String?>.filled(jobs.length, null);
var nextIndex = 0;
var failed = false;
Future<void> worker() async {
while (!failed) {
final i = nextIndex++;
if (i >= files.length) return;
final token = await _uploadOnePhoto(files[i], i, progress);
if (i >= jobs.length) return;
final token = await _uploadOnePhoto(jobs[i], i, progress);
if (token == null) {
failed = true;
return;
@@ -5988,16 +5992,23 @@ class _ChatScreenState extends State<ChatScreen>
}
}
final workerCount = math.min(_photoUploadConcurrency, files.length);
final workerCount = math.min(_photoUploadConcurrency, jobs.length);
await Future.wait(List.generate(workerCount, (_) => worker()));
return tokens;
}
Future<String?> _uploadOnePhoto(
File file,
({File file, GalleryItem? item}) job,
int index,
ValueNotifier<List<double>> progress,
) async {
File file;
try {
file = await optimizePhotoForUpload(job.file, item: job.item);
} catch (e) {
logger.w('optimize photo: $e');
file = job.file;
}
for (var attempt = 0; attempt < _photoUploadAttempts; attempt++) {
if (attempt > 0) {
await Future.delayed(Duration(seconds: attempt));