diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c1805979..d5e0fcc6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -6372,6 +6372,24 @@ abstract class AppLocalizations { /// **'The selected cover will replace the current embedded cover when you tap Save.'** String get trackCoverReplaceNotice; + /// Label for selecting the embedded cover art resolution + /// + /// In en, this message translates to: + /// **'Cover resolution'** + String get trackCoverResolution; + + /// Explanation below the embedded cover art resolution selector + /// + /// In en, this message translates to: + /// **'Sets the longest edge when saved. Enlarging does not add image detail.'** + String get trackCoverResolutionHint; + + /// Error shown when resizing cover art before saving metadata fails + /// + /// In en, this message translates to: + /// **'The cover image could not be resized. Please try another size or image.'** + String get trackCoverResizeFailed; + /// Generic action - stop /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 547db1a7..88d985b5 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3895,6 +3895,17 @@ class AppLocalizationsDe extends AppLocalizations { String get trackCoverReplaceNotice => 'Das ausgewählte Cover ersetzt das aktuell eingebettete Cover, wenn auf speichern gedrückt wird.'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => 'Stop'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7d5d3d91..a9c025b0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3849,6 +3849,17 @@ class AppLocalizationsEn extends AppLocalizations { String get trackCoverReplaceNotice => 'The selected cover will replace the current embedded cover when you tap Save.'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => 'Stop'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index d76b7bf5..9b5b571e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3843,6 +3843,17 @@ class AppLocalizationsEs extends AppLocalizations { String get trackCoverReplaceNotice => 'The selected cover will replace the current embedded cover when you tap Save.'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => 'Stop'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index fb9a2d56..24a30cda 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3950,6 +3950,17 @@ class AppLocalizationsFr extends AppLocalizations { String get trackCoverReplaceNotice => 'La pochette sélectionnée remplacera la pochette actuellement intégrée lorsque vous appuierez sur « Enregistrer ».'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => 'Arrêter'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index b7a254b5..f3f653c5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3846,6 +3846,17 @@ class AppLocalizationsId extends AppLocalizations { String get trackCoverReplaceNotice => 'The selected cover will replace the current embedded cover when you tap Save.'; + @override + String get trackCoverResolution => 'Resolusi cover'; + + @override + String get trackCoverResolutionHint => + 'Mengatur sisi terpanjang saat disimpan. Memperbesar gambar tidak menambah detail.'; + + @override + String get trackCoverResizeFailed => + 'Ukuran cover tidak dapat diubah. Coba ukuran atau gambar lain.'; + @override String get actionStop => 'Stop'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index a1dafd65..9cd8b080 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3838,6 +3838,17 @@ class AppLocalizationsJa extends AppLocalizations { String get trackCoverReplaceNotice => 'The selected cover will replace the current embedded cover when you tap Save.'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => 'Stop'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 5ec4d9ce..8710bc4c 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3739,6 +3739,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackCoverReplaceNotice => '저장을 탭하면 선택한 표지가 현재 내장된 표지를 대체합니다'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => '중지'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index b6015954..8bfeeaa6 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3843,6 +3843,17 @@ class AppLocalizationsPt extends AppLocalizations { String get trackCoverReplaceNotice => 'The selected cover will replace the current embedded cover when you tap Save.'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => 'Stop'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 28821503..23145da2 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3880,6 +3880,17 @@ class AppLocalizationsRu extends AppLocalizations { String get trackCoverReplaceNotice => 'The selected cover will replace the current embedded cover when you tap Save.'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => 'Stop'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 38310394..b8722a6a 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3879,6 +3879,17 @@ class AppLocalizationsTr extends AppLocalizations { String get trackCoverReplaceNotice => 'The selected cover will replace the current embedded cover when you tap Save.'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => 'Stop'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 529c110e..ccdb3b1d 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3897,6 +3897,17 @@ class AppLocalizationsUk extends AppLocalizations { String get trackCoverReplaceNotice => 'The selected cover will replace the current embedded cover when you tap Save.'; + @override + String get trackCoverResolution => 'Cover resolution'; + + @override + String get trackCoverResolutionHint => + 'Sets the longest edge when saved. Enlarging does not add image detail.'; + + @override + String get trackCoverResizeFailed => + 'The cover image could not be resized. Please try another size or image.'; + @override String get actionStop => 'Stop'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 31b736c3..02f97357 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4992,6 +4992,18 @@ "@trackCoverReplaceNotice": { "description": "Notice shown when a new cover has been selected but not saved yet" }, + "trackCoverResolution": "Cover resolution", + "@trackCoverResolution": { + "description": "Label for selecting the embedded cover art resolution" + }, + "trackCoverResolutionHint": "Sets the longest edge when saved. Enlarging does not add image detail.", + "@trackCoverResolutionHint": { + "description": "Explanation below the embedded cover art resolution selector" + }, + "trackCoverResizeFailed": "The cover image could not be resized. Please try another size or image.", + "@trackCoverResizeFailed": { + "description": "Error shown when resizing cover art before saving metadata fails" + }, "actionStop": "Stop", "@actionStop": { "description": "Generic action - stop" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 3d2e4ed1..578e60be 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1656,6 +1656,9 @@ "description": "App bar title for the backup and restore page" }, "trackCoverReplaceNotice": "The selected cover will replace the current embedded cover when you tap Save.", + "trackCoverResolution": "Resolusi cover", + "trackCoverResolutionHint": "Mengatur sisi terpanjang saat disimpan. Memperbesar gambar tidak menambah detail.", + "trackCoverResizeFailed": "Ukuran cover tidak dapat diubah. Coba ukuran atau gambar lain.", "cacheSectionMaintenance": "Maintenance", "@cacheSectionMaintenance": { "description": "Section header for cleanup actions" diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 2fa4172c..ad2c4d40 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -42,6 +42,7 @@ class _EditMetadataSheet extends StatefulWidget { } class _EditMetadataSheetState extends State<_EditMetadataSheet> { + static const _coverResizeDimensions = [500, 1000, 1500, 2000, 3000]; static final RegExp _metadataCollapsePattern = RegExp(r'[^a-z0-9]+'); static final RegExp _metadataWhitespacePattern = RegExp(r'\s+'); static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); @@ -58,6 +59,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String? _currentCoverPath; String? _currentCoverTempDir; bool _loadingCurrentCover = false; + int? _coverMaxDimension; String? _selectedMetadataProviderId; _AutoFillPreview? _autoFillPreview; final GlobalKey _sheetMessengerKey = @@ -181,6 +183,101 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } + Future _showCoverResolutionPicker() async { + FocusScope.of(context).unfocus(); + final currentValue = _coverMaxDimension ?? 0; + final selectedValue = await showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + builder: (sheetContext) { + final cs = Theme.of(sheetContext).colorScheme; + final options = <({int value, String label})>[ + (value: 0, label: sheetContext.l10n.trackConvertOriginal), + for (final dimension in _coverResizeDimensions) + (value: dimension, label: '$dimension px'), + ]; + return SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.72, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + sheetContext.l10n.trackCoverResolution, + style: Theme.of(sheetContext).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 16), + child: SettingsGroup( + children: [ + for (var index = 0; index < options.length; index++) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + leading: Icon( + options[index].value == 0 + ? Icons.photo_size_select_actual_outlined + : Icons.photo_size_select_large_outlined, + color: options[index].value == currentValue + ? cs.primary + : cs.onSurfaceVariant, + ), + title: Text(options[index].label), + selected: options[index].value == currentValue, + selectedColor: cs.primary, + trailing: options[index].value == currentValue + ? const Icon(Icons.check_rounded) + : null, + onTap: () => Navigator.pop( + sheetContext, + options[index].value, + ), + ), + if (index != options.length - 1) + Divider( + height: 1, + indent: 60, + endIndent: 20, + color: cs.outlineVariant.withValues( + alpha: 0.3, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + + if (!mounted || selectedValue == null) return; + setState(() { + _coverMaxDimension = selectedValue == 0 ? null : selectedValue; + }); + } + Widget _metadataProviderOption({ required BuildContext context, required String id, @@ -934,14 +1031,11 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { results = await PlatformBridge.customSearchWithExtension( selectedProvider.id, query, - options: { - 'limit': 5, - if (trackFilter != null) 'filter': trackFilter, - }, + options: {'limit': 5, 'filter': ?trackFilter}, ); } else { results = await PlatformBridge.searchTracksWithMetadataProvider( - selectedProvider!.id, + selectedProvider.id, query, limit: 5, ); @@ -1334,29 +1428,55 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Future _save() async { setState(() => _saving = true); - - final metadata = { - 'title': _titleCtrl.text, - 'artist': _artistCtrl.text, - 'album': _albumCtrl.text, - 'album_artist': _albumArtistCtrl.text, - 'date': _dateCtrl.text, - 'track_number': _trackNumCtrl.text, - 'track_total': _trackTotalCtrl.text, - 'disc_number': _discNumCtrl.text, - 'disc_total': _discTotalCtrl.text, - 'genre': _genreCtrl.text, - 'isrc': _isrcCtrl.text, - 'lyrics': _lyricsCtrl.text, - 'label': _labelCtrl.text, - 'copyright': _copyrightCtrl.text, - 'composer': _composerCtrl.text, - 'comment': _commentCtrl.text, - 'cover_path': _selectedCoverPath ?? '', - 'artist_tag_mode': widget.artistTagMode, - }; - + final noCoverMessage = context.l10n.trackCoverNoEmbeddedArt; + final resizeFailedMessage = context.l10n.trackCoverResizeFailed; + Directory? resizedCoverTempDir; try { + String? coverPathForSave = _selectedCoverPath; + final requestedCoverDimension = _coverMaxDimension; + if (requestedCoverDimension != null) { + final sourceCoverPath = _selectedCoverPath ?? _currentCoverPath; + if (!_hasValue(sourceCoverPath)) { + throw StateError(noCoverMessage); + } + resizedCoverTempDir = await Directory.systemTemp.createTemp( + 'edit_resized_cover_', + ); + final resizedCoverPath = + '${resizedCoverTempDir.path}${Platform.pathSeparator}' + 'cover_${requestedCoverDimension}px.jpg'; + final resized = await FFmpegService.resizeCoverArt( + inputPath: sourceCoverPath!, + outputPath: resizedCoverPath, + maxDimension: requestedCoverDimension, + ); + if (!resized) { + throw StateError(resizeFailedMessage); + } + coverPathForSave = resizedCoverPath; + } + + final metadata = { + 'title': _titleCtrl.text, + 'artist': _artistCtrl.text, + 'album': _albumCtrl.text, + 'album_artist': _albumArtistCtrl.text, + 'date': _dateCtrl.text, + 'track_number': _trackNumCtrl.text, + 'track_total': _trackTotalCtrl.text, + 'disc_number': _discNumCtrl.text, + 'disc_total': _discTotalCtrl.text, + 'genre': _genreCtrl.text, + 'isrc': _isrcCtrl.text, + 'lyrics': _lyricsCtrl.text, + 'label': _labelCtrl.text, + 'copyright': _copyrightCtrl.text, + 'composer': _composerCtrl.text, + 'comment': _commentCtrl.text, + 'cover_path': coverPathForSave ?? '', + 'artist_tag_mode': widget.artistTagMode, + }; + final result = await PlatformBridge.editFileMetadata( widget.filePath, metadata, @@ -1371,7 +1491,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ); } - setState(() => _saving = false); return; } @@ -1447,7 +1566,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { // Lyrics/ReplayGain preservation is best-effort. } - String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath; + String? existingCoverPath = coverPathForSave ?? _currentCoverPath; String? extractedCoverPath; if (existingCoverPath == null || existingCoverPath.isEmpty) { // Preserve current embedded cover when user does not pick a new one. @@ -1513,15 +1632,15 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (mounted) { _showSheetSnackBar(context.l10n.metadataSaveFailedFfmpeg); } - setState(() => _saving = false); return; } if (tempPath != null && safUri != null) { final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); - if (!ok && mounted) { - _showSheetSnackBar(context.l10n.metadataSaveFailedStorage); - setState(() => _saving = false); + if (!ok) { + if (mounted) { + _showSheetSnackBar(context.l10n.metadataSaveFailedStorage); + } return; } } @@ -1537,6 +1656,13 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } } finally { + if (resizedCoverTempDir != null) { + try { + if (await resizedCoverTempDir.exists()) { + await resizedCoverTempDir.delete(recursive: true); + } + } catch (_) {} + } if (mounted) setState(() => _saving = false); } } @@ -2114,6 +2240,21 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), ), ], + const SizedBox(height: 12), + _selectionField( + label: context.l10n.trackCoverResolution, + value: _coverMaxDimension == null + ? context.l10n.trackConvertOriginal + : '${_coverMaxDimension!} px', + enabled: !_saving, + onTap: _showCoverResolutionPicker, + ), + Text( + context.l10n.trackCoverResolutionHint, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), ], const SizedBox(height: 8), ], diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index fee4f1cc..dfbcdb37 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; @@ -296,6 +296,84 @@ class FFmpegService { } } + @visibleForTesting + static List buildCoverResizeArguments({ + required String inputPath, + required String outputPath, + required int maxDimension, + }) { + if (maxDimension < 64 || maxDimension > 8192) { + throw ArgumentError.value( + maxDimension, + 'maxDimension', + 'Must be between 64 and 8192 pixels', + ); + } + return [ + '-y', + '-i', + inputPath, + '-map_metadata', + '-1', + '-an', + '-sn', + '-vf', + 'scale=$maxDimension:$maxDimension:' + 'force_original_aspect_ratio=decrease:flags=lanczos', + '-frames:v', + '1', + '-update', + '1', + '-q:v', + '2', + outputPath, + ]; + } + + /// Resizes cover art so its longest edge is [maxDimension]. + /// + /// FFmpeg is allowed to upscale smaller sources because this is an explicit + /// user choice. Aspect ratio is preserved and the result is written as the + /// format implied by [outputPath]. + static Future resizeCoverArt({ + required String inputPath, + required String outputPath, + required int maxDimension, + }) async { + try { + final source = File(inputPath); + if (!await source.exists()) return false; + + final result = await _executeWithArguments( + buildCoverResizeArguments( + inputPath: inputPath, + outputPath: outputPath, + maxDimension: maxDimension, + ), + ); + final output = File(outputPath); + final hasOutput = await output.exists() && await output.length() > 0; + if (!result.success || !hasOutput) { + _log.w( + 'Cover resize failed (${result.returnCode}): ' + '${_previewCommandForLog(result.output)}', + ); + try { + if (await output.exists()) await output.delete(); + } catch (_) {} + return false; + } + return true; + } catch (e) { + _log.w('Cover resize failed: $e'); + try { + final output = File(outputPath); + if (await output.exists()) await output.delete(); + } catch (_) {} + return false; + } + } + static Future probePrimaryAudioCodec(String filePath) async { try { final session = await FFprobeKit.getMediaInformation(filePath); diff --git a/test/ffmpeg_cover_resize_test.dart b/test/ffmpeg_cover_resize_test.dart new file mode 100644 index 00000000..a0449945 --- /dev/null +++ b/test/ffmpeg_cover_resize_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; + +void main() { + test('cover resize uses safe arguments and preserves aspect ratio', () { + final arguments = FFmpegService.buildCoverResizeArguments( + inputPath: '/tmp/input cover.jpg', + outputPath: '/tmp/output cover.jpg', + maxDimension: 1500, + ); + + expect(arguments, containsAllInOrder(['-i', '/tmp/input cover.jpg'])); + expect( + arguments, + containsAllInOrder([ + '-vf', + 'scale=1500:1500:force_original_aspect_ratio=decrease:flags=lanczos', + ]), + ); + expect(arguments, containsAllInOrder(['-frames:v', '1', '-update', '1'])); + expect(arguments.last, '/tmp/output cover.jpg'); + }); + + test('cover resize rejects unreasonable dimensions', () { + expect( + () => FFmpegService.buildCoverResizeArguments( + inputPath: 'input.jpg', + outputPath: 'output.jpg', + maxDimension: 0, + ), + throwsArgumentError, + ); + }); +}