From a9df3fc0adcc73da68326d7cec4cb55d7fc58391 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Thu, 12 Nov 2026 03:49:57 +0900 Subject: [PATCH] feat(audio): read tracker modules (XM, IT, S3M) through FFmpeg FastTracker 2 .xm, Impulse Tracker .it and Scream Tracker 3 .s3m modules are read-only audio formats that go to every audio target in one FFmpeg step. FFmpeg plays them only through its libopenmpt (or libmodplug) demuxer: the gyan.dev full build and BtbN's Linux GPL builds that Filee downloads have it, Homebrew's FFmpeg on macOS does not. Filee reads `ffmpeg -demuxers` once per executable and, without either demuxer, stops the file with error.tracker_needs_openmpt instead of FFmpeg's "Invalid data found". Settings schema 81 adds the three extensions to existing audio donuts without taking custom claims (80 is claimed by #1390). Amiga .mod stays the camcorder video it already is. Closes #1376 Co-Authored-By: Claude Opus 5.5 --- docs/ENGINES.md | 8 + src/Filee.App/Assets/i18n/ar.json | 1 + src/Filee.App/Assets/i18n/bn-BD.json | 1 + src/Filee.App/Assets/i18n/de.json | 1 + src/Filee.App/Assets/i18n/en.json | 1 + src/Filee.App/Assets/i18n/es.json | 1 + src/Filee.App/Assets/i18n/fa.json | 1 + src/Filee.App/Assets/i18n/fr.json | 1 + src/Filee.App/Assets/i18n/hi.json | 1 + src/Filee.App/Assets/i18n/id.json | 1 + src/Filee.App/Assets/i18n/it.json | 1 + src/Filee.App/Assets/i18n/ja.json | 1 + src/Filee.App/Assets/i18n/ko.json | 1 + src/Filee.App/Assets/i18n/ne.json | 1 + src/Filee.App/Assets/i18n/pl.json | 1 + src/Filee.App/Assets/i18n/pt-BR.json | 1 + src/Filee.App/Assets/i18n/pt-PT.json | 1 + src/Filee.App/Assets/i18n/ru.json | 1 + src/Filee.App/Assets/i18n/th.json | 1 + src/Filee.App/Assets/i18n/tr.json | 1 + src/Filee.App/Assets/i18n/vi.json | 1 + src/Filee.App/Assets/i18n/zh-CN.json | 1 + src/Filee.App/Assets/i18n/zh-TW.json | 1 + src/Filee.Core/Formats/FormatFields.cs | 10 +- src/Filee.Core/Formats/FormatRegistry.cs | 6 + src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 + src/Filee.Engines/Media/FfmpegConverter.cs | 43 ++++++ tests/Filee.Core.Tests/FormatFieldsTests.cs | 18 +-- .../TrackerModuleFormatTests.cs | 87 +++++++++++ tests/Filee.Engines.Tests/MediaTests.cs | 138 ++++++++++++++++++ 31 files changed, 324 insertions(+), 14 deletions(-) create mode 100644 tests/Filee.Core.Tests/TrackerModuleFormatTests.cs diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 5cea73c2f..21f5e6943 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -1788,6 +1788,14 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa Apple Voice Memos recordings (`.qta`, read only, #646) are QuickTime audio: FFmpeg's mov demuxer reads them and they go to every audio target, from the first audio track FFmpeg can decode, like any other source (iOS 26 memos also carry a spatial APAC track FFmpeg has no decoder for). + Tracker modules (FastTracker 2 `.xm`, Impulse Tracker `.it`, Scream Tracker 3 `.s3m`; read only, #1376) are songs + stored as note patterns and samples. FFmpeg renders them through its `libopenmpt` demuxer (or the older + `libmodplug`), which exists only in builds linked with that library: the gyan.dev full build (its README lists + libopenmpt) and BtbN's Linux GPL builds for x64 and ARM64 (`scripts.d/50-openmpt.sh`) have it, Homebrew's `ffmpeg` + on macOS has neither. Filee reads `ffmpeg -demuxers` (cached per executable) and, without either demuxer, stops + the file with "This FFmpeg can't read tracker modules" instead of FFmpeg's "Invalid data found". Amiga `.mod` files + are not among them: that extension is the camcorder MOD video above. Settings schema 81 adds the three extensions to + existing audio donuts. - **Rewrapping instead of re-encoding.** When the target container takes the source's codecs as they are (`MediaEncoding.Remuxable`: H.264/HEVC + AAC, ALAC, AC-3, E-AC-3 or MP3 for MP4/M4V/MOV, also ProRes and PCM for diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index a44e1aaa2..58ea23c72 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -126,6 +126,7 @@ "error.no_motion_photo": "لا تحتوي هذه الصورة على فيديو يستطيع Filee قراءته (صورة Motion Photo من Android، أو Live Photo من iPhone مع ملف MOV بجانبها)", "error.live_photo_start": "يبدأ المقطع بعد نهاية هذا الفيديو. اختر «بداية المقطع» أبكر", "error.live_photo_hdr": "لا يحتوي FFmpeg هذا على مرشح zscale، لذا لا يمكن تحويل فيديو HDR هذا إلى Live / Motion Photo. يحتوي FFmpeg الذي ينزّله Filee عليه", + "error.tracker_needs_openmpt": "لا يستطيع FFmpeg هذا قراءة وحدات التراكر (XM وIT وS3M) لأنه بُني دون libopenmpt. يحتوي FFmpeg الذي ينزّله Filee على Windows وLinux عليها", "error.rhwp_timeout": "لم يتمكن rhwp من إكمال هذا المستند الطويل في الوقت المحدد", "error.rhwp_timeout_calibre": "لم يتمكن rhwp من إكمال هذا المستند الطويل في الوقت المحدد. للكتب الإلكترونية، انقل Calibre فوق محرك كتابة HWPX من الإعدادات ← المحركات، ليُنشئ Calibre ملف PDF بسرعة أكبر بكثير", "error.engine_stalled": "توقف المحرك عن الاستجابة: لم يحرز أي تقدم لفترة طويلة، لذا تم إيقافه", diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index 7cdb6682c..3fc051f0f 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -125,6 +125,7 @@ "error.no_motion_photo": "এই ছবিতে ফাইলি পড়তে পারে এমন কোনো ভিডিও নেই (একটি Android Motion Photo, অথবা পাশে MOV ফাইলসহ iPhone Live Photo)", "error.live_photo_start": "ক্লিপটি এই ভিডিওর সমাপ্তির পরে শুরু হচ্ছে। আগের একটি \"ক্লিপ শুরু\" নির্বাচন করুন", "error.live_photo_hdr": "এই FFmpeg-এ কোনো zscale ফিল্টার নেই, তাই এই HDR ভিডিওটি Live / Motion Photo করা যাচ্ছে না। ফাইলি-র FFmpeg ডাউনলোডে এটি রয়েছে", + "error.tracker_needs_openmpt": "এই FFmpeg ট্র্যাকার মডিউল (XM, IT, S3M) পড়তে পারে না, কারণ এটি libopenmpt ছাড়া তৈরি। Windows ও Linux-এর জন্য ফাইলি-র FFmpeg ডাউনলোডে এটি রয়েছে", "error.rhwp_timeout": "rhwp এই দীর্ঘ ডকুমেন্টটি যথাসময়ে শেষ করতে পারেনি", "error.rhwp_timeout_calibre": "rhwp এই দীর্ঘ ডকুমেন্টটি যথাসময়ে শেষ করতে পারেনি। ই-বুকের ক্ষেত্রে, সেটিংস → ইঞ্জিন-এ HWPX রাইটারের ওপরে Calibre নিয়ে আসুন, Calibre অনেক দ্রুত পিডিএফ তৈরি করবে", "error.engine_stalled": "ইঞ্জিন সাড়া দেওয়া বন্ধ করে দিয়েছে: দীর্ঘ সময় ধরে কোনো অগ্রগতি দেখা যায়নি, তাই এটি বন্ধ করা হয়েছে", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 688d6b589..d940be08e 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -126,6 +126,7 @@ "error.no_motion_photo": "Dieses Foto enthält kein für Filee lesbares Video (Android Motion Photo oder iPhone Live Photo mit zugehöriger MOV-Datei im selben Ordner)", "error.live_photo_start": "Der Clip beginnt nach dem Ende dieses Videos. Wähle einen früheren „Clipbeginn“", "error.live_photo_hdr": "Dieser FFmpeg-Version fehlt der zscale-Filter. Deshalb lässt sich dieses HDR-Video nicht in ein Live / Motion Photo umwandeln. Der FFmpeg-Download von Filee enthält den Filter", + "error.tracker_needs_openmpt": "Dieses FFmpeg kann keine Tracker-Module (XM, IT, S3M) lesen, weil es ohne libopenmpt gebaut wurde. Der FFmpeg-Download von Filee für Windows und Linux enthält libopenmpt", "error.rhwp_timeout": "rhwp konnte dieses lange Dokument nicht rechtzeitig fertigstellen", "error.rhwp_timeout_calibre": "rhwp konnte dieses lange Dokument nicht rechtzeitig fertigstellen. Verschiebe bei E-Books Calibre unter Einstellungen → Module über den HWPX-Writer. Calibre erstellt die PDF deutlich schneller", "error.engine_stalled": "Das Modul reagiert nicht mehr: Es kam lange nicht voran und wurde beendet", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index c4b60563d..6816437b8 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -132,6 +132,7 @@ "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\"", "error.live_photo_hdr": "This FFmpeg has no zscale filter, so this HDR video can't become a Live / Motion Photo. Filee's FFmpeg download has it", + "error.tracker_needs_openmpt": "This FFmpeg can't read tracker modules (XM, IT, S3M): it was built without libopenmpt. Filee's FFmpeg download for Windows and Linux has it", "error.rhwp_timeout": "rhwp couldn't finish this long document in time", "error.rhwp_timeout_calibre": "rhwp couldn't finish this long document in time. For an e-book, move Calibre above the HWPX writer in Settings → Engines, and Calibre makes the PDF much faster", "error.engine_stalled": "The engine stopped responding: it showed no progress for a long time, so it was stopped", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 2ea2f2132..982731078 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -126,6 +126,7 @@ "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", "error.live_photo_hdr": "Este FFmpeg no tiene el filtro zscale, así que este vídeo HDR no puede convertirse en Live Photo ni Motion Photo. El FFmpeg que descarga Filee lo tiene", + "error.tracker_needs_openmpt": "Este FFmpeg no puede leer módulos de tracker (XM, IT, S3M) porque se compiló sin libopenmpt. El FFmpeg que descarga Filee en Windows y Linux lo incluye", "error.rhwp_timeout": "rhwp no pudo terminar este documento largo a tiempo", "error.rhwp_timeout_calibre": "rhwp no pudo terminar este documento largo a tiempo. Para un libro electrónico, mueve Calibre por encima del escritor HWPX en Ajustes → Motores; Calibre genera el PDF mucho más rápido", "error.engine_stalled": "El motor dejó de responder: no avanzó durante mucho tiempo y se detuvo", diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index 3427b4feb..72fc43b7e 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -125,6 +125,7 @@ "error.no_motion_photo": "این عکس ویدیویی ندارد که Filee بتواند بخواند (یک Motion Photo اندروید یا یک Live Photo آیفون به همراه فایل MOV در کنار آن)", "error.live_photo_start": "کلیپ بعد از پایان این ویدیو شروع می‌شود. نقطه شروع زودتری برای کلیپ انتخاب کنید", "error.live_photo_hdr": "این نسخه FFmpeg فیلتر zscale ندارد، بنابراین این ویدیوی HDR نمی‌تواند به Live / Motion Photo تبدیل شود. نسخه دانلودی FFmpeg برنامه Filee آن را دارد", + "error.tracker_needs_openmpt": "این نسخه FFmpeg نمی‌تواند ماژول‌های ترکر (XM، IT، S3M) را بخواند، چون بدون libopenmpt ساخته شده است. نسخه دانلودی FFmpeg برنامه Filee برای Windows و Linux آن را دارد", "error.rhwp_timeout": "موتور rhwp نتوانست این سند طولانی را در زمان مقرر تمام کند", "error.rhwp_timeout_calibre": "موتور rhwp نتوانست این سند طولانی را به موقع تمام کند. برای کتاب‌های الکترونیکی، Calibre را در تنظیمات ← موتورها بالاتر از HWPX writer قرار دهید تا بسیار سریع‌تر PDF ساخته شود", "error.engine_stalled": "موتور پاسخ نمی‌دهد: برای مدت طولانی هیچ پیشرفتی نشان نداد، بنابراین متوقف شد", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index e15e11f90..6e9de7d6e 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -132,6 +132,7 @@ "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", "error.live_photo_hdr": "Ce FFmpeg n'a pas le filtre zscale : cette vidéo HDR ne peut donc pas devenir une Live Photo ou une Motion Photo. Le FFmpeg téléchargé par Filee l'inclut", + "error.tracker_needs_openmpt": "Ce FFmpeg ne peut pas lire les modules de tracker (XM, IT, S3M) : il a été compilé sans libopenmpt. Le FFmpeg téléchargé par Filee sous Windows et Linux l'inclut", "error.rhwp_timeout": "rhwp n'a pas pu terminer ce long document à temps", "error.rhwp_timeout_calibre": "rhwp n'a pas pu terminer ce long document à temps. Pour un livre numérique, placez Calibre au-dessus du rédacteur HWPX dans Paramètres → Moteurs : Calibre produira le PDF bien plus vite", "error.engine_stalled": "Le moteur ne répond plus : il n'a pas progressé pendant longtemps et a été arrêté", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 28c787032..0f816bd67 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -126,6 +126,7 @@ "error.no_motion_photo": "इस फ़ोटो में ऐसा कोई वीडियो नहीं है जिसे Filee पढ़ सके (Android मोशन फ़ोटो, या पास में अपनी MOV फ़ाइल वाली iPhone लाइव फ़ोटो)", "error.live_photo_start": "क्लिप इस वीडियो के ख़त्म होने के बाद शुरू होती है। \"क्लिप की शुरुआत\" पहले चुनें", "error.live_photo_hdr": "इस FFmpeg में zscale फ़िल्टर नहीं है, इसलिए यह HDR वीडियो लाइव / मोशन फ़ोटो नहीं बन सकता। Filee से डाउनलोड होने वाले FFmpeg में यह है", + "error.tracker_needs_openmpt": "यह FFmpeg ट्रैकर मॉड्यूल (XM, IT, S3M) नहीं पढ़ सकता, क्योंकि इसे libopenmpt के बिना बनाया गया है। Windows और Linux पर Filee से डाउनलोड होने वाले FFmpeg में यह है", "error.rhwp_timeout": "rhwp इस लंबे दस्तावेज़ को समय पर पूरा नहीं कर सका", "error.rhwp_timeout_calibre": "rhwp इस लंबे दस्तावेज़ को समय पर पूरा नहीं कर सका। ई-बुक के लिए, सेटिंग्स → इंजन में Calibre को HWPX writer के ऊपर ले जाएँ, तब Calibre PDF बहुत तेज़ी से बनाएगा", "error.engine_stalled": "इंजन ने जवाब देना बंद कर दिया: लंबे समय तक कोई प्रगति नहीं हुई, इसलिए इसे रोक दिया गया", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index fd6059d9b..3cacac988 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -126,6 +126,7 @@ "error.no_motion_photo": "Foto ini tidak memiliki video yang dapat dibaca Filee (Motion Photo Android, atau Live Photo iPhone dengan file MOV di sampingnya)", "error.live_photo_start": "Klip dimulai setelah akhir video ini. Tentukan nilai \"Awal klip\" yang lebih awal", "error.live_photo_hdr": "FFmpeg ini tidak memiliki filter zscale, sehingga video HDR ini tidak dapat diubah menjadi Live / Motion Photo. Unduhan FFmpeg resmi Filee menyediakannya", + "error.tracker_needs_openmpt": "FFmpeg ini tidak dapat membaca modul tracker (XM, IT, S3M) karena dibuat tanpa libopenmpt. Unduhan FFmpeg resmi Filee untuk Windows dan Linux menyediakannya", "error.rhwp_timeout": "rhwp tidak dapat menyelesaikan dokumen panjang ini tepat waktu", "error.rhwp_timeout_calibre": "rhwp tidak dapat menyelesaikan dokumen panjang ini tepat waktu. Untuk e-book, pindahkan Calibre ke atas pembuat HWPX di Pengaturan → Engine, dan Calibre akan membuat PDF jauh lebih cepat", "error.engine_stalled": "Engine berhenti merespons: tidak menunjukkan progres dalam waktu lama, sehingga dihentikan", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index a606a6cf0..228f23be2 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -125,6 +125,7 @@ "error.no_motion_photo": "Questa foto non contiene un video che Filee possa leggere (una Motion Photo di Android, o una Live Photo di iPhone con il suo MOV accanto)", "error.live_photo_start": "La clip inizia dopo la fine di questo video. Scegli un “Inizio clip” precedente", "error.live_photo_hdr": "Questo FFmpeg non ha il filtro zscale, quindi questo video HDR non può diventare una Live / Motion Photo. L'FFmpeg scaricato da Filee lo include", + "error.tracker_needs_openmpt": "Questo FFmpeg non può leggere i moduli tracker (XM, IT, S3M) perché è stato compilato senza libopenmpt. L'FFmpeg scaricato da Filee su Windows e Linux lo include", "error.rhwp_timeout": "rhwp non è riuscito a completare in tempo questo documento lungo", "error.rhwp_timeout_calibre": "rhwp non è riuscito a completare in tempo questo documento lungo. Per un e-book, sposta Calibre sopra il writer HWPX in Impostazioni → Motori: Calibre crea il PDF molto più in fretta", "error.engine_stalled": "Il motore ha smesso di rispondere: non ha mostrato progressi per molto tempo, quindi è stato interrotto", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index e5afe9601..17d11fa81 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -126,6 +126,7 @@ "error.no_motion_photo": "この写真には、Fileeが読み取れる動画がありません(Androidのモーションフォト、または隣にMOVがあるiPhoneのLive Photo)", "error.live_photo_start": "クリップの開始位置が動画の終わりより後です。「クリップの開始」をもっと前にしてください", "error.live_photo_hdr": "このFFmpegにはzscaleフィルターがないため、HDR動画をLive Photo/モーションフォトにできません。FileeからダウンロードするFFmpegにはあります", + "error.tracker_needs_openmpt": "このFFmpegはlibopenmptなしでビルドされているため、トラッカーモジュール(XM、IT、S3M)を読み込めません。WindowsとLinuxでFileeからダウンロードするFFmpegにはあります", "error.rhwp_timeout": "文書が長いため、rhwpが制限時間内に処理を終えられませんでした", "error.rhwp_timeout_calibre": "文書が長いため、rhwpが制限時間内に処理を終えられませんでした。電子書籍の場合は、「設定」→「変換エンジン」でCalibreを「HWPX writer」より上に移動してください。CalibreならPDFを大幅に速く作成できます", "error.engine_stalled": "変換エンジンが応答しなくなりました。長時間進まなかったため停止しました", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index ed37215c4..389cf76c4 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -132,6 +132,7 @@ "error.no_motion_photo": "이 사진에서 읽을 수 있는 동영상을 찾지 못했어요 (안드로이드 모션 포토, 또는 옆에 MOV가 있는 아이폰 라이브 포토)", "error.live_photo_start": "클립 시작이 동영상 끝보다 뒤예요. \"클립 시작\"을 더 앞으로 정해 주세요", "error.live_photo_hdr": "이 FFmpeg에는 zscale 필터가 없어서 HDR 동영상을 라이브/모션 포토로 만들 수 없어요. Filee에서 내려받는 FFmpeg에는 있어요", + "error.tracker_needs_openmpt": "이 FFmpeg는 libopenmpt 없이 빌드되어서 트래커 모듈(XM, IT, S3M)을 읽을 수 없어요. Windows와 Linux에서 Filee가 내려받는 FFmpeg에는 있어요", "error.rhwp_timeout": "문서가 길어서 rhwp가 제한 시간 안에 끝내지 못했어요", "error.rhwp_timeout_calibre": "문서가 길어서 rhwp가 제한 시간 안에 끝내지 못했어요. 전자책이라면 설정 → 변환 엔진에서 Calibre를 HWPX writer보다 위로 옮겨 주세요. Calibre가 PDF를 훨씬 빨리 만들어요", "error.engine_stalled": "엔진이 응답하지 않아요. 오랫동안 진행되지 않아서 중지했어요", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index e5fcfeb3e..1ea9b8b86 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -126,6 +126,7 @@ "error.no_motion_photo": "यस तस्बिरमा Filee ले पढ्न सक्ने कुनै भिडियो छैन (Android मोशन फोटो, वा छेउमा MOV भएको iPhone लाइभ फोटो)", "error.live_photo_start": "क्लिप यस भिडियो सकिएपछि सुरु हुन्छ। पहिलेको \"क्लिप सुरु\" समय रोज्नुहोस्", "error.live_photo_hdr": "यस FFmpeg मा zscale फिल्टर छैन, त्यसैले यो HDR भिडियो लाइभ / मोशन फोटो बन्न सक्दैन। Filee को FFmpeg डाउनलोडमा यो उपलब्ध छ", + "error.tracker_needs_openmpt": "यो FFmpeg libopenmpt बिना बनाइएकोले ट्र्याकर मोड्युल (XM, IT, S3M) पढ्न सक्दैन। Windows र Linux का लागि Filee को FFmpeg डाउनलोडमा यो उपलब्ध छ", "error.rhwp_timeout": "rhwp ले यस लामो कागजातलाई समयमै पूरा गर्न सकेन", "error.rhwp_timeout_calibre": "rhwp ले यस लामो कागजातलाई समयमै पूरा गर्न सकेन। इ-पुस्तकका लागि, सेटिङहरू → इन्जिनहरूमा गएर Calibre लाई HWPX writer भन्दा माथि सार्नुहोस्, र Calibre ले PDF धेरै छिटो बनाउनेछ", "error.engine_stalled": "इन्जिनले प्रतिक्रिया दिन बन्द गर्‍यो: लामो समयसम्म कुनै प्रगति नभएकाले यसलाई रोकियो", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 9dd214e16..b9dfcb6c7 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -132,6 +132,7 @@ "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”", "error.live_photo_hdr": "Ten FFmpeg nie ma filtra zscale, więc z tego wideo HDR nie powstanie Live Photo ani Motion Photo. FFmpeg pobierany przez Filee go ma", + "error.tracker_needs_openmpt": "Ten FFmpeg nie odczyta modułów trackerowych (XM, IT, S3M), bo zbudowano go bez libopenmpt. FFmpeg pobierany przez Filee dla Windows i Linux ma tę bibliotekę", "error.rhwp_timeout": "rhwp nie zdążył przetworzyć tego długiego dokumentu", "error.rhwp_timeout_calibre": "rhwp nie zdążył przetworzyć tego długiego dokumentu. Dla e-booka przenieś Calibre nad HWPX writer w Ustawienia → Silniki, a Calibre zrobi PDF znacznie szybciej", "error.engine_stalled": "Silnik przestał odpowiadać: długo nie robił postępów, więc został zatrzymany", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 71665d9cb..c74113696 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -132,6 +132,7 @@ "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", "error.live_photo_hdr": "Este FFmpeg não tem o filtro zscale, então este vídeo HDR não pode virar Live / Motion Photo. O FFmpeg que o Filee baixa tem esse filtro", + "error.tracker_needs_openmpt": "Este FFmpeg não lê módulos de tracker (XM, IT, S3M) porque foi compilado sem libopenmpt. O FFmpeg que o Filee baixa no Windows e no Linux tem essa biblioteca", "error.rhwp_timeout": "O rhwp não conseguiu terminar este documento longo a tempo", "error.rhwp_timeout_calibre": "O rhwp não conseguiu terminar este documento longo a tempo. Para um e-book, coloque o Calibre acima do gravador de HWPX em Configurações → Motores. O Calibre gera o PDF bem mais rápido", "error.engine_stalled": "O motor parou de responder: ficou muito tempo sem progresso e foi interrompido", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 2b908a155..3d2e1e1ef 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -132,6 +132,7 @@ "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»", "error.live_photo_hdr": "Este FFmpeg não tem o filtro zscale, pelo que este vídeo HDR não pode ser convertido em Live / Motion Photo. A versão do FFmpeg que o Filee disponibiliza para descarregar inclui esse filtro", + "error.tracker_needs_openmpt": "Este FFmpeg não consegue ler módulos de tracker (XM, IT, S3M) porque foi compilado sem a libopenmpt. A versão do FFmpeg que o Filee disponibiliza para descarregar no Windows e no Linux inclui essa biblioteca", "error.rhwp_timeout": "O rhwp não conseguiu concluir este documento longo a tempo", "error.rhwp_timeout_calibre": "O rhwp não conseguiu concluir este documento longo a tempo. Para um livro digital, coloca o Calibre acima do gerador HWPX em Definições → Motores. O Calibre cria o PDF muito mais depressa", "error.engine_stalled": "O motor deixou de responder: esteve muito tempo sem progresso e foi parado", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index d31395e57..74b3f9d72 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -132,6 +132,7 @@ "error.no_motion_photo": "В этом фото нет видео, которое Filee может прочитать (Motion Photo с Android или Live Photo с iPhone с файлом MOV рядом)", "error.live_photo_start": "Клип начинается после конца этого видео. Выберите более раннее «Начало клипа»", "error.live_photo_hdr": "В этом FFmpeg нет фильтра zscale, поэтому из этого HDR-видео нельзя сделать Live Photo или Motion Photo. В FFmpeg, который скачивает Filee, он есть", + "error.tracker_needs_openmpt": "Этот FFmpeg не читает трекерные модули (XM, IT, S3M): он собран без libopenmpt. В FFmpeg, который Filee скачивает для Windows и Linux, эта библиотека есть", "error.rhwp_timeout": "rhwp не успел обработать этот длинный документ", "error.rhwp_timeout_calibre": "rhwp не успел обработать этот длинный документ. Для электронной книги поставьте Calibre выше HWPX writer в разделе «Настройки → Движки» — так Calibre сделает PDF намного быстрее", "error.engine_stalled": "Движок перестал отвечать: он долго не продвигался и был остановлен", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index fdcdfc6a0..b711eecdb 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -126,6 +126,7 @@ "error.no_motion_photo": "รูปภาพนี้ไม่มีวิดีโอที่ Filee สามารถอ่านได้ (เช่น ภาพเคลื่อนไหวของ Android หรือ Live Photo ของ iPhone ที่มีไฟล์ MOV อยู่คู่กัน)", "error.live_photo_start": "คลิปเริ่มต้นหลังจุดสิ้นสุดของวิดีโอนี้ โปรดเลือกจุดเริ่มต้นคลิปให้เร็วกว่านี้", "error.live_photo_hdr": "FFmpeg นี้ไม่มีตัวกรอง zscale จึงไม่สามารถแปลงวิดีโอ HDR นี้เป็น Live / Motion Photo ได้ (FFmpeg ที่ Filee ดาวน์โหลดจะมีตัวกรองนี้)", + "error.tracker_needs_openmpt": "FFmpeg นี้อ่านโมดูลแทร็กเกอร์ (XM, IT, S3M) ไม่ได้ เพราะสร้างมาโดยไม่มี libopenmpt (FFmpeg ที่ Filee ดาวน์โหลดบน Windows และ Linux มีไลบรารีนี้)", "error.rhwp_timeout": "rhwp ไม่สามารถประมวลผลเอกสารขนาดยาวนี้ได้ทันเวลา", "error.rhwp_timeout_calibre": "rhwp ไม่สามารถประมวลผลเอกสารขนาดยาวนี้ได้ทันเวลา สำหรับอีบุ๊ก ให้ย้าย Calibre ไปไว้เหนือตัวเขียน HWPX ใน การตั้งค่า → เครื่องมือแปลงไฟล์ Calibre จะสร้าง PDF ได้เร็วกว่ามาก", "error.engine_stalled": "เครื่องมือแปลงไฟล์หยุดตอบสนอง: ไม่มีความคืบหน้าเป็นเวลานาน จึงถูกหยุดการทำงาน", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 0060bc016..c83ec7d9a 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -126,6 +126,7 @@ "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", "error.live_photo_hdr": "Bu FFmpeg'de zscale filtresi yok; bu yüzden HDR video Live / Motion Photo'ya dönüştürülemiyor. Filee'nin indirdiği FFmpeg bu filtreyi içerir", + "error.tracker_needs_openmpt": "Bu FFmpeg libopenmpt olmadan derlendiği için tracker modüllerini (XM, IT, S3M) okuyamıyor. Filee'nin Windows ve Linux için indirdiği FFmpeg bu kütüphaneyi içerir", "error.rhwp_timeout": "rhwp bu uzun belgeyi zamanında tamamlayamadı", "error.rhwp_timeout_calibre": "rhwp bu uzun belgeyi zamanında tamamlayamadı. E-kitaplar için Ayarlar → Dönüştürme motorları bölümünde Calibre'yi HWPX yazıcısının üstüne taşıyın; Calibre PDF'yi çok daha hızlı oluşturur", "error.engine_stalled": "Motor yanıt vermeyi bıraktı: uzun süre ilerleme göstermediği için durduruldu", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index ed41f9188..ecf5018eb 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -132,6 +132,7 @@ "error.no_motion_photo": "Ảnh này không có video mà Filee đọc được (ảnh Motion Photo của Android, hoặc Live Photo của iPhone kèm tệp MOV bên cạnh)", "error.live_photo_start": "Đoạn cắt bắt đầu sau khi video này kết thúc. Hãy chọn “Điểm bắt đầu đoạn cắt” sớm hơn", "error.live_photo_hdr": "FFmpeg này không có bộ lọc zscale nên video HDR này không thể thành Live / Motion Photo. FFmpeg do Filee tải xuống có bộ lọc này", + "error.tracker_needs_openmpt": "FFmpeg này không đọc được mô-đun tracker (XM, IT, S3M) vì được biên dịch không kèm libopenmpt. FFmpeg do Filee tải xuống trên Windows và Linux có thư viện này", "error.rhwp_timeout": "rhwp không kịp xử lý xong tài liệu dài này", "error.rhwp_timeout_calibre": "rhwp không kịp xử lý xong tài liệu dài này. Với sách điện tử, hãy chuyển Calibre lên trên trình ghi HWPX trong Cài đặt → Bộ xử lý, Calibre sẽ tạo PDF nhanh hơn nhiều", "error.engine_stalled": "Bộ xử lý đã ngừng phản hồi: rất lâu không có tiến triển nên đã bị dừng", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 3981f959b..ee2ee2d1f 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -132,6 +132,7 @@ "error.no_motion_photo": "这张照片中没有可读取的视频(安卓动态照片,或旁边有 MOV 的 iPhone 实况照片)", "error.live_photo_start": "片段开始位置在视频结束之后。请把“片段开始”设得更早", "error.live_photo_hdr": "此 FFmpeg 没有 zscale 滤镜,无法把 HDR 视频制作成实况/动态照片。Filee 下载的 FFmpeg 带有该滤镜", + "error.tracker_needs_openmpt": "此 FFmpeg 编译时未包含 libopenmpt,无法读取 Tracker 音乐模块(XM、IT、S3M)。Filee 在 Windows 和 Linux 上下载的 FFmpeg 带有该库", "error.rhwp_timeout": "文档太长,rhwp 未能在时限内完成", "error.rhwp_timeout_calibre": "文档太长,rhwp 未能在时限内完成。如果是电子书,请在“设置 → 转换引擎”中把 Calibre 移到 HWPX writer 之上,Calibre 生成 PDF 要快得多", "error.engine_stalled": "转换引擎停止响应:长时间没有进展,已将其停止", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 190d38148..a6d893db6 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -132,6 +132,7 @@ "error.no_motion_photo": "這張照片中沒有可讀取的影片(Android 動態相片,或旁邊有 MOV 的 iPhone 原況照片)", "error.live_photo_start": "片段開始位置在影片結束之後。請把「片段開始」設得更早", "error.live_photo_hdr": "此 FFmpeg 沒有 zscale 濾鏡,無法把 HDR 影片製作成原況/動態相片。Filee 下載的 FFmpeg 帶有該濾鏡", + "error.tracker_needs_openmpt": "此 FFmpeg 編譯時未包含 libopenmpt,無法讀取 Tracker 音樂模組(XM、IT、S3M)。Filee 在 Windows 和 Linux 上下載的 FFmpeg 帶有該函式庫", "error.rhwp_timeout": "文件太長,rhwp 未能在時限內完成", "error.rhwp_timeout_calibre": "文件太長,rhwp 未能在時限內完成。如果是電子書,請在「設定 → 轉換引擎」中把 Calibre 移到 HWPX writer 之上,Calibre 產生 PDF 要快得多", "error.engine_stalled": "轉換引擎停止回應:長時間沒有進展,已將其停止", diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs index d1640b1d1..8d72fec5e 100644 --- a/src/Filee.Core/Formats/FormatFields.cs +++ b/src/Filee.Core/Formats/FormatFields.cs @@ -82,14 +82,14 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack", "cab", "cpio", "deb", "rpm", "dwg", "ttf", "otf", "woff", "woff2", "ass", "ssa", "ttml", "scc", "glb", "gltf", - "stl", "obj", "3mf", "ply", "fbx", "geojson", "kml", "kmz", "gpx", "shp", "fit", "osm" + "stl", "obj", "3mf", "ply", "fbx", "geojson", "kml", "kmz", "gpx", "shp", "fit", "osm", "xm", "it" ]), (FormatGrade.Legacy, [ "mng", "pcx", "wbmp", "sgi", "ras", "pcd", "wmf", "dot", "wpd", "lwp", "sdw", "cwk", "mcw", "602", "psw", "xlt", "sdc", "sda", "mobi", "azw", "azw4", "prc", "lit", "lrf", "pdb", "pml", "rb", "snb", "tcr", "oeb", "flv", "3gp", "3g2", "vob", "mod", "rm", "swf", "wtv", "flc", "amr", "au", "voc", "mpc", "spx", "tz", "z", - "lha", "arj", "eot", "fon", "sub" + "lha", "arj", "eot", "fon", "sub", "s3m" ]), ]); @@ -139,8 +139,10 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow (["pcd"], ["legacy", "design"]), // FLIC: Autodesk Animator's animations, also the cutscenes of DOS games. (["flc"], ["legacy", "game"]), - // Musepack. - (["mpc"], ["legacy", "audio-pro"]), + // Musepack, and Scream Tracker 3 modules, which Impulse Tracker's IT replaced. + (["mpc", "s3m"], ["legacy", "audio-pro"]), + // Tracker modules: made in trackers (MilkyTracker, OpenMPT) by musicians, also for game soundtracks. + (["xm", "it"], ["audio-pro", "game"]), // EOT gave way to WOFF, FON to TrueType. (["eot", "fon"], ["legacy", "font"]), // Alpha-channel image sequences in video and motion graphics, as well as game textures. diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 6b4282b5a..8b5fce3cc 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -297,6 +297,12 @@ public static class FormatRegistry new("spx", "Speex", FormatCategory.Audio, ["spx"], Writable: false), // Apple Voice Memos recordings (#646): QuickTime audio, read by FFmpeg's mov demuxer. new("qta", "QTA", FormatCategory.Audio, ["qta"], Writable: false), + // Tracker modules (#1376): FastTracker 2, Impulse Tracker and Scream Tracker 3 songs, stored as note patterns and + // samples. FFmpeg reads them only when built with libopenmpt or libmodplug (FfmpegConverter checks). + // Amiga .mod is left out: that extension is the camcorder video above. + new("xm", "XM (FastTracker 2 module)", FormatCategory.Audio, ["xm"], Writable: false), + new("it", "IT (Impulse Tracker module)", FormatCategory.Audio, ["it"], Writable: false), + new("s3m", "S3M (Scream Tracker 3 module)", FormatCategory.Audio, ["s3m"], Writable: false), // Archives // ANSYS Workbench project archives use the ZIP container (issue #294). diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 1d7e2acd3..1b7e2ed6a 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -139,7 +139,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 79; + public const int CurrentSchemaVersion = 81; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 1311c1f3d..58184d118 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1097,6 +1097,10 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id != BuiltInData.RingtoneId)) changed |= AddLikeNewLibrary(presets, BuiltInData.RingtoneId); } + // v81: tracker modules (.xm, .it, .s3m, #1376) learn the audio donut; custom claims stay put. (Schema 81 may be + // renumbered if another change takes it first.) + if (from < 81) + changed |= LearnExtensions(profiles, out _, ["xm", "it", "s3m"]); return changed; } diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs index 62ac361f3..305a9dfb5 100644 --- a/src/Filee.Engines/Media/FfmpegConverter.cs +++ b/src/Filee.Engines/Media/FfmpegConverter.cs @@ -121,6 +121,7 @@ public EngineStatus GetStatus() public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { var (ffmpeg, ffprobe) = _tools ?? Locate() ?? throw new InvalidOperationException("FFmpeg was not found."); + await RequireTrackerDemuxerAsync(ffmpeg, step.From); if (step.MotionPhoto) return await ExtractMotionAsync(step, progress, cancellationToken); if (step.LivePhoto) @@ -343,6 +344,7 @@ public async Task JoinAsync(IReadOnlyList inputPaths, string outputPath, for (var i = 0; i < inputPaths.Count; i++) { var path = Path.GetFullPath(inputPaths[i]); + await RequireTrackerDemuxerAsync(ffmpeg, FormatRegistry.Detect(path)?.Id); clips.Add((path, await MediaInfo.ProbeAsync(ffprobe, path, cancellationToken))); progress?.Report(0.05 * (i + 1) / inputPaths.Count); } @@ -699,6 +701,34 @@ internal static string VersionOf(string ffmpeg, TimeSpan? timeout = null) => internal static Task?> DecodableCodecsOfAsync(string ffmpeg, TimeSpan? timeout = null) => QueryOnceAsync(Decoders, ffmpeg, "-codecs", timeout, ParseDecodableCodecs); + private static readonly ConcurrentDictionary?>>> Demuxers = new(); + + /// + /// The demuxer names of this ffmpeg (ffmpeg -demuxers), cached per executable. Null when the list can't be + /// read. Queried once at a time and kept when read; asked again after a failure. + /// + internal static Task?> DemuxersOfAsync(string ffmpeg, TimeSpan? timeout = null) => + QueryOnceAsync(Demuxers, ffmpeg, "-demuxers", timeout, ParseDemuxers); + + /// Tracker modules (#1376): FFmpeg has no demuxer of its own for them, only the external libraries'. + internal static readonly IReadOnlySet TrackerModules = new HashSet(["xm", "it", "s3m"], StringComparer.OrdinalIgnoreCase); + + /// + /// Whether an FFmpeg with these demuxers reads tracker modules: libopenmpt (Filee's Windows and Linux downloads have + /// it) or the older libmodplug. Homebrew's FFmpeg has neither. Assumed when the list couldn't be read (null). + /// + internal static bool ReadsTrackerModules(IReadOnlySet? demuxers) => + demuxers is null || demuxers.Contains("libopenmpt") || demuxers.Contains("libmodplug"); + + /// + /// Refuses a tracker module with Filee's message before FFmpeg reports "Invalid data found when processing input". + /// + private static async Task RequireTrackerDemuxerAsync(string ffmpeg, string? format) + { + if (format is not null && TrackerModules.Contains(format) && !ReadsTrackerModules(await DemuxersOfAsync(ffmpeg))) + throw new ConversionRefusedException("error.tracker_needs_openmpt"); + } + private static async Task?> QueryOnceAsync( ConcurrentDictionary?>>> cache, string ffmpeg, string option, TimeSpan? timeout, Func> parse) @@ -761,6 +791,19 @@ internal static IReadOnlySet ParseFilters(string list) => .Select(parts => parts[1]) .ToHashSet(StringComparer.Ordinal); + /// The demuxer names in ffmpeg -demuxers output, each name of a shared line on its own. + internal static IReadOnlySet ParseDemuxers(string list) => + // " D libopenmpt Tracker formats (libopenmpt)", " D mov,mp4,m4a,3gp,3g2,mj2 QuickTime / MOV", + // " D d dshow DirectShow capture"; the legend above them (" D.. = Demuxing supported") has no name. + list.Split('\n') + .Select(line => DemuxerLine().Match(line)) + .Where(match => match.Success) + .SelectMany(match => match.Groups["names"].Value.Split(',', StringSplitOptions.RemoveEmptyEntries)) + .ToHashSet(StringComparer.Ordinal); + + [GeneratedRegex(@"^ D[E. ]?[d. ]? +(?[a-z0-9_][\w,.-]*)")] + private static partial Regex DemuxerLine(); + /// The encoder names in ffmpeg -encoders output. internal static IReadOnlySet ParseEncoders(string list) => // " V....D libx265 libx265 H.265 / HEVC (codec hevc)" diff --git a/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs index 23476e2c0..ec804e976 100644 --- a/tests/Filee.Core.Tests/FormatFieldsTests.cs +++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs @@ -29,14 +29,14 @@ private static string[] Hidden(IReadOnlySet turnedOff) => "bz2", "cab", "caf", "cavs", "cdr", "cgm", "chm", "cpio", "cur", "cwk", "dav", "dbk", "dcm", "dds", "deb", "dff", "dot", "dsf", "dss", "dst", "dts", "dv", "dwg", "eac3", "eot", "eps", "exr", "fbx", "fit", "fits", "flc", "flv", "fodg", "fodp", "fods", "fodt", "fon", "geojson", "glb", "gltf", "gpx", "gz", "h264", "h265", "har", - "hdr", "htmlz", "icns", "ipynb", "jar", "jp2", "jsonl", "kml", "kmz", "kra", "lha", "lit", "lrf", "lwp", "lz", + "hdr", "htmlz", "icns", "ipynb", "it", "jar", "jp2", "jsonl", "kml", "kmz", "kra", "lha", "lit", "lrf", "lwp", "lz", "lzma", "m4s", "man", "mcaddon", "mcpack", "mctemplate", "mcw", "mcworld", "mediawiki", "miff", "mng", "mobi", "mod", "mp2", "mpc", "mrpack", "mxf", "obj", "oeb", "org", "osm", "otf", "pam", "pcd", "pcx", "pdb", "pfm", "plist", "plt", "ply", "pml", "ppm", "prc", "ps", "psb", "psd", - "psw", "qoi", "ras", "raw", "rb", "rm", "rpm", "rst", "scc", "sda", "sdc", "sdw", "sgi", "shp", "snb", "spx", + "psw", "qoi", "ras", "raw", "rb", "rm", "rpm", "rst", "s3m", "scc", "sda", "sdc", "sdw", "sgi", "shp", "snb", "spx", "ssa", "stl", "sub", "swf", "tak", "tar", "tar7z", "tbz2", "tcr", "tex", "textile", "tgz", "tlz", "toml", "tta", "ttf", "ttml", "txtz", "txz", "typ", "tz", "usm", "vob", "voc", "w64", "wbmp", "wmf", "woff", "woff2", - "wpd", "wtv", "wv", "xbm", "xcf", "xlt", "xpm", "xz", "yaml", "z" + "wpd", "wtv", "wv", "xbm", "xcf", "xlt", "xm", "xpm", "xz", "yaml", "z" ]; public static TheoryData HiddenWithOneFieldOff => new() @@ -72,7 +72,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "model3d", ["3mf", "fbx", "glb", "gltf", "obj", "ply", "stl"] }, { "geo", ["fit", "geojson", "gpx", "kml", "kmz", "osm", "shp"] }, { "font", ["eot", "fon", "otf", "ttf", "woff", "woff2"] }, - { "game", ["bik", "dds", "flc", "mcaddon", "mcpack", "mctemplate", "mcworld", "mrpack", "tga", "usm"] }, + { "game", ["bik", "dds", "flc", "it", "mcaddon", "mcpack", "mctemplate", "mcworld", "mrpack", "tga", "usm", "xm"] }, { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "jp2", "kra", "pcd", "plt", "ps", "psb", "psd", "raw", "xcf"] }, { "science", ["dcm", "fits", "ipynb", "jp2"] }, { "developer", [ @@ -84,12 +84,12 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "video-pro", [ "ac3", "ass", "cavs", "dav", "dts", "dv", "eac3", "h264", "h265", "mp2", "mxf", "scc", "ssa", "ttml" ] }, - { "audio-pro", ["ape", "caf", "dff", "dsf", "dss", "mpc", "tak", "tta", "w64", "wv"] }, + { "audio-pro", ["ape", "caf", "dff", "dsf", "dss", "it", "mpc", "s3m", "tak", "tta", "w64", "wv", "xm"] }, { "legacy", [ "3g2", "3gp", "602", "amr", "arj", "au", "azw", "azw4", "cwk", "dot", "eot", "flc", "flv", "fon", "lha", "lit", "lrf", "lwp", "mcw", "mng", "mobi", "mod", "mpc", "oeb", "pcd", "pcx", "pdb", "pml", "prc", - "psw", "ras", "rb", "rm", "sda", "sdc", "sdw", "sgi", "snb", "spx", "sub", "swf", "tcr", "tz", "vob", - "voc", "wbmp", "wmf", "wpd", "wtv", "xlt", "z" + "psw", "ras", "rb", "rm", "s3m", "sda", "sdc", "sdw", "sgi", "snb", "spx", "sub", "swf", "tcr", "tz", + "vob", "voc", "wbmp", "wmf", "wpd", "wtv", "xlt", "z" ] }, }; @@ -145,8 +145,8 @@ public void Grade_counts_match_the_published_table() { [FormatGrade.Everyday] = 19, [FormatGrade.General] = 97, - [FormatGrade.Specialist] = 115, - [FormatGrade.Legacy] = 51, + [FormatGrade.Specialist] = 117, + [FormatGrade.Legacy] = 52, }, counts); } diff --git a/tests/Filee.Core.Tests/TrackerModuleFormatTests.cs b/tests/Filee.Core.Tests/TrackerModuleFormatTests.cs new file mode 100644 index 000000000..836e9812b --- /dev/null +++ b/tests/Filee.Core.Tests/TrackerModuleFormatTests.cs @@ -0,0 +1,87 @@ +// Tracker modules (.xm, .it, .s3m, #1376): read-only audio sources on the audio donut, learned by existing libraries. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class TrackerModuleFormatTests +{ + private static readonly string[] Modules = ["xm", "it", "s3m"]; + + [Theory] + [InlineData("Song.XM", "xm")] + [InlineData("song.it", "it")] + [InlineData("song.s3m", "s3m")] + public void Tracker_modules_are_audio_filee_reads_but_never_writes(string file, string id) + { + var format = FormatRegistry.Detect(file); + Assert.NotNull(format); + Assert.Equal((id, FormatCategory.Audio, false), (format.Id, format.Category, format.Writable)); + Assert.Equal("audio", ProfileSelector.Select(BuiltInData.CreateProfiles(), [file])?.Id); + } + + [Fact] + public void Amiga_mod_stays_the_camcorder_video() => + Assert.Equal(FormatCategory.Video, FormatRegistry.Detect("clip.mod")?.Category); + + [Fact] + public void Version_80_libraries_learn_tracker_modules_once() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var audio = profiles.Single(p => p.Id == "audio"); + audio.Extensions.RemoveAll(Modules.Contains); + + Assert.True(SettingsMigrations.ApplyToLibrary(80, presets, profiles)); + + Assert.All(Modules, module => Assert.Contains(module, audio.Extensions)); + Assert.False(SettingsMigrations.ApplyToLibrary(80, presets, profiles)); + } + + [Fact] + public void A_version_80_library_leaves_a_module_on_the_users_own_donut() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var audio = profiles.Single(p => p.Id == "audio"); + audio.Extensions.RemoveAll(Modules.Contains); + profiles.Insert(0, new ToolbarProfile { Id = "my-tracks", Name = "Tracks", Extensions = ["XM"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(80, presets, profiles)); + + Assert.DoesNotContain("xm", audio.Extensions, StringComparer.OrdinalIgnoreCase); + Assert.Contains("it", audio.Extensions); + Assert.Contains("s3m", audio.Extensions); + Assert.Equal("my-tracks", ProfileSelector.Select(profiles, ["song.xm"])?.Id); + } + + [Fact] + public void A_saved_version_80_library_learns_tracker_modules_on_the_next_start_only() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + store.Settings.SchemaVersion = 80; + foreach (var profile in store.Profiles) + profile.Extensions.RemoveAll(Modules.Contains); + store.SaveLibrary(); + store.SaveSettings(); + + var upgraded = new UserDataStore(dir.Path); + upgraded.Load(); + var audio = upgraded.Profiles.Single(p => p.Id == "audio").Extensions; + Assert.All(Modules, module => Assert.Contains(module, audio)); + Assert.Equal(AppSettings.CurrentSchemaVersion, upgraded.Settings.SchemaVersion); + + // The user takes .xm off the audio donut again: the start after that leaves it off. + audio.Remove("xm"); + upgraded.SaveLibrary(); + upgraded.SaveSettings(); + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + Assert.DoesNotContain("xm", reloaded.Profiles.Single(p => p.Id == "audio").Extensions); + } +} diff --git a/tests/Filee.Engines.Tests/MediaTests.cs b/tests/Filee.Engines.Tests/MediaTests.cs index 5ff65d91b..a0fe1b2de 100644 --- a/tests/Filee.Engines.Tests/MediaTests.cs +++ b/tests/Filee.Engines.Tests/MediaTests.cs @@ -334,6 +334,144 @@ await RunAsync(Tools!.Value.Ffmpeg, ["-hide_banner", "-nostdin", "-v", "error", } } + [Theory] + [InlineData("xm")] + [InlineData("it")] + [InlineData("s3m")] + public void Tracker_modules_go_to_every_audio_target_in_one_ffmpeg_step(string module) + { + // #1376: read through FFmpeg's libopenmpt demuxer; never a target. + foreach (var target in MediaEncoding.AudioTargets.Keys) + { + var route = Planner().Plan(module, target); + Assert.True(route is not null, target); + Assert.Equal("ffmpeg", Assert.Single(route.Steps).Converter.Id); + } + Assert.DoesNotContain(new FfmpegConverter().Edges, e => e.To == module); + Assert.Contains(module, FfmpegConverter.TrackerModules); + } + + [Fact] + public void Tracker_modules_need_an_ffmpeg_with_libopenmpt_or_libmodplug() + { + var demuxers = FfmpegConverter.ParseDemuxers(""" + Formats: + D.. = Demuxing supported + .E. = Muxing supported + ..d = Is a device + --- + D aac raw ADTS AAC (Advanced Audio Coding) + D d dshow DirectShow capture + D libopenmpt Tracker formats (libopenmpt) + D mov,mp4,m4a,3gp,3g2,mj2 QuickTime / MOV + """); + Assert.Equal(["3g2", "3gp", "aac", "dshow", "libopenmpt", "m4a", "mj2", "mov", "mp4"], demuxers.Order(StringComparer.Ordinal)); + Assert.True(FfmpegConverter.ReadsTrackerModules(demuxers)); + Assert.True(FfmpegConverter.ReadsTrackerModules(new HashSet { "libmodplug" })); + // Homebrew's FFmpeg: neither library. + Assert.False(FfmpegConverter.ReadsTrackerModules(new HashSet { "aac", "mov", "mp4" })); + // A list that can't be read leaves the decision to FFmpeg. + Assert.True(FfmpegConverter.ReadsTrackerModules(null)); + } + + [Fact] + public async Task A_fasttracker_xm_module_converts_to_audio() + { + SkipUnlessInstalled(); + var module = Path.Combine(fx.NewFolder(), "song.xm"); + await File.WriteAllBytesAsync(module, MakeXm(), TestContext.Current.CancellationToken); + + if (!FfmpegConverter.ReadsTrackerModules(await FfmpegConverter.DemuxersOfAsync(Tools!.Value.Ffmpeg))) + { + // An FFmpeg built without libopenmpt: Filee's message, not FFmpeg's "Invalid data found". + var job = await fx.ConvertAsync([module], new Preset { TargetFormat = "wav" }); + var file = Assert.Single(job.Files); + Assert.Equal((FileState.Failed, "error.tracker_needs_openmpt"), (file.State, file.ErrorKey)); + return; + } + foreach (var target in new[] { "wav", "mp3", "flac" }) + { + var output = await ConvertAsync(module, target); + var probed = await ProbeAsync(output); + Assert.NotNull(probed.Audio); + // 16 rows at speed 6 and 125 BPM: 16 × 0.12 s. + Assert.InRange(probed.Duration, 1.5, 2.6); + // The note on the first row is heard, so the song is not rendered as silence. + if (target == "wav") + Assert.Contains((await File.ReadAllBytesAsync(output, TestContext.Current.CancellationToken)).Skip(1024), b => b != 0); + } + } + + /// + /// A FastTracker 2 module (XM 1.04): two channels, one 16-row pattern with a C-4 on its first row, played by one + /// instrument whose sample is a short 8-bit square wave. + /// + private static byte[] MakeXm() + { + using var stream = new MemoryStream(); + using var w = new BinaryWriter(stream); + static byte[] Text(string text, int length) => [.. Encoding.ASCII.GetBytes(text.PadRight(length))]; + + w.Write(Text("Extended Module: ", 17)); + w.Write(Text("Filee test", 20)); + w.Write((byte)0x1A); + w.Write(Text("Filee", 20)); + w.Write((ushort)0x0104); + w.Write(276u); // header size, counted from this field: 20 bytes of fields + the 256-entry order table + w.Write((ushort)1); // song length (orders) + w.Write((ushort)0); // restart position + w.Write((ushort)2); // channels + w.Write((ushort)1); // patterns + w.Write((ushort)1); // instruments + w.Write((ushort)1); // linear frequency table + w.Write((ushort)6); // speed (ticks per row) + w.Write((ushort)125); // BPM + w.Write(new byte[256]); // order table: pattern 0 + + // Pattern 0, packed: a note byte with bit 7 set says which fields follow, 0x80 is an empty cell. + var rows = new List { 0x83, 49, 1, 0x80 }; // C-4 with instrument 1, then an empty channel + for (var row = 1; row < 16; row++) + rows.AddRange([0x80, 0x80]); + w.Write(9u); // pattern header size + w.Write((byte)0); // packing type + w.Write((ushort)16); // rows + w.Write((ushort)rows.Count); + w.Write(rows.ToArray()); + + // Instrument 1 with one sample: no envelopes, every note on sample 0. + w.Write(263u); // instrument header size + w.Write(Text("square", 22)); + w.Write((byte)0); // type + w.Write((ushort)1); // samples + w.Write(40u); // sample header size + w.Write(new byte[96 + 48 + 48]); // note → sample map, volume and panning envelope points + w.Write(new byte[14]); // envelope counts, sustain and loop points, envelope types, vibrato + w.Write((ushort)0); // fadeout + w.Write(new byte[22]); // reserved + + // The sample: 4000 bytes of 8-bit delta-coded square wave, played once at 8363 Hz (about half a second). + const int length = 4000; + w.Write((uint)length); + w.Write(0u); // loop start + w.Write(0u); // loop length + w.Write((byte)64); // volume + w.Write((sbyte)0); // finetune + w.Write((byte)0); // no loop, 8-bit + w.Write((byte)128); // panning + w.Write((sbyte)0); // relative note + w.Write((byte)0); // reserved + w.Write(Text("square", 22)); + sbyte previous = 0; + for (var i = 0; i < length; i++) + { + var value = (sbyte)(i / 16 % 2 == 0 ? 64 : -64); + w.Write(unchecked((sbyte)(value - previous))); + previous = value; + } + w.Flush(); + return stream.ToArray(); + } + [Fact] public async Task A_quicktime_named_movie_converts_like_a_mov() {