From d1067202f19cf1f862e4d7c864dee52f2d63e7df Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:08:19 +0530 Subject: [PATCH 1/2] Presets: a size limit, and ready-made presets for where a file goes (#553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pictures and videos get a "Size limit (MB)". A picture over it is saved again at a lower quality (down to 50), then smaller, until it fits. A video over it is encoded again from the source at the bitrate its length allows, twice at most. When it can't fit, the file is refused with a reason rather than handed over too large. Only the step that writes the result sees the limit. Settings → Presets → New now offers an empty preset or a copy of a ready-made one: video for email (18 MB), Discord (10 MB), WhatsApp (720p, 16 MB) and a photo for Instagram (JPG, 1080 px wide). They aren't in the library or on a donut: no settings migration. --- src/Filee.App/Assets/i18n/ar.json | 10 ++ src/Filee.App/Assets/i18n/en.json | 10 ++ src/Filee.App/Assets/i18n/es.json | 10 ++ src/Filee.App/Assets/i18n/fr.json | 10 ++ src/Filee.App/Assets/i18n/hi.json | 10 ++ src/Filee.App/Assets/i18n/ja.json | 10 ++ src/Filee.App/Assets/i18n/ko.json | 10 ++ src/Filee.App/Assets/i18n/ne.json | 10 ++ src/Filee.App/Assets/i18n/pl.json | 10 ++ src/Filee.App/Assets/i18n/pt-BR.json | 10 ++ src/Filee.App/Assets/i18n/pt-PT.json | 10 ++ src/Filee.App/Assets/i18n/ru.json | 10 ++ src/Filee.App/Assets/i18n/th.json | 10 ++ src/Filee.App/Assets/i18n/tr.json | 10 ++ src/Filee.App/Assets/i18n/zh-CN.json | 10 ++ src/Filee.App/Assets/i18n/zh-TW.json | 10 ++ .../ViewModels/Pages/PresetsPageViewModel.cs | 13 ++ .../ViewModels/PresetEditorViewModel.cs | 16 ++- src/Filee.App/Views/Pages/PresetsPage.axaml | 14 +- src/Filee.App/Views/PresetEditorView.axaml | 10 ++ src/Filee.Core/Conversion/JobQueue.cs | 11 ++ src/Filee.Core/Presets/BuiltInData.cs | 29 ++++ src/Filee.Core/Presets/Preset.cs | 12 ++ src/Filee.Engines/Magick/ImageEncoder.cs | 32 +++++ src/Filee.Engines/Media/FfmpegConverter.cs | 31 ++++ src/Filee.Engines/Media/MediaEncoding.cs | 32 +++++ tests/Filee.App.Tests/RenderTests.cs | 60 ++++++++ tests/Filee.Core.Tests/FakeConverter.cs | 4 + tests/Filee.Core.Tests/SizeLimitTests.cs | 44 ++++++ tests/Filee.Engines.Tests/SizeLimitTests.cs | 134 ++++++++++++++++++ 30 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 tests/Filee.Core.Tests/SizeLimitTests.cs create mode 100644 tests/Filee.Engines.Tests/SizeLimitTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 3de1151ec..8fc32d96c 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -83,6 +83,7 @@ "error.ocr_needs_tesseract": "لا يستطيع Apple Vision قراءة هذه اللغة. ثبّت Tesseract من الإعدادات ← المحركات", "error.too_many_frames": "عدد الإطارات كبير جدًا لحفظها كصور. زِد قيمة «كل إطار رقم N»", "error.too_many_pictures": "عدد الصور كبير جدًا لفيديو واحد، قسّمها على عدة فيديوهات", + "error.over_size_limit": "لا يمكن تصغيره بما يكفي ليناسب حد الحجم", "error.not_enough_space": "لا توجد مساحة كافية على القرص لحفظ الصور", "error.no_motion_photo": "لا تحتوي هذه الصورة على فيديو يستطيع Filee قراءته (صورة Motion Photo من Android، أو Live Photo من iPhone مع ملف MOV بجانبها)", "error.live_photo_start": "يبدأ المقطع بعد نهاية هذا الفيديو. اختر «بداية المقطع» أبكر", @@ -342,6 +343,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "ضغط الصورة", "preset.compress_video": "ضغط الفيديو", + "preset.for_email": "فيديو للبريد الإلكتروني (أقل من 18 ميغابايت)", + "preset.for_discord": "فيديو لـ Discord (أقل من 10 ميغابايت)", + "preset.for_whatsapp": "فيديو لـ WhatsApp (720p، أقل من 16 ميغابايت)", + "preset.for_instagram": "صورة لـ Instagram (JPG، بعرض 1080 بكسل)", "preset.video_from_images": "فيديو من الصور", "preset.extract_audio": "استخراج الصوت", "preset.merge_videos": "دمج الفيديوهات", @@ -570,6 +575,8 @@ "tags.remove": "إزالة", "presets.title": "الإعدادات المسبقة", "presets.add": "جديد", + "presets.add_empty": "إعداد مسبق فارغ", + "presets.add_ready": "جاهز، حسب وجهة الملف:", "presets.duplicate": "إنشاء نسخة", "presets.delete": "حذف", "presets.save": "حفظ", @@ -696,6 +703,9 @@ "presets.media_quality.Small": "صغيرة (للمشاركة)", "presets.max_height": "الارتفاع الأقصى", "presets.max_height.original": "الأصلي", + "presets.max_size": "حد الحجم (ميغابايت)", + "presets.max_size_hint_image": "0 = بلا حد. تُحفظ الصورة الأكبر مرة أخرى بجودة أقل، ثم بحجم أصغر، حتى تناسب الحد.", + "presets.max_size_hint_video": "0 = بلا حد. يُرمَّز الفيديو الأكبر مرة أخرى بمعدل البت الذي يسمح به طوله.", "presets.audio_bitrate": "معدل بت الصوت", "presets.audio_bitrate.auto": "تلقائي", "presets.remove_audio": "إزالة الصوت", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 75d0ee92a..92500268e 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -89,6 +89,7 @@ "error.ocr_needs_tesseract": "Apple Vision can't read this language. Install Tesseract in Settings → Engines", "error.too_many_frames": "Too many frames to save as pictures, raise \"Every Nth frame\"", "error.too_many_pictures": "Too many pictures for one video, split them into several", + "error.over_size_limit": "It can't be made small enough for the size limit", "error.not_enough_space": "Not enough free disk space for the pictures", "error.no_motion_photo": "This photo has no video Filee can read (an Android Motion Photo, or an iPhone Live Photo with its MOV next to it)", "error.live_photo_start": "The clip starts after the end of this video. Choose an earlier \"Clip start\"", @@ -352,6 +353,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Compress image", "preset.compress_video": "Compress video", + "preset.for_email": "Video for email (under 18 MB)", + "preset.for_discord": "Video for Discord (under 10 MB)", + "preset.for_whatsapp": "Video for WhatsApp (720p, under 16 MB)", + "preset.for_instagram": "Photo for Instagram (JPG, 1080 px wide)", "preset.video_from_images": "Video from images", "preset.extract_audio": "Extract audio", "preset.merge_videos": "Merge videos", @@ -585,6 +590,8 @@ "presets.title": "Presets", "presets.add": "New", + "presets.add_empty": "Empty preset", + "presets.add_ready": "Ready-made, for where the file goes:", "presets.duplicate": "Duplicate", "presets.delete": "Delete", "presets.save": "Save", @@ -711,6 +718,9 @@ "presets.media_quality.Small": "Small (for sharing)", "presets.max_height": "Maximum height", "presets.max_height.original": "Original", + "presets.max_size": "Size limit (MB)", + "presets.max_size_hint_image": "0 = no limit. A larger picture is saved again at a lower quality, then smaller, until it fits.", + "presets.max_size_hint_video": "0 = no limit. A larger video is encoded again at the bitrate its length allows.", "presets.audio_bitrate": "Audio bitrate", "presets.audio_bitrate.auto": "Automatic", "presets.remove_audio": "Remove the sound", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index c074a17f9..6019da3aa 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -83,6 +83,7 @@ "error.ocr_needs_tesseract": "Apple Vision no puede leer este idioma. Instala Tesseract en Ajustes → Motores", "error.too_many_frames": "Demasiados fotogramas para guardar como imágenes; aumenta «Cada N fotogramas»", "error.too_many_pictures": "Demasiadas imágenes para un vídeo; divídelas en varios", + "error.over_size_limit": "No se puede reducir lo suficiente para el límite de tamaño", "error.not_enough_space": "Espacio libre en disco insuficiente para las imágenes", "error.no_motion_photo": "Esta foto no tiene un vídeo que Filee pueda leer (una Motion Photo de Android o una Live Photo de iPhone con su MOV al lado)", "error.live_photo_start": "El clip empieza después del final de este vídeo. Elige un «Inicio del clip» anterior", @@ -342,6 +343,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Comprimir imagen", "preset.compress_video": "Comprimir vídeo", + "preset.for_email": "Vídeo para correo (menos de 18 MB)", + "preset.for_discord": "Vídeo para Discord (menos de 10 MB)", + "preset.for_whatsapp": "Vídeo para WhatsApp (720p, menos de 16 MB)", + "preset.for_instagram": "Foto para Instagram (JPG, 1080 px de ancho)", "preset.video_from_images": "Vídeo de imágenes", "preset.extract_audio": "Extraer audio", "preset.merge_videos": "Unir vídeos", @@ -570,6 +575,8 @@ "tags.remove": "Quitar", "presets.title": "Ajustes preestablecidos", "presets.add": "Nuevo", + "presets.add_empty": "Ajuste vacío", + "presets.add_ready": "Listos, según adónde va el archivo:", "presets.duplicate": "Duplicar", "presets.delete": "Eliminar", "presets.save": "Guardar", @@ -696,6 +703,9 @@ "presets.media_quality.Small": "Pequeño (para compartir)", "presets.max_height": "Altura máxima", "presets.max_height.original": "Original", + "presets.max_size": "Límite de tamaño (MB)", + "presets.max_size_hint_image": "0 = sin límite. Una imagen más grande se guarda de nuevo con menos calidad y después más pequeña, hasta que cabe.", + "presets.max_size_hint_video": "0 = sin límite. Un vídeo más grande se codifica de nuevo con la tasa de bits que permite su duración.", "presets.audio_bitrate": "Tasa de bits del audio", "presets.audio_bitrate.auto": "Automática", "presets.remove_audio": "Quitar el sonido", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 68101164f..8b95c8f5c 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -89,6 +89,7 @@ "error.ocr_needs_tesseract": "Apple Vision ne lit pas cette langue. Installez Tesseract dans Paramètres → Moteurs", "error.too_many_frames": "Trop d'images à enregistrer, augmentez « Une image sur N »", "error.too_many_pictures": "Trop d'images pour une seule vidéo, répartissez-les en plusieurs", + "error.over_size_limit": "Impossible de le rendre assez léger pour la taille maximale", "error.not_enough_space": "Pas assez d'espace disque libre pour les images", "error.no_motion_photo": "Cette photo ne contient aucune vidéo lisible par Filee (une Motion Photo Android, ou une Live Photo d'iPhone avec son MOV à côté)", "error.live_photo_start": "L'extrait commence après la fin de cette vidéo. Choisissez un « Début de l'extrait » plus tôt", @@ -352,6 +353,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Compresser l'image", "preset.compress_video": "Compresser la vidéo", + "preset.for_email": "Vidéo pour e-mail (moins de 18 Mo)", + "preset.for_discord": "Vidéo pour Discord (moins de 10 Mo)", + "preset.for_whatsapp": "Vidéo pour WhatsApp (720p, moins de 16 Mo)", + "preset.for_instagram": "Photo pour Instagram (JPG, 1080 px de large)", "preset.video_from_images": "Vidéo à partir d'images", "preset.extract_audio": "Extraire l'audio", "preset.merge_videos": "Fusionner les vidéos", @@ -585,6 +590,8 @@ "presets.title": "Préréglages", "presets.add": "Nouveau", + "presets.add_empty": "Préréglage vide", + "presets.add_ready": "Prêts à l'emploi, selon la destination du fichier :", "presets.duplicate": "Dupliquer", "presets.delete": "Supprimer", "presets.save": "Enregistrer", @@ -711,6 +718,9 @@ "presets.media_quality.Small": "Petit (pour le partage)", "presets.max_height": "Hauteur maximale", "presets.max_height.original": "Originale", + "presets.max_size": "Taille maximale (Mo)", + "presets.max_size_hint_image": "0 = aucune limite. Une image plus lourde est réenregistrée en qualité plus basse, puis plus petite, jusqu'à ce qu'elle tienne.", + "presets.max_size_hint_video": "0 = aucune limite. Une vidéo plus lourde est réencodée au débit que permet sa durée.", "presets.audio_bitrate": "Débit audio", "presets.audio_bitrate.auto": "Automatique", "presets.remove_audio": "Supprimer le son", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 957153f43..a000275ff 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -83,6 +83,7 @@ "error.ocr_needs_tesseract": "Apple Vision यह भाषा नहीं पढ़ सकता। सेटिंग्स → इंजन में Tesseract इंस्टॉल करें", "error.too_many_frames": "तस्वीरों के रूप में सहेजने के लिए बहुत ज़्यादा फ़्रेम हैं, \"हर Nवाँ फ़्रेम\" बढ़ाएँ", "error.too_many_pictures": "एक वीडियो के लिए तस्वीरें बहुत ज़्यादा हैं, इन्हें कई हिस्सों में बाँटें", + "error.over_size_limit": "इसे आकार सीमा जितना छोटा नहीं किया जा सकता", "error.not_enough_space": "तस्वीरों के लिए डिस्क पर पर्याप्त खाली जगह नहीं है", "error.no_motion_photo": "इस फ़ोटो में ऐसा कोई वीडियो नहीं है जिसे Filee पढ़ सके (Android मोशन फ़ोटो, या पास में अपनी MOV फ़ाइल वाली iPhone लाइव फ़ोटो)", "error.live_photo_start": "क्लिप इस वीडियो के ख़त्म होने के बाद शुरू होती है। \"क्लिप की शुरुआत\" पहले चुनें", @@ -342,6 +343,10 @@ "preset.motion_photo_android": "मोशन फ़ोटो (Android)", "preset.compress_image": "इमेज कंप्रेस करें", "preset.compress_video": "वीडियो कंप्रेस करें", + "preset.for_email": "ईमेल के लिए वीडियो (18 MB से कम)", + "preset.for_discord": "Discord के लिए वीडियो (10 MB से कम)", + "preset.for_whatsapp": "WhatsApp के लिए वीडियो (720p, 16 MB से कम)", + "preset.for_instagram": "Instagram के लिए फ़ोटो (JPG, 1080 px चौड़ी)", "preset.video_from_images": "तस्वीरों से वीडियो", "preset.extract_audio": "ऑडियो निकालें", "preset.merge_videos": "वीडियो जोड़ें", @@ -570,6 +575,8 @@ "tags.remove": "हटाएँ", "presets.title": "प्रीसेट", "presets.add": "नया", + "presets.add_empty": "खाली प्रीसेट", + "presets.add_ready": "तैयार प्रीसेट, फ़ाइल जहाँ जाएगी उसके हिसाब से:", "presets.duplicate": "डुप्लिकेट करें", "presets.delete": "हटाएँ", "presets.save": "सहेजें", @@ -696,6 +703,9 @@ "presets.media_quality.Small": "छोटा (शेयर करने के लिए)", "presets.max_height": "अधिकतम ऊँचाई", "presets.max_height.original": "मूल", + "presets.max_size": "आकार सीमा (MB)", + "presets.max_size_hint_image": "0 = कोई सीमा नहीं। बड़ी तस्वीर कम गुणवत्ता पर, फिर छोटी करके, तब तक दोबारा सहेजी जाती है जब तक वह सीमा में न आ जाए।", + "presets.max_size_hint_video": "0 = कोई सीमा नहीं। बड़ा वीडियो उसकी लंबाई के हिसाब से संभव बिटरेट पर दोबारा एन्कोड किया जाता है।", "presets.audio_bitrate": "ऑडियो बिटरेट", "presets.audio_bitrate.auto": "स्वचालित", "presets.remove_audio": "आवाज़ हटाएँ", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5dcc03f93..3b0bba008 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -83,6 +83,7 @@ "error.ocr_needs_tesseract": "Apple Vision はこの言語を読めません。「設定」→「変換エンジン」で Tesseract をインストールしてください", "error.too_many_frames": "画像として保存するフレーム数が多すぎます。「Nフレームごと」の値を大きくしてください", "error.too_many_pictures": "1本の動画にするには画像が多すぎます。いくつかに分けてください", + "error.over_size_limit": "サイズ上限に収まるまで小さくできません", "error.not_enough_space": "画像を保存するための空き容量が不足しています", "error.no_motion_photo": "この写真には、Fileeが読み取れる動画がありません(Androidのモーションフォト、または隣にMOVがあるiPhoneのLive Photo)", "error.live_photo_start": "クリップの開始位置が動画の終わりより後です。「クリップの開始」をもっと前にしてください", @@ -342,6 +343,10 @@ "preset.motion_photo_android": "モーションフォト(Android)", "preset.compress_image": "画像を圧縮", "preset.compress_video": "動画を圧縮", + "preset.for_email": "メール用の動画 (18 MB 未満)", + "preset.for_discord": "Discord 用の動画 (10 MB 未満)", + "preset.for_whatsapp": "WhatsApp 用の動画 (720p、16 MB 未満)", + "preset.for_instagram": "Instagram 用の写真 (JPG、幅 1080 px)", "preset.video_from_images": "写真から動画", "preset.extract_audio": "音声を抽出", "preset.merge_videos": "動画を結合", @@ -570,6 +575,8 @@ "tags.remove": "削除", "presets.title": "プリセット", "presets.add": "新規", + "presets.add_empty": "空のプリセット", + "presets.add_ready": "送り先に合わせた既製のプリセット:", "presets.duplicate": "複製", "presets.delete": "削除", "presets.save": "保存", @@ -696,6 +703,9 @@ "presets.media_quality.Small": "小さめ(共有向け)", "presets.max_height": "高さの上限", "presets.max_height.original": "元のまま", + "presets.max_size": "サイズ上限 (MB)", + "presets.max_size_hint_image": "0 = 上限なし。大きい画像は品質を下げ、次に縮小して、収まるまで保存し直します。", + "presets.max_size_hint_video": "0 = 上限なし。大きい動画は長さに合ったビットレートでエンコードし直します。", "presets.audio_bitrate": "音声ビットレート", "presets.audio_bitrate.auto": "自動", "presets.remove_audio": "音声を削除", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 5e899b549..78be1f8db 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -89,6 +89,7 @@ "error.ocr_needs_tesseract": "Apple Vision은 이 언어를 읽지 못해요. 설정 → 변환 엔진에서 Tesseract를 설치해 주세요", "error.too_many_frames": "프레임이 너무 많아요. 'N번째 프레임마다' 값을 늘려 주세요", "error.too_many_pictures": "한 동영상에 넣기엔 사진이 너무 많아요. 여러 번에 나눠 주세요", + "error.over_size_limit": "크기 제한에 맞을 만큼 줄일 수 없어요", "error.not_enough_space": "이미지를 저장할 디스크 공간이 부족해요", "error.no_motion_photo": "이 사진에서 읽을 수 있는 동영상을 찾지 못했어요 (안드로이드 모션 포토, 또는 옆에 MOV가 있는 아이폰 라이브 포토)", "error.live_photo_start": "클립 시작이 동영상 끝보다 뒤예요. \"클립 시작\"을 더 앞으로 정해 주세요", @@ -352,6 +353,10 @@ "preset.motion_photo_android": "모션 포토 (안드로이드)", "preset.compress_image": "이미지 압축", "preset.compress_video": "동영상 압축", + "preset.for_email": "이메일용 동영상 (18MB 미만)", + "preset.for_discord": "Discord용 동영상 (10MB 미만)", + "preset.for_whatsapp": "WhatsApp용 동영상 (720p, 16MB 미만)", + "preset.for_instagram": "Instagram용 사진 (JPG, 너비 1080px)", "preset.video_from_images": "사진으로 동영상", "preset.extract_audio": "오디오 추출", "preset.merge_videos": "동영상 합치기", @@ -585,6 +590,8 @@ "presets.title": "프리셋", "presets.add": "새로 만들기", + "presets.add_empty": "빈 프리셋", + "presets.add_ready": "보낼 곳에 맞춘 프리셋:", "presets.duplicate": "복제", "presets.delete": "삭제", "presets.save": "저장", @@ -711,6 +718,9 @@ "presets.media_quality.Small": "작게 (공유용)", "presets.max_height": "최대 높이", "presets.max_height.original": "원본", + "presets.max_size": "크기 제한 (MB)", + "presets.max_size_hint_image": "0 = 제한 없음. 더 큰 그림은 품질을 낮추고, 그다음 크기를 줄여 맞을 때까지 다시 저장해요.", + "presets.max_size_hint_video": "0 = 제한 없음. 더 큰 동영상은 길이에 맞는 비트레이트로 다시 인코딩해요.", "presets.audio_bitrate": "오디오 비트레이트", "presets.audio_bitrate.auto": "자동", "presets.remove_audio": "소리 빼기", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index a9a85890c..0e5a39492 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -83,6 +83,7 @@ "error.no_transparency": "यो ढाँचामा पारदर्शिता हुँदैन, त्यसैले काट्नुपर्ने केही छैन", "error.too_many_frames": "छविका रूपमा सुरक्षित गर्न धेरै फ्रेमहरू छन्, \"प्रत्येक N-औं फ्रेम\" बढाउनुहोस्", "error.too_many_pictures": "एउटा भिडियोका लागि धेरै तस्बिरहरू छन्, तिनलाई केही भागमा बाँड्नुहोस्", + "error.over_size_limit": "यसलाई आकार सीमाभित्र आउने गरी सानो बनाउन सकिएन", "error.not_enough_space": "छविहरूका लागि डिस्कमा पर्याप्त खाली ठाउँ छैन", "error.no_motion_photo": "यस तस्बिरमा Filee ले पढ्न सक्ने कुनै भिडियो छैन (Android मोशन फोटो, वा छेउमा MOV भएको iPhone लाइभ फोटो)", "error.live_photo_start": "क्लिप यस भिडियो सकिएपछि सुरु हुन्छ। पहिलेको \"क्लिप सुरु\" समय रोज्नुहोस्", @@ -342,6 +343,10 @@ "preset.motion_photo_android": "मोशन फोटो (Android)", "preset.compress_image": "छवि कम्प्रेस गर्नुहोस्", "preset.compress_video": "भिडियो कम्प्रेस गर्नुहोस्", + "preset.for_email": "इमेलका लागि भिडियो (18 MB भन्दा कम)", + "preset.for_discord": "Discord का लागि भिडियो (10 MB भन्दा कम)", + "preset.for_whatsapp": "WhatsApp का लागि भिडियो (720p, 16 MB भन्दा कम)", + "preset.for_instagram": "Instagram का लागि फोटो (JPG, 1080 px चौडा)", "preset.video_from_images": "छविहरूबाट भिडियो", "preset.extract_audio": "अडियो निकाल्नुहोस्", "preset.merge_videos": "भिडियोहरू मर्ज गर्नुहोस्", @@ -570,6 +575,8 @@ "tags.remove": "ट्याग हटाउनुहोस्", "presets.title": "प्रिसेटहरू", "presets.add": "नयाँ", + "presets.add_empty": "खाली प्रिसेट", + "presets.add_ready": "फाइल जाने ठाउँअनुसार तयार प्रिसेट:", "presets.duplicate": "प्रतिलिपि बनाउनुहोस्", "presets.delete": "हटाउनुहोस्", "presets.save": "सुरक्षित गर्नुहोस्", @@ -696,6 +703,9 @@ "presets.media_quality.Small": "सानो (साझा गर्नका लागि)", "presets.max_height": "अधिकतम उचाइ", "presets.max_height.original": "मूल", + "presets.max_size": "आकार सीमा (MB)", + "presets.max_size_hint_image": "0 = कुनै सीमा छैन। ठूलो तस्बिरलाई कम गुणस्तरमा, त्यसपछि सानो बनाएर, सीमाभित्र नआउँदासम्म फेरि सेभ गरिन्छ।", + "presets.max_size_hint_video": "0 = कुनै सीमा छैन। ठूलो भिडियोलाई यसको लम्बाइले दिने बिटरेटमा फेरि इन्कोड गरिन्छ।", "presets.audio_bitrate": "अडियो बिटरेट", "presets.audio_bitrate.auto": "स्वचालित", "presets.remove_audio": "अडियो हटाउनुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 72f4aa02f..a5a7cf3c2 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -89,6 +89,7 @@ "error.ocr_needs_tesseract": "Apple Vision nie czyta tego języka. Zainstaluj Tesseract w Ustawienia → Silniki", "error.too_many_frames": "Za dużo klatek do zapisania jako obrazy, zwiększ „Co N-ta klatka”", "error.too_many_pictures": "Za dużo zdjęć na jedno wideo, podziel je na kilka", + "error.over_size_limit": "Nie da się go zmniejszyć do limitu rozmiaru", "error.not_enough_space": "Za mało wolnego miejsca na dysku na obrazy", "error.no_motion_photo": "To zdjęcie nie ma wideo, które Filee może odczytać (Motion Photo z Androida lub Live Photo z iPhone'a z plikiem MOV obok)", "error.live_photo_start": "Klip zaczyna się po końcu tego wideo. Wybierz wcześniejszy „Początek klipu”", @@ -352,6 +353,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Kompresuj obraz", "preset.compress_video": "Kompresuj wideo", + "preset.for_email": "Wideo do e-maila (poniżej 18 MB)", + "preset.for_discord": "Wideo na Discorda (poniżej 10 MB)", + "preset.for_whatsapp": "Wideo na WhatsAppa (720p, poniżej 16 MB)", + "preset.for_instagram": "Zdjęcie na Instagram (JPG, 1080 px szerokości)", "preset.video_from_images": "Wideo ze zdjęć", "preset.extract_audio": "Wyodrębnij dźwięk", "preset.merge_videos": "Połącz wideo", @@ -585,6 +590,8 @@ "presets.title": "Ustawienia wstępne", "presets.add": "Nowe", + "presets.add_empty": "Pusty preset", + "presets.add_ready": "Gotowe, zależnie od tego, dokąd trafi plik:", "presets.duplicate": "Duplikuj", "presets.delete": "Usuń", "presets.save": "Zapisz", @@ -711,6 +718,9 @@ "presets.media_quality.Small": "Mała (do udostępniania)", "presets.max_height": "Maksymalna wysokość", "presets.max_height.original": "Oryginalna", + "presets.max_size": "Limit rozmiaru (MB)", + "presets.max_size_hint_image": "0 = bez limitu. Większy obraz jest zapisywany ponownie w niższej jakości, a potem mniejszy, aż się zmieści.", + "presets.max_size_hint_video": "0 = bez limitu. Większe wideo jest kodowane ponownie z przepływnością, na jaką pozwala jego długość.", "presets.audio_bitrate": "Bitrate audio", "presets.audio_bitrate.auto": "Automatyczny", "presets.remove_audio": "Usuń dźwięk", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 750998136..de665a24f 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -89,6 +89,7 @@ "error.ocr_needs_tesseract": "O Apple Vision não lê este idioma. Instale o Tesseract em Configurações → Motores", "error.too_many_frames": "Quadros demais para salvar como imagens. Aumente “A cada N quadros”", "error.too_many_pictures": "Imagens demais para um vídeo; divida-as em vários", + "error.over_size_limit": "Não foi possível deixá-lo pequeno o suficiente para o limite de tamanho", "error.not_enough_space": "Não há espaço livre suficiente em disco para as imagens", "error.no_motion_photo": "Esta foto não tem um vídeo que o Filee consiga ler (uma Motion Photo do Android, ou uma Live Photo do iPhone com o MOV ao lado)", "error.live_photo_start": "O clipe começa depois do fim deste vídeo. Escolha um “Início do clipe” mais cedo", @@ -352,6 +353,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Comprimir imagem", "preset.compress_video": "Comprimir vídeo", + "preset.for_email": "Vídeo para e-mail (menos de 18 MB)", + "preset.for_discord": "Vídeo para Discord (menos de 10 MB)", + "preset.for_whatsapp": "Vídeo para WhatsApp (720p, menos de 16 MB)", + "preset.for_instagram": "Foto para Instagram (JPG, 1080 px de largura)", "preset.video_from_images": "Vídeo de imagens", "preset.extract_audio": "Extrair áudio", "preset.merge_videos": "Juntar vídeos", @@ -585,6 +590,8 @@ "presets.title": "Predefinições", "presets.add": "Nova", + "presets.add_empty": "Predefinição vazia", + "presets.add_ready": "Prontas, conforme o destino do arquivo:", "presets.duplicate": "Duplicar", "presets.delete": "Excluir", "presets.save": "Salvar", @@ -711,6 +718,9 @@ "presets.media_quality.Small": "Pequena (para compartilhar)", "presets.max_height": "Altura máxima", "presets.max_height.original": "Original", + "presets.max_size": "Limite de tamanho (MB)", + "presets.max_size_hint_image": "0 = sem limite. Uma imagem maior é salva de novo com qualidade menor e depois menor, até caber.", + "presets.max_size_hint_video": "0 = sem limite. Um vídeo maior é codificado de novo com a taxa de bits que a duração permite.", "presets.audio_bitrate": "Taxa de bits do áudio", "presets.audio_bitrate.auto": "Automática", "presets.remove_audio": "Remover o som", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index e6ca7c15b..1faee5406 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -89,6 +89,7 @@ "error.ocr_needs_tesseract": "O Apple Vision não consegue ler este idioma. Instala o Tesseract em Definições → Motores", "error.too_many_frames": "Há demasiados fotogramas para guardar como imagens. Aumenta o valor de «A cada N fotogramas»", "error.too_many_pictures": "Há demasiadas imagens para um só vídeo. Divide-as em vários vídeos", + "error.over_size_limit": "Não foi possível torná-lo suficientemente pequeno para o limite de tamanho", "error.not_enough_space": "Não há espaço livre suficiente no disco para as imagens", "error.no_motion_photo": "Esta fotografia não tem um vídeo que o Filee consiga ler (no interior de uma Motion Photo de Android ou num MOV junto a uma Live Photo de iPhone)", "error.live_photo_start": "O início do excerto fica depois do fim deste vídeo. Escolhe um valor anterior em «Início do excerto»", @@ -352,6 +353,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Comprimir imagem", "preset.compress_video": "Comprimir vídeo", + "preset.for_email": "Vídeo para e-mail (menos de 18 MB)", + "preset.for_discord": "Vídeo para o Discord (menos de 10 MB)", + "preset.for_whatsapp": "Vídeo para o WhatsApp (720p, menos de 16 MB)", + "preset.for_instagram": "Fotografia para o Instagram (JPG, 1080 px de largura)", "preset.video_from_images": "Vídeo a partir de imagens", "preset.extract_audio": "Extrair áudio", "preset.merge_videos": "Juntar vídeos", @@ -585,6 +590,8 @@ "presets.title": "Predefinições", "presets.add": "Nova", + "presets.add_empty": "Predefinição vazia", + "presets.add_ready": "Prontas, consoante o destino do ficheiro:", "presets.duplicate": "Duplicar", "presets.delete": "Eliminar", "presets.save": "Guardar", @@ -711,6 +718,9 @@ "presets.media_quality.Small": "Tamanho reduzido (para partilhar)", "presets.max_height": "Altura máxima", "presets.max_height.original": "Original", + "presets.max_size": "Limite de tamanho (MB)", + "presets.max_size_hint_image": "0 = sem limite. Uma imagem maior é guardada novamente com menos qualidade e depois mais pequena, até caber.", + "presets.max_size_hint_video": "0 = sem limite. Um vídeo maior é codificado novamente com a taxa de bits que a duração permite.", "presets.audio_bitrate": "Taxa de bits do áudio", "presets.audio_bitrate.auto": "Automática", "presets.remove_audio": "Remover o som", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 29acdd1e0..29a490a05 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -89,6 +89,7 @@ "error.ocr_needs_tesseract": "Apple Vision не читает этот язык. Установите Tesseract в разделе «Настройки → Движки»", "error.too_many_frames": "Слишком много кадров для сохранения картинками — увеличьте «Каждый N-й кадр»", "error.too_many_pictures": "Слишком много картинок для одного видео — разделите их на несколько", + "error.over_size_limit": "Не удалось уменьшить его до предела размера", "error.not_enough_space": "Не хватает места на диске для картинок", "error.no_motion_photo": "В этом фото нет видео, которое Filee может прочитать (Motion Photo с Android или Live Photo с iPhone с файлом MOV рядом)", "error.live_photo_start": "Клип начинается после конца этого видео. Выберите более раннее «Начало клипа»", @@ -352,6 +353,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Сжать изображение", "preset.compress_video": "Сжать видео", + "preset.for_email": "Видео для почты (до 18 МБ)", + "preset.for_discord": "Видео для Discord (до 10 МБ)", + "preset.for_whatsapp": "Видео для WhatsApp (720p, до 16 МБ)", + "preset.for_instagram": "Фото для Instagram (JPG, ширина 1080 пикс.)", "preset.video_from_images": "Видео из фото", "preset.extract_audio": "Извлечь звук", "preset.merge_videos": "Объединить видео", @@ -585,6 +590,8 @@ "presets.title": "Пресеты", "presets.add": "Новый", + "presets.add_empty": "Пустой пресет", + "presets.add_ready": "Готовые, по месту назначения файла:", "presets.duplicate": "Дублировать", "presets.delete": "Удалить", "presets.save": "Сохранить", @@ -711,6 +718,9 @@ "presets.media_quality.Small": "Компактное (для отправки)", "presets.max_height": "Наибольшая высота", "presets.max_height.original": "Исходная", + "presets.max_size": "Предел размера (МБ)", + "presets.max_size_hint_image": "0 = без предела. Изображение крупнее сохраняется заново с меньшим качеством, затем меньше по размеру, пока не уложится.", + "presets.max_size_hint_video": "0 = без предела. Видео крупнее кодируется заново с битрейтом, который позволяет его длительность.", "presets.audio_bitrate": "Битрейт звука", "presets.audio_bitrate.auto": "Автоматически", "presets.remove_audio": "Убрать звук", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 8c419e6b3..0bd3a098a 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -83,6 +83,7 @@ "error.no_transparency": "ฟอร์แมตนี้ไม่มีความโปร่งใส จึงไม่มีขอบให้ตัดออก", "error.too_many_frames": "มีจำนวนเฟรมมากเกินกว่าจะบันทึกเป็นรูปภาพได้ ลองเพิ่มค่า \"บันทึกทุกๆ N เฟรม\"", "error.too_many_pictures": "รูปภาพมากเกินไปสำหรับวิดีโอเดียว ลองแบ่งเป็นหลายวิดีโอ", + "error.over_size_limit": "ทำให้เล็กพอสำหรับขนาดสูงสุดไม่ได้", "error.not_enough_space": "พื้นที่ว่างบนดิสก์ไม่เพียงพอสำหรับการบันทึกรูปภาพ", "error.no_motion_photo": "รูปภาพนี้ไม่มีวิดีโอที่ Filee สามารถอ่านได้ (เช่น ภาพเคลื่อนไหวของ Android หรือ Live Photo ของ iPhone ที่มีไฟล์ MOV อยู่คู่กัน)", "error.live_photo_start": "คลิปเริ่มต้นหลังจุดสิ้นสุดของวิดีโอนี้ โปรดเลือกจุดเริ่มต้นคลิปให้เร็วกว่านี้", @@ -342,6 +343,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "บีบอัดรูปภาพ", "preset.compress_video": "บีบอัดวิดีโอ", + "preset.for_email": "วิดีโอสำหรับอีเมล (ไม่เกิน 18 MB)", + "preset.for_discord": "วิดีโอสำหรับ Discord (ไม่เกิน 10 MB)", + "preset.for_whatsapp": "วิดีโอสำหรับ WhatsApp (720p ไม่เกิน 16 MB)", + "preset.for_instagram": "รูปสำหรับ Instagram (JPG กว้าง 1080 px)", "preset.video_from_images": "วิดีโอจากรูปภาพ", "preset.extract_audio": "แยกเสียง", "preset.merge_videos": "รวมวิดีโอ", @@ -570,6 +575,8 @@ "tags.remove": "ลบออก", "presets.title": "พรีเซ็ต", "presets.add": "สร้างใหม่", + "presets.add_empty": "พรีเซ็ตว่าง", + "presets.add_ready": "พรีเซ็ตสำเร็จรูป ตามปลายทางของไฟล์:", "presets.duplicate": "ทำซ้ำ", "presets.delete": "ลบ", "presets.save": "บันทึก", @@ -696,6 +703,9 @@ "presets.media_quality.Small": "ขนาดเล็ก (สำหรับแชร์)", "presets.max_height": "ความสูงสูงสุด", "presets.max_height.original": "ต้นฉบับ", + "presets.max_size": "ขนาดสูงสุด (MB)", + "presets.max_size_hint_image": "0 = ไม่จำกัด รูปที่ใหญ่กว่าจะถูกบันทึกใหม่ด้วยคุณภาพต่ำลง แล้วย่อขนาด จนกว่าจะพอดี", + "presets.max_size_hint_video": "0 = ไม่จำกัด วิดีโอที่ใหญ่กว่าจะถูกเข้ารหัสใหม่ด้วยบิตเรตที่ความยาวของมันรองรับ", "presets.audio_bitrate": "บิตเรตเสียง", "presets.audio_bitrate.auto": "อัตโนมัติ", "presets.remove_audio": "ลบเสียงออก", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 62a8e5e1d..23466575f 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -83,6 +83,7 @@ "error.ocr_needs_tesseract": "Apple Vision bu dili okuyamıyor. Ayarlar → Dönüştürme motorları bölümünden Tesseract kurun", "error.too_many_frames": "Görsel olarak kaydedilecek çok fazla kare var; “Her N'inci kare” değerini artırın", "error.too_many_pictures": "Tek video için çok fazla görsel var; birkaç gruba bölün", + "error.over_size_limit": "Boyut sınırına sığacak kadar küçültülemiyor", "error.not_enough_space": "Görseller için yeterli boş disk alanı yok", "error.no_motion_photo": "Bu fotoğrafta Filee'nin okuyabileceği video yok (Android Motion Photo veya yanında MOV dosyası bulunan iPhone Live Photo)", "error.live_photo_start": "Klip bu videonun bitiminden sonra başlıyor. Daha erken bir “Klip başlangıcı” seçin", @@ -342,6 +343,10 @@ "preset.motion_photo_android": "Motion Photo (Android)", "preset.compress_image": "Görseli sıkıştır", "preset.compress_video": "Videoyu sıkıştır", + "preset.for_email": "E-posta için video (18 MB altı)", + "preset.for_discord": "Discord için video (10 MB altı)", + "preset.for_whatsapp": "WhatsApp için video (720p, 16 MB altı)", + "preset.for_instagram": "Instagram için fotoğraf (JPG, 1080 px genişlik)", "preset.video_from_images": "Görsellerden video", "preset.extract_audio": "Sesi çıkar", "preset.merge_videos": "Videoları birleştir", @@ -570,6 +575,8 @@ "tags.remove": "Kaldır", "presets.title": "Ön ayarlar", "presets.add": "Yeni", + "presets.add_empty": "Boş ön ayar", + "presets.add_ready": "Dosyanın gideceği yere göre hazır ayarlar:", "presets.duplicate": "Çoğalt", "presets.delete": "Sil", "presets.save": "Kaydet", @@ -696,6 +703,9 @@ "presets.media_quality.Small": "Küçük (paylaşım için)", "presets.max_height": "En fazla yükseklik", "presets.max_height.original": "Özgün", + "presets.max_size": "Boyut sınırı (MB)", + "presets.max_size_hint_image": "0 = sınır yok. Daha büyük bir resim, sığana kadar önce daha düşük kalitede, sonra daha küçük olarak yeniden kaydedilir.", + "presets.max_size_hint_video": "0 = sınır yok. Daha büyük bir video, süresinin izin verdiği bit hızıyla yeniden kodlanır.", "presets.audio_bitrate": "Ses bit hızı", "presets.audio_bitrate.auto": "Otomatik", "presets.remove_audio": "Sesi kaldır", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 340de83b0..a3484d3a7 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -89,6 +89,7 @@ "error.ocr_needs_tesseract": "Apple Vision 无法识别此语言。请在“设置 → 转换引擎”中安装 Tesseract", "error.too_many_frames": "帧数太多,请调大“每 N 帧”", "error.too_many_pictures": "图片太多,无法放进一个视频,请分成几次", + "error.over_size_limit": "无法缩小到大小上限以内", "error.not_enough_space": "磁盘空间不足,无法保存这些图片", "error.no_motion_photo": "这张照片中没有可读取的视频(安卓动态照片,或旁边有 MOV 的 iPhone 实况照片)", "error.live_photo_start": "片段开始位置在视频结束之后。请把“片段开始”设得更早", @@ -352,6 +353,10 @@ "preset.motion_photo_android": "动态照片(安卓)", "preset.compress_image": "压缩图片", "preset.compress_video": "压缩视频", + "preset.for_email": "用于邮件的视频(18 MB 以下)", + "preset.for_discord": "用于 Discord 的视频(10 MB 以下)", + "preset.for_whatsapp": "用于 WhatsApp 的视频(720p,16 MB 以下)", + "preset.for_instagram": "用于 Instagram 的照片(JPG,宽 1080 像素)", "preset.video_from_images": "图片生成视频", "preset.extract_audio": "提取音频", "preset.merge_videos": "合并视频", @@ -585,6 +590,8 @@ "presets.title": "预设", "presets.add": "新建", + "presets.add_empty": "空白预设", + "presets.add_ready": "现成预设,按文件的去处:", "presets.duplicate": "复制", "presets.delete": "删除", "presets.save": "保存", @@ -711,6 +718,9 @@ "presets.media_quality.Small": "小(便于分享)", "presets.max_height": "最大高度", "presets.max_height.original": "原始", + "presets.max_size": "大小上限(MB)", + "presets.max_size_hint_image": "0 = 不限。较大的图片会先降低质量、再缩小,重新保存直到符合上限。", + "presets.max_size_hint_video": "0 = 不限。较大的视频会按其时长允许的码率重新编码。", "presets.audio_bitrate": "音频比特率", "presets.audio_bitrate.auto": "自动", "presets.remove_audio": "去除声音", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index ee508a742..d824ac870 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -89,6 +89,7 @@ "error.ocr_needs_tesseract": "Apple Vision 無法辨識此語言。請在「設定 → 轉換引擎」中安裝 Tesseract", "error.too_many_frames": "影格數太多,請調大「每 N 個影格」", "error.too_many_pictures": "圖片太多,無法放進同一部影片,請分成幾次", + "error.over_size_limit": "無法縮小到大小上限以內", "error.not_enough_space": "磁碟空間不足,無法儲存這些圖片", "error.no_motion_photo": "這張照片中沒有可讀取的影片(Android 動態相片,或旁邊有 MOV 的 iPhone 原況照片)", "error.live_photo_start": "片段開始位置在影片結束之後。請把「片段開始」設得更早", @@ -352,6 +353,10 @@ "preset.motion_photo_android": "動態相片(Android)", "preset.compress_image": "壓縮圖片", "preset.compress_video": "壓縮影片", + "preset.for_email": "用於郵件的影片(18 MB 以下)", + "preset.for_discord": "用於 Discord 的影片(10 MB 以下)", + "preset.for_whatsapp": "用於 WhatsApp 的影片(720p,16 MB 以下)", + "preset.for_instagram": "用於 Instagram 的相片(JPG,寬 1080 像素)", "preset.video_from_images": "圖片製成影片", "preset.extract_audio": "擷取音訊", "preset.merge_videos": "合併影片", @@ -585,6 +590,8 @@ "presets.title": "預設", "presets.add": "新增", + "presets.add_empty": "空白預設", + "presets.add_ready": "現成預設,依檔案的去處:", "presets.duplicate": "複製", "presets.delete": "刪除", "presets.save": "儲存", @@ -711,6 +718,9 @@ "presets.media_quality.Small": "小(便於分享)", "presets.max_height": "最大高度", "presets.max_height.original": "原始", + "presets.max_size": "大小上限(MB)", + "presets.max_size_hint_image": "0 = 不限。較大的圖片會先降低品質、再縮小,重新儲存直到符合上限。", + "presets.max_size_hint_video": "0 = 不限。較大的影片會以其長度允許的位元速率重新編碼。", "presets.audio_bitrate": "音訊位元率", "presets.audio_bitrate.auto": "自動", "presets.remove_audio": "去除聲音", diff --git a/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs b/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs index a73afa5d8..9b3c4b44d 100644 --- a/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs @@ -47,6 +47,7 @@ public PresetsPageViewModel(UserDataStore store, ILocalizer loc, WindowService w foreach (var preset in store.Presets.Where(p => !p.IsGroup && !p.IsTool)) Presets.Add(new PresetListItem(preset, loc)); SelectedPreset = Presets.FirstOrDefault(); + PlacePresets = [.. BuiltInData.PlacePresets().Select(p => new PresetListItem(p, loc))]; } public ObservableCollection Presets { get; } = []; @@ -84,6 +85,18 @@ private void Save() [RelayCommand] private void Add() => AddPreset(new Preset { Name = _loc["toolbar.new_preset"], TargetFormat = "png" }); + /// The ready-made presets Add offers besides an empty one (). + public IReadOnlyList PlacePresets { get; } + + /// Adds a copy of a ready-made preset under a new id, so it can be added again and changed freely. + [RelayCommand] + private void AddPlace(Preset template) + { + var copy = template.Clone(); + copy.Id = Preset.NewId(); + AddPreset(copy); + } + [RelayCommand] private void Duplicate() { diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 44cb482cf..b570b0b52 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -157,6 +157,8 @@ .. FormatRegistry.Known _traceColors = Math.Clamp(_edit.Trace.Colors, TracingOptions.MinColors, TracingOptions.MaxColors); _traceDetail = Math.Clamp(_edit.Trace.Detail, 0, 100); _quality = _edit.Image.Quality; + _imageMaxSizeMb = (decimal)_edit.Image.MaxSizeMb; + _videoMaxSizeMb = (decimal)_edit.Media.MaxSizeMb; _resizePercent = _edit.Image.ResizePercent; _longEdge = _edit.Image.LongEdge; _width = _edit.Image.Width; @@ -240,7 +242,7 @@ .. FormatRegistry.Known nameof(ShowFitsTiff), nameof(ShowFitsDebayer), nameof(ShowZipResult), nameof(ShowPdf), nameof(ShowPdfBuild), nameof(ShowDocument), nameof(ShowLeaveOutPictures), nameof(ShowPdfSplit), nameof(ShowPdfCombine), nameof(ShowPdfCompression), nameof(ShowPdfCompressionOptions), nameof(ShowOcr), nameof(ShowPageSize), nameof(ShowPdfTablesNote), - nameof(ShowMedia), nameof(ShowMediaQuality), nameof(ShowMaxHeight), nameof(ShowAudioBitrate), nameof(ShowRemoveAudio), + nameof(ShowMedia), nameof(ShowMediaQuality), nameof(ShowMaxHeight), nameof(ShowVideoMaxSize), nameof(ShowAudioBitrate), nameof(ShowRemoveAudio), nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), nameof(ShowHardwareEncoding), nameof(AudioCodecs), nameof(AudioCodecChoice), nameof(ShowAudioCodec), nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), @@ -382,6 +384,10 @@ public Choice AudioCodecChoice /// FITS → other pictures: debayer a colour camera's raw frame (ImageOptions.FitsDebayer). [ObservableProperty] private bool _fitsDebayer; [ObservableProperty] private int _quality; + + /// "Size limit (MB)" for a picture or a video (0 = none, #553). + [ObservableProperty] private decimal? _imageMaxSizeMb; + [ObservableProperty] private decimal? _videoMaxSizeMb; [ObservableProperty] private int _resizePercent; [ObservableProperty] private int _longEdge; [ObservableProperty] private int _width; @@ -607,6 +613,8 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne public bool ShowAudioCodec => AudioCodecs.Count > 1; public bool ShowMediaQuality => TargetIsLossyMedia; public bool ShowMaxHeight => TargetIsVideo; + /// A size limit for a converted video; a clip is cut, not fitted. + public bool ShowVideoMaxSize => TargetCategory == FormatCategory.Video && !_original.IsClipTool; public bool ShowAudioBitrate => TargetIsLossyMedia; public bool ShowRemoveAudio => TargetIsVideo; public bool ShowArchive => TargetCategory == FormatCategory.Archive; @@ -640,6 +648,9 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne public bool ShowSubfolder => Location.Value == OutputLocation.Subfolder; public bool ShowCustomFolder => Location.Value == OutputLocation.CustomFolder; + /// An emptied box is no limit; at most 4 GB, the largest file FAT32 (and many sites) take. + private static double SizeLimit(decimal? megabytes) => (double)Math.Clamp(megabytes ?? 0, 0, 4096); + public string? NameError => string.IsNullOrWhiteSpace(Name) ? _loc["presets.name_required"] : null; public bool IsValid => NameError is null; @@ -656,6 +667,8 @@ public bool Apply() _original.TargetFormat = TargetId; var image = _original.Image; image.Quality = Math.Clamp(Quality, 1, 100); + // Only where the field is shown, as with SaveEveryFrame below: a limit left behind by a JPG target fits nothing. + image.MaxSizeMb = ShowQuality ? SizeLimit(ImageMaxSizeMb) : 0; image.Resize = Resize.Value; // Only images and PDFs are turned; for any other target a rotation left over from an earlier target would make // every file fail ("can only turn images") with nothing on screen to turn it off. @@ -724,6 +737,7 @@ public bool Apply() var media = _original.Media; media.Quality = MediaQuality.Value; media.MaxHeight = MaxHeight.Value; + media.MaxSizeMb = ShowVideoMaxSize ? SizeLimit(VideoMaxSizeMb) : 0; media.AudioBitrateKbps = AudioBitrate.Value; media.RemoveAudio = RemoveAudio; media.AudioChannels = AudioChannels.Value; diff --git a/src/Filee.App/Views/Pages/PresetsPage.axaml b/src/Filee.App/Views/Pages/PresetsPage.axaml index 01b014a13..70116c842 100644 --- a/src/Filee.App/Views/Pages/PresetsPage.axaml +++ b/src/Filee.App/Views/Pages/PresetsPage.axaml @@ -12,11 +12,23 @@ - diff --git a/src/Filee.App/Views/Pages/PresetsPage.axaml.cs b/src/Filee.App/Views/Pages/PresetsPage.axaml.cs index 82fddc4f2..37d497887 100644 --- a/src/Filee.App/Views/Pages/PresetsPage.axaml.cs +++ b/src/Filee.App/Views/Pages/PresetsPage.axaml.cs @@ -1,8 +1,27 @@ using Avalonia.Controls; +using Filee.App.ViewModels.Pages; namespace Filee.App.Views.Pages; public partial class PresetsPage : UserControl { - public PresetsPage() => InitializeComponent(); + private const int FixedMenuItems = 3; // "Empty preset", the separator and the heading + + public PresetsPage() + { + InitializeComponent(); + DataContextChanged += (_, _) => FillAddMenu(); + } + + /// One "New" menu item per ready-made preset, so the menu follows the list (#553). + private void FillAddMenu() + { + var menu = (MenuFlyout)AddPresetButton.Flyout!; // a flyout isn't in the page's name scope + while (menu.Items.Count > FixedMenuItems) + menu.Items.RemoveAt(menu.Items.Count - 1); + if (DataContext is not PresetsPageViewModel vm) + return; + foreach (var place in vm.PlacePresets) + menu.Items.Add(new MenuItem { Header = place.Name, Command = vm.AddPlaceCommand, CommandParameter = place.Preset }); + } } diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index 92c726b4a..3babce307 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -460,6 +460,7 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), try { await _mediaJoiner!.JoinAsync(inputs, output, target.Id, job.Preset, workDir, progress, job.CancellationToken); + SizeLimit.Check(job.Preset, [output]); // joins aren't fitted: one over the limit is refused (#553) first.Outputs.Add(output); foreach (var f in joined) { @@ -802,6 +803,9 @@ private async Task> RunFileAsync( // Watermark, page numbers, password: on every PDF this file became, from whichever engine. if (finalOutput && job.Preset.TargetFormat == "pdf") await FinishPdfsAsync(job.Preset, inputs, ct); + // The size limit (#553): a result the engine couldn't fit (HEIC, a render, an animation) isn't handed over. + if (finalOutput) + SizeLimit.Check(job.Preset, inputs); if (zipResult && inputs.Count > 0) inputs = PackZip(job, file, index, inputs, ct); diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index dcd2db28a..ea4d79a68 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -312,8 +312,8 @@ public sealed class ImageOptions public int Quality { get; set; } = 90; /// - /// Keep each picture under this many MB (e.g. for email); 0 = no limit. A larger one is saved again at a lower - /// quality, then smaller, until it fits (#553). + /// Keep each picture under this many MB of 1,000,000 bytes; 0 = no limit. A larger one is saved again at a lower + /// quality, then smaller (down to a long edge of 640 px), until it fits (#553). /// public double MaxSizeMb { get; set; } @@ -610,8 +610,8 @@ public sealed class MediaOptions public int MaxHeight { get; set; } /// - /// Keep each video under this many MB (WhatsApp 16, Discord 10, email 20 …); 0 = no limit. A larger one is - /// encoded again at the bitrate its length allows (#553). + /// Keep each video under this many MB of 1,000,000 bytes (WhatsApp 16, Discord 10, email 18 …); 0 = no limit. Its + /// bitrate is capped at what its length allows, and it is encoded once more if it still misses (#553). /// public double MaxSizeMb { get; set; } diff --git a/src/Filee.Core/Presets/SizeLimit.cs b/src/Filee.Core/Presets/SizeLimit.cs new file mode 100644 index 000000000..ea9fb1fac --- /dev/null +++ b/src/Filee.Core/Presets/SizeLimit.cs @@ -0,0 +1,39 @@ +// "Size limit" (#553): megabytes as Filee shows sizes and as mail and chat services count them, and the check that +// no result over a preset's limit is handed over. + +using System.Globalization; +using Filee.Core.Conversion; +using Filee.Core.Formats; + +namespace Filee.Core.Presets; + +public static class SizeLimit +{ + /// Megabytes of 1,000,000 bytes. + public static long Bytes(double megabytes) => (long)(megabytes * 1_000_000); + + /// "12.3 MB > 10 MB", in the same megabytes. + public static string Describe(long size, long limit) => + string.Create(CultureInfo.InvariantCulture, $"{size / 1e6:0.##} MB > {limit / 1e6:0.##} MB"); + + /// The limit for a result of 's kind: pictures and videos have their own; 0 = none. + public static long For(Preset preset, string path) => FormatRegistry.Detect(path)?.Category switch + { + FormatCategory.Image when preset.Image.MaxSizeMb > 0 => Bytes(preset.Image.MaxSizeMb), + FormatCategory.Video when preset.Media.MaxSizeMb > 0 => Bytes(preset.Media.MaxSizeMb), + _ => 0, + }; + + /// + /// Refuses when a result is over its limit. The engines fit pictures and videos where they can; this catches the + /// routes they don't (HEIC, CAD and 3D renders, animations and pages, joined videos, "Remove metadata"). + /// + public static void Check(Preset preset, IEnumerable outputs) + { + foreach (var output in outputs) + { + if (For(preset, output) is > 0 and var limit && File.Exists(output) && new FileInfo(output).Length is var size && size > limit) + throw new ConversionRefusedException("error.over_size_limit", Describe(size, limit)); + } + } +} diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs index b1359444f..1296655a4 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -415,18 +415,22 @@ public static List Write(IReadOnlyList> frames, strin if (path is null) continue; frames[i].Write(path, format); - // shortcut: only single pictures are fitted to the size limit, not animations or multi-page files written above. + // Animations and multi-page files written above aren't fitted: the queue refuses them when they're too large. if (options is { MaxSizeMb: > 0 }) - FitToSize(frames[i], path, format, (long)(options.MaxSizeMb * 1024 * 1024)); + FitToSize(frames[i], path, format, SizeLimit.Bytes(options.MaxSizeMb)); written.Add(path); } return written; } + /// A fitted picture is never made smaller than this long edge (nor than its own): below it, it's a thumbnail. + internal const int MinFittedLongEdge = 640; + /// /// Saves again until is at most bytes: a - /// lower quality first (down to 50, where a lossy format still looks fine), then fewer pixels. Refuses when even a - /// tiny picture is too big, rather than handing over a file that breaks the limit. + /// lower quality first (down to 50, where a lossy format still looks fine), then fewer pixels, but never below a + /// long edge of . Refuses when it still doesn't fit, rather than hand over a file + /// that breaks the limit or a thumbnail. /// internal static void FitToSize(IMagickImage image, string path, MagickFormat format, long limit) { @@ -435,21 +439,25 @@ internal static void FitToSize(IMagickImage image, string path, MagickForm return; using var copy = image.Clone(); var lossy = format is MagickFormat.Jpeg or MagickFormat.WebP or MagickFormat.Avif or MagickFormat.Jxl or MagickFormat.Jp2; - for (var attempt = 0; attempt < 12 && size > limit; attempt++) + var floor = Math.Min(MinFittedLongEdge, Math.Max(copy.Width, copy.Height)); + while (size > limit) { if (lossy && copy.Quality > 50) copy.Quality = Math.Max(50, copy.Quality - 10); else { - // The file shrinks about as the pixel count does: aim a little under the limit. - var scale = Math.Clamp(Math.Sqrt((double)limit / size) * 0.95, 0.3, 0.9); - copy.Resize(new MagickGeometry((uint)Math.Max(1, copy.Width * scale), (uint)Math.Max(1, copy.Height * scale))); + var longEdge = Math.Max(copy.Width, copy.Height); + if (longEdge <= floor) + break; + // The file shrinks about as the pixel count does: aim a little under the limit, but not below the floor. + var scale = Math.Max(Math.Clamp(Math.Sqrt((double)limit / size) * 0.95, 0.3, 0.9), (double)floor / longEdge); + copy.Resize(new MagickGeometry((uint)Math.Max(1, Math.Round(copy.Width * scale)), (uint)Math.Max(1, Math.Round(copy.Height * scale)))); } copy.Write(path, format); size = new FileInfo(path).Length; } if (size > limit) - throw new ConversionRefusedException("error.over_size_limit", $"{size / 1048576.0:0.##} MB > {limit / 1048576.0:0.##} MB"); + throw new ConversionRefusedException("error.over_size_limit", SizeLimit.Describe(size, limit)); } /// Writes one image as (APNG through ). diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs index 0ab607725..a712cf16c 100644 --- a/src/Filee.Engines/Media/FfmpegConverter.cs +++ b/src/Filee.Engines/Media/FfmpegConverter.cs @@ -168,6 +168,17 @@ await RunPassesAsync(ffmpeg, [new FfmpegPass(MediaEncoding.WithoutMetadata(input return [output]; } + // The size limit (#553): refused before anything is encoded when not even a low bitrate fits, else one encode with + // the bitrate capped at what fits (MediaEncoding.ForSizeLimit), and another at that bitrate only if it missed. + var limit = !still && media.MaxSizeMb > 0 && MediaEncoding.VideoTargets.ContainsKey(step.To) ? SizeLimit.Bytes(media.MaxSizeMb) : 0; + var kbps = 0; + if (limit > 0) + { + (media, kbps) = MediaEncoding.ForSizeLimit(media, info, limit); + if (kbps == 0 && info.Duration is { TotalSeconds: > 0 }) + throw new ConversionRefusedException("error.over_size_limit", $"{info.Duration.Value.TotalSeconds:0} s in {media.MaxSizeMb:0.##} MB"); + } + var encoders = still ? null : EncodersOf(ffmpeg); // Hardware encoding (preset option): the first GPU encoder that passes its test encode, if any. HardwareEncoder? hardware = null; @@ -185,15 +196,17 @@ IReadOnlyList Plan(HardwareEncoder? encoder) => still ? MediaEncoding.PlanStill(input, output, step.To, media, info) : MediaEncoding.Plan(input, output, step.To, media, info, step.WorkDirectory, cover, encoders, encoder, speed); // Throws for a missing stream before anything is written (an existing file of that name stays untouched). - var passes = Plan(hardware); + var passes = kbps > 0 ? MediaEncoding.WithMaxRate(Plan(hardware), kbps) : Plan(hardware); // FFmpeg reports the position in the output, which a speed change makes longer or shorter. var duration = still ? MediaEncoding.StillDuration : info.Duration / speed; + // With a size limit the first encode takes most of the bar, a second one (if needed) the rest. + var encoding = limit > 0 && progress is not null ? new Progress(p => progress.Report(p * 0.9)) : progress; try { await RunWithFallbackAsync(passes, hardware, Plan, - planned => RunPassesAsync(ffmpeg, planned, output, duration, progress, cancellationToken), output, _logger, cancellationToken); - if (!still && media.MaxSizeMb > 0 && MediaEncoding.VideoTargets.ContainsKey(step.To)) - await FitToSizeAsync(ffmpeg, step, input, output, info, encoders, cover, cancellationToken); + planned => RunPassesAsync(ffmpeg, planned, output, duration, encoding, cancellationToken), output, _logger, cancellationToken); + if (limit > 0) + await FitAgainAsync(ffmpeg, step, input, output, media, info, encoders, cover, limit, kbps, progress, cancellationToken); } catch { @@ -207,32 +220,25 @@ await RunWithFallbackAsync(passes, hardware, Plan, } /// - /// The size limit (#553): a video over it is encoded again from the source at the bitrate its length allows, in - /// software (every encoder takes -b:v) with the sound at 96 kbit/s at most. A bitrate is an average, so a second try - /// aims lower by as much as the first one missed. Refuses when it can't fit, rather than hand over a file too large. + /// The size limit (#553), after the capped encode: usually it fits. A bitrate is an average, so when it missed, the + /// video is encoded once more at a bitrate as much lower as it missed by, on the rest of the progress bar. Refuses + /// when that still doesn't fit (or the length is unknown), rather than hand over a file too large. /// - private static async Task FitToSizeAsync(string ffmpeg, ConversionStep step, string input, string output, MediaInfo info, - IReadOnlySet? encoders, CoverExtras? cover, CancellationToken cancellationToken) + private static async Task FitAgainAsync(string ffmpeg, ConversionStep step, string input, string output, MediaOptions fitting, + MediaInfo info, IReadOnlySet? encoders, CoverExtras? cover, long limit, int kbps, IProgress? progress, + CancellationToken cancellationToken) { - var limit = (long)(step.Preset.Media.MaxSizeMb * 1024 * 1024); var size = new FileInfo(output).Length; - if (size <= limit) - return; - var fitting = step.Preset.Clone().Media; - fitting.Quality = MediaQuality.Small; // never copies the video stream as it is - fitting.HardwareEncoding = false; - fitting.AudioBitrateKbps = fitting.AudioBitrateKbps is > 0 and < 96 ? fitting.AudioBitrateKbps : 96; - var audio = fitting.RemoveAudio ? 0 : fitting.AudioBitrateKbps * info.AudioTracks.Count; - var kbps = info.Duration is { TotalSeconds: > 0 } length ? MediaEncoding.FitBitrateKbps(limit, length.TotalSeconds, audio) : 0; - for (var attempt = 0; attempt < 2 && kbps > 0 && size > limit; attempt++) + if (size > limit && kbps > 0) { + kbps = (int)(kbps * (double)limit / size * 0.95); + var again = progress is null ? null : new Progress(p => progress.Report(0.9 + p * 0.1)); var passes = MediaEncoding.AtBitrate(MediaEncoding.Plan(input, output, step.To, fitting, info, step.WorkDirectory, cover, encoders, null, 1), kbps); - await RunPassesAsync(ffmpeg, passes, output, info.Duration, null, cancellationToken); + await RunPassesAsync(ffmpeg, passes, output, info.Duration, again, cancellationToken); size = new FileInfo(output).Length; - kbps = (int)(kbps * (double)limit / size * 0.95); } if (size > limit) - throw new ConversionRefusedException("error.over_size_limit", $"{size / 1048576.0:0.##} MB > {limit / 1048576.0:0.##} MB"); + throw new ConversionRefusedException("error.over_size_limit", SizeLimit.Describe(size, limit)); } /// diff --git a/src/Filee.Engines/Media/MediaEncoding.cs b/src/Filee.Engines/Media/MediaEncoding.cs index 55734dcad..f433d5dc2 100644 --- a/src/Filee.Engines/Media/MediaEncoding.cs +++ b/src/Filee.Engines/Media/MediaEncoding.cs @@ -382,7 +382,8 @@ internal static List FrameAt(string input, double seconds, string output /// internal static bool CanCopyVideo(string target, VideoStreamInfo video, MediaOptions options) { - if (options.Quality == MediaQuality.Small || options.MaxHeight > 0 && video.DisplayHeight > Math.Max(16, options.MaxHeight) + // A size limit encodes the video, so its bitrate can be capped (ForSizeLimit). + if (options.Quality == MediaQuality.Small || options.MaxSizeMb > 0 || options.MaxHeight > 0 && video.DisplayHeight > Math.Max(16, options.MaxHeight) || video.Codec is not { } codec || !Remuxable.TryGetValue(target, out var codecs)) return false; var takes = MediaCodecs.Effective(target, options.VideoCodec) switch @@ -465,6 +466,48 @@ internal static int FitBitrateKbps(long limitBytes, double seconds, int audioKbp return video >= 100 ? video : 0; } + /// + /// The options a video with a size limit is encoded with, and the video bitrate that fits (0 when the length is + /// unknown or nothing fits): the video is never copied as it is (), the sound is at most + /// 96 kbit/s a track, and no graphics chip encodes, since software rate control keeps to the cap. + /// + internal static (MediaOptions Options, int Kbps) ForSizeLimit(MediaOptions media, MediaInfo info, long limitBytes) + { + var fitting = new Preset { Media = media }.Clone().Media; + fitting.HardwareEncoding = false; + fitting.AudioBitrateKbps = fitting.AudioBitrateKbps is > 0 and < 96 ? fitting.AudioBitrateKbps : 96; + var audio = fitting.RemoveAudio ? 0 : fitting.AudioBitrateKbps * info.AudioTracks.Count; + var kbps = info.Duration is { TotalSeconds: > 0 } length ? FitBitrateKbps(limitBytes, length.TotalSeconds, audio) : 0; + return (fitting, kbps); + } + + /// + /// The passes with the video's peaks capped at next to its constant quality: one encode, + /// never larger than without the cap and usually within the size limit. VP9 and libaom's "-b:v 0" becomes the cap. + /// + internal static IReadOnlyList WithMaxRate(IReadOnlyList passes, int kbps) => + [.. passes.Select(pass => pass with { Arguments = WithMaxRate(pass.Arguments, kbps) })]; + + private static List WithMaxRate(IReadOnlyList args, int kbps) + { + var result = new List(); + for (var i = 0; i < args.Count; i++) + { + if (args[i] == "-b:v" && i + 1 < args.Count && args[i + 1] == "0") + { + result.AddRange(["-b:v", $"{kbps}k"]); + i++; + continue; + } + result.Add(args[i]); + if (args[i] is "-crf" or "-q:v" && i + 1 < args.Count) + { + result.AddRange([args[++i], "-maxrate", $"{kbps}k", "-bufsize", $"{kbps * 2}k"]); + } + } + return result; + } + /// The passes with the video at instead of a constant quality (CRF or quantizer). internal static IReadOnlyList AtBitrate(IReadOnlyList passes, int kbps) => [.. passes.Select(pass => pass with { Arguments = AtBitrate(pass.Arguments, kbps) })]; diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index 54811eced..5412324cc 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -481,6 +481,13 @@ public void Add_offers_presets_for_where_a_file_goes_and_the_editor_keeps_their_ Assert.Equal(25, added[^1].Media.MaxSizeMb); Assert.Equal(16, whatsapp.Media.MaxSizeMb); + // Not where nothing could fit it: VOB and DV have a fixed bitrate. + editor.Target = editor.Targets.Single(t => t.Value == "vob"); + Assert.False(editor.ShowVideoMaxSize); + editor.Target = editor.Targets.Single(t => t.Value == "mp4"); + Assert.True(editor.ShowVideoMaxSize); + Assert.False(new PresetEditorViewModel(Filee.Core.Presets.BuiltInData.CreatePresets().First(p => p.JoinsIntoOneMedia), loc).ShowVideoMaxSize); + // A cleared box is no limit; a JPG target shows its own box, and a limit left behind by MP4 isn't kept. editor.VideoMaxSizeMb = null; editor.Target = editor.Targets.Single(t => t.Value == "jpg"); diff --git a/tests/Filee.Core.Tests/SizeLimitTests.cs b/tests/Filee.Core.Tests/SizeLimitTests.cs index ffd89d7a4..6289be761 100644 --- a/tests/Filee.Core.Tests/SizeLimitTests.cs +++ b/tests/Filee.Core.Tests/SizeLimitTests.cs @@ -27,6 +27,41 @@ public async Task Only_the_step_that_writes_the_result_gets_the_size_limit() Assert.Equal(2, preset.Image.MaxSizeMb); // the user's preset itself is never changed } + [Fact] + public async Task A_result_no_engine_fitted_is_refused_rather_than_handed_over_too_large() + { + // The fake engine writes its result as it is (like HEIC, a CAD render or an animation): the queue checks it. + using var dir = new TempDir(); + var photo = dir.File("photo.jpg"); + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "png"))])); + var job = new ConversionJob([photo], new Preset { TargetFormat = "png", Image = { MaxSizeMb = 0.000001 } }, "PNG"); + + await queue.RunAsync(job); + + var file = Assert.Single(job.Files); + Assert.Equal(FileState.Failed, file.State); + Assert.Equal("error.over_size_limit", file.ErrorKey); + Assert.Empty(job.Outputs); + Assert.Equal([photo], Directory.GetFiles(dir.Path)); // nothing too large is left behind + } + + [Fact] + public async Task A_result_within_the_limit_or_without_one_is_kept() + { + using var dir = new TempDir(); + var photo = dir.File("photo.jpg"); + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "png"))])); + + var fits = new ConversionJob([photo], new Preset { TargetFormat = "png", Image = { MaxSizeMb = 1 } }, "PNG"); + await queue.RunAsync(fits); + // A video limit says nothing about a picture. + var other = new ConversionJob([photo], new Preset { TargetFormat = "png", Media = { MaxSizeMb = 0.000001 }, Output = { FileNamePattern = "{name}_2" } }, "PNG"); + await queue.RunAsync(other); + + Assert.Equal(JobState.Completed, fits.State); + Assert.Equal(JobState.Completed, other.State); + } + [Fact] public void The_presets_for_where_a_file_goes_are_offered_but_not_installed() { diff --git a/tests/Filee.Engines.Tests/SizeLimitTests.cs b/tests/Filee.Engines.Tests/SizeLimitTests.cs index e18338656..43ce69efd 100644 --- a/tests/Filee.Engines.Tests/SizeLimitTests.cs +++ b/tests/Filee.Engines.Tests/SizeLimitTests.cs @@ -70,13 +70,64 @@ public void A_limit_no_picture_can_meet_is_refused_rather_than_broken() Assert.Equal("error.over_size_limit", refused.ErrorKey); } + [Fact] + public void A_picture_is_never_made_a_thumbnail_to_fit() + { + // A tight limit for a big photo: it stops at a long edge of 640 px and refuses, instead of shrinking on. + var path = Path.Combine(fx.NewFolder(), "noise.jpg"); + using var image = Noisy(2400, 1600); + image.Quality = 95; + image.Write(path, MagickFormat.Jpeg); + + Assert.Throws(() => ImageEncoder.FitToSize(image, path, MagickFormat.Jpeg, 20_000)); + using var last = new MagickImage(path); + Assert.Equal((uint)ImageEncoder.MinFittedLongEdge, Math.Max(last.Width, last.Height)); + } + + [Fact] + public void Megabytes_are_a_million_bytes_as_the_services_count_them() + { + Assert.Equal(18_000_000, SizeLimit.Bytes(18)); + Assert.Equal("18.87 MB > 18 MB", SizeLimit.Describe(18_874_368, 18_000_000)); + } + [Fact] public void The_bitrate_that_fits_leaves_room_for_the_sound_and_the_container() { - // 16 MB over 60 s with 96 kbit/s of sound: 16 × 1024² × 8 × 0.95 / 1000 / 60 = 2125 kbit/s, less 96. - Assert.Equal(2029, MediaEncoding.FitBitrateKbps(16L * 1024 * 1024, 60, 96)); + // 16 MB over 60 s with 96 kbit/s of sound: 16,000,000 × 8 × 0.95 / 1000 / 60 = 2026 kbit/s, less 96. + Assert.Equal(1930, MediaEncoding.FitBitrateKbps(SizeLimit.Bytes(16), 60, 96)); // An hour in 10 MB would be a smear: refused instead. - Assert.Equal(0, MediaEncoding.FitBitrateKbps(10L * 1024 * 1024, 3600, 96)); + Assert.Equal(0, MediaEncoding.FitBitrateKbps(SizeLimit.Bytes(10), 3600, 96)); + } + + [Fact] + public void The_first_encode_caps_the_bitrate_next_to_the_constant_quality() + { + FfmpegPass[] passes = [new(["-c:v", "libx264", "-crf", "23", "-c:a", "aac", "out.mp4"], 1), + new(["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "out.webm"], 1)]; + + var capped = MediaEncoding.WithMaxRate(passes, 800); + + Assert.Equal(["-c:v", "libx264", "-crf", "23", "-maxrate", "800k", "-bufsize", "1600k", "-c:a", "aac", "out.mp4"], capped[0].Arguments); + // VP9's constrained quality: CRF with the cap as its bitrate. + Assert.Equal(["-c:v", "libvpx-vp9", "-b:v", "800k", "-crf", "32", "-maxrate", "800k", "-bufsize", "1600k", "out.webm"], capped[1].Arguments); + } + + [Fact] + public void A_size_limit_never_copies_the_video_or_uses_the_graphics_chip() + { + var video = new VideoStreamInfo(1920, 1080, 1, 0, 30, "h264", "yuv420p"); + var info = new MediaInfo(TimeSpan.FromSeconds(60), video, new AudioStreamInfo(2, 48000, "aac"), null); + var media = new MediaOptions { MaxSizeMb = 16, HardwareEncoding = true }; + + var (fitting, kbps) = MediaEncoding.ForSizeLimit(media, info, SizeLimit.Bytes(16)); + + Assert.False(MediaEncoding.CanCopyVideo("mp4", video, fitting)); + Assert.True(MediaEncoding.CanCopyVideo("mp4", video, new MediaOptions())); + Assert.False(fitting.HardwareEncoding); + Assert.Equal(96, fitting.AudioBitrateKbps); + Assert.Equal(1930, kbps); + Assert.True(media.HardwareEncoding); // the preset's own options are left alone } [Fact] @@ -117,7 +168,7 @@ public async Task A_video_over_the_limit_is_encoded_again_until_it_fits() var job = await fx.ConvertAsync([source], new Preset { TargetFormat = "mp4", Media = { MaxSizeMb = 1 }, Output = { FileNamePattern = "{name}_fit" } }); Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); - Assert.InRange(new FileInfo(Assert.Single(job.Outputs)).Length, 1, 1024 * 1024); + Assert.InRange(new FileInfo(Assert.Single(job.Outputs)).Length, 1, 1_000_000); } [Fact] @@ -128,7 +179,10 @@ public async Task A_video_too_long_for_its_limit_is_refused() var job = await fx.ConvertAsync([source], new Preset { TargetFormat = "mp4", Media = { MaxSizeMb = 0.05 }, Output = { FileNamePattern = "{name}_fit" } }); - Assert.Equal("error.over_size_limit", Assert.Single(job.Files).ErrorKey); + // Refused before encoding: 8 s in 50 KB leaves no bitrate for the picture. + var file = Assert.Single(job.Files); + Assert.Equal("error.over_size_limit", file.ErrorKey); + Assert.Equal("8 s in 0.05 MB", file.ErrorDetail); Assert.Empty(job.Outputs); } }