fix: сделал сжатие для фоток при отправке. 80 мб фото это нечто
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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() =>
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user